Cherry picking backwards-compatible changes from the themes branch
This commit is contained in:
@@ -35,9 +35,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, patch_webbrowser
|
from .objects import curses_session, patch_webbrowser
|
||||||
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__
|
||||||
@@ -169,11 +170,9 @@ def main():
|
|||||||
try:
|
try:
|
||||||
with curses_session() as stdscr:
|
with curses_session() as stdscr:
|
||||||
|
|
||||||
# Initialize global color-pairs with curses
|
theme = Theme(config['monochrome'])
|
||||||
if not config['monochrome']:
|
term = Terminal(stdscr, config, theme)
|
||||||
Color.init()
|
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -135,6 +135,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):
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
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')
|
self.term.show_notification('Denied access', style='error')
|
||||||
return
|
return
|
||||||
elif self.params['error']:
|
elif self.params['error']:
|
||||||
self.term.show_notification('Authentication 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')
|
self.term.show_notification('UUID mismatch', style='error')
|
||||||
return
|
return
|
||||||
|
|
||||||
with self.term.loader('Logging in'):
|
with self.term.loader('Logging in'):
|
||||||
|
|||||||
@@ -230,7 +230,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):
|
||||||
@@ -250,12 +251,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.
|
||||||
@@ -285,49 +290,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.
|
||||||
|
|||||||
80
rtv/page.py
80
rtv/page.py
@@ -4,7 +4,6 @@ from __future__ import unicode_literals
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import curses
|
|
||||||
import logging
|
import logging
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
@@ -12,7 +11,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__
|
||||||
@@ -158,19 +157,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
|
||||||
@@ -376,7 +371,6 @@ class Page(object):
|
|||||||
self._draw_banner()
|
self._draw_banner()
|
||||||
self._draw_content()
|
self._draw_content()
|
||||||
self._draw_footer()
|
self._draw_footer()
|
||||||
self._add_cursor()
|
|
||||||
self.term.clear_screen()
|
self.term.clear_screen()
|
||||||
self.term.stdscr.refresh()
|
self.term.stdscr.refresh()
|
||||||
|
|
||||||
@@ -388,8 +382,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')
|
||||||
@@ -421,7 +414,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
|
||||||
@@ -442,8 +435,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)
|
|
||||||
|
|
||||||
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(' ')
|
||||||
@@ -455,7 +447,8 @@ 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)
|
attr = self.term.theme.get('order_bar', modifier='selected')
|
||||||
|
window.chgat(0, col, 3, attr)
|
||||||
|
|
||||||
self._row += 1
|
self._row += 1
|
||||||
|
|
||||||
@@ -465,8 +458,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(
|
window = self.term.stdscr.derwin(n_rows - self._row - 1, n_cols, self._row, 0)
|
||||||
n_rows - self._row - 1, n_cols, self._row, 0)
|
|
||||||
window.erase()
|
window.erase()
|
||||||
win_n_rows, win_n_cols = window.getmaxyx()
|
win_n_rows, win_n_cols = window.getmaxyx()
|
||||||
|
|
||||||
@@ -493,10 +485,8 @@ class Page(object):
|
|||||||
top_item_height = None
|
top_item_height = None
|
||||||
subwin_n_cols = win_n_cols - data['h_offset']
|
subwin_n_cols = win_n_cols - data['h_offset']
|
||||||
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'])
|
self._subwindows.append((subwindow, data, subwin_inverted))
|
||||||
attr = self._draw_item(subwindow, data, subwin_inverted)
|
|
||||||
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
|
||||||
current_row += step * (subwin_n_rows + 1)
|
current_row += step * (subwin_n_rows + 1)
|
||||||
if available_rows <= 0:
|
if available_rows <= 0:
|
||||||
@@ -518,6 +508,25 @@ class Page(object):
|
|||||||
self.nav.flip((len(self._subwindows) - 1))
|
self.nav.flip((len(self._subwindows) - 1))
|
||||||
return self._draw_content()
|
return self._draw_content()
|
||||||
|
|
||||||
|
if self.nav.cursor_index >= len(self._subwindows):
|
||||||
|
# Don't allow the cursor to go over the number of subwindows
|
||||||
|
# This could happen if the window is resized and the cursor index is
|
||||||
|
# pushed out of bounds
|
||||||
|
self.nav.cursor_index = len(self._subwindows) - 1
|
||||||
|
|
||||||
|
# Now that the windows are setup, we can take a second pass through
|
||||||
|
# to draw the content
|
||||||
|
for index, (win, data, inverted) in enumerate(self._subwindows):
|
||||||
|
if index == self.nav.cursor_index:
|
||||||
|
# This lets the theme know to invert the cursor
|
||||||
|
modifier = 'selected'
|
||||||
|
else:
|
||||||
|
modifier = None
|
||||||
|
|
||||||
|
win.bkgd(str(' '), self.term.attr('normal'))
|
||||||
|
with self.term.theme.set_modifier(modifier):
|
||||||
|
self._draw_item(win, data, inverted)
|
||||||
|
|
||||||
self._row += win_n_rows
|
self._row += win_n_rows
|
||||||
|
|
||||||
def _draw_footer(self):
|
def _draw_footer(self):
|
||||||
@@ -525,54 +534,23 @@ 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):
|
|
||||||
|
|
||||||
# Don't allow the cursor to go below page index 0
|
|
||||||
if self.nav.absolute_index < 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Don't allow the cursor to go over the number of subwindows
|
|
||||||
# This could happen if the window is resized and the cursor index is
|
|
||||||
# pushed out of bounds
|
|
||||||
if self.nav.cursor_index >= len(self._subwindows):
|
|
||||||
self.nav.cursor_index = len(self._subwindows) - 1
|
|
||||||
|
|
||||||
window, attr = self._subwindows[self.nav.cursor_index]
|
|
||||||
if attr is not None:
|
|
||||||
attribute |= attr
|
|
||||||
|
|
||||||
n_rows, _ = window.getmaxyx()
|
|
||||||
for row in range(n_rows):
|
|
||||||
window.chgat(row, 0, 1, attribute)
|
|
||||||
|
|
||||||
def _prompt_period(self, order):
|
def _prompt_period(self, order):
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,11 @@ from __future__ import unicode_literals
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
@@ -119,7 +118,7 @@ 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()
|
||||||
@@ -207,6 +206,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:
|
||||||
@@ -285,89 +288,114 @@ class SubmissionPage(Page):
|
|||||||
split_body = data['split_body']
|
split_body = data['split_body']
|
||||||
if data['n_rows'] > n_rows:
|
if data['n_rows'] > n_rows:
|
||||||
# Only when there is a single comment on the page and not inverted
|
# Only when there is a single comment on the page and not inverted
|
||||||
if not inverted and len(self._subwindows) == 0:
|
if not inverted and len(self._subwindows) == 1:
|
||||||
cutoff = data['n_rows'] - n_rows + 1
|
cutoff = data['n_rows'] - n_rows + 1
|
||||||
split_body = split_body[:-cutoff]
|
split_body = split_body[:-cutoff]
|
||||||
split_body.append('(Not enough space to display)')
|
split_body.append('(Not enough space to display)')
|
||||||
|
|
||||||
row = offset
|
row = offset
|
||||||
if row in valid_rows:
|
if row in valid_rows:
|
||||||
|
|
||||||
attr = curses.A_BOLD
|
|
||||||
attr |= (Color.BLUE if not data['is_author'] else Color.GREEN)
|
|
||||||
text = '{author} '.format(**data)
|
|
||||||
if data['is_author']:
|
if data['is_author']:
|
||||||
text += '[S] '
|
attr = self.term.attr('comment_author_self')
|
||||||
|
text = '{author} [S]'.format(**data)
|
||||||
|
else:
|
||||||
|
attr = self.term.attr('comment_author')
|
||||||
|
text = '{author}'.format(**data)
|
||||||
self.term.add_line(win, text, row, 1, attr)
|
self.term.add_line(win, text, row, 1, attr)
|
||||||
|
|
||||||
if data['flair']:
|
if data['flair']:
|
||||||
attr = curses.A_BOLD | Color.YELLOW
|
attr = self.term.attr('user_flair')
|
||||||
self.term.add_line(win, '{flair} '.format(**data), attr=attr)
|
self.term.add_space(win)
|
||||||
|
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'])
|
index = data['level'] % len(self.term.theme.BAR_LEVELS)
|
||||||
x = 0
|
attr = self.term.attr(self.term.theme.BAR_LEVELS[index])
|
||||||
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, 0, self.term.vline, attr)
|
||||||
|
|
||||||
return attr | self.term.vline
|
|
||||||
|
|
||||||
def _draw_more_comments(self, win, data):
|
def _draw_more_comments(self, win, data):
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
index = data['level'] % len(self.term.theme.BAR_LEVELS)
|
||||||
|
attr = self.term.attr(self.term.theme.BAR_LEVELS[index])
|
||||||
self.term.addch(win, 0, 0, self.term.vline, attr)
|
self.term.addch(win, 0, 0, self.term.vline, attr)
|
||||||
|
|
||||||
return attr | self.term.vline
|
|
||||||
|
|
||||||
def _draw_submission(self, win, data):
|
def _draw_submission(self, win, data):
|
||||||
|
|
||||||
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']:
|
||||||
self.term.add_line(win, ' {flair}'.format(**data), attr=attr)
|
attr = self.term.attr('submission_flair')
|
||||||
self.term.add_line(win, ' {created} {subreddit}'.format(**data))
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, '{flair}'.format(**data), attr=attr)
|
||||||
|
|
||||||
|
attr = self.term.attr('created')
|
||||||
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, '{created}'.format(**data), attr=attr)
|
||||||
|
|
||||||
|
attr = self.term.attr('submission_subreddit')
|
||||||
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, '/r/{subreddit}'.format(**data), attr=attr)
|
||||||
|
|
||||||
row = len(data['split_title']) + 2
|
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
|
||||||
@@ -377,25 +405,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()
|
||||||
|
|||||||
@@ -3,12 +3,11 @@ from __future__ import unicode_literals
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
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
|
||||||
@@ -265,50 +264,75 @@ 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)
|
||||||
|
|
||||||
|
attr = self.term.attr('cursor')
|
||||||
|
for y in range(n_rows):
|
||||||
|
self.term.addch(win, y, 0, str(' '), 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):
|
||||||
@@ -95,10 +93,21 @@ 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)
|
||||||
|
|
||||||
|
attr = self.term.attr('cursor')
|
||||||
|
for y in range(n_rows):
|
||||||
|
self.term.addch(win, y, 0, str(' '), attr)
|
||||||
|
|||||||
105
rtv/terminal.py
105
rtv/terminal.py
@@ -20,9 +20,9 @@ from tempfile import NamedTemporaryFile
|
|||||||
import six
|
import six
|
||||||
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
|
||||||
@@ -46,16 +46,20 @@ 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.get('TERM')
|
self._term = os.environ.get('TERM')
|
||||||
@@ -66,27 +70,19 @@ class Terminal(object):
|
|||||||
|
|
||||||
@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):
|
||||||
@@ -197,11 +193,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):
|
||||||
"""
|
"""
|
||||||
@@ -278,7 +274,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.
|
||||||
|
|
||||||
@@ -286,12 +296,17 @@ 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
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
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
|
||||||
@@ -301,10 +316,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()
|
||||||
|
|
||||||
@@ -382,7 +398,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()))
|
code, stderr.strip()), style='error')
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Non-blocking, open a background process
|
# Non-blocking, open a background process
|
||||||
@@ -692,18 +708,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()
|
||||||
|
|
||||||
@@ -802,3 +822,34 @@ 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):
|
||||||
|
"""
|
||||||
|
Set the terminal theme. This is a stub for what will eventually
|
||||||
|
support managing custom themes.
|
||||||
|
|
||||||
|
Check that the terminal supports the provided theme, and applies
|
||||||
|
the theme to the terminal if possible.
|
||||||
|
|
||||||
|
If the terminal doesn't support the theme, this falls back to the
|
||||||
|
default theme. The default theme only requires 8 colors so it
|
||||||
|
should be compatible with any terminal that supports basic colors.
|
||||||
|
"""
|
||||||
|
monochrome = (not curses.has_colors())
|
||||||
|
|
||||||
|
if theme is None:
|
||||||
|
theme = Theme(monochrome=monochrome)
|
||||||
|
|
||||||
|
theme.bind_curses()
|
||||||
|
|
||||||
|
# Apply the default color to the whole screen
|
||||||
|
self.stdscr.bkgd(str(' '), theme.get('normal'))
|
||||||
|
|
||||||
|
self.theme = theme
|
||||||
|
|||||||
113
rtv/theme.py
Normal file
113
rtv/theme.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"""
|
||||||
|
This file is a stub that contains the default RTV theme.
|
||||||
|
|
||||||
|
This will eventually be expanded to support loading/managing custom themes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import curses
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_THEME = {
|
||||||
|
'normal': (-1, -1, curses.A_NORMAL),
|
||||||
|
'bar_level_1': (curses.COLOR_MAGENTA, -1, curses.A_NORMAL),
|
||||||
|
'bar_level_1.selected': (curses.COLOR_MAGENTA, -1, curses.A_REVERSE),
|
||||||
|
'bar_level_2': (curses.COLOR_CYAN, -1, curses.A_NORMAL),
|
||||||
|
'bar_level_2.selected': (curses.COLOR_CYAN, -1, curses.A_REVERSE),
|
||||||
|
'bar_level_3': (curses.COLOR_GREEN, -1, curses.A_NORMAL),
|
||||||
|
'bar_level_3.selected': (curses.COLOR_GREEN, -1, curses.A_REVERSE),
|
||||||
|
'bar_level_4': (curses.COLOR_YELLOW, -1, curses.A_NORMAL),
|
||||||
|
'bar_level_4.selected': (curses.COLOR_YELLOW, -1, curses.A_REVERSE),
|
||||||
|
'comment_author': (curses.COLOR_BLUE, -1, curses.A_BOLD),
|
||||||
|
'comment_author_self': (curses.COLOR_GREEN, -1, curses.A_BOLD),
|
||||||
|
'comment_count': (-1, -1, curses.A_NORMAL),
|
||||||
|
'comment_text': (-1, -1, curses.A_NORMAL),
|
||||||
|
'created': (-1, -1, curses.A_NORMAL),
|
||||||
|
'cursor': (-1, -1, curses.A_NORMAL),
|
||||||
|
'cursor.selected': (-1, -1, curses.A_REVERSE),
|
||||||
|
'downvote': (curses.COLOR_RED, -1, curses.A_BOLD),
|
||||||
|
'gold': (curses.COLOR_YELLOW, -1, curses.A_BOLD),
|
||||||
|
'help_bar': (curses.COLOR_CYAN, -1, curses.A_BOLD | curses.A_REVERSE),
|
||||||
|
'hidden_comment_expand': (-1, -1, curses.A_BOLD),
|
||||||
|
'hidden_comment_text': (-1, -1, curses.A_NORMAL),
|
||||||
|
'multireddit_name': (curses.COLOR_YELLOW, -1, curses.A_BOLD),
|
||||||
|
'multireddit_text': (-1, -1, curses.A_NORMAL),
|
||||||
|
'neutral_vote': (-1, -1, curses.A_BOLD),
|
||||||
|
'notice_info': (-1, -1, curses.A_NORMAL),
|
||||||
|
'notice_loading': (-1, -1, curses.A_NORMAL),
|
||||||
|
'notice_error': (-1, -1, curses.A_NORMAL),
|
||||||
|
'notice_success': (-1, -1, curses.A_NORMAL),
|
||||||
|
'nsfw': (curses.COLOR_RED, -1, curses.A_BOLD | curses.A_REVERSE),
|
||||||
|
'order_bar': (curses.COLOR_YELLOW, -1, curses.A_BOLD),
|
||||||
|
'order_bar.selected': (curses.COLOR_YELLOW, -1, curses.A_BOLD | curses.A_REVERSE),
|
||||||
|
'prompt': (curses.COLOR_CYAN, -1, curses.A_BOLD | curses.A_REVERSE),
|
||||||
|
'saved': (curses.COLOR_GREEN, -1, curses.A_NORMAL),
|
||||||
|
'score': (-1, -1, curses.A_NORMAL),
|
||||||
|
'separator': (-1, -1, curses.A_BOLD),
|
||||||
|
'stickied': (curses.COLOR_GREEN, -1, curses.A_NORMAL),
|
||||||
|
'subscription_name': (curses.COLOR_YELLOW, -1, curses.A_BOLD),
|
||||||
|
'subscription_text': (-1, -1, curses.A_NORMAL),
|
||||||
|
'submission_author': (curses.COLOR_GREEN, -1, curses.A_NORMAL),
|
||||||
|
'submission_flair': (curses.COLOR_RED, -1, curses.A_NORMAL),
|
||||||
|
'submission_subreddit': (curses.COLOR_YELLOW, -1, curses.A_NORMAL),
|
||||||
|
'submission_text': (-1, -1, curses.A_NORMAL),
|
||||||
|
'submission_title': (-1, -1, curses.A_BOLD),
|
||||||
|
'title_bar': (curses.COLOR_CYAN, -1, curses.A_BOLD | curses.A_REVERSE),
|
||||||
|
'upvote': (curses.COLOR_GREEN, -1, curses.A_BOLD),
|
||||||
|
'url': (curses.COLOR_BLUE, -1, curses.A_UNDERLINE),
|
||||||
|
'url_seen': (curses.COLOR_MAGENTA, -1, curses.A_UNDERLINE),
|
||||||
|
'user_flair': (curses.COLOR_YELLOW, -1, curses.A_BOLD)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Theme(object):
|
||||||
|
|
||||||
|
BAR_LEVELS = ['bar_level_1', 'bar_level_2', 'bar_level_3', 'bar_level_4']
|
||||||
|
|
||||||
|
def __init__(self, monochrome=True):
|
||||||
|
|
||||||
|
self.monochrome = monochrome
|
||||||
|
self._modifier = None
|
||||||
|
self._elements = {}
|
||||||
|
self._color_pairs = {}
|
||||||
|
|
||||||
|
def bind_curses(self):
|
||||||
|
|
||||||
|
if self.monochrome:
|
||||||
|
# Skip initializing the colors and just use the attributes
|
||||||
|
self._elements = {key: val[2] for key, val in DEFAULT_THEME.items()}
|
||||||
|
return
|
||||||
|
|
||||||
|
# Shortcut for the default fg/bg
|
||||||
|
self._color_pairs[(-1, -1)] = curses.A_NORMAL
|
||||||
|
|
||||||
|
for key, (fg, bg, attr) in DEFAULT_THEME.items():
|
||||||
|
# Register the color pair for the element
|
||||||
|
if (fg, bg) not in self._color_pairs:
|
||||||
|
index = len(self._color_pairs) + 1
|
||||||
|
curses.init_pair(index, fg, bg)
|
||||||
|
self._color_pairs[(fg, bg)] = curses.color_pair(index)
|
||||||
|
|
||||||
|
self._elements[key] = self._color_pairs[(fg, bg)] | attr
|
||||||
|
|
||||||
|
def get(self, element, modifier=None):
|
||||||
|
|
||||||
|
modifier = modifier or self._modifier
|
||||||
|
if modifier:
|
||||||
|
modified_element = '{0}.{1}'.format(element, modifier)
|
||||||
|
if modified_element in self._elements:
|
||||||
|
return self._elements[modified_element]
|
||||||
|
|
||||||
|
return self._elements[element]
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def set_modifier(self, modifier=None):
|
||||||
|
|
||||||
|
# This case is undefined if the context manager is nested
|
||||||
|
assert self._modifier is None
|
||||||
|
|
||||||
|
self._modifier = modifier
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
self._modifier = None
|
||||||
@@ -52,6 +52,9 @@ class MockStdscr(mock.MagicMock):
|
|||||||
def getyx(self):
|
def getyx(self):
|
||||||
return self.y, self.x
|
return self.y, self.x
|
||||||
|
|
||||||
|
def getbegyx(self):
|
||||||
|
return 0, 0
|
||||||
|
|
||||||
def getmaxyx(self):
|
def getmaxyx(self):
|
||||||
return self.nlines, self.ncols
|
return self.nlines, self.ncols
|
||||||
|
|
||||||
@@ -154,12 +157,14 @@ def stdscr():
|
|||||||
patch('curses.curs_set'), \
|
patch('curses.curs_set'), \
|
||||||
patch('curses.init_pair'), \
|
patch('curses.init_pair'), \
|
||||||
patch('curses.color_pair'), \
|
patch('curses.color_pair'), \
|
||||||
|
patch('curses.has_colors'), \
|
||||||
patch('curses.start_color'), \
|
patch('curses.start_color'), \
|
||||||
patch('curses.use_default_colors'):
|
patch('curses.use_default_colors'):
|
||||||
out = MockStdscr(nlines=40, ncols=80, x=0, y=0)
|
out = MockStdscr(nlines=40, ncols=80, x=0, y=0)
|
||||||
curses.initscr.return_value = out
|
curses.initscr.return_value = out
|
||||||
curses.newwin.side_effect = lambda *args: out.derwin(*args)
|
curses.newwin.side_effect = lambda *args: out.derwin(*args)
|
||||||
curses.color_pair.return_value = 23
|
curses.color_pair.return_value = 23
|
||||||
|
curses.has_colors.return_value = True
|
||||||
curses.ACS_VLINE = 0
|
curses.ACS_VLINE = 0
|
||||||
yield out
|
yield out
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import requests
|
|||||||
from six.moves import reload_module
|
from six.moves import reload_module
|
||||||
|
|
||||||
from rtv import exceptions
|
from rtv import exceptions
|
||||||
from rtv.objects import Color, Controller, Navigator, Command, KeyMap, \
|
from rtv.objects import Controller, Navigator, Command, KeyMap, \
|
||||||
curses_session, patch_webbrowser
|
curses_session, patch_webbrowser
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -189,21 +189,6 @@ def test_objects_load_screen_nested_complex(terminal, stdscr, use_ascii):
|
|||||||
stdscr.subwin.addstr.assert_called_once_with(1, 1, error_message)
|
stdscr.subwin.addstr.assert_called_once_with(1, 1, error_message)
|
||||||
|
|
||||||
|
|
||||||
def test_objects_color(stdscr):
|
|
||||||
|
|
||||||
colors = ['RED', 'GREEN', 'YELLOW', 'BLUE', 'MAGENTA', 'CYAN', 'WHITE']
|
|
||||||
|
|
||||||
# Check that all colors start with the default value
|
|
||||||
for color in colors:
|
|
||||||
assert getattr(Color, color) == curses.A_NORMAL
|
|
||||||
|
|
||||||
Color.init()
|
|
||||||
|
|
||||||
# Check that all colors are populated
|
|
||||||
for color in colors:
|
|
||||||
assert getattr(Color, color) == 23
|
|
||||||
|
|
||||||
|
|
||||||
def test_objects_curses_session(stdscr):
|
def test_objects_curses_session(stdscr):
|
||||||
|
|
||||||
# Normal setup and cleanup
|
# Normal setup and cleanup
|
||||||
|
|||||||
@@ -79,15 +79,16 @@ def test_submission_page_construct(reddit, terminal, config, oauth):
|
|||||||
# Comment
|
# Comment
|
||||||
comment_data = page.content.get(0)
|
comment_data = page.content.get(0)
|
||||||
text = comment_data['split_body'][0].encode('utf-8')
|
text = comment_data['split_body'][0].encode('utf-8')
|
||||||
window.subwin.addstr.assert_any_call(1, 1, text)
|
window.subwin.addstr.assert_any_call(1, 1, text, curses.A_NORMAL)
|
||||||
|
|
||||||
# More Comments
|
# More Comments
|
||||||
comment_data = page.content.get(1)
|
comment_data = page.content.get(1)
|
||||||
text = comment_data['body'].encode('utf-8')
|
text = comment_data['body'].encode('utf-8')
|
||||||
window.subwin.addstr.assert_any_call(0, 1, text)
|
window.subwin.addstr.assert_any_call(0, 1, text, curses.A_NORMAL)
|
||||||
|
|
||||||
# Cursor should not be drawn when the page is first opened
|
# Cursor should not be drawn when the page is first opened
|
||||||
assert not window.subwin.chgat.called
|
# TODO: Add a new test for this
|
||||||
|
# assert not window.subwin.chgat.called
|
||||||
|
|
||||||
# Reload with a smaller terminal window
|
# Reload with a smaller terminal window
|
||||||
terminal.stdscr.ncols = 20
|
terminal.stdscr.ncols = 20
|
||||||
@@ -264,7 +265,7 @@ def test_submission_comment_not_enough_space(submission_page, terminal):
|
|||||||
|
|
||||||
text = '(Not enough space to display)'.encode('ascii')
|
text = '(Not enough space to display)'.encode('ascii')
|
||||||
window = terminal.stdscr.subwin
|
window = terminal.stdscr.subwin
|
||||||
window.subwin.addstr.assert_any_call(6, 1, text)
|
window.subwin.addstr.assert_any_call(6, 1, text, curses.A_NORMAL)
|
||||||
|
|
||||||
|
|
||||||
def test_submission_vote(submission_page, refresh_token):
|
def test_submission_vote(submission_page, refresh_token):
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import curses
|
||||||
|
|
||||||
import six
|
import six
|
||||||
|
|
||||||
from rtv import __version__
|
from rtv import __version__
|
||||||
@@ -38,7 +40,7 @@ def test_subreddit_page_construct(reddit, terminal, config, oauth):
|
|||||||
window.subwin.addstr.assert_any_call(0, 1, text, 2097152)
|
window.subwin.addstr.assert_any_call(0, 1, text, 2097152)
|
||||||
|
|
||||||
# Cursor should have been drawn
|
# Cursor should have been drawn
|
||||||
assert window.subwin.chgat.called
|
window.subwin.addch.assert_any_call(0, 0, ' ', curses.A_REVERSE)
|
||||||
|
|
||||||
# Reload with a smaller terminal window
|
# Reload with a smaller terminal window
|
||||||
terminal.stdscr.ncols = 20
|
terminal.stdscr.ncols = 20
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ def test_subscription_page_construct(reddit, terminal, config, oauth,
|
|||||||
window.addstr.assert_any_call(0, 0, menu)
|
window.addstr.assert_any_call(0, 0, menu)
|
||||||
|
|
||||||
# Cursor - 2 lines
|
# Cursor - 2 lines
|
||||||
window.subwin.chgat.assert_any_call(0, 0, 1, 262144)
|
window.subwin.addch.assert_any_call(0, 0, ' ', 262144)
|
||||||
window.subwin.chgat.assert_any_call(1, 0, 1, 262144)
|
window.subwin.addch.assert_any_call(1, 0, ' ', 262144)
|
||||||
|
|
||||||
# Reload with a smaller terminal window
|
# Reload with a smaller terminal window
|
||||||
terminal.stdscr.ncols = 20
|
terminal.stdscr.ncols = 20
|
||||||
|
|||||||
@@ -20,14 +20,10 @@ except ImportError:
|
|||||||
|
|
||||||
def test_terminal_properties(terminal, config):
|
def test_terminal_properties(terminal, config):
|
||||||
|
|
||||||
assert len(terminal.up_arrow) == 2
|
assert isinstance(terminal.up_arrow, six.text_type)
|
||||||
assert isinstance(terminal.up_arrow[0], six.text_type)
|
assert isinstance(terminal.down_arrow, six.text_type)
|
||||||
assert len(terminal.down_arrow) == 2
|
assert isinstance(terminal.neutral_arrow, six.text_type)
|
||||||
assert isinstance(terminal.down_arrow[0], six.text_type)
|
assert isinstance(terminal.guilded, six.text_type)
|
||||||
assert len(terminal.neutral_arrow) == 2
|
|
||||||
assert isinstance(terminal.neutral_arrow[0], six.text_type)
|
|
||||||
assert len(terminal.guilded) == 2
|
|
||||||
assert isinstance(terminal.guilded[0], six.text_type)
|
|
||||||
|
|
||||||
terminal._display = None
|
terminal._display = None
|
||||||
with mock.patch('rtv.terminal.sys') as sys, \
|
with mock.patch('rtv.terminal.sys') as sys, \
|
||||||
|
|||||||
Reference in New Issue
Block a user