%PDF- %PDF-
Direktori : /lib/calibre/calibre/gui2/ |
Current File : //lib/calibre/calibre/gui2/keyboard.py |
#!/usr/bin/env python3 __license__ = 'GPL v3' __copyright__ = '2011, Kovid Goyal <kovid@kovidgoyal.net>' __docformat__ = 'restructuredtext en' from collections import OrderedDict from functools import partial from gettext import pgettext from qt.core import (QObject, QKeySequence, QAbstractItemModel, QModelIndex, QItemSelectionModel, Qt, QStyledItemDelegate, QTextDocument, QStyle, pyqtSignal, QFrame, QAbstractItemView, QMenu, QApplication, QSize, QRectF, QWidget, QTreeView, QHBoxLayout, QVBoxLayout, QAbstractItemDelegate, QGridLayout, QLabel, QRadioButton, QPushButton, QToolButton, QIcon, QEvent, sip) from calibre.utils.config import JSONConfig from calibre.constants import DEBUG from calibre import prints, prepare_string_for_xml from calibre.utils.icu import sort_key, lower from calibre.gui2 import error_dialog, info_dialog from calibre.utils.search_query_parser import SearchQueryParser, ParseException from calibre.gui2.search_box import SearchBox2 from polyglot.builtins import iteritems, itervalues ROOT = QModelIndex() class NameConflict(ValueError): pass def keysequence_from_event(ev): # {{{ k, mods = ev.key(), ev.modifiers() if k in ( 0, Qt.Key.Key_unknown, Qt.Key.Key_Shift, Qt.Key.Key_Control, Qt.Key.Key_Alt, Qt.Key.Key_Meta, Qt.Key.Key_AltGr, Qt.Key.Key_CapsLock, Qt.Key.Key_NumLock, Qt.Key.Key_ScrollLock): return letter = QKeySequence(k).toString(QKeySequence.SequenceFormat.PortableText) if mods & Qt.KeyboardModifier.ShiftModifier and letter.lower() == letter.upper(): # Something like Shift+* or Shift+> we have to remove the shift, # since it is included in keycode. mods = mods & ~Qt.KeyboardModifier.ShiftModifier return QKeySequence(k | int(mods)) # }}} def finalize(shortcuts, custom_keys_map={}): # {{{ ''' Resolve conflicts and assign keys to every action in shortcuts, 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_default member correctly for each shortcut. ''' seen, keys_map = {}, {} for unique_name, shortcut in iteritems(shortcuts): 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.SequenceFormat.PortableText) x = str(ks.toString(QKeySequence.SequenceFormat.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'])) keys_map[unique_name] = () continue seen[x] = shortcut keys.append(ks) keys = tuple(keys) keys_map[unique_name] = keys ac = shortcut['action'] if ac is None or sip.isdeleted(ac): if ac is not None and DEBUG: prints('Shortcut %r has a deleted action' % unique_name) continue ac.setShortcuts(list(keys)) return keys_map # }}} class Manager(QObject): # {{{ def __init__(self, parent=None, config_name='shortcuts/main'): QObject.__init__(self, parent) self.config = JSONConfig(config_name) self.shortcuts = OrderedDict() self.keys_map = {} self.groups = {} def register_shortcut(self, unique_name, name, default_keys=(), description=None, action=None, group=None, persist_shortcut=False): ''' Register a shortcut with calibre. calibre will manage the shortcut, automatically resolving conflicts and allowing the user to customize it. :param unique_name: A string that uniquely identifies this shortcut :param name: A user visible name describing the action performed by this shortcut :param default_keys: A tuple of keys that trigger this shortcut. Each key must be a string. For example: ('Ctrl+A', 'Alt+B', 'C', 'Shift+Meta+D'). These keys will be assigned to the shortcut unless there is a conflict. :param action: A QAction object. The shortcut will cause this QAction to be triggered. Connect to its triggered signal in your code to respond to the shortcut. :param group: A string describing what "group" this shortcut belongs to. This is used to organize the list of shortcuts when the user is customizing them. :persist_shortcut: Shortcuts for actions that don't always appear, or are library dependent, may disappear when other keyboard shortcuts are edited unless ```persist_shortcut``` is set True. ''' if unique_name in self.shortcuts: name = self.shortcuts[unique_name]['name'] raise NameConflict('Shortcut for %r already registered by %s'%( unique_name, name)) shortcut = {'name':name, 'desc':description, 'action': action, 'default_keys':tuple(default_keys), 'persist_shortcut':persist_shortcut} self.shortcuts[unique_name] = shortcut group = group if group else pgettext('keyboard shortcuts', _('Miscellaneous')) self.groups[group] = self.groups.get(group, []) + [unique_name] def unregister_shortcut(self, unique_name): ''' Remove a registered shortcut. You need to call finalize() after you are done unregistering. ''' self.shortcuts.pop(unique_name, None) for group in itervalues(self.groups): try: group.remove(unique_name) except ValueError: pass def finalize(self): custom_keys_map = {un:tuple(keys) for un, keys in iteritems(self.config.get( 'map', {}))} self.keys_map = finalize(self.shortcuts, custom_keys_map=custom_keys_map) def replace_action(self, unique_name, new_action): ''' Replace the action associated with a shortcut. Once you're done calling replace_action() for all shortcuts you want replaced, call finalize() to have the shortcuts assigned to the replaced actions. ''' sc = self.shortcuts[unique_name] sc['action'] = new_action # }}} # Model {{{ class Node: def __init__(self, group_map, shortcut_map, name=None, shortcut=None): self.data = name if name is not None else shortcut self.is_shortcut = shortcut is not None self.children = [] if name is not None: self.children = [Node(None, None, shortcut=shortcut_map[uname]) for uname in group_map[name]] def __len__(self): return len(self.children) def __getitem__(self, row): return self.children[row] def __iter__(self): yield from self.children class ConfigModel(SearchQueryParser, QAbstractItemModel): def __init__(self, keyboard, parent=None): QAbstractItemModel.__init__(self, parent) SearchQueryParser.__init__(self, ['all']) self.keyboard = keyboard groups = sorted(keyboard.groups, key=sort_key) shortcut_map = {k:v.copy() for k, v in iteritems(self.keyboard.shortcuts)} for un, s in iteritems(shortcut_map): s['keys'] = tuple(self.keyboard.keys_map.get(un, ())) s['unique_name'] = un s['group'] = [g for g, names in iteritems(self.keyboard.groups) if un in names][0] group_map = {group:sorted(names, key=lambda x: sort_key(shortcut_map[x]['name'])) for group, names in iteritems(self.keyboard.groups)} self.data = [Node(group_map, shortcut_map, group) for group in groups] @property def all_shortcuts(self): for group in self.data: yield from group def rowCount(self, parent=ROOT): ip = parent.internalPointer() if ip is None: return len(self.data) return len(ip) def columnCount(self, parent=ROOT): return 1 def index(self, row, column, parent=ROOT): ip = parent.internalPointer() if ip is None: ip = self.data try: return self.createIndex(row, column, ip[row]) except: pass return ROOT def parent(self, index): ip = index.internalPointer() if ip is None or not ip.is_shortcut: return ROOT group = ip.data['group'] for i, g in enumerate(self.data): if g.data == group: return self.index(i, 0) return ROOT def data(self, index, role=Qt.ItemDataRole.DisplayRole): ip = index.internalPointer() if ip is not None and role == Qt.ItemDataRole.UserRole: return ip return None def flags(self, index): ans = QAbstractItemModel.flags(self, index) ip = index.internalPointer() if getattr(ip, 'is_shortcut', False): ans |= Qt.ItemFlag.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 range(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 = {} # persist flags not in map for back compat # not *just* persist flag for forward compat options_map = {} options_map.update(self.keyboard.config.get('options_map', {})) # keep mapped keys that are marked persistent. for un, keys in iteritems(self.keyboard.config.get('map', {})): if options_map.get(un, {}).get('persist_shortcut',False): kmap[un] = keys for node in self.all_shortcuts: sc = node.data un = sc['unique_name'] if sc['set_to_default']: if un in kmap: del kmap[un] if un in options_map: del options_map[un] else: if sc['persist_shortcut']: options_map[un] = options_map.get(un, {}) options_map[un]['persist_shortcut'] = sc['persist_shortcut'] keys = [str(k.toString(QKeySequence.SequenceFormat.PortableText)) for k in sc['keys']] kmap[un] = keys with self.keyboard.config: self.keyboard.config['map'] = kmap self.keyboard.config['options_map'] = options_map def universal_set(self): ans = set() for i, group in enumerate(self.data): ans.add((i, -1)) for j, sc in enumerate(group.children): ans.add((i, j)) return ans def get_matches(self, location, query, candidates=None): if candidates is None: candidates = self.universal_set() ans = set() if not query: return ans query = lower(query) for c, p in candidates: if p < 0: if query in lower(self.data[c].data): ans.add((c, p)) else: try: sc = self.data[c].children[p].data except: continue if query in lower(sc['name']): ans.add((c, p)) return ans def find(self, query): query = query.strip() if not query: return ROOT matches = self.parse(query) if not matches: return ROOT matches = list(sorted(matches)) c, p = matches[0] cat_idx = self.index(c, 0) if p == -1: return cat_idx return self.index(p, 0, cat_idx) def find_next(self, idx, query, backwards=False): query = query.strip() if not query: return idx matches = self.parse(query) if not matches: return idx if idx.parent().isValid(): loc = (idx.parent().row(), idx.row()) else: loc = (idx.row(), -1) if loc not in matches: return self.find(query) if len(matches) == 1: return ROOT matches = list(sorted(matches)) i = matches.index(loc) if backwards: ans = i - 1 if i - 1 >= 0 else len(matches)-1 else: ans = i + 1 if i + 1 < len(matches) else 0 ans = matches[ans] return (self.index(ans[0], 0) if ans[1] < 0 else self.index(ans[1], 0, self.index(ans[0], 0))) def index_for_group(self, name): for i in range(self.rowCount()): node = self.data[i] if node.data == name: return self.index(i, 0) @property def group_names(self): for i in range(self.rowCount()): node = self.data[i] yield node.data # }}} class Editor(QFrame): # {{{ editing_done = pyqtSignal(object) def __init__(self, parent=None): QFrame.__init__(self, parent) self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.setAutoFillBackground(True) self.capture = 0 self.setFrameShape(QFrame.Shape.StyledPanel) self.setFrameShadow(QFrame.Shadow.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.installEventFilter(self) 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.SequenceFormat.PortableText) for k in shortcut['default_keys']] self.current_keys = list(shortcut['keys']) default = ', '.join([str(k.toString(QKeySequence.SequenceFormat.NativeText)) for k in self.default_keys]) if not default: default = _('None') current = ', '.join([str(k.toString(QKeySequence.SequenceFormat.NativeText)) for k in self.current_keys]) if not current: current = _('None') self.use_default.setText(_('&Default: %(deflt)s [Currently not conflicting: %(curr)s]')% dict(deflt=default, curr=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(QKeySequence.SequenceFormat.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.FocusReason.OtherFocusReason) button.setStyleSheet('QPushButton { font-weight: bold}') def clear_clicked(self, which=0): button = getattr(self, 'button%d'%which) button.setText(_('None')) def eventFilter(self, obj, event): if self.capture and obj in (self.button1, self.button2): t = event.type() if t == QEvent.Type.ShortcutOverride: event.accept() return True if t == QEvent.Type.KeyPress: self.key_press_event(event, 1 if obj is self.button1 else 2) return True return QFrame.eventFilter(self, obj, event) def key_press_event(self, ev, which=0): if self.capture == 0: return QWidget.keyPressEvent(self, ev) sequence = keysequence_from_event(ev) if sequence is None: return QWidget.keyPressEvent(self, ev) ev.accept() button = getattr(self, 'button%d'%which) button.setStyleSheet('QPushButton { font-weight: normal}') button.setText(sequence.toString(QKeySequence.SequenceFormat.NativeText)) self.capture = 0 dup_desc = self.dup_check(sequence) if dup_desc is not None: error_dialog(self, _('Already assigned'), str(sequence.toString(QKeySequence.SequenceFormat.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 = str(button.text()) if t == _('None'): continue ks = QKeySequence(t, QKeySequence.SequenceFormat.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_index = None self.closeEditor.connect(self.editing_done) def to_doc(self, index): data = index.data(Qt.ItemDataRole.UserRole) if data is None: html = _('<b>This shortcut no longer exists</b>') elif data.is_shortcut: shortcut = data.data # Shortcut keys = [str(k.toString(QKeySequence.SequenceFormat.NativeText)) for k in shortcut['keys']] if not keys: keys = _('None') else: keys = ', '.join(keys) html = '<b>%s</b><br>%s: %s'%( prepare_string_for_xml(shortcut['name']), _('Shortcuts'), prepare_string_for_xml(keys)) else: # Group html = data.data doc = QTextDocument() doc.setHtml(html) return doc def sizeHint(self, option, index): if index == self.editing_index: return QSize(200, 200) ans = self.to_doc(index).size().toSize() return ans def paint(self, painter, option, index): painter.save() painter.setClipRect(QRectF(option.rect)) if hasattr(QStyle, 'CE_ItemViewItem'): QApplication.style().drawControl(QStyle.ControlElement.CE_ItemViewItem, option, painter) elif option.state & QStyle.StateFlag.State_Selected: painter.fillRect(option.rect, option.palette.highlight()) painter.translate(option.rect.topLeft()) 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.current_editor = w self.sizeHintChanged.emit(index) return w def accept_changes(self): self.editor_done(self.current_editor) 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, QAbstractItemDelegate.EndEditHint.NoHint) custom_keys = editor.custom_keys sc = index.data(Qt.ItemDataRole.UserRole).data if custom_keys is None: candidates = [] for ckey in sc['default_keys']: ckey = QKeySequence(ckey, QKeySequence.SequenceFormat.PortableText) matched = False for s in editor.all_shortcuts: if s is editor.shortcut: continue 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): self.current_editor = None idx = self.editing_index self.editing_index = None if idx is not None: self.sizeHintChanged.emit(idx) # }}} class ShortcutConfig(QWidget): # {{{ changed_signal = pyqtSignal() def __init__(self, parent=None): QWidget.__init__(self, parent) self._layout = l = QVBoxLayout(self) self.header = QLabel(_('Double click on any entry to change the' ' keyboard shortcuts associated with it')) l.addWidget(self.header) self.view = QTreeView(self) self.view.setAlternatingRowColors(True) self.view.setHeaderHidden(True) self.view.setAnimated(True) self.view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.view.customContextMenuRequested.connect(self.show_context_menu) l.addWidget(self.view) self.delegate = Delegate() self.view.setItemDelegate(self.delegate) self.delegate.sizeHintChanged.connect(self.editor_opened, type=Qt.ConnectionType.QueuedConnection) self.delegate.changed_signal.connect(self.changed_signal) self.search = SearchBox2(self) self.search.initialize('shortcuts_search_history', help_text=_('Search for a shortcut by name')) self.search.search.connect(self.find) self._h = h = QHBoxLayout() l.addLayout(h) h.addWidget(self.search) self.nb = QPushButton(QIcon(I('arrow-down.png')), _('&Next'), self) self.pb = QPushButton(QIcon(I('arrow-up.png')), _('&Previous'), self) self.nb.clicked.connect(self.find_next) self.pb.clicked.connect(self.find_previous) h.addWidget(self.nb), h.addWidget(self.pb) h.setStretch(0, 100) def show_context_menu(self, pos): menu = QMenu(self) menu.addAction(QIcon.ic('plus.png'), _('Expand all'), self.view.expandAll) menu.addAction(QIcon.ic('minus.png'), _('Collapse all'), self.view.collapseAll) menu.exec(self.view.mapToGlobal(pos)) def restore_defaults(self): self._model.restore_defaults() self.changed_signal.emit() def commit(self): if self.view.state() == QAbstractItemView.State.EditingState: self.delegate.accept_changes() self._model.commit() def initialize(self, keyboard): self._model = ConfigModel(keyboard, parent=self) self.view.setModel(self._model) def editor_opened(self, index): self.view.scrollTo(index, QAbstractItemView.ScrollHint.EnsureVisible) @property def is_editing(self): return self.view.state() == QAbstractItemView.State.EditingState def find(self, query): if not query: return try: idx = self._model.find(query) except ParseException: self.search.search_done(False) return self.search.search_done(True) if not idx.isValid(): info_dialog(self, _('No matches'), _('Could not find any shortcuts matching <i>{}</i>').format(prepare_string_for_xml(query)), show=True, show_copy_button=False) return self.highlight_index(idx) def highlight_index(self, idx): self.view.scrollTo(idx) self.view.selectionModel().select(idx, QItemSelectionModel.SelectionFlag.ClearAndSelect) self.view.setCurrentIndex(idx) self.view.setFocus(Qt.FocusReason.OtherFocusReason) def find_next(self, *args): idx = self.view.currentIndex() if not idx.isValid(): idx = self._model.index(0, 0) idx = self._model.find_next(idx, str(self.search.currentText())) self.highlight_index(idx) def find_previous(self, *args): idx = self.view.currentIndex() if not idx.isValid(): idx = self._model.index(0, 0) idx = self._model.find_next(idx, str(self.search.currentText()), backwards=True) self.highlight_index(idx) def highlight_group(self, group_name): idx = self.view.model().index_for_group(group_name) if idx is not None: self.view.expand(idx) self.view.scrollTo(idx, QAbstractItemView.ScrollHint.PositionAtTop) self.view.selectionModel().select(idx, QItemSelectionModel.SelectionFlag.ClearAndSelect) self.view.setCurrentIndex(idx) self.view.setFocus(Qt.FocusReason.OtherFocusReason) # }}}