Almost functional.
This commit is contained in:
@@ -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']:
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
@@ -552,3 +569,52 @@ class Controller(object):
|
||||
cls.character_map[char] = f
|
||||
return f
|
||||
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)
|
||||
38
rtv/page.py
38
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):
|
||||
"""
|
||||
|
||||
@@ -100,7 +100,7 @@ INBOX = i
|
||||
REFRESH = r, <KEY_F5>
|
||||
|
||||
; 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_POST = c
|
||||
SUBMISSION_EXIT = h, <KEY_LEFT>
|
||||
@@ -111,6 +111,7 @@ 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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user