1# -*- coding: utf-8 -*- 2# 3# Copyright © Spyder Project Contributors 4# Licensed under the terms of the MIT License 5# (see spyder/__init__.py for details) 6 7"""Shortcut management""" 8 9# Standard library imports 10from __future__ import print_function 11import os 12import re 13import sys 14 15# Third party imports 16from qtpy import PYQT5 17from qtpy.compat import from_qvariant, to_qvariant 18from qtpy.QtCore import (QAbstractTableModel, QModelIndex, QRegExp, 19 QSortFilterProxyModel, Qt, Slot) 20from qtpy.QtGui import (QKeySequence, QRegExpValidator) 21from qtpy.QtWidgets import (QAbstractItemView, QApplication, QDialog, 22 QDialogButtonBox, QGridLayout, QHBoxLayout, QLabel, 23 QLineEdit, QMessageBox, QPushButton, QSpacerItem, 24 QTableView, QVBoxLayout) 25 26# Local imports 27from spyder.config.base import _, debug_print 28from spyder.config.gui import (get_shortcut, iter_shortcuts, 29 reset_shortcuts, set_shortcut) 30from spyder.plugins.configdialog import GeneralConfigPage 31from spyder.utils import icon_manager as ima 32from spyder.utils.qthelpers import get_std_icon 33from spyder.utils.stringmatching import get_search_scores, get_search_regex 34from spyder.widgets.helperwidgets import HTMLDelegate 35from spyder.widgets.helperwidgets import HelperToolButton 36 37 38MODIFIERS = {Qt.Key_Shift: Qt.SHIFT, 39 Qt.Key_Control: Qt.CTRL, 40 Qt.Key_Alt: Qt.ALT, 41 Qt.Key_Meta: Qt.META} 42 43# Valid shortcut keys 44SINGLE_KEYS = ["F{}".format(_i) for _i in range(1, 36)] + ["Delete", "Escape"] 45KEYSTRINGS = ["Tab", "Backtab", "Backspace", "Return", "Enter", 46 "Pause", "Print", "Clear", "Home", "End", "Left", 47 "Up", "Right", "Down", "PageUp", "PageDown"] + \ 48 ["Space", "Exclam", "QuoteDbl", "NumberSign", "Dollar", 49 "Percent", "Ampersand", "Apostrophe", "ParenLeft", 50 "ParenRight", "Asterisk", "Plus", "Comma", "Minus", 51 "Period", "Slash"] + \ 52 [str(_i) for _i in range(10)] + \ 53 ["Colon", "Semicolon", "Less", "Equal", "Greater", 54 "Question", "At"] + [chr(_i) for _i in range(65, 91)] + \ 55 ["BracketLeft", "Backslash", "BracketRight", "Underscore", 56 "Control", "Alt", "Shift", "Meta"] 57VALID_SINGLE_KEYS = [getattr(Qt, 'Key_{0}'.format(k)) for k in SINGLE_KEYS] 58VALID_KEYS = [getattr(Qt, 'Key_{0}'.format(k)) for k in KEYSTRINGS+SINGLE_KEYS] 59 60# Valid finder chars. To be improved 61VALID_ACCENT_CHARS = "ÁÉÍOÚáéíúóàèìòùÀÈÌÒÙâêîôûÂÊÎÔÛäëïöüÄËÏÖÜñÑ" 62VALID_FINDER_CHARS = r"[A-Za-z\s{0}]".format(VALID_ACCENT_CHARS) 63 64BLACKLIST = { 65 'Shift+Del': _('Currently used to delete lines on editor/Cut a word'), 66 'Shift+Ins': _('Currently used to paste a word') 67} 68 69if os.name == 'nt': 70 BLACKLIST['Alt+Backspace'] = _('We cannot support this ' 71 'shortcut on Windows') 72 73BLACKLIST['Shift'] = _('Shortcuts that use Shift and another key' 74 ' are unsupported') 75 76 77class CustomLineEdit(QLineEdit): 78 """QLineEdit that filters its key press and release events.""" 79 def __init__(self, parent): 80 super(CustomLineEdit, self).__init__(parent) 81 self.setReadOnly(True) 82 self.setFocusPolicy(Qt.NoFocus) 83 84 def keyPressEvent(self, e): 85 """Qt Override""" 86 self.parent().keyPressEvent(e) 87 88 def keyReleaseEvent(self, e): 89 """Qt Override""" 90 self.parent().keyReleaseEvent(e) 91 92 93class ShortcutFinder(QLineEdit): 94 """Textbox for filtering listed shortcuts in the table.""" 95 def __init__(self, parent, callback=None): 96 super(ShortcutFinder, self).__init__(parent) 97 self._parent = parent 98 99 # Widget setup 100 regex = QRegExp(VALID_FINDER_CHARS + "{100}") 101 self.setValidator(QRegExpValidator(regex)) 102 103 # Signals 104 if callback: 105 self.textChanged.connect(callback) 106 107 def set_text(self, text): 108 """Set the filter text.""" 109 text = text.strip() 110 new_text = self.text() + text 111 self.setText(new_text) 112 113 def keyPressEvent(self, event): 114 """Qt Override.""" 115 key = event.key() 116 if key in [Qt.Key_Up]: 117 self._parent.previous_row() 118 elif key in [Qt.Key_Down]: 119 self._parent.next_row() 120 elif key in [Qt.Key_Enter, Qt.Key_Return]: 121 self._parent.show_editor() 122 else: 123 super(ShortcutFinder, self).keyPressEvent(event) 124 125 126# Error codes for the shortcut editor dialog 127(NO_WARNING, SEQUENCE_LENGTH, SEQUENCE_CONFLICT, 128 INVALID_KEY, IN_BLACKLIST, SHIFT_BLACKLIST) = [0, 1, 2, 3, 4, 5] 129 130 131class ShortcutEditor(QDialog): 132 """A dialog for entering key sequences.""" 133 def __init__(self, parent, context, name, sequence, shortcuts): 134 super(ShortcutEditor, self).__init__(parent) 135 self._parent = parent 136 137 self.context = context 138 self.npressed = 0 139 self.keys = set() 140 self.key_modifiers = set() 141 self.key_non_modifiers = list() 142 self.key_text = list() 143 self.sequence = sequence 144 self.new_sequence = None 145 self.edit_state = True 146 self.shortcuts = shortcuts 147 148 # Widgets 149 self.label_info = QLabel() 150 self.label_info.setText(_("Press the new shortcut and select 'Ok': \n" 151 "(Press 'Tab' once to switch focus between the shortcut entry \n" 152 "and the buttons below it)")) 153 self.label_current_sequence = QLabel(_("Current shortcut:")) 154 self.text_current_sequence = QLabel(sequence) 155 self.label_new_sequence = QLabel(_("New shortcut:")) 156 self.text_new_sequence = CustomLineEdit(self) 157 self.text_new_sequence.setPlaceholderText(sequence) 158 self.helper_button = HelperToolButton() 159 self.helper_button.hide() 160 self.label_warning = QLabel() 161 self.label_warning.hide() 162 163 bbox = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) 164 self.button_ok = bbox.button(QDialogButtonBox.Ok) 165 self.button_cancel = bbox.button(QDialogButtonBox.Cancel) 166 167 # Setup widgets 168 self.setWindowTitle(_('Shortcut: {0}').format(name)) 169 self.button_ok.setFocusPolicy(Qt.NoFocus) 170 self.button_ok.setEnabled(False) 171 self.button_cancel.setFocusPolicy(Qt.NoFocus) 172 self.helper_button.setToolTip('') 173 self.helper_button.setFocusPolicy(Qt.NoFocus) 174 style = """ 175 QToolButton { 176 margin:1px; 177 border: 0px solid grey; 178 padding:0px; 179 border-radius: 0px; 180 }""" 181 self.helper_button.setStyleSheet(style) 182 self.text_new_sequence.setFocusPolicy(Qt.NoFocus) 183 self.label_warning.setFocusPolicy(Qt.NoFocus) 184 185 # Layout 186 spacing = 5 187 layout_sequence = QGridLayout() 188 layout_sequence.addWidget(self.label_info, 0, 0, 1, 3) 189 layout_sequence.addItem(QSpacerItem(spacing, spacing), 1, 0, 1, 2) 190 layout_sequence.addWidget(self.label_current_sequence, 2, 0) 191 layout_sequence.addWidget(self.text_current_sequence, 2, 2) 192 layout_sequence.addWidget(self.label_new_sequence, 3, 0) 193 layout_sequence.addWidget(self.helper_button, 3, 1) 194 layout_sequence.addWidget(self.text_new_sequence, 3, 2) 195 layout_sequence.addWidget(self.label_warning, 4, 2, 1, 2) 196 197 layout = QVBoxLayout() 198 layout.addLayout(layout_sequence) 199 layout.addSpacing(spacing) 200 layout.addWidget(bbox) 201 self.setLayout(layout) 202 203 # Signals 204 bbox.accepted.connect(self.accept) 205 bbox.rejected.connect(self.reject) 206 207 @Slot() 208 def reject(self): 209 """Slot for rejected signal.""" 210 # Added for issue #5426. Due to the focusPolicy of Qt.NoFocus for the 211 # buttons, if the cancel button was clicked without first setting focus 212 # to the button, it would cause a seg fault crash. 213 self.button_cancel.setFocus() 214 super(ShortcutEditor, self).reject() 215 216 @Slot() 217 def accept(self): 218 """Slot for accepted signal.""" 219 # Added for issue #5426. Due to the focusPolicy of Qt.NoFocus for the 220 # buttons, if the ok button was clicked without first setting focus to 221 # the button, it would cause a seg fault crash. 222 self.button_ok.setFocus() 223 super(ShortcutEditor, self).accept() 224 225 def keyPressEvent(self, e): 226 """Qt override.""" 227 key = e.key() 228 # Check if valid keys 229 if key not in VALID_KEYS: 230 self.invalid_key_flag = True 231 return 232 233 self.npressed += 1 234 self.key_non_modifiers.append(key) 235 self.key_modifiers.add(key) 236 self.key_text.append(e.text()) 237 self.invalid_key_flag = False 238 239 debug_print('key {0}, npressed: {1}'.format(key, self.npressed)) 240 241 if key == Qt.Key_unknown: 242 return 243 244 # The user clicked just and only the special keys 245 # Ctrl, Shift, Alt, Meta. 246 if (key == Qt.Key_Control or 247 key == Qt.Key_Shift or 248 key == Qt.Key_Alt or 249 key == Qt.Key_Meta): 250 return 251 252 modifiers = e.modifiers() 253 if modifiers & Qt.ShiftModifier: 254 key += Qt.SHIFT 255 if modifiers & Qt.ControlModifier: 256 key += Qt.CTRL 257 if sys.platform == 'darwin': 258 self.npressed -= 1 259 debug_print('decrementing') 260 if modifiers & Qt.AltModifier: 261 key += Qt.ALT 262 if modifiers & Qt.MetaModifier: 263 key += Qt.META 264 265 self.keys.add(key) 266 267 def toggle_state(self): 268 """Switch between shortcut entry and Accept/Cancel shortcut mode.""" 269 self.edit_state = not self.edit_state 270 271 if not self.edit_state: 272 self.text_new_sequence.setEnabled(False) 273 if self.button_ok.isEnabled(): 274 self.button_ok.setFocus() 275 else: 276 self.button_cancel.setFocus() 277 else: 278 self.text_new_sequence.setEnabled(True) 279 self.text_new_sequence.setFocus() 280 281 def nonedit_keyrelease(self, e): 282 """Key release event for non-edit state.""" 283 key = e.key() 284 if key in [Qt.Key_Escape]: 285 self.close() 286 return 287 288 if key in [Qt.Key_Left, Qt.Key_Right, Qt.Key_Up, 289 Qt.Key_Down]: 290 if self.button_ok.hasFocus(): 291 self.button_cancel.setFocus() 292 else: 293 self.button_ok.setFocus() 294 295 def keyReleaseEvent(self, e): 296 """Qt override.""" 297 self.npressed -= 1 298 if self.npressed <= 0: 299 key = e.key() 300 301 if len(self.keys) == 1 and key == Qt.Key_Tab: 302 self.toggle_state() 303 return 304 305 if len(self.keys) == 1 and key == Qt.Key_Escape: 306 self.set_sequence('') 307 self.label_warning.setText(_("Please introduce a different " 308 "shortcut")) 309 310 if len(self.keys) == 1 and key in [Qt.Key_Return, Qt.Key_Enter]: 311 self.toggle_state() 312 return 313 314 if not self.edit_state: 315 self.nonedit_keyrelease(e) 316 else: 317 debug_print('keys: {}'.format(self.keys)) 318 if self.keys and key != Qt.Key_Escape: 319 self.validate_sequence() 320 self.keys = set() 321 self.key_modifiers = set() 322 self.key_non_modifiers = list() 323 self.key_text = list() 324 self.npressed = 0 325 326 def check_conflicts(self): 327 """Check shortcuts for conflicts.""" 328 conflicts = [] 329 for index, shortcut in enumerate(self.shortcuts): 330 sequence = str(shortcut.key) 331 if sequence == self.new_sequence and \ 332 (shortcut.context == self.context or shortcut.context == '_' or 333 self.context == '_'): 334 conflicts.append(shortcut) 335 return conflicts 336 337 def update_warning(self, warning_type=NO_WARNING, conflicts=[]): 338 """Update warning label to reflect conflict status of new shortcut""" 339 if warning_type == NO_WARNING: 340 warn = False 341 tip = 'This shortcut is correct!' 342 elif warning_type == SEQUENCE_CONFLICT: 343 template = '<i>{0}<b>{1}</b></i>' 344 tip_title = _('The new shortcut conflicts with:') + '<br>' 345 tip_body = '' 346 for s in conflicts: 347 tip_body += ' - {0}: {1}<br>'.format(s.context, s.name) 348 tip_body = tip_body[:-4] # Removing last <br> 349 tip = template.format(tip_title, tip_body) 350 warn = True 351 elif warning_type == IN_BLACKLIST: 352 template = '<i>{0}<b>{1}</b></i>' 353 tip_title = _('Forbidden key sequence!') + '<br>' 354 tip_body = '' 355 use = BLACKLIST[self.new_sequence] 356 if use is not None: 357 tip_body = use 358 tip = template.format(tip_title, tip_body) 359 warn = True 360 elif warning_type == SHIFT_BLACKLIST: 361 template = '<i>{0}<b>{1}</b></i>' 362 tip_title = _('Forbidden key sequence!') + '<br>' 363 tip_body = '' 364 use = BLACKLIST['Shift'] 365 if use is not None: 366 tip_body = use 367 tip = template.format(tip_title, tip_body) 368 warn = True 369 elif warning_type == SEQUENCE_LENGTH: 370 # Sequences with 5 keysequences (i.e. Ctrl+1, Ctrl+2, Ctrl+3, 371 # Ctrl+4, Ctrl+5) are invalid 372 template = '<i>{0}</i>' 373 tip = _('A compound sequence can have {break} a maximum of ' 374 '4 subsequences.{break}').format(**{'break': '<br>'}) 375 warn = True 376 elif warning_type == INVALID_KEY: 377 template = '<i>{0}</i>' 378 tip = _('Invalid key entered') + '<br>' 379 warn = True 380 381 self.helper_button.show() 382 if warn: 383 self.label_warning.show() 384 self.helper_button.setIcon(get_std_icon('MessageBoxWarning')) 385 self.button_ok.setEnabled(False) 386 else: 387 self.helper_button.setIcon(get_std_icon('DialogApplyButton')) 388 389 self.label_warning.setText(tip) 390 391 def set_sequence(self, sequence): 392 """Set the new shortcut and update buttons.""" 393 if not sequence or self.sequence == sequence: 394 self.button_ok.setEnabled(False) 395 different_sequence = False 396 else: 397 self.button_ok.setEnabled(True) 398 different_sequence = True 399 400 if sys.platform == 'darwin': 401 if 'Meta+Ctrl' in sequence: 402 shown_sequence = sequence.replace('Meta+Ctrl', 'Ctrl+Cmd') 403 elif 'Ctrl+Meta' in sequence: 404 shown_sequence = sequence.replace('Ctrl+Meta', 'Cmd+Ctrl') 405 elif 'Ctrl' in sequence: 406 shown_sequence = sequence.replace('Ctrl', 'Cmd') 407 elif 'Meta' in sequence: 408 shown_sequence = sequence.replace('Meta', 'Ctrl') 409 else: 410 shown_sequence = sequence 411 else: 412 shown_sequence = sequence 413 self.text_new_sequence.setText(shown_sequence) 414 self.new_sequence = sequence 415 416 conflicts = self.check_conflicts() 417 blacklist = self.new_sequence in BLACKLIST 418 individual_keys = self.new_sequence.split('+') 419 if conflicts and different_sequence: 420 warning_type = SEQUENCE_CONFLICT 421 elif blacklist: 422 warning_type = IN_BLACKLIST 423 elif len(individual_keys) == 2 and individual_keys[0] == 'Shift': 424 warning_type = SHIFT_BLACKLIST 425 else: 426 warning_type = NO_WARNING 427 428 self.update_warning(warning_type=warning_type, conflicts=conflicts) 429 430 def validate_sequence(self): 431 """Provide additional checks for accepting or rejecting shortcuts.""" 432 if self.invalid_key_flag: 433 self.update_warning(warning_type=INVALID_KEY) 434 return 435 436 for mod in MODIFIERS: 437 non_mod = set(self.key_non_modifiers) 438 non_mod.discard(mod) 439 if mod in self.key_non_modifiers: 440 self.key_non_modifiers.remove(mod) 441 442 self.key_modifiers = self.key_modifiers - non_mod 443 444 while u'' in self.key_text: 445 self.key_text.remove(u'') 446 447 self.key_text = [k.upper() for k in self.key_text] 448 449 # Fix Backtab, Tab issue 450 if Qt.Key_Backtab in self.key_non_modifiers: 451 idx = self.key_non_modifiers.index(Qt.Key_Backtab) 452 self.key_non_modifiers[idx] = Qt.Key_Tab 453 454 if len(self.key_modifiers) == 0: 455 # Filter single key allowed 456 if self.key_non_modifiers[0] not in VALID_SINGLE_KEYS: 457 return 458 # Filter 459 elif len(self.key_non_modifiers) > 1: 460 return 461 462 # QKeySequence accepts a maximum of 4 different sequences 463 if len(self.keys) > 4: 464 # Update warning 465 self.update_warning(warning_type=SEQUENCE_LENGTH) 466 return 467 468 keys = [] 469 for i in range(len(self.keys)): 470 key_seq = 0 471 for m in self.key_modifiers: 472 key_seq += MODIFIERS[m] 473 key_seq += self.key_non_modifiers[i] 474 keys.append(key_seq) 475 476 sequence = QKeySequence(*keys) 477 478 self.set_sequence(sequence.toString()) 479 480 481class Shortcut(object): 482 """Shortcut convenience class for holding shortcut context, name, 483 original ordering index, key sequence for the shortcut and localized text. 484 """ 485 def __init__(self, context, name, key=None): 486 self.index = 0 # Sorted index. Populated when loading shortcuts 487 self.context = context 488 self.name = name 489 self.key = key 490 491 def __str__(self): 492 return "{0}/{1}: {2}".format(self.context, self.name, self.key) 493 494 def load(self): 495 self.key = get_shortcut(self.context, self.name) 496 497 def save(self): 498 set_shortcut(self.context, self.name, self.key) 499 500 501CONTEXT, NAME, SEQUENCE, SEARCH_SCORE = [0, 1, 2, 3] 502 503 504class ShortcutsModel(QAbstractTableModel): 505 def __init__(self, parent): 506 QAbstractTableModel.__init__(self) 507 self._parent = parent 508 509 self.shortcuts = [] 510 self.scores = [] 511 self.rich_text = [] 512 self.normal_text = [] 513 self.letters = '' 514 self.label = QLabel() 515 self.widths = [] 516 517 # Needed to compensate for the HTMLDelegate color selection unawarness 518 palette = parent.palette() 519 self.text_color = palette.text().color().name() 520 self.text_color_highlight = palette.highlightedText().color().name() 521 522 def current_index(self): 523 """Get the currently selected index in the parent table view.""" 524 i = self._parent.proxy_model.mapToSource(self._parent.currentIndex()) 525 return i 526 527 def sortByName(self): 528 """Qt Override.""" 529 self.shortcuts = sorted(self.shortcuts, 530 key=lambda x: x.context+'/'+x.name) 531 self.reset() 532 533 def flags(self, index): 534 """Qt Override.""" 535 if not index.isValid(): 536 return Qt.ItemIsEnabled 537 return Qt.ItemFlags(QAbstractTableModel.flags(self, index)) 538 539 def data(self, index, role=Qt.DisplayRole): 540 """Qt Override.""" 541 row = index.row() 542 if not index.isValid() or not (0 <= row < len(self.shortcuts)): 543 return to_qvariant() 544 545 shortcut = self.shortcuts[row] 546 key = shortcut.key 547 column = index.column() 548 549 if role == Qt.DisplayRole: 550 if column == CONTEXT: 551 return to_qvariant(shortcut.context) 552 elif column == NAME: 553 color = self.text_color 554 if self._parent == QApplication.focusWidget(): 555 if self.current_index().row() == row: 556 color = self.text_color_highlight 557 else: 558 color = self.text_color 559 text = self.rich_text[row] 560 text = '<p style="color:{0}">{1}</p>'.format(color, text) 561 return to_qvariant(text) 562 elif column == SEQUENCE: 563 text = QKeySequence(key).toString(QKeySequence.NativeText) 564 return to_qvariant(text) 565 elif column == SEARCH_SCORE: 566 # Treating search scores as a table column simplifies the 567 # sorting once a score for a specific string in the finder 568 # has been defined. This column however should always remain 569 # hidden. 570 return to_qvariant(self.scores[row]) 571 elif role == Qt.TextAlignmentRole: 572 return to_qvariant(int(Qt.AlignHCenter | Qt.AlignVCenter)) 573 return to_qvariant() 574 575 def headerData(self, section, orientation, role=Qt.DisplayRole): 576 """Qt Override.""" 577 if role == Qt.TextAlignmentRole: 578 if orientation == Qt.Horizontal: 579 return to_qvariant(int(Qt.AlignHCenter | Qt.AlignVCenter)) 580 return to_qvariant(int(Qt.AlignRight | Qt.AlignVCenter)) 581 if role != Qt.DisplayRole: 582 return to_qvariant() 583 if orientation == Qt.Horizontal: 584 if section == CONTEXT: 585 return to_qvariant(_("Context")) 586 elif section == NAME: 587 return to_qvariant(_("Name")) 588 elif section == SEQUENCE: 589 return to_qvariant(_("Shortcut")) 590 elif section == SEARCH_SCORE: 591 return to_qvariant(_("Score")) 592 return to_qvariant() 593 594 def rowCount(self, index=QModelIndex()): 595 """Qt Override.""" 596 return len(self.shortcuts) 597 598 def columnCount(self, index=QModelIndex()): 599 """Qt Override.""" 600 return 4 601 602 def setData(self, index, value, role=Qt.EditRole): 603 """Qt Override.""" 604 if index.isValid() and 0 <= index.row() < len(self.shortcuts): 605 shortcut = self.shortcuts[index.row()] 606 column = index.column() 607 text = from_qvariant(value, str) 608 if column == SEQUENCE: 609 shortcut.key = text 610 self.dataChanged.emit(index, index) 611 return True 612 return False 613 614 def update_search_letters(self, text): 615 """Update search letters with text input in search box.""" 616 self.letters = text 617 names = [shortcut.name for shortcut in self.shortcuts] 618 results = get_search_scores(text, names, template='<b>{0}</b>') 619 self.normal_text, self.rich_text, self.scores = zip(*results) 620 self.reset() 621 622 def update_active_row(self): 623 """Update active row to update color in selected text.""" 624 self.data(self.current_index()) 625 626 def row(self, row_num): 627 """Get row based on model index. Needed for the custom proxy model.""" 628 return self.shortcuts[row_num] 629 630 def reset(self): 631 """"Reset model to take into account new search letters.""" 632 self.beginResetModel() 633 self.endResetModel() 634 635 636class CustomSortFilterProxy(QSortFilterProxyModel): 637 """Custom column filter based on regex.""" 638 def __init__(self, parent=None): 639 super(CustomSortFilterProxy, self).__init__(parent) 640 self._parent = parent 641 self.pattern = re.compile(r'') 642 643 def set_filter(self, text): 644 """Set regular expression for filter.""" 645 self.pattern = get_search_regex(text) 646 if self.pattern: 647 self._parent.setSortingEnabled(False) 648 else: 649 self._parent.setSortingEnabled(True) 650 self.invalidateFilter() 651 652 def filterAcceptsRow(self, row_num, parent): 653 """Qt override. 654 655 Reimplemented from base class to allow the use of custom filtering. 656 """ 657 model = self.sourceModel() 658 name = model.row(row_num).name 659 r = re.search(self.pattern, name) 660 661 if r is None: 662 return False 663 else: 664 return True 665 666 667class ShortcutsTable(QTableView): 668 def __init__(self, parent=None): 669 QTableView.__init__(self, parent) 670 self._parent = parent 671 self.finder = None 672 673 self.source_model = ShortcutsModel(self) 674 self.proxy_model = CustomSortFilterProxy(self) 675 self.last_regex = '' 676 677 self.proxy_model.setSourceModel(self.source_model) 678 self.proxy_model.setDynamicSortFilter(True) 679 self.proxy_model.setFilterKeyColumn(NAME) 680 self.proxy_model.setFilterCaseSensitivity(Qt.CaseInsensitive) 681 self.setModel(self.proxy_model) 682 683 self.hideColumn(SEARCH_SCORE) 684 self.setItemDelegateForColumn(NAME, HTMLDelegate(self, margin=9)) 685 self.setSelectionBehavior(QAbstractItemView.SelectRows) 686 self.setSelectionMode(QAbstractItemView.SingleSelection) 687 self.setSortingEnabled(True) 688 self.setEditTriggers(QAbstractItemView.AllEditTriggers) 689 self.selectionModel().selectionChanged.connect(self.selection) 690 691 self.verticalHeader().hide() 692 self.load_shortcuts() 693 694 def focusOutEvent(self, e): 695 """Qt Override.""" 696 self.source_model.update_active_row() 697 super(ShortcutsTable, self).focusOutEvent(e) 698 699 def focusInEvent(self, e): 700 """Qt Override.""" 701 super(ShortcutsTable, self).focusInEvent(e) 702 self.selectRow(self.currentIndex().row()) 703 704 def selection(self, index): 705 """Update selected row.""" 706 self.update() 707 self.isActiveWindow() 708 709 def adjust_cells(self): 710 """Adjust column size based on contents.""" 711 self.resizeRowsToContents() 712 self.resizeColumnsToContents() 713 fm = self.horizontalHeader().fontMetrics() 714 names = [fm.width(s.name + ' '*9) for s in self.source_model.shortcuts] 715 self.setColumnWidth(NAME, max(names)) 716 self.horizontalHeader().setStretchLastSection(True) 717 718 def load_shortcuts(self): 719 """Load shortcuts and assign to table model.""" 720 shortcuts = [] 721 for context, name, keystr in iter_shortcuts(): 722 shortcut = Shortcut(context, name, keystr) 723 shortcuts.append(shortcut) 724 shortcuts = sorted(shortcuts, key=lambda x: x.context+x.name) 725 # Store the original order of shortcuts 726 for i, shortcut in enumerate(shortcuts): 727 shortcut.index = i 728 self.source_model.shortcuts = shortcuts 729 self.source_model.scores = [0]*len(shortcuts) 730 self.source_model.rich_text = [s.name for s in shortcuts] 731 self.source_model.reset() 732 self.adjust_cells() 733 self.sortByColumn(CONTEXT, Qt.AscendingOrder) 734 735 def check_shortcuts(self): 736 """Check shortcuts for conflicts.""" 737 conflicts = [] 738 for index, sh1 in enumerate(self.source_model.shortcuts): 739 if index == len(self.source_model.shortcuts)-1: 740 break 741 for sh2 in self.source_model.shortcuts[index+1:]: 742 if sh2 is sh1: 743 continue 744 if str(sh2.key) == str(sh1.key) \ 745 and (sh1.context == sh2.context or sh1.context == '_' or 746 sh2.context == '_'): 747 conflicts.append((sh1, sh2)) 748 if conflicts: 749 self.parent().show_this_page.emit() 750 cstr = "\n".join(['%s <---> %s' % (sh1, sh2) 751 for sh1, sh2 in conflicts]) 752 QMessageBox.warning(self, _("Conflicts"), 753 _("The following conflicts have been " 754 "detected:")+"\n"+cstr, QMessageBox.Ok) 755 756 def save_shortcuts(self): 757 """Save shortcuts from table model.""" 758 self.check_shortcuts() 759 for shortcut in self.source_model.shortcuts: 760 shortcut.save() 761 762 def show_editor(self): 763 """Create, setup and display the shortcut editor dialog.""" 764 index = self.proxy_model.mapToSource(self.currentIndex()) 765 row, column = index.row(), index.column() 766 shortcuts = self.source_model.shortcuts 767 context = shortcuts[row].context 768 name = shortcuts[row].name 769 770 sequence_index = self.source_model.index(row, SEQUENCE) 771 sequence = sequence_index.data() 772 773 dialog = ShortcutEditor(self, context, name, sequence, shortcuts) 774 775 if dialog.exec_(): 776 new_sequence = dialog.new_sequence 777 self.source_model.setData(sequence_index, new_sequence) 778 779 def set_regex(self, regex=None, reset=False): 780 """Update the regex text for the shortcut finder.""" 781 if reset: 782 text = '' 783 else: 784 text = self.finder.text().replace(' ', '').lower() 785 786 self.proxy_model.set_filter(text) 787 self.source_model.update_search_letters(text) 788 self.sortByColumn(SEARCH_SCORE, Qt.AscendingOrder) 789 790 if self.last_regex != regex: 791 self.selectRow(0) 792 self.last_regex = regex 793 794 def next_row(self): 795 """Move to next row from currently selected row.""" 796 row = self.currentIndex().row() 797 rows = self.proxy_model.rowCount() 798 if row + 1 == rows: 799 row = -1 800 self.selectRow(row + 1) 801 802 def previous_row(self): 803 """Move to previous row from currently selected row.""" 804 row = self.currentIndex().row() 805 rows = self.proxy_model.rowCount() 806 if row == 0: 807 row = rows 808 self.selectRow(row - 1) 809 810 def keyPressEvent(self, event): 811 """Qt Override.""" 812 key = event.key() 813 if key in [Qt.Key_Enter, Qt.Key_Return]: 814 self.show_editor() 815 elif key in [Qt.Key_Tab]: 816 self.finder.setFocus() 817 elif key in [Qt.Key_Backtab]: 818 self.parent().reset_btn.setFocus() 819 elif key in [Qt.Key_Up, Qt.Key_Down, Qt.Key_Left, Qt.Key_Right]: 820 super(ShortcutsTable, self).keyPressEvent(event) 821 elif key not in [Qt.Key_Escape, Qt.Key_Space]: 822 text = event.text() 823 if text: 824 if re.search(VALID_FINDER_CHARS, text) is not None: 825 self.finder.setFocus() 826 self.finder.set_text(text) 827 elif key in [Qt.Key_Escape]: 828 self.finder.keyPressEvent(event) 829 830 def mouseDoubleClickEvent(self, event): 831 """Qt Override.""" 832 self.show_editor() 833 834 835class ShortcutsConfigPage(GeneralConfigPage): 836 CONF_SECTION = "shortcuts" 837 838 NAME = _("Keyboard shortcuts") 839 ICON = ima.icon('keyboard') 840 841 def setup_page(self): 842 # Widgets 843 self.table = ShortcutsTable(self) 844 self.finder = ShortcutFinder(self.table, self.table.set_regex) 845 self.table.finder = self.finder 846 self.label_finder = QLabel(_('Search: ')) 847 self.reset_btn = QPushButton(_("Reset to default values")) 848 849 # Layout 850 hlayout = QHBoxLayout() 851 vlayout = QVBoxLayout() 852 hlayout.addWidget(self.label_finder) 853 hlayout.addWidget(self.finder) 854 vlayout.addWidget(self.table) 855 vlayout.addLayout(hlayout) 856 vlayout.addWidget(self.reset_btn) 857 self.setLayout(vlayout) 858 859 self.setTabOrder(self.table, self.finder) 860 self.setTabOrder(self.finder, self.reset_btn) 861 862 # Signals and slots 863 if PYQT5: 864 # Qt5 'dataChanged' has 3 parameters 865 self.table.proxy_model.dataChanged.connect( 866 lambda i1, i2, roles, opt='': self.has_been_modified(opt)) 867 else: 868 self.table.proxy_model.dataChanged.connect( 869 lambda i1, i2, opt='': self.has_been_modified(opt)) 870 self.reset_btn.clicked.connect(self.reset_to_default) 871 872 def check_settings(self): 873 self.table.check_shortcuts() 874 875 def reset_to_default(self): 876 """Reset to default values of the shortcuts making a confirmation.""" 877 reset = QMessageBox.warning(self, _("Shortcuts reset"), 878 _("Do you want to reset " 879 "to default values?"), 880 QMessageBox.Yes | QMessageBox.No) 881 if reset == QMessageBox.No: 882 return 883 reset_shortcuts() 884 self.main.apply_shortcuts() 885 self.table.load_shortcuts() 886 self.load_from_conf() 887 self.set_modified(False) 888 889 def apply_settings(self, options): 890 self.table.save_shortcuts() 891 self.main.apply_shortcuts() 892 893 894def test(): 895 from spyder.utils.qthelpers import qapplication 896 app = qapplication() 897 table = ShortcutsTable() 898 table.show() 899 app.exec_() 900 print([str(s) for s in table.source_model.shortcuts]) # spyder: test-skip 901 table.check_shortcuts() 902 903if __name__ == '__main__': 904 test() 905