From 181507d9bbb5b196cafdbe4fbfc433370a7267a8 Mon Sep 17 00:00:00 2001 From: Michael Lazar Date: Tue, 9 Feb 2016 02:56:55 -0800 Subject: [PATCH] Almost functional. --- rtv/__main__.py | 4 ++- rtv/config.py | 36 +++++----------------- rtv/objects.py | 74 ++++++++++++++++++++++++++++++++++++++++++--- rtv/page.py | 38 +++++++++++------------ rtv/rtv.cfg | 3 +- rtv/submission.py | 15 +++++---- rtv/subreddit.py | 16 +++++----- rtv/subscription.py | 10 +++--- 8 files changed, 121 insertions(+), 75 deletions(-) diff --git a/rtv/__main__.py b/rtv/__main__.py index c56dc9b..ee49b7d 100644 --- a/rtv/__main__.py +++ b/rtv/__main__.py @@ -26,6 +26,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,12 +39,13 @@ 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) + config.keymap.update(**bindings) # Copy the default config file and quit if config['copy_config']: diff --git a/rtv/config.py b/rtv/config.py index 84c1182..53be7de 100644 --- a/rtv/config.py +++ b/rtv/config.py @@ -1,19 +1,17 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -import re import os -import curses import codecs import shutil import argparse -from curses import ascii from functools import partial import six from six.moves import configparser from . import docs, __version__ +from .objects import KeyMap PACKAGE = os.path.dirname(__file__) HOME = os.path.expanduser('~') @@ -115,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,8 +199,8 @@ class Config(object): return cls._parse_rtv_file(config) - @classmethod - def _parse_rtv_file(cls, config): + @staticmethod + def _parse_rtv_file(config): rtv = {} if config.has_section('rtv'): @@ -213,7 +214,6 @@ class Config(object): 'oauth_redirect_port': partial(config.getint, 'rtv'), 'oauth_scope': lambda x: rtv[x].split(',') } - for key, func in params.items(): if key in rtv: rtv[key] = func(key) @@ -223,30 +223,10 @@ class Config(object): bindings = dict(config.items('bindings')) for name, keys in bindings.items(): - bindings[name] = [cls._parse_key(key) for key in keys.split(',')] + bindings[name] = [key.strip() for key in keys.split(',')] return rtv, bindings - @staticmethod - def _parse_key(key): - """ - Parse a key represented by a string return its character code. - """ - - key = key.strip() - if re.match('[<]KEY_.*[>]', key): - # Curses control character - return getattr(curses, key[1:-1]) - elif re.match('[<].*[>]', key): - # Ascii control character - return getattr(ascii, key[1:-1]) - elif key.startswith('0x'): - # Ascii hex code - return int(key, 16) - else: - # Ascii character - return ord(key) - @staticmethod def _ensure_filepath(filename): """ diff --git a/rtv/objects.py b/rtv/objects.py index 34af0f8..7816e55 100644 --- a/rtv/objects.py +++ b/rtv/objects.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +import re import os import time import curses @@ -9,7 +10,9 @@ import inspect import weakref import logging import threading +from curses import ascii from contextlib import contextmanager +from collections import defaultdict import six import praw @@ -502,6 +505,11 @@ class Controller(object): >>> def func(self, *args) >>> ... + Register a KeyBinding that can be defined later by the config file + >>> @Controller.register(KeyMap.UPVOTE) + >>> def upvote(self, *args) + >> ... + Register a default behavior by using `None`. >>> @Controller.register(None) >>> def default_func(self, *args) @@ -515,13 +523,22 @@ 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] + # Update the character map with the bindings defined in the keymap + if not keymap: + for controller in [self] + self.parents: + for binding, func in controller.character_map.items(): + if isinstance(binding, KeyBinding): + # TODO: Raise error if trying to overwrite a char + mappings = {char: func for char in keymap.get(binding)} + self.character_map.update(mappings) + def trigger(self, char, *args, **kwargs): if isinstance(char, six.string_types) and len(char) == 1: @@ -551,4 +568,53 @@ class Controller(object): else: cls.character_map[char] = f return f - return inner \ No newline at end of file + return inner + + +class KeyBinding(object): + + def __init__(self, val): + self.val = val.upper() + + +class KeyMapMeta(type): + + def __getattr__(cls, key): + return KeyBinding(key) + + +@six.add_metaclass(KeyMapMeta) +class KeyMap(object): + + def __init__(self, bindings): + self._bindings = defaultdict(list) + self.update(bindings) + + def update(self, **bindings): + for command, keys in bindings: + binding = KeyBinding(command) + self._bindings[binding] = [self._parse_key(key) for key in keys] + + def get(self, binding): + return self._bindings[binding] + + @staticmethod + def _parse_key(key): + """ + Parse a key represented by a string and return its character code. + """ + + 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(ascii, key[1:-1]) + elif key.startswith('0x'): + # Ascii hex code + return int(key, 16) + else: + # Ascii character + return ord(key) \ No newline at end of file diff --git a/rtv/page.py b/rtv/page.py index b3c6162..155cff7 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, KeyMap 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(KeyMap.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(KeyMap.FORCE_EXIT) def force_exit(self): sys.exit() - @PageController.register('?') + @PageController.register(KeyMap.HELP) def show_help(self): self.term.show_notification(docs.HELP.strip().splitlines()) - @PageController.register('1') + @PageController.register(KeyMap.SORT_HOT) def sort_content_hot(self): self.refresh_content(order='hot') - @PageController.register('2') + @PageController.register(KeyMap.SORT_TOP) def sort_content_top(self): self.refresh_content(order='top') - @PageController.register('3') + @PageController.register(KeyMap.SORT_RISING) def sort_content_rising(self): self.refresh_content(order='rising') - @PageController.register('4') + @PageController.register(KeyMap.SORT_NEW) def sort_content_new(self): self.refresh_content(order='new') - @PageController.register('5') + @PageController.register(KeyMap.SORT_CONTROVERSIAL) def sort_content_controversial(self): self.refresh_content(order='controversial') - @PageController.register(curses.KEY_UP, 'k') + @PageController.register(KeyMap.MOVE_UP) def move_cursor_up(self): self._move_cursor(-1) self.clear_input_queue() - @PageController.register(curses.KEY_DOWN, 'j') + @PageController.register(KeyMap.MOVE_DOWN) def move_cursor_down(self): self._move_cursor(1) self.clear_input_queue() - @PageController.register('m', curses.KEY_PPAGE) + @PageController.register(KeyMap.PAGE_UP) def move_page_up(self): self._move_page(-1) self.clear_input_queue() - @PageController.register('n', curses.KEY_NPAGE) + @PageController.register(KeyMap.PAGE_DOWN) def move_page_down(self): self._move_page(1) self.clear_input_queue() - @PageController.register('a') + @PageController.register(KeyMap.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(KeyMap.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(KeyMap.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(KeyMap.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(KeyMap.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(KeyMap.INBOX) @logged_in def get_inbox(self): """ diff --git a/rtv/rtv.cfg b/rtv/rtv.cfg index 5d01cf5..8404827 100644 --- a/rtv/rtv.cfg +++ b/rtv/rtv.cfg @@ -100,7 +100,7 @@ INBOX = i REFRESH = r, ; Submission page -SUBMISSION_TOGGLE_COMMENTS = l, 0x20, +SUBMISSION_TOGGLE_COMMENT = l, 0x20, SUBMISSION_OPEN_IN_BROWSER = o, , SUBMISSION_POST = c SUBMISSION_EXIT = h, @@ -111,6 +111,7 @@ SUBREDDIT_PROMPT = / SUBREDDIT_POST = c SUBREDDIT_OPEN = l, SUBREDDIT_OPEN_IN_BROWSER = o, , , +SUBREDDIT_OPEN_SUBSCRIPTIONS = s ; Subscription page SUBSCRIPTION_SELECT = l, , , diff --git a/rtv/submission.py b/rtv/submission.py index 959ff0c..1ec91f6 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, KeyMap class SubmissionController(PageController): @@ -29,7 +28,7 @@ class SubmissionPage(Page): # 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(KeyMap.SUBMISSION_TOGGLE_COMMENT) def toggle_comment(self): "Toggle the selected comment tree between visible and hidden" @@ -41,13 +40,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(KeyMap.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(KeyMap.REFRESH) def refresh_content(self, order=None, name=None): "Re-download comments and reset the page index" @@ -60,7 +59,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(KeyMap.SUBMISSION_OPEN_IN_BROWSER) def open_link(self): "Open the selected item with the webbrowser" @@ -71,7 +70,7 @@ class SubmissionPage(Page): else: self.term.flash() - @SubmissionController.register('c') + @SubmissionController.register(KeyMap.SUBMISSION_POST) @logged_in def add_comment(self): """ @@ -114,7 +113,7 @@ class SubmissionPage(Page): if not self.term.loader.exception: self.refresh_content() - @SubmissionController.register('d') + @SubmissionController.register(KeyMap.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..950fe59 100644 --- a/rtv/subreddit.py +++ b/rtv/subreddit.py @@ -7,7 +7,7 @@ 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, KeyMap from .submission import SubmissionPage from .subscription import SubscriptionPage from .terminal import Terminal @@ -31,7 +31,7 @@ class SubredditPage(Page): self.controller = SubredditController(self) self.nav = Navigator(self.content.get) - @SubredditController.register(curses.KEY_F5, 'r') + @SubredditController.register(KeyMap.REFRESH) def refresh_content(self, order=None, name=None): "Re-download all submissions and reset the page index" @@ -49,7 +49,7 @@ class SubredditPage(Page): if not self.term.loader.exception: self.nav = Navigator(self.content.get) - @SubredditController.register('f') + @SubredditController.register(KeyMap.SUBREDDIT_SEARCH) def search_subreddit(self, name=None): "Open a prompt to search the given subreddit" @@ -65,7 +65,7 @@ class SubredditPage(Page): if not self.term.loader.exception: self.nav = Navigator(self.content.get) - @SubredditController.register('/') + @SubredditController.register(KeyMap.SUBREDDIT_PROMPT) def prompt_subreddit(self): "Open a prompt to navigate to a different subreddit" @@ -73,7 +73,7 @@ class SubredditPage(Page): if name is not None: self.refresh_content(order='ignore', name=name) - @SubredditController.register(curses.KEY_RIGHT, 'l') + @SubredditController.register(KeyMap.SUBREDDIT_OPEN) def open_submission(self, url=None): "Select the current submission to view posts" @@ -93,7 +93,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(KeyMap.SUBREDDIT_OPEN_IN_BROWSER) def open_link(self): "Open a link with the webbrowser" @@ -107,7 +107,7 @@ class SubredditPage(Page): self.term.open_browser(data['url_full']) self.config.history.add(data['url_full']) - @SubredditController.register('c') + @SubredditController.register(KeyMap.SUBREDDIT_POST) @logged_in def post_submission(self): "Post a new submission to the given subreddit" @@ -145,7 +145,7 @@ class SubredditPage(Page): self.refresh_content() - @SubredditController.register('s') + @SubredditController.register(KeyMap.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..efbfe3e 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, KeyMap class SubscriptionController(PageController): @@ -23,7 +22,7 @@ class SubscriptionPage(Page): self.nav = Navigator(self.content.get) self.subreddit_data = None - @SubscriptionController.register(curses.KEY_F5, 'r') + @SubscriptionController.register(KeyMap.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(KeyMap.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(KeyMap.SUBSCRIPTION_CLOSE) def close_subscriptions(self): "Close subscriptions and return to the subreddit page"