Merge pull request #195 from michael-lazar/new_keybindings_attempt

New keybindings attempt
This commit is contained in:
Michael Lazar
2016-02-11 00:32:16 -08:00
12 changed files with 376 additions and 66 deletions

View File

@@ -14,6 +14,7 @@ from .oauth import OAuthHelper
from .terminal import Terminal
from .objects import curses_session
from .subreddit import SubredditPage
from .exceptions import ConfigError
from .__version__ import __version__
@@ -26,6 +27,7 @@ _logger = logging.getLogger(__name__)
# ptrace_scope to 0 in /etc/sysctl.d/10-ptrace.conf.
# http://blog.mellenthin.de/archives/2010/10/18/gdb-attach-fails
def main():
"Main entry point"
@@ -38,13 +40,17 @@ def main():
sys.stdout.write('\x1b]2;{0}\x07'.format(title))
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
config = Config()
config.update(**fargs)
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
if config['copy_config']:
copy_default_config()
@@ -101,6 +107,9 @@ def main():
# Launch the subreddit page
page.loop()
except ConfigError as e:
_logger.exception(e)
print(e)
except Exception as e:
_logger.exception(e)
raise

View File

@@ -11,6 +11,7 @@ import six
from six.moves import configparser
from . import docs, __version__
from .objects import KeyMap
PACKAGE = os.path.dirname(__file__)
HOME = os.path.expanduser('~')
@@ -112,7 +113,10 @@ class Config(object):
self.history_file = history_file
self.token_file = token_file
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,
# so they are treated differently from the rest of the config options.
@@ -198,9 +202,9 @@ class Config(object):
@staticmethod
def _parse_rtv_file(config):
out = {}
rtv = {}
if config.has_section('rtv'):
out = dict(config.items('rtv'))
rtv = dict(config.items('rtv'))
params = {
'ascii': partial(config.getboolean, 'rtv'),
@@ -208,13 +212,20 @@ class Config(object):
'persistent': partial(config.getboolean, 'rtv'),
'history_size': 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():
if key in out:
out[key] = func(key)
return out
if key in rtv:
rtv[key] = func(key)
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
def _ensure_filepath(filename):

View File

@@ -6,6 +6,10 @@ class EscapeInterrupt(Exception):
"Signal that the ESC key has been pressed"
class ConfigError(Exception):
"There was a problem with the configuration"
class RTVError(Exception):
"Base RTV error class"

View File

@@ -1,14 +1,16 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import re
import os
import time
import curses
import signal
import inspect
import weakref
import logging
import threading
import curses
import curses.ascii
from contextlib import contextmanager
import six
@@ -502,6 +504,11 @@ class Controller(object):
>>> 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`.
>>> @Controller.register(None)
>>> def default_func(self, *args)
@@ -515,13 +522,34 @@ class Controller(object):
character_map = {}
def __init__(self, instance):
def __init__(self, instance, keymap=None):
self.instance = instance
# Build a list of parent controllers that follow the object's MRO to
# check if any parent controllers have registered the keypress
# Build a list of parent controllers that follow the object's MRO
# to check if any parent controllers have registered the keypress
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):
if isinstance(char, six.string_types) and len(char) == 1:
@@ -551,4 +579,90 @@ class Controller(object):
else:
cls.character_map[char] = 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)

View File

@@ -9,7 +9,7 @@ from functools import wraps
from kitchen.text.display import textual_width
from . import docs
from .objects import Controller, Color
from .objects import Controller, Color, Command
def logged_in(f):
@@ -68,60 +68,60 @@ class Page(object):
ch = self.term.stdscr.getch()
self.controller.trigger(ch)
@PageController.register('q')
@PageController.register(Command('EXIT'))
def exit(self):
if self.term.prompt_y_or_n('Do you really want to quit? (y/n): '):
sys.exit()
@PageController.register('Q')
@PageController.register(Command('FORCE_EXIT'))
def force_exit(self):
sys.exit()
@PageController.register('?')
@PageController.register(Command('HELP'))
def show_help(self):
self.term.show_notification(docs.HELP.strip().splitlines())
@PageController.register('1')
@PageController.register(Command('SORT_HOT'))
def sort_content_hot(self):
self.refresh_content(order='hot')
@PageController.register('2')
@PageController.register(Command('SORT_TOP'))
def sort_content_top(self):
self.refresh_content(order='top')
@PageController.register('3')
@PageController.register(Command('SORT_RISING'))
def sort_content_rising(self):
self.refresh_content(order='rising')
@PageController.register('4')
@PageController.register(Command('SORT_NEW'))
def sort_content_new(self):
self.refresh_content(order='new')
@PageController.register('5')
@PageController.register(Command('SORT_CONTROVERSIAL'))
def sort_content_controversial(self):
self.refresh_content(order='controversial')
@PageController.register(curses.KEY_UP, 'k')
@PageController.register(Command('MOVE_UP'))
def move_cursor_up(self):
self._move_cursor(-1)
self.clear_input_queue()
@PageController.register(curses.KEY_DOWN, 'j')
@PageController.register(Command('MOVE_DOWN'))
def move_cursor_down(self):
self._move_cursor(1)
self.clear_input_queue()
@PageController.register('m', curses.KEY_PPAGE)
@PageController.register(Command('PAGE_UP'))
def move_page_up(self):
self._move_page(-1)
self.clear_input_queue()
@PageController.register('n', curses.KEY_NPAGE)
@PageController.register(Command('PAGE_DOWN'))
def move_page_down(self):
self._move_page(1)
self.clear_input_queue()
@PageController.register('a')
@PageController.register(Command('UPVOTE'))
@logged_in
def upvote(self):
data = self.content.get(self.nav.absolute_index)
@@ -138,7 +138,7 @@ class Page(object):
if not self.term.loader.exception:
data['likes'] = True
@PageController.register('z')
@PageController.register(Command('DOWNVOTE'))
@logged_in
def downvote(self):
data = self.content.get(self.nav.absolute_index)
@@ -155,7 +155,7 @@ class Page(object):
if not self.term.loader.exception:
data['likes'] = None
@PageController.register('u')
@PageController.register(Command('LOGIN'))
def login(self):
"""
Prompt to log into the user's account, or log out of the current
@@ -169,7 +169,7 @@ class Page(object):
else:
self.oauth.authorize()
@PageController.register('d')
@PageController.register(Command('DELETE'))
@logged_in
def delete_item(self):
"""
@@ -193,7 +193,7 @@ class Page(object):
if self.term.loader.exception is None:
self.refresh_content()
@PageController.register('e')
@PageController.register(Command('EDIT'))
@logged_in
def edit(self):
"""
@@ -228,7 +228,7 @@ class Page(object):
if self.term.loader.exception is None:
self.refresh_content()
@PageController.register('i')
@PageController.register(Command('INBOX'))
@logged_in
def get_inbox(self):
"""

View File

@@ -49,4 +49,74 @@ oauth_redirect_uri = http://127.0.0.1:65000/
oauth_redirect_port = 65000
; 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>

View File

@@ -7,8 +7,7 @@ import curses
from . import docs
from .content import SubmissionContent
from .page import Page, PageController, logged_in
from .objects import Navigator, Color
from .terminal import Terminal
from .objects import Navigator, Color, Command
class SubmissionController(PageController):
@@ -20,16 +19,15 @@ class SubmissionPage(Page):
def __init__(self, reddit, term, config, oauth, url=None, submission=None):
super(SubmissionPage, self).__init__(reddit, term, config, oauth)
self.controller = SubmissionController(self, keymap=config.keymap)
if url:
self.content = SubmissionContent.from_url(reddit, url, term.loader)
else:
self.content = SubmissionContent(submission, term.loader)
self.controller = SubmissionController(self)
# Start at the submission post, which is indexed as -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):
"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.
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):
"Close the submission and return to the subreddit page"
self.active = False
@SubmissionController.register(curses.KEY_F5, 'r')
@SubmissionController.register(Command('REFRESH'))
def refresh_content(self, order=None, name=None):
"Re-download comments and reset the page index"
@@ -60,7 +58,7 @@ class SubmissionPage(Page):
if not self.term.loader.exception:
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):
"Open the selected item with the webbrowser"
@@ -71,7 +69,7 @@ class SubmissionPage(Page):
else:
self.term.flash()
@SubmissionController.register('c')
@SubmissionController.register(Command('SUBMISSION_POST'))
@logged_in
def add_comment(self):
"""
@@ -114,7 +112,7 @@ class SubmissionPage(Page):
if not self.term.loader.exception:
self.refresh_content()
@SubmissionController.register('d')
@SubmissionController.register(Command('DELETE'))
@logged_in
def delete_comment(self):
"Delete a comment as long as it is not the current submission"

View File

@@ -7,10 +7,9 @@ import curses
from . import docs
from .content import SubredditContent
from .page import Page, PageController, logged_in
from .objects import Navigator, Color
from .objects import Navigator, Color, Command
from .submission import SubmissionPage
from .subscription import SubscriptionPage
from .terminal import Terminal
class SubredditController(PageController):
@@ -27,11 +26,11 @@ class SubredditPage(Page):
"""
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.controller = SubredditController(self)
self.nav = Navigator(self.content.get)
@SubredditController.register(curses.KEY_F5, 'r')
@SubredditController.register(Command('REFRESH'))
def refresh_content(self, order=None, name=None):
"Re-download all submissions and reset the page index"
@@ -49,7 +48,7 @@ class SubredditPage(Page):
if not self.term.loader.exception:
self.nav = Navigator(self.content.get)
@SubredditController.register('f')
@SubredditController.register(Command('SUBREDDIT_SEARCH'))
def search_subreddit(self, name=None):
"Open a prompt to search the given subreddit"
@@ -65,7 +64,7 @@ class SubredditPage(Page):
if not self.term.loader.exception:
self.nav = Navigator(self.content.get)
@SubredditController.register('/')
@SubredditController.register(Command('SUBREDDIT_PROMPT'))
def prompt_subreddit(self):
"Open a prompt to navigate to a different subreddit"
@@ -73,7 +72,7 @@ class SubredditPage(Page):
if name is not None:
self.refresh_content(order='ignore', name=name)
@SubredditController.register(curses.KEY_RIGHT, 'l')
@SubredditController.register(Command('SUBREDDIT_OPEN'))
def open_submission(self, url=None):
"Select the current submission to view posts"
@@ -93,7 +92,7 @@ class SubredditPage(Page):
if data.get('url_type') == 'selfpost':
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):
"Open a link with the webbrowser"
@@ -107,7 +106,7 @@ class SubredditPage(Page):
self.term.open_browser(data['url_full'])
self.config.history.add(data['url_full'])
@SubredditController.register('c')
@SubredditController.register(Command('SUBREDDIT_POST'))
@logged_in
def post_submission(self):
"Post a new submission to the given subreddit"
@@ -145,7 +144,7 @@ class SubredditPage(Page):
self.refresh_content()
@SubredditController.register('s')
@SubredditController.register(Command('SUBREDDIT_OPEN_SUBSCRIPTIONS'))
@logged_in
def open_subscriptions(self):
"Open user subscriptions page"

View File

@@ -5,8 +5,7 @@ import curses
from .page import Page, PageController
from .content import SubscriptionContent
from .objects import Color, Navigator
from .terminal import Terminal
from .objects import Color, Navigator, Command
class SubscriptionController(PageController):
@@ -18,12 +17,12 @@ class SubscriptionPage(Page):
def __init__(self, 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.controller = SubscriptionController(self)
self.nav = Navigator(self.content.get)
self.subreddit_data = None
@SubscriptionController.register(curses.KEY_F5, 'r')
@SubscriptionController.register(Command('REFRESH'))
def refresh_content(self, order=None, name=None):
"Re-download all subscriptions and reset the page index"
@@ -38,15 +37,14 @@ class SubscriptionPage(Page):
if not self.term.loader.exception:
self.nav = Navigator(self.content.get)
@SubscriptionController.register(curses.KEY_ENTER, Terminal.RETURN,
curses.KEY_RIGHT, 'l')
@SubscriptionController.register(Command('SUBSCRIPTION_SELECT'))
def select_subreddit(self):
"Store the selected subreddit and return to the subreddit page"
self.subreddit_data = self.content.get(self.nav.absolute_index)
self.active = False
@SubscriptionController.register(curses.KEY_LEFT, Terminal.ESCAPE, 'h', 's')
@SubscriptionController.register(Command('SUBSCRIPTION_EXIT'))
def close_subscriptions(self):
"Close subscriptions and return to the subreddit page"

View File

@@ -88,19 +88,35 @@ def test_config_from_file():
'link': 'https://reddit.com/permalink •',
'subreddit': 'cfb'}
bindings = {
'REFRESH': 'r, <KEY_F5>',
'UPVOTE': ''}
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.keymap.set_bindings(fbindings)
assert config.config == {}
assert config.keymap._keymap == {}
# [rtv]
rows = ['{0}={1}'.format(key, val) for key, val in args.items()]
data = '\n'.join(['[rtv]'] + rows)
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()
fargs = Config.get_file(filename=fp.name)
fargs, fbindings = Config.get_file(filename=fp.name)
config.update(**fargs)
config.keymap.set_bindings(fbindings)
assert config.config == args
assert config.keymap.get('REFRESH') == ['r', '<KEY_F5>']
assert config.keymap.get('UPVOTE') == ['']
def test_config_refresh_token():

View File

@@ -4,10 +4,13 @@ from __future__ import unicode_literals
import time
import curses
import six
import pytest
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:
from unittest import mock
@@ -246,6 +249,94 @@ def test_objects_controller():
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 valid_page_cb(_):

View File

@@ -38,7 +38,7 @@ def test_page_logged_in(terminal):
def test_page_unauthenticated(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'), \
mock.patch.object(page, 'content'), \
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):
page = Page(reddit, terminal, config, oauth)
page.controller = PageController(page)
page.controller = PageController(page, keymap=config.keymap)
config.refresh_token = refresh_token
# Login