Allow customization of the keyboard shortcuts used in calibre via Preferences->Advanced->Keyboard

This commit is contained in:
Kovid Goyal 2011-08-10 11:27:18 -06:00
parent 622ed308e7
commit 07a9042312

View File

@ -8,29 +8,70 @@ __copyright__ = '2011, Kovid Goyal <kovid@kovidgoyal.net>'
__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('<b>%s: %s</b>'%(_('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
# }}}