@@ -17,6 +17,8 @@ Basic Commands
|
|||||||
:``q``/``Q``: Quit/Force quit
|
:``q``/``Q``: Quit/Force quit
|
||||||
:``y``: Copy submission permalink to clipboard
|
:``y``: Copy submission permalink to clipboard
|
||||||
:``Y``: Copy submission link to clipboard
|
:``Y``: Copy submission link to clipboard
|
||||||
|
:``F2``: Cycle to the previous color theme
|
||||||
|
:``F3``: Cycle to the next color theme
|
||||||
|
|
||||||
----------------------
|
----------------------
|
||||||
Authenticated Commands
|
Authenticated Commands
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ include README.md
|
|||||||
include LICENSE
|
include LICENSE
|
||||||
include rtv.1
|
include rtv.1
|
||||||
include rtv/templates/*
|
include rtv/templates/*
|
||||||
|
include rtv/themes/*
|
||||||
|
|||||||
48
README.md
@@ -40,7 +40,8 @@ RTV is built in python using the curses library.
|
|||||||
* [Demo](#demo)
|
* [Demo](#demo)
|
||||||
* [Installation](#installation)
|
* [Installation](#installation)
|
||||||
* [Usage](#usage)
|
* [Usage](#usage)
|
||||||
* [Settings](#settings)
|
* [Settings](#settings)
|
||||||
|
* [Themes](#themes)
|
||||||
* [FAQ](#faq)
|
* [FAQ](#faq)
|
||||||
* [Contributing](#contributing)
|
* [Contributing](#contributing)
|
||||||
* [License](#license)
|
* [License](#license)
|
||||||
@@ -106,7 +107,7 @@ Press <kbd>/</kbd> to open the navigation prompt, where you can type things like
|
|||||||
- ``/u/multi-mod/m/art``
|
- ``/u/multi-mod/m/art``
|
||||||
- ``/domain/github.com``
|
- ``/domain/github.com``
|
||||||
|
|
||||||
See [CONTROLS](https://github.com/michael-lazar/rtv/blob/master/CONTROLS.rst) for the full list of commands.
|
See [CONTROLS](CONTROLS.rst) for the full list of commands.
|
||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
@@ -114,7 +115,7 @@ See [CONTROLS](https://github.com/michael-lazar/rtv/blob/master/CONTROLS.rst) fo
|
|||||||
|
|
||||||
Configuration files are stored in the ``{HOME}/.config/rtv/`` directory.
|
Configuration files are stored in the ``{HOME}/.config/rtv/`` directory.
|
||||||
|
|
||||||
Check out [rtv.cfg](https://github.com/michael-lazar/rtv/blob/master/rtv/templates/rtv.cfg) for the full list of configurable options. You can clone this file into your home directory by running:
|
Check out [rtv.cfg](rtv/templates/rtv.cfg) for the full list of configurable options. You can clone this file into your home directory by running:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ rtv --copy-config
|
$ rtv --copy-config
|
||||||
@@ -134,7 +135,7 @@ A mailcap file allows you to associate different MIME media types, like ``image/
|
|||||||
$ rtv --copy-mailcap
|
$ rtv --copy-mailcap
|
||||||
```
|
```
|
||||||
|
|
||||||
This template contains examples for common MIME types that work with popular reddit websites like *imgur*, *youtube*, and *gfycat*. Open the mailcap template and follow the [instructions](https://github.com/michael-lazar/rtv/blob/master/rtv/templates/mailcap) listed inside.
|
This template contains examples for common MIME types that work with popular reddit websites like *imgur*, *youtube*, and *gfycat*. Open the mailcap template and follow the [instructions](rtv/templates/mailcap) listed inside.
|
||||||
|
|
||||||
Once you've setup your mailcap file, enable it by launching rtv with the ``rtv --enable-media`` flag (or set it in your **rtv.cfg**)
|
Once you've setup your mailcap file, enable it by launching rtv with the ``rtv --enable-media`` flag (or set it in your **rtv.cfg**)
|
||||||
|
|
||||||
@@ -161,10 +162,41 @@ The default programs that RTV interacts with can be configured through environme
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
### Clipboard
|
### Clipboard
|
||||||
RTV supports copying submission links to the OS clipboard.
|
|
||||||
On macOS this is supported out of the box.
|
RTV supports copying submission links to the OS clipboard. On macOS this is supported out of the box.
|
||||||
On Linux systems you will need to install either [xsel](http://www.vergenet.net/~conrad/software/xsel/) or [xclip](https://sourceforge.net/projects/xclip/).
|
On Linux systems you will need to install either [xsel](http://www.vergenet.net/~conrad/software/xsel/) or [xclip](https://sourceforge.net/projects/xclip/).
|
||||||
|
|
||||||
|
## Themes
|
||||||
|
|
||||||
|
Themes can be used to customize the look and feel of RTV
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<p><strong>Solarized Dark</strong></p>
|
||||||
|
<img src="resources/theme_solarized_dark.png"></img>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<p><strong>Solarized Light</strong></p>
|
||||||
|
<img src="resources/theme_solarized_light.png"></img>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<p><strong>Papercolor</strong></p>
|
||||||
|
<img src="resources/theme_papercolor.png"></img>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<p><strong>Molokai</strong></p>
|
||||||
|
<img src="resources/theme_molokai.png"></img>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
List installed themes with ``--list-themes`` command, and select one with ``--theme``. You can also set the theme permenantly in your [rtv.cfg](rtv/templates/rtv.cfg) file. While inside of RTV, you can use the <kbd>F2</kbd> & <kbd>F3</kbd> keys for a live preview.
|
||||||
|
|
||||||
|
For instructions on writing and installing your own themes, see [THEMES.md](THEMES.md).
|
||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -213,8 +245,8 @@ On Linux systems you will need to install either [xsel](http://www.vergenet.net/
|
|||||||
## Contributing
|
## Contributing
|
||||||
All feedback and suggestions are welcome, just post an issue!
|
All feedback and suggestions are welcome, just post an issue!
|
||||||
|
|
||||||
Before writing any code, please read the [Contributor Guidelines](https://github.com/michael-lazar/rtv/blob/master/CONTRIBUTING.rst).
|
Before writing any code, please read the [Contributor Guidelines](CONTRIBUTING.rst).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
This project is distributed under the [MIT](https://github.com/michael-lazar/rtv/blob/master/LICENSE) license.
|
This project is distributed under the [MIT](LICENSE) license.
|
||||||
|
|
||||||
|
|||||||
217
THEMES.md
Normal file
@@ -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()
|
copy_default_mailcap()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if config['list_themes']:
|
||||||
|
Theme.print_themes()
|
||||||
|
return
|
||||||
|
|
||||||
# Load the browsing history from previous sessions
|
# Load the browsing history from previous sessions
|
||||||
config.load_history()
|
config.load_history()
|
||||||
|
|
||||||
@@ -171,8 +175,19 @@ def main():
|
|||||||
try:
|
try:
|
||||||
with curses_session() as stdscr:
|
with curses_session() as stdscr:
|
||||||
|
|
||||||
theme = Theme(config['monochrome'])
|
term = Terminal(stdscr, config)
|
||||||
term = Terminal(stdscr, config, theme)
|
|
||||||
|
if config['monochrome'] or config['theme'] == 'monochrome':
|
||||||
|
_logger.info('Using monochrome theme')
|
||||||
|
theme = Theme(use_color=False)
|
||||||
|
elif config['theme'] and config['theme'] != 'default':
|
||||||
|
_logger.info('Loading theme: %s', config['theme'])
|
||||||
|
theme = Theme.from_name(config['theme'])
|
||||||
|
else:
|
||||||
|
# Set to None to let the terminal figure out which theme
|
||||||
|
# to use depending on if colors are supported or not
|
||||||
|
theme = None
|
||||||
|
term.set_theme(theme)
|
||||||
|
|
||||||
with term.loader('Initializing', catch_exception=False):
|
with term.loader('Initializing', catch_exception=False):
|
||||||
reddit = praw.Reddit(user_agent=user_agent,
|
reddit = praw.Reddit(user_agent=user_agent,
|
||||||
|
|||||||
@@ -13,16 +13,19 @@ from six.moves import configparser
|
|||||||
from . import docs, __version__
|
from . import docs, __version__
|
||||||
from .objects import KeyMap
|
from .objects import KeyMap
|
||||||
|
|
||||||
|
|
||||||
PACKAGE = os.path.dirname(__file__)
|
PACKAGE = os.path.dirname(__file__)
|
||||||
HOME = os.path.expanduser('~')
|
HOME = os.path.expanduser('~')
|
||||||
TEMPLATES = os.path.join(PACKAGE, 'templates')
|
TEMPLATES = os.path.join(PACKAGE, 'templates')
|
||||||
DEFAULT_CONFIG = os.path.join(TEMPLATES, 'rtv.cfg')
|
DEFAULT_CONFIG = os.path.join(TEMPLATES, 'rtv.cfg')
|
||||||
DEFAULT_MAILCAP = os.path.join(TEMPLATES, 'mailcap')
|
DEFAULT_MAILCAP = os.path.join(TEMPLATES, 'mailcap')
|
||||||
|
DEFAULT_THEMES = os.path.join(PACKAGE, 'themes')
|
||||||
XDG_HOME = os.getenv('XDG_CONFIG_HOME', os.path.join(HOME, '.config'))
|
XDG_HOME = os.getenv('XDG_CONFIG_HOME', os.path.join(HOME, '.config'))
|
||||||
CONFIG = os.path.join(XDG_HOME, 'rtv', 'rtv.cfg')
|
CONFIG = os.path.join(XDG_HOME, 'rtv', 'rtv.cfg')
|
||||||
MAILCAP = os.path.join(HOME, '.mailcap')
|
MAILCAP = os.path.join(HOME, '.mailcap')
|
||||||
TOKEN = os.path.join(XDG_HOME, 'rtv', 'refresh-token')
|
TOKEN = os.path.join(XDG_HOME, 'rtv', 'refresh-token')
|
||||||
HISTORY = os.path.join(XDG_HOME, 'rtv', 'history.log')
|
HISTORY = os.path.join(XDG_HOME, 'rtv', 'history.log')
|
||||||
|
THEMES = os.path.join(XDG_HOME, 'rtv', 'themes')
|
||||||
|
|
||||||
|
|
||||||
def build_parser():
|
def build_parser():
|
||||||
@@ -52,6 +55,12 @@ def build_parser():
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'--monochrome', action='store_const', const=True,
|
'--monochrome', action='store_const', const=True,
|
||||||
help='Disable color')
|
help='Disable color')
|
||||||
|
parser.add_argument(
|
||||||
|
'--theme', metavar='FILE', action='store',
|
||||||
|
help='Color theme to use, see --list-themes for valid options')
|
||||||
|
parser.add_argument(
|
||||||
|
'--list-themes', metavar='FILE', action='store_const', const=True,
|
||||||
|
help='List all of the available color themes')
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'--non-persistent', dest='persistent', action='store_const', const=False,
|
'--non-persistent', dest='persistent', action='store_const', const=False,
|
||||||
help='Forget the authenticated user when the program exits')
|
help='Forget the authenticated user when the program exits')
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ class Content(object):
|
|||||||
data['title'] = sub.title
|
data['title'] = sub.title
|
||||||
data['text'] = sub.selftext
|
data['text'] = sub.selftext
|
||||||
data['created'] = cls.humanize_timestamp(sub.created_utc)
|
data['created'] = cls.humanize_timestamp(sub.created_utc)
|
||||||
|
data['created_long'] = cls.humanize_timestamp(sub.created_utc, True)
|
||||||
data['comments'] = '{0} comments'.format(sub.num_comments)
|
data['comments'] = '{0} comments'.format(sub.num_comments)
|
||||||
data['score'] = '{0} pts'.format('-' if sub.hide_score else sub.score)
|
data['score'] = '{0} pts'.format('-' if sub.hide_score else sub.score)
|
||||||
data['author'] = name
|
data['author'] = name
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ https://github.com/michael-lazar/rtv
|
|||||||
b : Display urls with urlview
|
b : Display urls with urlview
|
||||||
y : Copy submission permalink to clipboard
|
y : Copy submission permalink to clipboard
|
||||||
Y : Copy submission link to clipboard
|
Y : Copy submission link to clipboard
|
||||||
|
F2 : Cycle to previous theme
|
||||||
|
F3 : Cycle to next theme
|
||||||
|
|
||||||
[Prompt]
|
[Prompt]
|
||||||
The `/` prompt accepts subreddits in the following formats
|
The `/` prompt accepts subreddits in the following formats
|
||||||
@@ -87,7 +89,6 @@ BANNER_SEARCH = """
|
|||||||
[1]relevance [2]top [3]comments [4]new
|
[1]relevance [2]top [3]comments [4]new
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
FOOTER_SUBREDDIT = """
|
FOOTER_SUBREDDIT = """
|
||||||
[?]Help [q]Quit [l]Comments [/]Prompt [u]Login [o]Open [c]Post [a/z]Vote
|
[?]Help [q]Quit [l]Comments [/]Prompt [u]Login [o]Open [c]Post [a/z]Vote
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -193,23 +193,23 @@ class OAuthHelper(object):
|
|||||||
# If an exception is raised it will be seen by the thread
|
# If an exception is raised it will be seen by the thread
|
||||||
# so we don't need to explicitly shutdown() the server
|
# so we don't need to explicitly shutdown() the server
|
||||||
_logger.exception(e)
|
_logger.exception(e)
|
||||||
self.term.show_notification('Browser Error', style='error')
|
self.term.show_notification('Browser Error', style='Error')
|
||||||
else:
|
else:
|
||||||
self.server.shutdown()
|
self.server.shutdown()
|
||||||
finally:
|
finally:
|
||||||
thread.join()
|
thread.join()
|
||||||
|
|
||||||
if self.params['error'] == 'access_denied':
|
if self.params['error'] == 'access_denied':
|
||||||
self.term.show_notification('Denied access', style='error')
|
self.term.show_notification('Denied access', style='Error')
|
||||||
return
|
return
|
||||||
elif self.params['error']:
|
elif self.params['error']:
|
||||||
self.term.show_notification('Authentication error', style='error')
|
self.term.show_notification('Authentication error', style='Error')
|
||||||
return
|
return
|
||||||
elif self.params['state'] is None:
|
elif self.params['state'] is None:
|
||||||
# Something went wrong but it's not clear what happened
|
# Something went wrong but it's not clear what happened
|
||||||
return
|
return
|
||||||
elif self.params['state'] != state:
|
elif self.params['state'] != state:
|
||||||
self.term.show_notification('UUID mismatch', style='error')
|
self.term.show_notification('UUID mismatch', style='Error')
|
||||||
return
|
return
|
||||||
|
|
||||||
with self.term.loader('Logging in'):
|
with self.term.loader('Logging in'):
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ class LoadScreen(object):
|
|||||||
# Some exceptions we want to swallow and display a notification
|
# Some exceptions we want to swallow and display a notification
|
||||||
if isinstance(e, e_type):
|
if isinstance(e, e_type):
|
||||||
msg = message.format(e)
|
msg = message.format(e)
|
||||||
self._terminal.show_notification(msg, style='error')
|
self._terminal.show_notification(msg, style='Error')
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def animate(self, delay, interval, message, trail):
|
def animate(self, delay, interval, message, trail):
|
||||||
@@ -267,7 +267,7 @@ class LoadScreen(object):
|
|||||||
s_row = (n_rows - 3) // 2 + v_offset
|
s_row = (n_rows - 3) // 2 + v_offset
|
||||||
s_col = (n_cols - message_len - 1) // 2 + h_offset
|
s_col = (n_cols - message_len - 1) // 2 + h_offset
|
||||||
window = curses.newwin(3, message_len + 2, s_row, s_col)
|
window = curses.newwin(3, message_len + 2, s_row, s_col)
|
||||||
window.bkgd(str(' '), self._terminal.attr('notice_loading'))
|
window.bkgd(str(' '), self._terminal.attr('NoticeLoading'))
|
||||||
|
|
||||||
# Animate the loading prompt until the stopping condition is triggered
|
# Animate the loading prompt until the stopping condition is triggered
|
||||||
# when the context manager exits.
|
# when the context manager exits.
|
||||||
|
|||||||
47
rtv/page.py
@@ -95,6 +95,30 @@ class Page(object):
|
|||||||
def force_exit(self):
|
def force_exit(self):
|
||||||
sys.exit()
|
sys.exit()
|
||||||
|
|
||||||
|
@PageController.register(Command('PREVIOUS_THEME'))
|
||||||
|
def previous_theme(self):
|
||||||
|
|
||||||
|
theme = self.term.theme_list.previous(self.term.theme)
|
||||||
|
while not self.term.check_theme(theme):
|
||||||
|
theme = self.term.theme_list.previous(theme)
|
||||||
|
|
||||||
|
self.term.set_theme(theme)
|
||||||
|
self.draw()
|
||||||
|
message = self.term.theme.display_string
|
||||||
|
self.term.show_notification(message, timeout=1)
|
||||||
|
|
||||||
|
@PageController.register(Command('NEXT_THEME'))
|
||||||
|
def next_theme(self):
|
||||||
|
|
||||||
|
theme = self.term.theme_list.next(self.term.theme)
|
||||||
|
while not self.term.check_theme(theme):
|
||||||
|
theme = self.term.theme_list.next(theme)
|
||||||
|
|
||||||
|
self.term.set_theme(theme)
|
||||||
|
self.draw()
|
||||||
|
message = self.term.theme.display_string
|
||||||
|
self.term.show_notification(message, timeout=1)
|
||||||
|
|
||||||
@PageController.register(Command('HELP'))
|
@PageController.register(Command('HELP'))
|
||||||
def show_help(self):
|
def show_help(self):
|
||||||
self.term.open_pager(docs.HELP.strip())
|
self.term.open_pager(docs.HELP.strip())
|
||||||
@@ -347,7 +371,7 @@ class Page(object):
|
|||||||
window = self.term.stdscr.derwin(1, n_cols, self._row, 0)
|
window = self.term.stdscr.derwin(1, n_cols, self._row, 0)
|
||||||
window.erase()
|
window.erase()
|
||||||
# curses.bkgd expects bytes in py2 and unicode in py3
|
# curses.bkgd expects bytes in py2 and unicode in py3
|
||||||
window.bkgd(str(' '), self.term.attr('title_bar'))
|
window.bkgd(str(' '), self.term.attr('TitleBar'))
|
||||||
|
|
||||||
sub_name = self.content.name
|
sub_name = self.content.name
|
||||||
sub_name = sub_name.replace('/r/front', 'Front Page')
|
sub_name = sub_name.replace('/r/front', 'Front Page')
|
||||||
@@ -402,7 +426,7 @@ class Page(object):
|
|||||||
n_rows, n_cols = self.term.stdscr.getmaxyx()
|
n_rows, n_cols = self.term.stdscr.getmaxyx()
|
||||||
window = self.term.stdscr.derwin(1, n_cols, self._row, 0)
|
window = self.term.stdscr.derwin(1, n_cols, self._row, 0)
|
||||||
window.erase()
|
window.erase()
|
||||||
window.bkgd(str(' '), self.term.attr('order_bar'))
|
window.bkgd(str(' '), self.term.attr('OrderBar'))
|
||||||
|
|
||||||
banner = docs.BANNER_SEARCH if self.content.query else docs.BANNER
|
banner = docs.BANNER_SEARCH if self.content.query else docs.BANNER
|
||||||
items = banner.strip().split(' ')
|
items = banner.strip().split(' ')
|
||||||
@@ -414,7 +438,7 @@ class Page(object):
|
|||||||
if self.content.order is not None:
|
if self.content.order is not None:
|
||||||
order = self.content.order.split('-')[0]
|
order = self.content.order.split('-')[0]
|
||||||
col = text.find(order) - 3
|
col = text.find(order) - 3
|
||||||
attr = self.term.theme.get('order_bar', modifier='selected')
|
attr = self.term.attr('OrderBarHighlight')
|
||||||
window.chgat(0, col, 3, attr)
|
window.chgat(0, col, 3, attr)
|
||||||
|
|
||||||
self._row += 1
|
self._row += 1
|
||||||
@@ -482,16 +506,14 @@ class Page(object):
|
|||||||
self.nav.cursor_index = len(self._subwindows) - 1
|
self.nav.cursor_index = len(self._subwindows) - 1
|
||||||
|
|
||||||
# Now that the windows are setup, we can take a second pass through
|
# Now that the windows are setup, we can take a second pass through
|
||||||
# to draw the content
|
# to draw the text onto each subwindow
|
||||||
for index, (win, data, inverted) in enumerate(self._subwindows):
|
for index, (win, data, inverted) in enumerate(self._subwindows):
|
||||||
if index == self.nav.cursor_index:
|
if self.nav.absolute_index >= 0 and index == self.nav.cursor_index:
|
||||||
# This lets the theme know to invert the cursor
|
win.bkgd(str(' '), self.term.attr('Selected'))
|
||||||
modifier = 'selected'
|
with self.term.theme.turn_on_selected():
|
||||||
|
self._draw_item(win, data, inverted)
|
||||||
else:
|
else:
|
||||||
modifier = None
|
win.bkgd(str(' '), self.term.attr('Normal'))
|
||||||
|
|
||||||
win.bkgd(str(' '), self.term.attr('normal'))
|
|
||||||
with self.term.theme.set_modifier(modifier):
|
|
||||||
self._draw_item(win, data, inverted)
|
self._draw_item(win, data, inverted)
|
||||||
|
|
||||||
self._row += win_n_rows
|
self._row += win_n_rows
|
||||||
@@ -501,7 +523,7 @@ class Page(object):
|
|||||||
n_rows, n_cols = self.term.stdscr.getmaxyx()
|
n_rows, n_cols = self.term.stdscr.getmaxyx()
|
||||||
window = self.term.stdscr.derwin(1, n_cols, self._row, 0)
|
window = self.term.stdscr.derwin(1, n_cols, self._row, 0)
|
||||||
window.erase()
|
window.erase()
|
||||||
window.bkgd(str(' '), self.term.attr('help_bar'))
|
window.bkgd(str(' '), self.term.attr('HelpBar'))
|
||||||
|
|
||||||
text = self.FOOTER.strip()
|
text = self.FOOTER.strip()
|
||||||
self.term.add_line(window, text, 0, 0)
|
self.term.add_line(window, text, 0, 0)
|
||||||
@@ -534,4 +556,3 @@ class Page(object):
|
|||||||
ch = self.term.show_notification(message)
|
ch = self.term.show_notification(message)
|
||||||
ch = six.unichr(ch)
|
ch = six.unichr(ch)
|
||||||
return choices.get(ch)
|
return choices.get(ch)
|
||||||
|
|
||||||
|
|||||||
@@ -317,15 +317,15 @@ class SubmissionPage(Page):
|
|||||||
row = offset
|
row = offset
|
||||||
if row in valid_rows:
|
if row in valid_rows:
|
||||||
if data['is_author']:
|
if data['is_author']:
|
||||||
attr = self.term.attr('comment_author_self')
|
attr = self.term.attr('CommentAuthorSelf')
|
||||||
text = '{author} [S]'.format(**data)
|
text = '{author} [S]'.format(**data)
|
||||||
else:
|
else:
|
||||||
attr = self.term.attr('comment_author')
|
attr = self.term.attr('CommentAuthor')
|
||||||
text = '{author}'.format(**data)
|
text = '{author}'.format(**data)
|
||||||
self.term.add_line(win, text, row, 1, attr)
|
self.term.add_line(win, text, row, 1, attr)
|
||||||
|
|
||||||
if data['flair']:
|
if data['flair']:
|
||||||
attr = self.term.attr('user_flair')
|
attr = self.term.attr('UserFlair')
|
||||||
self.term.add_space(win)
|
self.term.add_space(win)
|
||||||
self.term.add_line(win, '{flair}'.format(**data), attr=attr)
|
self.term.add_line(win, '{flair}'.format(**data), attr=attr)
|
||||||
|
|
||||||
@@ -333,38 +333,38 @@ class SubmissionPage(Page):
|
|||||||
self.term.add_space(win)
|
self.term.add_space(win)
|
||||||
self.term.add_line(win, arrow, attr=attr)
|
self.term.add_line(win, arrow, attr=attr)
|
||||||
|
|
||||||
attr = self.term.attr('score')
|
attr = self.term.attr('Score')
|
||||||
self.term.add_space(win)
|
self.term.add_space(win)
|
||||||
self.term.add_line(win, '{score}'.format(**data), attr=attr)
|
self.term.add_line(win, '{score}'.format(**data), attr=attr)
|
||||||
|
|
||||||
attr = self.term.attr('created')
|
attr = self.term.attr('Created')
|
||||||
self.term.add_space(win)
|
self.term.add_space(win)
|
||||||
self.term.add_line(win, '{created}'.format(**data), attr=attr)
|
self.term.add_line(win, '{created}'.format(**data), attr=attr)
|
||||||
|
|
||||||
if data['gold']:
|
if data['gold']:
|
||||||
attr = self.term.attr('gold')
|
attr = self.term.attr('Gold')
|
||||||
self.term.add_space(win)
|
self.term.add_space(win)
|
||||||
self.term.add_line(win, self.term.guilded, attr=attr)
|
self.term.add_line(win, self.term.guilded, attr=attr)
|
||||||
|
|
||||||
if data['stickied']:
|
if data['stickied']:
|
||||||
attr = self.term.attr('stickied')
|
attr = self.term.attr('Stickied')
|
||||||
self.term.add_space(win)
|
self.term.add_space(win)
|
||||||
self.term.add_line(win, '[stickied]', attr=attr)
|
self.term.add_line(win, '[stickied]', attr=attr)
|
||||||
|
|
||||||
if data['saved']:
|
if data['saved']:
|
||||||
attr = self.term.attr('saved')
|
attr = self.term.attr('Saved')
|
||||||
self.term.add_space(win)
|
self.term.add_space(win)
|
||||||
self.term.add_line(win, '[saved]', attr=attr)
|
self.term.add_line(win, '[saved]', attr=attr)
|
||||||
|
|
||||||
for row, text in enumerate(split_body, start=offset+1):
|
for row, text in enumerate(split_body, start=offset+1):
|
||||||
attr = self.term.attr('comment_text')
|
attr = self.term.attr('CommentText')
|
||||||
if row in valid_rows:
|
if row in valid_rows:
|
||||||
self.term.add_line(win, text, row, 1, attr=attr)
|
self.term.add_line(win, text, row, 1, attr=attr)
|
||||||
|
|
||||||
# Unfortunately vline() doesn't support custom color so we have to
|
# curses.vline() doesn't support custom colors so need to build the
|
||||||
# build it one segment at a time.
|
# cursor bar on the left of the comment one character at a time
|
||||||
index = data['level'] % len(self.term.theme.BAR_LEVELS)
|
index = data['level'] % len(self.term.theme.CURSOR_BARS)
|
||||||
attr = self.term.attr(self.term.theme.BAR_LEVELS[index])
|
attr = self.term.attr(self.term.theme.CURSOR_BARS[index])
|
||||||
for y in range(n_rows):
|
for y in range(n_rows):
|
||||||
self.term.addch(win, y, 0, self.term.vline, attr)
|
self.term.addch(win, y, 0, self.term.vline, attr)
|
||||||
|
|
||||||
@@ -373,15 +373,15 @@ class SubmissionPage(Page):
|
|||||||
n_rows, n_cols = win.getmaxyx()
|
n_rows, n_cols = win.getmaxyx()
|
||||||
n_cols -= 1
|
n_cols -= 1
|
||||||
|
|
||||||
attr = self.term.attr('hidden_comment_text')
|
attr = self.term.attr('HiddenCommentText')
|
||||||
self.term.add_line(win, '{body}'.format(**data), 0, 1, attr=attr)
|
self.term.add_line(win, '{body}'.format(**data), 0, 1, attr=attr)
|
||||||
|
|
||||||
attr = self.term.attr('hidden_comment_expand')
|
attr = self.term.attr('HiddenCommentExpand')
|
||||||
self.term.add_space(win)
|
self.term.add_space(win)
|
||||||
self.term.add_line(win, '[{count}]'.format(**data), attr=attr)
|
self.term.add_line(win, '[{count}]'.format(**data), attr=attr)
|
||||||
|
|
||||||
index = data['level'] % len(self.term.theme.BAR_LEVELS)
|
index = data['level'] % len(self.term.theme.CURSOR_BARS)
|
||||||
attr = self.term.attr(self.term.theme.BAR_LEVELS[index])
|
attr = self.term.attr(self.term.theme.CURSOR_BARS[index])
|
||||||
self.term.addch(win, 0, 0, self.term.vline, attr)
|
self.term.addch(win, 0, 0, self.term.vline, attr)
|
||||||
|
|
||||||
def _draw_submission(self, win, data):
|
def _draw_submission(self, win, data):
|
||||||
@@ -389,32 +389,32 @@ class SubmissionPage(Page):
|
|||||||
n_rows, n_cols = win.getmaxyx()
|
n_rows, n_cols = win.getmaxyx()
|
||||||
n_cols -= 3 # one for each side of the border + one for offset
|
n_cols -= 3 # one for each side of the border + one for offset
|
||||||
|
|
||||||
attr = self.term.attr('submission_title')
|
attr = self.term.attr('SubmissionTitle')
|
||||||
for row, text in enumerate(data['split_title'], start=1):
|
for row, text in enumerate(data['split_title'], start=1):
|
||||||
self.term.add_line(win, text, row, 1, attr)
|
self.term.add_line(win, text, row, 1, attr)
|
||||||
|
|
||||||
row = len(data['split_title']) + 1
|
row = len(data['split_title']) + 1
|
||||||
attr = self.term.attr('submission_author')
|
attr = self.term.attr('SubmissionAuthor')
|
||||||
self.term.add_line(win, '{author}'.format(**data), row, 1, attr)
|
self.term.add_line(win, '{author}'.format(**data), row, 1, attr)
|
||||||
|
|
||||||
if data['flair']:
|
if data['flair']:
|
||||||
attr = self.term.attr('submission_flair')
|
attr = self.term.attr('SubmissionFlair')
|
||||||
self.term.add_space(win)
|
self.term.add_space(win)
|
||||||
self.term.add_line(win, '{flair}'.format(**data), attr=attr)
|
self.term.add_line(win, '{flair}'.format(**data), attr=attr)
|
||||||
|
|
||||||
attr = self.term.attr('created')
|
attr = self.term.attr('SubmissionSubreddit')
|
||||||
self.term.add_space(win)
|
|
||||||
self.term.add_line(win, '{created}'.format(**data), attr=attr)
|
|
||||||
|
|
||||||
attr = self.term.attr('submission_subreddit')
|
|
||||||
self.term.add_space(win)
|
self.term.add_space(win)
|
||||||
self.term.add_line(win, '/r/{subreddit}'.format(**data), attr=attr)
|
self.term.add_line(win, '/r/{subreddit}'.format(**data), attr=attr)
|
||||||
|
|
||||||
|
attr = self.term.attr('Created')
|
||||||
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, '{created_long}'.format(**data), attr=attr)
|
||||||
|
|
||||||
row = len(data['split_title']) + 2
|
row = len(data['split_title']) + 2
|
||||||
if data['url_full'] in self.config.history:
|
if data['url_full'] in self.config.history:
|
||||||
attr = self.term.attr('url_seen')
|
attr = self.term.attr('LinkSeen')
|
||||||
else:
|
else:
|
||||||
attr = self.term.attr('url')
|
attr = self.term.attr('Link')
|
||||||
self.term.add_line(win, '{url}'.format(**data), row, 1, attr)
|
self.term.add_line(win, '{url}'.format(**data), row, 1, attr)
|
||||||
|
|
||||||
offset = len(data['split_title']) + 3
|
offset = len(data['split_title']) + 3
|
||||||
@@ -426,34 +426,34 @@ class SubmissionPage(Page):
|
|||||||
split_text = split_text[:-cutoff]
|
split_text = split_text[:-cutoff]
|
||||||
split_text.append('(Not enough space to display)')
|
split_text.append('(Not enough space to display)')
|
||||||
|
|
||||||
attr = self.term.attr('submission_text')
|
attr = self.term.attr('SubmissionText')
|
||||||
for row, text in enumerate(split_text, start=offset):
|
for row, text in enumerate(split_text, start=offset):
|
||||||
self.term.add_line(win, text, row, 1, attr=attr)
|
self.term.add_line(win, text, row, 1, attr=attr)
|
||||||
|
|
||||||
row = len(data['split_title']) + len(split_text) + 3
|
row = len(data['split_title']) + len(split_text) + 3
|
||||||
attr = self.term.attr('score')
|
attr = self.term.attr('Score')
|
||||||
self.term.add_line(win, '{score}'.format(**data), row, 1, attr=attr)
|
self.term.add_line(win, '{score}'.format(**data), row, 1, attr=attr)
|
||||||
|
|
||||||
arrow, attr = self.term.get_arrow(data['likes'])
|
arrow, attr = self.term.get_arrow(data['likes'])
|
||||||
self.term.add_space(win)
|
self.term.add_space(win)
|
||||||
self.term.add_line(win, arrow, attr=attr)
|
self.term.add_line(win, arrow, attr=attr)
|
||||||
|
|
||||||
attr = self.term.attr('comment_count')
|
attr = self.term.attr('CommentCount')
|
||||||
self.term.add_space(win)
|
self.term.add_space(win)
|
||||||
self.term.add_line(win, '{comments}'.format(**data), attr=attr)
|
self.term.add_line(win, '{comments}'.format(**data), attr=attr)
|
||||||
|
|
||||||
if data['gold']:
|
if data['gold']:
|
||||||
attr = self.term.attr('gold')
|
attr = self.term.attr('Gold')
|
||||||
self.term.add_space(win)
|
self.term.add_space(win)
|
||||||
self.term.add_line(win, self.term.guilded, attr=attr)
|
self.term.add_line(win, self.term.guilded, attr=attr)
|
||||||
|
|
||||||
if data['nsfw']:
|
if data['nsfw']:
|
||||||
attr = self.term.attr('nsfw')
|
attr = self.term.attr('NSFW')
|
||||||
self.term.add_space(win)
|
self.term.add_space(win)
|
||||||
self.term.add_line(win, 'NSFW', attr=attr)
|
self.term.add_line(win, 'NSFW', attr=attr)
|
||||||
|
|
||||||
if data['saved']:
|
if data['saved']:
|
||||||
attr = self.term.attr('saved')
|
attr = self.term.attr('Saved')
|
||||||
self.term.add_space(win)
|
self.term.add_space(win)
|
||||||
self.term.add_line(win, '[saved]', attr=attr)
|
self.term.add_line(win, '[saved]', attr=attr)
|
||||||
|
|
||||||
|
|||||||
@@ -304,22 +304,22 @@ class SubredditPage(Page):
|
|||||||
|
|
||||||
n_title = len(data['split_title'])
|
n_title = len(data['split_title'])
|
||||||
for row, text in enumerate(data['split_title'], start=offset):
|
for row, text in enumerate(data['split_title'], start=offset):
|
||||||
attr = self.term.attr('submission_title')
|
attr = self.term.attr('SubmissionTitle')
|
||||||
if row in valid_rows:
|
if row in valid_rows:
|
||||||
self.term.add_line(win, text, row, 1, attr)
|
self.term.add_line(win, text, row, 1, attr)
|
||||||
|
|
||||||
row = n_title + offset
|
row = n_title + offset
|
||||||
if row in valid_rows:
|
if row in valid_rows:
|
||||||
if data['url_full'] in self.config.history:
|
if data['url_full'] in self.config.history:
|
||||||
attr = self.term.attr('url_seen')
|
attr = self.term.attr('LinkSeen')
|
||||||
else:
|
else:
|
||||||
attr = self.term.attr('url')
|
attr = self.term.attr('Link')
|
||||||
self.term.add_line(win, '{url}'.format(**data), row, 1, attr)
|
self.term.add_line(win, '{url}'.format(**data), row, 1, attr)
|
||||||
|
|
||||||
row = n_title + offset + 1
|
row = n_title + offset + 1
|
||||||
if row in valid_rows:
|
if row in valid_rows:
|
||||||
|
|
||||||
attr = self.term.attr('score')
|
attr = self.term.attr('Score')
|
||||||
self.term.add_line(win, '{score}'.format(**data), row, 1, attr)
|
self.term.add_line(win, '{score}'.format(**data), row, 1, attr)
|
||||||
self.term.add_space(win)
|
self.term.add_space(win)
|
||||||
|
|
||||||
@@ -327,52 +327,52 @@ class SubredditPage(Page):
|
|||||||
self.term.add_line(win, arrow, attr=attr)
|
self.term.add_line(win, arrow, attr=attr)
|
||||||
self.term.add_space(win)
|
self.term.add_space(win)
|
||||||
|
|
||||||
attr = self.term.attr('created')
|
attr = self.term.attr('Created')
|
||||||
self.term.add_line(win, '{created}'.format(**data), attr=attr)
|
self.term.add_line(win, '{created}'.format(**data), attr=attr)
|
||||||
|
|
||||||
if data['comments'] is not None:
|
if data['comments'] is not None:
|
||||||
attr = self.term.attr('separator')
|
attr = self.term.attr('Separator')
|
||||||
self.term.add_space(win)
|
self.term.add_space(win)
|
||||||
self.term.add_line(win, '-', attr=attr)
|
self.term.add_line(win, '-', attr=attr)
|
||||||
|
|
||||||
attr = self.term.attr('comment_count')
|
attr = self.term.attr('CommentCount')
|
||||||
self.term.add_space(win)
|
self.term.add_space(win)
|
||||||
self.term.add_line(win, '{comments}'.format(**data), attr=attr)
|
self.term.add_line(win, '{comments}'.format(**data), attr=attr)
|
||||||
|
|
||||||
if data['saved']:
|
if data['saved']:
|
||||||
attr = self.term.attr('saved')
|
attr = self.term.attr('Saved')
|
||||||
self.term.add_space(win)
|
self.term.add_space(win)
|
||||||
self.term.add_line(win, '[saved]', attr=attr)
|
self.term.add_line(win, '[saved]', attr=attr)
|
||||||
|
|
||||||
if data['stickied']:
|
if data['stickied']:
|
||||||
attr = self.term.attr('stickied')
|
attr = self.term.attr('Stickied')
|
||||||
self.term.add_space(win)
|
self.term.add_space(win)
|
||||||
self.term.add_line(win, '[stickied]', attr=attr)
|
self.term.add_line(win, '[stickied]', attr=attr)
|
||||||
|
|
||||||
if data['gold']:
|
if data['gold']:
|
||||||
attr = self.term.attr('gold')
|
attr = self.term.attr('Gold')
|
||||||
self.term.add_space(win)
|
self.term.add_space(win)
|
||||||
self.term.add_line(win, self.term.guilded, attr=attr)
|
self.term.add_line(win, self.term.guilded, attr=attr)
|
||||||
|
|
||||||
if data['nsfw']:
|
if data['nsfw']:
|
||||||
attr = self.term.attr('nsfw')
|
attr = self.term.attr('NSFW')
|
||||||
self.term.add_space(win)
|
self.term.add_space(win)
|
||||||
self.term.add_line(win, 'NSFW', attr=attr)
|
self.term.add_line(win, 'NSFW', attr=attr)
|
||||||
|
|
||||||
row = n_title + offset + 2
|
row = n_title + offset + 2
|
||||||
if row in valid_rows:
|
if row in valid_rows:
|
||||||
attr = self.term.attr('submission_author')
|
attr = self.term.attr('SubmissionAuthor')
|
||||||
self.term.add_line(win, '{author}'.format(**data), row, 1, attr)
|
self.term.add_line(win, '{author}'.format(**data), row, 1, attr)
|
||||||
self.term.add_space(win)
|
self.term.add_space(win)
|
||||||
|
|
||||||
attr = self.term.attr('submission_subreddit')
|
attr = self.term.attr('SubmissionSubreddit')
|
||||||
self.term.add_line(win, '/r/{subreddit}'.format(**data), attr=attr)
|
self.term.add_line(win, '/r/{subreddit}'.format(**data), attr=attr)
|
||||||
|
|
||||||
if data['flair']:
|
if data['flair']:
|
||||||
attr = self.term.attr('submission_flair')
|
attr = self.term.attr('SubmissionFlair')
|
||||||
self.term.add_space(win)
|
self.term.add_space(win)
|
||||||
self.term.add_line(win, '{flair}'.format(**data), attr=attr)
|
self.term.add_line(win, '{flair}'.format(**data), attr=attr)
|
||||||
|
|
||||||
attr = self.term.attr('cursor')
|
attr = self.term.attr('CursorBlock')
|
||||||
for y in range(n_rows):
|
for y in range(n_rows):
|
||||||
self.term.addch(win, y, 0, str(' '), attr)
|
self.term.addch(win, y, 0, str(' '), attr)
|
||||||
|
|||||||
@@ -93,20 +93,20 @@ class SubscriptionPage(Page):
|
|||||||
row = offset
|
row = offset
|
||||||
if row in valid_rows:
|
if row in valid_rows:
|
||||||
if data['type'] == 'Multireddit':
|
if data['type'] == 'Multireddit':
|
||||||
attr = self.term.attr('multireddit_name')
|
attr = self.term.attr('MultiredditName')
|
||||||
else:
|
else:
|
||||||
attr = self.term.attr('subscription_name')
|
attr = self.term.attr('SubscriptionName')
|
||||||
self.term.add_line(win, '{name}'.format(**data), row, 1, attr)
|
self.term.add_line(win, '{name}'.format(**data), row, 1, attr)
|
||||||
|
|
||||||
row = offset + 1
|
row = offset + 1
|
||||||
for row, text in enumerate(data['split_title'], start=row):
|
for row, text in enumerate(data['split_title'], start=row):
|
||||||
if row in valid_rows:
|
if row in valid_rows:
|
||||||
if data['type'] == 'Multireddit':
|
if data['type'] == 'Multireddit':
|
||||||
attr = self.term.attr('multireddit_text')
|
attr = self.term.attr('MultiredditText')
|
||||||
else:
|
else:
|
||||||
attr = self.term.attr('subscription_text')
|
attr = self.term.attr('SubscriptionText')
|
||||||
self.term.add_line(win, text, row, 1, attr)
|
self.term.add_line(win, text, row, 1, attr)
|
||||||
|
|
||||||
attr = self.term.attr('cursor')
|
attr = self.term.attr('CursorBlock')
|
||||||
for y in range(n_rows):
|
for y in range(n_rows):
|
||||||
self.term.addch(win, y, 0, str(' '), attr)
|
self.term.addch(win, y, 0, str(' '), attr)
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ max_comment_cols = 120
|
|||||||
; Hide username if logged in, display "Logged in" instead
|
; Hide username if logged in, display "Logged in" instead
|
||||||
hide_username = False
|
hide_username = False
|
||||||
|
|
||||||
|
; Color theme, use "rtv --list-themes" to view a list of valid options.
|
||||||
|
; This can be an absolute filepath, or the name of a theme file that has
|
||||||
|
; been installed into either the custom of default theme paths.
|
||||||
|
;theme = monokai
|
||||||
|
|
||||||
################
|
################
|
||||||
# OAuth Settings
|
# OAuth Settings
|
||||||
################
|
################
|
||||||
@@ -110,6 +115,8 @@ SORT_NEW = 4
|
|||||||
SORT_CONTROVERSIAL = 5
|
SORT_CONTROVERSIAL = 5
|
||||||
MOVE_UP = k, <KEY_UP>
|
MOVE_UP = k, <KEY_UP>
|
||||||
MOVE_DOWN = j, <KEY_DOWN>
|
MOVE_DOWN = j, <KEY_DOWN>
|
||||||
|
PREVIOUS_THEME = <KEY_F2>
|
||||||
|
NEXT_THEME = <KEY_F3>
|
||||||
PAGE_UP = m, <KEY_PPAGE>, <NAK>
|
PAGE_UP = m, <KEY_PPAGE>, <NAK>
|
||||||
PAGE_DOWN = n, <KEY_NPAGE>, <EOT>
|
PAGE_DOWN = n, <KEY_NPAGE>, <EOT>
|
||||||
PAGE_TOP = gg
|
PAGE_TOP = gg
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import six
|
|||||||
from kitchen.text.display import textual_width_chop
|
from kitchen.text.display import textual_width_chop
|
||||||
|
|
||||||
from . import exceptions, mime_parsers, content
|
from . import exceptions, mime_parsers, content
|
||||||
from .theme import Theme
|
from .theme import Theme, ThemeList
|
||||||
from .objects import LoadScreen
|
from .objects import LoadScreen
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -51,14 +51,13 @@ class Terminal(object):
|
|||||||
RETURN = 10
|
RETURN = 10
|
||||||
SPACE = 32
|
SPACE = 32
|
||||||
|
|
||||||
def __init__(self, stdscr, config, theme=None):
|
def __init__(self, stdscr, config):
|
||||||
|
|
||||||
self.stdscr = stdscr
|
self.stdscr = stdscr
|
||||||
self.config = config
|
self.config = config
|
||||||
self.loader = LoadScreen(self)
|
self.loader = LoadScreen(self)
|
||||||
|
self.theme = None # Initialized by term.set_theme()
|
||||||
self.theme = None
|
self.theme_list = ThemeList()
|
||||||
self.set_theme(theme)
|
|
||||||
|
|
||||||
self._display = None
|
self._display = None
|
||||||
self._mailcap_dict = mailcap.getcaps()
|
self._mailcap_dict = mailcap.getcaps()
|
||||||
@@ -193,11 +192,11 @@ class Terminal(object):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
if likes is None:
|
if likes is None:
|
||||||
return self.neutral_arrow, self.attr('neutral_vote')
|
return self.neutral_arrow, self.attr('NeutralVote')
|
||||||
elif likes:
|
elif likes:
|
||||||
return self.up_arrow, self.attr('upvote')
|
return self.up_arrow, self.attr('Upvote')
|
||||||
else:
|
else:
|
||||||
return self.down_arrow, self.attr('downvote')
|
return self.down_arrow, self.attr('Downvote')
|
||||||
|
|
||||||
def clean(self, string, n_cols=None):
|
def clean(self, string, n_cols=None):
|
||||||
"""
|
"""
|
||||||
@@ -293,7 +292,7 @@ class Terminal(object):
|
|||||||
|
|
||||||
window.addstr(row, col, ' ')
|
window.addstr(row, col, ' ')
|
||||||
|
|
||||||
def show_notification(self, message, timeout=None, style='info'):
|
def show_notification(self, message, timeout=None, style='Info'):
|
||||||
"""
|
"""
|
||||||
Overlay a message box on the center of the screen and wait for input.
|
Overlay a message box on the center of the screen and wait for input.
|
||||||
|
|
||||||
@@ -305,7 +304,7 @@ class Terminal(object):
|
|||||||
notification window
|
notification window
|
||||||
"""
|
"""
|
||||||
|
|
||||||
assert style in ('info', 'warning', 'error', 'success')
|
assert style in ('Info', 'Warning', 'Error', 'Success')
|
||||||
|
|
||||||
if isinstance(message, six.string_types):
|
if isinstance(message, six.string_types):
|
||||||
message = message.splitlines()
|
message = message.splitlines()
|
||||||
@@ -325,7 +324,7 @@ class Terminal(object):
|
|||||||
s_col = (n_cols - box_width) // 2 + h_offset
|
s_col = (n_cols - box_width) // 2 + h_offset
|
||||||
|
|
||||||
window = curses.newwin(box_height, box_width, s_row, s_col)
|
window = curses.newwin(box_height, box_width, s_row, s_col)
|
||||||
window.bkgd(str(' '), self.attr('notice_{0}'.format(style)))
|
window.bkgd(str(' '), self.attr('Notice{0}'.format(style)))
|
||||||
window.erase()
|
window.erase()
|
||||||
window.border()
|
window.border()
|
||||||
|
|
||||||
@@ -403,7 +402,7 @@ class Terminal(object):
|
|||||||
_logger.warning(stderr)
|
_logger.warning(stderr)
|
||||||
self.show_notification(
|
self.show_notification(
|
||||||
'Program exited with status={0}\n{1}'.format(
|
'Program exited with status={0}\n{1}'.format(
|
||||||
code, stderr.strip()), style='error')
|
code, stderr.strip()), style='Error')
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Non-blocking, open a background process
|
# Non-blocking, open a background process
|
||||||
@@ -731,7 +730,7 @@ class Terminal(object):
|
|||||||
|
|
||||||
n_rows, n_cols = self.stdscr.getmaxyx()
|
n_rows, n_cols = self.stdscr.getmaxyx()
|
||||||
v_offset, h_offset = self.stdscr.getbegyx()
|
v_offset, h_offset = self.stdscr.getbegyx()
|
||||||
ch, attr = str(' '), self.attr('prompt')
|
ch, attr = str(' '), self.attr('Prompt')
|
||||||
prompt = self.clean(prompt, n_cols-1)
|
prompt = self.clean(prompt, n_cols-1)
|
||||||
|
|
||||||
# Create a new window to draw the text at the bottom of the screen,
|
# Create a new window to draw the text at the bottom of the screen,
|
||||||
@@ -849,14 +848,27 @@ class Terminal(object):
|
|||||||
"""
|
"""
|
||||||
Shortcut for fetching the color + attribute code for an element.
|
Shortcut for fetching the color + attribute code for an element.
|
||||||
"""
|
"""
|
||||||
|
# The theme must be initialized before calling this
|
||||||
|
assert self.theme is not None
|
||||||
|
|
||||||
return self.theme.get(element)
|
return self.theme.get(element)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def check_theme(theme):
|
||||||
|
"""
|
||||||
|
Check if the given theme is compatible with the terminal
|
||||||
|
"""
|
||||||
|
terminal_colors = curses.COLORS if curses.has_colors() else 0
|
||||||
|
|
||||||
|
if theme.required_colors > terminal_colors:
|
||||||
|
return False
|
||||||
|
elif theme.required_color_pairs > curses.COLOR_PAIRS:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
|
||||||
def set_theme(self, theme=None):
|
def set_theme(self, theme=None):
|
||||||
"""
|
"""
|
||||||
Set the terminal theme. This is a stub for what will eventually
|
|
||||||
support managing custom themes.
|
|
||||||
|
|
||||||
Check that the terminal supports the provided theme, and applies
|
Check that the terminal supports the provided theme, and applies
|
||||||
the theme to the terminal if possible.
|
the theme to the terminal if possible.
|
||||||
|
|
||||||
@@ -864,14 +876,31 @@ class Terminal(object):
|
|||||||
default theme. The default theme only requires 8 colors so it
|
default theme. The default theme only requires 8 colors so it
|
||||||
should be compatible with any terminal that supports basic colors.
|
should be compatible with any terminal that supports basic colors.
|
||||||
"""
|
"""
|
||||||
monochrome = (not curses.has_colors())
|
|
||||||
|
|
||||||
if theme is None or monochrome:
|
terminal_colors = curses.COLORS if curses.has_colors() else 0
|
||||||
theme = Theme(monochrome=monochrome)
|
default_theme = Theme(use_color=bool(terminal_colors))
|
||||||
|
|
||||||
|
if theme is None:
|
||||||
|
theme = default_theme
|
||||||
|
|
||||||
|
elif theme.required_color_pairs > curses.COLOR_PAIRS:
|
||||||
|
_logger.warning(
|
||||||
|
'Theme `%s` requires %s color pairs, but $TERM=%s only '
|
||||||
|
'supports %s color pairs, switching to default theme',
|
||||||
|
theme.name, theme.required_color_pairs, self._term,
|
||||||
|
curses.COLOR_PAIRS)
|
||||||
|
theme = default_theme
|
||||||
|
|
||||||
|
elif theme.required_colors > terminal_colors:
|
||||||
|
_logger.warning(
|
||||||
|
'Theme `%s` requires %s colors, but $TERM=%s only '
|
||||||
|
'supports %s colors, switching to default theme',
|
||||||
|
theme.name, theme.required_colors, self._term,
|
||||||
|
curses.COLORS)
|
||||||
|
theme = default_theme
|
||||||
|
|
||||||
theme.bind_curses()
|
theme.bind_curses()
|
||||||
|
self.theme = theme
|
||||||
|
|
||||||
# Apply the default color to the whole screen
|
# Apply the default color to the whole screen
|
||||||
self.stdscr.bkgd(str(' '), theme.get('normal'))
|
self.stdscr.bkgd(str(' '), self.attr('Normal'))
|
||||||
|
|
||||||
self.theme = theme
|
|
||||||
|
|||||||
616
rtv/theme.py
@@ -1,113 +1,555 @@
|
|||||||
"""
|
import os
|
||||||
This file is a stub that contains the default RTV theme.
|
import codecs
|
||||||
|
|
||||||
This will eventually be expanded to support loading/managing custom themes.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import curses
|
import curses
|
||||||
|
import logging
|
||||||
|
from collections import OrderedDict
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
import six
|
||||||
|
from six.moves import configparser
|
||||||
|
|
||||||
DEFAULT_THEME = {
|
from .config import THEMES, DEFAULT_THEMES
|
||||||
'normal': (-1, -1, curses.A_NORMAL),
|
from .exceptions import ConfigError
|
||||||
'bar_level_1': (curses.COLOR_MAGENTA, -1, curses.A_NORMAL),
|
|
||||||
'bar_level_1.selected': (curses.COLOR_MAGENTA, -1, curses.A_REVERSE),
|
_logger = logging.getLogger(__name__)
|
||||||
'bar_level_2': (curses.COLOR_CYAN, -1, curses.A_NORMAL),
|
|
||||||
'bar_level_2.selected': (curses.COLOR_CYAN, -1, curses.A_REVERSE),
|
|
||||||
'bar_level_3': (curses.COLOR_GREEN, -1, curses.A_NORMAL),
|
|
||||||
'bar_level_3.selected': (curses.COLOR_GREEN, -1, curses.A_REVERSE),
|
|
||||||
'bar_level_4': (curses.COLOR_YELLOW, -1, curses.A_NORMAL),
|
|
||||||
'bar_level_4.selected': (curses.COLOR_YELLOW, -1, curses.A_REVERSE),
|
|
||||||
'comment_author': (curses.COLOR_BLUE, -1, curses.A_BOLD),
|
|
||||||
'comment_author_self': (curses.COLOR_GREEN, -1, curses.A_BOLD),
|
|
||||||
'comment_count': (-1, -1, curses.A_NORMAL),
|
|
||||||
'comment_text': (-1, -1, curses.A_NORMAL),
|
|
||||||
'created': (-1, -1, curses.A_NORMAL),
|
|
||||||
'cursor': (-1, -1, curses.A_NORMAL),
|
|
||||||
'cursor.selected': (-1, -1, curses.A_REVERSE),
|
|
||||||
'downvote': (curses.COLOR_RED, -1, curses.A_BOLD),
|
|
||||||
'gold': (curses.COLOR_YELLOW, -1, curses.A_BOLD),
|
|
||||||
'help_bar': (curses.COLOR_CYAN, -1, curses.A_BOLD | curses.A_REVERSE),
|
|
||||||
'hidden_comment_expand': (-1, -1, curses.A_BOLD),
|
|
||||||
'hidden_comment_text': (-1, -1, curses.A_NORMAL),
|
|
||||||
'multireddit_name': (curses.COLOR_YELLOW, -1, curses.A_BOLD),
|
|
||||||
'multireddit_text': (-1, -1, curses.A_NORMAL),
|
|
||||||
'neutral_vote': (-1, -1, curses.A_BOLD),
|
|
||||||
'notice_info': (-1, -1, curses.A_NORMAL),
|
|
||||||
'notice_loading': (-1, -1, curses.A_NORMAL),
|
|
||||||
'notice_error': (-1, -1, curses.A_NORMAL),
|
|
||||||
'notice_success': (-1, -1, curses.A_NORMAL),
|
|
||||||
'nsfw': (curses.COLOR_RED, -1, curses.A_BOLD | curses.A_REVERSE),
|
|
||||||
'order_bar': (curses.COLOR_YELLOW, -1, curses.A_BOLD),
|
|
||||||
'order_bar.selected': (curses.COLOR_YELLOW, -1, curses.A_BOLD | curses.A_REVERSE),
|
|
||||||
'prompt': (curses.COLOR_CYAN, -1, curses.A_BOLD | curses.A_REVERSE),
|
|
||||||
'saved': (curses.COLOR_GREEN, -1, curses.A_NORMAL),
|
|
||||||
'score': (-1, -1, curses.A_NORMAL),
|
|
||||||
'separator': (-1, -1, curses.A_BOLD),
|
|
||||||
'stickied': (curses.COLOR_GREEN, -1, curses.A_NORMAL),
|
|
||||||
'subscription_name': (curses.COLOR_YELLOW, -1, curses.A_BOLD),
|
|
||||||
'subscription_text': (-1, -1, curses.A_NORMAL),
|
|
||||||
'submission_author': (curses.COLOR_GREEN, -1, curses.A_NORMAL),
|
|
||||||
'submission_flair': (curses.COLOR_RED, -1, curses.A_NORMAL),
|
|
||||||
'submission_subreddit': (curses.COLOR_YELLOW, -1, curses.A_NORMAL),
|
|
||||||
'submission_text': (-1, -1, curses.A_NORMAL),
|
|
||||||
'submission_title': (-1, -1, curses.A_BOLD),
|
|
||||||
'title_bar': (curses.COLOR_CYAN, -1, curses.A_BOLD | curses.A_REVERSE),
|
|
||||||
'upvote': (curses.COLOR_GREEN, -1, curses.A_BOLD),
|
|
||||||
'url': (curses.COLOR_BLUE, -1, curses.A_UNDERLINE),
|
|
||||||
'url_seen': (curses.COLOR_MAGENTA, -1, curses.A_UNDERLINE),
|
|
||||||
'user_flair': (curses.COLOR_YELLOW, -1, curses.A_BOLD)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class Theme(object):
|
class Theme(object):
|
||||||
|
|
||||||
BAR_LEVELS = ['bar_level_1', 'bar_level_2', 'bar_level_3', 'bar_level_4']
|
ATTRIBUTE_CODES = {
|
||||||
|
'-': None,
|
||||||
|
'': None,
|
||||||
|
'normal': curses.A_NORMAL,
|
||||||
|
'bold': curses.A_BOLD,
|
||||||
|
'reverse': curses.A_REVERSE,
|
||||||
|
'underline': curses.A_UNDERLINE,
|
||||||
|
'standout': curses.A_STANDOUT
|
||||||
|
}
|
||||||
|
|
||||||
def __init__(self, monochrome=True):
|
COLOR_CODES = {
|
||||||
|
'-': None,
|
||||||
|
'default': -1,
|
||||||
|
'black': curses.COLOR_BLACK,
|
||||||
|
'red': curses.COLOR_RED,
|
||||||
|
'green': curses.COLOR_GREEN,
|
||||||
|
'yellow': curses.COLOR_YELLOW,
|
||||||
|
'blue': curses.COLOR_BLUE,
|
||||||
|
'magenta': curses.COLOR_MAGENTA,
|
||||||
|
'cyan': curses.COLOR_CYAN,
|
||||||
|
'light_gray': curses.COLOR_WHITE,
|
||||||
|
'dark_gray': 8,
|
||||||
|
'bright_red': 9,
|
||||||
|
'bright_green': 10,
|
||||||
|
'bright_yellow': 11,
|
||||||
|
'bright_blue': 12,
|
||||||
|
'bright_magenta': 13,
|
||||||
|
'bright_cyan': 14,
|
||||||
|
'white': 15,
|
||||||
|
}
|
||||||
|
|
||||||
self.monochrome = monochrome
|
for i in range(256):
|
||||||
self._modifier = None
|
COLOR_CODES['ansi_{0}'.format(i)] = i
|
||||||
self._elements = {}
|
|
||||||
self._color_pairs = {}
|
# For compatibility with as many terminals as possible, the default theme
|
||||||
|
# can only use the 8 basic colors with the default color as the background
|
||||||
|
DEFAULT_THEME = {
|
||||||
|
'modifiers': {
|
||||||
|
'Normal': (-1, -1, curses.A_NORMAL),
|
||||||
|
'Selected': (-1, -1, curses.A_NORMAL),
|
||||||
|
'SelectedCursor': (-1, -1, curses.A_REVERSE),
|
||||||
|
},
|
||||||
|
'page': {
|
||||||
|
'TitleBar': (curses.COLOR_CYAN, None, curses.A_BOLD | curses.A_REVERSE),
|
||||||
|
'OrderBar': (curses.COLOR_YELLOW, None, curses.A_BOLD),
|
||||||
|
'OrderBarHighlight': (curses.COLOR_YELLOW, None, curses.A_BOLD | curses.A_REVERSE),
|
||||||
|
'HelpBar': (curses.COLOR_CYAN, None, curses.A_BOLD | curses.A_REVERSE),
|
||||||
|
'Prompt': (curses.COLOR_CYAN, None, curses.A_BOLD | curses.A_REVERSE),
|
||||||
|
'NoticeInfo': (None, None, curses.A_BOLD),
|
||||||
|
'NoticeLoading': (None, None, curses.A_BOLD),
|
||||||
|
'NoticeError': (None, None, curses.A_BOLD),
|
||||||
|
'NoticeSuccess': (None, None, curses.A_BOLD),
|
||||||
|
},
|
||||||
|
# Fields that might be highlighted by the "SelectedCursor" element
|
||||||
|
'cursor': {
|
||||||
|
'CursorBlock': (None, None, None),
|
||||||
|
'CursorBar1': (curses.COLOR_MAGENTA, None, None),
|
||||||
|
'CursorBar2': (curses.COLOR_CYAN, None, None),
|
||||||
|
'CursorBar3': (curses.COLOR_GREEN, None, None),
|
||||||
|
'CursorBar4': (curses.COLOR_YELLOW, None, None),
|
||||||
|
},
|
||||||
|
# Fields that might be highlighted by the "Selected" element
|
||||||
|
'normal': {
|
||||||
|
'CommentAuthor': (curses.COLOR_BLUE, None, curses.A_BOLD),
|
||||||
|
'CommentAuthorSelf': (curses.COLOR_GREEN, None, curses.A_BOLD),
|
||||||
|
'CommentCount': (None, None, None),
|
||||||
|
'CommentText': (None, None, None),
|
||||||
|
'Created': (None, None, None),
|
||||||
|
'Downvote': (curses.COLOR_RED, None, curses.A_BOLD),
|
||||||
|
'Gold': (curses.COLOR_YELLOW, None, curses.A_BOLD),
|
||||||
|
'HiddenCommentExpand': (None, None, curses.A_BOLD),
|
||||||
|
'HiddenCommentText': (None, None, None),
|
||||||
|
'MultiredditName': (curses.COLOR_YELLOW, None, curses.A_BOLD),
|
||||||
|
'MultiredditText': (None, None, None),
|
||||||
|
'NeutralVote': (None, None, curses.A_BOLD),
|
||||||
|
'NSFW': (curses.COLOR_RED, None, curses.A_BOLD | curses.A_REVERSE),
|
||||||
|
'Saved': (curses.COLOR_GREEN, None, None),
|
||||||
|
'Score': (None, None, None),
|
||||||
|
'Separator': (None, None, curses.A_BOLD),
|
||||||
|
'Stickied': (curses.COLOR_GREEN, None, None),
|
||||||
|
'SubscriptionName': (curses.COLOR_YELLOW, None, curses.A_BOLD),
|
||||||
|
'SubscriptionText': (None, None, None),
|
||||||
|
'SubmissionAuthor': (curses.COLOR_GREEN, None, curses.A_BOLD),
|
||||||
|
'SubmissionFlair': (curses.COLOR_RED, None, None),
|
||||||
|
'SubmissionSubreddit': (curses.COLOR_YELLOW, None, None),
|
||||||
|
'SubmissionText': (None, None, None),
|
||||||
|
'SubmissionTitle': (None, None, curses.A_BOLD),
|
||||||
|
'Upvote': (curses.COLOR_GREEN, None, curses.A_BOLD),
|
||||||
|
'Link': (curses.COLOR_BLUE, None, curses.A_UNDERLINE),
|
||||||
|
'LinkSeen': (curses.COLOR_MAGENTA, None, curses.A_UNDERLINE),
|
||||||
|
'UserFlair': (curses.COLOR_YELLOW, None, curses.A_BOLD)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_ELEMENTS = {k: v for group in DEFAULT_THEME.values()
|
||||||
|
for k, v in group.items()}
|
||||||
|
|
||||||
|
# The SubmissionPage uses this to determine which color bar to use
|
||||||
|
CURSOR_BARS = ['CursorBar1', 'CursorBar2', 'CursorBar3', 'CursorBar4']
|
||||||
|
|
||||||
|
def __init__(self, name=None, source=None, elements=None, use_color=True):
|
||||||
|
"""
|
||||||
|
Params:
|
||||||
|
name (str): A unique string that describes the theme
|
||||||
|
source (str): A string that describes the source of the theme:
|
||||||
|
built-in - Should only be used when Theme() is called directly
|
||||||
|
preset - Themes packaged with rtv
|
||||||
|
installed - Themes in ~/.config/rtv/themes/
|
||||||
|
custom - When a filepath is explicitly provided, e.g.
|
||||||
|
``rtv --theme=/path/to/theme_file.cfg``
|
||||||
|
elements (dict): The theme's element map, should be in the same
|
||||||
|
format as Theme.DEFAULT_THEME.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if source not in (None, 'built-in', 'preset', 'installed', 'custom'):
|
||||||
|
raise ValueError('Invalid source')
|
||||||
|
|
||||||
|
if name is None and source is None:
|
||||||
|
name = 'default' if use_color else 'monochrome'
|
||||||
|
source = 'built-in'
|
||||||
|
elif name is None or source is None:
|
||||||
|
raise ValueError('Must specify both `name` and `source`, or neither one')
|
||||||
|
|
||||||
|
self.name = name
|
||||||
|
self.source = source
|
||||||
|
self.use_color = use_color
|
||||||
|
|
||||||
|
self._color_pair_map = None
|
||||||
|
self._attribute_map = None
|
||||||
|
self._selected = None
|
||||||
|
|
||||||
|
self.required_color_pairs = 0
|
||||||
|
self.required_colors = 0
|
||||||
|
|
||||||
|
if elements is None:
|
||||||
|
elements = self.DEFAULT_ELEMENTS.copy()
|
||||||
|
|
||||||
|
# Set any elements that weren't defined by the config to fallback to
|
||||||
|
# the default color and attributes
|
||||||
|
for key in self.DEFAULT_ELEMENTS.keys():
|
||||||
|
if key not in elements:
|
||||||
|
elements[key] = (None, None, None)
|
||||||
|
|
||||||
|
self._set_fallback(elements, 'Normal', (-1, -1, curses.A_NORMAL))
|
||||||
|
self._set_fallback(elements, 'Selected', 'Normal')
|
||||||
|
self._set_fallback(elements, 'SelectedCursor', 'Normal')
|
||||||
|
|
||||||
|
# Create the "Selected" versions of elements, which are prefixed with
|
||||||
|
# the @ symbol. For example, "@CommentText" represents how comment
|
||||||
|
# text is formatted when it is highlighted by the cursor.
|
||||||
|
for name in self.DEFAULT_THEME['normal']:
|
||||||
|
dest = '@{0}'.format(name)
|
||||||
|
self._set_fallback(elements, name, 'Selected', dest)
|
||||||
|
for name in self.DEFAULT_THEME['cursor']:
|
||||||
|
dest = '@{0}'.format(name)
|
||||||
|
self._set_fallback(elements, name, 'SelectedCursor', dest)
|
||||||
|
|
||||||
|
# Fill in the ``None`` values for all of the elements with normal text
|
||||||
|
for name in self.DEFAULT_THEME['normal']:
|
||||||
|
self._set_fallback(elements, name, 'Normal')
|
||||||
|
for name in self.DEFAULT_THEME['cursor']:
|
||||||
|
self._set_fallback(elements, name, 'Normal')
|
||||||
|
for name in self.DEFAULT_THEME['page']:
|
||||||
|
self._set_fallback(elements, name, 'Normal')
|
||||||
|
|
||||||
|
self.elements = elements
|
||||||
|
|
||||||
|
if self.use_color:
|
||||||
|
# Pre-calculate how many colors / color pairs the theme will need
|
||||||
|
colors, color_pairs = set(), set()
|
||||||
|
for fg, bg, _ in self.elements.values():
|
||||||
|
colors.add(fg)
|
||||||
|
colors.add(bg)
|
||||||
|
color_pairs.add((fg, bg))
|
||||||
|
|
||||||
|
# Don't count the default (-1, -1) as a color pair because it
|
||||||
|
# doesn't need to be initialized by curses.init_pair().
|
||||||
|
color_pairs.discard((-1, -1))
|
||||||
|
self.required_color_pairs = len(color_pairs)
|
||||||
|
|
||||||
|
# Determine how many colors the terminal needs to support in order
|
||||||
|
# to be able to use the theme. This uses the common breakpoints
|
||||||
|
# that 99% of terminals follow and doesn't take into account
|
||||||
|
# 88 color themes.
|
||||||
|
self.required_colors = None
|
||||||
|
for marker in [0, 8, 16, 256]:
|
||||||
|
if max(colors) < marker:
|
||||||
|
self.required_colors = marker
|
||||||
|
break
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_string(self):
|
||||||
|
return '{0} ({1})'.format(self.name, self.source)
|
||||||
|
|
||||||
def bind_curses(self):
|
def bind_curses(self):
|
||||||
|
"""
|
||||||
|
Bind the theme's colors to curses's internal color pair map.
|
||||||
|
|
||||||
if self.monochrome:
|
This method must be called once (after curses has been initialized)
|
||||||
# Skip initializing the colors and just use the attributes
|
before any element attributes can be accessed. Color codes and other
|
||||||
self._elements = {key: val[2] for key, val in DEFAULT_THEME.items()}
|
special attributes will be mixed bitwise into a single value that
|
||||||
return
|
can be passed into curses draw functions.
|
||||||
|
"""
|
||||||
|
self._color_pair_map = {}
|
||||||
|
self._attribute_map = {}
|
||||||
|
|
||||||
# Shortcut for the default fg/bg
|
for element, item in self.elements.items():
|
||||||
self._color_pairs[(-1, -1)] = curses.A_NORMAL
|
fg, bg, attrs = item
|
||||||
|
|
||||||
for key, (fg, bg, attr) in DEFAULT_THEME.items():
|
color_pair = (fg, bg)
|
||||||
# Register the color pair for the element
|
if self.use_color and color_pair != (-1, -1):
|
||||||
if (fg, bg) not in self._color_pairs:
|
# Curses limits the number of available color pairs, so we
|
||||||
index = len(self._color_pairs) + 1
|
# need to reuse them if there are multiple elements with the
|
||||||
curses.init_pair(index, fg, bg)
|
# same foreground and background.
|
||||||
self._color_pairs[(fg, bg)] = curses.color_pair(index)
|
if color_pair not in self._color_pair_map:
|
||||||
|
# Index 0 is reserved by curses for the default color
|
||||||
|
index = len(self._color_pair_map) + 1
|
||||||
|
curses.init_pair(index, color_pair[0], color_pair[1])
|
||||||
|
self._color_pair_map[color_pair] = curses.color_pair(index)
|
||||||
|
attrs |= self._color_pair_map[color_pair]
|
||||||
|
|
||||||
self._elements[key] = self._color_pairs[(fg, bg)] | attr
|
self._attribute_map[element] = attrs
|
||||||
|
|
||||||
def get(self, element, modifier=None):
|
def get(self, element, selected=False):
|
||||||
|
"""
|
||||||
|
Returns the curses attribute code for the given element.
|
||||||
|
"""
|
||||||
|
if self._attribute_map is None:
|
||||||
|
raise RuntimeError('Attempted to access theme attribute before '
|
||||||
|
'calling initialize_curses_theme()')
|
||||||
|
|
||||||
modifier = modifier or self._modifier
|
if selected or self._selected:
|
||||||
if modifier:
|
element = '@{0}'.format(element)
|
||||||
modified_element = '{0}.{1}'.format(element, modifier)
|
|
||||||
if modified_element in self._elements:
|
|
||||||
return self._elements[modified_element]
|
|
||||||
|
|
||||||
return self._elements[element]
|
return self._attribute_map[element]
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def set_modifier(self, modifier=None):
|
def turn_on_selected(self):
|
||||||
|
"""
|
||||||
|
Sets the selected modifier inside of context block.
|
||||||
|
|
||||||
# This case is undefined if the context manager is nested
|
For example:
|
||||||
assert self._modifier is None
|
>>> with theme.turn_on_selected():
|
||||||
|
>>> attr = theme.get('CursorBlock')
|
||||||
|
|
||||||
self._modifier = modifier
|
Is the same as:
|
||||||
|
>>> attr = theme.get('CursorBlock', selected=True)
|
||||||
|
|
||||||
|
Is also the same as:
|
||||||
|
>>> attr = theme.get('@CursorBlock')
|
||||||
|
|
||||||
|
"""
|
||||||
|
# This context manager should never be nested
|
||||||
|
assert self._selected is None
|
||||||
|
|
||||||
|
self._selected = True
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
self._modifier = None
|
self._selected = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def list_themes(cls, path=THEMES):
|
||||||
|
"""
|
||||||
|
Compile all of the themes configuration files in the search path.
|
||||||
|
"""
|
||||||
|
themes, errors = [], OrderedDict()
|
||||||
|
|
||||||
|
def load_themes(path, source):
|
||||||
|
"""
|
||||||
|
Load all themes in the given path.
|
||||||
|
"""
|
||||||
|
if os.path.isdir(path):
|
||||||
|
for filename in sorted(os.listdir(path)):
|
||||||
|
if not filename.endswith('.cfg'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
filepath = os.path.join(path, filename)
|
||||||
|
name = filename[:-4]
|
||||||
|
try:
|
||||||
|
# Make sure the theme is valid
|
||||||
|
theme = cls.from_file(filepath, source)
|
||||||
|
except Exception as e:
|
||||||
|
errors[(source, name)] = e
|
||||||
|
else:
|
||||||
|
themes.append(theme)
|
||||||
|
|
||||||
|
themes.extend([Theme(use_color=True), Theme(use_color=False)])
|
||||||
|
load_themes(DEFAULT_THEMES, 'preset')
|
||||||
|
load_themes(path, 'installed')
|
||||||
|
|
||||||
|
return themes, errors
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def print_themes(cls, path=THEMES):
|
||||||
|
"""
|
||||||
|
Prints a human-readable summary of the installed themes to stdout.
|
||||||
|
|
||||||
|
This is intended to be used as a command-line utility, outside of the
|
||||||
|
main curses display loop.
|
||||||
|
"""
|
||||||
|
themes, errors = cls.list_themes(path=path + '/')
|
||||||
|
|
||||||
|
print('\nInstalled ({0}):'.format(path))
|
||||||
|
installed = [t for t in themes if t.source == 'installed']
|
||||||
|
if installed:
|
||||||
|
for theme in installed:
|
||||||
|
line = ' {0:<20}[requires {1} colors]'
|
||||||
|
print(line.format(theme.name, theme.required_colors))
|
||||||
|
else:
|
||||||
|
print(' (empty)')
|
||||||
|
|
||||||
|
print('\nPresets:')
|
||||||
|
preset = [t for t in themes if t.source == 'preset']
|
||||||
|
for theme in preset:
|
||||||
|
line = ' {0:<20}[requires {1} colors]'
|
||||||
|
print(line.format(theme.name, theme.required_colors))
|
||||||
|
|
||||||
|
print('\nBuilt-in:')
|
||||||
|
built_in = [t for t in themes if t.source == 'built-in']
|
||||||
|
for theme in built_in:
|
||||||
|
line = ' {0:<20}[requires {1} colors]'
|
||||||
|
print(line.format(theme.name, theme.required_colors))
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print('\nWARNING: Some files encountered errors:')
|
||||||
|
for (source, name), error in errors.items():
|
||||||
|
theme_info = '({0}) {1}'.format(source, name)
|
||||||
|
# Align multi-line error messages with the right column
|
||||||
|
err_message = six.text_type(error).replace('\n', '\n' + ' ' * 20)
|
||||||
|
print(' {0:<20}{1}'.format(theme_info, err_message))
|
||||||
|
|
||||||
|
print('')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_name(cls, name, path=THEMES):
|
||||||
|
"""
|
||||||
|
Search for the given theme on the filesystem and attempt to load it.
|
||||||
|
|
||||||
|
Directories will be checked in a pre-determined order. If the name is
|
||||||
|
provided as an absolute file path, it will be loaded directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if os.path.isfile(name):
|
||||||
|
return cls.from_file(name, 'custom')
|
||||||
|
|
||||||
|
filename = os.path.join(path, '{0}.cfg'.format(name))
|
||||||
|
if os.path.isfile(filename):
|
||||||
|
return cls.from_file(filename, 'installed')
|
||||||
|
|
||||||
|
filename = os.path.join(DEFAULT_THEMES, '{0}.cfg'.format(name))
|
||||||
|
if os.path.isfile(filename):
|
||||||
|
return cls.from_file(filename, 'preset')
|
||||||
|
|
||||||
|
raise ConfigError('Could not find theme named "{0}"'.format(name))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_file(cls, filename, source):
|
||||||
|
"""
|
||||||
|
Load a theme from the specified configuration file.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
filename: The name of the filename to load.
|
||||||
|
source: A description of where the theme was loaded from.
|
||||||
|
"""
|
||||||
|
_logger.info('Loading theme %s', filename)
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = configparser.ConfigParser()
|
||||||
|
config.optionxform = six.text_type # Preserve case
|
||||||
|
with codecs.open(filename, encoding='utf-8') as fp:
|
||||||
|
config.readfp(fp)
|
||||||
|
except configparser.ParsingError as e:
|
||||||
|
raise ConfigError(e.message)
|
||||||
|
|
||||||
|
if not config.has_section('theme'):
|
||||||
|
raise ConfigError(
|
||||||
|
'Error loading {0}:\n'
|
||||||
|
' missing [theme] section'.format(filename))
|
||||||
|
|
||||||
|
theme_name = os.path.basename(filename)
|
||||||
|
theme_name, _ = os.path.splitext(theme_name)
|
||||||
|
|
||||||
|
elements = {}
|
||||||
|
for element, line in config.items('theme'):
|
||||||
|
if element not in cls.DEFAULT_ELEMENTS:
|
||||||
|
# Could happen if using a new config with an older version
|
||||||
|
# of the software
|
||||||
|
_logger.info('Skipping element %s', element)
|
||||||
|
continue
|
||||||
|
elements[element] = cls._parse_line(element, line, filename)
|
||||||
|
|
||||||
|
return cls(name=theme_name, source=source, elements=elements)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse_line(cls, element, line, filename=None):
|
||||||
|
"""
|
||||||
|
Parse a single line from a theme file.
|
||||||
|
|
||||||
|
Format:
|
||||||
|
<element>: <foreground> <background> <attributes>
|
||||||
|
"""
|
||||||
|
|
||||||
|
items = line.split()
|
||||||
|
if len(items) == 2:
|
||||||
|
fg, bg, attrs = items[0], items[1], ''
|
||||||
|
elif len(items) == 3:
|
||||||
|
fg, bg, attrs = items
|
||||||
|
else:
|
||||||
|
raise ConfigError(
|
||||||
|
'Error loading {0}, invalid line:\n'
|
||||||
|
' {1} = {2}'.format(filename, element, line))
|
||||||
|
|
||||||
|
if fg.startswith('#'):
|
||||||
|
fg = cls.rgb_to_ansi(fg)
|
||||||
|
if bg.startswith('#'):
|
||||||
|
bg = cls.rgb_to_ansi(bg)
|
||||||
|
|
||||||
|
if fg not in cls.COLOR_CODES:
|
||||||
|
raise ConfigError(
|
||||||
|
'Error loading {0}, invalid <foreground>:\n'
|
||||||
|
' {1} = {2}'.format(filename, element, line))
|
||||||
|
fg_code = cls.COLOR_CODES[fg]
|
||||||
|
|
||||||
|
if bg not in cls.COLOR_CODES:
|
||||||
|
raise ConfigError(
|
||||||
|
'Error loading {0}, invalid <background>:\n'
|
||||||
|
' {1} = {2}'.format(filename, element, line))
|
||||||
|
bg_code = cls.COLOR_CODES[bg]
|
||||||
|
|
||||||
|
attrs_code = curses.A_NORMAL
|
||||||
|
for attr in attrs.split('+'):
|
||||||
|
if attr not in cls.ATTRIBUTE_CODES:
|
||||||
|
raise ConfigError(
|
||||||
|
'Error loading {0}, invalid <attributes>:\n'
|
||||||
|
' {1} = {2}'.format(filename, element, line))
|
||||||
|
attr_code = cls.ATTRIBUTE_CODES[attr]
|
||||||
|
if attr_code is None:
|
||||||
|
attrs_code = None
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
attrs_code |= attr_code
|
||||||
|
|
||||||
|
return fg_code, bg_code, attrs_code
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _set_fallback(elements, src_field, fallback, dest_field=None):
|
||||||
|
"""
|
||||||
|
Helper function used to set the fallback attributes of an element when
|
||||||
|
they are defined by the configuration as "None" or "-".
|
||||||
|
"""
|
||||||
|
|
||||||
|
if dest_field is None:
|
||||||
|
dest_field = src_field
|
||||||
|
if isinstance(fallback, six.string_types):
|
||||||
|
fallback = elements[fallback]
|
||||||
|
|
||||||
|
attrs = elements[src_field]
|
||||||
|
elements[dest_field] = (
|
||||||
|
attrs[0] if attrs[0] is not None else fallback[0],
|
||||||
|
attrs[1] if attrs[1] is not None else fallback[1],
|
||||||
|
attrs[2] if attrs[2] is not None else fallback[2])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def rgb_to_ansi(color):
|
||||||
|
"""
|
||||||
|
Converts hex RGB to the 6x6x6 xterm color space
|
||||||
|
|
||||||
|
Args:
|
||||||
|
color (str): RGB color string in the format "#RRGGBB"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: ansi color string in the format "ansi_n", where n
|
||||||
|
is between 16 and 230
|
||||||
|
|
||||||
|
Reference:
|
||||||
|
https://github.com/chadj2/bash-ui/blob/master/COLORS.md
|
||||||
|
"""
|
||||||
|
|
||||||
|
if color[0] != '#' or len(color) != 7:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = round(int(color[1:3], 16) / 51.0) # Normalize between 0-5
|
||||||
|
g = round(int(color[3:5], 16) / 51.0)
|
||||||
|
b = round(int(color[5:7], 16) / 51.0)
|
||||||
|
n = int(36 * r + 6 * g + b + 16)
|
||||||
|
return 'ansi_{0:d}'.format(n)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class ThemeList(object):
|
||||||
|
"""
|
||||||
|
This is a small container around Theme.list_themes() that can be used
|
||||||
|
to cycle through all of the available themes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.themes = None
|
||||||
|
self.errors = None
|
||||||
|
|
||||||
|
def reload(self):
|
||||||
|
"""
|
||||||
|
This acts as a lazy load, it won't read all of the theme files from
|
||||||
|
disk until the first time somebody tries to access the theme list.
|
||||||
|
"""
|
||||||
|
self.themes, self.errors = Theme.list_themes()
|
||||||
|
|
||||||
|
def _step(self, theme, direction):
|
||||||
|
"""
|
||||||
|
Traverse the list in the given direction and return the next theme
|
||||||
|
"""
|
||||||
|
if not self.themes:
|
||||||
|
self.reload()
|
||||||
|
|
||||||
|
# Try to find the starting index
|
||||||
|
key = (theme.source, theme.name)
|
||||||
|
for i, val in enumerate(self.themes):
|
||||||
|
if (val.source, val.name) == key:
|
||||||
|
index = i
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# If the theme was set from a custom source it might
|
||||||
|
# not be a part of the list returned by list_themes().
|
||||||
|
self.themes.insert(0, theme)
|
||||||
|
index = 0
|
||||||
|
|
||||||
|
index = (index + direction) % len(self.themes)
|
||||||
|
new_theme = self.themes[index]
|
||||||
|
return new_theme
|
||||||
|
|
||||||
|
def next(self, theme):
|
||||||
|
return self._step(theme, 1)
|
||||||
|
|
||||||
|
def previous(self, theme):
|
||||||
|
return self._step(theme, -1)
|
||||||
|
|||||||
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'
|
'rtv.packages.praw'
|
||||||
],
|
],
|
||||||
package_data={
|
package_data={
|
||||||
'rtv': ['templates/*'],
|
'rtv': ['templates/*', 'themes/*'],
|
||||||
'rtv.packages.praw': ['praw.ini']
|
'rtv.packages.praw': ['praw.ini']
|
||||||
},
|
},
|
||||||
data_files=[("share/man/man1", ["rtv.1"])],
|
data_files=[("share/man/man1", ["rtv.1"])],
|
||||||
|
|||||||
@@ -167,6 +167,8 @@ def stdscr():
|
|||||||
curses.color_pair.return_value = 23
|
curses.color_pair.return_value = 23
|
||||||
curses.has_colors.return_value = True
|
curses.has_colors.return_value = True
|
||||||
curses.ACS_VLINE = 0
|
curses.ACS_VLINE = 0
|
||||||
|
curses.COLORS = 256
|
||||||
|
curses.COLOR_PAIRS = 256
|
||||||
yield out
|
yield out
|
||||||
|
|
||||||
|
|
||||||
@@ -199,6 +201,7 @@ def reddit(vcr, request):
|
|||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def terminal(stdscr, config):
|
def terminal(stdscr, config):
|
||||||
term = Terminal(stdscr, config=config)
|
term = Terminal(stdscr, config=config)
|
||||||
|
term.set_theme()
|
||||||
# Disable the python 3.4 addch patch so that the mock stdscr calls are
|
# Disable the python 3.4 addch patch so that the mock stdscr calls are
|
||||||
# always made the same way
|
# always made the same way
|
||||||
term.addch = lambda window, *args: window.addch(*args)
|
term.addch = lambda window, *args: window.addch(*args)
|
||||||
|
|||||||
@@ -89,7 +89,9 @@ def test_config_get_args():
|
|||||||
'--non-persistent',
|
'--non-persistent',
|
||||||
'--clear-auth',
|
'--clear-auth',
|
||||||
'--copy-config',
|
'--copy-config',
|
||||||
'--enable-media']
|
'--enable-media',
|
||||||
|
'--theme', 'molokai',
|
||||||
|
'--list-themes']
|
||||||
|
|
||||||
with mock.patch('sys.argv', ['rtv']):
|
with mock.patch('sys.argv', ['rtv']):
|
||||||
config_dict = Config.get_args()
|
config_dict = Config.get_args()
|
||||||
@@ -111,6 +113,8 @@ def test_config_get_args():
|
|||||||
assert config['config'] == 'configfile.cfg'
|
assert config['config'] == 'configfile.cfg'
|
||||||
assert config['copy_config'] is True
|
assert config['copy_config'] is True
|
||||||
assert config['enable_media'] is True
|
assert config['enable_media'] is True
|
||||||
|
assert config['theme'] == 'molokai'
|
||||||
|
assert config['list_themes'] is True
|
||||||
|
|
||||||
|
|
||||||
def test_config_link_deprecated():
|
def test_config_link_deprecated():
|
||||||
@@ -143,7 +147,8 @@ def test_config_from_file():
|
|||||||
'subreddit': 'cfb',
|
'subreddit': 'cfb',
|
||||||
'enable_media': True,
|
'enable_media': True,
|
||||||
'max_comment_cols': 150,
|
'max_comment_cols': 150,
|
||||||
'hide_username': True}
|
'hide_username': True,
|
||||||
|
'theme': 'molokai'}
|
||||||
|
|
||||||
bindings = {
|
bindings = {
|
||||||
'REFRESH': 'r, <KEY_F5>',
|
'REFRESH': 'r, <KEY_F5>',
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import curses
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from rtv.page import Page, PageController, logged_in
|
from rtv.page import Page, PageController, logged_in
|
||||||
@@ -112,3 +114,40 @@ def test_page_authenticated(reddit, terminal, config, oauth, refresh_token):
|
|||||||
terminal.stdscr.getch.return_value = ord('y')
|
terminal.stdscr.getch.return_value = ord('y')
|
||||||
page.controller.trigger('u')
|
page.controller.trigger('u')
|
||||||
assert not reddit.is_oauth_session()
|
assert not reddit.is_oauth_session()
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_cycle_theme(reddit, terminal, config, oauth):
|
||||||
|
|
||||||
|
page = Page(reddit, terminal, config, oauth)
|
||||||
|
page.controller = PageController(page, keymap=config.keymap)
|
||||||
|
|
||||||
|
page.term.set_theme()
|
||||||
|
assert page.term.theme.name == 'default'
|
||||||
|
|
||||||
|
with mock.patch.object(terminal, 'show_notification'), \
|
||||||
|
mock.patch.object(page, 'draw'):
|
||||||
|
|
||||||
|
# Next theme
|
||||||
|
page.controller.trigger(curses.KEY_F3)
|
||||||
|
assert page.term.theme.name == 'monochrome'
|
||||||
|
terminal.show_notification.assert_called_with(
|
||||||
|
'monochrome (built-in)', timeout=1)
|
||||||
|
|
||||||
|
# Previous theme
|
||||||
|
page.controller.trigger(curses.KEY_F2)
|
||||||
|
assert page.term.theme.name == 'default'
|
||||||
|
terminal.show_notification.assert_called_with(
|
||||||
|
'default (built-in)', timeout=1)
|
||||||
|
|
||||||
|
# Previous - will loop to one of the 256 color themes
|
||||||
|
page.controller.trigger(curses.KEY_F2)
|
||||||
|
assert page.term.theme.source in ('preset', 'installed')
|
||||||
|
|
||||||
|
# Reset
|
||||||
|
page.term.set_theme()
|
||||||
|
|
||||||
|
# Will skip over any installed themes that aren't supported
|
||||||
|
curses.has_colors.return_value = False
|
||||||
|
page.controller.trigger(curses.KEY_F2)
|
||||||
|
assert page.term.theme.required_colors == 0
|
||||||
|
|
||||||
|
|||||||
@@ -575,27 +575,81 @@ def test_add_space(terminal, stdscr):
|
|||||||
|
|
||||||
def test_attr(terminal):
|
def test_attr(terminal):
|
||||||
|
|
||||||
assert terminal.attr('cursor') == 0
|
assert terminal.attr('CursorBlock') == 0
|
||||||
assert terminal.attr('cursor.selected') == curses.A_REVERSE
|
assert terminal.attr('@CursorBlock') == curses.A_REVERSE
|
||||||
assert terminal.attr('neutral_vote') == curses.A_BOLD
|
assert terminal.attr('NeutralVote') == curses.A_BOLD
|
||||||
|
|
||||||
with terminal.theme.set_modifier('selected'):
|
with terminal.theme.turn_on_selected():
|
||||||
assert terminal.attr('cursor') == curses.A_REVERSE
|
assert terminal.attr('CursorBlock') == curses.A_REVERSE
|
||||||
assert terminal.attr('neutral_vote') == curses.A_BOLD
|
assert terminal.attr('NeutralVote') == curses.A_BOLD
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_theme(terminal):
|
||||||
|
|
||||||
|
monochrome = Theme(use_color=False)
|
||||||
|
default = Theme()
|
||||||
|
color256 = Theme.from_name('molokai')
|
||||||
|
|
||||||
|
curses.has_colors.return_value = False
|
||||||
|
assert terminal.check_theme(monochrome)
|
||||||
|
assert not terminal.check_theme(default)
|
||||||
|
assert not terminal.check_theme(color256)
|
||||||
|
|
||||||
|
curses.has_colors.return_value = True
|
||||||
|
curses.COLORS = 0
|
||||||
|
assert terminal.check_theme(monochrome)
|
||||||
|
assert not terminal.check_theme(default)
|
||||||
|
assert not terminal.check_theme(color256)
|
||||||
|
|
||||||
|
curses.COLORS = 8
|
||||||
|
assert terminal.check_theme(monochrome)
|
||||||
|
assert terminal.check_theme(default)
|
||||||
|
assert not terminal.check_theme(color256)
|
||||||
|
|
||||||
|
curses.COLORS = 256
|
||||||
|
assert terminal.check_theme(monochrome)
|
||||||
|
assert terminal.check_theme(default)
|
||||||
|
assert terminal.check_theme(color256)
|
||||||
|
|
||||||
|
curses.COLOR_PAIRS = 8
|
||||||
|
assert terminal.check_theme(monochrome)
|
||||||
|
assert terminal.check_theme(default)
|
||||||
|
assert not terminal.check_theme(color256)
|
||||||
|
|
||||||
|
|
||||||
def test_set_theme(terminal, stdscr):
|
def test_set_theme(terminal, stdscr):
|
||||||
|
|
||||||
|
# Default with color enabled
|
||||||
stdscr.reset_mock()
|
stdscr.reset_mock()
|
||||||
terminal.set_theme()
|
terminal.set_theme()
|
||||||
assert not terminal.theme.monochrome
|
assert terminal.theme.use_color
|
||||||
|
assert terminal.theme.display_string == 'default (built-in)'
|
||||||
stdscr.bkgd.assert_called_once_with(' ', 0)
|
stdscr.bkgd.assert_called_once_with(' ', 0)
|
||||||
|
|
||||||
stdscr.reset_mock()
|
# When the user passes in the --monochrome flag
|
||||||
theme = Theme(monochrome=True)
|
terminal.theme = None
|
||||||
terminal.set_theme(theme=theme)
|
terminal.set_theme(Theme(use_color=False))
|
||||||
assert terminal.theme.monochrome
|
assert not terminal.theme.use_color
|
||||||
stdscr.bkgd.assert_called_once_with(' ', 0)
|
assert terminal.theme.display_string == 'monochrome (built-in)'
|
||||||
|
|
||||||
|
# When the terminal doesn't support colors
|
||||||
|
curses.COLORS = 0
|
||||||
|
terminal.theme = None
|
||||||
|
terminal.set_theme()
|
||||||
|
assert terminal.theme.display_string == 'monochrome (built-in)'
|
||||||
|
|
||||||
|
# When the terminal doesn't support 256 colors so it falls back to the
|
||||||
|
# built-in default theme
|
||||||
|
curses.COLORS = 8
|
||||||
|
terminal.theme = None
|
||||||
|
terminal.set_theme(Theme.from_name('molokai'))
|
||||||
|
assert terminal.theme.display_string == 'default (built-in)'
|
||||||
|
|
||||||
|
# When the terminal does support the 256 color theme
|
||||||
|
curses.COLORS = 256
|
||||||
|
terminal.theme = None
|
||||||
|
terminal.set_theme(Theme.from_name('molokai'))
|
||||||
|
assert terminal.theme.display_string == 'molokai (preset)'
|
||||||
|
|
||||||
|
|
||||||
def test_set_theme_no_colors(terminal, stdscr):
|
def test_set_theme_no_colors(terminal, stdscr):
|
||||||
@@ -605,8 +659,7 @@ def test_set_theme_no_colors(terminal, stdscr):
|
|||||||
has_colors.return_value = False
|
has_colors.return_value = False
|
||||||
|
|
||||||
terminal.set_theme()
|
terminal.set_theme()
|
||||||
assert terminal.theme.monochrome
|
assert not terminal.theme.use_color
|
||||||
|
|
||||||
theme = Theme(monochrome=False)
|
terminal.set_theme(Theme(use_color=True))
|
||||||
terminal.set_theme(theme=theme)
|
assert not terminal.theme.use_color
|
||||||
assert terminal.theme.monochrome
|
|
||||||
276
tests/test_theme.py
Normal file
@@ -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
|
||||||