From 07a90423125273bfd0e29aeba3d9c7fc81d14693 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 10 Aug 2011 11:27:18 -0600 Subject: [PATCH] Allow customization of the keyboard shortcuts used in calibre via Preferences->Advanced->Keyboard --- src/calibre/gui2/keyboard.py | 340 ++++++++++++++++++++++++++++++----- 1 file changed, 299 insertions(+), 41 deletions(-) diff --git a/src/calibre/gui2/keyboard.py b/src/calibre/gui2/keyboard.py index 67a39b44e3..f2a9f8d886 100644 --- a/src/calibre/gui2/keyboard.py +++ b/src/calibre/gui2/keyboard.py @@ -8,29 +8,70 @@ __copyright__ = '2011, Kovid Goyal ' __docformat__ = 'restructuredtext en' from collections import OrderedDict +from functools import partial from PyQt4.Qt import (QObject, QKeySequence, QAbstractItemModel, QModelIndex, - Qt, QStyledItemDelegate, QTextDocument, QStyle, pyqtSignal, - QApplication, QSize, QRectF, QWidget, QHBoxLayout, QTreeView) + Qt, QStyledItemDelegate, QTextDocument, QStyle, pyqtSignal, QFrame, + QApplication, QSize, QRectF, QWidget, QTreeView, + QGridLayout, QLabel, QRadioButton, QPushButton, QToolButton, QIcon) from calibre.utils.config import JSONConfig from calibre.constants import DEBUG from calibre import prints from calibre.utils.icu import sort_key -from calibre.gui2 import NONE +from calibre.gui2 import NONE, error_dialog ROOT = QModelIndex() class NameConflict(ValueError): pass +def finalize(shortcuts, custom_keys_map={}): # {{{ + ''' + Resolve conflicts and assign keys to every action in shorcuts, which must + be a OrderedDict. User specified mappings of unique names to keys (as a + list of strings) should be passed in in custom_keys_map. Return a mapping + of unique names to resolved keys. Also sets the set_to_defaul member + correctly for each shortcut. + ''' + seen, keys_map = {}, {} + for unique_name, shortcut in shortcuts.iteritems(): + custom_keys = custom_keys_map.get(unique_name, None) + if custom_keys is None: + candidates = shortcut['default_keys'] + shortcut['set_to_default'] = True + else: + candidates = custom_keys + shortcut['set_to_default'] = False + keys = [] + for x in candidates: + ks = QKeySequence(x, QKeySequence.PortableText) + x = unicode(ks.toString(QKeySequence.PortableText)) + if x in seen: + if DEBUG: + prints('Key %r for shortcut %s is already used by' + ' %s, ignoring'%(x, shortcut['name'], seen[x]['name'])) + continue + seen[x] = shortcut + keys.append(ks) + keys = tuple(keys) + #print (111111, unique_name, candidates, keys) + + keys_map[unique_name] = keys + ac = shortcut['action'] + if ac is not None: + ac.setShortcuts(list(keys)) + + return keys_map + +# }}} + class Manager(QObject): # {{{ def __init__(self, parent=None): QObject.__init__(self, parent) self.config = JSONConfig('shortcuts/main') - self.custom_keys_map = {} self.shortcuts = OrderedDict() self.keys_map = {} self.groups = {} @@ -48,36 +89,10 @@ class Manager(QObject): # {{{ self.groups[group] = self.groups.get(group, []) + [unique_name] def finalize(self): - self.custom_keys_map = {un:tuple(keys) for un, keys in self.config.get( - 'map', {}).iteritems()} + custom_keys_map = {un:tuple(keys) for un, keys in self.config.get( + 'map', {}).iteritems()} + self.keys_map = finalize(self.shortcuts, custom_keys_map=custom_keys_map) - seen = {} - for unique_name, shortcut in self.shortcuts.iteritems(): - custom_keys = self.custom_keys_map.get(unique_name, None) - if custom_keys is None: - candidates = shortcut['default_keys'] - else: - candidates = custom_keys - keys = [] - for x in candidates: - ks = QKeySequence(x, QKeySequence.PortableText) - x = unicode(ks.toString(QKeySequence.PortableText)) - if x in seen: - if DEBUG: - prints('Key %r for shortcut %s is already used by' - ' %s, ignoring'%(x, shortcut['name'], seen[x]['name'])) - continue - seen[x] = shortcut - keys.append(ks) - keys = tuple(keys) - #print (111111, unique_name, candidates, keys) - - self.keys_map[unique_name] = keys - ac = shortcut['action'] - if ac is not None: - ac.setShortcuts(list(keys)) - - self.groups = {g:frozenset(v) for g, v in self.groups.iteritems()} # }}} # Model {{{ @@ -98,6 +113,10 @@ class Node(object): def __getitem__(self, row): return self.children[row] + def __iter__(self): + for child in self.children: + yield child + class ConfigModel(QAbstractItemModel): def __init__(self, keyboard, parent=None): @@ -105,7 +124,7 @@ class ConfigModel(QAbstractItemModel): self.keyboard = keyboard groups = sorted(keyboard.groups, key=sort_key) - shortcut_map = {k:dict(v) for k, v in + shortcut_map = {k:v.copy() for k, v in self.keyboard.shortcuts.iteritems()} for un, s in shortcut_map.iteritems(): s['keys'] = tuple(self.keyboard.keys_map[un]) @@ -119,6 +138,12 @@ class ConfigModel(QAbstractItemModel): self.data = [Node(group_map, shortcut_map, group) for group in groups] + @property + def all_shortcuts(self): + for group in self.data: + for sc in group: + yield sc + def rowCount(self, parent=ROOT): ip = parent.internalPointer() if ip is None: @@ -154,13 +179,186 @@ class ConfigModel(QAbstractItemModel): return ip return NONE + def flags(self, index): + ans = QAbstractItemModel.flags(self, index) + ip = index.internalPointer() + if getattr(ip, 'is_shortcut', False): + ans |= Qt.ItemIsEditable + return ans + + def restore_defaults(self): + shortcut_map = {} + for node in self.all_shortcuts: + sc = node.data + shortcut_map[sc['unique_name']] = sc + shortcuts = OrderedDict([(un, shortcut_map[un]) for un in + self.keyboard.shortcuts]) + keys_map = finalize(shortcuts) + for node in self.all_shortcuts: + s = node.data + s['keys'] = tuple(keys_map[s['unique_name']]) + for r in xrange(self.rowCount()): + group = self.index(r, 0) + num = self.rowCount(group) + if num > 0: + self.dataChanged.emit(self.index(0, 0, group), + self.index(num-1, 0, group)) + + def commit(self): + kmap = {} + for node in self.all_shortcuts: + sc = node.data + if sc['set_to_default']: continue + keys = [unicode(k.toString(k.PortableText)) for k in sc['keys']] + kmap[sc['unique_name']] = keys + self.keyboard.config['map'] = kmap + + +# }}} + +class Editor(QFrame): # {{{ + + editing_done = pyqtSignal(object) + + def __init__(self, parent=None): + QFrame.__init__(self, parent) + self.setFocusPolicy(Qt.StrongFocus) + self.setAutoFillBackground(True) + self.capture = 0 + + self.setFrameShape(self.StyledPanel) + self.setFrameShadow(self.Raised) + self._layout = l = QGridLayout(self) + self.setLayout(l) + + self.header = QLabel('') + l.addWidget(self.header, 0, 0, 1, 2) + + self.use_default = QRadioButton('') + self.use_custom = QRadioButton(_('Custom')) + l.addWidget(self.use_default, 1, 0, 1, 3) + l.addWidget(self.use_custom, 2, 0, 1, 3) + self.use_custom.toggled.connect(self.custom_toggled) + + off = 2 + for which in (1, 2): + text = _('&Shortcut:') if which == 1 else _('&Alternate shortcut:') + la = QLabel(text) + la.setStyleSheet('QLabel { margin-left: 1.5em }') + l.addWidget(la, off+which, 0, 1, 3) + setattr(self, 'label%d'%which, la) + button = QPushButton(_('None'), self) + button.clicked.connect(partial(self.capture_clicked, which=which)) + button.keyPressEvent = partial(self.key_press_event, which=which) + setattr(self, 'button%d'%which, button) + clear = QToolButton(self) + clear.setIcon(QIcon(I('clear_left.png'))) + clear.clicked.connect(partial(self.clear_clicked, which=which)) + setattr(self, 'clear%d'%which, clear) + l.addWidget(button, off+which, 1, 1, 1) + l.addWidget(clear, off+which, 2, 1, 1) + la.setBuddy(button) + + self.done_button = doneb = QPushButton(_('Done'), self) + l.addWidget(doneb, 0, 2, 1, 1) + doneb.clicked.connect(lambda : self.editing_done.emit(self)) + l.setColumnStretch(0, 100) + + self.custom_toggled(False) + + def initialize(self, shortcut, all_shortcuts): + self.header.setText('%s: %s'%(_('Customize'), shortcut['name'])) + self.all_shortcuts = all_shortcuts + self.shortcut = shortcut + + self.default_keys = [QKeySequence(k, QKeySequence.PortableText) for k + in shortcut['default_keys']] + self.current_keys = list(shortcut['keys']) + default = ', '.join([unicode(k.toString(k.NativeText)) for k in + self.default_keys]) + if not default: default = _('None') + current = ', '.join([unicode(k.toString(k.NativeText)) for k in + self.current_keys]) + if not current: current = _('None') + + self.use_default.setText(_('Default: %s [Currently not conflicting: %s]')% + (default, current)) + + if shortcut['set_to_default']: + self.use_default.setChecked(True) + else: + self.use_custom.setChecked(True) + for key, which in zip(self.current_keys, [1,2]): + button = getattr(self, 'button%d'%which) + button.setText(key.toString(key.NativeText)) + + def custom_toggled(self, checked): + for w in ('1', '2'): + for o in ('label', 'button', 'clear'): + getattr(self, o+w).setEnabled(checked) + + def capture_clicked(self, which=1): + self.capture = which + button = getattr(self, 'button%d'%which) + button.setText(_('Press a key...')) + button.setFocus(Qt.OtherFocusReason) + button.setStyleSheet('QPushButton { font-weight: bold}') + + def clear_clicked(self, which=0): + button = getattr(self, 'button%d'%which) + button.setText(_('None')) + + def key_press_event(self, ev, which=0): + code = ev.key() + if self.capture == 0 or code in (0, Qt.Key_unknown, + Qt.Key_Shift, Qt.Key_Control, Qt.Key_Alt, Qt.Key_Meta, + Qt.Key_AltGr, Qt.Key_CapsLock, Qt.Key_NumLock, Qt.Key_ScrollLock): + return QWidget.keyPressEvent(self, ev) + button = getattr(self, 'button%d'%which) + button.setStyleSheet('QPushButton { font-weight: normal}') + sequence = QKeySequence(code|(int(ev.modifiers())&~Qt.KeypadModifier)) + button.setText(sequence.toString(QKeySequence.NativeText)) + self.capture = 0 + dup_desc = self.dup_check(sequence) + if dup_desc is not None: + error_dialog(self, _('Already assigned'), + unicode(sequence.toString(QKeySequence.NativeText)) + ' ' + + _('already assigned to') + ' ' + dup_desc, show=True) + self.clear_clicked(which=which) + + def dup_check(self, sequence): + for sc in self.all_shortcuts: + if sc is self.shortcut: continue + for k in sc['keys']: + if k == sequence: + return sc['name'] + + @property + def custom_keys(self): + if self.use_default.isChecked(): + return None + ans = [] + for which in (1, 2): + button = getattr(self, 'button%d'%which) + t = unicode(button.text()) + if t == _('None'): + continue + ks = QKeySequence(t, QKeySequence.NativeText) + if not ks.isEmpty(): + ans.append(ks) + return tuple(ans) + + # }}} class Delegate(QStyledItemDelegate): # {{{ + changed_signal = pyqtSignal() + def __init__(self, parent=None): QStyledItemDelegate.__init__(self, parent) - self.editing_indices = {} + self.editing_index = None + self.closeEditor.connect(self.editing_done) def to_doc(self, index): data = index.data(Qt.UserRole).toPyObject() @@ -181,10 +379,9 @@ class Delegate(QStyledItemDelegate): # {{{ return doc def sizeHint(self, option, index): - if index.row() in self.editing_indices: + if index == self.editing_index: return QSize(200, 200) ans = self.to_doc(index).size().toSize() - #ans.setHeight(ans.height()+10) return ans def paint(self, painter, option, index): @@ -198,34 +395,95 @@ class Delegate(QStyledItemDelegate): # {{{ self.to_doc(index).drawContents(painter) painter.restore() + def createEditor(self, parent, option, index): + w = Editor(parent=parent) + w.editing_done.connect(self.editor_done) + self.editing_index = index + self.sizeHintChanged.emit(index) + return w + + def editor_done(self, editor): + self.commitData.emit(editor) + + def setEditorData(self, editor, index): + all_shortcuts = [x.data for x in index.model().all_shortcuts] + shortcut = index.internalPointer().data + editor.initialize(shortcut, all_shortcuts) + + def setModelData(self, editor, model, index): + self.closeEditor.emit(editor, self.NoHint) + custom_keys = editor.custom_keys + sc = index.data(Qt.UserRole).toPyObject().data + if custom_keys is None: + candidates = [] + for ckey in sc['default_keys']: + ckey = QKeySequence(ckey, QKeySequence.PortableText) + matched = False + for s in editor.all_shortcuts: + for k in s['keys']: + if k == ckey: + matched = True + break + if not matched: + candidates.append(ckey) + candidates = tuple(candidates) + sc['set_to_default'] = True + else: + sc['set_to_default'] = False + candidates = custom_keys + sc['keys'] = candidates + self.changed_signal.emit() + + def updateEditorGeometry(self, editor, option, index): + editor.setGeometry(option.rect) + + def editing_done(self, *args): + idx = self.editing_index + self.editing_index = None + if idx is not None: + self.sizeHintChanged.emit(idx) + # }}} -class ShortcutConfig(QWidget): +class ShortcutConfig(QWidget): # {{{ changed_signal = pyqtSignal() def __init__(self, parent=None): QWidget.__init__(self, parent) - self._layout = QHBoxLayout() + self._layout = l = QGridLayout() self.setLayout(self._layout) + self.header = QLabel(_('Double click on any entry to change the' + ' keyboard shortcuts associated with it')) + l.addWidget(self.header, 0, 0, 1, 1) self.view = QTreeView(self) self.view.setAlternatingRowColors(True) self.view.setHeaderHidden(True) self.view.setAnimated(True) - self._layout.addWidget(self.view) + l.addWidget(self.view, 1, 0, 1, 1) self.delegate = Delegate() self.view.setItemDelegate(self.delegate) self.delegate.sizeHintChanged.connect(self.scrollTo) + self.delegate.changed_signal.connect(self.changed_signal) + + def restore_defaults(self): + self._model.restore_defaults() + self.changed_signal.emit() + + def commit(self): + self._model.commit() def initialize(self, keyboard): self._model = ConfigModel(keyboard, parent=self) self.view.setModel(self._model) def scrollTo(self, index): - self.view.scrollTo(index) + if index is not None: + self.view.scrollTo(index, self.view.PositionAtCenter) @property def is_editing(self): return self.view.state() == self.view.EditingState +# }}}