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 19 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2620ffe
Add config for mouse interaction shortcuts in the editor
athompson673 Jan 14, 2025
b465f24
implement configurable mouse modifier shortcuts in codeeditor
athompson673 Jan 14, 2025
cc7c9a5
Update scrollflag.py to respect configurable mouse modifiers from cod…
athompson673 Jan 14, 2025
065583a
Update editorstack.py to configure codeeditors with mouse shortcut co…
athompson673 Jan 14, 2025
a56d682
fix missed .emit() call on signal
athompson673 Jan 14, 2025
825d59e
pep8
athompson673 Jan 14, 2025
69c5b47
pep8 style
athompson673 Jan 14, 2025
43ea6b6
Refactored codeeditor mouse_shortcuts attribute name and added docstr…
athompson673 Jan 15, 2025
93bfce6
renamed scrollflag shortcut signals and updated test_scrollflag with …
athompson673 Jan 15, 2025
0dacc7e
Implement first shortcut editor dialog in codeeditor confpage
athompson673 Jan 17, 2025
55e59de
fix case for no mouse shortcut
athompson673 Jan 17, 2025
8da0093
Correct some logic around apply button and "if modified" state
athompson673 Jan 17, 2025
d53f197
Implement shortcut collision checking
athompson673 Jan 17, 2025
ca0e9ee
write docstrings
athompson673 Jan 17, 2025
70af700
simplify icon creation
athompson673 Jan 17, 2025
78b28c3
Merge branch 'spyder-ide:master' into master
athompson673 Jan 27, 2025
84f8e1f
remove stray whitespace
athompson673 Jan 27, 2025
a4d7d63
Merge branch 'spyder-ide:master' into master
athompson673 Jan 29, 2025
960dccc
Merge branch 'spyder-ide:master' into master
athompson673 Feb 12, 2025
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