Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PR: Make CodeEditor mouse shortcuts configurable. #23463

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions spyder/config/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
{
Expand Down
239 changes: 237 additions & 2 deletions spyder/plugins/editor/confpage.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,20 @@

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
from spyder.api.preferences import PluginConfigPage
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"
Expand Down Expand Up @@ -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"),
Expand All @@ -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(
Expand Down Expand Up @@ -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()
35 changes: 28 additions & 7 deletions spyder/plugins/editor/panels/scrollflag.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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())
Expand All @@ -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(
Expand Down Expand Up @@ -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"""
Expand All @@ -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):
"""
Expand Down
6 changes: 3 additions & 3 deletions spyder/plugins/editor/panels/tests/test_scrollflag.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,23 +235,23 @@ 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

# 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()

# 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()
Expand Down
Loading
Loading