diff --git a/rtv/__main__.py b/rtv/__main__.py index c56dc9b..40ed843 100644 --- a/rtv/__main__.py +++ b/rtv/__main__.py @@ -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 diff --git a/rtv/config.py b/rtv/config.py index 81cf04b..53be7de 100644 --- a/rtv/config.py +++ b/rtv/config.py @@ -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): diff --git a/rtv/exceptions.py b/rtv/exceptions.py index ab6a8c6..3e8fa6a 100644 --- a/rtv/exceptions.py +++ b/rtv/exceptions.py @@ -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" diff --git a/rtv/objects.py b/rtv/objects.py index 34af0f8..f64e3cb 100644 --- a/rtv/objects.py +++ b/rtv/objects.py @@ -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 \ No newline at end of file + 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) \ No newline at end of file diff --git a/rtv/page.py b/rtv/page.py index b3c6162..0e006b1 100644 --- a/rtv/page.py +++ b/rtv/page.py @@ -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): """ diff --git a/rtv/rtv.cfg b/rtv/rtv.cfg index 540ee7a..1aa0a62 100644 --- a/rtv/rtv.cfg +++ b/rtv/rtv.cfg @@ -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 \ No newline at end of file +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. (enter), (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. (left arrow), , (page down) +; +; Notes: +; - Curses is unreliable and should always be used in conjunction +; with . +; - 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 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, +MOVE_DOWN = j, +PAGE_UP = m, , +PAGE_DOWN = n, , +UPVOTE = a +DOWNVOTE = z +LOGIN = u +DELETE = d +EDIT = e +INBOX = i +REFRESH = r, + +; Submission page +SUBMISSION_TOGGLE_COMMENT = l, 0x20, +SUBMISSION_OPEN_IN_BROWSER = o, , +SUBMISSION_POST = c +SUBMISSION_EXIT = h, + +; Subreddit page +SUBREDDIT_SEARCH = f +SUBREDDIT_PROMPT = / +SUBREDDIT_POST = c +SUBREDDIT_OPEN = l, +SUBREDDIT_OPEN_IN_BROWSER = o, , , +SUBREDDIT_OPEN_SUBSCRIPTIONS = s + +; Subscription page +SUBSCRIPTION_SELECT = l, , , +SUBSCRIPTION_EXIT = h, s, , diff --git a/rtv/submission.py b/rtv/submission.py index 959ff0c..5e18abd 100644 --- a/rtv/submission.py +++ b/rtv/submission.py @@ -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" diff --git a/rtv/subreddit.py b/rtv/subreddit.py index 1e79ca3..4c85fe5 100644 --- a/rtv/subreddit.py +++ b/rtv/subreddit.py @@ -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" diff --git a/rtv/subscription.py b/rtv/subscription.py index 6341a4a..44a443d 100644 --- a/rtv/subscription.py +++ b/rtv/subscription.py @@ -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" diff --git a/tests/test_config.py b/tests/test_config.py index 734a592..1f409c4 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -88,19 +88,35 @@ def test_config_from_file(): 'link': 'https://reddit.com/permalink •', 'subreddit': 'cfb'} + bindings = { + 'REFRESH': 'r, ', + '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', ''] + assert config.keymap.get('UPVOTE') == [''] def test_config_refresh_token(): diff --git a/tests/test_objects.py b/tests/test_objects.py index 2dda79e..d9592f6 100644 --- a/tests/test_objects.py +++ b/tests/test_objects.py @@ -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, '', ''], + 'exit': [], + Command('UPVOTE'): ['b', ''] + } + + keymap = KeyMap(bindings) + assert keymap.get(Command('REFRESH')) == ['a', 0x12, '', ''] + assert keymap.get(Command('exit')) == [] + assert keymap.get('upvote') == ['b', ''] + 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, '', '']} + 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('') == 10 + assert KeyMap.parse('') == 259 + assert KeyMap.parse('') == 269 + for key in ('', None, '', '', '', '♬'): + 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(_): diff --git a/tests/test_page.py b/tests/test_page.py index 01978d9..2891e16 100644 --- a/tests/test_page.py +++ b/tests/test_page.py @@ -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