Working on theme support
This commit is contained in:
@@ -27,9 +27,10 @@ from . import docs
|
|||||||
from . import packages
|
from . import packages
|
||||||
from .packages import praw
|
from .packages import praw
|
||||||
from .config import Config, copy_default_config, copy_default_mailcap
|
from .config import Config, copy_default_config, copy_default_mailcap
|
||||||
|
from .theme import Theme
|
||||||
from .oauth import OAuthHelper
|
from .oauth import OAuthHelper
|
||||||
from .terminal import Terminal
|
from .terminal import Terminal
|
||||||
from .objects import curses_session, Color
|
from .objects import curses_session
|
||||||
from .subreddit_page import SubredditPage
|
from .subreddit_page import SubredditPage
|
||||||
from .exceptions import ConfigError
|
from .exceptions import ConfigError
|
||||||
from .__version__ import __version__
|
from .__version__ import __version__
|
||||||
@@ -81,6 +82,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()
|
||||||
|
|
||||||
@@ -148,11 +153,13 @@ def main():
|
|||||||
try:
|
try:
|
||||||
with curses_session() as stdscr:
|
with curses_session() as stdscr:
|
||||||
|
|
||||||
# Initialize global color-pairs with curses
|
if config['theme']:
|
||||||
if not config['monochrome']:
|
theme = Theme.from_name(config['theme'], config['monochrome'])
|
||||||
Color.init()
|
else:
|
||||||
|
theme = Theme(monochrome=config['monochrome'])
|
||||||
|
|
||||||
|
term = Terminal(stdscr, config, theme)
|
||||||
|
|
||||||
term = Terminal(stdscr, config)
|
|
||||||
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,
|
||||||
decode_html_entities=False,
|
decode_html_entities=False,
|
||||||
|
|||||||
@@ -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():
|
||||||
@@ -51,6 +54,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',
|
'--non-persistent', dest='persistent', action='store_const',
|
||||||
const=False,
|
const=False,
|
||||||
@@ -133,6 +142,9 @@ class OrderedSet(object):
|
|||||||
|
|
||||||
|
|
||||||
class Config(object):
|
class Config(object):
|
||||||
|
"""
|
||||||
|
This class manages the loading and saving of configs and other files.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, history_file=HISTORY, token_file=TOKEN, **kwargs):
|
def __init__(self, history_file=HISTORY, token_file=TOKEN, **kwargs):
|
||||||
|
|
||||||
|
|||||||
@@ -203,7 +203,8 @@ class LoadScreen(object):
|
|||||||
for e_type, message in self.EXCEPTION_MESSAGES:
|
for e_type, message in self.EXCEPTION_MESSAGES:
|
||||||
# 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):
|
||||||
self._terminal.show_notification(message.format(e))
|
msg = message.format(e)
|
||||||
|
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):
|
||||||
@@ -223,12 +224,16 @@ class LoadScreen(object):
|
|||||||
return
|
return
|
||||||
time.sleep(0.01)
|
time.sleep(0.01)
|
||||||
|
|
||||||
# Build the notification window
|
# Build the notification window. Note that we need to use
|
||||||
|
# curses.newwin() instead of stdscr.derwin() so the text below the
|
||||||
|
# notification window does not got erased when we cover it up.
|
||||||
message_len = len(message) + len(trail)
|
message_len = len(message) + len(trail)
|
||||||
n_rows, n_cols = self._terminal.stdscr.getmaxyx()
|
n_rows, n_cols = self._terminal.stdscr.getmaxyx()
|
||||||
s_row = (n_rows - 3) // 2
|
v_offset, h_offset = self._terminal.stdscr.getbegyx()
|
||||||
s_col = (n_cols - message_len - 1) // 2
|
s_row = (n_rows - 3) // 2 + v_offset
|
||||||
|
s_col = (n_cols - message_len - 1) // 2 + h_offset
|
||||||
window = curses.newwin(3, message_len + 2, s_row, s_col)
|
window = curses.newwin(3, message_len + 2, s_row, s_col)
|
||||||
|
window.bkgd(str(' '), self._terminal.attr('notice_loading'))
|
||||||
|
|
||||||
# Animate the loading prompt until the stopping condition is triggered
|
# Animate the loading prompt until the stopping condition is triggered
|
||||||
# when the context manager exits.
|
# when the context manager exits.
|
||||||
@@ -258,49 +263,6 @@ class LoadScreen(object):
|
|||||||
time.sleep(0.01)
|
time.sleep(0.01)
|
||||||
|
|
||||||
|
|
||||||
class Color(object):
|
|
||||||
"""
|
|
||||||
Color attributes for curses.
|
|
||||||
"""
|
|
||||||
|
|
||||||
RED = curses.A_NORMAL
|
|
||||||
GREEN = curses.A_NORMAL
|
|
||||||
YELLOW = curses.A_NORMAL
|
|
||||||
BLUE = curses.A_NORMAL
|
|
||||||
MAGENTA = curses.A_NORMAL
|
|
||||||
CYAN = curses.A_NORMAL
|
|
||||||
WHITE = curses.A_NORMAL
|
|
||||||
|
|
||||||
_colors = {
|
|
||||||
'RED': (curses.COLOR_RED, -1),
|
|
||||||
'GREEN': (curses.COLOR_GREEN, -1),
|
|
||||||
'YELLOW': (curses.COLOR_YELLOW, -1),
|
|
||||||
'BLUE': (curses.COLOR_BLUE, -1),
|
|
||||||
'MAGENTA': (curses.COLOR_MAGENTA, -1),
|
|
||||||
'CYAN': (curses.COLOR_CYAN, -1),
|
|
||||||
'WHITE': (curses.COLOR_WHITE, -1),
|
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def init(cls):
|
|
||||||
"""
|
|
||||||
Initialize color pairs inside of curses using the default background.
|
|
||||||
|
|
||||||
This should be called once during the curses initial setup. Afterwards,
|
|
||||||
curses color pairs can be accessed directly through class attributes.
|
|
||||||
"""
|
|
||||||
|
|
||||||
for index, (attr, code) in enumerate(cls._colors.items(), start=1):
|
|
||||||
curses.init_pair(index, code[0], code[1])
|
|
||||||
setattr(cls, attr, curses.color_pair(index))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_level(cls, level):
|
|
||||||
|
|
||||||
levels = [cls.MAGENTA, cls.CYAN, cls.GREEN, cls.YELLOW]
|
|
||||||
return levels[level % len(levels)]
|
|
||||||
|
|
||||||
|
|
||||||
class Navigator(object):
|
class Navigator(object):
|
||||||
"""
|
"""
|
||||||
Handles the math behind cursor movement and screen paging.
|
Handles the math behind cursor movement and screen paging.
|
||||||
|
|||||||
42
rtv/page.py
42
rtv/page.py
@@ -12,7 +12,7 @@ import six
|
|||||||
from kitchen.text.display import textual_width
|
from kitchen.text.display import textual_width
|
||||||
|
|
||||||
from . import docs
|
from . import docs
|
||||||
from .objects import Controller, Color, Command
|
from .objects import Controller, Command
|
||||||
from .clipboard import copy
|
from .clipboard import copy
|
||||||
from .exceptions import TemporaryFileError, ProgramError
|
from .exceptions import TemporaryFileError, ProgramError
|
||||||
from .__version__ import __version__
|
from .__version__ import __version__
|
||||||
@@ -171,19 +171,15 @@ class Page(object):
|
|||||||
|
|
||||||
@PageController.register(Command('PAGE_TOP'))
|
@PageController.register(Command('PAGE_TOP'))
|
||||||
def move_page_top(self):
|
def move_page_top(self):
|
||||||
self._remove_cursor()
|
|
||||||
self.nav.page_index = self.content.range[0]
|
self.nav.page_index = self.content.range[0]
|
||||||
self.nav.cursor_index = 0
|
self.nav.cursor_index = 0
|
||||||
self.nav.inverted = False
|
self.nav.inverted = False
|
||||||
self._add_cursor()
|
|
||||||
|
|
||||||
@PageController.register(Command('PAGE_BOTTOM'))
|
@PageController.register(Command('PAGE_BOTTOM'))
|
||||||
def move_page_bottom(self):
|
def move_page_bottom(self):
|
||||||
self._remove_cursor()
|
|
||||||
self.nav.page_index = self.content.range[1]
|
self.nav.page_index = self.content.range[1]
|
||||||
self.nav.cursor_index = 0
|
self.nav.cursor_index = 0
|
||||||
self.nav.inverted = True
|
self.nav.inverted = True
|
||||||
self._add_cursor()
|
|
||||||
|
|
||||||
@PageController.register(Command('UPVOTE'))
|
@PageController.register(Command('UPVOTE'))
|
||||||
@logged_in
|
@logged_in
|
||||||
@@ -401,8 +397,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
|
||||||
ch, attr = str(' '), curses.A_REVERSE | curses.A_BOLD | Color.CYAN
|
window.bkgd(str(' '), self.term.attr('title_bar'))
|
||||||
window.bkgd(ch, attr)
|
|
||||||
|
|
||||||
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')
|
||||||
@@ -430,7 +425,7 @@ class Page(object):
|
|||||||
sys.stdout.write(title)
|
sys.stdout.write(title)
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
|
|
||||||
if self.reddit.user is not None:
|
if self.reddit and self.reddit.user is not None:
|
||||||
# The starting position of the name depends on if we're converting
|
# The starting position of the name depends on if we're converting
|
||||||
# to ascii or not
|
# to ascii or not
|
||||||
width = len if self.config['ascii'] else textual_width
|
width = len if self.config['ascii'] else textual_width
|
||||||
@@ -451,8 +446,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()
|
||||||
ch, attr = str(' '), curses.A_BOLD | Color.YELLOW
|
window.bkgd(str(' '), self.term.attr('order_bar'))
|
||||||
window.bkgd(ch, attr)
|
|
||||||
|
|
||||||
items = docs.BANNER.strip().split(' ')
|
items = docs.BANNER.strip().split(' ')
|
||||||
distance = (n_cols - sum(len(t) for t in items) - 1) / (len(items) - 1)
|
distance = (n_cols - sum(len(t) for t in items) - 1) / (len(items) - 1)
|
||||||
@@ -462,7 +456,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
|
||||||
window.chgat(0, col, 3, attr | curses.A_REVERSE)
|
window.chgat(0, col, 3, self.term.attr('order_selected'))
|
||||||
|
|
||||||
self._row += 1
|
self._row += 1
|
||||||
|
|
||||||
@@ -502,6 +496,7 @@ class Page(object):
|
|||||||
start = current_row - subwin_n_rows + 1 if inverted else current_row
|
start = current_row - subwin_n_rows + 1 if inverted else current_row
|
||||||
subwindow = window.derwin(
|
subwindow = window.derwin(
|
||||||
subwin_n_rows, subwin_n_cols, start, data['h_offset'])
|
subwin_n_rows, subwin_n_cols, start, data['h_offset'])
|
||||||
|
|
||||||
attr = self._draw_item(subwindow, data, subwin_inverted)
|
attr = self._draw_item(subwindow, data, subwin_inverted)
|
||||||
self._subwindows.append((subwindow, attr))
|
self._subwindows.append((subwindow, attr))
|
||||||
available_rows -= (subwin_n_rows + 1) # Add one for the blank line
|
available_rows -= (subwin_n_rows + 1) # Add one for the blank line
|
||||||
@@ -532,36 +527,25 @@ 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()
|
||||||
ch, attr = str(' '), curses.A_REVERSE | curses.A_BOLD | Color.CYAN
|
window.bkgd(str(' '), self.term.attr('help_bar'))
|
||||||
window.bkgd(ch, attr)
|
|
||||||
|
|
||||||
text = self.FOOTER.strip()
|
text = self.FOOTER.strip()
|
||||||
self.term.add_line(window, text, 0, 0)
|
self.term.add_line(window, text, 0, 0)
|
||||||
self._row += 1
|
self._row += 1
|
||||||
|
|
||||||
def _add_cursor(self):
|
|
||||||
self._edit_cursor(curses.A_REVERSE)
|
|
||||||
|
|
||||||
def _remove_cursor(self):
|
|
||||||
self._edit_cursor(curses.A_NORMAL)
|
|
||||||
|
|
||||||
def _move_cursor(self, direction):
|
def _move_cursor(self, direction):
|
||||||
self._remove_cursor()
|
|
||||||
# Note: ACS_VLINE doesn't like changing the attribute, so disregard the
|
# Note: ACS_VLINE doesn't like changing the attribute, so disregard the
|
||||||
# redraw flag and opt to always redraw
|
# redraw flag and opt to always redraw
|
||||||
valid, redraw = self.nav.move(direction, len(self._subwindows))
|
valid, redraw = self.nav.move(direction, len(self._subwindows))
|
||||||
if not valid:
|
if not valid:
|
||||||
self.term.flash()
|
self.term.flash()
|
||||||
self._add_cursor()
|
|
||||||
|
|
||||||
def _move_page(self, direction):
|
def _move_page(self, direction):
|
||||||
self._remove_cursor()
|
|
||||||
valid, redraw = self.nav.move_page(direction, len(self._subwindows)-1)
|
valid, redraw = self.nav.move_page(direction, len(self._subwindows)-1)
|
||||||
if not valid:
|
if not valid:
|
||||||
self.term.flash()
|
self.term.flash()
|
||||||
self._add_cursor()
|
|
||||||
|
|
||||||
def _edit_cursor(self, attribute):
|
def _add_cursor(self):
|
||||||
|
|
||||||
# Don't allow the cursor to go below page index 0
|
# Don't allow the cursor to go below page index 0
|
||||||
if self.nav.absolute_index < 0:
|
if self.nav.absolute_index < 0:
|
||||||
@@ -573,10 +557,12 @@ class Page(object):
|
|||||||
if self.nav.cursor_index >= len(self._subwindows):
|
if self.nav.cursor_index >= len(self._subwindows):
|
||||||
self.nav.cursor_index = len(self._subwindows) - 1
|
self.nav.cursor_index = len(self._subwindows) - 1
|
||||||
|
|
||||||
window, attr = self._subwindows[self.nav.cursor_index]
|
window, cursor_attr = self._subwindows[self.nav.cursor_index]
|
||||||
if attr is not None:
|
if cursor_attr is None:
|
||||||
attribute |= attr
|
attr = self.term.attr('cursor')
|
||||||
|
else:
|
||||||
|
attr = cursor_attr | curses.A_REVERSE
|
||||||
|
|
||||||
n_rows, _ = window.getmaxyx()
|
n_rows, _ = window.getmaxyx()
|
||||||
for row in range(n_rows):
|
for row in range(n_rows):
|
||||||
window.chgat(row, 0, 1, attribute)
|
window.chgat(row, 0, 1, attr)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import curses
|
|||||||
from . import docs
|
from . import docs
|
||||||
from .content import SubmissionContent, SubredditContent
|
from .content import SubmissionContent, SubredditContent
|
||||||
from .page import Page, PageController, logged_in
|
from .page import Page, PageController, logged_in
|
||||||
from .objects import Navigator, Color, Command
|
from .objects import Navigator, Command
|
||||||
from .exceptions import TemporaryFileError
|
from .exceptions import TemporaryFileError
|
||||||
|
|
||||||
|
|
||||||
@@ -38,7 +38,9 @@ class SubmissionPage(Page):
|
|||||||
|
|
||||||
@SubmissionController.register(Command('SUBMISSION_TOGGLE_COMMENT'))
|
@SubmissionController.register(Command('SUBMISSION_TOGGLE_COMMENT'))
|
||||||
def toggle_comment(self):
|
def toggle_comment(self):
|
||||||
"Toggle the selected comment tree between visible and hidden"
|
"""
|
||||||
|
Toggle the selected comment tree between visible and hidden
|
||||||
|
"""
|
||||||
|
|
||||||
current_index = self.nav.absolute_index
|
current_index = self.nav.absolute_index
|
||||||
self.content.toggle(current_index)
|
self.content.toggle(current_index)
|
||||||
@@ -58,13 +60,17 @@ class SubmissionPage(Page):
|
|||||||
|
|
||||||
@SubmissionController.register(Command('SUBMISSION_EXIT'))
|
@SubmissionController.register(Command('SUBMISSION_EXIT'))
|
||||||
def exit_submission(self):
|
def exit_submission(self):
|
||||||
"Close the submission and return to the subreddit page"
|
"""
|
||||||
|
Close the submission and return to the subreddit page
|
||||||
|
"""
|
||||||
|
|
||||||
self.active = False
|
self.active = False
|
||||||
|
|
||||||
@SubmissionController.register(Command('REFRESH'))
|
@SubmissionController.register(Command('REFRESH'))
|
||||||
def refresh_content(self, order=None, name=None):
|
def refresh_content(self, order=None, name=None):
|
||||||
"Re-download comments and reset the page index"
|
"""
|
||||||
|
Re-download comments and reset the page index
|
||||||
|
"""
|
||||||
|
|
||||||
order = order or self.content.order
|
order = order or self.content.order
|
||||||
url = name or self.content.name
|
url = name or self.content.name
|
||||||
@@ -78,7 +84,9 @@ class SubmissionPage(Page):
|
|||||||
|
|
||||||
@SubmissionController.register(Command('PROMPT'))
|
@SubmissionController.register(Command('PROMPT'))
|
||||||
def prompt_subreddit(self):
|
def prompt_subreddit(self):
|
||||||
"Open a prompt to navigate to a different subreddit"
|
"""
|
||||||
|
Open a prompt to navigate to a different subreddit
|
||||||
|
"""
|
||||||
|
|
||||||
name = self.term.prompt_input('Enter page: /')
|
name = self.term.prompt_input('Enter page: /')
|
||||||
if name is not None:
|
if name is not None:
|
||||||
@@ -91,7 +99,9 @@ class SubmissionPage(Page):
|
|||||||
|
|
||||||
@SubmissionController.register(Command('SUBMISSION_OPEN_IN_BROWSER'))
|
@SubmissionController.register(Command('SUBMISSION_OPEN_IN_BROWSER'))
|
||||||
def open_link(self):
|
def open_link(self):
|
||||||
"Open the selected item with the webbrowser"
|
"""
|
||||||
|
Open the selected item with the web browser
|
||||||
|
"""
|
||||||
|
|
||||||
data = self.get_selected_item()
|
data = self.get_selected_item()
|
||||||
if data['type'] == 'Submission':
|
if data['type'] == 'Submission':
|
||||||
@@ -104,7 +114,9 @@ class SubmissionPage(Page):
|
|||||||
|
|
||||||
@SubmissionController.register(Command('SUBMISSION_OPEN_IN_PAGER'))
|
@SubmissionController.register(Command('SUBMISSION_OPEN_IN_PAGER'))
|
||||||
def open_pager(self):
|
def open_pager(self):
|
||||||
"Open the selected item with the system's pager"
|
"""
|
||||||
|
Open the selected item with the system's pager
|
||||||
|
"""
|
||||||
|
|
||||||
data = self.get_selected_item()
|
data = self.get_selected_item()
|
||||||
if data['type'] == 'Submission':
|
if data['type'] == 'Submission':
|
||||||
@@ -165,7 +177,9 @@ class SubmissionPage(Page):
|
|||||||
@SubmissionController.register(Command('DELETE'))
|
@SubmissionController.register(Command('DELETE'))
|
||||||
@logged_in
|
@logged_in
|
||||||
def delete_comment(self):
|
def delete_comment(self):
|
||||||
"Delete the selected comment"
|
"""
|
||||||
|
Delete the selected comment
|
||||||
|
"""
|
||||||
|
|
||||||
if self.get_selected_item()['type'] == 'Comment':
|
if self.get_selected_item()['type'] == 'Comment':
|
||||||
self.delete_item()
|
self.delete_item()
|
||||||
@@ -174,6 +188,10 @@ class SubmissionPage(Page):
|
|||||||
|
|
||||||
@SubmissionController.register(Command('SUBMISSION_OPEN_IN_URLVIEWER'))
|
@SubmissionController.register(Command('SUBMISSION_OPEN_IN_URLVIEWER'))
|
||||||
def comment_urlview(self):
|
def comment_urlview(self):
|
||||||
|
"""
|
||||||
|
Open the selected comment with the URL viewer
|
||||||
|
"""
|
||||||
|
|
||||||
data = self.get_selected_item()
|
data = self.get_selected_item()
|
||||||
comment = data.get('body') or data.get('text') or data.get('url_full')
|
comment = data.get('body') or data.get('text') or data.get('url_full')
|
||||||
if comment:
|
if comment:
|
||||||
@@ -213,38 +231,52 @@ class SubmissionPage(Page):
|
|||||||
|
|
||||||
row = offset
|
row = offset
|
||||||
if row in valid_rows:
|
if row in valid_rows:
|
||||||
|
if data['is_author']:
|
||||||
attr = curses.A_BOLD
|
attr = self.term.attr('comment_author_self')
|
||||||
attr |= (Color.BLUE if not data['is_author'] else Color.GREEN)
|
else:
|
||||||
|
attr = self.term.attr('comment_author')
|
||||||
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 = curses.A_BOLD | Color.YELLOW
|
attr = self.term.attr('user_flair')
|
||||||
|
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)
|
||||||
|
|
||||||
text, attr = self.term.get_arrow(data['likes'])
|
arrow, attr = self.term.get_arrow(data['likes'])
|
||||||
self.term.add_line(win, text, attr=attr)
|
self.term.add_space(win)
|
||||||
self.term.add_line(win, ' {score} {created} '.format(**data))
|
self.term.add_line(win, arrow, attr=attr)
|
||||||
|
|
||||||
|
attr = self.term.attr('score')
|
||||||
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, '{score}'.format(**data), attr=attr)
|
||||||
|
|
||||||
|
attr = self.term.attr('created')
|
||||||
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, '{created}'.format(**data), attr=attr)
|
||||||
|
|
||||||
if data['gold']:
|
if data['gold']:
|
||||||
text, attr = self.term.guilded
|
attr = self.term.attr('gold')
|
||||||
self.term.add_line(win, text, attr=attr)
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, self.term.guilded, attr=attr)
|
||||||
|
|
||||||
if data['stickied']:
|
if data['stickied']:
|
||||||
text, attr = '[stickied]', Color.GREEN
|
attr = self.term.attr('stickied')
|
||||||
self.term.add_line(win, text, attr=attr)
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, '[stickied]', attr=attr)
|
||||||
|
|
||||||
if data['saved']:
|
if data['saved']:
|
||||||
text, attr = '[saved]', Color.GREEN
|
attr = self.term.attr('saved')
|
||||||
self.term.add_line(win, text, attr=attr)
|
self.term.add_space(win)
|
||||||
|
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')
|
||||||
if row in valid_rows:
|
if row in valid_rows:
|
||||||
self.term.add_line(win, text, row, 1)
|
self.term.add_line(win, text, row, 1, attr=attr)
|
||||||
|
|
||||||
# Unfortunately vline() doesn't support custom color so we have to
|
# Unfortunately vline() doesn't support custom color so we have to
|
||||||
# build it one segment at a time.
|
# build it one segment at a time.
|
||||||
attr = Color.get_level(data['level'])
|
attr = self.term.theme.get_bar_level(data['level'])
|
||||||
x = 0
|
x = 0
|
||||||
for y in range(n_rows):
|
for y in range(n_rows):
|
||||||
self.term.addch(win, y, x, self.term.vline, attr)
|
self.term.addch(win, y, x, self.term.vline, attr)
|
||||||
@@ -256,11 +288,14 @@ class SubmissionPage(Page):
|
|||||||
n_rows, n_cols = win.getmaxyx()
|
n_rows, n_cols = win.getmaxyx()
|
||||||
n_cols -= 1
|
n_cols -= 1
|
||||||
|
|
||||||
self.term.add_line(win, '{body}'.format(**data), 0, 1)
|
attr = self.term.attr('hidden_comment_text')
|
||||||
self.term.add_line(
|
self.term.add_line(win, '{body}'.format(**data), 0, 1, attr=attr)
|
||||||
win, ' [{count}]'.format(**data), attr=curses.A_BOLD)
|
|
||||||
|
|
||||||
attr = Color.get_level(data['level'])
|
attr = self.term.attr('hidden_comment_expand')
|
||||||
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, '[{count}]'.format(**data), attr=attr)
|
||||||
|
|
||||||
|
attr = self.term.theme.get_bar_level(data['level'])
|
||||||
self.term.addch(win, 0, 0, self.term.vline, attr)
|
self.term.addch(win, 0, 0, self.term.vline, attr)
|
||||||
|
|
||||||
return attr | self.term.vline
|
return attr | self.term.vline
|
||||||
@@ -270,22 +305,34 @@ 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')
|
||||||
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, curses.A_BOLD)
|
self.term.add_line(win, text, row, 1, attr)
|
||||||
|
|
||||||
row = len(data['split_title']) + 1
|
row = len(data['split_title']) + 1
|
||||||
attr = curses.A_BOLD | Color.GREEN
|
attr = self.term.attr('submission_author')
|
||||||
self.term.add_line(win, '{author}'.format(**data), row, 1, attr)
|
self.term.add_line(win, '{author}'.format(**data), row, 1, attr)
|
||||||
attr = curses.A_BOLD | Color.YELLOW
|
|
||||||
if data['flair']:
|
if data['flair']:
|
||||||
|
attr = self.term.attr('submission_flair')
|
||||||
|
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)
|
||||||
self.term.add_line(win, ' {created} {subreddit}'.format(**data))
|
|
||||||
|
attr = self.term.attr('created')
|
||||||
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, '{created}'.format(**data), attr=attr)
|
||||||
|
|
||||||
|
attr = self.term.attr('submission_subreddit')
|
||||||
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, '/r/{subreddit}'.format(**data), attr=attr)
|
||||||
|
|
||||||
row = len(data['split_title']) + 2
|
row = len(data['split_title']) + 2
|
||||||
seen = (data['url_full'] in self.config.history)
|
if data['url_full'] in self.config.history:
|
||||||
link_color = Color.MAGENTA if seen else Color.BLUE
|
attr = self.term.attr('url_seen')
|
||||||
attr = curses.A_UNDERLINE | link_color
|
else:
|
||||||
|
attr = self.term.attr('url')
|
||||||
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
|
||||||
|
|
||||||
# Cut off text if there is not enough room to display the whole post
|
# Cut off text if there is not enough room to display the whole post
|
||||||
@@ -295,25 +342,35 @@ 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')
|
||||||
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)
|
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
|
||||||
self.term.add_line(win, '{score} '.format(**data), row, 1)
|
attr = self.term.attr('score')
|
||||||
text, attr = self.term.get_arrow(data['likes'])
|
self.term.add_line(win, '{score}'.format(**data), row, 1, attr=attr)
|
||||||
self.term.add_line(win, text, attr=attr)
|
|
||||||
self.term.add_line(win, ' {comments} '.format(**data))
|
arrow, attr = self.term.get_arrow(data['likes'])
|
||||||
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, arrow, attr=attr)
|
||||||
|
|
||||||
|
attr = self.term.attr('comment_count')
|
||||||
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, '{comments}'.format(**data), attr=attr)
|
||||||
|
|
||||||
if data['gold']:
|
if data['gold']:
|
||||||
text, attr = self.term.guilded
|
attr = self.term.attr('gold')
|
||||||
self.term.add_line(win, text, attr=attr)
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, self.term.guilded, attr=attr)
|
||||||
|
|
||||||
if data['nsfw']:
|
if data['nsfw']:
|
||||||
text, attr = 'NSFW', (curses.A_BOLD | Color.RED)
|
attr = self.term.attr('nsfw')
|
||||||
self.term.add_line(win, text, attr=attr)
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, 'NSFW', attr=attr)
|
||||||
|
|
||||||
if data['saved']:
|
if data['saved']:
|
||||||
text, attr = '[saved]', Color.GREEN
|
attr = self.term.attr('saved')
|
||||||
self.term.add_line(win, text, attr=attr)
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, '[saved]', attr=attr)
|
||||||
|
|
||||||
win.border()
|
win.border()
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import curses
|
|||||||
from . import docs
|
from . import docs
|
||||||
from .content import SubredditContent
|
from .content import SubredditContent
|
||||||
from .page import Page, PageController, logged_in
|
from .page import Page, PageController, logged_in
|
||||||
from .objects import Navigator, Color, Command
|
from .objects import Navigator, Command
|
||||||
from .submission_page import SubmissionPage
|
from .submission_page import SubmissionPage
|
||||||
from .subscription_page import SubscriptionPage
|
from .subscription_page import SubscriptionPage
|
||||||
from .exceptions import TemporaryFileError
|
from .exceptions import TemporaryFileError
|
||||||
@@ -244,50 +244,71 @@ 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')
|
||||||
if row in valid_rows:
|
if row in valid_rows:
|
||||||
self.term.add_line(win, text, row, 1, curses.A_BOLD)
|
self.term.add_line(win, text, row, 1, attr)
|
||||||
|
|
||||||
row = n_title + offset
|
row = n_title + offset
|
||||||
if row in valid_rows:
|
if row in valid_rows:
|
||||||
seen = (data['url_full'] in self.config.history)
|
if data['url_full'] in self.config.history:
|
||||||
link_color = Color.MAGENTA if seen else Color.BLUE
|
attr = self.term.attr('url_seen')
|
||||||
attr = curses.A_UNDERLINE | link_color
|
else:
|
||||||
|
attr = self.term.attr('url')
|
||||||
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:
|
||||||
self.term.add_line(win, '{score} '.format(**data), row, 1)
|
|
||||||
text, attr = self.term.get_arrow(data['likes'])
|
attr = self.term.attr('score')
|
||||||
self.term.add_line(win, text, attr=attr)
|
self.term.add_line(win, '{score}'.format(**data), row, 1, attr)
|
||||||
self.term.add_line(win, ' {created} '.format(**data))
|
self.term.add_space(win)
|
||||||
|
|
||||||
|
arrow, attr = self.term.get_arrow(data['likes'])
|
||||||
|
self.term.add_line(win, arrow, attr=attr)
|
||||||
|
self.term.add_space(win)
|
||||||
|
|
||||||
|
attr = self.term.attr('created')
|
||||||
|
self.term.add_line(win, '{created}'.format(**data), attr=attr)
|
||||||
|
|
||||||
if data['comments'] is not None:
|
if data['comments'] is not None:
|
||||||
text, attr = '-', curses.A_BOLD
|
attr = self.term.attr('separator')
|
||||||
self.term.add_line(win, text, attr=attr)
|
self.term.add_space(win)
|
||||||
self.term.add_line(win, ' {comments} '.format(**data))
|
self.term.add_line(win, '-', attr=attr)
|
||||||
|
|
||||||
|
attr = self.term.attr('comment_count')
|
||||||
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, '{comments}'.format(**data), attr=attr)
|
||||||
|
|
||||||
if data['saved']:
|
if data['saved']:
|
||||||
text, attr = '[saved]', Color.GREEN
|
attr = self.term.attr('saved')
|
||||||
self.term.add_line(win, text, attr=attr)
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, '[saved]', attr=attr)
|
||||||
|
|
||||||
if data['stickied']:
|
if data['stickied']:
|
||||||
text, attr = '[stickied]', Color.GREEN
|
attr = self.term.attr('stickied')
|
||||||
self.term.add_line(win, text, attr=attr)
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, '[stickied]', attr=attr)
|
||||||
|
|
||||||
if data['gold']:
|
if data['gold']:
|
||||||
text, attr = self.term.guilded
|
attr = self.term.attr('gold')
|
||||||
self.term.add_line(win, text, attr=attr)
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, self.term.guilded, attr=attr)
|
||||||
|
|
||||||
if data['nsfw']:
|
if data['nsfw']:
|
||||||
text, attr = 'NSFW', (curses.A_BOLD | Color.RED)
|
attr = self.term.attr('nsfw')
|
||||||
self.term.add_line(win, text, attr=attr)
|
self.term.add_space(win)
|
||||||
|
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:
|
||||||
text = '{author}'.format(**data)
|
attr = self.term.attr('submission_author')
|
||||||
self.term.add_line(win, text, row, 1, Color.GREEN)
|
self.term.add_line(win, '{author}'.format(**data), row, 1, attr)
|
||||||
text = ' /r/{subreddit}'.format(**data)
|
self.term.add_space(win)
|
||||||
self.term.add_line(win, text, attr=Color.YELLOW)
|
|
||||||
|
attr = self.term.attr('submission_subreddit')
|
||||||
|
self.term.add_line(win, '/r/{subreddit}'.format(**data), attr=attr)
|
||||||
|
|
||||||
if data['flair']:
|
if data['flair']:
|
||||||
text = ' {flair}'.format(**data)
|
attr = self.term.attr('submission_flair')
|
||||||
self.term.add_line(win, text, attr=Color.RED)
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, '{flair}'.format(**data), attr=attr)
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
import curses
|
|
||||||
|
|
||||||
from . import docs
|
from . import docs
|
||||||
from .page import Page, PageController
|
from .page import Page, PageController
|
||||||
from .content import SubscriptionContent, SubredditContent
|
from .content import SubscriptionContent, SubredditContent
|
||||||
from .objects import Color, Navigator, Command
|
from .objects import Navigator, Command
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionController(PageController):
|
class SubscriptionController(PageController):
|
||||||
@@ -29,7 +27,9 @@ class SubscriptionPage(Page):
|
|||||||
|
|
||||||
@SubscriptionController.register(Command('REFRESH'))
|
@SubscriptionController.register(Command('REFRESH'))
|
||||||
def refresh_content(self, order=None, name=None):
|
def refresh_content(self, order=None, name=None):
|
||||||
"Re-download all subscriptions and reset the page index"
|
"""
|
||||||
|
Re-download all subscriptions and reset the page index
|
||||||
|
"""
|
||||||
|
|
||||||
# reddit.get_my_subreddits() does not support sorting by order
|
# reddit.get_my_subreddits() does not support sorting by order
|
||||||
if order:
|
if order:
|
||||||
@@ -44,7 +44,9 @@ class SubscriptionPage(Page):
|
|||||||
|
|
||||||
@SubscriptionController.register(Command('PROMPT'))
|
@SubscriptionController.register(Command('PROMPT'))
|
||||||
def prompt_subreddit(self):
|
def prompt_subreddit(self):
|
||||||
"Open a prompt to navigate to a different subreddit"
|
"""
|
||||||
|
Open a prompt to navigate to a different subreddit
|
||||||
|
"""
|
||||||
|
|
||||||
name = self.term.prompt_input('Enter page: /')
|
name = self.term.prompt_input('Enter page: /')
|
||||||
if name is not None:
|
if name is not None:
|
||||||
@@ -57,7 +59,9 @@ class SubscriptionPage(Page):
|
|||||||
|
|
||||||
@SubscriptionController.register(Command('SUBSCRIPTION_SELECT'))
|
@SubscriptionController.register(Command('SUBSCRIPTION_SELECT'))
|
||||||
def select_subreddit(self):
|
def select_subreddit(self):
|
||||||
"Store the selected subreddit and return to the subreddit page"
|
"""
|
||||||
|
Store the selected subreddit and return to the subreddit page
|
||||||
|
"""
|
||||||
|
|
||||||
name = self.get_selected_item()['name']
|
name = self.get_selected_item()['name']
|
||||||
with self.term.loader('Loading page'):
|
with self.term.loader('Loading page'):
|
||||||
@@ -69,7 +73,9 @@ class SubscriptionPage(Page):
|
|||||||
|
|
||||||
@SubscriptionController.register(Command('SUBSCRIPTION_EXIT'))
|
@SubscriptionController.register(Command('SUBSCRIPTION_EXIT'))
|
||||||
def close_subscriptions(self):
|
def close_subscriptions(self):
|
||||||
"Close subscriptions and return to the subreddit page"
|
"""
|
||||||
|
Close subscriptions and return to the subreddit page
|
||||||
|
"""
|
||||||
|
|
||||||
self.active = False
|
self.active = False
|
||||||
|
|
||||||
@@ -87,10 +93,17 @@ class SubscriptionPage(Page):
|
|||||||
|
|
||||||
row = offset
|
row = offset
|
||||||
if row in valid_rows:
|
if row in valid_rows:
|
||||||
attr = curses.A_BOLD | Color.YELLOW
|
if data['type'] == 'Multireddit':
|
||||||
|
attr = self.term.attr('multireddit_name')
|
||||||
|
else:
|
||||||
|
attr = self.term.attr('subscription_name')
|
||||||
self.term.add_line(win, '{name}'.format(**data), row, 1, attr)
|
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:
|
||||||
self.term.add_line(win, text, row, 1)
|
if data['type'] == 'Multireddit':
|
||||||
|
attr = self.term.attr('multireddit_text')
|
||||||
|
else:
|
||||||
|
attr = self.term.attr('subscription_text')
|
||||||
|
self.term.add_line(win, text, row, 1, attr)
|
||||||
|
|||||||
113
rtv/terminal.py
113
rtv/terminal.py
@@ -21,9 +21,9 @@ import six
|
|||||||
from six.moves.urllib.parse import quote
|
from six.moves.urllib.parse import quote
|
||||||
from kitchen.text.display import textual_width_chop
|
from kitchen.text.display import textual_width_chop
|
||||||
|
|
||||||
from . import exceptions
|
from . import exceptions, mime_parsers
|
||||||
from . import mime_parsers
|
from .theme import Theme
|
||||||
from .objects import LoadScreen, Color
|
from .objects import LoadScreen
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Fix only needed for versions prior to python 3.6
|
# Fix only needed for versions prior to python 3.6
|
||||||
@@ -47,43 +47,39 @@ class Terminal(object):
|
|||||||
MIN_HEIGHT = 10
|
MIN_HEIGHT = 10
|
||||||
MIN_WIDTH = 20
|
MIN_WIDTH = 20
|
||||||
|
|
||||||
# ASCII code
|
# ASCII codes
|
||||||
ESCAPE = 27
|
ESCAPE = 27
|
||||||
RETURN = 10
|
RETURN = 10
|
||||||
SPACE = 32
|
SPACE = 32
|
||||||
|
|
||||||
def __init__(self, stdscr, config):
|
def __init__(self, stdscr, config, theme=None):
|
||||||
|
|
||||||
self.stdscr = stdscr
|
self.stdscr = stdscr
|
||||||
self.config = config
|
self.config = config
|
||||||
self.loader = LoadScreen(self)
|
self.loader = LoadScreen(self)
|
||||||
|
|
||||||
|
self.theme = None
|
||||||
|
self.set_theme(theme)
|
||||||
|
|
||||||
self._display = None
|
self._display = None
|
||||||
self._mailcap_dict = mailcap.getcaps()
|
self._mailcap_dict = mailcap.getcaps()
|
||||||
self._term = os.environ['TERM']
|
self._term = os.environ['TERM']
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def up_arrow(self):
|
def up_arrow(self):
|
||||||
symbol = '^' if self.config['ascii'] else '▲'
|
return '^' if self.config['ascii'] else '▲'
|
||||||
attr = curses.A_BOLD | Color.GREEN
|
|
||||||
return symbol, attr
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def down_arrow(self):
|
def down_arrow(self):
|
||||||
symbol = 'v' if self.config['ascii'] else '▼'
|
return 'v' if self.config['ascii'] else '▼'
|
||||||
attr = curses.A_BOLD | Color.RED
|
|
||||||
return symbol, attr
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def neutral_arrow(self):
|
def neutral_arrow(self):
|
||||||
symbol = 'o' if self.config['ascii'] else '•'
|
return 'o' if self.config['ascii'] else '•'
|
||||||
attr = curses.A_BOLD
|
|
||||||
return symbol, attr
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def guilded(self):
|
def guilded(self):
|
||||||
symbol = '*' if self.config['ascii'] else '✪'
|
return '*' if self.config['ascii'] else '✪'
|
||||||
attr = curses.A_BOLD | Color.YELLOW
|
|
||||||
return symbol, attr
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def vline(self):
|
def vline(self):
|
||||||
@@ -194,11 +190,11 @@ class Terminal(object):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
if likes is None:
|
if likes is None:
|
||||||
return self.neutral_arrow
|
return self.neutral_arrow, self.attr('neutral_vote')
|
||||||
elif likes:
|
elif likes:
|
||||||
return self.up_arrow
|
return self.up_arrow, self.attr('upvote')
|
||||||
else:
|
else:
|
||||||
return self.down_arrow
|
return self.down_arrow, self.attr('downvote')
|
||||||
|
|
||||||
def clean(self, string, n_cols=None):
|
def clean(self, string, n_cols=None):
|
||||||
"""
|
"""
|
||||||
@@ -275,7 +271,21 @@ class Terminal(object):
|
|||||||
params = [] if attr is None else [attr]
|
params = [] if attr is None else [attr]
|
||||||
window.addstr(row, col, text, *params)
|
window.addstr(row, col, text, *params)
|
||||||
|
|
||||||
def show_notification(self, message, timeout=None):
|
@staticmethod
|
||||||
|
def add_space(window):
|
||||||
|
"""
|
||||||
|
Shortcut for adding a single space to a window at the current position
|
||||||
|
"""
|
||||||
|
|
||||||
|
row, col = window.getyx()
|
||||||
|
_, max_cols = window.getmaxyx()
|
||||||
|
if max_cols - col - 1 <= 0:
|
||||||
|
# Trying to draw outside of the screen bounds
|
||||||
|
return
|
||||||
|
|
||||||
|
window.addstr(row, col, ' ')
|
||||||
|
|
||||||
|
def show_notification(self, message, timeout=None, style='info'):
|
||||||
"""
|
"""
|
||||||
Overlay a message box on the center of the screen and wait for input.
|
Overlay a message box on the center of the screen and wait for input.
|
||||||
|
|
||||||
@@ -283,12 +293,15 @@ class Terminal(object):
|
|||||||
message (list or string): List of strings, one per line.
|
message (list or string): List of strings, one per line.
|
||||||
timeout (float): Optional, maximum length of time that the message
|
timeout (float): Optional, maximum length of time that the message
|
||||||
will be shown before disappearing.
|
will be shown before disappearing.
|
||||||
|
style (str): The theme element that will be applied to the
|
||||||
|
notification window
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if isinstance(message, six.string_types):
|
if isinstance(message, six.string_types):
|
||||||
message = message.splitlines()
|
message = message.splitlines()
|
||||||
|
|
||||||
n_rows, n_cols = self.stdscr.getmaxyx()
|
n_rows, n_cols = self.stdscr.getmaxyx()
|
||||||
|
v_offset, h_offset = self.stdscr.getbegyx()
|
||||||
|
|
||||||
box_width = max(len(m) for m in message) + 2
|
box_width = max(len(m) for m in message) + 2
|
||||||
box_height = len(message) + 2
|
box_height = len(message) + 2
|
||||||
@@ -298,10 +311,11 @@ class Terminal(object):
|
|||||||
box_height = min(box_height, n_rows)
|
box_height = min(box_height, n_rows)
|
||||||
message = message[:box_height-2]
|
message = message[:box_height-2]
|
||||||
|
|
||||||
s_row = (n_rows - box_height) // 2
|
s_row = (n_rows - box_height) // 2 + v_offset
|
||||||
s_col = (n_cols - box_width) // 2
|
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.erase()
|
window.erase()
|
||||||
window.border()
|
window.border()
|
||||||
|
|
||||||
@@ -687,18 +701,22 @@ class Terminal(object):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
n_rows, n_cols = self.stdscr.getmaxyx()
|
n_rows, n_cols = self.stdscr.getmaxyx()
|
||||||
ch, attr = str(' '), curses.A_BOLD | curses.A_REVERSE | Color.CYAN
|
v_offset, h_offset = self.stdscr.getbegyx()
|
||||||
|
ch, attr = str(' '), self.attr('prompt')
|
||||||
prompt = self.clean(prompt, n_cols-1)
|
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,
|
||||||
# so we can erase it when we're done.
|
# so we can erase it when we're done.
|
||||||
prompt_win = curses.newwin(1, len(prompt)+1, n_rows-1, 0)
|
s_row = v_offset + n_rows - 1
|
||||||
|
s_col = h_offset
|
||||||
|
prompt_win = curses.newwin(1, len(prompt) + 1, s_row, s_col)
|
||||||
prompt_win.bkgd(ch, attr)
|
prompt_win.bkgd(ch, attr)
|
||||||
self.add_line(prompt_win, prompt)
|
self.add_line(prompt_win, prompt)
|
||||||
prompt_win.refresh()
|
prompt_win.refresh()
|
||||||
|
|
||||||
# Create a separate window for text input
|
# Create a separate window for text input
|
||||||
input_win = curses.newwin(1, n_cols-len(prompt), n_rows-1, len(prompt))
|
s_col = h_offset + len(prompt)
|
||||||
|
input_win = curses.newwin(1, n_cols - len(prompt), s_row, s_col)
|
||||||
input_win.bkgd(ch, attr)
|
input_win.bkgd(ch, attr)
|
||||||
input_win.refresh()
|
input_win.refresh()
|
||||||
|
|
||||||
@@ -797,3 +815,46 @@ class Terminal(object):
|
|||||||
self.stdscr.touchwin()
|
self.stdscr.touchwin()
|
||||||
else:
|
else:
|
||||||
self.stdscr.clearok(True)
|
self.stdscr.clearok(True)
|
||||||
|
|
||||||
|
def attr(self, element):
|
||||||
|
"""
|
||||||
|
Shortcut for fetching the color + attribute code for an element.
|
||||||
|
"""
|
||||||
|
return self.theme.get(element)
|
||||||
|
|
||||||
|
def set_theme(self, theme=None):
|
||||||
|
"""
|
||||||
|
Check that the terminal supports the provided theme, and applies
|
||||||
|
the theme to the terminal if possible.
|
||||||
|
|
||||||
|
If the terminal doesn't support the theme, this falls back to the
|
||||||
|
default theme. The default theme only requires 8 colors so it
|
||||||
|
should be compatible with any terminal that supports basic colors.
|
||||||
|
"""
|
||||||
|
monochrome = (not curses.has_colors())
|
||||||
|
|
||||||
|
if theme is None:
|
||||||
|
theme = Theme(monochrome=monochrome)
|
||||||
|
|
||||||
|
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 = Theme(monochrome=monochrome)
|
||||||
|
|
||||||
|
elif theme.required_colors > curses.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 = Theme(monochrome=monochrome)
|
||||||
|
|
||||||
|
theme.bind_curses()
|
||||||
|
|
||||||
|
# Apply the default color to the whole screen
|
||||||
|
self.stdscr.bkgd(str(' '), theme.get('default'))
|
||||||
|
|
||||||
|
self.theme = theme
|
||||||
|
|||||||
370
rtv/theme.py
Normal file
370
rtv/theme.py
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
import codecs
|
||||||
|
import configparser
|
||||||
|
import curses
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from .config import THEMES, DEFAULT_THEMES
|
||||||
|
from .exceptions import ConfigError
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Theme(object):
|
||||||
|
|
||||||
|
ATTRIBUTE_CODES = {
|
||||||
|
'': curses.A_NORMAL,
|
||||||
|
'bold': curses.A_BOLD,
|
||||||
|
'reverse': curses.A_REVERSE,
|
||||||
|
'underline': curses.A_UNDERLINE,
|
||||||
|
'standout': curses.A_STANDOUT
|
||||||
|
}
|
||||||
|
|
||||||
|
COLOR_CODES = {
|
||||||
|
'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,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add keywords for the 256 ansi color codes
|
||||||
|
for i in range(256):
|
||||||
|
COLOR_CODES['ansi_{0}'.format(i)] = i
|
||||||
|
|
||||||
|
# For compatibility with as many terminals as possible, the default theme
|
||||||
|
# can only use the 8 basic colors with the default background.
|
||||||
|
DEFAULT_THEME = {
|
||||||
|
'bar_level_1': (curses.COLOR_MAGENTA, -1, curses.A_NORMAL),
|
||||||
|
'bar_level_2': (curses.COLOR_CYAN, -1, curses.A_NORMAL),
|
||||||
|
'bar_level_3': (curses.COLOR_GREEN, -1, curses.A_NORMAL),
|
||||||
|
'bar_level_4': (curses.COLOR_YELLOW, -1, curses.A_NORMAL),
|
||||||
|
'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_REVERSE),
|
||||||
|
'default': (-1, -1, curses.A_NORMAL),
|
||||||
|
'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': (curses.COLOR_RED, -1, curses.A_NORMAL),
|
||||||
|
'notice_success': (curses.COLOR_GREEN, -1, curses.A_NORMAL),
|
||||||
|
'nsfw': (curses.COLOR_RED, -1, curses.A_BOLD),
|
||||||
|
'order_bar': (curses.COLOR_YELLOW, -1, curses.A_BOLD),
|
||||||
|
'order_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)
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, name='default', elements=None, monochrome=False):
|
||||||
|
"""
|
||||||
|
Params:
|
||||||
|
name (str): A unique string that describes the theme
|
||||||
|
elements (dict): The theme's element map, should be in the same
|
||||||
|
format as Theme.DEFAULT_THEME.
|
||||||
|
monochrome (bool): If true, force all color pairs to use the
|
||||||
|
terminal's default foreground/background color.
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.elements = self.DEFAULT_THEME.copy()
|
||||||
|
if elements:
|
||||||
|
self.elements.update(elements)
|
||||||
|
|
||||||
|
self.name = name
|
||||||
|
self.monochrome = monochrome
|
||||||
|
self._color_pair_map = None
|
||||||
|
self._attribute_map = None
|
||||||
|
|
||||||
|
self.required_color_pairs = 0
|
||||||
|
self.required_colors = 0
|
||||||
|
|
||||||
|
if not self.monochrome:
|
||||||
|
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 fg/bg as a color pair
|
||||||
|
color_pairs.discard((-1, -1))
|
||||||
|
self.required_color_pairs = len(color_pairs)
|
||||||
|
|
||||||
|
# Determine which color set the terminal needs to
|
||||||
|
# support in order to be able to use the theme
|
||||||
|
self.required_colors = None
|
||||||
|
for marker in [0, 8, 16, 256]:
|
||||||
|
if max(colors) < marker:
|
||||||
|
self.required_colors = marker
|
||||||
|
break
|
||||||
|
|
||||||
|
def bind_curses(self):
|
||||||
|
"""
|
||||||
|
Bind the theme's colors to curses's internal color pair map.
|
||||||
|
|
||||||
|
This method must be called once (after curses has been initialized)
|
||||||
|
before any element attributes can be accessed. Color codes and other
|
||||||
|
special attributes will be mixed bitwise into a single value that
|
||||||
|
can be understood by curses.
|
||||||
|
"""
|
||||||
|
self._color_pair_map = {}
|
||||||
|
self._attribute_map = {}
|
||||||
|
|
||||||
|
for element, item in self.elements.items():
|
||||||
|
fg, bg, attrs = item
|
||||||
|
|
||||||
|
color_pair = (fg, bg)
|
||||||
|
if not self.monochrome and color_pair != (-1, -1):
|
||||||
|
# Curses limits the number of available color pairs, so we
|
||||||
|
# need to reuse them if there are multiple elements with the
|
||||||
|
# same foreground and background.
|
||||||
|
if color_pair not in self._color_pair_map:
|
||||||
|
# Index 0 is reserved by curses for the default color
|
||||||
|
index = len(self._color_pair_map) + 1
|
||||||
|
curses.init_pair(index, color_pair[0], color_pair[1])
|
||||||
|
self._color_pair_map[color_pair] = curses.color_pair(index)
|
||||||
|
attrs |= self._color_pair_map[color_pair]
|
||||||
|
|
||||||
|
self._attribute_map[element] = attrs
|
||||||
|
|
||||||
|
def get(self, val):
|
||||||
|
"""
|
||||||
|
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()')
|
||||||
|
return self._attribute_map[val]
|
||||||
|
|
||||||
|
def get_bar_level(self, indentation_level):
|
||||||
|
"""
|
||||||
|
Helper method for loading the bar format given the indentation level.
|
||||||
|
"""
|
||||||
|
|
||||||
|
levels = ['bar_level_1', 'bar_level_2', 'bar_level_3', 'bar_level_4']
|
||||||
|
level = levels[indentation_level % len(levels)]
|
||||||
|
return self.get(level)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def list_themes(cls, path=THEMES):
|
||||||
|
"""
|
||||||
|
Compile all of the themes configuration files in the search path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
themes = {'invalid': {}, 'custom': {}, 'default': {}}
|
||||||
|
for container, theme_path in [
|
||||||
|
(themes['custom'], path),
|
||||||
|
(themes['default'], DEFAULT_THEMES)]:
|
||||||
|
|
||||||
|
if os.path.isdir(theme_path):
|
||||||
|
for filename in os.listdir(theme_path):
|
||||||
|
if not filename.endswith('.cfg'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
filepath = os.path.join(theme_path, filename)
|
||||||
|
name = filename[:-4]
|
||||||
|
try:
|
||||||
|
# Make sure the theme is valid
|
||||||
|
theme = cls.from_file(filepath)
|
||||||
|
except Exception as e:
|
||||||
|
themes['invalid'][name] = e
|
||||||
|
else:
|
||||||
|
container[name] = theme
|
||||||
|
|
||||||
|
return themes
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def print_themes(cls, path=THEMES):
|
||||||
|
"""
|
||||||
|
Prints a human-readable summary of all 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 = cls.list_themes(path=path)
|
||||||
|
|
||||||
|
print('\nInstalled ({0}):'.format(path))
|
||||||
|
custom_themes = sorted(themes['custom'].items())
|
||||||
|
if custom_themes:
|
||||||
|
for name, theme in custom_themes:
|
||||||
|
print(' {0:<20}[requires {1} colors]'.format(
|
||||||
|
name, theme.required_colors))
|
||||||
|
else:
|
||||||
|
print(' (empty)')
|
||||||
|
|
||||||
|
print('\nBuilt-in:')
|
||||||
|
default_themes = sorted(themes['default'].items())
|
||||||
|
for name, theme in default_themes:
|
||||||
|
print(' {0:<20}[requires {1} colors]'.format(
|
||||||
|
name, theme.required_colors))
|
||||||
|
|
||||||
|
invalid_themes = sorted(themes['invalid'].items())
|
||||||
|
if invalid_themes:
|
||||||
|
print('\nWARNING: Some themes had problems loading:')
|
||||||
|
for name, error in invalid_themes:
|
||||||
|
print(' {0:<20}{1!r}'.format(name, error))
|
||||||
|
|
||||||
|
print('')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_name(cls, name, monochrome=False, 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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
filenames = [
|
||||||
|
name,
|
||||||
|
os.path.join(path, '{0}.cfg'.format(name)),
|
||||||
|
os.path.join(DEFAULT_THEMES, '{0}.cfg'.format(name))]
|
||||||
|
|
||||||
|
for filename in filenames:
|
||||||
|
if os.path.isfile(filename):
|
||||||
|
return cls.from_file(filename, monochrome)
|
||||||
|
|
||||||
|
raise ConfigError('Could not find theme named "{0}"'.format(name))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_file(cls, filename, monochrome=False):
|
||||||
|
"""
|
||||||
|
Load a theme from the specified configuration file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = configparser.ConfigParser()
|
||||||
|
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 = {}
|
||||||
|
if config.has_section('theme'):
|
||||||
|
for element, line in config.items('theme'):
|
||||||
|
if element not in cls.DEFAULT_THEME:
|
||||||
|
# Could happen if using a new config with an older version
|
||||||
|
# of the software
|
||||||
|
continue
|
||||||
|
elements[element] = cls._parse_line(element, line, filename)
|
||||||
|
|
||||||
|
return cls(theme_name, elements, monochrome)
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
fg_code = cls.COLOR_CODES.get(fg)
|
||||||
|
if fg_code is None:
|
||||||
|
raise ConfigError(
|
||||||
|
'Error loading {0}, invalid <foreground>:\n'
|
||||||
|
' {1} = {2}'.format(filename, element, line))
|
||||||
|
|
||||||
|
bg_code = cls.COLOR_CODES.get(bg)
|
||||||
|
if bg_code is None:
|
||||||
|
raise ConfigError(
|
||||||
|
'Error loading {0}, invalid <background>:\n'
|
||||||
|
' {1} = {2}'.format(filename, element, line))
|
||||||
|
|
||||||
|
attrs_code = curses.A_NORMAL
|
||||||
|
for attr in attrs.split('+'):
|
||||||
|
attr_code = cls.ATTRIBUTE_CODES.get(attr)
|
||||||
|
if attr_code is None:
|
||||||
|
raise ConfigError(
|
||||||
|
'Error loading {0}, invalid <attributes>:\n'
|
||||||
|
' {1} = {2}'.format(filename, element, line))
|
||||||
|
attrs_code |= attr_code
|
||||||
|
|
||||||
|
return fg_code, bg_code, attrs_code
|
||||||
|
|
||||||
|
@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 = 36 * r + 6 * g + b + 16
|
||||||
|
return 'ansi_{0}'.format(n)
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
47
rtv/themes/default.cfg
Normal file
47
rtv/themes/default.cfg
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# RTV theme
|
||||||
|
|
||||||
|
[theme]
|
||||||
|
;<element> = <foreground> <background> <attributes>
|
||||||
|
bar_level_1 = magenta default
|
||||||
|
bar_level_2 = cyan default
|
||||||
|
bar_level_3 = green default
|
||||||
|
bar_level_4 = yellow default
|
||||||
|
comment_author = blue default bold
|
||||||
|
comment_author_self = green default bold
|
||||||
|
comment_count = default default
|
||||||
|
comment_text = default default
|
||||||
|
created = default default
|
||||||
|
cursor = default default reverse
|
||||||
|
default = default default
|
||||||
|
downvote = red default bold
|
||||||
|
gold = yellow default bold
|
||||||
|
help_bar = cyan default bold+reverse
|
||||||
|
hidden_comment_expand = default default bold
|
||||||
|
hidden_comment_text = default default
|
||||||
|
multireddit_name = yellow default bold
|
||||||
|
multireddit_text = default default
|
||||||
|
neutral_vote = default default bold
|
||||||
|
notice_info = default default
|
||||||
|
notice_loading = default default
|
||||||
|
notice_error = red default
|
||||||
|
notice_success = green default
|
||||||
|
nsfw = red default bold
|
||||||
|
order_bar = yellow default bold
|
||||||
|
order_selected = yellow default bold+reverse
|
||||||
|
prompt = cyan default bold+reverse
|
||||||
|
saved = green default
|
||||||
|
score = default default
|
||||||
|
separator = default default bold
|
||||||
|
stickied = green default
|
||||||
|
subscription_name = yellow default bold
|
||||||
|
subscription_text = default default
|
||||||
|
submission_author = green default
|
||||||
|
submission_flair = red default
|
||||||
|
submission_subreddit = yellow default
|
||||||
|
submission_text = default default
|
||||||
|
submission_title = default default bold
|
||||||
|
title_bar = cyan default bold+reverse
|
||||||
|
upvote = green default bold
|
||||||
|
url = blue default underline
|
||||||
|
url_seen = magenta default underline
|
||||||
|
user_flair = yellow default bold
|
||||||
48
rtv/themes/monochrome.cfg
Normal file
48
rtv/themes/monochrome.cfg
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# RTV theme
|
||||||
|
|
||||||
|
[theme]
|
||||||
|
;<element> = <foreground> <background> <attributes>
|
||||||
|
|
||||||
|
bar_level_1 = default default
|
||||||
|
bar_level_2 = default default
|
||||||
|
bar_level_3 = default default
|
||||||
|
bar_level_4 = default default
|
||||||
|
comment_author = default default bold
|
||||||
|
comment_author_self = default default bold
|
||||||
|
comment_count = default default
|
||||||
|
comment_text = default default
|
||||||
|
created = default default
|
||||||
|
cursor = default default reverse
|
||||||
|
default = default default
|
||||||
|
downvote = default default bold
|
||||||
|
gold = default default bold
|
||||||
|
help_bar = default default bold+reverse
|
||||||
|
hidden_comment_expand = default default bold
|
||||||
|
hidden_comment_text = default default
|
||||||
|
multireddit_name = default default bold
|
||||||
|
multireddit_text = default default
|
||||||
|
neutral_vote = default default bold
|
||||||
|
notice_info = default default
|
||||||
|
notice_loading = default default
|
||||||
|
notice_error = default default
|
||||||
|
notice_success = default default
|
||||||
|
nsfw = default default bold
|
||||||
|
order_bar = default default bold
|
||||||
|
order_selected = default default bold+reverse
|
||||||
|
prompt = default default bold+reverse
|
||||||
|
saved = default default
|
||||||
|
score = default default
|
||||||
|
separator = default default bold
|
||||||
|
stickied = default default
|
||||||
|
subscription_name = default default bold
|
||||||
|
subscription_text = default default
|
||||||
|
submission_author = default default
|
||||||
|
submission_flair = default default bold
|
||||||
|
submission_subreddit = default default
|
||||||
|
submission_text = default default
|
||||||
|
submission_title = default default bold
|
||||||
|
title_bar = default default bold+reverse
|
||||||
|
upvote = default default bold
|
||||||
|
url = default default underline
|
||||||
|
url_seen = default default underline
|
||||||
|
user_flair = default default bold
|
||||||
65
rtv/themes/solarized-dark.cfg
Normal file
65
rtv/themes/solarized-dark.cfg
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# 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>
|
||||||
|
|
||||||
|
bar_level_1 = ansi_125 ansi_234
|
||||||
|
bar_level_2 = ansi_160 ansi_234
|
||||||
|
bar_level_3 = ansi_61 ansi_234
|
||||||
|
bar_level_4 = ansi_37 ansi_234
|
||||||
|
comment_author = ansi_33 ansi_234 bold
|
||||||
|
comment_author_self = ansi_64 ansi_234 bold
|
||||||
|
comment_count = ansi_244 ansi_234
|
||||||
|
comment_text = ansi_244 ansi_234
|
||||||
|
created = ansi_244 ansi_234
|
||||||
|
cursor = ansi_244 ansi_234 reverse
|
||||||
|
default = ansi_244 ansi_234
|
||||||
|
downvote = ansi_160 ansi_234 bold
|
||||||
|
gold = ansi_136 ansi_234 bold
|
||||||
|
help_bar = ansi_37 ansi_234 bold+reverse
|
||||||
|
hidden_comment_expand = ansi_240 ansi_234 bold
|
||||||
|
hidden_comment_text = ansi_240 ansi_234
|
||||||
|
multireddit_name = ansi_245 ansi_234 bold
|
||||||
|
multireddit_text = ansi_240 ansi_234
|
||||||
|
neutral_vote = ansi_244 ansi_234 bold
|
||||||
|
notice_info = ansi_244 ansi_234 bold
|
||||||
|
notice_loading = ansi_244 ansi_234 bold
|
||||||
|
notice_error = ansi_160 ansi_234 bold
|
||||||
|
notice_success = ansi_64 ansi_234 bold
|
||||||
|
nsfw = ansi_125 ansi_234 bold+reverse
|
||||||
|
order_bar = ansi_240 ansi_235 bold
|
||||||
|
order_selected = ansi_240 ansi_235 bold+reverse
|
||||||
|
prompt = ansi_33 ansi_234 bold+reverse
|
||||||
|
saved = ansi_125 ansi_234
|
||||||
|
score = ansi_244 ansi_234
|
||||||
|
separator = ansi_244 ansi_234 bold
|
||||||
|
stickied = ansi_136 ansi_234
|
||||||
|
subscription_name = ansi_245 ansi_234 bold
|
||||||
|
subscription_text = ansi_240 ansi_234
|
||||||
|
submission_author = ansi_64 ansi_234 bold
|
||||||
|
submission_flair = ansi_160 ansi_234
|
||||||
|
submission_subreddit = ansi_166 ansi_234
|
||||||
|
submission_text = ansi_244 ansi_234
|
||||||
|
submission_title = ansi_245 ansi_234 bold
|
||||||
|
title_bar = ansi_37 ansi_234 bold+reverse
|
||||||
|
upvote = ansi_64 ansi_234 bold
|
||||||
|
url = ansi_33 ansi_234 underline
|
||||||
|
url_seen = ansi_61 ansi_234 underline
|
||||||
|
user_flair = ansi_136 ansi_234 bold
|
||||||
65
rtv/themes/solarized-light.cfg
Normal file
65
rtv/themes/solarized-light.cfg
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# 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>
|
||||||
|
|
||||||
|
bar_level_1 = ansi_125 ansi_230
|
||||||
|
bar_level_2 = ansi_160 ansi_230
|
||||||
|
bar_level_3 = ansi_61 ansi_230
|
||||||
|
bar_level_4 = ansi_37 ansi_230
|
||||||
|
comment_author = ansi_33 ansi_230 bold
|
||||||
|
comment_author_self = ansi_64 ansi_230 bold
|
||||||
|
comment_count = ansi_241 ansi_230
|
||||||
|
comment_text = ansi_241 ansi_230
|
||||||
|
created = ansi_241 ansi_230
|
||||||
|
cursor = ansi_244 ansi_230 reverse
|
||||||
|
default = ansi_241 ansi_230
|
||||||
|
downvote = ansi_160 ansi_230 bold
|
||||||
|
gold = ansi_136 ansi_230 bold
|
||||||
|
help_bar = ansi_37 ansi_230 bold+reverse
|
||||||
|
hidden_comment_expand = ansi_245 ansi_230 bold
|
||||||
|
hidden_comment_text = ansi_245 ansi_230
|
||||||
|
multireddit_name = ansi_240 ansi_230 bold
|
||||||
|
multireddit_text = ansi_245 ansi_230
|
||||||
|
neutral_vote = ansi_241 ansi_230 bold
|
||||||
|
notice_info = ansi_241 ansi_230 bold
|
||||||
|
notice_loading = ansi_241 ansi_230 bold
|
||||||
|
notice_error = ansi_160 ansi_230 bold
|
||||||
|
notice_success = ansi_64 ansi_230 bold
|
||||||
|
nsfw = ansi_125 ansi_230 bold+reverse
|
||||||
|
order_bar = ansi_245 ansi_254 bold
|
||||||
|
order_selected = ansi_245 ansi_254 bold+reverse
|
||||||
|
prompt = ansi_33 ansi_230 bold+reverse
|
||||||
|
saved = ansi_125 ansi_230
|
||||||
|
score = ansi_241 ansi_230
|
||||||
|
separator = ansi_241 ansi_230 bold
|
||||||
|
stickied = ansi_136 ansi_230
|
||||||
|
subscription_name = ansi_240 ansi_230 bold
|
||||||
|
subscription_text = ansi_245 ansi_230
|
||||||
|
submission_author = ansi_64 ansi_230 bold
|
||||||
|
submission_flair = ansi_160 ansi_230
|
||||||
|
submission_subreddit = ansi_166 ansi_230
|
||||||
|
submission_text = ansi_241 ansi_230
|
||||||
|
submission_title = ansi_240 ansi_230 bold
|
||||||
|
title_bar = ansi_37 ansi_230 bold+reverse
|
||||||
|
upvote = ansi_64 ansi_230 bold
|
||||||
|
url = ansi_33 ansi_230 underline
|
||||||
|
url_seen = ansi_61 ansi_230 underline
|
||||||
|
user_flair = ansi_136 ansi_230 bold
|
||||||
6363
scripts/cassettes/demo_theme.yaml
Normal file
6363
scripts/cassettes/demo_theme.yaml
Normal file
File diff suppressed because it is too large
Load Diff
270
scripts/demo_theme.py
Executable file
270
scripts/demo_theme.py
Executable file
@@ -0,0 +1,270 @@
|
|||||||
|
#!/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 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
|
||||||
|
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, 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, 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, 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, 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, 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():
|
||||||
|
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
theme_name = sys.argv[1]
|
||||||
|
else:
|
||||||
|
theme_name = 'default'
|
||||||
|
|
||||||
|
themes = Theme.list_themes()
|
||||||
|
default_themes = sorted(themes['default'].keys())
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
oauth = OAuthHelper(reddit, term, config)
|
||||||
|
oauth.authorize()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
|
||||||
|
theme = Theme.from_name(theme_name)
|
||||||
|
term = Terminal(stdscr, config, theme=theme)
|
||||||
|
threads = draw_screen(stdscr, reddit, config, theme, oauth)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ch = term.show_notification(theme_name)
|
||||||
|
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()
|
||||||
|
|
||||||
|
if ch == curses.KEY_RIGHT:
|
||||||
|
i = (default_themes.index(theme_name) + 1)
|
||||||
|
theme_name = default_themes[i % len(default_themes)]
|
||||||
|
elif ch == curses.KEY_LEFT:
|
||||||
|
i = (default_themes.index(theme_name) - 1)
|
||||||
|
theme_name = default_themes[i % len(default_themes)]
|
||||||
|
elif ch == Terminal.ESCAPE:
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
sys.exit(main())
|
||||||
191
tests/test_theme.py
Normal file
191
tests/test_theme.py
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import os
|
||||||
|
import curses
|
||||||
|
from collections import OrderedDict
|
||||||
|
from tempfile import 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')
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def test_theme_construct():
|
||||||
|
|
||||||
|
theme = Theme()
|
||||||
|
assert theme.name == 'default'
|
||||||
|
assert theme.elements == Theme.DEFAULT_THEME
|
||||||
|
assert theme.required_colors == 8
|
||||||
|
assert theme.required_color_pairs == 6
|
||||||
|
|
||||||
|
theme = Theme(name='monochrome', monochrome=True)
|
||||||
|
assert theme.name == 'monochrome'
|
||||||
|
assert theme.monochrome is True
|
||||||
|
assert theme.required_colors == 0
|
||||||
|
assert theme.required_color_pairs == 0
|
||||||
|
|
||||||
|
elements = {'bar_level_1': (100, 101, curses.A_UNDERLINE)}
|
||||||
|
theme = Theme(elements=elements)
|
||||||
|
assert theme.elements['bar_level_1'] == elements['bar_level_1']
|
||||||
|
assert theme.required_colors == 256
|
||||||
|
|
||||||
|
|
||||||
|
def test_theme_default_cfg_matches_builtin():
|
||||||
|
|
||||||
|
filename = os.path.join(DEFAULT_THEMES, 'default.cfg')
|
||||||
|
default_theme = Theme.from_file(filename)
|
||||||
|
|
||||||
|
# The default theme file should match the hardcoded values
|
||||||
|
assert default_theme.elements == Theme().elements
|
||||||
|
|
||||||
|
class MockTheme(Theme):
|
||||||
|
def __init__(self, name=None, elements=None, monochrome=False):
|
||||||
|
assert name == 'default'
|
||||||
|
assert elements == Theme.DEFAULT_THEME
|
||||||
|
assert monochrome is False
|
||||||
|
|
||||||
|
# Make sure that the config file elements exactly match the defaults
|
||||||
|
MockTheme.from_file(filename)
|
||||||
|
|
||||||
|
|
||||||
|
args, ids = INVALID_ELEMENTS.values(), list(INVALID_ELEMENTS)
|
||||||
|
@pytest.mark.parametrize('line', args, ids=ids)
|
||||||
|
def test_theme_from_file_invalid(line):
|
||||||
|
|
||||||
|
with NamedTemporaryFile(mode='w+') as fp:
|
||||||
|
fp.write('[theme]\n')
|
||||||
|
fp.write(line)
|
||||||
|
fp.flush()
|
||||||
|
with pytest.raises(ConfigError):
|
||||||
|
Theme.from_file(fp.name)
|
||||||
|
|
||||||
|
|
||||||
|
def test_theme_from_file():
|
||||||
|
|
||||||
|
with NamedTemporaryFile(mode='w+') as fp:
|
||||||
|
|
||||||
|
# Needs a [theme] section
|
||||||
|
with pytest.raises(ConfigError):
|
||||||
|
Theme.from_file(fp.name)
|
||||||
|
|
||||||
|
fp.write('[theme]\n')
|
||||||
|
fp.write('unknown = neutral neutral\n')
|
||||||
|
fp.write('upvote = default red\n')
|
||||||
|
fp.write('downvote = ansi_0 ansi_255 bold\n')
|
||||||
|
fp.write('neutral_vote = #000000 #ffffff bold+reverse\n')
|
||||||
|
fp.flush()
|
||||||
|
|
||||||
|
theme = Theme.from_file(fp.name)
|
||||||
|
assert 'unknown' not in theme.elements
|
||||||
|
assert theme.elements['upvote'] == (
|
||||||
|
-1, curses.COLOR_RED, curses.A_NORMAL)
|
||||||
|
assert theme.elements['downvote'] == (
|
||||||
|
0, 255, curses.A_BOLD)
|
||||||
|
assert theme.elements['neutral_vote'] == (
|
||||||
|
16, 231, curses.A_BOLD | curses.A_REVERSE)
|
||||||
|
|
||||||
|
|
||||||
|
def test_theme_from_name():
|
||||||
|
|
||||||
|
with NamedTemporaryFile(mode='w+', suffix='.cfg') 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.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.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()
|
||||||
|
|
||||||
|
# Can't access elements before initializing curses
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
theme.get('upvote')
|
||||||
|
|
||||||
|
theme.bind_curses()
|
||||||
|
|
||||||
|
# Our pre-computed required color pairs should have been correct
|
||||||
|
assert len(theme._color_pair_map) == theme.required_color_pairs
|
||||||
|
|
||||||
|
for element in Theme.DEFAULT_THEME:
|
||||||
|
assert isinstance(theme.get(element), int)
|
||||||
|
|
||||||
|
assert theme.get_bar_level(0) == theme.get_bar_level(4)
|
||||||
|
|
||||||
|
|
||||||
|
def test_theme_initialize_attributes_monochrome(stdscr):
|
||||||
|
|
||||||
|
theme = Theme(monochrome=True)
|
||||||
|
theme.bind_curses()
|
||||||
|
|
||||||
|
# Avoid making these curses calls if colors aren't initialized
|
||||||
|
curses.init_pair.assert_not_called()
|
||||||
|
curses.color_pair.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_theme_list_themes():
|
||||||
|
|
||||||
|
with NamedTemporaryFile(mode='w+', suffix='.cfg') as fp:
|
||||||
|
path, filename = os.path.split(fp.name)
|
||||||
|
theme_name = filename[:-4]
|
||||||
|
|
||||||
|
fp.write('[theme]\n')
|
||||||
|
fp.flush()
|
||||||
|
|
||||||
|
Theme.print_themes(path)
|
||||||
|
themes = Theme.list_themes(path)
|
||||||
|
assert themes['custom'][theme_name].name == theme_name
|
||||||
|
assert themes['default']['monochrome'].name == 'monochrome'
|
||||||
|
|
||||||
|
# This also checks that all of the default themes are valid
|
||||||
|
assert not themes['invalid']
|
||||||
|
|
||||||
|
|
||||||
|
def test_theme_list_themes_invalid():
|
||||||
|
|
||||||
|
with NamedTemporaryFile(mode='w+', suffix='.cfg') 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 = Theme.list_themes(path)
|
||||||
|
assert theme_name in themes['invalid']
|
||||||
|
assert not themes['custom']
|
||||||
Reference in New Issue
Block a user