diff --git a/spyder/config/main.py b/spyder/config/main.py index 7df48bdffaf..38fcfe06e88 100644 --- a/spyder/config/main.py +++ b/spyder/config/main.py @@ -269,6 +269,10 @@ 'docstring_type': 'Numpydoc', 'strip_trailing_spaces_on_modify': False, 'show_outline_in_editor_window': True, + 'mouse_shortcuts': {'jump_to_position': 'Alt', + 'goto_definition': 'Ctrl', + 'add_remove_cursor': 'Ctrl+Alt', + 'column_cursor': 'Ctrl+Alt+Shift'}, }), ('historylog', { diff --git a/spyder/plugins/editor/confpage.py b/spyder/plugins/editor/confpage.py index 23f8606aad1..dee07fa985f 100644 --- a/spyder/plugins/editor/confpage.py +++ b/spyder/plugins/editor/confpage.py @@ -8,9 +8,12 @@ import os import sys +from itertools import combinations from qtpy.QtWidgets import (QGridLayout, QGroupBox, QHBoxLayout, QLabel, - QVBoxLayout) + QVBoxLayout, QDialog, QDialogButtonBox, QWidget, + QCheckBox, QSizePolicy) +from qtpy.QtCore import Qt, Signal from spyder.api.config.decorators import on_conf_change from spyder.api.config.mixins import SpyderConfigurationObserver @@ -18,6 +21,7 @@ from spyder.config.base import _ from spyder.config.manager import CONF from spyder.utils.icon_manager import ima +from spyder.widgets.helperwidgets import TipWidget NUMPYDOC = "https://numpydoc.readthedocs.io/en/latest/format.html" @@ -378,6 +382,17 @@ def enable_tabwidth_spin(index): multicursor_layout.addWidget(multicursor_box) multicursor_group.setLayout(multicursor_layout) + # -- Mouse Shortcuts + mouse_shortcuts_group = QGroupBox(_("Mouse Shortcuts")) + mouse_shortcuts_button = self.create_button( + lambda: MouseShortcutEditor(self).exec_(), + _("Edit Mouse Shortcut Modifiers") + ) + + mouse_shortcuts_layout = QVBoxLayout() + mouse_shortcuts_layout.addWidget(mouse_shortcuts_button) + mouse_shortcuts_group.setLayout(mouse_shortcuts_layout) + # --- Tabs --- self.create_tab( _("Interface"), @@ -389,7 +404,8 @@ def enable_tabwidth_spin(index): self.create_tab( _("Advanced settings"), [templates_group, autosave_group, docstring_group, - annotations_group, eol_group, multicursor_group] + annotations_group, eol_group, multicursor_group, + mouse_shortcuts_group] ) @on_conf_change( @@ -424,3 +440,222 @@ def on_format_save_state(self, value): else: option.setToolTip("") option.setDisabled(value) + + +class MouseShortcutEditor(QDialog): + """A dialog to edit the modifier keys for CodeEditor mouse interactions.""" + + def __init__(self, parent): + super().__init__(parent) + self.editor_config_page = parent + mouse_shortcuts = CONF.get('editor', 'mouse_shortcuts') + self.setWindowFlags(self.windowFlags() & + ~Qt.WindowContextHelpButtonHint) + + layout = QVBoxLayout(self) + + self.scrollflag_shortcut = ShortcutSelector( + self, + _("Jump Within Document"), + mouse_shortcuts['jump_to_position'] + ) + self.scrollflag_shortcut.sig_changed.connect(self.validate) + layout.addWidget(self.scrollflag_shortcut) + + self.goto_def_shortcut = ShortcutSelector( + self, + _("Goto Definition"), + mouse_shortcuts['goto_definition'] + ) + self.goto_def_shortcut .sig_changed.connect(self.validate) + layout.addWidget(self.goto_def_shortcut) + + self.add_cursor_shortcut = ShortcutSelector( + self, + _("Add / Remove Cursor"), + mouse_shortcuts['add_remove_cursor'] + ) + self.add_cursor_shortcut.sig_changed.connect(self.validate) + layout.addWidget(self.add_cursor_shortcut) + + self.column_cursor_shortcut = ShortcutSelector( + self, + _("Add Column Cursor"), + mouse_shortcuts['column_cursor'] + ) + self.column_cursor_shortcut.sig_changed.connect(self.validate) + layout.addWidget(self.column_cursor_shortcut) + + button_box = QDialogButtonBox(self) + apply_b = button_box.addButton(QDialogButtonBox.StandardButton.Apply) + apply_b.clicked.connect(self.apply_mouse_shortcuts) + apply_b.setEnabled(False) + self.apply_button = apply_b + ok_b = button_box.addButton(QDialogButtonBox.StandardButton.Ok) + ok_b.clicked.connect(self.accept) + self.ok_button = ok_b + cancel_b = button_box.addButton(QDialogButtonBox.StandardButton.Cancel) + cancel_b.clicked.connect(self.reject) + layout.addWidget(button_box) + + def apply_mouse_shortcuts(self): + """Set new config to CONF""" + + self.editor_config_page.set_option('mouse_shortcuts', + self.mouse_shortcuts) + self.scrollflag_shortcut.apply_modifiers() + self.goto_def_shortcut.apply_modifiers() + self.add_cursor_shortcut.apply_modifiers() + self.column_cursor_shortcut.apply_modifiers() + self.apply_button.setEnabled(False) + + def accept(self): + """Apply new settings and close dialog.""" + + self.apply_mouse_shortcuts() + super().accept() + + def validate(self): + """ + Detect conflicts between shortcuts, and detect if current selection is + different from current config. Set Ok and Apply buttons enabled or + disabled accordingly, as well as set visibility of the warning for + shortcut conflict. + """ + shortcut_selectors = ( + self.scrollflag_shortcut, + self.goto_def_shortcut, + self.add_cursor_shortcut, + self.column_cursor_shortcut + ) + + for selector in shortcut_selectors: + selector.warning.setVisible(False) + + conflict = False + for a, b in combinations(shortcut_selectors, 2): + if a.modifiers() and a.modifiers() == b.modifiers(): + conflict = True + a.warning.setVisible(True) + b.warning.setVisible(True) + + self.ok_button.setEnabled(not conflict) + + self.apply_button.setEnabled( + not conflict and ( + self.scrollflag_shortcut.is_changed() or + self.goto_def_shortcut.is_changed() or + self.add_cursor_shortcut.is_changed() or + self.column_cursor_shortcut.is_changed() + ) + ) + + @property + def mouse_shortcuts(self): + """Format shortcuts dict for CONF.""" + + return {'jump_to_position': self.scrollflag_shortcut.modifiers(), + 'goto_definition': self.goto_def_shortcut.modifiers(), + 'add_remove_cursor': self.add_cursor_shortcut.modifiers(), + 'column_cursor': self.column_cursor_shortcut.modifiers()} + + +class ShortcutSelector(QWidget): + """Line representing an editor for a single mouse shortcut.""" + + sig_changed = Signal() + + def __init__(self, parent, label, modifiers): + super().__init__(parent) + + layout = QHBoxLayout(self) + + label = QLabel(label) + layout.addWidget(label) + + spacer = QWidget(self) + spacer.setSizePolicy(QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.Preferred) + layout.addWidget(spacer) + + # TODO _() translate these checkboxes? + # TODO rename based on OS? (CONF strings should stay the same) + self.ctrl_check = QCheckBox("Ctrl") + self.ctrl_check.setChecked("ctrl" in modifiers.lower()) + self.ctrl_check.toggled.connect(self.validate) + layout.addWidget(self.ctrl_check) + + self.alt_check = QCheckBox("Alt") + self.alt_check.setChecked("alt" in modifiers.lower()) + self.alt_check.toggled.connect(self.validate) + layout.addWidget(self.alt_check) + + self.meta_check = QCheckBox("Meta") + self.meta_check.setChecked("meta" in modifiers.lower()) + self.meta_check.toggled.connect(self.validate) + layout.addWidget(self.meta_check) + + self.shift_check = QCheckBox("Shift") + self.shift_check.setChecked("shift" in modifiers.lower()) + self.shift_check.toggled.connect(self.validate) + layout.addWidget(self.shift_check) + + warning_icon = ima.icon("MessageBoxWarning") + self.warning = TipWidget( + _("Shortcut Conflicts With Another"), + warning_icon, + warning_icon + ) + # Thanks to https://stackoverflow.com/a/34663079/3220135 + sp_retain = self.warning.sizePolicy() + sp_retain.setRetainSizeWhenHidden(True) + self.warning.setSizePolicy(sp_retain) + self.warning.setVisible(False) + layout.addWidget(self.warning) + + self.setLayout(layout) + + self.apply_modifiers() + + def validate(self): + """ + Cannot have shortcut of Shift alone as that conflicts with setting the + cursor position without moving the anchor. Enable/Disable the Shift + checkbox accordingly. (Re)Emit a signal to MouseShortcutEditor which + will perform other validation. + """ + + if ( + self.ctrl_check.isChecked() or + self.alt_check.isChecked() or + self.meta_check.isChecked() + ): + self.shift_check.setEnabled(True) + + else: + self.shift_check.setEnabled(False) + self.shift_check.setChecked(False) + + self.sig_changed.emit() + + def modifiers(self): + """Get the current modifiers string.""" + + modifiers = [] + if self.ctrl_check.isChecked(): + modifiers.append("Ctrl") + if self.alt_check.isChecked(): + modifiers.append("Alt") + if self.meta_check.isChecked(): + modifiers.append("Meta") + if self.shift_check.isChecked(): + modifiers.append("Shift") + return "+".join(modifiers) + + def is_changed(self): + """Is the current selection different from when last applied?""" + return self.current_modifiers != self.modifiers() + + def apply_modifiers(self): + """Informs ShortcutSelector that settings have been applied.""" + self.current_modifiers = self.modifiers() diff --git a/spyder/plugins/editor/panels/scrollflag.py b/spyder/plugins/editor/panels/scrollflag.py index 97f5c1d9098..b606e276f1e 100644 --- a/spyder/plugins/editor/panels/scrollflag.py +++ b/spyder/plugins/editor/panels/scrollflag.py @@ -52,6 +52,8 @@ def __init__(self): self._range_indicator_is_visible = False self._alt_key_is_down = False self._ctrl_key_is_down = False + self._shift_key_is_down = False + self._meta_key_is_down = False self._slider_range_color = QColor(Qt.gray) self._slider_range_color.setAlphaF(.85) @@ -86,8 +88,8 @@ def on_install(self, editor): editor.sig_focus_changed.connect(self.update) editor.sig_key_pressed.connect(self.keyPressEvent) editor.sig_key_released.connect(self.keyReleaseEvent) - editor.sig_alt_left_mouse_pressed.connect(self.mousePressEvent) - editor.sig_alt_mouse_moved.connect(self.mouseMoveEvent) + editor.sig_scrollflag_shortcut_click.connect(self.mousePressEvent) + editor.sig_scrollflag_shortcut_move.connect(self.mouseMoveEvent) editor.sig_leave_out.connect(self.update) editor.sig_flags_changed.connect(self.update_flags) editor.sig_theme_colors_changed.connect(self.update_flag_colors) @@ -282,11 +284,18 @@ def paintEvent(self, event): # Paint the slider range if not self._unit_testing: modifiers = QApplication.queryKeyboardModifiers() - alt = modifiers & Qt.KeyboardModifier.AltModifier - ctrl = modifiers & Qt.KeyboardModifier.ControlModifier else: - alt = self._alt_key_is_down - ctrl = self._ctrl_key_is_down + modifiers = Qt.KeyboardModifier.NoModifier + if self._alt_key_is_down: + modifiers |= Qt.KeyboardModifier.AltModifier + if self._ctrl_key_is_down: + modifiers |= Qt.KeyboardModifier.ControlModifier + if self._shift_key_is_down: + modifiers |= Qt.KeyboardModifier.ShiftModifier + if self._meta_key_is_down: + modifiers |= Qt.KeyboardModifier.MetaModifier + mouse_modifiers = editor.mouse_shortcuts['jump_to_position'] + modifiers_held = modifiers == mouse_modifiers if self.slider: cursor_pos = self.mapFromGlobal(QCursor().pos()) @@ -297,7 +306,7 @@ def paintEvent(self, event): # determined if the cursor is over the editor or the flag scrollbar # because the later gives a wrong result when a mouse button # is pressed. - if is_over_self or (alt and not ctrl and is_over_editor): + if is_over_self or (modifiers_held and is_over_editor): painter.setPen(self._slider_range_color) painter.setBrush(self._slider_range_brush) x, y, width, height = self.make_slider_range( @@ -334,6 +343,12 @@ def keyReleaseEvent(self, event): elif event.key() == Qt.Key.Key_Control: self._ctrl_key_is_down = False self.update() + elif event.key() == Qt.Key.Key_Shift: + self._shift_key_is_down = False + self.update() + elif event.key() == Qt.Key.Key_Meta: + self._meta_key_is_down = False + self.update() def keyPressEvent(self, event): """Override Qt method""" @@ -343,6 +358,12 @@ def keyPressEvent(self, event): elif event.key() == Qt.Key.Key_Control: self._ctrl_key_is_down = True self.update() + elif event.key() == Qt.Key.Key_Shift: + self._shift_key_is_down = True + self.update() + elif event.key() == Qt.Key.Key_Meta: + self._meta_key_is_down = True + self.update() def get_vertical_offset(self): """ diff --git a/spyder/plugins/editor/panels/tests/test_scrollflag.py b/spyder/plugins/editor/panels/tests/test_scrollflag.py index a917b291b89..91a370cd012 100644 --- a/spyder/plugins/editor/panels/tests/test_scrollflag.py +++ b/spyder/plugins/editor/panels/tests/test_scrollflag.py @@ -235,7 +235,7 @@ def test_range_indicator_alt_modifier_response(editor_bot, qtbot): # While the alt key is pressed, click with the mouse in the middle of the # editor's height and assert that the editor vertical scrollbar has moved # to its middle range position. - with qtbot.waitSignal(editor.sig_alt_left_mouse_pressed, raising=True): + with qtbot.waitSignal(editor.sig_scrollflag_shortcut_click, raising=True): qtbot.mousePress(editor.viewport(), Qt.LeftButton, Qt.AltModifier, QPoint(w//2, h//2)) assert vsb.value() == (vsb.minimum()+vsb.maximum())//2 @@ -243,7 +243,7 @@ def test_range_indicator_alt_modifier_response(editor_bot, qtbot): # While the alt key is pressed, click with the mouse at the top of the # editor's height and assert that the editor vertical scrollbar has moved # to its minimum position. - with qtbot.waitSignal(editor.sig_alt_left_mouse_pressed, raising=True): + with qtbot.waitSignal(editor.sig_scrollflag_shortcut_click, raising=True): qtbot.mousePress(editor.viewport(), Qt.LeftButton, Qt.AltModifier, QPoint(w//2, 1)) assert vsb.value() == vsb.minimum() @@ -251,7 +251,7 @@ def test_range_indicator_alt_modifier_response(editor_bot, qtbot): # While the alt key is pressed, click with the mouse at the bottom of the # editor's height and assert that the editor vertical scrollbar has moved # to its maximum position. - with qtbot.waitSignal(editor.sig_alt_left_mouse_pressed, raising=True): + with qtbot.waitSignal(editor.sig_scrollflag_shortcut_click, raising=True): qtbot.mousePress(editor.viewport(), Qt.LeftButton, Qt.AltModifier, QPoint(w//2, h-1)) assert vsb.value() == vsb.maximum() diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 6f3beeb231f..dbc5e068c61 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -180,13 +180,13 @@ class CodeEditor(LSPMixin, TextEditBaseWidget, MultiCursorMixin): #: Signal emitted when a key is released sig_key_released = Signal(QKeyEvent) - #: Signal emitted when the alt key is pressed and the left button of the - # mouse is clicked - sig_alt_left_mouse_pressed = Signal(QMouseEvent) + #: Signal emitted when the jump position modifiers are pressed and the left + # button of the mouse is clicked + sig_scrollflag_shortcut_click = Signal(QMouseEvent) - #: Signal emitted when the alt key is pressed and the cursor moves over - # the editor - sig_alt_mouse_moved = Signal(QMouseEvent) + #: Signal emitted when the jump position modifiers are pressed and the + # cursor moves over the editor + sig_scrollflag_shortcut_move = Signal(QMouseEvent) #: Signal emitted when the cursor leaves the editor sig_leave_out = Signal() @@ -474,6 +474,9 @@ def __init__(self, parent=None): self.__cursor_changed = False self._mouse_left_button_pressed = False self.ctrl_click_color = QColor(Qt.blue) + self.mouse_shortcuts = None + # init mouse_shortcuts to default values + self.set_mouse_shortcuts(None) self._bookmarks_blocks = {} self.bookmarks = [] @@ -759,7 +762,8 @@ def setup_editor(self, remove_trailing_newlines=False, add_newline=False, format_on_save=False, - multi_cursor_enabled=True): + multi_cursor_enabled=True, + mouse_shortcuts=None): """ Set-up configuration for the CodeEditor instance. @@ -841,6 +845,8 @@ def setup_editor(self, Default False. multi_cursor_enabled: Enable/Disable multi-cursor functionality. Default True + mouse_shortcuts: Configure modifiers used for mouse click actions + Default None """ self.set_close_parentheses_enabled(close_parentheses) @@ -960,6 +966,8 @@ def setup_editor(self, self.toggle_multi_cursor(multi_cursor_enabled) + self.set_mouse_shortcuts(mouse_shortcuts) + # ---- Set different attributes # ------------------------------------------------------------------------- def set_folding_panel(self, folding): @@ -1134,6 +1142,38 @@ def _set_highlighter(self, sh_class): self._rehighlight_timer.timeout.connect( self.highlighter.rehighlight) + def set_mouse_shortcuts(self, shortcuts): + """Apply mouse_shortcuts from CONF""" + + ctrl = Qt.KeyboardModifier.ControlModifier + alt = Qt.KeyboardModifier.AltModifier + shift = Qt.KeyboardModifier.ShiftModifier + meta = Qt.KeyboardModifier.MetaModifier + + # Default values + self.mouse_shortcuts = { + 'jump_to_position': alt, + 'goto_definition': ctrl, + 'add_remove_cursor': ctrl | alt, + 'column_cursor': ctrl | alt | shift + } + + if shortcuts: + for key, value in shortcuts.items(): + if not value: + self.mouse_shortcuts[key] = None + continue + modifiers = Qt.KeyboardModifier.NoModifier + if "ctrl" in value.lower(): + modifiers |= ctrl + if "alt" in value.lower(): + modifiers |= alt + if "shift" in value.lower(): + modifiers |= shift + if "meta" in value.lower(): + modifiers |= meta + self.mouse_shortcuts[key] = modifiers + def sync_font(self): """Highlighter changed font, update.""" self.setFont(self.highlighter.font) @@ -3772,7 +3812,7 @@ def keyPressEvent(self, event): Qt.Key_Meta, Qt.KeypadModifier]: self.setOverwriteMode(False) # The user pressed only a modifier key. - if ctrl: + if event.modifiers() == self.mouse_shortcuts['goto_definition']: pos = self.mapFromGlobal(QCursor.pos()) pos = self.calculate_real_position_from_global(pos) if self._handle_goto_uri_event(pos): @@ -4448,20 +4488,23 @@ def mouseMoveEvent(self, event): pos = event.pos() self._last_point = pos - alt = event.modifiers() & Qt.AltModifier - ctrl = event.modifiers() & Qt.ControlModifier - if alt: - self.sig_alt_mouse_moved.emit(event) + modifiers = event.modifiers() + + if modifiers == self.mouse_shortcuts['jump_to_position']: + self.sig_scrollflag_shortcut_move.emit(event) event.accept() return - if ctrl: + if modifiers == self.mouse_shortcuts['goto_definition']: if self._handle_goto_uri_event(pos): event.accept() return - if self.go_to_definition_enabled and ctrl: + if ( + self.go_to_definition_enabled and + modifiers == self.mouse_shortcuts['goto_definition'] + ): if self._handle_goto_definition_event(pos): event.accept() return @@ -4510,43 +4553,46 @@ def mousePressEvent(self, event: QKeyEvent): """Override Qt method.""" self.hide_tooltip() - ctrl = event.modifiers() & Qt.KeyboardModifier.ControlModifier - alt = event.modifiers() & Qt.KeyboardModifier.AltModifier - shift = event.modifiers() & Qt.KeyboardModifier.ShiftModifier - cursor_for_pos = self.cursorForPosition(event.pos()) - self._mouse_left_button_pressed = event.button() == Qt.LeftButton + modifiers = event.modifiers() + left_button = event.button() == Qt.LeftButton + self._mouse_left_button_pressed = left_button + # Handle adding cursors if ( - self.multi_cursor_enabled - and event.button() == Qt.LeftButton - and ctrl - and alt + self.multi_cursor_enabled and left_button and + modifiers == self.mouse_shortcuts['add_remove_cursor'] ): - # ---- Ctrl-Alt: multi-cursor mouse interactions - if shift: - # Ctrl-Shift-Alt click adds colum of cursors towards primary - # cursor - self.add_column_cursor(event) - else: # Ctrl-Alt click adds and removes cursors - # Move existing primary cursor to extra_cursors list and set - # new primary cursor - self.add_remove_cursor(event) - else: - # ---- not multi-cursor - if event.button() == Qt.MouseButton.LeftButton: - self.clear_extra_cursors() + self.add_remove_cursor(event) - if event.button() == Qt.LeftButton and ctrl: - TextEditBaseWidget.mousePressEvent(self, event) - uri = self._last_hover_pattern_text - if uri: - self.go_to_uri_from_cursor(uri) - else: - self.go_to_definition_from_cursor(cursor_for_pos) - elif event.button() == Qt.LeftButton and alt: - self.sig_alt_left_mouse_pressed.emit(event) + elif ( + self.multi_cursor_enabled and left_button and + modifiers == self.mouse_shortcuts['column_cursor'] + ): + self.add_column_cursor(event) + + # Handle jump (scrollflag click) + elif (left_button and + modifiers == self.mouse_shortcuts['jump_to_position']): + + self.sig_scrollflag_shortcut_click.emit(event) + + # Handle goto definition + elif (left_button and + modifiers == self.mouse_shortcuts['goto_definition']): + + self.clear_extra_cursors() + TextEditBaseWidget.mousePressEvent(self, event) + uri = self._last_hover_pattern_text + if uri: + self.go_to_uri_from_cursor(uri) else: - TextEditBaseWidget.mousePressEvent(self, event) + cursor_for_pos = self.cursorForPosition(event.pos()) + self.go_to_definition_from_cursor(cursor_for_pos) + + else: + if left_button: + self.clear_extra_cursors() + TextEditBaseWidget.mousePressEvent(self, event) def mouseReleaseEvent(self, event): """Override Qt method.""" diff --git a/spyder/plugins/editor/widgets/editorstack/editorstack.py b/spyder/plugins/editor/widgets/editorstack/editorstack.py index cece88e3cf8..ede8cd8c322 100644 --- a/spyder/plugins/editor/widgets/editorstack/editorstack.py +++ b/spyder/plugins/editor/widgets/editorstack/editorstack.py @@ -355,6 +355,10 @@ def __init__(self, parent, actions, use_switcher=True): self.create_new_file_if_empty = True self.indent_guides = False self.__file_status_flag = False + self.mouse_shortcuts = {'jump_to_position': 'Alt', + 'goto_definition': 'Ctrl', + 'add_remove_cursor': 'Ctrl+Alt', + 'column_cursor': 'Ctrl+Alt+Shift'} # Set default color scheme color_scheme = 'spyder/dark' if is_dark_interface() else 'spyder' @@ -1101,6 +1105,13 @@ def set_multicursor_support(self, state): for finfo in self.data: finfo.editor.toggle_multi_cursor(state) + @on_conf_change(option='mouse_shortcuts') + def set_mouse_shortcuts(self, state): + self.mouse_shortcuts = state + if self.data: + for finfo in self.data: + finfo.editor.set_mouse_shortcuts(state) + def set_current_project_path(self, root_path=None): """ Set the current active project root path. @@ -2594,7 +2605,8 @@ def create_new_editor(self, fname, enc, txt, set_current, new=False, remove_trailing_newlines=self.remove_trailing_newlines, add_newline=self.add_newline, format_on_save=self.format_on_save, - multi_cursor_enabled=self.multicursor_support + multi_cursor_enabled=self.multicursor_support, + mouse_shortcuts=self.mouse_shortcuts ) if cloned_from is None: