Almost functional.

This commit is contained in:
Michael Lazar
2016-02-09 02:56:55 -08:00
parent 2f093e47a8
commit 181507d9bb
8 changed files with 121 additions and 75 deletions

View File

@@ -26,6 +26,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,12 +39,13 @@ 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)
config.keymap.update(**bindings)
# Copy the default config file and quit # Copy the default config file and quit
if config['copy_config']: if config['copy_config']:

View File

@@ -1,19 +1,17 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
import re
import os import os
import curses
import codecs import codecs
import shutil import shutil
import argparse import argparse
from curses import ascii
from functools import partial from functools import partial
import six 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('~')
@@ -115,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,8 +199,8 @@ class Config(object):
return cls._parse_rtv_file(config) return cls._parse_rtv_file(config)
@classmethod @staticmethod
def _parse_rtv_file(cls, config): def _parse_rtv_file(config):
rtv = {} rtv = {}
if config.has_section('rtv'): if config.has_section('rtv'):
@@ -213,7 +214,6 @@ class Config(object):
'oauth_redirect_port': partial(config.getint, 'rtv'), 'oauth_redirect_port': partial(config.getint, 'rtv'),
'oauth_scope': lambda x: rtv[x].split(',') 'oauth_scope': lambda x: rtv[x].split(',')
} }
for key, func in params.items(): for key, func in params.items():
if key in rtv: if key in rtv:
rtv[key] = func(key) rtv[key] = func(key)
@@ -223,30 +223,10 @@ class Config(object):
bindings = dict(config.items('bindings')) bindings = dict(config.items('bindings'))
for name, keys in bindings.items(): 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 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 @staticmethod
def _ensure_filepath(filename): def _ensure_filepath(filename):
""" """

View File

@@ -1,6 +1,7 @@
# -*- 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 curses
@@ -9,7 +10,9 @@ import inspect
import weakref import weakref
import logging import logging
import threading import threading
from curses import ascii
from contextlib import contextmanager from contextlib import contextmanager
from collections import defaultdict
import six import six
import praw import praw
@@ -502,6 +505,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(KeyMap.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 +523,22 @@ 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]
# 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): 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:
@@ -552,3 +569,52 @@ class Controller(object):
cls.character_map[char] = f cls.character_map[char] = f
return f return f
return inner 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)

View File

@@ -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, KeyMap
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(KeyMap.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(KeyMap.FORCE_EXIT)
def force_exit(self): def force_exit(self):
sys.exit() sys.exit()
@PageController.register('?') @PageController.register(KeyMap.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(KeyMap.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(KeyMap.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(KeyMap.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(KeyMap.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(KeyMap.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(KeyMap.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(KeyMap.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(KeyMap.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(KeyMap.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(KeyMap.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(KeyMap.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(KeyMap.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(KeyMap.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(KeyMap.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(KeyMap.INBOX)
@logged_in @logged_in
def get_inbox(self): def get_inbox(self):
""" """

View File

@@ -100,7 +100,7 @@ INBOX = i
REFRESH = r, <KEY_F5> REFRESH = r, <KEY_F5>
; Submission page ; Submission page
SUBMISSION_TOGGLE_COMMENTS = l, 0x20, <KEY_RIGHT> SUBMISSION_TOGGLE_COMMENT = l, 0x20, <KEY_RIGHT>
SUBMISSION_OPEN_IN_BROWSER = o, <LF>, <KEY_ENTER> SUBMISSION_OPEN_IN_BROWSER = o, <LF>, <KEY_ENTER>
SUBMISSION_POST = c SUBMISSION_POST = c
SUBMISSION_EXIT = h, <KEY_LEFT> SUBMISSION_EXIT = h, <KEY_LEFT>
@@ -111,6 +111,7 @@ SUBREDDIT_PROMPT = /
SUBREDDIT_POST = c SUBREDDIT_POST = c
SUBREDDIT_OPEN = l, <KEY_RIGHT> SUBREDDIT_OPEN = l, <KEY_RIGHT>
SUBREDDIT_OPEN_IN_BROWSER = o, <LF>, <KEY_ENTER>, <KEY_ENTER> SUBREDDIT_OPEN_IN_BROWSER = o, <LF>, <KEY_ENTER>, <KEY_ENTER>
SUBREDDIT_OPEN_SUBSCRIPTIONS = s
; Subscription page ; Subscription page
SUBSCRIPTION_SELECT = l, <LF>, <KEY_ENTER>, <KEY_RIGHT> SUBSCRIPTION_SELECT = l, <LF>, <KEY_ENTER>, <KEY_RIGHT>

View File

@@ -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, KeyMap
from .terminal import Terminal
class SubmissionController(PageController): class SubmissionController(PageController):
@@ -29,7 +28,7 @@ class SubmissionPage(Page):
# 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(KeyMap.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 +40,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(KeyMap.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(KeyMap.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 +59,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(KeyMap.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 +70,7 @@ class SubmissionPage(Page):
else: else:
self.term.flash() self.term.flash()
@SubmissionController.register('c') @SubmissionController.register(KeyMap.SUBMISSION_POST)
@logged_in @logged_in
def add_comment(self): def add_comment(self):
""" """
@@ -114,7 +113,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(KeyMap.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"

View File

@@ -7,7 +7,7 @@ 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, KeyMap
from .submission import SubmissionPage from .submission import SubmissionPage
from .subscription import SubscriptionPage from .subscription import SubscriptionPage
from .terminal import Terminal from .terminal import Terminal
@@ -31,7 +31,7 @@ class SubredditPage(Page):
self.controller = SubredditController(self) self.controller = SubredditController(self)
self.nav = Navigator(self.content.get) self.nav = Navigator(self.content.get)
@SubredditController.register(curses.KEY_F5, 'r') @SubredditController.register(KeyMap.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 +49,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(KeyMap.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 +65,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(KeyMap.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 +73,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(KeyMap.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 +93,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(KeyMap.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 +107,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(KeyMap.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 +145,7 @@ class SubredditPage(Page):
self.refresh_content() self.refresh_content()
@SubredditController.register('s') @SubredditController.register(KeyMap.SUBREDDIT_OPEN_SUBSCRIPTIONS)
@logged_in @logged_in
def open_subscriptions(self): def open_subscriptions(self):
"Open user subscriptions page" "Open user subscriptions page"

View File

@@ -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, KeyMap
from .terminal import Terminal
class SubscriptionController(PageController): class SubscriptionController(PageController):
@@ -23,7 +22,7 @@ class SubscriptionPage(Page):
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(KeyMap.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(KeyMap.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(KeyMap.SUBSCRIPTION_CLOSE)
def close_subscriptions(self): def close_subscriptions(self):
"Close subscriptions and return to the subreddit page" "Close subscriptions and return to the subreddit page"