Merge pull request #195 from michael-lazar/new_keybindings_attempt
New keybindings attempt
This commit is contained in:
@@ -14,6 +14,7 @@ from .oauth import OAuthHelper
|
|||||||
from .terminal import Terminal
|
from .terminal import Terminal
|
||||||
from .objects import curses_session
|
from .objects import curses_session
|
||||||
from .subreddit import SubredditPage
|
from .subreddit import SubredditPage
|
||||||
|
from .exceptions import ConfigError
|
||||||
from .__version__ import __version__
|
from .__version__ import __version__
|
||||||
|
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ _logger = logging.getLogger(__name__)
|
|||||||
# ptrace_scope to 0 in /etc/sysctl.d/10-ptrace.conf.
|
# ptrace_scope to 0 in /etc/sysctl.d/10-ptrace.conf.
|
||||||
# http://blog.mellenthin.de/archives/2010/10/18/gdb-attach-fails
|
# http://blog.mellenthin.de/archives/2010/10/18/gdb-attach-fails
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"Main entry point"
|
"Main entry point"
|
||||||
|
|
||||||
@@ -38,13 +40,17 @@ def main():
|
|||||||
sys.stdout.write('\x1b]2;{0}\x07'.format(title))
|
sys.stdout.write('\x1b]2;{0}\x07'.format(title))
|
||||||
|
|
||||||
args = Config.get_args()
|
args = Config.get_args()
|
||||||
fargs = Config.get_file(args.get('config'))
|
fargs, bindings = Config.get_file(args.get('config'))
|
||||||
|
|
||||||
# Apply the file config first, then overwrite with any command line args
|
# Apply the file config first, then overwrite with any command line args
|
||||||
config = Config()
|
config = Config()
|
||||||
config.update(**fargs)
|
config.update(**fargs)
|
||||||
config.update(**args)
|
config.update(**args)
|
||||||
|
|
||||||
|
# If key bindings are supplied in the config file, overwrite the defaults
|
||||||
|
if bindings:
|
||||||
|
config.keymap.set_bindings(bindings)
|
||||||
|
|
||||||
# Copy the default config file and quit
|
# Copy the default config file and quit
|
||||||
if config['copy_config']:
|
if config['copy_config']:
|
||||||
copy_default_config()
|
copy_default_config()
|
||||||
@@ -101,6 +107,9 @@ def main():
|
|||||||
# Launch the subreddit page
|
# Launch the subreddit page
|
||||||
page.loop()
|
page.loop()
|
||||||
|
|
||||||
|
except ConfigError as e:
|
||||||
|
_logger.exception(e)
|
||||||
|
print(e)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.exception(e)
|
_logger.exception(e)
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import six
|
|||||||
from six.moves import configparser
|
from six.moves import configparser
|
||||||
|
|
||||||
from . import docs, __version__
|
from . import docs, __version__
|
||||||
|
from .objects import KeyMap
|
||||||
|
|
||||||
PACKAGE = os.path.dirname(__file__)
|
PACKAGE = os.path.dirname(__file__)
|
||||||
HOME = os.path.expanduser('~')
|
HOME = os.path.expanduser('~')
|
||||||
@@ -112,7 +113,10 @@ class Config(object):
|
|||||||
self.history_file = history_file
|
self.history_file = history_file
|
||||||
self.token_file = token_file
|
self.token_file = token_file
|
||||||
self.config = kwargs
|
self.config = kwargs
|
||||||
self.default = self.get_file(DEFAULT_CONFIG)
|
|
||||||
|
default, bindings = self.get_file(DEFAULT_CONFIG)
|
||||||
|
self.default = default
|
||||||
|
self.keymap = KeyMap(bindings)
|
||||||
|
|
||||||
# `refresh_token` and `history` are saved/loaded at separate locations,
|
# `refresh_token` and `history` are saved/loaded at separate locations,
|
||||||
# so they are treated differently from the rest of the config options.
|
# so they are treated differently from the rest of the config options.
|
||||||
@@ -198,9 +202,9 @@ class Config(object):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_rtv_file(config):
|
def _parse_rtv_file(config):
|
||||||
|
|
||||||
out = {}
|
rtv = {}
|
||||||
if config.has_section('rtv'):
|
if config.has_section('rtv'):
|
||||||
out = dict(config.items('rtv'))
|
rtv = dict(config.items('rtv'))
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
'ascii': partial(config.getboolean, 'rtv'),
|
'ascii': partial(config.getboolean, 'rtv'),
|
||||||
@@ -208,13 +212,20 @@ class Config(object):
|
|||||||
'persistent': partial(config.getboolean, 'rtv'),
|
'persistent': partial(config.getboolean, 'rtv'),
|
||||||
'history_size': partial(config.getint, 'rtv'),
|
'history_size': partial(config.getint, 'rtv'),
|
||||||
'oauth_redirect_port': partial(config.getint, 'rtv'),
|
'oauth_redirect_port': partial(config.getint, 'rtv'),
|
||||||
'oauth_scope': lambda x: out[x].split(',')
|
'oauth_scope': lambda x: rtv[x].split(',')
|
||||||
}
|
}
|
||||||
|
|
||||||
for key, func in params.items():
|
for key, func in params.items():
|
||||||
if key in out:
|
if key in rtv:
|
||||||
out[key] = func(key)
|
rtv[key] = func(key)
|
||||||
return out
|
|
||||||
|
bindings = {}
|
||||||
|
if config.has_section('bindings'):
|
||||||
|
bindings = dict(config.items('bindings'))
|
||||||
|
|
||||||
|
for name, keys in bindings.items():
|
||||||
|
bindings[name] = [key.strip() for key in keys.split(',')]
|
||||||
|
|
||||||
|
return rtv, bindings
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _ensure_filepath(filename):
|
def _ensure_filepath(filename):
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ class EscapeInterrupt(Exception):
|
|||||||
"Signal that the ESC key has been pressed"
|
"Signal that the ESC key has been pressed"
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigError(Exception):
|
||||||
|
"There was a problem with the configuration"
|
||||||
|
|
||||||
|
|
||||||
class RTVError(Exception):
|
class RTVError(Exception):
|
||||||
"Base RTV error class"
|
"Base RTV error class"
|
||||||
|
|
||||||
|
|||||||
124
rtv/objects.py
124
rtv/objects.py
@@ -1,14 +1,16 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import re
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import curses
|
|
||||||
import signal
|
import signal
|
||||||
import inspect
|
import inspect
|
||||||
import weakref
|
import weakref
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
|
import curses
|
||||||
|
import curses.ascii
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
|
||||||
import six
|
import six
|
||||||
@@ -502,6 +504,11 @@ class Controller(object):
|
|||||||
>>> def func(self, *args)
|
>>> def func(self, *args)
|
||||||
>>> ...
|
>>> ...
|
||||||
|
|
||||||
|
Register a KeyBinding that can be defined later by the config file
|
||||||
|
>>> @Controller.register(Command("UPVOTE"))
|
||||||
|
>>> def upvote(self, *args)
|
||||||
|
>> ...
|
||||||
|
|
||||||
Register a default behavior by using `None`.
|
Register a default behavior by using `None`.
|
||||||
>>> @Controller.register(None)
|
>>> @Controller.register(None)
|
||||||
>>> def default_func(self, *args)
|
>>> def default_func(self, *args)
|
||||||
@@ -515,13 +522,34 @@ class Controller(object):
|
|||||||
|
|
||||||
character_map = {}
|
character_map = {}
|
||||||
|
|
||||||
def __init__(self, instance):
|
def __init__(self, instance, keymap=None):
|
||||||
|
|
||||||
self.instance = instance
|
self.instance = instance
|
||||||
# Build a list of parent controllers that follow the object's MRO to
|
# Build a list of parent controllers that follow the object's MRO
|
||||||
# check if any parent controllers have registered the keypress
|
# to check if any parent controllers have registered the keypress
|
||||||
self.parents = inspect.getmro(type(self))[:-1]
|
self.parents = inspect.getmro(type(self))[:-1]
|
||||||
|
|
||||||
|
if not keymap:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Go through the controller and all of it's parents and look for
|
||||||
|
# Command objects in the character map. Use the keymap the lookup the
|
||||||
|
# keys associated with those command objects and add them to the
|
||||||
|
# character map.
|
||||||
|
for controller in self.parents:
|
||||||
|
for command, func in controller.character_map.copy().items():
|
||||||
|
if isinstance(command, Command):
|
||||||
|
for key in keymap.get(command):
|
||||||
|
val = keymap.parse(key)
|
||||||
|
# Check if the key is already programmed to trigger a
|
||||||
|
# different function.
|
||||||
|
if controller.character_map.get(val, func) != func:
|
||||||
|
raise exceptions.ConfigError(
|
||||||
|
"Invalid configuration! `%s` is bound to "
|
||||||
|
"duplicate commands in the "
|
||||||
|
"%s" % (key, controller.__name__))
|
||||||
|
controller.character_map[val] = func
|
||||||
|
|
||||||
def trigger(self, char, *args, **kwargs):
|
def trigger(self, char, *args, **kwargs):
|
||||||
|
|
||||||
if isinstance(char, six.string_types) and len(char) == 1:
|
if isinstance(char, six.string_types) and len(char) == 1:
|
||||||
@@ -551,4 +579,90 @@ class Controller(object):
|
|||||||
else:
|
else:
|
||||||
cls.character_map[char] = f
|
cls.character_map[char] = f
|
||||||
return f
|
return f
|
||||||
return inner
|
return inner
|
||||||
|
|
||||||
|
|
||||||
|
class Command(object):
|
||||||
|
"""
|
||||||
|
Minimal class that should be used to wrap abstract commands that may be
|
||||||
|
implemented as one or more physical keystrokes.
|
||||||
|
|
||||||
|
E.g. Command("REFRESH") can be represented by the KeyMap to be triggered
|
||||||
|
by either `r` or `F5`
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, val):
|
||||||
|
self.val = val.upper()
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return 'Command(%s)' % self.val
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return repr(self) == repr(other)
|
||||||
|
|
||||||
|
def __ne__(self, other):
|
||||||
|
return not self == other
|
||||||
|
|
||||||
|
def __hash__(self):
|
||||||
|
return hash(repr(self))
|
||||||
|
|
||||||
|
|
||||||
|
class KeyMap(object):
|
||||||
|
"""
|
||||||
|
Mapping between commands and the keys that they represent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, bindings):
|
||||||
|
self._keymap = None
|
||||||
|
self.set_bindings(bindings)
|
||||||
|
|
||||||
|
def set_bindings(self, bindings):
|
||||||
|
# Clear the keymap before applying the bindings to avoid confusion.
|
||||||
|
# If a user defines custom bindings in their config file, they must
|
||||||
|
# explicitly define ALL of the bindings.
|
||||||
|
self._keymap = {}
|
||||||
|
for command, keys in bindings.items():
|
||||||
|
if not isinstance(command, Command):
|
||||||
|
command = Command(command)
|
||||||
|
self._keymap[command] = keys
|
||||||
|
|
||||||
|
def get(self, command):
|
||||||
|
if not isinstance(command, Command):
|
||||||
|
command = Command(command)
|
||||||
|
try:
|
||||||
|
return self._keymap[command]
|
||||||
|
except KeyError:
|
||||||
|
raise exceptions.ConfigError('Invalid configuration! `%s` key is '
|
||||||
|
'undefined' % command.val)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse(key):
|
||||||
|
"""
|
||||||
|
Parse a key represented by a string and return its character code.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
if isinstance(key, int):
|
||||||
|
return key
|
||||||
|
elif re.match('[<]KEY_.*[>]', key):
|
||||||
|
# Curses control character
|
||||||
|
return getattr(curses, key[1:-1])
|
||||||
|
elif re.match('[<].*[>]', key):
|
||||||
|
# Ascii control character
|
||||||
|
return getattr(curses.ascii, key[1:-1])
|
||||||
|
elif key.startswith('0x'):
|
||||||
|
# Ascii hex code
|
||||||
|
return int(key, 16)
|
||||||
|
else:
|
||||||
|
# Ascii character
|
||||||
|
code = ord(key)
|
||||||
|
if 0 <= code <= 255:
|
||||||
|
return code
|
||||||
|
# Python 3.3 has a curses.get_wch() function that we can use
|
||||||
|
# for unicode keys, but Python 2.7 is limited to ascii.
|
||||||
|
raise exceptions.ConfigError('Invalid configuration! `%s` is '
|
||||||
|
'not in the ascii range' % key)
|
||||||
|
|
||||||
|
except (AttributeError, ValueError, TypeError):
|
||||||
|
raise exceptions.ConfigError('Invalid configuration! "%s" is not a '
|
||||||
|
'valid key' % key)
|
||||||
38
rtv/page.py
38
rtv/page.py
@@ -9,7 +9,7 @@ from functools import wraps
|
|||||||
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
|
from .objects import Controller, Color, Command
|
||||||
|
|
||||||
|
|
||||||
def logged_in(f):
|
def logged_in(f):
|
||||||
@@ -68,60 +68,60 @@ class Page(object):
|
|||||||
ch = self.term.stdscr.getch()
|
ch = self.term.stdscr.getch()
|
||||||
self.controller.trigger(ch)
|
self.controller.trigger(ch)
|
||||||
|
|
||||||
@PageController.register('q')
|
@PageController.register(Command('EXIT'))
|
||||||
def exit(self):
|
def exit(self):
|
||||||
if self.term.prompt_y_or_n('Do you really want to quit? (y/n): '):
|
if self.term.prompt_y_or_n('Do you really want to quit? (y/n): '):
|
||||||
sys.exit()
|
sys.exit()
|
||||||
|
|
||||||
@PageController.register('Q')
|
@PageController.register(Command('FORCE_EXIT'))
|
||||||
def force_exit(self):
|
def force_exit(self):
|
||||||
sys.exit()
|
sys.exit()
|
||||||
|
|
||||||
@PageController.register('?')
|
@PageController.register(Command('HELP'))
|
||||||
def show_help(self):
|
def show_help(self):
|
||||||
self.term.show_notification(docs.HELP.strip().splitlines())
|
self.term.show_notification(docs.HELP.strip().splitlines())
|
||||||
|
|
||||||
@PageController.register('1')
|
@PageController.register(Command('SORT_HOT'))
|
||||||
def sort_content_hot(self):
|
def sort_content_hot(self):
|
||||||
self.refresh_content(order='hot')
|
self.refresh_content(order='hot')
|
||||||
|
|
||||||
@PageController.register('2')
|
@PageController.register(Command('SORT_TOP'))
|
||||||
def sort_content_top(self):
|
def sort_content_top(self):
|
||||||
self.refresh_content(order='top')
|
self.refresh_content(order='top')
|
||||||
|
|
||||||
@PageController.register('3')
|
@PageController.register(Command('SORT_RISING'))
|
||||||
def sort_content_rising(self):
|
def sort_content_rising(self):
|
||||||
self.refresh_content(order='rising')
|
self.refresh_content(order='rising')
|
||||||
|
|
||||||
@PageController.register('4')
|
@PageController.register(Command('SORT_NEW'))
|
||||||
def sort_content_new(self):
|
def sort_content_new(self):
|
||||||
self.refresh_content(order='new')
|
self.refresh_content(order='new')
|
||||||
|
|
||||||
@PageController.register('5')
|
@PageController.register(Command('SORT_CONTROVERSIAL'))
|
||||||
def sort_content_controversial(self):
|
def sort_content_controversial(self):
|
||||||
self.refresh_content(order='controversial')
|
self.refresh_content(order='controversial')
|
||||||
|
|
||||||
@PageController.register(curses.KEY_UP, 'k')
|
@PageController.register(Command('MOVE_UP'))
|
||||||
def move_cursor_up(self):
|
def move_cursor_up(self):
|
||||||
self._move_cursor(-1)
|
self._move_cursor(-1)
|
||||||
self.clear_input_queue()
|
self.clear_input_queue()
|
||||||
|
|
||||||
@PageController.register(curses.KEY_DOWN, 'j')
|
@PageController.register(Command('MOVE_DOWN'))
|
||||||
def move_cursor_down(self):
|
def move_cursor_down(self):
|
||||||
self._move_cursor(1)
|
self._move_cursor(1)
|
||||||
self.clear_input_queue()
|
self.clear_input_queue()
|
||||||
|
|
||||||
@PageController.register('m', curses.KEY_PPAGE)
|
@PageController.register(Command('PAGE_UP'))
|
||||||
def move_page_up(self):
|
def move_page_up(self):
|
||||||
self._move_page(-1)
|
self._move_page(-1)
|
||||||
self.clear_input_queue()
|
self.clear_input_queue()
|
||||||
|
|
||||||
@PageController.register('n', curses.KEY_NPAGE)
|
@PageController.register(Command('PAGE_DOWN'))
|
||||||
def move_page_down(self):
|
def move_page_down(self):
|
||||||
self._move_page(1)
|
self._move_page(1)
|
||||||
self.clear_input_queue()
|
self.clear_input_queue()
|
||||||
|
|
||||||
@PageController.register('a')
|
@PageController.register(Command('UPVOTE'))
|
||||||
@logged_in
|
@logged_in
|
||||||
def upvote(self):
|
def upvote(self):
|
||||||
data = self.content.get(self.nav.absolute_index)
|
data = self.content.get(self.nav.absolute_index)
|
||||||
@@ -138,7 +138,7 @@ class Page(object):
|
|||||||
if not self.term.loader.exception:
|
if not self.term.loader.exception:
|
||||||
data['likes'] = True
|
data['likes'] = True
|
||||||
|
|
||||||
@PageController.register('z')
|
@PageController.register(Command('DOWNVOTE'))
|
||||||
@logged_in
|
@logged_in
|
||||||
def downvote(self):
|
def downvote(self):
|
||||||
data = self.content.get(self.nav.absolute_index)
|
data = self.content.get(self.nav.absolute_index)
|
||||||
@@ -155,7 +155,7 @@ class Page(object):
|
|||||||
if not self.term.loader.exception:
|
if not self.term.loader.exception:
|
||||||
data['likes'] = None
|
data['likes'] = None
|
||||||
|
|
||||||
@PageController.register('u')
|
@PageController.register(Command('LOGIN'))
|
||||||
def login(self):
|
def login(self):
|
||||||
"""
|
"""
|
||||||
Prompt to log into the user's account, or log out of the current
|
Prompt to log into the user's account, or log out of the current
|
||||||
@@ -169,7 +169,7 @@ class Page(object):
|
|||||||
else:
|
else:
|
||||||
self.oauth.authorize()
|
self.oauth.authorize()
|
||||||
|
|
||||||
@PageController.register('d')
|
@PageController.register(Command('DELETE'))
|
||||||
@logged_in
|
@logged_in
|
||||||
def delete_item(self):
|
def delete_item(self):
|
||||||
"""
|
"""
|
||||||
@@ -193,7 +193,7 @@ class Page(object):
|
|||||||
if self.term.loader.exception is None:
|
if self.term.loader.exception is None:
|
||||||
self.refresh_content()
|
self.refresh_content()
|
||||||
|
|
||||||
@PageController.register('e')
|
@PageController.register(Command('EDIT'))
|
||||||
@logged_in
|
@logged_in
|
||||||
def edit(self):
|
def edit(self):
|
||||||
"""
|
"""
|
||||||
@@ -228,7 +228,7 @@ class Page(object):
|
|||||||
if self.term.loader.exception is None:
|
if self.term.loader.exception is None:
|
||||||
self.refresh_content()
|
self.refresh_content()
|
||||||
|
|
||||||
@PageController.register('i')
|
@PageController.register(Command('INBOX'))
|
||||||
@logged_in
|
@logged_in
|
||||||
def get_inbox(self):
|
def get_inbox(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
72
rtv/rtv.cfg
72
rtv/rtv.cfg
@@ -49,4 +49,74 @@ oauth_redirect_uri = http://127.0.0.1:65000/
|
|||||||
oauth_redirect_port = 65000
|
oauth_redirect_port = 65000
|
||||||
|
|
||||||
; Access permissions that will be requested.
|
; Access permissions that will be requested.
|
||||||
oauth_scope = edit,history,identity,mysubreddits,privatemessages,read,report,save,submit,subscribe,vote
|
oauth_scope = edit,history,identity,mysubreddits,privatemessages,read,report,save,submit,subscribe,vote
|
||||||
|
|
||||||
|
[bindings]
|
||||||
|
##############
|
||||||
|
# Key Bindings
|
||||||
|
##############
|
||||||
|
; If you would like to define custom bindings, copy this section into your
|
||||||
|
; config file with the [bindings] heading. All commands must be bound to at
|
||||||
|
; least one key for the config to be valid.
|
||||||
|
;
|
||||||
|
; 1.) Plain keys can be represented by either uppercase/lowercase characters
|
||||||
|
; or the hexadecimal numbers referring their ascii codes. For reference, see
|
||||||
|
; https://en.wikipedia.org/wiki/ASCII#ASCII_printable_code_chart
|
||||||
|
; e.g. Q, q, 1, ?
|
||||||
|
; e.g. 0x20 (space), 0x3c (less-than sign)
|
||||||
|
;
|
||||||
|
; 2.) Special ascii control codes should be surrounded with <>. For reference,
|
||||||
|
; see https://en.wikipedia.org/wiki/ASCII#ASCII_control_code_chart
|
||||||
|
; e.g. <LF> (enter), <ESC> (escape)
|
||||||
|
;
|
||||||
|
; 3.) Other special keys are defined by curses, they should be surrounded by <>
|
||||||
|
; and prefixed with KEY_. For reference, see
|
||||||
|
; https://docs.python.org/2/library/curses.html#constants
|
||||||
|
; e.g. <KEY_LEFT> (left arrow), <KEY_F5>, <KEY_NPAGE> (page down)
|
||||||
|
;
|
||||||
|
; Notes:
|
||||||
|
; - Curses <KEY_ENTER> is unreliable and should always be used in conjunction
|
||||||
|
; with <LF>.
|
||||||
|
; - Use 0x20 for the space key.
|
||||||
|
; - A subset of Ctrl modifiers are available through the ascii control codes.
|
||||||
|
; For example, Ctrl-D will trigger an <EOT> signal. See the table above for
|
||||||
|
; a complete reference.
|
||||||
|
|
||||||
|
; Base page
|
||||||
|
EXIT = q
|
||||||
|
FORCE_EXIT = Q
|
||||||
|
HELP = ?
|
||||||
|
SORT_HOT = 1
|
||||||
|
SORT_TOP = 2
|
||||||
|
SORT_RISING = 3
|
||||||
|
SORT_NEW = 4
|
||||||
|
SORT_CONTROVERSIAL = 5
|
||||||
|
MOVE_UP = k, <KEY_UP>
|
||||||
|
MOVE_DOWN = j, <KEY_DOWN>
|
||||||
|
PAGE_UP = m, <KEY_PPAGE>, <NAK>
|
||||||
|
PAGE_DOWN = n, <KEY_NPAGE>, <EOT>
|
||||||
|
UPVOTE = a
|
||||||
|
DOWNVOTE = z
|
||||||
|
LOGIN = u
|
||||||
|
DELETE = d
|
||||||
|
EDIT = e
|
||||||
|
INBOX = i
|
||||||
|
REFRESH = r, <KEY_F5>
|
||||||
|
|
||||||
|
; Submission page
|
||||||
|
SUBMISSION_TOGGLE_COMMENT = l, 0x20, <KEY_RIGHT>
|
||||||
|
SUBMISSION_OPEN_IN_BROWSER = o, <LF>, <KEY_ENTER>
|
||||||
|
SUBMISSION_POST = c
|
||||||
|
SUBMISSION_EXIT = h, <KEY_LEFT>
|
||||||
|
|
||||||
|
; Subreddit page
|
||||||
|
SUBREDDIT_SEARCH = f
|
||||||
|
SUBREDDIT_PROMPT = /
|
||||||
|
SUBREDDIT_POST = c
|
||||||
|
SUBREDDIT_OPEN = l, <KEY_RIGHT>
|
||||||
|
SUBREDDIT_OPEN_IN_BROWSER = o, <LF>, <KEY_ENTER>, <KEY_ENTER>
|
||||||
|
SUBREDDIT_OPEN_SUBSCRIPTIONS = s
|
||||||
|
|
||||||
|
; Subscription page
|
||||||
|
SUBSCRIPTION_SELECT = l, <LF>, <KEY_ENTER>, <KEY_RIGHT>
|
||||||
|
SUBSCRIPTION_EXIT = h, s, <ESC>, <KEY_LEFT>
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ import curses
|
|||||||
from . import docs
|
from . import docs
|
||||||
from .content import SubmissionContent
|
from .content import SubmissionContent
|
||||||
from .page import Page, PageController, logged_in
|
from .page import Page, PageController, logged_in
|
||||||
from .objects import Navigator, Color
|
from .objects import Navigator, Color, Command
|
||||||
from .terminal import Terminal
|
|
||||||
|
|
||||||
|
|
||||||
class SubmissionController(PageController):
|
class SubmissionController(PageController):
|
||||||
@@ -20,16 +19,15 @@ class SubmissionPage(Page):
|
|||||||
def __init__(self, reddit, term, config, oauth, url=None, submission=None):
|
def __init__(self, reddit, term, config, oauth, url=None, submission=None):
|
||||||
super(SubmissionPage, self).__init__(reddit, term, config, oauth)
|
super(SubmissionPage, self).__init__(reddit, term, config, oauth)
|
||||||
|
|
||||||
|
self.controller = SubmissionController(self, keymap=config.keymap)
|
||||||
if url:
|
if url:
|
||||||
self.content = SubmissionContent.from_url(reddit, url, term.loader)
|
self.content = SubmissionContent.from_url(reddit, url, term.loader)
|
||||||
else:
|
else:
|
||||||
self.content = SubmissionContent(submission, term.loader)
|
self.content = SubmissionContent(submission, term.loader)
|
||||||
|
|
||||||
self.controller = SubmissionController(self)
|
|
||||||
# Start at the submission post, which is indexed as -1
|
# Start at the submission post, which is indexed as -1
|
||||||
self.nav = Navigator(self.content.get, page_index=-1)
|
self.nav = Navigator(self.content.get, page_index=-1)
|
||||||
|
|
||||||
@SubmissionController.register(curses.KEY_RIGHT, 'l', ' ')
|
@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"
|
||||||
|
|
||||||
@@ -41,13 +39,13 @@ class SubmissionPage(Page):
|
|||||||
# causes the cursor index to go out of bounds.
|
# causes the cursor index to go out of bounds.
|
||||||
self.nav.page_index, self.nav.cursor_index = current_index, 0
|
self.nav.page_index, self.nav.cursor_index = current_index, 0
|
||||||
|
|
||||||
@SubmissionController.register(curses.KEY_LEFT, 'h')
|
@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(curses.KEY_F5, 'r')
|
@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"
|
||||||
|
|
||||||
@@ -60,7 +58,7 @@ class SubmissionPage(Page):
|
|||||||
if not self.term.loader.exception:
|
if not self.term.loader.exception:
|
||||||
self.nav = Navigator(self.content.get, page_index=-1)
|
self.nav = Navigator(self.content.get, page_index=-1)
|
||||||
|
|
||||||
@SubmissionController.register(curses.KEY_ENTER, Terminal.RETURN, 'o')
|
@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 webbrowser"
|
||||||
|
|
||||||
@@ -71,7 +69,7 @@ class SubmissionPage(Page):
|
|||||||
else:
|
else:
|
||||||
self.term.flash()
|
self.term.flash()
|
||||||
|
|
||||||
@SubmissionController.register('c')
|
@SubmissionController.register(Command('SUBMISSION_POST'))
|
||||||
@logged_in
|
@logged_in
|
||||||
def add_comment(self):
|
def add_comment(self):
|
||||||
"""
|
"""
|
||||||
@@ -114,7 +112,7 @@ class SubmissionPage(Page):
|
|||||||
if not self.term.loader.exception:
|
if not self.term.loader.exception:
|
||||||
self.refresh_content()
|
self.refresh_content()
|
||||||
|
|
||||||
@SubmissionController.register('d')
|
@SubmissionController.register(Command('DELETE'))
|
||||||
@logged_in
|
@logged_in
|
||||||
def delete_comment(self):
|
def delete_comment(self):
|
||||||
"Delete a comment as long as it is not the current submission"
|
"Delete a comment as long as it is not the current submission"
|
||||||
|
|||||||
@@ -7,10 +7,9 @@ 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
|
from .objects import Navigator, Color, Command
|
||||||
from .submission import SubmissionPage
|
from .submission import SubmissionPage
|
||||||
from .subscription import SubscriptionPage
|
from .subscription import SubscriptionPage
|
||||||
from .terminal import Terminal
|
|
||||||
|
|
||||||
|
|
||||||
class SubredditController(PageController):
|
class SubredditController(PageController):
|
||||||
@@ -27,11 +26,11 @@ class SubredditPage(Page):
|
|||||||
"""
|
"""
|
||||||
super(SubredditPage, self).__init__(reddit, term, config, oauth)
|
super(SubredditPage, self).__init__(reddit, term, config, oauth)
|
||||||
|
|
||||||
|
self.controller = SubredditController(self, keymap=config.keymap)
|
||||||
self.content = SubredditContent.from_name(reddit, name, term.loader)
|
self.content = SubredditContent.from_name(reddit, name, term.loader)
|
||||||
self.controller = SubredditController(self)
|
|
||||||
self.nav = Navigator(self.content.get)
|
self.nav = Navigator(self.content.get)
|
||||||
|
|
||||||
@SubredditController.register(curses.KEY_F5, 'r')
|
@SubredditController.register(Command('REFRESH'))
|
||||||
def refresh_content(self, order=None, name=None):
|
def refresh_content(self, order=None, name=None):
|
||||||
"Re-download all submissions and reset the page index"
|
"Re-download all submissions and reset the page index"
|
||||||
|
|
||||||
@@ -49,7 +48,7 @@ class SubredditPage(Page):
|
|||||||
if not self.term.loader.exception:
|
if not self.term.loader.exception:
|
||||||
self.nav = Navigator(self.content.get)
|
self.nav = Navigator(self.content.get)
|
||||||
|
|
||||||
@SubredditController.register('f')
|
@SubredditController.register(Command('SUBREDDIT_SEARCH'))
|
||||||
def search_subreddit(self, name=None):
|
def search_subreddit(self, name=None):
|
||||||
"Open a prompt to search the given subreddit"
|
"Open a prompt to search the given subreddit"
|
||||||
|
|
||||||
@@ -65,7 +64,7 @@ class SubredditPage(Page):
|
|||||||
if not self.term.loader.exception:
|
if not self.term.loader.exception:
|
||||||
self.nav = Navigator(self.content.get)
|
self.nav = Navigator(self.content.get)
|
||||||
|
|
||||||
@SubredditController.register('/')
|
@SubredditController.register(Command('SUBREDDIT_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"
|
||||||
|
|
||||||
@@ -73,7 +72,7 @@ class SubredditPage(Page):
|
|||||||
if name is not None:
|
if name is not None:
|
||||||
self.refresh_content(order='ignore', name=name)
|
self.refresh_content(order='ignore', name=name)
|
||||||
|
|
||||||
@SubredditController.register(curses.KEY_RIGHT, 'l')
|
@SubredditController.register(Command('SUBREDDIT_OPEN'))
|
||||||
def open_submission(self, url=None):
|
def open_submission(self, url=None):
|
||||||
"Select the current submission to view posts"
|
"Select the current submission to view posts"
|
||||||
|
|
||||||
@@ -93,7 +92,7 @@ class SubredditPage(Page):
|
|||||||
if data.get('url_type') == 'selfpost':
|
if data.get('url_type') == 'selfpost':
|
||||||
self.config.history.add(data['url_full'])
|
self.config.history.add(data['url_full'])
|
||||||
|
|
||||||
@SubredditController.register(curses.KEY_ENTER, Terminal.RETURN, 'o')
|
@SubredditController.register(Command('SUBREDDIT_OPEN_IN_BROWSER'))
|
||||||
def open_link(self):
|
def open_link(self):
|
||||||
"Open a link with the webbrowser"
|
"Open a link with the webbrowser"
|
||||||
|
|
||||||
@@ -107,7 +106,7 @@ class SubredditPage(Page):
|
|||||||
self.term.open_browser(data['url_full'])
|
self.term.open_browser(data['url_full'])
|
||||||
self.config.history.add(data['url_full'])
|
self.config.history.add(data['url_full'])
|
||||||
|
|
||||||
@SubredditController.register('c')
|
@SubredditController.register(Command('SUBREDDIT_POST'))
|
||||||
@logged_in
|
@logged_in
|
||||||
def post_submission(self):
|
def post_submission(self):
|
||||||
"Post a new submission to the given subreddit"
|
"Post a new submission to the given subreddit"
|
||||||
@@ -145,7 +144,7 @@ class SubredditPage(Page):
|
|||||||
|
|
||||||
self.refresh_content()
|
self.refresh_content()
|
||||||
|
|
||||||
@SubredditController.register('s')
|
@SubredditController.register(Command('SUBREDDIT_OPEN_SUBSCRIPTIONS'))
|
||||||
@logged_in
|
@logged_in
|
||||||
def open_subscriptions(self):
|
def open_subscriptions(self):
|
||||||
"Open user subscriptions page"
|
"Open user subscriptions page"
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import curses
|
|||||||
|
|
||||||
from .page import Page, PageController
|
from .page import Page, PageController
|
||||||
from .content import SubscriptionContent
|
from .content import SubscriptionContent
|
||||||
from .objects import Color, Navigator
|
from .objects import Color, Navigator, Command
|
||||||
from .terminal import Terminal
|
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionController(PageController):
|
class SubscriptionController(PageController):
|
||||||
@@ -18,12 +17,12 @@ class SubscriptionPage(Page):
|
|||||||
def __init__(self, reddit, term, config, oauth):
|
def __init__(self, reddit, term, config, oauth):
|
||||||
super(SubscriptionPage, self).__init__(reddit, term, config, oauth)
|
super(SubscriptionPage, self).__init__(reddit, term, config, oauth)
|
||||||
|
|
||||||
|
self.controller = SubscriptionController(self, keymap=config.keymap)
|
||||||
self.content = SubscriptionContent.from_user(reddit, term.loader)
|
self.content = SubscriptionContent.from_user(reddit, term.loader)
|
||||||
self.controller = SubscriptionController(self)
|
|
||||||
self.nav = Navigator(self.content.get)
|
self.nav = Navigator(self.content.get)
|
||||||
self.subreddit_data = None
|
self.subreddit_data = None
|
||||||
|
|
||||||
@SubscriptionController.register(curses.KEY_F5, 'r')
|
@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"
|
||||||
|
|
||||||
@@ -38,15 +37,14 @@ class SubscriptionPage(Page):
|
|||||||
if not self.term.loader.exception:
|
if not self.term.loader.exception:
|
||||||
self.nav = Navigator(self.content.get)
|
self.nav = Navigator(self.content.get)
|
||||||
|
|
||||||
@SubscriptionController.register(curses.KEY_ENTER, Terminal.RETURN,
|
@SubscriptionController.register(Command('SUBSCRIPTION_SELECT'))
|
||||||
curses.KEY_RIGHT, 'l')
|
|
||||||
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"
|
||||||
|
|
||||||
self.subreddit_data = self.content.get(self.nav.absolute_index)
|
self.subreddit_data = self.content.get(self.nav.absolute_index)
|
||||||
self.active = False
|
self.active = False
|
||||||
|
|
||||||
@SubscriptionController.register(curses.KEY_LEFT, Terminal.ESCAPE, 'h', 's')
|
@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"
|
||||||
|
|
||||||
|
|||||||
@@ -88,19 +88,35 @@ def test_config_from_file():
|
|||||||
'link': 'https://reddit.com/permalink •',
|
'link': 'https://reddit.com/permalink •',
|
||||||
'subreddit': 'cfb'}
|
'subreddit': 'cfb'}
|
||||||
|
|
||||||
|
bindings = {
|
||||||
|
'REFRESH': 'r, <KEY_F5>',
|
||||||
|
'UPVOTE': ''}
|
||||||
|
|
||||||
with NamedTemporaryFile(suffix='.cfg') as fp:
|
with NamedTemporaryFile(suffix='.cfg') as fp:
|
||||||
|
|
||||||
fargs = Config.get_file(filename=fp.name)
|
fargs, fbindings = Config.get_file(filename=fp.name)
|
||||||
config = Config(**fargs)
|
config = Config(**fargs)
|
||||||
|
config.keymap.set_bindings(fbindings)
|
||||||
assert config.config == {}
|
assert config.config == {}
|
||||||
|
assert config.keymap._keymap == {}
|
||||||
|
|
||||||
|
# [rtv]
|
||||||
rows = ['{0}={1}'.format(key, val) for key, val in args.items()]
|
rows = ['{0}={1}'.format(key, val) for key, val in args.items()]
|
||||||
data = '\n'.join(['[rtv]'] + rows)
|
data = '\n'.join(['[rtv]'] + rows)
|
||||||
fp.write(codecs.encode(data, 'utf-8'))
|
fp.write(codecs.encode(data, 'utf-8'))
|
||||||
|
|
||||||
|
# [bindings]
|
||||||
|
rows = ['{0}={1}'.format(key, val) for key, val in bindings.items()]
|
||||||
|
data = '\n'.join(['', '', '[bindings]'] + rows)
|
||||||
|
fp.write(codecs.encode(data, 'utf-8'))
|
||||||
|
|
||||||
fp.flush()
|
fp.flush()
|
||||||
fargs = Config.get_file(filename=fp.name)
|
fargs, fbindings = Config.get_file(filename=fp.name)
|
||||||
config.update(**fargs)
|
config.update(**fargs)
|
||||||
|
config.keymap.set_bindings(fbindings)
|
||||||
assert config.config == args
|
assert config.config == args
|
||||||
|
assert config.keymap.get('REFRESH') == ['r', '<KEY_F5>']
|
||||||
|
assert config.keymap.get('UPVOTE') == ['']
|
||||||
|
|
||||||
|
|
||||||
def test_config_refresh_token():
|
def test_config_refresh_token():
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ from __future__ import unicode_literals
|
|||||||
import time
|
import time
|
||||||
import curses
|
import curses
|
||||||
|
|
||||||
|
import six
|
||||||
import pytest
|
import pytest
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from rtv.objects import Color, Controller, Navigator, curses_session
|
from rtv import exceptions
|
||||||
|
from rtv.objects import Color, Controller, Navigator, Command, KeyMap, \
|
||||||
|
curses_session
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
@@ -246,6 +249,94 @@ def test_objects_controller():
|
|||||||
assert controller_c.trigger('3') is None
|
assert controller_c.trigger('3') is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_objects_controller_command():
|
||||||
|
|
||||||
|
class ControllerA(Controller):
|
||||||
|
character_map = {}
|
||||||
|
|
||||||
|
class ControllerB(ControllerA):
|
||||||
|
character_map = {}
|
||||||
|
|
||||||
|
@ControllerA.register(Command('REFRESH'))
|
||||||
|
def call_page(_):
|
||||||
|
return 'a1'
|
||||||
|
|
||||||
|
@ControllerA.register(Command('UPVOTE'))
|
||||||
|
def call_page(_):
|
||||||
|
return 'a2'
|
||||||
|
|
||||||
|
@ControllerB.register(Command('REFRESH'))
|
||||||
|
def call_page(_):
|
||||||
|
return 'b1'
|
||||||
|
|
||||||
|
# Two commands aren't allowed to share keys
|
||||||
|
keymap = KeyMap({'REFRESH': [0x10, 0x11], 'UPVOTE': [0x11, 0x12]})
|
||||||
|
with pytest.raises(exceptions.ConfigError) as e:
|
||||||
|
ControllerA(None, keymap=keymap)
|
||||||
|
assert 'ControllerA' in six.text_type(e)
|
||||||
|
|
||||||
|
# Reset the character map
|
||||||
|
ControllerA.character_map = {Command('REFRESH'): 0, Command('UPVOTE'): 0}
|
||||||
|
|
||||||
|
# All commands must be defined in the keymap
|
||||||
|
keymap = KeyMap({'REFRESH': [0x10]})
|
||||||
|
with pytest.raises(exceptions.ConfigError) as e:
|
||||||
|
ControllerB(None, keymap=keymap)
|
||||||
|
assert 'UPVOTE' in six.text_type(e)
|
||||||
|
|
||||||
|
|
||||||
|
def test_objects_command():
|
||||||
|
|
||||||
|
c1 = Command("REFRESH")
|
||||||
|
c2 = Command("refresh")
|
||||||
|
c3 = Command("EXIT")
|
||||||
|
|
||||||
|
assert c1 == c2
|
||||||
|
assert c1 != c3
|
||||||
|
|
||||||
|
keymap = {c1: None, c2: None, c3: None}
|
||||||
|
assert len(keymap) == 2
|
||||||
|
assert c1 in keymap
|
||||||
|
assert c2 in keymap
|
||||||
|
assert c3 in keymap
|
||||||
|
|
||||||
|
|
||||||
|
def test_objects_keymap():
|
||||||
|
|
||||||
|
bindings = {
|
||||||
|
'refresh': ['a', 0x12, '<LF>', '<KEY_UP>'],
|
||||||
|
'exit': [],
|
||||||
|
Command('UPVOTE'): ['b', '<KEY_F5>']
|
||||||
|
}
|
||||||
|
|
||||||
|
keymap = KeyMap(bindings)
|
||||||
|
assert keymap.get(Command('REFRESH')) == ['a', 0x12, '<LF>', '<KEY_UP>']
|
||||||
|
assert keymap.get(Command('exit')) == []
|
||||||
|
assert keymap.get('upvote') == ['b', '<KEY_F5>']
|
||||||
|
with pytest.raises(exceptions.ConfigError) as e:
|
||||||
|
keymap.get('downvote')
|
||||||
|
assert 'DOWNVOTE' in six.text_type(e)
|
||||||
|
|
||||||
|
# Updating the bindings wipes out the old ones
|
||||||
|
bindings = {'refresh': ['a', 0x12, '<LF>', '<KEY_UP>']}
|
||||||
|
keymap.set_bindings(bindings)
|
||||||
|
assert keymap.get('refresh')
|
||||||
|
with pytest.raises(exceptions.ConfigError) as e:
|
||||||
|
keymap.get('upvote')
|
||||||
|
assert 'UPVOTE' in six.text_type(e)
|
||||||
|
|
||||||
|
# Strings should be parsed correctly into keys
|
||||||
|
assert KeyMap.parse('a') == 97
|
||||||
|
assert KeyMap.parse(0x12) == 18
|
||||||
|
assert KeyMap.parse('<LF>') == 10
|
||||||
|
assert KeyMap.parse('<KEY_UP>') == 259
|
||||||
|
assert KeyMap.parse('<KEY_F5>') == 269
|
||||||
|
for key in ('', None, '<lf>', '<DNS>', '<KEY_UD>', '♬'):
|
||||||
|
with pytest.raises(exceptions.ConfigError) as e:
|
||||||
|
keymap.parse(key)
|
||||||
|
assert six.text_type(key) in six.text_type(e)
|
||||||
|
|
||||||
|
|
||||||
def test_objects_navigator_properties():
|
def test_objects_navigator_properties():
|
||||||
|
|
||||||
def valid_page_cb(_):
|
def valid_page_cb(_):
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ def test_page_logged_in(terminal):
|
|||||||
def test_page_unauthenticated(reddit, terminal, config, oauth):
|
def test_page_unauthenticated(reddit, terminal, config, oauth):
|
||||||
|
|
||||||
page = Page(reddit, terminal, config, oauth)
|
page = Page(reddit, terminal, config, oauth)
|
||||||
page.controller = PageController(page)
|
page.controller = PageController(page, keymap=config.keymap)
|
||||||
with mock.patch.object(page, 'refresh_content'), \
|
with mock.patch.object(page, 'refresh_content'), \
|
||||||
mock.patch.object(page, 'content'), \
|
mock.patch.object(page, 'content'), \
|
||||||
mock.patch.object(page, 'nav'), \
|
mock.patch.object(page, 'nav'), \
|
||||||
@@ -104,7 +104,7 @@ def test_page_unauthenticated(reddit, terminal, config, oauth):
|
|||||||
def test_page_authenticated(reddit, terminal, config, oauth, refresh_token):
|
def test_page_authenticated(reddit, terminal, config, oauth, refresh_token):
|
||||||
|
|
||||||
page = Page(reddit, terminal, config, oauth)
|
page = Page(reddit, terminal, config, oauth)
|
||||||
page.controller = PageController(page)
|
page.controller = PageController(page, keymap=config.keymap)
|
||||||
config.refresh_token = refresh_token
|
config.refresh_token = refresh_token
|
||||||
|
|
||||||
# Login
|
# Login
|
||||||
|
|||||||
Reference in New Issue
Block a user