From 8a249818e956f180a4f08f70710b68e99a739279 Mon Sep 17 00:00:00 2001 From: athompson673 Date: Sat, 2 Nov 2024 21:36:51 -0400 Subject: [PATCH 01/95] manually draw all text cursors and mouse shortcut to add cursors --- .../editor/widgets/codeeditor/codeeditor.py | 70 ++++++++++++++++++- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 4c5f2de0af3..62fde0cb7c7 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -255,7 +255,16 @@ def __init__(self, parent=None): self.current_project_path = None # Caret (text cursor) - self.setCursorWidth(self.get_conf('cursor/width', section='main')) + self.cursor_width = self.get_conf('cursor/width', section='main') + self.setCursorWidth(0) # draw our own cursor + self.extra_cursors = [] + self.cursor_blink_state = False + self.cursor_blink_timer = QTimer(self) + self.cursor_blink_timer.setInterval(QApplication.cursorFlashTime()//2) + self.cursor_blink_timer.timeout.connect(self._on_cursor_blinktimer_timeout) + + self.focus_in.connect(self.start_cursor_blink) + self.focus_changed.connect(self.stop_cursor_blink) self.text_helper = TextHelper(self) @@ -509,6 +518,59 @@ def __init__(self, parent=None): self._rehighlight_timer.setSingleShot(True) self._rehighlight_timer.setInterval(150) + # ---- Multi Cursor + def add_cursor(self, cursor: QTextCursor): + self.extra_cursors.append(cursor) + + def _on_cursor_blinktimer_timeout(self): + """ + text cursor blink timer generates paint events + """ + self.cursor_blink_state = not self.cursor_blink_state + if self.isVisible(): + self.viewport().update() + + def _paint_cursors(self): # TODO move into paintEvent? so we can add extraSelections before super()paintEvent then paint cursors afterward? + qp = QPainter() + qp.begin(self.viewport()) + offset = self.contentOffset() + qp.setBrushOrigin(offset) + + for cursor in self.extra_cursors + [self.textCursor()]: + # add to extraSelections to paint selection + # TODO + + # paint cursor + editable = not self.isReadOnly() + flags = (self.textInteractionFlags() & + Qt.TextInteractionFlag.TextSelectableByKeyboard) + block = cursor.block() + if (self.cursor_blink_state and + (editable or flags) and + block.isVisible()): + # TODO don't bother with preeditArea? + for top, blocknum, visblock in self.visible_blocks: + if block.position() == visblock.position(): + offset.setY(top) + block.layout().drawCursor(qp, offset, + cursor.positionInBlock(), + self.cursor_width) + break + qp.end() + + @Slot() + def start_cursor_blink(self): + self.current_blink_state = True + self.cursor_blink_timer.start() + self.viewport().update() + + @Slot() + def stop_cursor_blink(self): + self.cursor_blink_state = False + self.cursor_blink_timer.stop() + self.viewport().update() + + # ---- Hover/Hints # ------------------------------------------------------------------------- def _should_display_hover(self, point): @@ -4382,7 +4444,10 @@ def mousePressEvent(self, event): pos = event.pos() self._mouse_left_button_pressed = event.button() == Qt.LeftButton - if event.button() == Qt.LeftButton and ctrl: + if event.button() == Qt.LeftButton and ctrl and alt: + self.add_cursor(self.textCursor()) + self.setTextCursor(self.cursorForPosition(pos)) + elif event.button() == Qt.LeftButton and ctrl: TextEditBaseWidget.mousePressEvent(self, event) cursor = self.cursorForPosition(pos) uri = self._last_hover_pattern_text @@ -4496,6 +4561,7 @@ def paintEvent(self, event): """Overrides paint event to update the list of visible blocks""" self.update_visible_blocks(event) TextEditBaseWidget.paintEvent(self, event) + self._paint_cursors() self.painted.emit(event) def update_visible_blocks(self, event): From d03be3e6eda42621b668153b8958c485a143fe92 Mon Sep 17 00:00:00 2001 From: athompson673 Date: Sun, 3 Nov 2024 16:32:55 -0500 Subject: [PATCH 02/95] non-shortcut mouse press clears extra cursors --- .../editor/widgets/codeeditor/codeeditor.py | 46 +++++++++++-------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 62fde0cb7c7..b9fbdef85fe 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -255,14 +255,8 @@ def __init__(self, parent=None): self.current_project_path = None # Caret (text cursor) - self.cursor_width = self.get_conf('cursor/width', section='main') - self.setCursorWidth(0) # draw our own cursor - self.extra_cursors = [] - self.cursor_blink_state = False - self.cursor_blink_timer = QTimer(self) - self.cursor_blink_timer.setInterval(QApplication.cursorFlashTime()//2) - self.cursor_blink_timer.timeout.connect(self._on_cursor_blinktimer_timeout) - + self.init_multi_cursor() + self.focus_in.connect(self.start_cursor_blink) self.focus_changed.connect(self.stop_cursor_blink) @@ -519,8 +513,20 @@ def __init__(self, parent=None): self._rehighlight_timer.setInterval(150) # ---- Multi Cursor + def init_multi_cursor(self): + self.cursor_width = self.get_conf('cursor/width', section='main') + self.setCursorWidth(0) # draw our own cursor + self.extra_cursors = [] + self.cursor_blink_state = False + self.cursor_blink_timer = QTimer(self) + self.cursor_blink_timer.setInterval(QApplication.cursorFlashTime()//2) + self.cursor_blink_timer.timeout.connect(self._on_cursor_blinktimer_timeout) + def add_cursor(self, cursor: QTextCursor): self.extra_cursors.append(cursor) + + def clear_extra_cursors(self): + self.extra_cursors = [] def _on_cursor_blinktimer_timeout(self): """ @@ -4447,18 +4453,20 @@ def mousePressEvent(self, event): if event.button() == Qt.LeftButton and ctrl and alt: self.add_cursor(self.textCursor()) self.setTextCursor(self.cursorForPosition(pos)) - elif event.button() == Qt.LeftButton and ctrl: - TextEditBaseWidget.mousePressEvent(self, event) - cursor = self.cursorForPosition(pos) - uri = self._last_hover_pattern_text - if uri: - self.go_to_uri_from_cursor(uri) - else: - self.go_to_definition_from_cursor(cursor) - elif event.button() == Qt.LeftButton and alt: - self.sig_alt_left_mouse_pressed.emit(event) else: - TextEditBaseWidget.mousePressEvent(self, event) + self.clear_extra_cursors() + if event.button() == Qt.LeftButton and ctrl: + TextEditBaseWidget.mousePressEvent(self, event) + cursor = self.cursorForPosition(pos) + uri = self._last_hover_pattern_text + if uri: + self.go_to_uri_from_cursor(uri) + else: + self.go_to_definition_from_cursor(cursor) + elif event.button() == Qt.LeftButton and alt: + self.sig_alt_left_mouse_pressed.emit(event) + else: + TextEditBaseWidget.mousePressEvent(self, event) def mouseReleaseEvent(self, event): """Override Qt method.""" From d3e3e65474c009baf287b6ab018574b1a2fda9cb Mon Sep 17 00:00:00 2001 From: athompson673 Date: Sun, 3 Nov 2024 18:56:22 -0500 Subject: [PATCH 03/95] draw extra cursor selections using text decorations --- .../editor/widgets/codeeditor/codeeditor.py | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index b9fbdef85fe..a51c7fe6372 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -37,7 +37,8 @@ QMouseEvent, QTextCursor, QDesktopServices, QKeyEvent, QTextDocument, QTextFormat, QTextOption, QTextCharFormat, QTextLayout) -from qtpy.QtWidgets import QApplication, QMessageBox, QSplitter, QScrollBar +from qtpy.QtWidgets import (QApplication, QMessageBox, QSplitter, QScrollBar, + QTextEdit) from spyder_kernels.utils.dochelpers import getobj # Local imports @@ -524,9 +525,24 @@ def init_multi_cursor(self): def add_cursor(self, cursor: QTextCursor): self.extra_cursors.append(cursor) + selections = [] + for cursor in self.extra_cursors: + extra_selection = TextDecoration(cursor, draw_order=5,kind="extra_cursor_selection") + extra_selection.cursor = cursor + + # TODO get colors from theme? or from stylesheet? + extra_selection.set_foreground(QColor("#ffffff")) + extra_selection.set_background(QColor("#346792")) + selections.append(extra_selection) + self.set_extra_selections('extra_cursor_selections', selections) + def clear_extra_cursors(self): self.extra_cursors = [] + self.set_extra_selections('extra_cursor_selections', []) + + def merge_extra_cursors(self): + pass def _on_cursor_blinktimer_timeout(self): """ @@ -537,14 +553,14 @@ def _on_cursor_blinktimer_timeout(self): self.viewport().update() def _paint_cursors(self): # TODO move into paintEvent? so we can add extraSelections before super()paintEvent then paint cursors afterward? + """paint all cursors and cursor selections""" qp = QPainter() qp.begin(self.viewport()) offset = self.contentOffset() qp.setBrushOrigin(offset) for cursor in self.extra_cursors + [self.textCursor()]: - # add to extraSelections to paint selection - # TODO + # TODO add to extraSelections to paint selection # paint cursor editable = not self.isReadOnly() @@ -556,6 +572,9 @@ def _paint_cursors(self): # TODO move into paintEvent? so we can add extraSelec block.isVisible()): # TODO don't bother with preeditArea? for top, blocknum, visblock in self.visible_blocks: + #TODO is there a better way to get `top` because we already + # have cursor.block(). Is it meaningfully slow anyway + # even if it is a double loop? We paint pretty often... if block.position() == visblock.position(): offset.setY(top) block.layout().drawCursor(qp, offset, @@ -566,12 +585,14 @@ def _paint_cursors(self): # TODO move into paintEvent? so we can add extraSelec @Slot() def start_cursor_blink(self): + """start manually updating the cursor(s) blink state: Show cursors""" self.current_blink_state = True self.cursor_blink_timer.start() self.viewport().update() @Slot() def stop_cursor_blink(self): + """stop manually updating the cursor(s) blink state: Hide cursors""" self.cursor_blink_state = False self.cursor_blink_timer.stop() self.viewport().update() @@ -4451,7 +4472,9 @@ def mousePressEvent(self, event): self._mouse_left_button_pressed = event.button() == Qt.LeftButton if event.button() == Qt.LeftButton and ctrl and alt: - self.add_cursor(self.textCursor()) + # move existing primary cursor to extra_cursors list and set new + # primary cursor + self.add_cursor(self.textCursor()) self.setTextCursor(self.cursorForPosition(pos)) else: self.clear_extra_cursors() From 13414fd3ef1cf228d037aa72b64cb3b7fac88b77 Mon Sep 17 00:00:00 2001 From: athompson673 Date: Sun, 3 Nov 2024 19:09:53 -0500 Subject: [PATCH 04/95] refactoring --- .../editor/widgets/codeeditor/codeeditor.py | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index a51c7fe6372..82fe46e0a9c 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -257,9 +257,6 @@ def __init__(self, parent=None): # Caret (text cursor) self.init_multi_cursor() - - self.focus_in.connect(self.start_cursor_blink) - self.focus_changed.connect(self.stop_cursor_blink) self.text_helper = TextHelper(self) @@ -515,54 +512,61 @@ def __init__(self, parent=None): # ---- Multi Cursor def init_multi_cursor(self): + """Initialize attrs and callbacks for multi-cursor functionality""" self.cursor_width = self.get_conf('cursor/width', section='main') self.setCursorWidth(0) # draw our own cursor self.extra_cursors = [] self.cursor_blink_state = False self.cursor_blink_timer = QTimer(self) self.cursor_blink_timer.setInterval(QApplication.cursorFlashTime()//2) - self.cursor_blink_timer.timeout.connect(self._on_cursor_blinktimer_timeout) - + self.cursor_blink_timer.timeout.connect( + self._on_cursor_blinktimer_timeout + ) + self.focus_in.connect(self.start_cursor_blink) + self.focus_changed.connect(self.stop_cursor_blink) + self.painted.connect(self.paint_cursors) + def add_cursor(self, cursor: QTextCursor): + """Add this cursor to the list of extra cursors""" self.extra_cursors.append(cursor) selections = [] for cursor in self.extra_cursors: - extra_selection = TextDecoration(cursor, draw_order=5,kind="extra_cursor_selection") + extra_selection = TextDecoration(cursor, draw_order=5, + kind="extra_cursor_selection") extra_selection.cursor = cursor - + # TODO get colors from theme? or from stylesheet? extra_selection.set_foreground(QColor("#ffffff")) extra_selection.set_background(QColor("#346792")) selections.append(extra_selection) self.set_extra_selections('extra_cursor_selections', selections) - - + def clear_extra_cursors(self): + """Remove all extra cursors""" self.extra_cursors = [] self.set_extra_selections('extra_cursor_selections', []) - + def merge_extra_cursors(self): + """Merge overlapping cursors""" pass def _on_cursor_blinktimer_timeout(self): """ - text cursor blink timer generates paint events + Text cursor blink timer generates paint events and inverts draw state """ self.cursor_blink_state = not self.cursor_blink_state if self.isVisible(): self.viewport().update() - def _paint_cursors(self): # TODO move into paintEvent? so we can add extraSelections before super()paintEvent then paint cursors afterward? - """paint all cursors and cursor selections""" + @Slot(QPaintEvent) + def paint_cursors(self, event): + """Paint all cursors""" qp = QPainter() qp.begin(self.viewport()) offset = self.contentOffset() qp.setBrushOrigin(offset) for cursor in self.extra_cursors + [self.textCursor()]: - # TODO add to extraSelections to paint selection - - # paint cursor editable = not self.isReadOnly() flags = (self.textInteractionFlags() & Qt.TextInteractionFlag.TextSelectableByKeyboard) @@ -4592,7 +4596,7 @@ def paintEvent(self, event): """Overrides paint event to update the list of visible blocks""" self.update_visible_blocks(event) TextEditBaseWidget.paintEvent(self, event) - self._paint_cursors() + # self._paint_cursors() self.painted.emit(event) def update_visible_blocks(self, event): From c457bb07b6da209deaa1c367ddf4aec677837d1a Mon Sep 17 00:00:00 2001 From: athompson673 Date: Sun, 3 Nov 2024 21:13:24 -0500 Subject: [PATCH 05/95] formatting and comments --- .../editor/widgets/codeeditor/codeeditor.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 82fe46e0a9c..61e44b2aa73 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -548,7 +548,9 @@ def clear_extra_cursors(self): def merge_extra_cursors(self): """Merge overlapping cursors""" - pass + if not self.extra_cursors: + return + # TODO write this def _on_cursor_blinktimer_timeout(self): """ @@ -572,13 +574,13 @@ def paint_cursors(self, event): Qt.TextInteractionFlag.TextSelectableByKeyboard) block = cursor.block() if (self.cursor_blink_state and - (editable or flags) and - block.isVisible()): + (editable or flags) and + block.isVisible()): # TODO don't bother with preeditArea? for top, blocknum, visblock in self.visible_blocks: - #TODO is there a better way to get `top` because we already - # have cursor.block(). Is it meaningfully slow anyway - # even if it is a double loop? We paint pretty often... + # TODO is there a better way to get `top` because we + # already have cursor.block(). Is it meaningfully slow + # anyway even if it is a double loop? if block.position() == visblock.position(): offset.setY(top) block.layout().drawCursor(qp, offset, @@ -601,7 +603,6 @@ def stop_cursor_blink(self): self.cursor_blink_timer.stop() self.viewport().update() - # ---- Hover/Hints # ------------------------------------------------------------------------- def _should_display_hover(self, point): @@ -4596,7 +4597,6 @@ def paintEvent(self, event): """Overrides paint event to update the list of visible blocks""" self.update_visible_blocks(event) TextEditBaseWidget.paintEvent(self, event) - # self._paint_cursors() self.painted.emit(event) def update_visible_blocks(self, event): From 2c8d669873c3a5255ab5e60c37be7951b90b130c Mon Sep 17 00:00:00 2001 From: athompson673 Date: Mon, 4 Nov 2024 07:59:20 -0500 Subject: [PATCH 06/95] first attempt at handling keyEvent for all cursors --- .../plugins/editor/widgets/codeeditor/codeeditor.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 61e44b2aa73..989fa70d0b0 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -525,6 +525,7 @@ def init_multi_cursor(self): self.focus_in.connect(self.start_cursor_blink) self.focus_changed.connect(self.stop_cursor_blink) self.painted.connect(self.paint_cursors) + self.sig_key_pressed.connect(self.handle_multi_cursor_keypress) def add_cursor(self, cursor: QTextCursor): """Add this cursor to the list of extra cursors""" @@ -552,6 +553,16 @@ def merge_extra_cursors(self): return # TODO write this + @Slot(QKeyEvent) + def handle_multi_cursor_keypress(self, event: QKeyEvent): + if self.extra_cursors: + for cursor in self.extra_cursors + [self.textCursor()]: + self.setTextCursor(cursor) + # TODO works for text, not for movement arrows or home, end + self._handle_keypress_event(event) + print("curs") + event.accept() + def _on_cursor_blinktimer_timeout(self): """ Text cursor blink timer generates paint events and inverts draw state From df165522b2394e672fd0bfd37eae52c0503cbcf4 Mon Sep 17 00:00:00 2001 From: Aaron Date: Mon, 4 Nov 2024 14:12:01 -0500 Subject: [PATCH 07/95] implement various cursor movement --- .../editor/widgets/codeeditor/codeeditor.py | 90 +++++++++++++++---- 1 file changed, 72 insertions(+), 18 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 989fa70d0b0..d058096a996 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -37,7 +37,7 @@ QMouseEvent, QTextCursor, QDesktopServices, QKeyEvent, QTextDocument, QTextFormat, QTextOption, QTextCharFormat, QTextLayout) -from qtpy.QtWidgets import (QApplication, QMessageBox, QSplitter, QScrollBar, +from qtpy.QtWidgets import (QApplication, QMessageBox, QSplitter, QScrollBar, QTextEdit) from spyder_kernels.utils.dochelpers import getobj @@ -530,6 +530,9 @@ def init_multi_cursor(self): def add_cursor(self, cursor: QTextCursor): """Add this cursor to the list of extra cursors""" self.extra_cursors.append(cursor) + self.set_extra_cursor_selections() + + def set_extra_cursor_selections(self): selections = [] for cursor in self.extra_cursors: extra_selection = TextDecoration(cursor, draw_order=5, @@ -556,12 +559,61 @@ def merge_extra_cursors(self): @Slot(QKeyEvent) def handle_multi_cursor_keypress(self, event: QKeyEvent): if self.extra_cursors: + event.accept() + key = event.key() + text = event.text() + shift = event.modifiers() & Qt.ShiftModifier + ctrl = event.modifiers() & Qt.ControlModifier + move_mode = QTextCursor.KeepAnchor if shift else QTextCursor.MoveAnchor + for cursor in self.extra_cursors + [self.textCursor()]: + self.setTextCursor(cursor) - # TODO works for text, not for movement arrows or home, end - self._handle_keypress_event(event) - print("curs") - event.accept() + # ---- handle movement keys + if key == Qt.Key_Up: + cursor.movePosition(QTextCursor.Up, move_mode) + elif key == Qt.Key_Down: + cursor.movePosition(QTextCursor.Down, move_mode) + elif key == Qt.Key_Left: + if ctrl: + cursor.movePosition( + QTextCursor.PreviousWord, move_mode + ) + else: + cursor.movePosition(QTextCursor.Left, move_mode) + elif key == Qt.Key_Right: + if ctrl: + cursor.movePosition(QTextCursor.NextWord, move_mode) + else: + cursor.movePosition(QTextCursor.Right, move_mode) + elif key == Qt.Key_Home: + if ctrl: + cursor.movePosition(QTextCursor.Start, move_mode) + else: + block_pos = cursor.block().position() + line = cursor.block().text() + indent_pos = block_pos + len(line) - len(line.lstrip()) + if cursor.position() != indent_pos: + cursor.setPosition(indent_pos, move_mode) + else: + cursor.setPosition(block_pos, move_mode) + elif key == Qt.Key_End: + if ctrl: + cursor.movePosition(QTextCursor.End, move_mode) + else: + cursor.movePosition(QTextCursor.EndOfBlock, move_mode) + if not cursor.hasSelection(): + cursor.movePosition(QTextCursor.NextCharacter, + move_mode) + cursor.removeSelectedText() + # TODO needed? See spyder-ide/spyder#12663 + # from remove_selected_text + # cursor.setPosition(cursor.position()) + else: + self._handle_keypress_event(event) + self.merge_extra_cursors() + self.set_extra_cursor_selections() + self.setTextCursor(cursor) # last cursor from for loop is primary def _on_cursor_blinktimer_timeout(self): """ @@ -694,10 +746,10 @@ def outlineexplorer_data_list(self): def create_cursor_callback(self, attr): """Make a callback for cursor move event type, (e.g. "Start")""" def cursor_move_event(): - cursor = self.textCursor() move_type = getattr(QTextCursor, attr) - cursor.movePosition(move_type) - self.setTextCursor(cursor) + for cursor in self.extra_cursors + [self.textCursor()]: + cursor.movePosition(move_type) + self.setTextCursor(cursor) return cursor_move_event def register_shortcuts(self): @@ -1413,17 +1465,19 @@ def next_cursor_position(self, position=None, @Slot() def delete(self): """Remove selected text or next character.""" - self.sig_delete_requested.emit() - - if not self.has_selected_text(): - cursor = self.textCursor() - if not cursor.atEnd(): - cursor.setPosition( - self.next_cursor_position(), QTextCursor.KeepAnchor - ) + for cursor in self.extra_cursors + [self.textCursor()]: self.setTextCursor(cursor) - self.remove_selected_text() + if not self.has_selected_text(): + cursor = self.textCursor() + if not cursor.atEnd(): + cursor.setPosition( + self.next_cursor_position(), QTextCursor.KeepAnchor + ) + self.setTextCursor(cursor) + + self.remove_selected_text() + self.sig_delete_requested.emit() def delete_line(self): """Delete current line.""" @@ -4490,7 +4544,7 @@ def mousePressEvent(self, event): if event.button() == Qt.LeftButton and ctrl and alt: # move existing primary cursor to extra_cursors list and set new # primary cursor - self.add_cursor(self.textCursor()) + self.add_cursor(self.textCursor()) self.setTextCursor(self.cursorForPosition(pos)) else: self.clear_extra_cursors() From 7895b99964ebc7638c79e033f6adec0cc9e4fec3 Mon Sep 17 00:00:00 2001 From: Aaron Date: Tue, 5 Nov 2024 16:04:12 -0500 Subject: [PATCH 08/95] Fix missing line for delete key handling --- spyder/plugins/editor/widgets/codeeditor/codeeditor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index d058096a996..1baf1eccde8 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -602,6 +602,7 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): cursor.movePosition(QTextCursor.End, move_mode) else: cursor.movePosition(QTextCursor.EndOfBlock, move_mode) + elif key == Qt.Key_Delete: if not cursor.hasSelection(): cursor.movePosition(QTextCursor.NextCharacter, move_mode) From c121ec38935aa8406fe8489b93e5200929ece0dc Mon Sep 17 00:00:00 2001 From: Aaron Date: Wed, 6 Nov 2024 11:07:44 -0500 Subject: [PATCH 09/95] Add cursor EditBlock to key event for undo history and other changes --- .../plugins/editor/widgets/codeeditor/codeeditor.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 1baf1eccde8..c80bc011ba4 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -530,6 +530,7 @@ def init_multi_cursor(self): def add_cursor(self, cursor: QTextCursor): """Add this cursor to the list of extra cursors""" self.extra_cursors.append(cursor) + self.merge_extra_cursors() self.set_extra_cursor_selections() def set_extra_cursor_selections(self): @@ -537,7 +538,6 @@ def set_extra_cursor_selections(self): for cursor in self.extra_cursors: extra_selection = TextDecoration(cursor, draw_order=5, kind="extra_cursor_selection") - extra_selection.cursor = cursor # TODO get colors from theme? or from stylesheet? extra_selection.set_foreground(QColor("#ffffff")) @@ -565,7 +565,7 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): shift = event.modifiers() & Qt.ShiftModifier ctrl = event.modifiers() & Qt.ControlModifier move_mode = QTextCursor.KeepAnchor if shift else QTextCursor.MoveAnchor - + self.textCursor().beginEditBlock() for cursor in self.extra_cursors + [self.textCursor()]: self.setTextCursor(cursor) @@ -577,13 +577,13 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): elif key == Qt.Key_Left: if ctrl: cursor.movePosition( - QTextCursor.PreviousWord, move_mode + QTextCursor.StartOfWord, move_mode ) else: cursor.movePosition(QTextCursor.Left, move_mode) elif key == Qt.Key_Right: if ctrl: - cursor.movePosition(QTextCursor.NextWord, move_mode) + cursor.movePosition(QTextCursor.EndOfWord, move_mode) else: cursor.movePosition(QTextCursor.Right, move_mode) elif key == Qt.Key_Home: @@ -605,7 +605,7 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): elif key == Qt.Key_Delete: if not cursor.hasSelection(): cursor.movePosition(QTextCursor.NextCharacter, - move_mode) + QTextCursor.KeepAnchor) cursor.removeSelectedText() # TODO needed? See spyder-ide/spyder#12663 # from remove_selected_text @@ -614,6 +614,7 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): self._handle_keypress_event(event) self.merge_extra_cursors() self.set_extra_cursor_selections() + cursor.endEditBlock() self.setTextCursor(cursor) # last cursor from for loop is primary def _on_cursor_blinktimer_timeout(self): From daf592ff37c05387c27c01a8e0c6d68ccc270099 Mon Sep 17 00:00:00 2001 From: Aaron Date: Wed, 6 Nov 2024 16:17:55 -0500 Subject: [PATCH 10/95] write multi-cursor cut, copy, paste --- .../editor/widgets/codeeditor/codeeditor.py | 77 +++++++++++++++++-- 1 file changed, 71 insertions(+), 6 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index c80bc011ba4..7fd80c948f5 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -561,15 +561,21 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): if self.extra_cursors: event.accept() key = event.key() - text = event.text() + text = event.text() # TODO needed for any key handling? shift = event.modifiers() & Qt.ShiftModifier ctrl = event.modifiers() & Qt.ControlModifier move_mode = QTextCursor.KeepAnchor if shift else QTextCursor.MoveAnchor + cursors = self.extra_cursors + [self.textCursor()] + + # Some operations should only be 1 per row even if there's multiple + # cursors on that row + handled_rows = [] + self.textCursor().beginEditBlock() - for cursor in self.extra_cursors + [self.textCursor()]: + for cursor in cursors: self.setTextCursor(cursor) - # ---- handle movement keys + # ---- handle arrow keys if key == Qt.Key_Up: cursor.movePosition(QTextCursor.Up, move_mode) elif key == Qt.Key_Down: @@ -586,6 +592,7 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): cursor.movePosition(QTextCursor.EndOfWord, move_mode) else: cursor.movePosition(QTextCursor.Right, move_mode) + # ---- handle Home, End elif key == Qt.Key_Home: if ctrl: cursor.movePosition(QTextCursor.Start, move_mode) @@ -602,6 +609,7 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): cursor.movePosition(QTextCursor.End, move_mode) else: cursor.movePosition(QTextCursor.EndOfBlock, move_mode) + # ---- handle Delete (maybe backspace?) elif key == Qt.Key_Delete: if not cursor.hasSelection(): cursor.movePosition(QTextCursor.NextCharacter, @@ -610,6 +618,17 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): # TODO needed? See spyder-ide/spyder#12663 # from remove_selected_text # cursor.setPosition(cursor.position()) + # ---- handle Tab + elif key == Qt.Key_Tab and not ctrl: # ctrl-tab is shortcut + # Don't do intelligent tab with multi-cursor to skip + # calls to do_completion. Avoiding completions with multi + # cursor is much easier than solving all the edge cases. + + # Trivial implementation: # TODO respect tab_mode + self.replace(self.indent_chars) + elif key == Qt.Key_Backtab and not ctrl: + self.unindent() + # ---- use default handler for cursor (text) else: self._handle_keypress_event(event) self.merge_extra_cursors() @@ -668,6 +687,46 @@ def stop_cursor_blink(self): self.cursor_blink_timer.stop() self.viewport().update() + def multi_cursor_copy(self): + """copy multi-cursor selections separated by newlines""" + cursors = self.extra_cursors + [self.textCursor()] + cursors.sort(key=lambda cursor: cursor.position()) + selections = [] + for cursor in cursors: + text = cursor.selectedText().replace(u"\u2029", + self.get_line_separator()) + selections.append(text) # TODO skip empty selections? + clip_text = self.get_line_separator().join(selections) + print(clip_text) + QApplication.clipboard().setText(clip_text) + + def multi_cursor_cut(self): + self.multi_cursor_copy() + self.textCursor().beginEditBlock() + for cursor in self.extra_cursors + [self.textCursor()]: + cursor.removeSelectedText() + self.merge_extra_cursors() + self.set_extra_cursor_selections() + self.textCursor().endEditBlock() + + def multi_cursor_paste(self, clip_text): + main_cursor = self.textCursor() + main_cursor.beginEditBlock() + cursors = self.extra_cursors + [main_cursor] + cursors.sort(key=lambda cursor: cursor.position()) + self.skip_rstrip = True + self.sig_will_paste_text.emit(clip_text) + for cursor, text in zip(cursors, clip_text.splitlines()): + self.setTextCursor(cursor) + cursor.insertText(text) + # TODO handle extra lines or extra cursors? + self.setTextCursor(main_cursor) + self.merge_extra_cursors() + self.set_extra_cursor_selections() + main_cursor.endEditBlock() + self.sig_text_was_inserted.emit() + self.skip_rstrip = False + # ---- Hover/Hints # ------------------------------------------------------------------------- def _should_display_hover(self, point): @@ -1469,15 +1528,12 @@ def delete(self): """Remove selected text or next character.""" for cursor in self.extra_cursors + [self.textCursor()]: self.setTextCursor(cursor) - if not self.has_selected_text(): - cursor = self.textCursor() if not cursor.atEnd(): cursor.setPosition( self.next_cursor_position(), QTextCursor.KeepAnchor ) self.setTextCursor(cursor) - self.remove_selected_text() self.sig_delete_requested.emit() @@ -2003,6 +2059,9 @@ def paste(self): """ clipboard = QApplication.clipboard() text = to_text_string(clipboard.text()) + if self.extra_cursors: + self.multi_cursor_paste(text) + return if clipboard.mimeData().hasUrls(): # Have copied file and folder urls pasted as text paths. @@ -2111,6 +2170,9 @@ def _save_clipboard_indentation(self): @Slot() def cut(self): """Reimplement cut to signal listeners about changes on the text.""" + if self.extra_cursors: + self.multi_cursor_cut() + return has_selected_text = self.has_selected_text() if not has_selected_text: return @@ -2124,6 +2186,9 @@ def cut(self): @Slot() def copy(self): """Reimplement copy to save indentation.""" + if self.extra_cursors: + self.multi_cursor_copy() + return TextEditBaseWidget.copy(self) self._save_clipboard_indentation() From 1a955e97ff26bb22cc92cdbb005773e3248d95f8 Mon Sep 17 00:00:00 2001 From: Aaron Date: Thu, 7 Nov 2024 10:11:19 -0500 Subject: [PATCH 11/95] rework delete handling for better undo/redo cursor positioning --- .../editor/widgets/codeeditor/codeeditor.py | 23 ++++--------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 5071de7da45..621f9e88026 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -609,15 +609,6 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): cursor.movePosition(QTextCursor.End, move_mode) else: cursor.movePosition(QTextCursor.EndOfBlock, move_mode) - # ---- handle Delete (maybe backspace?) - elif key == Qt.Key_Delete: - if not cursor.hasSelection(): - cursor.movePosition(QTextCursor.NextCharacter, - QTextCursor.KeepAnchor) - cursor.removeSelectedText() - # TODO needed? See spyder-ide/spyder#12663 - # from remove_selected_text - # cursor.setPosition(cursor.position()) # ---- handle Tab elif key == Qt.Key_Tab and not ctrl: # ctrl-tab is shortcut # Don't do intelligent tab with multi-cursor to skip @@ -1526,15 +1517,10 @@ def next_cursor_position(self, position=None, @Slot() def delete(self): """Remove selected text or next character.""" + self.textCursor().beginEditBlock() for cursor in self.extra_cursors + [self.textCursor()]: - self.setTextCursor(cursor) - if not self.has_selected_text(): - if not cursor.atEnd(): - cursor.setPosition( - self.next_cursor_position(), QTextCursor.KeepAnchor - ) - self.setTextCursor(cursor) - self.remove_selected_text() + cursor.deleteChar() + cursor.endEditBlock() self.sig_delete_requested.emit() def delete_line(self): @@ -2175,8 +2161,7 @@ def cut(self): return has_selected_text = self.has_selected_text() if not has_selected_text: - self.select_current_line_and_sep() - + return start, end = self.get_selection_start_end() self.sig_will_remove_selection.emit(start, end) self.sig_delete_requested.emit() From 63fe5ed6801292c65fc60048604cabe042d57936 Mon Sep 17 00:00:00 2001 From: Aaron Date: Thu, 7 Nov 2024 11:26:17 -0500 Subject: [PATCH 12/95] rework cursor painting --- .../editor/widgets/codeeditor/codeeditor.py | 27 +++++++++---------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 621f9e88026..590fafa3504 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -641,27 +641,23 @@ def paint_cursors(self, event): qp = QPainter() qp.begin(self.viewport()) offset = self.contentOffset() + offset_y = offset.y() qp.setBrushOrigin(offset) - + editable = not self.isReadOnly() + flags = (self.textInteractionFlags() & + Qt.TextInteractionFlag.TextSelectableByKeyboard) + self.textCursor().block().charFormat() for cursor in self.extra_cursors + [self.textCursor()]: - editable = not self.isReadOnly() - flags = (self.textInteractionFlags() & - Qt.TextInteractionFlag.TextSelectableByKeyboard) block = cursor.block() if (self.cursor_blink_state and (editable or flags) and block.isVisible()): # TODO don't bother with preeditArea? - for top, blocknum, visblock in self.visible_blocks: - # TODO is there a better way to get `top` because we - # already have cursor.block(). Is it meaningfully slow - # anyway even if it is a double loop? - if block.position() == visblock.position(): - offset.setY(top) - block.layout().drawCursor(qp, offset, - cursor.positionInBlock(), - self.cursor_width) - break + offset.setY(int(self.blockBoundingGeometry(block).top() + + offset_y)) + block.layout().drawCursor(qp, offset, + cursor.positionInBlock(), + self.cursor_width) qp.end() @Slot() @@ -688,7 +684,6 @@ def multi_cursor_copy(self): self.get_line_separator()) selections.append(text) # TODO skip empty selections? clip_text = self.get_line_separator().join(selections) - print(clip_text) QApplication.clipboard().setText(clip_text) def multi_cursor_cut(self): @@ -3918,6 +3913,8 @@ def keyPressEvent(self, event): return return + self.start_cursor_blink() # reset cursor blink by reseting timer + # ---- Handle hard coded and builtin actions operators = {'+', '-', '*', '**', '/', '//', '%', '@', '<<', '>>', '&', '|', '^', '~', '<', '>', '<=', '>=', '==', '!='} From af348c5cbf9c50110bf8d6b2a4a9390953842de1 Mon Sep 17 00:00:00 2001 From: Aaron Date: Thu, 7 Nov 2024 18:36:47 -0500 Subject: [PATCH 13/95] overtype cursor rendering Required manually tracking overwriteMode and leaving it disabled except for during keyEvent. This might be a fragile solution... --- .../editor/widgets/codeeditor/codeeditor.py | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 590fafa3504..c82021cbdcf 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -33,9 +33,9 @@ from qtpy.compat import to_qvariant from qtpy.QtCore import ( QEvent, QRegularExpression, Qt, QTimer, QUrl, Signal, Slot) -from qtpy.QtGui import (QColor, QCursor, QFont, QPaintEvent, QPainter, - QMouseEvent, QTextCursor, QDesktopServices, QKeyEvent, - QTextDocument, QTextFormat, QTextOption, +from qtpy.QtGui import (QColor, QCursor, QFont, QFontMetrics, QPaintEvent, + QPainter, QMouseEvent, QTextCursor, QDesktopServices, + QKeyEvent, QTextDocument, QTextFormat, QTextOption, QTextCharFormat, QTextLayout) from qtpy.QtWidgets import (QApplication, QMessageBox, QSplitter, QScrollBar, QTextEdit) @@ -514,6 +514,9 @@ def __init__(self, parent=None): def init_multi_cursor(self): """Initialize attrs and callbacks for multi-cursor functionality""" self.cursor_width = self.get_conf('cursor/width', section='main') + self.overwrite_mode = self.overwriteMode() + # track overwrite manually for painting the cursor(s) + self.setOverwriteMode(False) self.setCursorWidth(0) # draw our own cursor self.extra_cursors = [] self.cursor_blink_state = False @@ -646,7 +649,11 @@ def paint_cursors(self, event): editable = not self.isReadOnly() flags = (self.textInteractionFlags() & Qt.TextInteractionFlag.TextSelectableByKeyboard) - self.textCursor().block().charFormat() + if self.overwrite_mode: + font = self.textCursor().block().charFormat().font() + cursor_width = QFontMetrics(font).horizontalAdvance(" ") + else: + cursor_width = self.cursor_width for cursor in self.extra_cursors + [self.textCursor()]: block = cursor.block() if (self.cursor_blink_state and @@ -657,7 +664,7 @@ def paint_cursors(self, event): offset_y)) block.layout().drawCursor(qp, offset, cursor.positionInBlock(), - self.cursor_width) + cursor_width) qp.end() @Slot() @@ -3874,6 +3881,9 @@ def keyPressEvent(self, event): else: self._set_completions_hint_idle() + # Only set overwrite mode during key handling to allow correct painting + # of multiple overwrite cursors. Must unset overwrite before return. + self.setOverwriteMode(self.overwrite_mode) # Send the signal to the editor's extension. event.ignore() self.sig_key_pressed.emit(event) @@ -3896,10 +3906,12 @@ def keyPressEvent(self, event): if event.isAccepted(): # The event was handled by one of the editor extension. + self.setOverwriteMode(False) return if key in [Qt.Key_Control, Qt.Key_Shift, Qt.Key_Alt, Qt.Key_Meta, Qt.KeypadModifier]: + self.setOverwriteMode(False) # The user pressed only a modifier key. if ctrl: pos = self.mapFromGlobal(QCursor.pos()) @@ -3968,7 +3980,7 @@ def keyPressEvent(self, event): cur_indent=cur_indent) self.textCursor().endEditBlock() elif key == Qt.Key_Insert and not shift and not ctrl: - self.setOverwriteMode(not self.overwriteMode()) + self.overwrite_mode = not self.overwrite_mode elif key == Qt.Key_Backspace and not shift and not ctrl: if has_selection or not self.intelligent_backspace: self._handle_keypress_event(event) @@ -4090,6 +4102,7 @@ def keyPressEvent(self, event): # Modifiers should be passed to the parent because they # could be shortcuts event.accept() + self.setOverwriteMode(False) def do_automatic_completions(self): """Perform on the fly completions.""" From 8b2c27c880bd967c9832d0a560aaf48c1eced10a Mon Sep 17 00:00:00 2001 From: Aaron Date: Fri, 8 Nov 2024 13:07:05 -0500 Subject: [PATCH 14/95] implement overlapping cursor merge --- .../editor/widgets/codeeditor/codeeditor.py | 84 +++++++++++++++---- 1 file changed, 67 insertions(+), 17 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index c82021cbdcf..aef97a72121 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -533,7 +533,7 @@ def init_multi_cursor(self): def add_cursor(self, cursor: QTextCursor): """Add this cursor to the list of extra cursors""" self.extra_cursors.append(cursor) - self.merge_extra_cursors() + self.merge_extra_cursors(True) self.set_extra_cursor_selections() def set_extra_cursor_selections(self): @@ -553,11 +553,54 @@ def clear_extra_cursors(self): self.extra_cursors = [] self.set_extra_selections('extra_cursor_selections', []) - def merge_extra_cursors(self): + @property + def all_cursors(self): + return self.extra_cursors + [self.textCursor()] + + def merge_extra_cursors(self, increasing_position): """Merge overlapping cursors""" if not self.extra_cursors: return - # TODO write this + + while True: + cursor_was_removed = False + + cursors = self.all_cursors + main_cursor = self.all_cursors[-1] + cursors.sort(key= lambda cursor: cursor.position()) + + for i, cursor1 in enumerate(cursors[:-1]): + if cursor_was_removed: + break # list will be modified, so re-start at while loop + for cursor2 in cursors[i+1:]: + # given cursors.sort, pos1 should be <= pos2 + pos1 = cursor1.position() + pos2 = cursor2.position() + print(pos1, pos2) + anchor1 = cursor1.anchor() + anchor2 = cursor2.anchor() + + if not pos1 == pos2: + continue # only merge coincident cursors + + if cursor1 is main_cursor: + # swap cursors to keep main_cursor + cursor1, cursor2 = cursor2, cursor1 + self.extra_cursors.remove(cursor1) + cursor_was_removed = True + + # reposition cursor we're keeping + positions = sorted([pos1, anchor1, anchor2]) + if not increasing_position: + positions.reverse() + cursor2.setPosition(positions[0], + QTextCursor.MoveMode.MoveAnchor) + cursor2.setPosition(positions[2], + QTextCursor.MoveMode.KeepAnchor) + break + + if not cursor_was_removed: + break @Slot(QKeyEvent) def handle_multi_cursor_keypress(self, event: QKeyEvent): @@ -568,22 +611,24 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): shift = event.modifiers() & Qt.ShiftModifier ctrl = event.modifiers() & Qt.ControlModifier move_mode = QTextCursor.KeepAnchor if shift else QTextCursor.MoveAnchor - cursors = self.extra_cursors + [self.textCursor()] - + # Will cursors have increased or decreased in position? + increasing_direction = True # Some operations should only be 1 per row even if there's multiple - # cursors on that row + # cursors on that row (smart indent/unindent) handled_rows = [] self.textCursor().beginEditBlock() - for cursor in cursors: + for cursor in self.all_cursors: self.setTextCursor(cursor) # ---- handle arrow keys if key == Qt.Key_Up: cursor.movePosition(QTextCursor.Up, move_mode) + increasing_direction = False elif key == Qt.Key_Down: cursor.movePosition(QTextCursor.Down, move_mode) elif key == Qt.Key_Left: + increasing_direction = False if ctrl: cursor.movePosition( QTextCursor.StartOfWord, move_mode @@ -597,6 +642,7 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): cursor.movePosition(QTextCursor.Right, move_mode) # ---- handle Home, End elif key == Qt.Key_Home: + increasing_direction = False if ctrl: cursor.movePosition(QTextCursor.Start, move_mode) else: @@ -621,11 +667,12 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): # Trivial implementation: # TODO respect tab_mode self.replace(self.indent_chars) elif key == Qt.Key_Backtab and not ctrl: + increasing_direction = False self.unindent() # ---- use default handler for cursor (text) else: self._handle_keypress_event(event) - self.merge_extra_cursors() + self.merge_extra_cursors(increasing_direction) self.set_extra_cursor_selections() cursor.endEditBlock() self.setTextCursor(cursor) # last cursor from for loop is primary @@ -654,7 +701,7 @@ def paint_cursors(self, event): cursor_width = QFontMetrics(font).horizontalAdvance(" ") else: cursor_width = self.cursor_width - for cursor in self.extra_cursors + [self.textCursor()]: + for cursor in self.all_cursors: block = cursor.block() if (self.cursor_blink_state and (editable or flags) and @@ -683,7 +730,7 @@ def stop_cursor_blink(self): def multi_cursor_copy(self): """copy multi-cursor selections separated by newlines""" - cursors = self.extra_cursors + [self.textCursor()] + cursors = self.all_cursors cursors.sort(key=lambda cursor: cursor.position()) selections = [] for cursor in cursors: @@ -696,16 +743,17 @@ def multi_cursor_copy(self): def multi_cursor_cut(self): self.multi_cursor_copy() self.textCursor().beginEditBlock() - for cursor in self.extra_cursors + [self.textCursor()]: + for cursor in self.all_cursors: cursor.removeSelectedText() - self.merge_extra_cursors() + # merge direction doesn't matter here as all selections are removed + self.merge_extra_cursors(True) self.set_extra_cursor_selections() self.textCursor().endEditBlock() def multi_cursor_paste(self, clip_text): main_cursor = self.textCursor() main_cursor.beginEditBlock() - cursors = self.extra_cursors + [main_cursor] + cursors = self.all_cursors cursors.sort(key=lambda cursor: cursor.position()) self.skip_rstrip = True self.sig_will_paste_text.emit(clip_text) @@ -714,7 +762,8 @@ def multi_cursor_paste(self, clip_text): cursor.insertText(text) # TODO handle extra lines or extra cursors? self.setTextCursor(main_cursor) - self.merge_extra_cursors() + # merge direction doesn't matter here as all selections are removed + self.merge_extra_cursors(True) self.set_extra_cursor_selections() main_cursor.endEditBlock() self.sig_text_was_inserted.emit() @@ -801,7 +850,7 @@ def create_cursor_callback(self, attr): """Make a callback for cursor move event type, (e.g. "Start")""" def cursor_move_event(): move_type = getattr(QTextCursor, attr) - for cursor in self.extra_cursors + [self.textCursor()]: + for cursor in self.all_cursors: cursor.movePosition(move_type) self.setTextCursor(cursor) return cursor_move_event @@ -1520,7 +1569,7 @@ def next_cursor_position(self, position=None, def delete(self): """Remove selected text or next character.""" self.textCursor().beginEditBlock() - for cursor in self.extra_cursors + [self.textCursor()]: + for cursor in self.all_cursors: cursor.deleteChar() cursor.endEditBlock() self.sig_delete_requested.emit() @@ -4607,8 +4656,9 @@ def mousePressEvent(self, event): if event.button() == Qt.LeftButton and ctrl and alt: # move existing primary cursor to extra_cursors list and set new # primary cursor - self.add_cursor(self.textCursor()) + old_cursor = self.textCursor() self.setTextCursor(self.cursorForPosition(pos)) + self.add_cursor(old_cursor) else: self.clear_extra_cursors() if event.button() == Qt.LeftButton and ctrl: From 4c803fa16965ed51f9789294b5884bf33f1fc58c Mon Sep 17 00:00:00 2001 From: Aaron Date: Fri, 8 Nov 2024 16:39:39 -0500 Subject: [PATCH 15/95] impl select-all, and template methods for wrapping other shortcuts --- .../editor/widgets/codeeditor/codeeditor.py | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index aef97a72121..67cbe50ac3c 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -37,8 +37,7 @@ QPainter, QMouseEvent, QTextCursor, QDesktopServices, QKeyEvent, QTextDocument, QTextFormat, QTextOption, QTextCharFormat, QTextLayout) -from qtpy.QtWidgets import (QApplication, QMessageBox, QSplitter, QScrollBar, - QTextEdit) +from qtpy.QtWidgets import QApplication, QMessageBox, QSplitter, QScrollBar from spyder_kernels.utils.dochelpers import getobj # Local imports @@ -576,7 +575,6 @@ def merge_extra_cursors(self, increasing_position): # given cursors.sort, pos1 should be <= pos2 pos1 = cursor1.position() pos2 = cursor2.position() - print(pos1, pos2) anchor1 = cursor1.anchor() anchor2 = cursor2.anchor() @@ -601,6 +599,7 @@ def merge_extra_cursors(self, increasing_position): if not cursor_was_removed: break + self.set_extra_cursor_selections() @Slot(QKeyEvent) def handle_multi_cursor_keypress(self, event: QKeyEvent): @@ -614,8 +613,8 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): # Will cursors have increased or decreased in position? increasing_direction = True # Some operations should only be 1 per row even if there's multiple - # cursors on that row (smart indent/unindent) - handled_rows = [] + # cursors on that row (smart indent/unindent?) + handled_rows = [] # TODO needed for any key handling? self.textCursor().beginEditBlock() for cursor in self.all_cursors: @@ -769,6 +768,27 @@ def multi_cursor_paste(self, clip_text): self.sig_text_was_inserted.emit() self.skip_rstrip = False + def for_each_cursor(self, method): + """ + Wrap callable to execute for each cursor by calling setTextCursor for each""" + pass # TODO write this & use to override shortcut methods? + + def clears_extra_cursors(self, method): + """Wrap callable to clear extra_cursors prior to calling""" + pass # TODO write this & use to override shortcut methods? + + def restrict_single_cursor(self, method): + """Wrap callable to only execute if extra_cursors is clear""" + def wrapped(*args, **kwargs): + if self.extra_cursors: + # Don't do completion for each cursor, as that would be too + # cluttered, and no way to easily choose different + # completions for each cursor. #TODO maybe do completion on + # primary cursor and insert to all cursors? + return + else: # TODO test this & use to override shortcut methods? + method(*args, **kwargs) + # ---- Hover/Hints # ------------------------------------------------------------------------- def _should_display_hover(self, point): @@ -2255,6 +2275,12 @@ def redo(self): self.is_redoing = False self.skip_rstrip = False + @Slot() + def selectAll(self): + """overrides Qt selectAll method to ensure we clear extra cursors""" + self.clear_extra_cursors() + super().selectAll() + # ---- High-level editor features # ------------------------------------------------------------------------- @Slot() From a612b7846307e64e6e635aa7d90624a00d0f3ddc Mon Sep 17 00:00:00 2001 From: athompson673 Date: Sat, 9 Nov 2024 15:47:07 -0500 Subject: [PATCH 16/95] refactor prev/next word, and prevent auto completions when multi-cursor --- spyder/plugins/editor/widgets/codeeditor/codeeditor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 67cbe50ac3c..7770fc6079c 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -630,13 +630,13 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): increasing_direction = False if ctrl: cursor.movePosition( - QTextCursor.StartOfWord, move_mode + QTextCursor.PreviousWord, move_mode ) else: cursor.movePosition(QTextCursor.Left, move_mode) elif key == Qt.Key_Right: if ctrl: - cursor.movePosition(QTextCursor.EndOfWord, move_mode) + cursor.movePosition(QTextCursor.NextWord, move_mode) else: cursor.movePosition(QTextCursor.Right, move_mode) # ---- handle Home, End @@ -4181,7 +4181,7 @@ def keyPressEvent(self, event): def do_automatic_completions(self): """Perform on the fly completions.""" - if not self.automatic_completions: + if not self.automatic_completions or self.extra_cursors: return cursor = self.textCursor() From 88d6fd2814a7589fcfb4be9c86a52c5e11c7ccf9 Mon Sep 17 00:00:00 2001 From: athompson673 Date: Sat, 9 Nov 2024 18:18:06 -0500 Subject: [PATCH 17/95] impl wrappers for shortcut functions and delete_line function for multi-cursor --- .../editor/widgets/codeeditor/codeeditor.py | 82 +++++++++++-------- 1 file changed, 47 insertions(+), 35 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 7770fc6079c..ef7dec635cc 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -25,6 +25,7 @@ import sre_constants import sys import textwrap +from functools import wraps # Third party imports from IPython.core.inputtransformer2 import TransformerManager @@ -769,25 +770,33 @@ def multi_cursor_paste(self, clip_text): self.skip_rstrip = False def for_each_cursor(self, method): - """ - Wrap callable to execute for each cursor by calling setTextCursor for each""" - pass # TODO write this & use to override shortcut methods? + """Wrap callable to execute once for each cursor""" + @wraps(method) + def wrapper(*args, **kwargs): + self.textCursor().beginEditBlock() + for cursor in self.all_cursors: + self.setTextCursor(cursor) + method(*args, **kwargs) + # selections will be deleted so merge direction does not matter + self.merge_extra_cursors(True) + self.textCursor().endEditBlock() + return wrapper def clears_extra_cursors(self, method): """Wrap callable to clear extra_cursors prior to calling""" - pass # TODO write this & use to override shortcut methods? + @wraps(method) + def wrapper(*args, **kwargs): + self.clear_extra_cursors() + method(*args, **kwargs) + return wrapper def restrict_single_cursor(self, method): - """Wrap callable to only execute if extra_cursors is clear""" - def wrapped(*args, **kwargs): - if self.extra_cursors: - # Don't do completion for each cursor, as that would be too - # cluttered, and no way to easily choose different - # completions for each cursor. #TODO maybe do completion on - # primary cursor and insert to all cursors? - return - else: # TODO test this & use to override shortcut methods? + """Wrap callable to only execute if there is a single cursor""" + @wraps(method) + def wrapper(*args, **kwargs): + if not self.extra_cursors: method(*args, **kwargs) + return wrapper # ---- Hover/Hints # ------------------------------------------------------------------------- @@ -878,9 +887,9 @@ def cursor_move_event(): def register_shortcuts(self): """Register shortcuts for this widget.""" shortcuts = ( - ('code completion', self.do_completion), - ('duplicate line down', self.duplicate_line_down), - ('duplicate line up', self.duplicate_line_up), + ('code completion', self.restrict_single_cursor(self.do_completion)), + ('duplicate line down', self.for_each_cursor(self.duplicate_line_down)), + ('duplicate line up', self.for_each_cursor(self.duplicate_line_up)), ('delete line', self.delete_line), ('move line up', self.move_line_up), ('move line down', self.move_line_down), @@ -1592,29 +1601,32 @@ def delete(self): for cursor in self.all_cursors: cursor.deleteChar() cursor.endEditBlock() + self.merge_extra_cursors(True) self.sig_delete_requested.emit() def delete_line(self): """Delete current line.""" - cursor = self.textCursor() - - if self.has_selected_text(): - self.extend_selection_to_complete_lines() - start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd() - cursor.setPosition(start_pos) - else: - start_pos = end_pos = cursor.position() - - cursor.setPosition(start_pos) - cursor.movePosition(QTextCursor.StartOfBlock) - while cursor.position() <= end_pos: - cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) - if cursor.atEnd(): - break - cursor.movePosition(QTextCursor.NextBlock, QTextCursor.KeepAnchor) - - self.setTextCursor(cursor) - self.delete() + self.textCursor().beginEditBlock() + for cursor in self.all_cursors: + start = cursor.selectionStart() + end = cursor.selectionEnd() + cursor.setPosition(start, + QTextCursor.MoveMode.MoveAnchor) + cursor.movePosition(QTextCursor.MoveOperation.StartOfBlock, + QTextCursor.MoveMode.MoveAnchor) + cursor.setPosition(end, + QTextCursor.MoveMode.KeepAnchor) + cursor.movePosition(QTextCursor.MoveOperation.EndOfBlock, + QTextCursor.MoveMode.KeepAnchor) + if not cursor.atEnd(): + cursor.movePosition(QTextCursor.MoveOperation.NextBlock, + QTextCursor.MoveMode.KeepAnchor) + self.setTextCursor(cursor) + for cursor in self.all_cursors: + cursor.removeSelectedText() + self.setTextCursor(cursor) + self.textCursor().endEditBlock() + self.merge_extra_cursors(True) self.ensureCursorVisible() # ---- Scrolling From 1c5685da36f5f2d5bc06b4f58ac1ce3f8fc721cf Mon Sep 17 00:00:00 2001 From: athompson673 Date: Sat, 9 Nov 2024 19:05:56 -0500 Subject: [PATCH 18/95] better cursor edit handling in for_each_cursor wrapper. Implements goto new line, and clears extra_cursors on goto_definition --- .../editor/widgets/codeeditor/codeeditor.py | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index ef7dec635cc..6ebbe41c3f4 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -567,7 +567,7 @@ def merge_extra_cursors(self, increasing_position): cursors = self.all_cursors main_cursor = self.all_cursors[-1] - cursors.sort(key= lambda cursor: cursor.position()) + cursors.sort(key=lambda cursor: cursor.position()) for i, cursor1 in enumerate(cursors[:-1]): if cursor_was_removed: @@ -607,15 +607,14 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): if self.extra_cursors: event.accept() key = event.key() - text = event.text() # TODO needed for any key handling? shift = event.modifiers() & Qt.ShiftModifier ctrl = event.modifiers() & Qt.ControlModifier - move_mode = QTextCursor.KeepAnchor if shift else QTextCursor.MoveAnchor + if shift: + move_mode = QTextCursor.KeepAnchor + else: + move_mode = QTextCursor.MoveAnchor # Will cursors have increased or decreased in position? - increasing_direction = True - # Some operations should only be 1 per row even if there's multiple - # cursors on that row (smart indent/unindent?) - handled_rows = [] # TODO needed for any key handling? + increasing_direction = True # used to merge multi-selections self.textCursor().beginEditBlock() for cursor in self.all_cursors: @@ -774,10 +773,18 @@ def for_each_cursor(self, method): @wraps(method) def wrapper(*args, **kwargs): self.textCursor().beginEditBlock() + new_cursors = [] for cursor in self.all_cursors: self.setTextCursor(cursor) method(*args, **kwargs) - # selections will be deleted so merge direction does not matter + new_cursors.append(self.textCursor()) + + # clear and re-add extra cursors to account for method operating + # on a copy of the cursor + self.clear_extra_cursors() + self.setTextCursor(new_cursors[-1]) + for cursor in new_cursors[:-1]: + self.add_cursor(cursor) self.merge_extra_cursors(True) self.textCursor().endEditBlock() return wrapper @@ -893,8 +900,8 @@ def register_shortcuts(self): ('delete line', self.delete_line), ('move line up', self.move_line_up), ('move line down', self.move_line_down), - ('go to new line', self.go_to_new_line), - ('go to definition', self.go_to_definition_from_cursor), + ('go to new line', self.for_each_cursor(self.go_to_new_line)), + ('go to definition', self.clears_extra_cursors(self.go_to_definition_from_cursor)), ('toggle comment', self.toggle_comment), ('blockcomment', self.blockcomment), ('create_new_cell', self.create_new_cell), @@ -3704,7 +3711,7 @@ def setup_context_menu(self): text=_('Go to definition'), register_shortcut=True, register_action=False, - triggered=self.go_to_definition_from_cursor + triggered=self.clears_extra_cursors(self.go_to_definition_from_cursor) ) self.inspect_current_object_action = self.create_action( CodeEditorActions.InspectCurrentObject, From df71b7b3b0467a88fb4afc157303e0f5aac1a796 Mon Sep 17 00:00:00 2001 From: athompson673 Date: Sat, 9 Nov 2024 20:52:02 -0500 Subject: [PATCH 19/95] revert cursor_move_event in favor of wrapper function: for_each_cursor. many TODO comments --- .../editor/widgets/codeeditor/codeeditor.py | 89 +++++++++---------- 1 file changed, 43 insertions(+), 46 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 6ebbe41c3f4..aa6df875bc8 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -16,6 +16,7 @@ # pylint: disable=R0911 # pylint: disable=R0201 + # Standard library imports from unicodedata import category import logging @@ -776,11 +777,12 @@ def wrapper(*args, **kwargs): new_cursors = [] for cursor in self.all_cursors: self.setTextCursor(cursor) + # may call setTtextCursor with modified copy method(*args, **kwargs) + # get modified cursor to re-add to extra_cursors new_cursors.append(self.textCursor()) - # clear and re-add extra cursors to account for method operating - # on a copy of the cursor + # re-add extra cursors self.clear_extra_cursors() self.setTextCursor(new_cursors[-1]) for cursor in new_cursors[:-1]: @@ -885,10 +887,10 @@ def outlineexplorer_data_list(self): def create_cursor_callback(self, attr): """Make a callback for cursor move event type, (e.g. "Start")""" def cursor_move_event(): + cursor = self.textCursor() move_type = getattr(QTextCursor, attr) - for cursor in self.all_cursors: - cursor.movePosition(move_type) - self.setTextCursor(cursor) + cursor.movePosition(move_type) + self.setTextCursor(cursor) return cursor_move_event def register_shortcuts(self): @@ -898,47 +900,47 @@ def register_shortcuts(self): ('duplicate line down', self.for_each_cursor(self.duplicate_line_down)), ('duplicate line up', self.for_each_cursor(self.duplicate_line_up)), ('delete line', self.delete_line), - ('move line up', self.move_line_up), - ('move line down', self.move_line_down), + ('move line up', self.move_line_up), # TODO multi-cursor + ('move line down', self.move_line_down), # TODO multi-cursor ('go to new line', self.for_each_cursor(self.go_to_new_line)), ('go to definition', self.clears_extra_cursors(self.go_to_definition_from_cursor)), - ('toggle comment', self.toggle_comment), - ('blockcomment', self.blockcomment), - ('create_new_cell', self.create_new_cell), - ('unblockcomment', self.unblockcomment), - ('transform to uppercase', self.transform_to_uppercase), - ('transform to lowercase', self.transform_to_lowercase), - ('indent', lambda: self.indent(force=True)), - ('unindent', lambda: self.unindent(force=True)), - ('start of line', self.create_cursor_callback('StartOfLine')), - ('end of line', self.create_cursor_callback('EndOfLine')), - ('previous line', self.create_cursor_callback('Up')), - ('next line', self.create_cursor_callback('Down')), - ('previous char', self.create_cursor_callback('Left')), - ('next char', self.create_cursor_callback('Right')), - ('previous word', self.create_cursor_callback('PreviousWord')), - ('next word', self.create_cursor_callback('NextWord')), - ('kill to line end', self.kill_line_end), - ('kill to line start', self.kill_line_start), - ('yank', self._kill_ring.yank), - ('rotate kill ring', self._kill_ring.rotate), - ('kill previous word', self.kill_prev_word), - ('kill next word', self.kill_next_word), - ('start of document', self.create_cursor_callback('Start')), - ('end of document', self.create_cursor_callback('End')), - ('undo', self.undo), - ('redo', self.redo), + ('toggle comment', self.toggle_comment), # TODO multi-cursor + ('blockcomment', self.blockcomment), # TODO multi-cursor + ('create_new_cell', self.for_each_cursor(self.create_new_cell)), + ('unblockcomment', self.unblockcomment), # TODO multi-cursor + ('transform to uppercase', self.transform_to_uppercase), # TODO multi-cursor + ('transform to lowercase', self.transform_to_lowercase), # TODO multi-cursor + ('indent', self.for_each_cursor(lambda: self.indent(force=True))), + ('unindent', self.for_each_cursor(lambda: self.unindent(force=True))), + ('start of line', self.for_each_cursor(self.create_cursor_callback('StartOfLine'))), + ('end of line', self.for_each_cursor(self.create_cursor_callback('EndOfLine'))), + ('previous line', self.for_each_cursor(self.create_cursor_callback('Up'))), + ('next line', self.for_each_cursor(self.create_cursor_callback('Down'))), + ('previous char', self.for_each_cursor(self.create_cursor_callback('Left'))), + ('next char', self.for_each_cursor(self.create_cursor_callback('Right'))), + ('previous word', self.for_each_cursor(self.create_cursor_callback('PreviousWord'))), + ('next word', self.for_each_cursor(self.create_cursor_callback('NextWord'))), + ('kill to line end', self.kill_line_end), # TODO multi-cursor + ('kill to line start', self.kill_line_start), # TODO multi-cursor + ('yank', self._kill_ring.yank), # TODO multi-cursor + ('rotate kill ring', self._kill_ring.rotate), # TODO multi-cursor + ('kill previous word', self.kill_prev_word), # TODO multi-cursor + ('kill next word', self.kill_next_word), # TODO multi-cursor + ('start of document', self.for_each_cursor(self.create_cursor_callback('Start'))), + ('end of document', self.for_each_cursor(self.create_cursor_callback('End'))), + ('undo', self.undo), # TODO multi-cursor (cursor positions) + ('redo', self.redo), # TODO multi-cursor (cursor positions) ('cut', self.cut), ('copy', self.copy), ('paste', self.paste), ('delete', self.delete), - ('select all', self.selectAll), - ('docstring', self.writer_docstring.write_docstring_for_shortcut), - ('autoformatting', self.format_document_or_range), - ('scroll line down', self.scroll_line_down), - ('scroll line up', self.scroll_line_up), - ('enter array inline', self.enter_array_inline), - ('enter array table', self.enter_array_table), + ('select all', self.clears_extra_cursors(self.selectAll)), + ('docstring', self.writer_docstring.write_docstring_for_shortcut), # TODO multi-cursor + ('autoformatting', self.format_document_or_range), # TODO multi-cursor + ('scroll line down', self.scroll_line_down), # TODO multi-cursor? + ('scroll line up', self.scroll_line_up), # TODO multi-cursor? + ('enter array inline', self.enter_array_inline), # TODO multi-cursor + ('enter array table', self.enter_array_table), # TODO multi-cursor ) for name, callback in shortcuts: @@ -2294,12 +2296,6 @@ def redo(self): self.is_redoing = False self.skip_rstrip = False - @Slot() - def selectAll(self): - """overrides Qt selectAll method to ensure we clear extra cursors""" - self.clear_extra_cursors() - super().selectAll() - # ---- High-level editor features # ------------------------------------------------------------------------- @Slot() @@ -2322,6 +2318,7 @@ def exec_gotolinedialog(self): """Execute the GoToLineDialog dialog box""" dlg = GoToLineDialog(self) if dlg.exec_(): + self.clear_extra_cursors() self.go_to_line(dlg.get_line_number()) def hide_tooltip(self): From bee76c3ba8b4d0f8a6e2b5460e00a63f3ac8cf4d Mon Sep 17 00:00:00 2001 From: athompson673 Date: Sat, 9 Nov 2024 21:54:22 -0500 Subject: [PATCH 20/95] re-work callback wrappers to prevent QAction from sending extraneous bool, and added #TODO notes --- .../editor/widgets/codeeditor/codeeditor.py | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index aa6df875bc8..1cd25c20785 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -772,13 +772,13 @@ def multi_cursor_paste(self, clip_text): def for_each_cursor(self, method): """Wrap callable to execute once for each cursor""" @wraps(method) - def wrapper(*args, **kwargs): + def wrapper(): self.textCursor().beginEditBlock() new_cursors = [] for cursor in self.all_cursors: self.setTextCursor(cursor) # may call setTtextCursor with modified copy - method(*args, **kwargs) + method() # get modified cursor to re-add to extra_cursors new_cursors.append(self.textCursor()) @@ -794,17 +794,17 @@ def wrapper(*args, **kwargs): def clears_extra_cursors(self, method): """Wrap callable to clear extra_cursors prior to calling""" @wraps(method) - def wrapper(*args, **kwargs): + def wrapper(): self.clear_extra_cursors() - method(*args, **kwargs) + method() return wrapper def restrict_single_cursor(self, method): """Wrap callable to only execute if there is a single cursor""" @wraps(method) - def wrapper(*args, **kwargs): + def wrapper(): if not self.extra_cursors: - method(*args, **kwargs) + method() return wrapper # ---- Hover/Hints @@ -3639,7 +3639,7 @@ def setup_context_menu(self): icon=self.create_icon('undo'), register_shortcut=True, register_action=False, - triggered=self.undo, + triggered=self.undo, # TODO multi-cursor position history ) self.redo_action = self.create_action( CodeEditorActions.Redo, @@ -3647,7 +3647,7 @@ def setup_context_menu(self): icon=self.create_icon('redo'), register_shortcut=True, register_action=False, - triggered=self.redo + triggered=self.redo # TODO multi-cursor position history ) self.cut_action = self.create_action( CodeEditorActions.Cut, @@ -3679,7 +3679,7 @@ def setup_context_menu(self): icon=self.create_icon('selectall'), register_shortcut=True, register_action=False, - triggered=self.selectAll + triggered=self.clears_extra_cursors(self.selectAll) ) toggle_comment_action = self.create_action( CodeEditorActions.ToggleComment, @@ -3694,21 +3694,21 @@ def setup_context_menu(self): text=_('Clear all ouput'), icon=self.create_icon('ipython_console'), register_action=False, - triggered=self.clear_all_output + triggered=self.clear_all_output # TODO multi-cursor how to consider? ) self.ipynb_convert_action = self.create_action( CodeEditorActions.ConvertToPython, text=_('Convert to Python file'), icon=self.create_icon('python'), register_action=False, - triggered=self.convert_notebook + triggered=self.convert_notebook # TODO multi-cursor how to consider? ) self.gotodef_action = self.create_action( CodeEditorActions.GoToDefinition, text=_('Go to definition'), register_shortcut=True, register_action=False, - triggered=self.clears_extra_cursors(self.go_to_definition_from_cursor) + triggered=self.clears_extra_cursors(self.go_to_definition_from_cursor) # BUG: causes bool to be passed to go_to_definition_from_cursor somehow ) self.inspect_current_object_action = self.create_action( CodeEditorActions.InspectCurrentObject, @@ -4702,7 +4702,8 @@ def mousePressEvent(self, event): self.setTextCursor(self.cursorForPosition(pos)) self.add_cursor(old_cursor) else: - self.clear_extra_cursors() + if event.button() == Qt.MouseButton.LeftButton: + self.clear_extra_cursors() if event.button() == Qt.LeftButton and ctrl: TextEditBaseWidget.mousePressEvent(self, event) cursor = self.cursorForPosition(pos) From 6b347971adc6ff10107fcbe57e6c52540594f370 Mon Sep 17 00:00:00 2001 From: athompson673 Date: Sat, 9 Nov 2024 22:17:40 -0500 Subject: [PATCH 21/95] style changes and #TODO comments --- .../editor/widgets/codeeditor/codeeditor.py | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 1cd25c20785..c88f21194bc 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -522,10 +522,10 @@ def init_multi_cursor(self): self.extra_cursors = [] self.cursor_blink_state = False self.cursor_blink_timer = QTimer(self) - self.cursor_blink_timer.setInterval(QApplication.cursorFlashTime()//2) + self.cursor_blink_timer.setInterval(QApplication.cursorFlashTime() // 2) self.cursor_blink_timer.timeout.connect( self._on_cursor_blinktimer_timeout - ) + ) self.focus_in.connect(self.start_cursor_blink) self.focus_changed.connect(self.stop_cursor_blink) self.painted.connect(self.paint_cursors) @@ -573,7 +573,7 @@ def merge_extra_cursors(self, increasing_position): for i, cursor1 in enumerate(cursors[:-1]): if cursor_was_removed: break # list will be modified, so re-start at while loop - for cursor2 in cursors[i+1:]: + for cursor2 in cursors[i + 1:]: # given cursors.sort, pos1 should be <= pos2 pos1 = cursor1.position() pos2 = cursor2.position() @@ -632,7 +632,7 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): if ctrl: cursor.movePosition( QTextCursor.PreviousWord, move_mode - ) + ) else: cursor.movePosition(QTextCursor.Left, move_mode) elif key == Qt.Key_Right: @@ -691,11 +691,12 @@ def paint_cursors(self, event): qp = QPainter() qp.begin(self.viewport()) offset = self.contentOffset() - offset_y = offset.y() + content_offset_y = offset.y() qp.setBrushOrigin(offset) editable = not self.isReadOnly() flags = (self.textInteractionFlags() & Qt.TextInteractionFlag.TextSelectableByKeyboard) + draw_cursor = self.cursor_blink_state and (editable or flags) if self.overwrite_mode: font = self.textCursor().block().charFormat().font() cursor_width = QFontMetrics(font).horizontalAdvance(" ") @@ -703,12 +704,10 @@ def paint_cursors(self, event): cursor_width = self.cursor_width for cursor in self.all_cursors: block = cursor.block() - if (self.cursor_blink_state and - (editable or flags) and - block.isVisible()): + if draw_cursor and block.isVisible(): # TODO don't bother with preeditArea? - offset.setY(int(self.blockBoundingGeometry(block).top() + - offset_y)) + block_geometry_top = int(self.blockBoundingGeometry(block).top()) + offset.setY(block_geometry_top + content_offset_y) block.layout().drawCursor(qp, offset, cursor.positionInBlock(), cursor_width) @@ -3708,7 +3707,7 @@ def setup_context_menu(self): text=_('Go to definition'), register_shortcut=True, register_action=False, - triggered=self.clears_extra_cursors(self.go_to_definition_from_cursor) # BUG: causes bool to be passed to go_to_definition_from_cursor somehow + triggered=self.clears_extra_cursors(self.go_to_definition_from_cursor) ) self.inspect_current_object_action = self.create_action( CodeEditorActions.InspectCurrentObject, @@ -3716,7 +3715,7 @@ def setup_context_menu(self): icon=self.create_icon('MessageBoxInformation'), register_shortcut=True, register_action=False, - triggered=self.sig_show_object_info + triggered=self.sig_show_object_info # TODO multi-cursor how to consider? ) # Run actions @@ -3768,7 +3767,7 @@ def setup_context_menu(self): text=_('Generate docstring'), register_shortcut=True, register_action=False, - triggered=writer.write_docstring_at_first_line_of_function + triggered=writer.write_docstring_at_first_line_of_function # TODO multi-cursor how to consider? ) # Document formatting @@ -3784,7 +3783,7 @@ def setup_context_menu(self): icon=self.create_icon("transparent"), register_shortcut=True, register_action=False, - triggered=self.format_document_or_range + triggered=self.format_document_or_range # TODO multi-cursor how to consider? ) self.format_action.setEnabled(False) From 6bb721fca0d067290b055d06240999bf63b50b6a Mon Sep 17 00:00:00 2001 From: athompson673 Date: Sat, 9 Nov 2024 23:47:01 -0500 Subject: [PATCH 22/95] docstrings, and unneeded func call cleanup --- spyder/plugins/editor/widgets/codeeditor/codeeditor.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index c88f21194bc..d8fb25b6d47 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -16,7 +16,6 @@ # pylint: disable=R0911 # pylint: disable=R0201 - # Standard library imports from unicodedata import category import logging @@ -535,7 +534,6 @@ def add_cursor(self, cursor: QTextCursor): """Add this cursor to the list of extra cursors""" self.extra_cursors.append(cursor) self.merge_extra_cursors(True) - self.set_extra_cursor_selections() def set_extra_cursor_selections(self): selections = [] @@ -556,6 +554,7 @@ def clear_extra_cursors(self): @property def all_cursors(self): + """Return list of all extra_cursors (if any) plus the primary cursor""" return self.extra_cursors + [self.textCursor()] def merge_extra_cursors(self, increasing_position): @@ -567,7 +566,7 @@ def merge_extra_cursors(self, increasing_position): cursor_was_removed = False cursors = self.all_cursors - main_cursor = self.all_cursors[-1] + main_cursor = cursors[-1] cursors.sort(key=lambda cursor: cursor.position()) for i, cursor1 in enumerate(cursors[:-1]): @@ -605,6 +604,7 @@ def merge_extra_cursors(self, increasing_position): @Slot(QKeyEvent) def handle_multi_cursor_keypress(self, event: QKeyEvent): + """Re-Implement keyEvent handler for multi-cursor""" if self.extra_cursors: event.accept() key = event.key() @@ -673,7 +673,6 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): else: self._handle_keypress_event(event) self.merge_extra_cursors(increasing_direction) - self.set_extra_cursor_selections() cursor.endEditBlock() self.setTextCursor(cursor) # last cursor from for loop is primary @@ -746,7 +745,6 @@ def multi_cursor_cut(self): cursor.removeSelectedText() # merge direction doesn't matter here as all selections are removed self.merge_extra_cursors(True) - self.set_extra_cursor_selections() self.textCursor().endEditBlock() def multi_cursor_paste(self, clip_text): @@ -763,7 +761,6 @@ def multi_cursor_paste(self, clip_text): self.setTextCursor(main_cursor) # merge direction doesn't matter here as all selections are removed self.merge_extra_cursors(True) - self.set_extra_cursor_selections() main_cursor.endEditBlock() self.sig_text_was_inserted.emit() self.skip_rstrip = False From cef93374d51f3eda0c236510d4eedb763fc729db Mon Sep 17 00:00:00 2001 From: athompson673 Date: Sun, 10 Nov 2024 00:44:29 -0500 Subject: [PATCH 23/95] small refactoring --- .../plugins/editor/widgets/codeeditor/codeeditor.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index d8fb25b6d47..bf314636eee 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -704,7 +704,6 @@ def paint_cursors(self, event): for cursor in self.all_cursors: block = cursor.block() if draw_cursor and block.isVisible(): - # TODO don't bother with preeditArea? block_geometry_top = int(self.blockBoundingGeometry(block).top()) offset.setY(block_geometry_top + content_offset_y) block.layout().drawCursor(qp, offset, @@ -734,7 +733,7 @@ def multi_cursor_copy(self): for cursor in cursors: text = cursor.selectedText().replace(u"\u2029", self.get_line_separator()) - selections.append(text) # TODO skip empty selections? + selections.append(text) clip_text = self.get_line_separator().join(selections) QApplication.clipboard().setText(clip_text) @@ -757,7 +756,7 @@ def multi_cursor_paste(self, clip_text): for cursor, text in zip(cursors, clip_text.splitlines()): self.setTextCursor(cursor) cursor.insertText(text) - # TODO handle extra lines or extra cursors? + # handle extra lines or extra cursors? self.setTextCursor(main_cursor) # merge direction doesn't matter here as all selections are removed self.merge_extra_cursors(True) @@ -2713,8 +2712,7 @@ def remove_prefix(self, prefix): cursor = self.textCursor() if self.has_selected_text(): # Remove prefix from selected line(s) - start_pos, end_pos = sorted([cursor.selectionStart(), - cursor.selectionEnd()]) + start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd() cursor.setPosition(start_pos) if not cursor.atBlockStart(): cursor.movePosition(QTextCursor.StartOfBlock) @@ -3168,8 +3166,7 @@ def unindent(self, force=False): def toggle_comment(self): """Toggle comment on current line or selection""" cursor = self.textCursor() - start_pos, end_pos = sorted([cursor.selectionStart(), - cursor.selectionEnd()]) + start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd() cursor.setPosition(end_pos) last_line = cursor.block().blockNumber() if cursor.atBlockStart() and start_pos != end_pos: From 3f35f0afcf1c29dd884b09a5e28347ae8ddb7e39 Mon Sep 17 00:00:00 2001 From: athompson673 Date: Mon, 11 Nov 2024 10:05:27 -0500 Subject: [PATCH 24/95] fix deletion of folded lines (delete and delete_line) --- .../editor/widgets/codeeditor/codeeditor.py | 56 +++++++++++-------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index bf314636eee..bc8c73cb6aa 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -1601,36 +1601,44 @@ def next_cursor_position(self, position=None, @Slot() def delete(self): """Remove selected text or next character.""" + self.textCursor().beginEditBlock() - for cursor in self.all_cursors: - cursor.deleteChar() - cursor.endEditBlock() - self.merge_extra_cursors(True) self.sig_delete_requested.emit() - - def delete_line(self): - """Delete current line.""" - self.textCursor().beginEditBlock() for cursor in self.all_cursors: - start = cursor.selectionStart() - end = cursor.selectionEnd() - cursor.setPosition(start, - QTextCursor.MoveMode.MoveAnchor) - cursor.movePosition(QTextCursor.MoveOperation.StartOfBlock, - QTextCursor.MoveMode.MoveAnchor) - cursor.setPosition(end, - QTextCursor.MoveMode.KeepAnchor) - cursor.movePosition(QTextCursor.MoveOperation.EndOfBlock, - QTextCursor.MoveMode.KeepAnchor) - if not cursor.atEnd(): - cursor.movePosition(QTextCursor.MoveOperation.NextBlock, - QTextCursor.MoveMode.KeepAnchor) self.setTextCursor(cursor) - for cursor in self.all_cursors: - cursor.removeSelectedText() + if not self.has_selected_text(): + if not cursor.atEnd(): + cursor.setPosition( + self.next_cursor_position(), QTextCursor.KeepAnchor + ) + self.setTextCursor(cursor) + + self.remove_selected_text() self.setTextCursor(cursor) self.textCursor().endEditBlock() - self.merge_extra_cursors(True) + + def delete_line(self): + """Delete current line.""" + cursor = self.textCursor() + for cursor in self.all_cursors: + + if self.has_selected_text(): + self.extend_selection_to_complete_lines() + start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd() + cursor.setPosition(start_pos) + else: + start_pos = end_pos = cursor.position() + + cursor.setPosition(start_pos) + cursor.movePosition(QTextCursor.StartOfBlock) + while cursor.position() <= end_pos: + cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) + if cursor.atEnd(): + break + cursor.movePosition(QTextCursor.NextBlock, QTextCursor.KeepAnchor) + + self.setTextCursor(cursor) + self.delete() self.ensureCursorVisible() # ---- Scrolling From 528672b099450b63ec4ec49b67f7a781ecc2d59b Mon Sep 17 00:00:00 2001 From: athompson673 Date: Mon, 11 Nov 2024 12:52:47 -0500 Subject: [PATCH 25/95] qt6 enum naming, and attempt to get cursor to not enter hidden blocks --- .../editor/widgets/codeeditor/codeeditor.py | 47 +++++++++++++------ 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index bc8c73cb6aa..2a79ba8d3d7 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -25,7 +25,7 @@ import sre_constants import sys import textwrap -from functools import wraps +import functools # Third party imports from IPython.core.inputtransformer2 import TransformerManager @@ -611,9 +611,9 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): shift = event.modifiers() & Qt.ShiftModifier ctrl = event.modifiers() & Qt.ControlModifier if shift: - move_mode = QTextCursor.KeepAnchor + move_mode = QTextCursor.MoveMode.KeepAnchor else: - move_mode = QTextCursor.MoveAnchor + move_mode = QTextCursor.MoveMode.MoveAnchor # Will cursors have increased or decreased in position? increasing_direction = True # used to merge multi-selections @@ -623,28 +623,40 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): self.setTextCursor(cursor) # ---- handle arrow keys if key == Qt.Key_Up: - cursor.movePosition(QTextCursor.Up, move_mode) + cursor.movePosition( + QTextCursor.MoveOperation.Up, move_mode + ) increasing_direction = False elif key == Qt.Key_Down: - cursor.movePosition(QTextCursor.Down, move_mode) + cursor.movePosition( + QTextCursor.MoveOperation.Down, move_mode + ) elif key == Qt.Key_Left: increasing_direction = False if ctrl: cursor.movePosition( - QTextCursor.PreviousWord, move_mode + QTextCursor.MoveOperation.PreviousWord, move_mode ) else: - cursor.movePosition(QTextCursor.Left, move_mode) + cursor.movePosition( + QTextCursor.MoveOperation.Left, move_mode + ) elif key == Qt.Key_Right: if ctrl: - cursor.movePosition(QTextCursor.NextWord, move_mode) + cursor.movePosition( + QTextCursor.MoveOperation.NextWord, move_mode + ) else: - cursor.movePosition(QTextCursor.Right, move_mode) + cursor.movePosition( + QTextCursor.MoveOperation.Right, move_mode + ) # ---- handle Home, End elif key == Qt.Key_Home: increasing_direction = False if ctrl: - cursor.movePosition(QTextCursor.Start, move_mode) + cursor.movePosition( + QTextCursor.MoveOperation.Start, move_mode + ) else: block_pos = cursor.block().position() line = cursor.block().text() @@ -655,9 +667,13 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): cursor.setPosition(block_pos, move_mode) elif key == Qt.Key_End: if ctrl: - cursor.movePosition(QTextCursor.End, move_mode) + cursor.movePosition( + QTextCursor.MoveOperation.End, move_mode + ) else: - cursor.movePosition(QTextCursor.EndOfBlock, move_mode) + cursor.movePosition( + QTextCursor.MoveOperation.EndOfBlock, move_mode + ) # ---- handle Tab elif key == Qt.Key_Tab and not ctrl: # ctrl-tab is shortcut # Don't do intelligent tab with multi-cursor to skip @@ -672,6 +688,7 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): # ---- use default handler for cursor (text) else: self._handle_keypress_event(event) + self.setTextCursor(cursor) self.merge_extra_cursors(increasing_direction) cursor.endEditBlock() self.setTextCursor(cursor) # last cursor from for loop is primary @@ -766,7 +783,7 @@ def multi_cursor_paste(self, clip_text): def for_each_cursor(self, method): """Wrap callable to execute once for each cursor""" - @wraps(method) + @functools.wraps(method) def wrapper(): self.textCursor().beginEditBlock() new_cursors = [] @@ -788,7 +805,7 @@ def wrapper(): def clears_extra_cursors(self, method): """Wrap callable to clear extra_cursors prior to calling""" - @wraps(method) + @functools.wraps(method) def wrapper(): self.clear_extra_cursors() method() @@ -796,7 +813,7 @@ def wrapper(): def restrict_single_cursor(self, method): """Wrap callable to only execute if there is a single cursor""" - @wraps(method) + @functools.wraps(method) def wrapper(): if not self.extra_cursors: method() From d76d32aa73ea944c18be744c87c1bd137c764b96 Mon Sep 17 00:00:00 2001 From: Aaron Date: Wed, 13 Nov 2024 12:13:20 -0500 Subject: [PATCH 26/95] edit docstrings and todo notes --- .../editor/widgets/codeeditor/codeeditor.py | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 2a79ba8d3d7..2672daa7ae8 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -743,7 +743,10 @@ def stop_cursor_blink(self): self.viewport().update() def multi_cursor_copy(self): - """copy multi-cursor selections separated by newlines""" + """ + Join all cursor selections in position sorted order by line_separator, + and put text to clipboard. + """ cursors = self.all_cursors cursors.sort(key=lambda cursor: cursor.position()) selections = [] @@ -755,6 +758,7 @@ def multi_cursor_copy(self): QApplication.clipboard().setText(clip_text) def multi_cursor_cut(self): + """Multi-cursor copy then removeSelectedText""" self.multi_cursor_copy() self.textCursor().beginEditBlock() for cursor in self.all_cursors: @@ -764,6 +768,10 @@ def multi_cursor_cut(self): self.textCursor().endEditBlock() def multi_cursor_paste(self, clip_text): + """ + Split clipboard by lines, and paste one line per cursor in position + sorted order. + """ main_cursor = self.textCursor() main_cursor.beginEditBlock() cursors = self.all_cursors @@ -916,12 +924,12 @@ def register_shortcuts(self): ('move line down', self.move_line_down), # TODO multi-cursor ('go to new line', self.for_each_cursor(self.go_to_new_line)), ('go to definition', self.clears_extra_cursors(self.go_to_definition_from_cursor)), - ('toggle comment', self.toggle_comment), # TODO multi-cursor + ('toggle comment', self.for_each_cursor(self.toggle_comment)), ('blockcomment', self.blockcomment), # TODO multi-cursor ('create_new_cell', self.for_each_cursor(self.create_new_cell)), ('unblockcomment', self.unblockcomment), # TODO multi-cursor - ('transform to uppercase', self.transform_to_uppercase), # TODO multi-cursor - ('transform to lowercase', self.transform_to_lowercase), # TODO multi-cursor + ('transform to uppercase', self.for_each_cursor(self.transform_to_uppercase)), + ('transform to lowercase', self.for_each_cursor(self.transform_to_lowercase)), ('indent', self.for_each_cursor(lambda: self.indent(force=True))), ('unindent', self.for_each_cursor(lambda: self.unindent(force=True))), ('start of line', self.for_each_cursor(self.create_cursor_callback('StartOfLine'))), @@ -938,8 +946,8 @@ def register_shortcuts(self): ('rotate kill ring', self._kill_ring.rotate), # TODO multi-cursor ('kill previous word', self.kill_prev_word), # TODO multi-cursor ('kill next word', self.kill_next_word), # TODO multi-cursor - ('start of document', self.for_each_cursor(self.create_cursor_callback('Start'))), - ('end of document', self.for_each_cursor(self.create_cursor_callback('End'))), + ('start of document', self.clears_extra_cursors(self.create_cursor_callback('Start'))), + ('end of document', self.clears_extra_cursors(self.create_cursor_callback('End'))), ('undo', self.undo), # TODO multi-cursor (cursor positions) ('redo', self.redo), # TODO multi-cursor (cursor positions) ('cut', self.cut), @@ -949,10 +957,10 @@ def register_shortcuts(self): ('select all', self.clears_extra_cursors(self.selectAll)), ('docstring', self.writer_docstring.write_docstring_for_shortcut), # TODO multi-cursor ('autoformatting', self.format_document_or_range), # TODO multi-cursor - ('scroll line down', self.scroll_line_down), # TODO multi-cursor? - ('scroll line up', self.scroll_line_up), # TODO multi-cursor? - ('enter array inline', self.enter_array_inline), # TODO multi-cursor - ('enter array table', self.enter_array_table), # TODO multi-cursor + ('scroll line down', self.scroll_line_down), + ('scroll line up', self.scroll_line_up), + ('enter array inline', self.clears_extra_cursors(self.enter_array_inline)), + ('enter array table', self.clears_extra_cursors(self.enter_array_table)), ) for name, callback in shortcuts: @@ -1618,7 +1626,7 @@ def next_cursor_position(self, position=None, @Slot() def delete(self): """Remove selected text or next character.""" - + self.textCursor().beginEditBlock() self.sig_delete_requested.emit() for cursor in self.all_cursors: @@ -1629,7 +1637,7 @@ def delete(self): self.next_cursor_position(), QTextCursor.KeepAnchor ) self.setTextCursor(cursor) - + self.remove_selected_text() self.setTextCursor(cursor) self.textCursor().endEditBlock() @@ -3734,7 +3742,7 @@ def setup_context_menu(self): icon=self.create_icon('MessageBoxInformation'), register_shortcut=True, register_action=False, - triggered=self.sig_show_object_info # TODO multi-cursor how to consider? + triggered=self.sig_show_object_info # only consider main cursor; don't clear extra cursors ) # Run actions From 6e949766c484a08e0bfce2476fb78082f2dc17ee Mon Sep 17 00:00:00 2001 From: Aaron Date: Wed, 13 Nov 2024 12:31:22 -0500 Subject: [PATCH 27/95] re-implement methods for next/prev cell --- .../editor/widgets/codeeditor/codeeditor.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 2672daa7ae8..534c364b453 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -826,6 +826,22 @@ def wrapper(): if not self.extra_cursors: method() return wrapper + + def go_to_next_cell(self): # TODO test this + """ + reimplement to clear extra cursors before calling + TextEditBaseWidget.go_to_next_cell + """ + self.clear_extra_cursors() + super().go_to_next_cell() + + def go_to_previous_cell(self): # TODO test this + """ + reimplement to clear extra cursors before calling + TextEditBaseWidget.go_to_previous_cell + """ + self.clear_extra_cursors() + super().go_to_previous_cell() # ---- Hover/Hints # ------------------------------------------------------------------------- From 128f306f3c58c566cebc0a226747578dd613f25b Mon Sep 17 00:00:00 2001 From: Aaron Date: Wed, 13 Nov 2024 13:46:22 -0500 Subject: [PATCH 28/95] move cursor clearing for several functions to go_to_line --- .../editor/widgets/codeeditor/codeeditor.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 534c364b453..3e4e972c5dd 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -826,7 +826,7 @@ def wrapper(): if not self.extra_cursors: method() return wrapper - + def go_to_next_cell(self): # TODO test this """ reimplement to clear extra cursors before calling @@ -834,7 +834,7 @@ def go_to_next_cell(self): # TODO test this """ self.clear_extra_cursors() super().go_to_next_cell() - + def go_to_previous_cell(self): # TODO test this """ reimplement to clear extra cursors before calling @@ -939,11 +939,11 @@ def register_shortcuts(self): ('move line up', self.move_line_up), # TODO multi-cursor ('move line down', self.move_line_down), # TODO multi-cursor ('go to new line', self.for_each_cursor(self.go_to_new_line)), - ('go to definition', self.clears_extra_cursors(self.go_to_definition_from_cursor)), + ('go to definition', self.go_to_definition_from_cursor), # clears extra cursors already handled by go_to_line ('toggle comment', self.for_each_cursor(self.toggle_comment)), - ('blockcomment', self.blockcomment), # TODO multi-cursor + ('blockcomment', self.for_each_cursor(self.blockcomment)), # TODO multi-cursor a bit wonky ('create_new_cell', self.for_each_cursor(self.create_new_cell)), - ('unblockcomment', self.unblockcomment), # TODO multi-cursor + ('unblockcomment', self.for_each_cursor(self.unblockcomment)), # TODO multi-cursor a bit wonky ('transform to uppercase', self.for_each_cursor(self.transform_to_uppercase)), ('transform to lowercase', self.for_each_cursor(self.transform_to_lowercase)), ('indent', self.for_each_cursor(lambda: self.indent(force=True))), @@ -2350,6 +2350,7 @@ def center_cursor_on_next_focus(self): def go_to_line(self, line, start_column=0, end_column=0, word=''): """Go to line number *line* and eventually highlight it""" + self.clear_extra_cursors() # handles go to warnings, todo, line number, definition, etc. self.text_helper.goto_line(line, column=start_column, end_column=end_column, move=True, word=word) @@ -2362,7 +2363,6 @@ def exec_gotolinedialog(self): """Execute the GoToLineDialog dialog box""" dlg = GoToLineDialog(self) if dlg.exec_(): - self.clear_extra_cursors() self.go_to_line(dlg.get_line_number()) def hide_tooltip(self): @@ -3750,7 +3750,7 @@ def setup_context_menu(self): text=_('Go to definition'), register_shortcut=True, register_action=False, - triggered=self.clears_extra_cursors(self.go_to_definition_from_cursor) + triggered=self.go_to_definition_from_cursor ) self.inspect_current_object_action = self.create_action( CodeEditorActions.InspectCurrentObject, From 7b7c1a442fb125a7b972d32a170076a198b08078 Mon Sep 17 00:00:00 2001 From: Aaron Date: Thu, 14 Nov 2024 12:40:26 -0500 Subject: [PATCH 29/95] simplify multi-cursor keypress handling and add merge direction to for_all_cursors wrapper --- .../editor/widgets/codeeditor/codeeditor.py | 126 +++++------------- 1 file changed, 31 insertions(+), 95 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 3e4e972c5dd..f9767644513 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -607,91 +607,28 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): """Re-Implement keyEvent handler for multi-cursor""" if self.extra_cursors: event.accept() + key = event.key() - shift = event.modifiers() & Qt.ShiftModifier ctrl = event.modifiers() & Qt.ControlModifier - if shift: - move_mode = QTextCursor.MoveMode.KeepAnchor + + # TODO handle other keys? move to keyPressEvent and emit (and + # handle) sig_key_pressed for each cursor + # ---- handle Tab + if key == Qt.Key_Tab and not ctrl: # ctrl-tab is shortcut + # Don't do intelligent tab with multi-cursor to skip + # calls to do_completion. Avoiding completions with multi + # cursor is much easier than solving all the edge cases. + + # Trivial implementation: # TODO respect tab_mode + self.for_each_cursor(lambda: self.replace(self.indent_chars))() + elif key == Qt.Key_Backtab and not ctrl: + increasing_direction = False + self.for_each_cursor(self.unindent,False)() + # ---- use default handler for cursor (text) else: - move_mode = QTextCursor.MoveMode.MoveAnchor - # Will cursors have increased or decreased in position? - increasing_direction = True # used to merge multi-selections - - self.textCursor().beginEditBlock() - for cursor in self.all_cursors: - - self.setTextCursor(cursor) - # ---- handle arrow keys - if key == Qt.Key_Up: - cursor.movePosition( - QTextCursor.MoveOperation.Up, move_mode - ) - increasing_direction = False - elif key == Qt.Key_Down: - cursor.movePosition( - QTextCursor.MoveOperation.Down, move_mode - ) - elif key == Qt.Key_Left: - increasing_direction = False - if ctrl: - cursor.movePosition( - QTextCursor.MoveOperation.PreviousWord, move_mode - ) - else: - cursor.movePosition( - QTextCursor.MoveOperation.Left, move_mode - ) - elif key == Qt.Key_Right: - if ctrl: - cursor.movePosition( - QTextCursor.MoveOperation.NextWord, move_mode - ) - else: - cursor.movePosition( - QTextCursor.MoveOperation.Right, move_mode - ) - # ---- handle Home, End - elif key == Qt.Key_Home: - increasing_direction = False - if ctrl: - cursor.movePosition( - QTextCursor.MoveOperation.Start, move_mode - ) - else: - block_pos = cursor.block().position() - line = cursor.block().text() - indent_pos = block_pos + len(line) - len(line.lstrip()) - if cursor.position() != indent_pos: - cursor.setPosition(indent_pos, move_mode) - else: - cursor.setPosition(block_pos, move_mode) - elif key == Qt.Key_End: - if ctrl: - cursor.movePosition( - QTextCursor.MoveOperation.End, move_mode - ) - else: - cursor.movePosition( - QTextCursor.MoveOperation.EndOfBlock, move_mode - ) - # ---- handle Tab - elif key == Qt.Key_Tab and not ctrl: # ctrl-tab is shortcut - # Don't do intelligent tab with multi-cursor to skip - # calls to do_completion. Avoiding completions with multi - # cursor is much easier than solving all the edge cases. - - # Trivial implementation: # TODO respect tab_mode - self.replace(self.indent_chars) - elif key == Qt.Key_Backtab and not ctrl: - increasing_direction = False - self.unindent() - # ---- use default handler for cursor (text) - else: - self._handle_keypress_event(event) - self.setTextCursor(cursor) - self.merge_extra_cursors(increasing_direction) - cursor.endEditBlock() - self.setTextCursor(cursor) # last cursor from for loop is primary + self.for_each_cursor(lambda: self._handle_keypress_event(event))() + + return def _on_cursor_blinktimer_timeout(self): """ @@ -789,7 +726,7 @@ def multi_cursor_paste(self, clip_text): self.sig_text_was_inserted.emit() self.skip_rstrip = False - def for_each_cursor(self, method): + def for_each_cursor(self, method, merge_increasing=True): """Wrap callable to execute once for each cursor""" @functools.wraps(method) def wrapper(): @@ -807,7 +744,7 @@ def wrapper(): self.setTextCursor(new_cursors[-1]) for cursor in new_cursors[:-1]: self.add_cursor(cursor) - self.merge_extra_cursors(True) + self.merge_extra_cursors(merge_increasing) self.textCursor().endEditBlock() return wrapper @@ -827,18 +764,17 @@ def wrapper(): method() return wrapper - def go_to_next_cell(self): # TODO test this + def go_to_next_cell(self): """ - reimplement to clear extra cursors before calling - TextEditBaseWidget.go_to_next_cell + reimplements TextEditBaseWidget.go_to_next_cell to clear extra cursors """ self.clear_extra_cursors() super().go_to_next_cell() - def go_to_previous_cell(self): # TODO test this + def go_to_previous_cell(self): """ - reimplement to clear extra cursors before calling - TextEditBaseWidget.go_to_previous_cell + reimplements TextEditBaseWidget.go_to_previous_cell to clear extra + cursors """ self.clear_extra_cursors() super().go_to_previous_cell() @@ -947,14 +883,14 @@ def register_shortcuts(self): ('transform to uppercase', self.for_each_cursor(self.transform_to_uppercase)), ('transform to lowercase', self.for_each_cursor(self.transform_to_lowercase)), ('indent', self.for_each_cursor(lambda: self.indent(force=True))), - ('unindent', self.for_each_cursor(lambda: self.unindent(force=True))), - ('start of line', self.for_each_cursor(self.create_cursor_callback('StartOfLine'))), + ('unindent', self.for_each_cursor(lambda: self.unindent(force=True), False)), + ('start of line', self.for_each_cursor(self.create_cursor_callback('StartOfLine'), False)), ('end of line', self.for_each_cursor(self.create_cursor_callback('EndOfLine'))), - ('previous line', self.for_each_cursor(self.create_cursor_callback('Up'))), + ('previous line', self.for_each_cursor(self.create_cursor_callback('Up'), False)), ('next line', self.for_each_cursor(self.create_cursor_callback('Down'))), - ('previous char', self.for_each_cursor(self.create_cursor_callback('Left'))), + ('previous char', self.for_each_cursor(self.create_cursor_callback('Left'), False)), ('next char', self.for_each_cursor(self.create_cursor_callback('Right'))), - ('previous word', self.for_each_cursor(self.create_cursor_callback('PreviousWord'))), + ('previous word', self.for_each_cursor(self.create_cursor_callback('PreviousWord'), False)), ('next word', self.for_each_cursor(self.create_cursor_callback('NextWord'))), ('kill to line end', self.kill_line_end), # TODO multi-cursor ('kill to line start', self.kill_line_start), # TODO multi-cursor From 4bb130fa8918c92afa1d441c61ea0ce764f1c6cd Mon Sep 17 00:00:00 2001 From: Aaron Date: Thu, 14 Nov 2024 13:27:56 -0500 Subject: [PATCH 30/95] multi-cursor docstring shortcuts --- .../plugins/editor/widgets/codeeditor/codeeditor.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index f9767644513..aa024711bb0 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -532,6 +532,7 @@ def init_multi_cursor(self): def add_cursor(self, cursor: QTextCursor): """Add this cursor to the list of extra cursors""" + # TODO remove cursor if duplicate: ctrl-click to add *and* remove self.extra_cursors.append(cursor) self.merge_extra_cursors(True) @@ -611,8 +612,8 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): key = event.key() ctrl = event.modifiers() & Qt.ControlModifier - # TODO handle other keys? move to keyPressEvent and emit (and - # handle) sig_key_pressed for each cursor + # TODO handle other keys? + # TODO handle sig_key_pressed for each cursor? (maybe not: extra extensions add complexity. Keep multi-cursor simpler) # ---- handle Tab if key == Qt.Key_Tab and not ctrl: # ctrl-tab is shortcut # Don't do intelligent tab with multi-cursor to skip @@ -622,7 +623,6 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): # Trivial implementation: # TODO respect tab_mode self.for_each_cursor(lambda: self.replace(self.indent_chars))() elif key == Qt.Key_Backtab and not ctrl: - increasing_direction = False self.for_each_cursor(self.unindent,False)() # ---- use default handler for cursor (text) else: @@ -907,7 +907,7 @@ def register_shortcuts(self): ('paste', self.paste), ('delete', self.delete), ('select all', self.clears_extra_cursors(self.selectAll)), - ('docstring', self.writer_docstring.write_docstring_for_shortcut), # TODO multi-cursor + ('docstring', self.for_each_cursor(self.writer_docstring.write_docstring_for_shortcut)), # TODO multi-cursor ('autoformatting', self.format_document_or_range), # TODO multi-cursor ('scroll line down', self.scroll_line_down), ('scroll line up', self.scroll_line_up), @@ -3746,7 +3746,7 @@ def setup_context_menu(self): text=_('Generate docstring'), register_shortcut=True, register_action=False, - triggered=writer.write_docstring_at_first_line_of_function # TODO multi-cursor how to consider? + triggered=writer.write_docstring_at_first_line_of_function # multi-cursor not needed: cursor position is taken from context menu position ) # Document formatting @@ -4870,7 +4870,7 @@ def popup_docstring(self, prev_text, prev_pos): text=_("Generate docstring"), icon=self.create_icon('TextFileIcon'), register_action=False, - triggered=writer.write_docstring + triggered=self.for_each_cursor(writer.write_docstring) # TODO multi-cursor support only needed if we start sending sig_key_pressed from multi-cursor key handler ) self.menu_docstring.addAction(self.docstring_action) self.menu_docstring.setActiveAction(self.docstring_action) From a0979d080636fffb61eff0ae2a7405db20739d35 Mon Sep 17 00:00:00 2001 From: Aaron Date: Thu, 14 Nov 2024 15:39:25 -0500 Subject: [PATCH 31/95] fix cursor blink typo so cursor is not hidden during rapid keypress events --- .../plugins/editor/widgets/codeeditor/codeeditor.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index aa024711bb0..26b27507ddb 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -668,16 +668,14 @@ def paint_cursors(self, event): @Slot() def start_cursor_blink(self): """start manually updating the cursor(s) blink state: Show cursors""" - self.current_blink_state = True + self.cursor_blink_state = True self.cursor_blink_timer.start() - self.viewport().update() @Slot() def stop_cursor_blink(self): """stop manually updating the cursor(s) blink state: Hide cursors""" self.cursor_blink_state = False self.cursor_blink_timer.stop() - self.viewport().update() def multi_cursor_copy(self): """ @@ -907,7 +905,7 @@ def register_shortcuts(self): ('paste', self.paste), ('delete', self.delete), ('select all', self.clears_extra_cursors(self.selectAll)), - ('docstring', self.for_each_cursor(self.writer_docstring.write_docstring_for_shortcut)), # TODO multi-cursor + ('docstring', self.for_each_cursor(self.writer_docstring.write_docstring_for_shortcut)), ('autoformatting', self.format_document_or_range), # TODO multi-cursor ('scroll line down', self.scroll_line_down), ('scroll line up', self.scroll_line_up), @@ -3956,7 +3954,9 @@ def keyPressEvent(self, event): # Send the signal to the editor's extension. event.ignore() self.sig_key_pressed.emit(event) - + + self.start_cursor_blink() # reset cursor blink by reseting timer + self._last_pressed_key = key = event.key() self._last_key_pressed_text = text = to_text_string(event.text()) has_selection = self.has_selected_text() @@ -3994,8 +3994,6 @@ def keyPressEvent(self, event): return return - self.start_cursor_blink() # reset cursor blink by reseting timer - # ---- Handle hard coded and builtin actions operators = {'+', '-', '*', '**', '/', '//', '%', '@', '<<', '>>', '&', '|', '^', '~', '<', '>', '<=', '>=', '==', '!='} From 525f8fdd5c457d235aa895ac46e673b34a079e64 Mon Sep 17 00:00:00 2001 From: Aaron Date: Thu, 14 Nov 2024 17:09:08 -0500 Subject: [PATCH 32/95] Colum cursor creation and multi-cursor remove cursor click interactions --- .../editor/widgets/codeeditor/codeeditor.py | 87 +++++++++++++++++-- 1 file changed, 78 insertions(+), 9 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 26b27507ddb..2ed9dc9de3a 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -4662,22 +4662,91 @@ def leaveEvent(self, event): self._restore_editor_cursor_and_selections() TextEditBaseWidget.leaveEvent(self, event) - def mousePressEvent(self, event): + def mousePressEvent(self, event: QKeyEvent): """Override Qt method.""" self.hide_tooltip() - ctrl = event.modifiers() & Qt.ControlModifier - alt = event.modifiers() & Qt.AltModifier + ctrl = event.modifiers() & Qt.KeyboardModifier.ControlModifier + alt = event.modifiers() & Qt.KeyboardModifier.AltModifier + shift = event.modifiers() & Qt.KeyboardModifier.ShiftModifier pos = event.pos() self._mouse_left_button_pressed = event.button() == Qt.LeftButton if event.button() == Qt.LeftButton and ctrl and alt: - # move existing primary cursor to extra_cursors list and set new - # primary cursor - old_cursor = self.textCursor() - self.setTextCursor(self.cursorForPosition(pos)) - self.add_cursor(old_cursor) - else: + # ---- Ctrl-Alt: multi-cursor mouse interactions + if shift: + # Ctrl-Shift-Alt click adds colum of cursors towards primary + # cursor + first_cursor = self.textCursor() + anchor_block = first_cursor.block() + anchor_col = first_cursor.anchor() - anchor_block.position() + second_cursor = self.cursorForPosition(pos) + pos_block = second_cursor.block() + pos_col = second_cursor.positionInBlock() + + #move primary cursor to pos_col + first_cursor.setPosition(anchor_block.position() + pos_col, + QTextCursor.MoveMode.KeepAnchor) + self.setTextCursor(first_cursor) + block = anchor_block + while True: + #get the next block + if anchor_block < pos_block: + block = block.next() + else: + block = block.previous() + + #add a cursor for this block + if block.isVisible() and block.isValid(): + cursor = QTextCursor(first_cursor) + a_col = min(block.length(), anchor_col) + cursor.setPosition(block.position() + a_col, + QTextCursor.MoveMode.MoveAnchor) + p_col = min(block.length(), pos_col) + cursor.setPosition(block.position() + p_col, + QTextCursor.MoveMode.KeepAnchor) + self.add_cursor(cursor) + + #break if it's the last block + if block == pos_block: + break + + else: # Ctrl-Alt click adds and removes cursors + # move existing primary cursor to extra_cursors list and set + # new primary cursor + old_cursor = self.textCursor() + new_cursor = self.cursorForPosition(pos) + + removed_cursor = False + # don't attempt to remove cursor if there's only one + if self.extra_cursors: + same_cursor = None + for cursor in self.all_cursors: + if new_cursor.position() == cursor.position(): + same_cursor = cursor + break + if same_cursor is not None: + removed_cursor = True + if same_cursor in self.extra_cursors: + # cursor to be removed was not primary + self.extra_cursors.remove(same_cursor) + else: + # cursor to be removed is primary cursor + # pick a new primary by position + new_primary = max( + self.extra_cursors, + key=lambda cursor: cursor.position() + ) + self.extra_cursors.remove(new_primary) + self.setTextCursor(new_primary) + # possibly clear selection of removed cursor + self.set_extra_cursor_selections() + + if not removed_cursor: + self.setTextCursor(new_cursor) + self.add_cursor(old_cursor) + else: + # ---- not multi-cursor if event.button() == Qt.MouseButton.LeftButton: self.clear_extra_cursors() if event.button() == Qt.LeftButton and ctrl: From 64b8523ae6157614fb9d301f956e9cb942057f56 Mon Sep 17 00:00:00 2001 From: athompson673 Date: Fri, 15 Nov 2024 22:42:15 -0500 Subject: [PATCH 33/95] Fix column cursor shortcut: clamp cursor position to text length not line length. Also revert to builtin cursor rendering for text dragging with single cursor. --- .../editor/widgets/codeeditor/codeeditor.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 2ed9dc9de3a..a097b96d05c 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -641,6 +641,12 @@ def _on_cursor_blinktimer_timeout(self): @Slot(QPaintEvent) def paint_cursors(self, event): """Paint all cursors""" + if not self.extra_cursors: + #revert to builtin cursor rendering if single cursor to handle + # cursor drawing while dragging a selection of text around. + self.setCursorWidth(self.cursor_width) + return + self.setCursorWidth(0) qp = QPainter() qp.begin(self.viewport()) offset = self.contentOffset() @@ -4685,7 +4691,10 @@ def mousePressEvent(self, event: QKeyEvent): pos_col = second_cursor.positionInBlock() #move primary cursor to pos_col - first_cursor.setPosition(anchor_block.position() + pos_col, + p_col = min(len(anchor_block.text()), pos_col) + # block.length() includes line seperator? just /n? + # use len(block.text()) instead + first_cursor.setPosition(anchor_block.position() + p_col, QTextCursor.MoveMode.KeepAnchor) self.setTextCursor(first_cursor) block = anchor_block @@ -4699,10 +4708,11 @@ def mousePressEvent(self, event: QKeyEvent): #add a cursor for this block if block.isVisible() and block.isValid(): cursor = QTextCursor(first_cursor) - a_col = min(block.length(), anchor_col) + + a_col = min(len(block.text()), anchor_col) cursor.setPosition(block.position() + a_col, QTextCursor.MoveMode.MoveAnchor) - p_col = min(block.length(), pos_col) + p_col = min(len(block.text()), pos_col) cursor.setPosition(block.position() + p_col, QTextCursor.MoveMode.KeepAnchor) self.add_cursor(cursor) From 0596845becb1c66f3e607e57d43b5c9b53e36554 Mon Sep 17 00:00:00 2001 From: athompson673 Date: Fri, 15 Nov 2024 23:20:56 -0500 Subject: [PATCH 34/95] edit multi cursor paste: if single line on clipboard; repeat for all cursors --- spyder/plugins/editor/widgets/codeeditor/codeeditor.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index a097b96d05c..13e653cf0fc 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -26,6 +26,7 @@ import sys import textwrap import functools +import itertools # Third party imports from IPython.core.inputtransformer2 import TransformerManager @@ -719,7 +720,10 @@ def multi_cursor_paste(self, clip_text): cursors.sort(key=lambda cursor: cursor.position()) self.skip_rstrip = True self.sig_will_paste_text.emit(clip_text) - for cursor, text in zip(cursors, clip_text.splitlines()): + lines = clip_text.splitlines() + if len(lines) == 1: + lines = itertools.repeat(lines[0]) + for cursor, text in zip(cursors, lines): self.setTextCursor(cursor) cursor.insertText(text) # handle extra lines or extra cursors? From a66d4e812e4658c5569dcd2741afe9759624c8d2 Mon Sep 17 00:00:00 2001 From: athompson673 Date: Sat, 16 Nov 2024 00:05:40 -0500 Subject: [PATCH 35/95] rewrite cursor rendering again for overwrite mode --- .../editor/widgets/codeeditor/codeeditor.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 13e653cf0fc..8ad05c74de0 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -516,7 +516,7 @@ def init_multi_cursor(self): """Initialize attrs and callbacks for multi-cursor functionality""" self.cursor_width = self.get_conf('cursor/width', section='main') self.overwrite_mode = self.overwriteMode() - # track overwrite manually for painting the cursor(s) + # track overwrite manually when for painting reasons with multi-cursor self.setOverwriteMode(False) self.setCursorWidth(0) # draw our own cursor self.extra_cursors = [] @@ -642,11 +642,18 @@ def _on_cursor_blinktimer_timeout(self): @Slot(QPaintEvent) def paint_cursors(self, event): """Paint all cursors""" + if self.overwrite_mode: + font = self.textCursor().block().charFormat().font() + cursor_width = QFontMetrics(font).horizontalAdvance(" ") + else: + cursor_width = self.cursor_width + if not self.extra_cursors: #revert to builtin cursor rendering if single cursor to handle # cursor drawing while dragging a selection of text around. - self.setCursorWidth(self.cursor_width) + self.setCursorWidth(cursor_width) return + self.setCursorWidth(0) qp = QPainter() qp.begin(self.viewport()) @@ -657,11 +664,7 @@ def paint_cursors(self, event): flags = (self.textInteractionFlags() & Qt.TextInteractionFlag.TextSelectableByKeyboard) draw_cursor = self.cursor_blink_state and (editable or flags) - if self.overwrite_mode: - font = self.textCursor().block().charFormat().font() - cursor_width = QFontMetrics(font).horizontalAdvance(" ") - else: - cursor_width = self.cursor_width + for cursor in self.all_cursors: block = cursor.block() if draw_cursor and block.isVisible(): From 2c1c9bfd37d3a0ef00cb56e1dbeb840e789596a6 Mon Sep 17 00:00:00 2001 From: athompson673 Date: Sat, 16 Nov 2024 00:18:18 -0500 Subject: [PATCH 36/95] pep 8 changes (some, not all) --- .../editor/widgets/codeeditor/codeeditor.py | 79 ++++++++++--------- 1 file changed, 42 insertions(+), 37 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 8ad05c74de0..9fb4e6ad75e 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -522,7 +522,9 @@ def init_multi_cursor(self): self.extra_cursors = [] self.cursor_blink_state = False self.cursor_blink_timer = QTimer(self) - self.cursor_blink_timer.setInterval(QApplication.cursorFlashTime() // 2) + self.cursor_blink_timer.setInterval( + QApplication.cursorFlashTime() // 2 + ) self.cursor_blink_timer.timeout.connect( self._on_cursor_blinktimer_timeout ) @@ -609,12 +611,12 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): """Re-Implement keyEvent handler for multi-cursor""" if self.extra_cursors: event.accept() - key = event.key() ctrl = event.modifiers() & Qt.ControlModifier - - # TODO handle other keys? - # TODO handle sig_key_pressed for each cursor? (maybe not: extra extensions add complexity. Keep multi-cursor simpler) + # TODO handle other keys? + # TODO handle sig_key_pressed for each cursor? + # (maybe not: extra extensions add complexity. + # Keep multi-cursor simpler) # ---- handle Tab if key == Qt.Key_Tab and not ctrl: # ctrl-tab is shortcut # Don't do intelligent tab with multi-cursor to skip @@ -624,11 +626,13 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): # Trivial implementation: # TODO respect tab_mode self.for_each_cursor(lambda: self.replace(self.indent_chars))() elif key == Qt.Key_Backtab and not ctrl: - self.for_each_cursor(self.unindent,False)() + self.for_each_cursor(self.unindent, False)() # ---- use default handler for cursor (text) else: - self.for_each_cursor(lambda: self._handle_keypress_event(event))() - + self.for_each_cursor( + lambda: self._handle_keypress_event(event) + )() + return def _on_cursor_blinktimer_timeout(self): @@ -649,8 +653,8 @@ def paint_cursors(self, event): cursor_width = self.cursor_width if not self.extra_cursors: - #revert to builtin cursor rendering if single cursor to handle - # cursor drawing while dragging a selection of text around. + # Revert to builtin cursor rendering if single cursor to handle + # cursor drawing while dragging a selection of text around. self.setCursorWidth(cursor_width) return @@ -664,12 +668,12 @@ def paint_cursors(self, event): flags = (self.textInteractionFlags() & Qt.TextInteractionFlag.TextSelectableByKeyboard) draw_cursor = self.cursor_blink_state and (editable or flags) - + for cursor in self.all_cursors: block = cursor.block() if draw_cursor and block.isVisible(): - block_geometry_top = int(self.blockBoundingGeometry(block).top()) - offset.setY(block_geometry_top + content_offset_y) + block_top = int(self.blockBoundingGeometry(block).top()) + offset.setY(block_top + content_offset_y) block.layout().drawCursor(qp, offset, cursor.positionInBlock(), cursor_width) @@ -2297,7 +2301,8 @@ def center_cursor_on_next_focus(self): def go_to_line(self, line, start_column=0, end_column=0, word=''): """Go to line number *line* and eventually highlight it""" - self.clear_extra_cursors() # handles go to warnings, todo, line number, definition, etc. + # handles go to warnings, todo, line number, definition, etc. + self.clear_extra_cursors() self.text_helper.goto_line(line, column=start_column, end_column=end_column, move=True, word=word) @@ -3284,7 +3289,7 @@ def unblockcomment(self): # See spyder-ide/spyder#2845. unblockcomment = self.__unblockcomment() if not unblockcomment: - unblockcomment = self.__unblockcomment(compatibility=True) + unblockcomment = self.__unblockcomment(compatibility=True) else: return unblockcomment @@ -3705,7 +3710,7 @@ def setup_context_menu(self): icon=self.create_icon('MessageBoxInformation'), register_shortcut=True, register_action=False, - triggered=self.sig_show_object_info # only consider main cursor; don't clear extra cursors + triggered=self.sig_show_object_info ) # Run actions @@ -3757,7 +3762,7 @@ def setup_context_menu(self): text=_('Generate docstring'), register_shortcut=True, register_action=False, - triggered=writer.write_docstring_at_first_line_of_function # multi-cursor not needed: cursor position is taken from context menu position + triggered=writer.write_docstring_at_first_line_of_function ) # Document formatting @@ -3967,9 +3972,9 @@ def keyPressEvent(self, event): # Send the signal to the editor's extension. event.ignore() self.sig_key_pressed.emit(event) - + self.start_cursor_blink() # reset cursor blink by reseting timer - + self._last_pressed_key = key = event.key() self._last_key_pressed_text = text = to_text_string(event.text()) has_selection = self.has_selected_text() @@ -4687,8 +4692,8 @@ def mousePressEvent(self, event: QKeyEvent): if event.button() == Qt.LeftButton and ctrl and alt: # ---- Ctrl-Alt: multi-cursor mouse interactions - if shift: - # Ctrl-Shift-Alt click adds colum of cursors towards primary + if shift: + # Ctrl-Shift-Alt click adds colum of cursors towards primary # cursor first_cursor = self.textCursor() anchor_block = first_cursor.block() @@ -4696,35 +4701,35 @@ def mousePressEvent(self, event: QKeyEvent): second_cursor = self.cursorForPosition(pos) pos_block = second_cursor.block() pos_col = second_cursor.positionInBlock() - - #move primary cursor to pos_col + + # Move primary cursor to pos_col p_col = min(len(anchor_block.text()), pos_col) # block.length() includes line seperator? just /n? # use len(block.text()) instead first_cursor.setPosition(anchor_block.position() + p_col, - QTextCursor.MoveMode.KeepAnchor) + QTextCursor.MoveMode.KeepAnchor) self.setTextCursor(first_cursor) block = anchor_block while True: - #get the next block + # Get the next block if anchor_block < pos_block: block = block.next() else: block = block.previous() - - #add a cursor for this block + + # Add a cursor for this block if block.isVisible() and block.isValid(): cursor = QTextCursor(first_cursor) - + a_col = min(len(block.text()), anchor_col) cursor.setPosition(block.position() + a_col, - QTextCursor.MoveMode.MoveAnchor) + QTextCursor.MoveMode.MoveAnchor) p_col = min(len(block.text()), pos_col) cursor.setPosition(block.position() + p_col, - QTextCursor.MoveMode.KeepAnchor) + QTextCursor.MoveMode.KeepAnchor) self.add_cursor(cursor) - - #break if it's the last block + + # Break if it's the last block if block == pos_block: break @@ -4733,7 +4738,7 @@ def mousePressEvent(self, event: QKeyEvent): # new primary cursor old_cursor = self.textCursor() new_cursor = self.cursorForPosition(pos) - + removed_cursor = False # don't attempt to remove cursor if there's only one if self.extra_cursors: @@ -4758,11 +4763,11 @@ def mousePressEvent(self, event: QKeyEvent): self.setTextCursor(new_primary) # possibly clear selection of removed cursor self.set_extra_cursor_selections() - + if not removed_cursor: self.setTextCursor(new_cursor) self.add_cursor(old_cursor) - else: + else: # ---- not multi-cursor if event.button() == Qt.MouseButton.LeftButton: self.clear_extra_cursors() @@ -5019,8 +5024,8 @@ class TestWidget(QSplitter): def __init__(self, parent): QSplitter.__init__(self, parent) self.editor = CodeEditor(self) - self.editor.setup_editor(linenumbers=True, markers=True, tab_mode=False, - font=QFont("Courier New", 10), + self.editor.setup_editor(linenumbers=True, markers=True, + tab_mode=False, font=QFont("Courier New", 10), show_blanks=True, color_scheme='Zenburn') self.addWidget(self.editor) self.setWindowIcon(ima.icon('spyder')) From 7d35e3f53390407c4cc71ee7ae19fd51ee4f9c92 Mon Sep 17 00:00:00 2001 From: athompson673 Date: Sat, 16 Nov 2024 00:37:43 -0500 Subject: [PATCH 37/95] revert badly merged line from cut() --- spyder/plugins/editor/widgets/codeeditor/codeeditor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 9fb4e6ad75e..53ec9695cd5 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -2248,7 +2248,7 @@ def cut(self): return has_selected_text = self.has_selected_text() if not has_selected_text: - return + self.select_current_line_and_sep() start, end = self.get_selection_start_end() self.sig_will_remove_selection.emit(start, end) self.sig_delete_requested.emit() From 2aeb19124835996054c5d01b2b2e9081d83d20ed Mon Sep 17 00:00:00 2001 From: Aaron Date: Mon, 18 Nov 2024 12:49:02 -0500 Subject: [PATCH 38/95] pep8 line length edits and attempt to fix delete behavior regression --- .../editor/widgets/codeeditor/codeeditor.py | 93 +++++++++++-------- 1 file changed, 54 insertions(+), 39 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 53ec9695cd5..53eccca3417 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -883,38 +883,54 @@ def cursor_move_event(): def register_shortcuts(self): """Register shortcuts for this widget.""" shortcuts = ( - ('code completion', self.restrict_single_cursor(self.do_completion)), - ('duplicate line down', self.for_each_cursor(self.duplicate_line_down)), - ('duplicate line up', self.for_each_cursor(self.duplicate_line_up)), + ('code completion', self.restrict_single_cursor( + self.do_completion)), + ('duplicate line down', self.for_each_cursor( + self.duplicate_line_down)), + ('duplicate line up', self.for_each_cursor( + self.duplicate_line_up)), ('delete line', self.delete_line), ('move line up', self.move_line_up), # TODO multi-cursor ('move line down', self.move_line_down), # TODO multi-cursor ('go to new line', self.for_each_cursor(self.go_to_new_line)), - ('go to definition', self.go_to_definition_from_cursor), # clears extra cursors already handled by go_to_line + ('go to definition', self.go_to_definition_from_cursor), ('toggle comment', self.for_each_cursor(self.toggle_comment)), - ('blockcomment', self.for_each_cursor(self.blockcomment)), # TODO multi-cursor a bit wonky + ('blockcomment', self.for_each_cursor(self.blockcomment)), ('create_new_cell', self.for_each_cursor(self.create_new_cell)), - ('unblockcomment', self.for_each_cursor(self.unblockcomment)), # TODO multi-cursor a bit wonky - ('transform to uppercase', self.for_each_cursor(self.transform_to_uppercase)), - ('transform to lowercase', self.for_each_cursor(self.transform_to_lowercase)), + ('unblockcomment', self.for_each_cursor(self.unblockcomment)), + ('transform to uppercase', self.for_each_cursor( + self.transform_to_uppercase)), + ('transform to lowercase', self.for_each_cursor( + self.transform_to_lowercase)), ('indent', self.for_each_cursor(lambda: self.indent(force=True))), - ('unindent', self.for_each_cursor(lambda: self.unindent(force=True), False)), - ('start of line', self.for_each_cursor(self.create_cursor_callback('StartOfLine'), False)), - ('end of line', self.for_each_cursor(self.create_cursor_callback('EndOfLine'))), - ('previous line', self.for_each_cursor(self.create_cursor_callback('Up'), False)), - ('next line', self.for_each_cursor(self.create_cursor_callback('Down'))), - ('previous char', self.for_each_cursor(self.create_cursor_callback('Left'), False)), - ('next char', self.for_each_cursor(self.create_cursor_callback('Right'))), - ('previous word', self.for_each_cursor(self.create_cursor_callback('PreviousWord'), False)), - ('next word', self.for_each_cursor(self.create_cursor_callback('NextWord'))), + ('unindent', self.for_each_cursor( + lambda: self.unindent(force=True), False)), + ('start of line', self.for_each_cursor( + self.create_cursor_callback('StartOfLine'), False)), + ('end of line', self.for_each_cursor( + self.create_cursor_callback('EndOfLine'))), + ('previous line', self.for_each_cursor( + self.create_cursor_callback('Up'), False)), + ('next line', self.for_each_cursor( + self.create_cursor_callback('Down'))), + ('previous char', self.for_each_cursor( + self.create_cursor_callback('Left'), False)), + ('next char', self.for_each_cursor( + self.create_cursor_callback('Right'))), + ('previous word', self.for_each_cursor( + self.create_cursor_callback('PreviousWord'), False)), + ('next word', self.for_each_cursor( + self.create_cursor_callback('NextWord'))), ('kill to line end', self.kill_line_end), # TODO multi-cursor ('kill to line start', self.kill_line_start), # TODO multi-cursor ('yank', self._kill_ring.yank), # TODO multi-cursor ('rotate kill ring', self._kill_ring.rotate), # TODO multi-cursor ('kill previous word', self.kill_prev_word), # TODO multi-cursor ('kill next word', self.kill_next_word), # TODO multi-cursor - ('start of document', self.clears_extra_cursors(self.create_cursor_callback('Start'))), - ('end of document', self.clears_extra_cursors(self.create_cursor_callback('End'))), + ('start of document', self.clears_extra_cursors( + self.create_cursor_callback('Start'))), + ('end of document', self.clears_extra_cursors( + self.create_cursor_callback('End'))), ('undo', self.undo), # TODO multi-cursor (cursor positions) ('redo', self.redo), # TODO multi-cursor (cursor positions) ('cut', self.cut), @@ -922,12 +938,15 @@ def register_shortcuts(self): ('paste', self.paste), ('delete', self.delete), ('select all', self.clears_extra_cursors(self.selectAll)), - ('docstring', self.for_each_cursor(self.writer_docstring.write_docstring_for_shortcut)), + ('docstring', self.for_each_cursor( + self.writer_docstring.write_docstring_for_shortcut)), ('autoformatting', self.format_document_or_range), # TODO multi-cursor ('scroll line down', self.scroll_line_down), ('scroll line up', self.scroll_line_up), - ('enter array inline', self.clears_extra_cursors(self.enter_array_inline)), - ('enter array table', self.clears_extra_cursors(self.enter_array_table)), + ('enter array inline', self.clears_extra_cursors( + self.enter_array_inline)), + ('enter array table', self.clears_extra_cursors( + self.enter_array_table)), ) for name, callback in shortcuts: @@ -1597,15 +1616,7 @@ def delete(self): self.textCursor().beginEditBlock() self.sig_delete_requested.emit() for cursor in self.all_cursors: - self.setTextCursor(cursor) - if not self.has_selected_text(): - if not cursor.atEnd(): - cursor.setPosition( - self.next_cursor_position(), QTextCursor.KeepAnchor - ) - self.setTextCursor(cursor) - - self.remove_selected_text() + cursor.deleteChar() self.setTextCursor(cursor) self.textCursor().endEditBlock() @@ -1616,18 +1627,20 @@ def delete_line(self): if self.has_selected_text(): self.extend_selection_to_complete_lines() - start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd() - cursor.setPosition(start_pos) + start, end = cursor.selectionStart(), cursor.selectionEnd() + cursor.setPosition(start) else: - start_pos = end_pos = cursor.position() + start = end = cursor.position() - cursor.setPosition(start_pos) + cursor.setPosition(start) cursor.movePosition(QTextCursor.StartOfBlock) - while cursor.position() <= end_pos: - cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) + while cursor.position() <= end: + cursor.movePosition(QTextCursor.EndOfBlock, + QTextCursor.KeepAnchor) if cursor.atEnd(): break - cursor.movePosition(QTextCursor.NextBlock, QTextCursor.KeepAnchor) + cursor.movePosition(QTextCursor.NextBlock, + QTextCursor.KeepAnchor) self.setTextCursor(cursor) self.delete() @@ -4959,7 +4972,9 @@ def popup_docstring(self, prev_text, prev_pos): text=_("Generate docstring"), icon=self.create_icon('TextFileIcon'), register_action=False, - triggered=self.for_each_cursor(writer.write_docstring) # TODO multi-cursor support only needed if we start sending sig_key_pressed from multi-cursor key handler + # TODO multi-cursor support only needed if we start sending + # sig_key_pressed from multi-cursor key handler + triggered=self.for_each_cursor(writer.write_docstring) ) self.menu_docstring.addAction(self.docstring_action) self.menu_docstring.setActiveAction(self.docstring_action) From de42e0819ebb047d2a0f9136894bf431383b0520 Mon Sep 17 00:00:00 2001 From: Aaron Date: Mon, 18 Nov 2024 17:48:09 -0500 Subject: [PATCH 39/95] reorganize multi-cursor keyPressEvent, and bugfix merge_cursors handle_multi_cursor_keypress no longer an event reciever, instead directly called from keyPressEvent. This way we can cleanly emit sig_key_pressed for each cursor and respond to event.isAccepted. This implements ignoring keystrokes on folded lines. --- .../editor/widgets/codeeditor/codeeditor.py | 81 ++++++++++++++----- 1 file changed, 61 insertions(+), 20 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 53eccca3417..61b6985e6b4 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -531,7 +531,7 @@ def init_multi_cursor(self): self.focus_in.connect(self.start_cursor_blink) self.focus_changed.connect(self.stop_cursor_blink) self.painted.connect(self.paint_cursors) - self.sig_key_pressed.connect(self.handle_multi_cursor_keypress) + # self.sig_key_pressed.connect(self.handle_multi_cursor_keypress) def add_cursor(self, cursor: QTextCursor): """Add this cursor to the list of extra cursors""" @@ -600,6 +600,8 @@ def merge_extra_cursors(self, increasing_position): QTextCursor.MoveMode.MoveAnchor) cursor2.setPosition(positions[2], QTextCursor.MoveMode.KeepAnchor) + if cursor2 is main_cursor: + self.setTextCursor(cursor2) break if not cursor_was_removed: @@ -609,31 +611,66 @@ def merge_extra_cursors(self, increasing_position): @Slot(QKeyEvent) def handle_multi_cursor_keypress(self, event: QKeyEvent): """Re-Implement keyEvent handler for multi-cursor""" - if self.extra_cursors: - event.accept() - key = event.key() - ctrl = event.modifiers() & Qt.ControlModifier - # TODO handle other keys? - # TODO handle sig_key_pressed for each cursor? - # (maybe not: extra extensions add complexity. - # Keep multi-cursor simpler) + + key = event.key() + ctrl = event.modifiers() & Qt.KeyboardModifier.ControlModifier + alt = event.modifiers() & Qt.KeyboardModifier.AltModifier + shift = event.modifiers() & Qt.KeyboardModifier.ShiftModifier + # TODO handle other keys? + # TODO handle sig_key_pressed for each cursor? + # (maybe not: extra extensions add complexity. + # Keep multi-cursor simpler) + # ---- handle insert + if key == Qt.Key.Key_Insert and not (ctrl or alt or shift): + self.overwrite_mode = not self.overwrite_mode + return + + increasing_position = True + new_cursors = [] + # some operations should be limited to once per line ? + handled_lines = [] + self.textCursor().beginEditBlock() + for cursor in self.all_cursors: + self.setTextCursor(cursor) + event.ignore() + self.sig_key_pressed.emit(event) + if event.isAccepted(): + # text folding swallows most input to prevent typing on folded + # lines. + pass # ---- handle Tab - if key == Qt.Key_Tab and not ctrl: # ctrl-tab is shortcut + elif key == Qt.Key.Key_Tab and not ctrl: # ctrl-tab is shortcut # Don't do intelligent tab with multi-cursor to skip # calls to do_completion. Avoiding completions with multi # cursor is much easier than solving all the edge cases. - # Trivial implementation: # TODO respect tab_mode - self.for_each_cursor(lambda: self.replace(self.indent_chars))() - elif key == Qt.Key_Backtab and not ctrl: - self.for_each_cursor(self.unindent, False)() + self.indent(force=self.tab_mode) + elif key == Qt.Key.Key_Backtab and not ctrl: + increasing_position = False + # TODO ignore indent level of neighboring lines and simply + # indent by 1 level at a time. Cursor update order can + # make this unpredictable otherwise. + self.unindent(force=self.tab_mode) # ---- use default handler for cursor (text) else: - self.for_each_cursor( - lambda: self._handle_keypress_event(event) - )() + if key in (Qt.Key.Key_Up, Qt.Key.Key_Left, Qt.Key.Key_Home): + increasing_position = False + if (key in (Qt.Key.Key_Up, Qt.Key.Key_Down) and + cursor.verticalMovementX() == -1): + # Builtin handler somehow does not set verticalMovementX + # when moving up and down (but works fine for single + # cursor somehow) # TODO why + x = self.cursorRect(cursor).x() + cursor.setVerticalMovementX(x) + self.setTextCursor(cursor) + self._handle_keypress_event(event) - return + # Update edited extra_cursors + new_cursors.append(self.textCursor()) + self.extra_cursors = new_cursors[:-1] + self.merge_extra_cursors(increasing_position) + self.textCursor().endEditBlock() + event.accept() # TODO when to pass along keypress or not def _on_cursor_blinktimer_timeout(self): """ @@ -3982,12 +4019,16 @@ def keyPressEvent(self, event): # Only set overwrite mode during key handling to allow correct painting # of multiple overwrite cursors. Must unset overwrite before return. self.setOverwriteMode(self.overwrite_mode) + self.start_cursor_blink() # reset cursor blink by reseting timer + if self.extra_cursors: + self.handle_multi_cursor_keypress(event) + self.setOverwriteMode(False) + return + # Send the signal to the editor's extension. event.ignore() self.sig_key_pressed.emit(event) - self.start_cursor_blink() # reset cursor blink by reseting timer - self._last_pressed_key = key = event.key() self._last_key_pressed_text = text = to_text_string(event.text()) has_selection = self.has_selected_text() From 5ffd2df2b39dfb01a860039c067a9dec646001e2 Mon Sep 17 00:00:00 2001 From: Aaron Date: Mon, 18 Nov 2024 19:23:33 -0500 Subject: [PATCH 40/95] rewrite delete and delete_line to fix behavior with folded code --- .../editor/widgets/codeeditor/codeeditor.py | 47 ++++++++++++------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 61b6985e6b4..a2489dca5b2 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -624,11 +624,9 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): if key == Qt.Key.Key_Insert and not (ctrl or alt or shift): self.overwrite_mode = not self.overwrite_mode return - + increasing_position = True new_cursors = [] - # some operations should be limited to once per line ? - handled_lines = [] self.textCursor().beginEditBlock() for cursor in self.all_cursors: self.setTextCursor(cursor) @@ -647,7 +645,7 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): self.indent(force=self.tab_mode) elif key == Qt.Key.Key_Backtab and not ctrl: increasing_position = False - # TODO ignore indent level of neighboring lines and simply + # TODO ignore indent level of neighboring lines and simply # indent by 1 level at a time. Cursor update order can # make this unpredictable otherwise. self.unindent(force=self.tab_mode) @@ -1649,26 +1647,28 @@ def next_cursor_position(self, position=None, @Slot() def delete(self): """Remove selected text or next character.""" - self.textCursor().beginEditBlock() - self.sig_delete_requested.emit() + new_cursors = [] for cursor in self.all_cursors: + self.setTextCursor(cursor) + self.sig_delete_requested.emit() + new_cursors.append(self.textCursor()) + # Signal all cursors first to call FoldingPanel._expand_selection + # before calling deleteChar. This fixes some issues with deletion + # order invalidating FoldingPanel properties in the wrong order + for cursor in new_cursors: cursor.deleteChar() self.setTextCursor(cursor) + self.extra_cursors = new_cursors[:-1] + self.merge_extra_cursors(True) self.textCursor().endEditBlock() def delete_line(self): """Delete current line.""" - cursor = self.textCursor() + self.textCursor().beginEditBlock() + cursors = [] for cursor in self.all_cursors: - - if self.has_selected_text(): - self.extend_selection_to_complete_lines() - start, end = cursor.selectionStart(), cursor.selectionEnd() - cursor.setPosition(start) - else: - start = end = cursor.position() - + start, end = cursor.selectionStart(), cursor.selectionEnd() cursor.setPosition(start) cursor.movePosition(QTextCursor.StartOfBlock) while cursor.position() <= end: @@ -1678,9 +1678,22 @@ def delete_line(self): break cursor.movePosition(QTextCursor.NextBlock, QTextCursor.KeepAnchor) + self.setTextCursor(cursor) + # Text folding looks for sig_delete_requested to expand selection + # to entire folded region. + self.sig_delete_requested.emit() + cursors.append(self.textCursor()) - self.setTextCursor(cursor) - self.delete() + new_cursors = [] + for cursor in cursors: + self.setTextCursor(cursor) + self.remove_selected_text() + new_cursors.append(self.textCursor()) + + self.extra_cursors = new_cursors[:-1] + self.setTextCursor(new_cursors[-1]) + self.merge_extra_cursors(True) + self.textCursor().endEditBlock() self.ensureCursorVisible() # ---- Scrolling From 2f300d2503fc30560ca4c9723c35a57cddc3dbc4 Mon Sep 17 00:00:00 2001 From: Aaron Date: Mon, 18 Nov 2024 19:41:22 -0500 Subject: [PATCH 41/95] update multi_cursor_keypress handling to emit signals before changing text similar to edits for delete function, emit a signal for each cursor prior to changing text as this prevents an out of order edit problem with code folding. --- .../editor/widgets/codeeditor/codeeditor.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index a2489dca5b2..1d1dfec23ae 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -624,15 +624,24 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): if key == Qt.Key.Key_Insert and not (ctrl or alt or shift): self.overwrite_mode = not self.overwrite_mode return - - increasing_position = True - new_cursors = [] + self.textCursor().beginEditBlock() + + cursors = [] + accepted = [] + # Handle all signals before editing text for cursor in self.all_cursors: self.setTextCursor(cursor) event.ignore() self.sig_key_pressed.emit(event) - if event.isAccepted(): + cursors.append(self.textCursor()) + accepted.append(event.isAccepted()) + + increasing_position = True + new_cursors = [] + for skip, cursor in zip(accepted, cursors): + self.setTextCursor(cursor) + if skip: # text folding swallows most input to prevent typing on folded # lines. pass From 8fb4df781afdfdc4166b04fbef14164913fd14bd Mon Sep 17 00:00:00 2001 From: Aaron Date: Tue, 19 Nov 2024 17:55:57 -0500 Subject: [PATCH 42/95] make autoformatting clear extra cursors The delayed update model with the LSP makes it difficult to encapsulate the changes with a simple call to for_each_cursor. Future implementation might edit lsp_mixin? --- .../plugins/editor/widgets/codeeditor/codeeditor.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 1d1dfec23ae..eb0b533cc58 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -624,9 +624,9 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): if key == Qt.Key.Key_Insert and not (ctrl or alt or shift): self.overwrite_mode = not self.overwrite_mode return - + self.textCursor().beginEditBlock() - + cursors = [] accepted = [] # Handle all signals before editing text @@ -984,7 +984,8 @@ def register_shortcuts(self): ('select all', self.clears_extra_cursors(self.selectAll)), ('docstring', self.for_each_cursor( self.writer_docstring.write_docstring_for_shortcut)), - ('autoformatting', self.format_document_or_range), # TODO multi-cursor + ('autoformatting', self.clears_extra_cursors( + self.format_document_or_range)), ('scroll line down', self.scroll_line_down), ('scroll line up', self.scroll_line_up), ('enter array inline', self.clears_extra_cursors( @@ -3850,7 +3851,7 @@ def setup_context_menu(self): icon=self.create_icon("transparent"), register_shortcut=True, register_action=False, - triggered=self.format_document_or_range # TODO multi-cursor how to consider? + triggered=self.clears_extra_cursors(self.format_document_or_range) ) self.format_action.setEnabled(False) @@ -5035,8 +5036,6 @@ def popup_docstring(self, prev_text, prev_pos): text=_("Generate docstring"), icon=self.create_icon('TextFileIcon'), register_action=False, - # TODO multi-cursor support only needed if we start sending - # sig_key_pressed from multi-cursor key handler triggered=self.for_each_cursor(writer.write_docstring) ) self.menu_docstring.addAction(self.docstring_action) From 8b19db91b83ecdccc8b0b00d3a9690df0471d45c Mon Sep 17 00:00:00 2001 From: Aaron Date: Wed, 20 Nov 2024 12:10:04 -0500 Subject: [PATCH 43/95] begin implementing multi-cursor toggle for settings option, and add decorator to two shortcuts --- .../editor/widgets/codeeditor/codeeditor.py | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index eb0b533cc58..8cd371569fd 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -514,6 +514,8 @@ def __init__(self, parent=None): # ---- Multi Cursor def init_multi_cursor(self): """Initialize attrs and callbacks for multi-cursor functionality""" + # actual default comes from setup_editor default args + self.multi_cursor_enabled = False self.cursor_width = self.get_conf('cursor/width', section='main') self.overwrite_mode = self.overwriteMode() # track overwrite manually when for painting reasons with multi-cursor @@ -533,9 +535,15 @@ def init_multi_cursor(self): self.painted.connect(self.paint_cursors) # self.sig_key_pressed.connect(self.handle_multi_cursor_keypress) + def toggle_multi_cursor(self, enabled): + """Enable/Disable multi-cursor editing""" + self.multi_cursor_enabled = enabled + # TODO any restrictions on enabling? only python-like? only code? + if not enabled: + self.clear_extra_cursors() + def add_cursor(self, cursor: QTextCursor): """Add this cursor to the list of extra cursors""" - # TODO remove cursor if duplicate: ctrl-click to add *and* remove self.extra_cursors.append(cursor) self.merge_extra_cursors(True) @@ -663,7 +671,7 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): if key in (Qt.Key.Key_Up, Qt.Key.Key_Left, Qt.Key.Key_Home): increasing_position = False if (key in (Qt.Key.Key_Up, Qt.Key.Key_Down) and - cursor.verticalMovementX() == -1): + cursor.verticalMovementX() == -1): # Builtin handler somehow does not set verticalMovementX # when moving up and down (but works fine for single # cursor somehow) # TODO why @@ -927,6 +935,8 @@ def cursor_move_event(): def register_shortcuts(self): """Register shortcuts for this widget.""" shortcuts = ( + # TODO should multi-cursor wrappers be applied as decorator to + # function definitions instead where possible? ('code completion', self.restrict_single_cursor( self.do_completion)), ('duplicate line down', self.for_each_cursor( @@ -1079,7 +1089,8 @@ def setup_editor(self, remove_trailing_spaces=False, remove_trailing_newlines=False, add_newline=False, - format_on_save=False): + format_on_save=False, + multi_cursor_enabled=True): """ Set-up configuration for the CodeEditor instance. @@ -1159,6 +1170,8 @@ def setup_editor(self, Default False. format_on_save: Autoformat file automatically when saving. Default False. + multi_cursor_enabled: Enable/Disable multi-cursor functionality. + Default True """ self.set_close_parentheses_enabled(close_parentheses) @@ -1276,6 +1289,8 @@ def setup_editor(self, self.set_strip_mode(strip_mode) + self.toggle_multi_cursor(multi_cursor_enabled) + # ---- Set different attributes # ------------------------------------------------------------------------- def set_folding_panel(self, folding): @@ -3761,14 +3776,14 @@ def setup_context_menu(self): text=_('Clear all ouput'), icon=self.create_icon('ipython_console'), register_action=False, - triggered=self.clear_all_output # TODO multi-cursor how to consider? + triggered=self.clears_extra_cursors(self.clear_all_output) ) self.ipynb_convert_action = self.create_action( CodeEditorActions.ConvertToPython, text=_('Convert to Python file'), icon=self.create_icon('python'), register_action=False, - triggered=self.convert_notebook # TODO multi-cursor how to consider? + triggered=self.clears_extra_cursors(self.convert_notebook) ) self.gotodef_action = self.create_action( CodeEditorActions.GoToDefinition, @@ -4767,7 +4782,8 @@ def mousePressEvent(self, event: QKeyEvent): pos = event.pos() self._mouse_left_button_pressed = event.button() == Qt.LeftButton - if event.button() == Qt.LeftButton and ctrl and alt: + if (self.multi_cursor_enabled and event.button() == Qt.LeftButton and + ctrl and alt): # ---- Ctrl-Alt: multi-cursor mouse interactions if shift: # Ctrl-Shift-Alt click adds colum of cursors towards primary From f6cbb277b39cec8de2e2dff96d8a7996225fd0f3 Mon Sep 17 00:00:00 2001 From: Aaron Date: Wed, 27 Nov 2024 11:22:37 -0500 Subject: [PATCH 44/95] multi-cursor enter key handling Enter key handling gives us automatic colon insertion and proper auto whitespace. Copied from single cursor key handling and removed extraneous edit block commands as well as completion handling (don't do completion with multi-cursor). --- .../editor/widgets/codeeditor/codeeditor.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 8cd371569fd..123636a160f 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -666,6 +666,46 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): # indent by 1 level at a time. Cursor update order can # make this unpredictable otherwise. self.unindent(force=self.tab_mode) + # ---- handle enter/return + elif key in (Qt.Key_Enter, Qt.Key_Return): + if not shift and not ctrl: + if ( + self.add_colons_enabled and + self.is_python_like() and + self.autoinsert_colons() + ): + self.insert_text(':' + self.get_line_separator()) + if self.strip_trailing_spaces_on_modify: + self.fix_and_strip_indent() + else: + self.fix_indent() + else: + cur_indent = self.get_block_indentation( + self.textCursor().blockNumber()) + self._handle_keypress_event(event) + # Check if we're in a comment or a string at the + # current position + cmt_or_str_cursor = self.in_comment_or_string() + + # Check if the line start with a comment or string + cursor = self.textCursor() + cursor.setPosition(cursor.block().position(), + QTextCursor.KeepAnchor) + cmt_or_str_line_begin = self.in_comment_or_string( + cursor=cursor) + + # Check if we are in a comment or a string + cmt_or_str = cmt_or_str_cursor and \ + cmt_or_str_line_begin + + if self.strip_trailing_spaces_on_modify: + self.fix_and_strip_indent( + comment_or_string=cmt_or_str, + cur_indent=cur_indent) + else: + self.fix_indent(comment_or_string=cmt_or_str, + cur_indent=cur_indent) + # ---- use default handler for cursor (text) else: if key in (Qt.Key.Key_Up, Qt.Key.Key_Left, Qt.Key.Key_Home): From d57f2f3979f3957a77a44ed82e26199474259b8e Mon Sep 17 00:00:00 2001 From: Aaron Date: Mon, 2 Dec 2024 11:35:03 -0500 Subject: [PATCH 45/95] implement multi-cursor move line up/down working with a few known bugs. bugs triggered by actions that should be illogical to the user (such as trying to move a line with multiple cursors, or trying to move multiple lines against the end of the file.) --- .../editor/widgets/codeeditor/codeeditor.py | 105 ++++++++++-------- 1 file changed, 59 insertions(+), 46 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 123636a160f..e3aa9f12fd0 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -4695,56 +4695,69 @@ def move_line_down(self): self.__move_line_or_selection(after_current_line=True) def __move_line_or_selection(self, after_current_line=True): - cursor = self.textCursor() - - # Unfold any folded code block before moving lines up/down - fold_start_line = cursor.blockNumber() + 1 - block = cursor.block().next() - - if fold_start_line in self.folding_panel.folding_status: - fold_status = self.folding_panel.folding_status[fold_start_line] - if fold_status: - self.folding_panel.toggle_fold_trigger(block) + # TODO multi-cursor implementation improperly handles moving multiple + # cursors up against the end of the file (lines get swapped) + # TODO multi-cursor implementation improperly handles multiple cursors + # on the same line. + self.textCursor().beginEditBlock() + sorted_cursors = sorted(self.all_cursors, + key=lambda cursor: cursor.position(), + reverse=after_current_line) + new_cursors = [] + for cursor in sorted_cursors: + self.setTextCursor(cursor) - if after_current_line: - # Unfold any folded region when moving lines down - fold_start_line = cursor.blockNumber() + 2 - block = cursor.block().next().next() + # Unfold any folded code block before moving lines up/down + fold_start_line = cursor.blockNumber() + 1 + block = cursor.block().next() if fold_start_line in self.folding_panel.folding_status: - fold_status = self.folding_panel.folding_status[ - fold_start_line - ] - if fold_status: + if self.folding_panel.folding_status[fold_start_line]: self.folding_panel.toggle_fold_trigger(block) - else: - # Unfold any folded region when moving lines up - block = cursor.block() - offset = 0 - if self.has_selected_text(): - ((selection_start, _), - (selection_end)) = self.get_selection_start_end() - if selection_end != selection_start: - offset = 1 - fold_start_line = block.blockNumber() - 1 - offset - - # Find the innermost code folding region for the current position - enclosing_regions = sorted(list( - self.folding_panel.current_tree[fold_start_line])) - - folding_status = self.folding_panel.folding_status - if len(enclosing_regions) > 0: - for region in enclosing_regions: - fold_start_line = region.begin - block = self.document().findBlockByNumber(fold_start_line) - if fold_start_line in folding_status: - fold_status = folding_status[fold_start_line] - if fold_status: - self.folding_panel.toggle_fold_trigger(block) - - self._TextEditBaseWidget__move_line_or_selection( - after_current_line=after_current_line - ) + + if after_current_line: + # Unfold any folded region when moving lines down + fold_start_line = cursor.blockNumber() + 2 + block = cursor.block().next().next() + + if fold_start_line in self.folding_panel.folding_status: + if self.folding_panel.folding_status[fold_start_line]: + self.folding_panel.toggle_fold_trigger(block) + else: + # Unfold any folded region when moving lines up + block = cursor.block() + offset = 0 + if self.has_selected_text(): + ((selection_start, _), + (selection_end)) = self.get_selection_start_end() + if selection_end != selection_start: + offset = 1 + fold_start_line = block.blockNumber() - 1 - offset + + # Find the innermost code folding region for the current pos + enclosing_regions = sorted(list( + self.folding_panel.current_tree[fold_start_line])) + + folding_status = self.folding_panel.folding_status + if len(enclosing_regions) > 0: + for region in enclosing_regions: + fold_start_line = region.begin + block = self.document().findBlockByNumber( + fold_start_line + ) + if fold_start_line in folding_status: + fold_status = folding_status[fold_start_line] + if fold_status: + self.folding_panel.toggle_fold_trigger(block) + + self._TextEditBaseWidget__move_line_or_selection( + after_current_line=after_current_line + ) + new_cursors.append(self.textCursor()) + self.extra_cursors = new_cursors[:-1] + self.setTextCursor(new_cursors[-1]) + self.merge_extra_cursors(True) + self.textCursor().endEditBlock() def mouseMoveEvent(self, event): """Underline words when pressing """ From 3a80f3b478eaf184bf41d5b0ea43f470cd630511 Mon Sep 17 00:00:00 2001 From: Aaron Date: Mon, 2 Dec 2024 12:14:46 -0500 Subject: [PATCH 46/95] implement smart backspace copied impl from single key handler (should key handling funcs be merged at some point?) --- .../editor/widgets/codeeditor/codeeditor.py | 43 ++++++++++++++++--- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index e3aa9f12fd0..fb776bf3a58 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -624,10 +624,6 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): ctrl = event.modifiers() & Qt.KeyboardModifier.ControlModifier alt = event.modifiers() & Qt.KeyboardModifier.AltModifier shift = event.modifiers() & Qt.KeyboardModifier.ShiftModifier - # TODO handle other keys? - # TODO handle sig_key_pressed for each cursor? - # (maybe not: extra extensions add complexity. - # Keep multi-cursor simpler) # ---- handle insert if key == Qt.Key.Key_Insert and not (ctrl or alt or shift): self.overwrite_mode = not self.overwrite_mode @@ -705,7 +701,41 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): else: self.fix_indent(comment_or_string=cmt_or_str, cur_indent=cur_indent) - + # ---- intelligent backspace handling + elif key == Qt.Key_Backspace and not shift and not ctrl: + increasing_position = False + if self.has_selected_text() or not self.intelligent_backspace: + self._handle_keypress_event(event) + else: + leading_text = self.get_text('sol', 'cursor') + leading_length = len(leading_text) + trailing_spaces = ( + leading_length - len(leading_text.rstrip()) + ) + trailing_text = self.get_text('cursor', 'eol') + matches = ('()', '[]', '{}', '\'\'', '""') + if ( + not leading_text.strip() and + (leading_length > len(self.indent_chars)) + ): + if leading_length % len(self.indent_chars) == 0: + self.unindent() + else: + self._handle_keypress_event(event) + elif trailing_spaces and not trailing_text.strip(): + self.remove_suffix(leading_text[-trailing_spaces:]) + elif ( + leading_text and + trailing_text and + (leading_text[-1] + trailing_text[0] in matches) + ): + cursor = self.textCursor() + cursor.movePosition(QTextCursor.PreviousCharacter) + cursor.movePosition(QTextCursor.NextCharacter, + QTextCursor.KeepAnchor, 2) + cursor.removeSelectedText() + else: + self._handle_keypress_event(event) # ---- use default handler for cursor (text) else: if key in (Qt.Key.Key_Up, Qt.Key.Key_Left, Qt.Key.Key_Home): @@ -714,7 +744,8 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): cursor.verticalMovementX() == -1): # Builtin handler somehow does not set verticalMovementX # when moving up and down (but works fine for single - # cursor somehow) # TODO why + # cursor somehow) + # TODO why? Am I forgetting something? x = self.cursorRect(cursor).x() cursor.setVerticalMovementX(x) self.setTextCursor(cursor) From 4422efc569cd9d304c0232bea686e31e2059bfe3 Mon Sep 17 00:00:00 2001 From: Aaron Date: Mon, 2 Dec 2024 16:13:19 -0500 Subject: [PATCH 47/95] Create test_multicursor.py initial commit to get tests under way. can't figure out qtbot.mousePress at the moment. CodeEditor.mousePressEvent is not called as far as I can tell. --- .../codeeditor/tests/test_multicursor.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py diff --git a/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py b/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py new file mode 100644 index 00000000000..917d52e1bdd --- /dev/null +++ b/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# + +# Standard library imports + +# Third party imports +from qtpy.QtCore import Qt, QPoint + +# Local imports + + +def test_add_cursor(codeeditor, qtbot): + code_editor = codeeditor + # enabled by default arg on CodeEditor.setup_editor (which is called in the + # pytest fixture creation in conftest.py) + assert code_editor.multi_cursor_enabled + code_editor.set_text("0123456789") + qtbot.wait(1000) + x, y = code_editor.get_coordinates(4) + point = code_editor.calculate_real_position(QPoint(x, y)) + + qtbot.mousePress(code_editor, + Qt.MouseButton.LeftButton, + (Qt.KeyboardModifier.ControlModifier | + Qt.KeyboardModifier.AltModifier), + pos=point, + delay=1000) + + # A cursor was added + assert bool(code_editor.extra_cursors) + qtbot.keyClick(code_editor, "a") + # Text was inserted correctly from two cursors + assert code_editor.toPlainText() == "a0123a456789" From 0e61dfe32ca09b890d22b86e656260fb24ee107d Mon Sep 17 00:00:00 2001 From: Aaron Date: Mon, 2 Dec 2024 16:24:31 -0500 Subject: [PATCH 48/95] small tweaks text color and depreciation warning fix --- spyder/plugins/editor/widgets/codeeditor/codeeditor.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index fb776bf3a58..354e3e4fdbf 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -22,7 +22,6 @@ import os import os.path as osp import re -import sre_constants import sys import textwrap import functools @@ -554,7 +553,7 @@ def set_extra_cursor_selections(self): kind="extra_cursor_selection") # TODO get colors from theme? or from stylesheet? - extra_selection.set_foreground(QColor("#ffffff")) + extra_selection.set_foreground(QColor("#dfe1e2")) extra_selection.set_background(QColor("#346792")) selections.append(extra_selection) self.set_extra_selections('extra_cursor_selections', selections) @@ -1964,7 +1963,7 @@ def highlight_found_results(self, pattern, word=False, regexp=False, re_flags = re.MULTILINE if case else re.IGNORECASE | re.MULTILINE try: regobj = re.compile(pattern, flags=re_flags) - except sre_constants.error: + except re.error: return extra_selections = [] @@ -4859,7 +4858,6 @@ def leaveEvent(self, event): 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 From 206891437453300a016be095c270cf2449ce872e Mon Sep 17 00:00:00 2001 From: Aaron Date: Mon, 2 Dec 2024 18:55:13 -0500 Subject: [PATCH 49/95] fix mouse click and add column cursor test --- .../codeeditor/tests/test_multicursor.py | 50 ++++++++++++++----- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py b/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py index 917d52e1bdd..610fbb939db 100644 --- a/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py +++ b/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py @@ -7,30 +7,56 @@ # Standard library imports # Third party imports +import pytest from qtpy.QtCore import Qt, QPoint +from qtpy.QtGui import QTextCursor # Local imports def test_add_cursor(codeeditor, qtbot): - code_editor = codeeditor # enabled by default arg on CodeEditor.setup_editor (which is called in the # pytest fixture creation in conftest.py) - assert code_editor.multi_cursor_enabled - code_editor.set_text("0123456789") - qtbot.wait(1000) - x, y = code_editor.get_coordinates(4) - point = code_editor.calculate_real_position(QPoint(x, y)) + assert codeeditor.multi_cursor_enabled + codeeditor.set_text("0123456789") + qtbot.wait(100) + x, y = codeeditor.get_coordinates(6) + point = codeeditor.calculate_real_position(QPoint(x, y)) + point = QPoint(x, y) - qtbot.mousePress(code_editor, + qtbot.mouseClick(codeeditor.viewport(), Qt.MouseButton.LeftButton, (Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.AltModifier), pos=point, - delay=1000) - + delay=100) # A cursor was added - assert bool(code_editor.extra_cursors) - qtbot.keyClick(code_editor, "a") + assert bool(codeeditor.extra_cursors) + qtbot.keyClick(codeeditor, "a") # Text was inserted correctly from two cursors - assert code_editor.toPlainText() == "a0123a456789" + assert codeeditor.toPlainText() == "a012345a6789" + +def test_column_add_cursor(codeeditor, qtbot): + codeeditor.set_text("0123456789\n0123456789\n0123456789\n0123456789\n") + cursor = codeeditor.textCursor() + cursor.movePosition(QTextCursor.MoveOperation.Down, + QTextCursor.MoveMode.MoveAnchor, + 3) + codeeditor.setTextCursor(cursor) + x, y = codeeditor.get_coordinates(6) + point = codeeditor.calculate_real_position(QPoint(x, y)) + point = QPoint(x, y) + qtbot.mouseClick(codeeditor.viewport(), + Qt.MouseButton.LeftButton, + (Qt.KeyboardModifier.ControlModifier | + Qt.KeyboardModifier.AltModifier | + Qt.KeyboardModifier.ShiftModifier), + pos=point, + delay=100) + assert bool(codeeditor.extra_cursors) + assert len(codeeditor.all_cursors) == 4 + for cursor in codeeditor.all_cursors: + assert cursor.selectedText() == "012345" + +if __name__ == '__main__': + pytest.main(['test_multicursor.py']) From 91ca369e34224699305f538ffb30a36de51fb29b Mon Sep 17 00:00:00 2001 From: Aaron Date: Mon, 2 Dec 2024 19:00:54 -0500 Subject: [PATCH 50/95] fix typo in function used for debugging --- spyder/plugins/editor/panels/codefolding.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spyder/plugins/editor/panels/codefolding.py b/spyder/plugins/editor/panels/codefolding.py index 023d7e3942f..721e2cdabf2 100644 --- a/spyder/plugins/editor/panels/codefolding.py +++ b/spyder/plugins/editor/panels/codefolding.py @@ -803,7 +803,7 @@ def expand_all(self): """Expands all fold triggers.""" block = self.editor.document().firstBlock() while block.isValid(): - line_number = block.BlockNumber() + line_number = block.blockNumber() if line_number in self.folding_regions: end_line = self.folding_regions[line_number] self.unfold_region(block, line_number, end_line) From 7370430500b46e69a2a27b2a73479595ca6bda0c Mon Sep 17 00:00:00 2001 From: Aaron Date: Tue, 3 Dec 2024 18:19:23 -0500 Subject: [PATCH 51/95] re-implement duplicate line to handle folded code. Duplicate line up with multi cursor and selection has known bug for now. (see todo) --- .../editor/widgets/codeeditor/codeeditor.py | 42 +++++++++++++++---- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 354e3e4fdbf..93f951c1c4f 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -1009,10 +1009,8 @@ def register_shortcuts(self): # function definitions instead where possible? ('code completion', self.restrict_single_cursor( self.do_completion)), - ('duplicate line down', self.for_each_cursor( - self.duplicate_line_down)), - ('duplicate line up', self.for_each_cursor( - self.duplicate_line_up)), + ('duplicate line down', self.duplicate_line_down), + ('duplicate line up', self.duplicate_line_up), ('delete line', self.delete_line), ('move line up', self.move_line_up), # TODO multi-cursor ('move line down', self.move_line_down), # TODO multi-cursor @@ -1964,6 +1962,8 @@ def highlight_found_results(self, pattern, word=False, regexp=False, try: regobj = re.compile(pattern, flags=re_flags) except re.error: + # renamed PatternError in 3.13 + # re.error kept for compatibility return extra_selections = [] @@ -3454,9 +3454,9 @@ def unblockcomment(self): def __unblockcomment(self, compatibility=False): """Un-block comment current line or selection helper.""" def __is_comment_bar(cursor): - return to_text_string(cursor.block().text() - ).startswith( - self.__blockcomment_bar(compatibility=compatibility)) + return to_text_string(cursor.block().text()).startswith( + self.__blockcomment_bar(compatibility=compatibility) + ) # Finding first comment bar cursor1 = self.textCursor() if __is_comment_bar(cursor1): @@ -4716,6 +4716,30 @@ def pos_in_line(pos): return N_strip return 0 + def duplicate_line_up(self): + """Duplicate current line or selection""" + # TODO selection anchor is wrong (selects original and new line) if + # selection starts or ends at the beginning of a block (for extra + # cursors only, main cursor is fine). + self._unfold_lines() + self.for_each_cursor(super().duplicate_line_up)() + + def duplicate_line_down(self): + """Duplicate current line or selection""" + self._unfold_lines() + self.for_each_cursor(super().duplicate_line_down)() + + def _unfold_lines(self): + """for each cursor: unfold current line if folded""" + for cursor in self.all_cursors: + # Unfold any folded code block before duplicating lines up/down + fold_start_line = cursor.blockNumber() + 1 + block = cursor.block().next() + + if fold_start_line in self.folding_panel.folding_status: + if self.folding_panel.folding_status[fold_start_line]: + self.folding_panel.toggle_fold_trigger(block) + def move_line_up(self): """Move up current line or selected text""" self.__move_line_or_selection(after_current_line=False) @@ -4779,8 +4803,8 @@ def __move_line_or_selection(self, after_current_line=True): fold_status = folding_status[fold_start_line] if fold_status: self.folding_panel.toggle_fold_trigger(block) - - self._TextEditBaseWidget__move_line_or_selection( + # TODO refactor to eliminate hidden private method call? + self._TextEditBaseWidget__move_line_or_selection( # this is ugly after_current_line=after_current_line ) new_cursors.append(self.textCursor()) From 461380ede5368be5d5ff2eccbc679683db009f73 Mon Sep 17 00:00:00 2001 From: athompson673 Date: Wed, 4 Dec 2024 01:54:36 -0500 Subject: [PATCH 52/95] merge extra cursors on editorstack.advance line --- spyder/plugins/editor/widgets/editorstack/editorstack.py | 1 + 1 file changed, 1 insertion(+) diff --git a/spyder/plugins/editor/widgets/editorstack/editorstack.py b/spyder/plugins/editor/widgets/editorstack/editorstack.py index e6bdf1e9e45..c32e3d67a64 100644 --- a/spyder/plugins/editor/widgets/editorstack/editorstack.py +++ b/spyder/plugins/editor/widgets/editorstack/editorstack.py @@ -2914,6 +2914,7 @@ def advance_line(self): editor.append(editor.get_line_separator()) editor.move_cursor_to_next('line', 'down') + editor.merge_extra_cursors(True) def get_current_cell(self): """Get current cell attributes.""" From 280e903600fd2a0b2848c67a9fa8a52551d31ec4 Mon Sep 17 00:00:00 2001 From: athompson673 Date: Wed, 4 Dec 2024 02:17:38 -0500 Subject: [PATCH 53/95] restrict killring commands to single cursor for now --- .../editor/widgets/codeeditor/codeeditor.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 6f2e023b789..d088c216980 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -1043,12 +1043,17 @@ def register_shortcuts(self): self.create_cursor_callback('PreviousWord'), False)), ('next word', self.for_each_cursor( self.create_cursor_callback('NextWord'))), - ('kill to line end', self.kill_line_end), # TODO multi-cursor - ('kill to line start', self.kill_line_start), # TODO multi-cursor - ('yank', self._kill_ring.yank), # TODO multi-cursor - ('rotate kill ring', self._kill_ring.rotate), # TODO multi-cursor - ('kill previous word', self.kill_prev_word), # TODO multi-cursor - ('kill next word', self.kill_next_word), # TODO multi-cursor + ('kill to line end', self.restrict_single_cursor( + self.kill_line_end)), + ('kill to line start', self.restrict_single_cursor( + self.kill_line_start)), + ('yank', self.restrict_single_cursor(self._kill_ring.yank)), + ('rotate kill ring', self.restrict_single_cursor( + self._kill_ring.rotate)), + ('kill previous word', self.restrict_single_cursor( + self.kill_prev_word)), + ('kill next word', self.restrict_single_cursor( + self.kill_next_word)), ('start of document', self.clears_extra_cursors( self.create_cursor_callback('Start'))), ('end of document', self.clears_extra_cursors( From c30a6e0783a279163ef29637b2833bc994428db9 Mon Sep 17 00:00:00 2001 From: Aaron Date: Wed, 4 Dec 2024 13:08:08 -0500 Subject: [PATCH 54/95] last_edit_position multicursor main_widget.py --- spyder/plugins/editor/widgets/main_widget.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/spyder/plugins/editor/widgets/main_widget.py b/spyder/plugins/editor/widgets/main_widget.py index ca70aa67c25..aeccbfc14c6 100644 --- a/spyder/plugins/editor/widgets/main_widget.py +++ b/spyder/plugins/editor/widgets/main_widget.py @@ -2721,8 +2721,8 @@ def add_cursor_to_history(self, filename=None, cursor=None): self.cursor_undo_history.append((filename, cursor)) self.update_cursorpos_actions() - def text_changed_at(self, filename, position): - self.last_edit_cursor_pos = (to_text_string(filename), position) + def text_changed_at(self, filename, positions): + self.last_edit_cursor_pos = (to_text_string(filename), positions) def current_file_changed(self, filename, position, line, column): editor = self.get_current_editor() @@ -2772,7 +2772,7 @@ def go_to_last_edit_location(self): if self.last_edit_cursor_pos is None: return - filename, position = self.last_edit_cursor_pos + filename, positions = self.last_edit_cursor_pos editor = None if osp.isfile(filename): self.load(filename) @@ -2784,8 +2784,15 @@ def go_to_last_edit_location(self): self.last_edit_cursor_pos = None return - if position < editor.document().characterCount(): - editor.set_cursor_position(position) + character_count = editor.document().characterCount() + if positions[-1] < character_count: + editor.set_cursor_position(positions[-1]) + + for position in positions[:-1]: + if position < character_count: + cursor = editor.textCursor() + cursor.setPosition(position) + editor.add_cursor(cursor) def _pop_next_cursor_diff(self, history, current_filename, current_cursor): """Get the next cursor from history that is different from current.""" From 31c318dda7ab8b8d682fdf51e1c909bf86c37eb7 Mon Sep 17 00:00:00 2001 From: Aaron Date: Wed, 4 Dec 2024 13:08:49 -0500 Subject: [PATCH 55/95] last_edit_position multicursor editorstack.py --- spyder/plugins/editor/widgets/editorstack/editorstack.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spyder/plugins/editor/widgets/editorstack/editorstack.py b/spyder/plugins/editor/widgets/editorstack/editorstack.py index c32e3d67a64..26de678a6ee 100644 --- a/spyder/plugins/editor/widgets/editorstack/editorstack.py +++ b/spyder/plugins/editor/widgets/editorstack/editorstack.py @@ -124,7 +124,7 @@ class EditorStack(QWidget, SpyderWidgetMixin): sig_update_code_analysis_actions = Signal() refresh_file_dependent_actions = Signal() refresh_save_all_action = Signal() - text_changed_at = Signal(str, int) + text_changed_at = Signal(str, tuple) current_file_changed = Signal(str, int, int, int) plugin_load = Signal((str,), ()) edit_goto = Signal(str, int, str) @@ -2590,8 +2590,8 @@ def create_new_editor(self, fname, enc, txt, set_current, new=False, editor.set_text(txt) editor.document().setModified(False) finfo.text_changed_at.connect( - lambda fname, position: - self.text_changed_at.emit(fname, position)) + lambda fname, positions: + self.text_changed_at.emit(fname, positions)) editor.sig_cursor_position_changed.connect( self.editor_cursor_position_changed) editor.textChanged.connect(self.start_stop_analysis_timer) From 03b8f66dcc847e962c48487ed3873c4f77c3aa73 Mon Sep 17 00:00:00 2001 From: Aaron Date: Wed, 4 Dec 2024 13:09:20 -0500 Subject: [PATCH 56/95] last_edit_position multicursor helpers.py --- spyder/plugins/editor/widgets/editorstack/helpers.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/spyder/plugins/editor/widgets/editorstack/helpers.py b/spyder/plugins/editor/widgets/editorstack/helpers.py index 26bd90fd610..6e18d48345b 100644 --- a/spyder/plugins/editor/widgets/editorstack/helpers.py +++ b/spyder/plugins/editor/widgets/editorstack/helpers.py @@ -120,7 +120,7 @@ class FileInfo(QObject): """File properties.""" todo_results_changed = Signal() sig_save_bookmarks = Signal(str, str) - text_changed_at = Signal(str, int) + text_changed_at = Signal(str, tuple) edit_goto = Signal(str, int, str) sig_send_to_help = Signal(str, str, bool) sig_filename_changed = Signal(str) @@ -163,8 +163,10 @@ def filename(self, value): def text_changed(self): """Editor's text has changed.""" self.default = False + all_cursors = self.editor.all_cursors + positions = tuple(cursor.position() for cursor in all_cursors) self.text_changed_at.emit(self.filename, - self.editor.get_position('cursor')) + positions) def get_source_code(self): """Return associated editor source code.""" From 028fbf7a77ad4ffa5a3711dfa2a27c6ce4e291df Mon Sep 17 00:00:00 2001 From: Aaron Date: Thu, 5 Dec 2024 14:02:04 -0500 Subject: [PATCH 57/95] ensure extra cursors aren't added if multicursor is disabled --- spyder/plugins/editor/widgets/codeeditor/codeeditor.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index d088c216980..2c548c19f95 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -543,8 +543,9 @@ def toggle_multi_cursor(self, enabled): def add_cursor(self, cursor: QTextCursor): """Add this cursor to the list of extra cursors""" - self.extra_cursors.append(cursor) - self.merge_extra_cursors(True) + if self.multi_cursor_enabled: + self.extra_cursors.append(cursor) + self.merge_extra_cursors(True) def set_extra_cursor_selections(self): selections = [] @@ -1012,8 +1013,8 @@ def register_shortcuts(self): ('duplicate line down', self.duplicate_line_down), ('duplicate line up', self.duplicate_line_up), ('delete line', self.delete_line), - ('move line up', self.move_line_up), # TODO multi-cursor - ('move line down', self.move_line_down), # TODO multi-cursor + ('move line up', self.move_line_up), + ('move line down', self.move_line_down), ('go to new line', self.for_each_cursor(self.go_to_new_line)), ('go to definition', self.go_to_definition_from_cursor), ('toggle comment', self.for_each_cursor(self.toggle_comment)), From 38c5a1feca0f876b00c2d98f555e3e0015ae6aa3 Mon Sep 17 00:00:00 2001 From: Aaron Date: Thu, 5 Dec 2024 20:02:51 -0500 Subject: [PATCH 58/95] multicursor cursor position history codeeditor.py --- .../editor/widgets/codeeditor/codeeditor.py | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 2c548c19f95..241bbdc63b6 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -532,7 +532,7 @@ def init_multi_cursor(self): self.focus_in.connect(self.start_cursor_blink) self.focus_changed.connect(self.stop_cursor_blink) self.painted.connect(self.paint_cursors) - # self.sig_key_pressed.connect(self.handle_multi_cursor_keypress) + self.multi_cursor_ignore_history = False def toggle_multi_cursor(self, enabled): """Enable/Disable multi-cursor editing""" @@ -573,7 +573,8 @@ def merge_extra_cursors(self, increasing_position): """Merge overlapping cursors""" if not self.extra_cursors: return - + previous_history = self.multi_cursor_ignore_history + self.multi_cursor_ignore_history = True while True: cursor_was_removed = False @@ -615,6 +616,7 @@ def merge_extra_cursors(self, increasing_position): if not cursor_was_removed: break self.set_extra_cursor_selections() + self.multi_cursor_ignore_history = previous_history @Slot(QKeyEvent) def handle_multi_cursor_keypress(self, event: QKeyEvent): @@ -630,6 +632,7 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): return self.textCursor().beginEditBlock() + self.multi_cursor_ignore_history = True cursors = [] accepted = [] @@ -756,6 +759,8 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): self.extra_cursors = new_cursors[:-1] self.merge_extra_cursors(increasing_position) self.textCursor().endEditBlock() + self.multi_cursor_ignore_history = False + self.cursorPositionChanged.emit() event.accept() # TODO when to pass along keypress or not def _on_cursor_blinktimer_timeout(self): @@ -853,11 +858,14 @@ def multi_cursor_paste(self, clip_text): lines = clip_text.splitlines() if len(lines) == 1: lines = itertools.repeat(lines[0]) + self.multi_cursor_ignore_history = True for cursor, text in zip(cursors, lines): self.setTextCursor(cursor) cursor.insertText(text) # handle extra lines or extra cursors? self.setTextCursor(main_cursor) + self.multi_cursor_ignore_history = False + self.cursorPositionChanged.emit() # merge direction doesn't matter here as all selections are removed self.merge_extra_cursors(True) main_cursor.endEditBlock() @@ -870,6 +878,7 @@ def for_each_cursor(self, method, merge_increasing=True): def wrapper(): self.textCursor().beginEditBlock() new_cursors = [] + self.multi_cursor_ignore_history = True for cursor in self.all_cursors: self.setTextCursor(cursor) # may call setTtextCursor with modified copy @@ -879,11 +888,13 @@ def wrapper(): # re-add extra cursors self.clear_extra_cursors() - self.setTextCursor(new_cursors[-1]) for cursor in new_cursors[:-1]: self.add_cursor(cursor) + self.setTextCursor(new_cursors[-1]) self.merge_extra_cursors(merge_increasing) self.textCursor().endEditBlock() + self.multi_cursor_ignore_history = False + self.cursorPositionChanged.emit() return wrapper def clears_extra_cursors(self, method): @@ -1748,6 +1759,7 @@ def delete(self): """Remove selected text or next character.""" self.textCursor().beginEditBlock() new_cursors = [] + self.multi_cursor_ignore_history = True for cursor in self.all_cursors: self.setTextCursor(cursor) self.sig_delete_requested.emit() @@ -1761,10 +1773,13 @@ def delete(self): self.extra_cursors = new_cursors[:-1] self.merge_extra_cursors(True) self.textCursor().endEditBlock() + self.multi_cursor_ignore_history = False + self.cursorPositionChanged.emit() def delete_line(self): """Delete current line.""" self.textCursor().beginEditBlock() + self.multi_cursor_ignore_history = True cursors = [] for cursor in self.all_cursors: start, end = cursor.selectionStart(), cursor.selectionEnd() @@ -1794,6 +1809,8 @@ def delete_line(self): self.merge_extra_cursors(True) self.textCursor().endEditBlock() self.ensureCursorVisible() + self.multi_cursor_ignore_history = False + self.cursorPositionChanged.emit() # ---- Scrolling # ------------------------------------------------------------------------- @@ -4758,6 +4775,7 @@ def __move_line_or_selection(self, after_current_line=True): # TODO multi-cursor implementation improperly handles multiple cursors # on the same line. self.textCursor().beginEditBlock() + self.multi_cursor_ignore_history = True sorted_cursors = sorted(self.all_cursors, key=lambda cursor: cursor.position(), reverse=after_current_line) @@ -4816,6 +4834,8 @@ def __move_line_or_selection(self, after_current_line=True): self.setTextCursor(new_cursors[-1]) self.merge_extra_cursors(True) self.textCursor().endEditBlock() + self.multi_cursor_ignore_history = False + self.cursorPositionChanged.emit() def mouseMoveEvent(self, event): """Underline words when pressing """ @@ -4895,6 +4915,7 @@ def mousePressEvent(self, event: QKeyEvent): if (self.multi_cursor_enabled and event.button() == Qt.LeftButton and ctrl and alt): # ---- Ctrl-Alt: multi-cursor mouse interactions + self.multi_cursor_ignore_history = True if shift: # Ctrl-Shift-Alt click adds colum of cursors towards primary # cursor @@ -4970,6 +4991,8 @@ def mousePressEvent(self, event: QKeyEvent): if not removed_cursor: self.setTextCursor(new_cursor) self.add_cursor(old_cursor) + self.multi_cursor_ignore_history = False + self.cursorPositionChanged.emit() else: # ---- not multi-cursor if event.button() == Qt.MouseButton.LeftButton: From cd9b2537114a111733024b233e14e0876e04477b Mon Sep 17 00:00:00 2001 From: Aaron Date: Thu, 5 Dec 2024 20:03:34 -0500 Subject: [PATCH 59/95] multi-cursor cursor position history main_widget.py --- spyder/plugins/editor/widgets/main_widget.py | 124 ++++++++++--------- 1 file changed, 68 insertions(+), 56 deletions(-) diff --git a/spyder/plugins/editor/widgets/main_widget.py b/spyder/plugins/editor/widgets/main_widget.py index aeccbfc14c6..a7d2d8239cf 100644 --- a/spyder/plugins/editor/widgets/main_widget.py +++ b/spyder/plugins/editor/widgets/main_widget.py @@ -954,8 +954,9 @@ def setup(self): current_editor = self.get_current_editor() if current_editor is not None: filename = self.get_current_filename() - cursor = current_editor.textCursor() - self.add_cursor_to_history(filename, cursor) + cursors = tuple(current_editor.all_cursors) + if not current_editor.multi_cursor_ignore_history: + self.add_cursor_to_history(filename, cursors) self.update_cursorpos_actions() # TODO: How the maintoolbar should be handled? @@ -2698,18 +2699,25 @@ def add_cursor_to_history(self, filename=None, cursor=None): return if filename is None: filename = self.get_current_filename() - if cursor is None: + if isinstance(cursor, tuple): + cursors = cursor + elif cursor is None: editor = self.get_editor(filename) if editor is None: return - cursor = editor.textCursor() + cursors = tuple(editor.all_cursors) + else: + cursors = (cursor,) replace_last_entry = False if len(self.cursor_undo_history) > 0: - fname, hist_cursor = self.cursor_undo_history[-1] - if fname == filename: - if cursor.blockNumber() == hist_cursor.blockNumber(): - # Only one cursor per line + fname, hist_cursors = self.cursor_undo_history[-1] + if fname == filename and len(cursors) == len(hist_cursors): + for cursor, hist_cursor in zip(cursors, hist_cursors): + if not cursor.blockNumber() == hist_cursor.blockNumber(): + break # If any cursor is now on a different line + else: + # No cursors have changed line replace_last_entry = True if replace_last_entry: @@ -2718,7 +2726,8 @@ def add_cursor_to_history(self, filename=None, cursor=None): # Drop redo stack as we moved self.cursor_redo_history = [] - self.cursor_undo_history.append((filename, cursor)) + self.cursor_undo_history.append((filename, cursors)) + print([cursor.position() for cursor in cursors]) self.update_cursorpos_actions() def text_changed_at(self, filename, positions): @@ -2730,8 +2739,9 @@ def current_file_changed(self, filename, position, line, column): # Needed to validate if an editor exists. # See spyder-ide/spyder#20643 if editor: - cursor = editor.textCursor() - self.add_cursor_to_history(to_text_string(filename), cursor) + cursors = tuple(editor.all_cursors) + if not editor.multi_cursor_ignore_history: + self.add_cursor_to_history(to_text_string(filename), cursors) # Hide any open tooltips current_stack = self.get_current_editorstack() @@ -2747,24 +2757,24 @@ def current_editor_cursor_changed(self, line, column): if editor: code_editor = self.get_current_editor() filename = code_editor.filename - cursor = code_editor.textCursor() - self.add_cursor_to_history( - to_text_string(filename), cursor) + cursors = tuple(code_editor.all_cursors) + if not editor.multi_cursor_ignore_history: + self.add_cursor_to_history(to_text_string(filename), cursors) def remove_file_cursor_history(self, id, filename): """Remove the cursor history of a file if the file is closed.""" new_history = [] - for i, (cur_filename, cursor) in enumerate( + for i, (cur_filename, cursors) in enumerate( self.cursor_undo_history): if cur_filename != filename: - new_history.append((cur_filename, cursor)) + new_history.append((cur_filename, cursors)) self.cursor_undo_history = new_history new_redo_history = [] - for i, (cur_filename, cursor) in enumerate( + for i, (cur_filename, cursors) in enumerate( self.cursor_redo_history): if cur_filename != filename: - new_redo_history.append((cur_filename, cursor)) + new_redo_history.append((cur_filename, cursors)) self.cursor_redo_history = new_redo_history @Slot() @@ -2794,38 +2804,41 @@ def go_to_last_edit_location(self): cursor.setPosition(position) editor.add_cursor(cursor) - def _pop_next_cursor_diff(self, history, current_filename, current_cursor): + def _pop_next_cursor_diff(self, history, current_filename, + current_cursors): """Get the next cursor from history that is different from current.""" while history: - filename, cursor = history.pop() - if (filename != current_filename or - cursor.position() != current_cursor.position()): - return filename, cursor + filename, cursors = history.pop() + if filename != current_filename: + return filename, cursors + if len(cursors) != len(current_cursors): + return filename, cursors + for cursor, current_cursor in zip(cursors, current_cursors): + if cursor.position() != current_cursor.position(): + return filename, cursors return None, None - def _history_steps(self, number_steps, - backwards_history, forwards_history, - current_filename, current_cursor): + def _history_step(self, backwards_history, forwards_history, + current_filename, current_cursors): """ Move number_steps in the forwards_history, filling backwards_history. """ - for i in range(number_steps): - if len(forwards_history) > 0: - # Put the current cursor in history - backwards_history.append( - (current_filename, current_cursor)) - # Extract the next different cursor - current_filename, current_cursor = ( - self._pop_next_cursor_diff( - forwards_history, - current_filename, current_cursor)) - if current_cursor is None: + if len(forwards_history) > 0: + # Put the current cursor in history + backwards_history.append( + (current_filename, current_cursors)) + # Extract the next different cursor + current_filename, current_cursors = ( + self._pop_next_cursor_diff( + forwards_history, + current_filename, current_cursors)) + if current_cursors is None: # Went too far, back up once - current_filename, current_cursor = ( + current_filename, current_cursors = ( backwards_history.pop()) - return current_filename, current_cursor + return current_filename, current_cursors - def __move_cursor_position(self, index_move): + def __move_cursor_position(self, undo: bool): """ Move the cursor position forward or backward in the cursor position history by the specified index increment. @@ -2837,27 +2850,22 @@ def __move_cursor_position(self, index_move): # Update last position on the line current_filename = self.get_current_filename() - current_cursor = self.get_current_editor().textCursor() + current_cursors = tuple(self.get_current_editor().all_cursors) - if index_move < 0: - # Undo - current_filename, current_cursor = self._history_steps( - -index_move, + if undo: + current_filename, current_cursors = self._history_step( self.cursor_redo_history, self.cursor_undo_history, - current_filename, current_cursor) - - else: - # Redo - current_filename, current_cursor = self._history_steps( - index_move, + current_filename, current_cursors) + else: # Redo + current_filename, current_cursors = self._history_step( self.cursor_undo_history, self.cursor_redo_history, - current_filename, current_cursor) + current_filename, current_cursors) # Place current cursor in history self.cursor_undo_history.append( - (current_filename, current_cursor)) + (current_filename, current_cursors)) filenames = self.get_current_editorstack().get_filenames() if (not osp.isfile(current_filename) and current_filename not in filenames): @@ -2865,7 +2873,11 @@ def __move_cursor_position(self, index_move): else: self.load(current_filename) editor = self.get_current_editor() - editor.setTextCursor(current_cursor) + editor.clear_extra_cursors() + editor.setTextCursor(current_cursors[-1]) + for cursor in current_cursors[:-1]: + editor.add_cursor(cursor) + editor.merge_extra_cursors(True) editor.ensureCursorVisible() self.__ignore_cursor_history = False self.update_cursorpos_actions() @@ -2880,13 +2892,13 @@ def create_cell(self): def go_to_previous_cursor_position(self): self.__ignore_cursor_history = True self.switch_to_plugin() - self.__move_cursor_position(-1) + self.__move_cursor_position(undo=True) @Slot() def go_to_next_cursor_position(self): self.__ignore_cursor_history = True self.switch_to_plugin() - self.__move_cursor_position(1) + self.__move_cursor_position(undo=False) @Slot() def go_to_line(self, line=None): From 7439758aa860b71eabfb7c0138e16a81528820d9 Mon Sep 17 00:00:00 2001 From: Aaron Date: Thu, 5 Dec 2024 20:06:27 -0500 Subject: [PATCH 60/95] remove print debug --- spyder/plugins/editor/widgets/main_widget.py | 1 - 1 file changed, 1 deletion(-) diff --git a/spyder/plugins/editor/widgets/main_widget.py b/spyder/plugins/editor/widgets/main_widget.py index a7d2d8239cf..6fcc70483e6 100644 --- a/spyder/plugins/editor/widgets/main_widget.py +++ b/spyder/plugins/editor/widgets/main_widget.py @@ -2727,7 +2727,6 @@ def add_cursor_to_history(self, filename=None, cursor=None): self.cursor_redo_history = [] self.cursor_undo_history.append((filename, cursors)) - print([cursor.position() for cursor in cursors]) self.update_cursorpos_actions() def text_changed_at(self, filename, positions): From 9b2ff7c1b9a5c04b0f8e689a9148be5ba903d2b1 Mon Sep 17 00:00:00 2001 From: Aaron Date: Thu, 5 Dec 2024 21:58:02 -0500 Subject: [PATCH 61/95] Update test_plugin.py for multi-cursor position history --- spyder/plugins/editor/tests/test_plugin.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/spyder/plugins/editor/tests/test_plugin.py b/spyder/plugins/editor/tests/test_plugin.py index bcf72d8b5ac..2813910ba70 100644 --- a/spyder/plugins/editor/tests/test_plugin.py +++ b/spyder/plugins/editor/tests/test_plugin.py @@ -294,7 +294,9 @@ def test_go_to_prev_next_cursor_position(editor_plugin, python_files): for history, expected_history in zip(main_widget.cursor_undo_history, expected_cursor_undo_history): assert history[0] == expected_history[0] - assert history[1].position() == expected_history[1] + # history[1] is a tuple of editor.all_cursor(s) + # only a single cursor is expected for this test + assert history[1][0].position() == expected_history[1] # Navigate to previous and next cursor positions. @@ -318,11 +320,11 @@ def test_go_to_prev_next_cursor_position(editor_plugin, python_files): for history, expected_history in zip(main_widget.cursor_undo_history, expected_cursor_undo_history[:1]): assert history[0] == expected_history[0] - assert history[1].position() == expected_history[1] + assert history[1][0].position() == expected_history[1] for history, expected_history in zip(main_widget.cursor_redo_history, expected_cursor_undo_history[:0:-1]): assert history[0] == expected_history[0] - assert history[1].position() == expected_history[1] + assert history[1][0].position() == expected_history[1] # So we are now expected to be at index 0 in the cursor position history. # From there, we go to the fourth file. @@ -337,7 +339,7 @@ def test_go_to_prev_next_cursor_position(editor_plugin, python_files): for history, expected_history in zip(main_widget.cursor_undo_history, expected_cursor_undo_history): assert history[0] == expected_history[0] - assert history[1].position() == expected_history[1] + assert history[1][0].position() == expected_history[1] assert main_widget.cursor_redo_history == [] From e8393e54e94ceddc9d7960f3912d4691595f40c6 Mon Sep 17 00:00:00 2001 From: athompson673 Date: Thu, 5 Dec 2024 22:58:58 -0500 Subject: [PATCH 62/95] added setting toggle for multicursor support --- spyder/config/main.py | 1 + spyder/plugins/editor/confpage.py | 25 ++++++++++++++++--- spyder/plugins/editor/tests/test_plugin.py | 10 +++++--- .../editor/widgets/editorstack/editorstack.py | 11 +++++++- spyder/plugins/editor/widgets/main_widget.py | 1 + 5 files changed, 39 insertions(+), 9 deletions(-) diff --git a/spyder/config/main.py b/spyder/config/main.py index ec6f7fe8df0..c61ff70b750 100644 --- a/spyder/config/main.py +++ b/spyder/config/main.py @@ -245,6 +245,7 @@ 'check_eol_chars': True, 'convert_eol_on_save': False, 'convert_eol_on_save_to': 'LF', + 'multicursor_support': True, 'tab_always_indent': False, 'intelligent_backspace': True, 'automatic_completions': True, diff --git a/spyder/plugins/editor/confpage.py b/spyder/plugins/editor/confpage.py index 01e6ee15841..23f8606aad1 100644 --- a/spyder/plugins/editor/confpage.py +++ b/spyder/plugins/editor/confpage.py @@ -62,7 +62,7 @@ def get_icon(self): def setup_page(self): newcb = self.create_checkbox - # --- Display tab --- + # ---- Display tab showtabbar_box = newcb(_("Show tab bar"), 'show_tab_bar') showclassfuncdropdown_box = newcb( _("Show selector for classes and functions"), @@ -130,7 +130,7 @@ def setup_page(self): other_layout.addWidget(scroll_past_end_box) other_group.setLayout(other_layout) - # --- Source code tab --- + # ---- Source code tab closepar_box = newcb( _("Automatic insertion of parentheses, braces and brackets"), 'close_parentheses') @@ -242,7 +242,7 @@ def enable_tabwidth_spin(index): indentation_layout.addWidget(tab_mode_box) indentation_group.setLayout(indentation_layout) - # --- Advanced tab --- + # ---- Advanced tab # -- Templates templates_group = QGroupBox(_('Templates')) template_btn = self.create_button( @@ -361,6 +361,23 @@ def enable_tabwidth_spin(index): eol_layout.addLayout(eol_on_save_layout) eol_group.setLayout(eol_layout) + # -- Multi-cursor + multicursor_group = QGroupBox(_("Multi-Cursor")) + multicursor_label = QLabel( + _("Enable adding multiple cursors for simultaneous editing. " + "Additional cursors are added and removed using the Ctrl-Alt " + "click shortcut. A column of cursors can be added using the " + "Ctrl-Alt-Shift click shortcut.")) + multicursor_label.setWordWrap(True) + multicursor_box = newcb( + _("Enable Multi-Cursor "), + 'multicursor_support') + + multicursor_layout = QVBoxLayout() + multicursor_layout.addWidget(multicursor_label) + multicursor_layout.addWidget(multicursor_box) + multicursor_group.setLayout(multicursor_layout) + # --- Tabs --- self.create_tab( _("Interface"), @@ -372,7 +389,7 @@ def enable_tabwidth_spin(index): self.create_tab( _("Advanced settings"), [templates_group, autosave_group, docstring_group, - annotations_group, eol_group] + annotations_group, eol_group, multicursor_group] ) @on_conf_change( diff --git a/spyder/plugins/editor/tests/test_plugin.py b/spyder/plugins/editor/tests/test_plugin.py index bcf72d8b5ac..2813910ba70 100644 --- a/spyder/plugins/editor/tests/test_plugin.py +++ b/spyder/plugins/editor/tests/test_plugin.py @@ -294,7 +294,9 @@ def test_go_to_prev_next_cursor_position(editor_plugin, python_files): for history, expected_history in zip(main_widget.cursor_undo_history, expected_cursor_undo_history): assert history[0] == expected_history[0] - assert history[1].position() == expected_history[1] + # history[1] is a tuple of editor.all_cursor(s) + # only a single cursor is expected for this test + assert history[1][0].position() == expected_history[1] # Navigate to previous and next cursor positions. @@ -318,11 +320,11 @@ def test_go_to_prev_next_cursor_position(editor_plugin, python_files): for history, expected_history in zip(main_widget.cursor_undo_history, expected_cursor_undo_history[:1]): assert history[0] == expected_history[0] - assert history[1].position() == expected_history[1] + assert history[1][0].position() == expected_history[1] for history, expected_history in zip(main_widget.cursor_redo_history, expected_cursor_undo_history[:0:-1]): assert history[0] == expected_history[0] - assert history[1].position() == expected_history[1] + assert history[1][0].position() == expected_history[1] # So we are now expected to be at index 0 in the cursor position history. # From there, we go to the fourth file. @@ -337,7 +339,7 @@ def test_go_to_prev_next_cursor_position(editor_plugin, python_files): for history, expected_history in zip(main_widget.cursor_undo_history, expected_cursor_undo_history): assert history[0] == expected_history[0] - assert history[1].position() == expected_history[1] + assert history[1][0].position() == expected_history[1] assert main_widget.cursor_redo_history == [] diff --git a/spyder/plugins/editor/widgets/editorstack/editorstack.py b/spyder/plugins/editor/widgets/editorstack/editorstack.py index 26de678a6ee..c57e565e250 100644 --- a/spyder/plugins/editor/widgets/editorstack/editorstack.py +++ b/spyder/plugins/editor/widgets/editorstack/editorstack.py @@ -1091,6 +1091,14 @@ def set_convert_eol_on_save_to(self, state): """`state` can be one of ('LF', 'CRLF', 'CR')""" self.convert_eol_on_save_to = state + @on_conf_change(option='multicursor_support') + def set_multicursor_support(self, state): + """If `state` is `True`, multi-cursor editing is enabled.""" + self.multicursor_support = state + if self.data: + for finfo in self.data: + finfo.editor.toggle_multi_cursor(state) + def set_current_project_path(self, root_path=None): """ Set the current active project root path. @@ -2583,7 +2591,8 @@ def create_new_editor(self, fname, enc, txt, set_current, new=False, remove_trailing_spaces=self.always_remove_trailing_spaces, remove_trailing_newlines=self.remove_trailing_newlines, add_newline=self.add_newline, - format_on_save=self.format_on_save + format_on_save=self.format_on_save, + multi_cursor_enabled=self.multicursor_support ) if cloned_from is None: diff --git a/spyder/plugins/editor/widgets/main_widget.py b/spyder/plugins/editor/widgets/main_widget.py index 6fcc70483e6..10f924029bf 100644 --- a/spyder/plugins/editor/widgets/main_widget.py +++ b/spyder/plugins/editor/widgets/main_widget.py @@ -1541,6 +1541,7 @@ def register_editorstack(self, editorstack): ('set_add_newline', 'add_newline'), ('set_convert_eol_on_save', 'convert_eol_on_save'), ('set_convert_eol_on_save_to', 'convert_eol_on_save_to'), + ('set_multicursor_support', 'multicursor_support'), ) for method, setting in settings: From 34325028bea39c255431fbcb1aaec317376be43b Mon Sep 17 00:00:00 2001 From: athompson673 Date: Thu, 5 Dec 2024 23:29:06 -0500 Subject: [PATCH 63/95] init editorstack with multicursor_support attr --- spyder/plugins/editor/widgets/codeeditor/codeeditor.py | 1 + spyder/plugins/editor/widgets/editorstack/editorstack.py | 1 + 2 files changed, 2 insertions(+) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 241bbdc63b6..86637edd7c9 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -774,6 +774,7 @@ def _on_cursor_blinktimer_timeout(self): @Slot(QPaintEvent) def paint_cursors(self, event): """Paint all cursors""" + # TODO Critical: timer and cursor width sync with multiple editorstacks if self.overwrite_mode: font = self.textCursor().block().charFormat().font() cursor_width = QFontMetrics(font).horizontalAdvance(" ") diff --git a/spyder/plugins/editor/widgets/editorstack/editorstack.py b/spyder/plugins/editor/widgets/editorstack/editorstack.py index c57e565e250..6ef3105ab98 100644 --- a/spyder/plugins/editor/widgets/editorstack/editorstack.py +++ b/spyder/plugins/editor/widgets/editorstack/editorstack.py @@ -351,6 +351,7 @@ def __init__(self, parent, actions, use_switcher=True): self.remove_trailing_newlines = False self.convert_eol_on_save = False self.convert_eol_on_save_to = 'LF' + self.multicursor_support = True self.create_new_file_if_empty = True self.indent_guides = False self.__file_status_flag = False From 13e00850bf3c16d6d977e04285d6b703d42dc8b1 Mon Sep 17 00:00:00 2001 From: Aaron Date: Fri, 6 Dec 2024 14:30:27 -0500 Subject: [PATCH 64/95] fix cursor rendering/sync with multiple instances of same document visible --- .../editor/widgets/codeeditor/codeeditor.py | 56 +++++++++++++------ 1 file changed, 40 insertions(+), 16 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 86637edd7c9..e7b25be8b86 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -533,6 +533,7 @@ def init_multi_cursor(self): self.focus_changed.connect(self.stop_cursor_blink) self.painted.connect(self.paint_cursors) self.multi_cursor_ignore_history = False + self._drag_cursor = None def toggle_multi_cursor(self, enabled): """Enable/Disable multi-cursor editing""" @@ -781,13 +782,6 @@ def paint_cursors(self, event): else: cursor_width = self.cursor_width - if not self.extra_cursors: - # Revert to builtin cursor rendering if single cursor to handle - # cursor drawing while dragging a selection of text around. - self.setCursorWidth(cursor_width) - return - - self.setCursorWidth(0) qp = QPainter() qp.begin(self.viewport()) offset = self.contentOffset() @@ -796,6 +790,17 @@ def paint_cursors(self, event): editable = not self.isReadOnly() flags = (self.textInteractionFlags() & Qt.TextInteractionFlag.TextSelectableByKeyboard) + + if self._drag_cursor is not None and (editable or flags): + cursor = self._drag_cursor + block = cursor.block() + if block.isVisible(): + block_top = int(self.blockBoundingGeometry(block).top()) + offset.setY(block_top + content_offset_y) + block.layout().drawCursor(qp, offset, + cursor.positionInBlock(), + cursor_width) + draw_cursor = self.cursor_blink_state and (editable or flags) for cursor in self.all_cursors: @@ -4910,7 +4915,7 @@ def mousePressEvent(self, event: QKeyEvent): ctrl = event.modifiers() & Qt.KeyboardModifier.ControlModifier alt = event.modifiers() & Qt.KeyboardModifier.AltModifier shift = event.modifiers() & Qt.KeyboardModifier.ShiftModifier - pos = event.pos() + cursor_for_pos = self.cursorForPosition(event.pos()) self._mouse_left_button_pressed = event.button() == Qt.LeftButton if (self.multi_cursor_enabled and event.button() == Qt.LeftButton and @@ -4923,9 +4928,8 @@ def mousePressEvent(self, event: QKeyEvent): first_cursor = self.textCursor() anchor_block = first_cursor.block() anchor_col = first_cursor.anchor() - anchor_block.position() - second_cursor = self.cursorForPosition(pos) - pos_block = second_cursor.block() - pos_col = second_cursor.positionInBlock() + pos_block = cursor_for_pos.block() + pos_col = cursor_for_pos.positionInBlock() # Move primary cursor to pos_col p_col = min(len(anchor_block.text()), pos_col) @@ -4962,14 +4966,13 @@ def mousePressEvent(self, event: QKeyEvent): # move existing primary cursor to extra_cursors list and set # new primary cursor old_cursor = self.textCursor() - new_cursor = self.cursorForPosition(pos) removed_cursor = False # don't attempt to remove cursor if there's only one if self.extra_cursors: same_cursor = None for cursor in self.all_cursors: - if new_cursor.position() == cursor.position(): + if cursor_for_pos.position() == cursor.position(): same_cursor = cursor break if same_cursor is not None: @@ -4990,7 +4993,7 @@ def mousePressEvent(self, event: QKeyEvent): self.set_extra_cursor_selections() if not removed_cursor: - self.setTextCursor(new_cursor) + self.setTextCursor(cursor_for_pos) self.add_cursor(old_cursor) self.multi_cursor_ignore_history = False self.cursorPositionChanged.emit() @@ -5000,12 +5003,11 @@ def mousePressEvent(self, event: QKeyEvent): self.clear_extra_cursors() if event.button() == Qt.LeftButton and ctrl: TextEditBaseWidget.mousePressEvent(self, event) - cursor = self.cursorForPosition(pos) uri = self._last_hover_pattern_text if uri: self.go_to_uri_from_cursor(uri) else: - self.go_to_definition_from_cursor(cursor) + self.go_to_definition_from_cursor(cursor_for_pos) elif event.button() == Qt.LeftButton and alt: self.sig_alt_left_mouse_pressed.emit(event) else: @@ -5083,6 +5085,7 @@ def dragEnterEvent(self, event): Inform Qt about the types of data that the widget accepts. """ logger.debug("dragEnterEvent was received") + self._drag_cursor = self.cursorForPosition(event.pos()) all_urls = mimedata2url(event.mimeData()) if all_urls: # Let the parent widget handle this @@ -5092,6 +5095,15 @@ def dragEnterEvent(self, event): logger.debug("Call TextEditBaseWidget dragEnterEvent method") TextEditBaseWidget.dragEnterEvent(self, event) + def dragMoveEvent(self, event): + """ + Reimplemented Qt method. + + Keep track of drag cursor while dragging + """ + self._drag_cursor = self.cursorForPosition(event.pos()) + TextEditBaseWidget.dragMoveEvent(self, event) + def dropEvent(self, event): """ Reimplemented Qt method. @@ -5099,6 +5111,7 @@ def dropEvent(self, event): Unpack dropped data and handle it. """ logger.debug("dropEvent was received") + self._drag_cursor = None if mimedata2url(event.mimeData()): logger.debug("Let the parent widget handle this") event.ignore() @@ -5106,6 +5119,17 @@ def dropEvent(self, event): logger.debug("Call TextEditBaseWidget dropEvent method") TextEditBaseWidget.dropEvent(self, event) + def dragLeaveEvent(self, event): + """ + Reimplemented Qt method. + + Stop tracking of drag cursor when drag leaves + """ + self._drag_cursor = None + TextEditBaseWidget.dragLeaveEvent(self, event) + # lost focus: need to manually paint to un-draw drag cursor + self.viewport().update() + # ---- Paint event # ------------------------------------------------------------------------- def paintEvent(self, event): From ca1a979fd74eff4c2b4bd697e69b71c312844f7f Mon Sep 17 00:00:00 2001 From: Aaron Date: Fri, 6 Dec 2024 15:01:35 -0500 Subject: [PATCH 65/95] update todo comments --- spyder/plugins/editor/widgets/codeeditor/codeeditor.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index e7b25be8b86..6183e8c9f5d 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -775,7 +775,6 @@ def _on_cursor_blinktimer_timeout(self): @Slot(QPaintEvent) def paint_cursors(self, event): """Paint all cursors""" - # TODO Critical: timer and cursor width sync with multiple editorstacks if self.overwrite_mode: font = self.textCursor().block().charFormat().font() cursor_width = QFontMetrics(font).horizontalAdvance(" ") @@ -1076,8 +1075,8 @@ def register_shortcuts(self): self.create_cursor_callback('Start'))), ('end of document', self.clears_extra_cursors( self.create_cursor_callback('End'))), - ('undo', self.undo), # TODO multi-cursor (cursor positions) - ('redo', self.redo), # TODO multi-cursor (cursor positions) + ('undo', self.undo), # TODO multi-cursor (cursor positions?) + ('redo', self.redo), # TODO multi-cursor (cursor positions?) ('cut', self.cut), ('copy', self.copy), ('paste', self.paste), From e3d6c915fbd258c5fd20f98bdfe1fd1d0ee2ff6c Mon Sep 17 00:00:00 2001 From: Aaron Date: Fri, 6 Dec 2024 16:03:42 -0500 Subject: [PATCH 66/95] Fix exceptions in test_flag_painting somehow block.layout may be invalid while block.isVisible --- .../editor/widgets/codeeditor/codeeditor.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 6183e8c9f5d..734f7061b02 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -796,9 +796,11 @@ def paint_cursors(self, event): if block.isVisible(): block_top = int(self.blockBoundingGeometry(block).top()) offset.setY(block_top + content_offset_y) - block.layout().drawCursor(qp, offset, - cursor.positionInBlock(), - cursor_width) + layout = block.layout() + if layout is not None: # Fix exceptions in test_flag_painting + layout.drawCursor(qp, offset, + cursor.positionInBlock(), + cursor_width) draw_cursor = self.cursor_blink_state and (editable or flags) @@ -807,9 +809,11 @@ def paint_cursors(self, event): if draw_cursor and block.isVisible(): block_top = int(self.blockBoundingGeometry(block).top()) offset.setY(block_top + content_offset_y) - block.layout().drawCursor(qp, offset, - cursor.positionInBlock(), - cursor_width) + layout = block.layout() + if layout is not None: + layout.drawCursor(qp, offset, + cursor.positionInBlock(), + cursor_width) qp.end() @Slot() From 7f19855cdad41d5c31443a0ed7adb77f539ff0b5 Mon Sep 17 00:00:00 2001 From: Aaron Date: Thu, 12 Dec 2024 12:52:24 -0500 Subject: [PATCH 67/95] fix bug: column cursor click on same line places cursor on every line of the document --- spyder/plugins/editor/widgets/codeeditor/codeeditor.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 734f7061b02..48e61fa5aa4 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -4942,7 +4942,8 @@ def mousePressEvent(self, event: QKeyEvent): QTextCursor.MoveMode.KeepAnchor) self.setTextCursor(first_cursor) block = anchor_block - while True: + while block != pos_block: + # Get the next block if anchor_block < pos_block: block = block.next() @@ -4961,10 +4962,6 @@ def mousePressEvent(self, event: QKeyEvent): QTextCursor.MoveMode.KeepAnchor) self.add_cursor(cursor) - # Break if it's the last block - if block == pos_block: - break - else: # Ctrl-Alt click adds and removes cursors # move existing primary cursor to extra_cursors list and set # new primary cursor From 5ca91c45fa97a7e18d1fb24551cce9f3c97ae7b8 Mon Sep 17 00:00:00 2001 From: Aaron Date: Thu, 12 Dec 2024 14:45:18 -0500 Subject: [PATCH 68/95] multi-cursor test cases; refactor, several new, lots of notes. --- .../codeeditor/tests/test_multicursor.py | 154 +++++++++++++++--- 1 file changed, 133 insertions(+), 21 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py b/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py index 610fbb939db..0fe141ef92d 100644 --- a/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py +++ b/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py @@ -14,28 +14,44 @@ # Local imports -def test_add_cursor(codeeditor, qtbot): - # enabled by default arg on CodeEditor.setup_editor (which is called in the - # pytest fixture creation in conftest.py) - assert codeeditor.multi_cursor_enabled - codeeditor.set_text("0123456789") - qtbot.wait(100) - x, y = codeeditor.get_coordinates(6) +def click_at(codeeditor, qtbot, position, ctrl=False, alt=False, shift=False): + x, y = codeeditor.get_coordinates(position) point = codeeditor.calculate_real_position(QPoint(x, y)) point = QPoint(x, y) + modifiers = Qt.KeyboardModifier.NoModifier + if ctrl: + modifiers |= Qt.KeyboardModifier.ControlModifier + if alt: + modifiers |= Qt.KeyboardModifier.AltModifier + if shift: + modifiers |= Qt.KeyboardModifier.ShiftModifier qtbot.mouseClick(codeeditor.viewport(), Qt.MouseButton.LeftButton, - (Qt.KeyboardModifier.ControlModifier | - Qt.KeyboardModifier.AltModifier), - pos=point, - delay=100) + modifiers, + pos=point) + + +def test_add_cursor(codeeditor, qtbot): + # enabled by default arg on CodeEditor.setup_editor (which is called in the + # pytest fixture creation in conftest.py) + assert codeeditor.multi_cursor_enabled + assert codeeditor.cursorWidth() == 0 # required for multi-cursor rendering + codeeditor.set_text("0123456789") + click_at(codeeditor, qtbot, 6, ctrl=True, alt=True) # A cursor was added assert bool(codeeditor.extra_cursors) qtbot.keyClick(codeeditor, "a") # Text was inserted correctly from two cursors assert codeeditor.toPlainText() == "a012345a6789" + # regular click to set main cursor and clear extra cursors + click_at(codeeditor, qtbot, 6) + assert not bool(codeeditor.extra_cursors) + qtbot.keyClick(codeeditor, "b") + assert codeeditor.toPlainText() == "a01234b5a6789" + + def test_column_add_cursor(codeeditor, qtbot): codeeditor.set_text("0123456789\n0123456789\n0123456789\n0123456789\n") cursor = codeeditor.textCursor() @@ -43,20 +59,116 @@ def test_column_add_cursor(codeeditor, qtbot): QTextCursor.MoveMode.MoveAnchor, 3) codeeditor.setTextCursor(cursor) - x, y = codeeditor.get_coordinates(6) - point = codeeditor.calculate_real_position(QPoint(x, y)) - point = QPoint(x, y) - qtbot.mouseClick(codeeditor.viewport(), - Qt.MouseButton.LeftButton, - (Qt.KeyboardModifier.ControlModifier | - Qt.KeyboardModifier.AltModifier | - Qt.KeyboardModifier.ShiftModifier), - pos=point, - delay=100) + click_at(codeeditor, qtbot, 6, ctrl=True, alt=True, shift=True) + assert bool(codeeditor.extra_cursors) assert len(codeeditor.all_cursors) == 4 for cursor in codeeditor.all_cursors: assert cursor.selectedText() == "012345" + +def test_settings_toggle(codeeditor, qtbot): + assert codeeditor.multi_cursor_enabled + assert codeeditor.cursorWidth() == 0 # required for multi-cursor rendering + codeeditor.set_text("0123456789\n0123456789\n") + click_at(codeeditor, qtbot, 6, ctrl=True, alt=True) + # A cursor was added + assert bool(codeeditor.extra_cursors) + codeeditor.toggle_multi_cursor(False) + # Extra cursors removed on settings toggle + assert not bool(codeeditor.extra_cursors) + click_at(codeeditor, qtbot, 3, ctrl=True, alt=True) + # Extra cursors not added wnen settings "multi-cursor enabled" is False + assert not bool(codeeditor.extra_cursors) + click_at(codeeditor, qtbot, 13, ctrl=True, alt=True, shift=True) + # Column cursors not added wnen settings "multi-cursor enabled" is False + assert not bool(codeeditor.extra_cursors) + + +def test_extra_selections_decoration(codeeditor, qtbot): + codeeditor.set_text("0123456789\n0123456789\n0123456789\n0123456789\n") + cursor = codeeditor.textCursor() + cursor.movePosition(QTextCursor.MoveOperation.Down, + QTextCursor.MoveMode.MoveAnchor, + 3) # column 0 row 4 + codeeditor.setTextCursor(cursor) + click_at(codeeditor, qtbot, 6, ctrl=True, alt=True, shift=True) + selections = codeeditor.get_extra_selections("extra_cursor_selections") + assert len(selections) == 3 + + +def test_multi_cursor_verticalMovementX(codeeditor, qtbot): + # Ensure extra cursors (and primary cursor) keep column position when + # moving up and down + codeeditor.set_text("012345678\n012345678\n\n012345678\n012345678\n") + click_at(codeeditor, qtbot, 4) + click_at(codeeditor, qtbot, 14, ctrl=True, alt=True) + for _ in range(3): + qtbot.keyClick(codeeditor, Qt.Key.Key_Down) + assert codeeditor.extra_cursors[0].position() == 25 + assert codeeditor.textCursor().position() == 35 + for _ in range(3): + qtbot.keyClick(codeeditor, Qt.Key.Key_Up) + assert codeeditor.extra_cursors[0].position() == 4 + assert codeeditor.textCursor().position() == 14 + + +def test_overwrite_mode(codeeditor, qtbot): + # Multi-cursor rendering requires overwrite mode be handled manually as + # there is no way to hide the primary textCursor with overwriteMode, and + # there is no way to sync the blinking of extra cursors with native + # rendering. + codeeditor.set_text("0123456789\n0123456789\n") + click_at(codeeditor, qtbot, 4) + qtbot.keyClick(codeeditor, Qt.Key.Key_Insert) + assert not codeeditor.overwriteMode() + assert codeeditor.overwrite_mode + qtbot.keyClick(codeeditor, Qt.Key.Key_A) + assert codeeditor.toPlainText() == "0123a56789\n0123456789\n" + click_at(codeeditor, qtbot, 16, ctrl=True, alt=True) + qtbot.keyClick(codeeditor, Qt.Key.Key_B) + assert codeeditor.toPlainText() == "0123ab6789\n01234b6789\n" + assert not codeeditor.overwriteMode() + assert not codeeditor.overwrite_mode + qtbot.keyClick(codeeditor, Qt.Key.Key_Insert) + qtbot.keyClick(codeeditor, Qt.Key.Key_C) + assert codeeditor.toPlainText() == "0123abc6789\n01234bc6789\n" + +# TODO test folded code + # extra cursor movement (skip past hidden blocks) + # typing on a folded line (should be read-only) + # delete & backspace (& delete line shortcut) should delete folded section + # move/duplicate line up/down should unfold current and previous/next lines +# TODO test drag & drop cursor rendering +# TODO test backspace & smart backspace +# TODO test smart indent, colon insertion, matching () "" '' +# ---- shortcuts +# TODO test code_completion shourcut (ensure disabled) +# TODO test duplicate/move line up/down +# TODO test delete line +# TODO test goto new line +# TODO test goto line number / definition / next cell / previous cell +# TODO test toggle comment, blockcomment, unblockcomment +# TODO test transform to UPPER / lower case +# TODO test indent / unindent +# TODO test start/end of document +# TODO test start/end of line +# TODO test prev/next char/word +# TODO test next/prev warning +# TODO test killring +# TODO test undo/redo +# TODO test cut copy paste +# TODO test delete +# TODO test select all +# TODO test docstring +# TODO test autoformatting +# TODO test enter inline array/table +# TODO test inspect current object +# TODO test last edit location +# TODO test next/prev cursor position +# TODO test run Cell (and advance) +# TODO test run selection (and advance)(from line)(in debugger) + + if __name__ == '__main__': pytest.main(['test_multicursor.py']) From 4ada1848542235cf6ae9ad385c5037b18d496b88 Mon Sep 17 00:00:00 2001 From: Aaron Date: Thu, 12 Dec 2024 16:04:45 -0500 Subject: [PATCH 69/95] fix mistake in test_multicursor.test_overwrite_mode --- .../plugins/editor/widgets/codeeditor/tests/test_multicursor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py b/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py index 0fe141ef92d..14dba355d4e 100644 --- a/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py +++ b/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py @@ -128,9 +128,9 @@ def test_overwrite_mode(codeeditor, qtbot): click_at(codeeditor, qtbot, 16, ctrl=True, alt=True) qtbot.keyClick(codeeditor, Qt.Key.Key_B) assert codeeditor.toPlainText() == "0123ab6789\n01234b6789\n" + qtbot.keyClick(codeeditor, Qt.Key.Key_Insert) assert not codeeditor.overwriteMode() assert not codeeditor.overwrite_mode - qtbot.keyClick(codeeditor, Qt.Key.Key_Insert) qtbot.keyClick(codeeditor, Qt.Key.Key_C) assert codeeditor.toPlainText() == "0123abc6789\n01234bc6789\n" From 65d55ff969e5c2ea853107a0008f834637b09365 Mon Sep 17 00:00:00 2001 From: Aaron Date: Tue, 17 Dec 2024 15:22:16 -0500 Subject: [PATCH 70/95] re-introduce smart home/end behavior --- .../plugins/editor/widgets/codeeditor/codeeditor.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 48e61fa5aa4..ffe865fdbf6 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -740,9 +740,18 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): cursor.removeSelectedText() else: self._handle_keypress_event(event) + # ---- handle home, end + elif key == Qt.Key.Key_Home: + increasing_position = False + self.stdkey_home(shift, ctrl) + elif key == Qt.Key.Key_End: + # See spyder-ide/spyder#495: on MacOS X, it is necessary to + # redefine this basic action which should have been implemented + # natively + self.stdkey_end(shift, ctrl) # ---- use default handler for cursor (text) else: - if key in (Qt.Key.Key_Up, Qt.Key.Key_Left, Qt.Key.Key_Home): + if key in (Qt.Key.Key_Up, Qt.Key.Key_Left): increasing_position = False if (key in (Qt.Key.Key_Up, Qt.Key.Key_Down) and cursor.verticalMovementX() == -1): From d55785cea83711553e01219785feba47b7336fed Mon Sep 17 00:00:00 2001 From: Aaron Date: Thu, 19 Dec 2024 20:13:25 -0500 Subject: [PATCH 71/95] Apply suggestions from code review Co-authored-by: Carlos Cordoba --- .../codeeditor/tests/test_multicursor.py | 39 ++++++++++++------- .../editor/widgets/editorstack/helpers.py | 3 +- spyder/plugins/editor/widgets/main_widget.py | 3 ++ 3 files changed, 30 insertions(+), 15 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py b/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py index 14dba355d4e..6f9cc757201 100644 --- a/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py +++ b/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py @@ -26,26 +26,31 @@ def click_at(codeeditor, qtbot, position, ctrl=False, alt=False, shift=False): modifiers |= Qt.KeyboardModifier.AltModifier if shift: modifiers |= Qt.KeyboardModifier.ShiftModifier - qtbot.mouseClick(codeeditor.viewport(), - Qt.MouseButton.LeftButton, - modifiers, - pos=point) + + qtbot.mouseClick( + codeeditor.viewport(), + Qt.MouseButton.LeftButton, + modifiers, + pos=point + ) def test_add_cursor(codeeditor, qtbot): - # enabled by default arg on CodeEditor.setup_editor (which is called in the - # pytest fixture creation in conftest.py) + # Enabled by default arg on CodeEditor.setup_editor (which is called in the + # pytest fixture creation in conftest.py) assert codeeditor.multi_cursor_enabled assert codeeditor.cursorWidth() == 0 # required for multi-cursor rendering codeeditor.set_text("0123456789") click_at(codeeditor, qtbot, 6, ctrl=True, alt=True) + # A cursor was added assert bool(codeeditor.extra_cursors) qtbot.keyClick(codeeditor, "a") + # Text was inserted correctly from two cursors assert codeeditor.toPlainText() == "a012345a6789" - # regular click to set main cursor and clear extra cursors + # Regular click to set main cursor and clear extra cursors click_at(codeeditor, qtbot, 6) assert not bool(codeeditor.extra_cursors) qtbot.keyClick(codeeditor, "b") @@ -55,9 +60,11 @@ def test_add_cursor(codeeditor, qtbot): def test_column_add_cursor(codeeditor, qtbot): codeeditor.set_text("0123456789\n0123456789\n0123456789\n0123456789\n") cursor = codeeditor.textCursor() - cursor.movePosition(QTextCursor.MoveOperation.Down, - QTextCursor.MoveMode.MoveAnchor, - 3) + cursor.movePosition( + QTextCursor.MoveOperation.Down, + QTextCursor.MoveMode.MoveAnchor, + 3 + ) codeeditor.setTextCursor(cursor) click_at(codeeditor, qtbot, 6, ctrl=True, alt=True, shift=True) @@ -72,15 +79,19 @@ def test_settings_toggle(codeeditor, qtbot): assert codeeditor.cursorWidth() == 0 # required for multi-cursor rendering codeeditor.set_text("0123456789\n0123456789\n") click_at(codeeditor, qtbot, 6, ctrl=True, alt=True) + # A cursor was added assert bool(codeeditor.extra_cursors) codeeditor.toggle_multi_cursor(False) + # Extra cursors removed on settings toggle assert not bool(codeeditor.extra_cursors) click_at(codeeditor, qtbot, 3, ctrl=True, alt=True) + # Extra cursors not added wnen settings "multi-cursor enabled" is False assert not bool(codeeditor.extra_cursors) click_at(codeeditor, qtbot, 13, ctrl=True, alt=True, shift=True) + # Column cursors not added wnen settings "multi-cursor enabled" is False assert not bool(codeeditor.extra_cursors) @@ -88,9 +99,11 @@ def test_settings_toggle(codeeditor, qtbot): def test_extra_selections_decoration(codeeditor, qtbot): codeeditor.set_text("0123456789\n0123456789\n0123456789\n0123456789\n") cursor = codeeditor.textCursor() - cursor.movePosition(QTextCursor.MoveOperation.Down, - QTextCursor.MoveMode.MoveAnchor, - 3) # column 0 row 4 + cursor.movePosition( + QTextCursor.MoveOperation.Down, + QTextCursor.MoveMode.MoveAnchor, + 3 # column 0 row 4 + ) codeeditor.setTextCursor(cursor) click_at(codeeditor, qtbot, 6, ctrl=True, alt=True, shift=True) selections = codeeditor.get_extra_selections("extra_cursor_selections") diff --git a/spyder/plugins/editor/widgets/editorstack/helpers.py b/spyder/plugins/editor/widgets/editorstack/helpers.py index 6e18d48345b..e737110da3d 100644 --- a/spyder/plugins/editor/widgets/editorstack/helpers.py +++ b/spyder/plugins/editor/widgets/editorstack/helpers.py @@ -165,8 +165,7 @@ def text_changed(self): self.default = False all_cursors = self.editor.all_cursors positions = tuple(cursor.position() for cursor in all_cursors) - self.text_changed_at.emit(self.filename, - positions) + self.text_changed_at.emit(self.filename, positions) def get_source_code(self): """Return associated editor source code.""" diff --git a/spyder/plugins/editor/widgets/main_widget.py b/spyder/plugins/editor/widgets/main_widget.py index 10f924029bf..b8ead4fdf45 100644 --- a/spyder/plugins/editor/widgets/main_widget.py +++ b/spyder/plugins/editor/widgets/main_widget.py @@ -2813,9 +2813,11 @@ def _pop_next_cursor_diff(self, history, current_filename, return filename, cursors if len(cursors) != len(current_cursors): return filename, cursors + for cursor, current_cursor in zip(cursors, current_cursors): if cursor.position() != current_cursor.position(): return filename, cursors + return None, None def _history_step(self, backwards_history, forwards_history, @@ -2832,6 +2834,7 @@ def _history_step(self, backwards_history, forwards_history, self._pop_next_cursor_diff( forwards_history, current_filename, current_cursors)) + if current_cursors is None: # Went too far, back up once current_filename, current_cursors = ( From b6003449cad4f812340ff051be2b4c1b8dd30a25 Mon Sep 17 00:00:00 2001 From: Aaron Date: Thu, 19 Dec 2024 20:20:54 -0500 Subject: [PATCH 72/95] Apply suggestions from code review Co-authored-by: Carlos Cordoba --- .../editor/widgets/codeeditor/codeeditor.py | 80 ++++++++++++------- 1 file changed, 50 insertions(+), 30 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index ffe865fdbf6..0585979c8c3 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -1035,8 +1035,8 @@ def cursor_move_event(): def register_shortcuts(self): """Register shortcuts for this widget.""" shortcuts = ( - # TODO should multi-cursor wrappers be applied as decorator to - # function definitions instead where possible? + # TODO: Should multi-cursor wrappers be applied as decorator to + # function definitions instead where possible? ('code completion', self.restrict_single_cursor( self.do_completion)), ('duplicate line down', self.duplicate_line_down), @@ -1782,9 +1782,10 @@ def delete(self): self.setTextCursor(cursor) self.sig_delete_requested.emit() new_cursors.append(self.textCursor()) + # Signal all cursors first to call FoldingPanel._expand_selection - # before calling deleteChar. This fixes some issues with deletion - # order invalidating FoldingPanel properties in the wrong order + # before calling deleteChar. This fixes some issues with deletion + # order invalidating FoldingPanel properties in the wrong order for cursor in new_cursors: cursor.deleteChar() self.setTextCursor(cursor) @@ -1810,9 +1811,11 @@ def delete_line(self): break cursor.movePosition(QTextCursor.NextBlock, QTextCursor.KeepAnchor) + self.setTextCursor(cursor) + # Text folding looks for sig_delete_requested to expand selection - # to entire folded region. + # to entire folded region. self.sig_delete_requested.emit() cursors.append(self.textCursor()) @@ -2443,6 +2446,7 @@ def cut(self): if self.extra_cursors: self.multi_cursor_cut() return + has_selected_text = self.has_selected_text() if not has_selected_text: self.select_current_line_and_sep() @@ -2459,6 +2463,7 @@ def copy(self): if self.extra_cursors: self.multi_cursor_copy() return + TextEditBaseWidget.copy(self) self._save_clipboard_indentation() @@ -3494,8 +3499,8 @@ def __unblockcomment(self, compatibility=False): """Un-block comment current line or selection helper.""" def __is_comment_bar(cursor): return to_text_string(cursor.block().text()).startswith( - self.__blockcomment_bar(compatibility=compatibility) - ) + self.__blockcomment_bar(compatibility=compatibility) + ) # Finding first comment bar cursor1 = self.textCursor() if __is_comment_bar(cursor1): @@ -4164,7 +4169,7 @@ def keyPressEvent(self, event): self._set_completions_hint_idle() # Only set overwrite mode during key handling to allow correct painting - # of multiple overwrite cursors. Must unset overwrite before return. + # of multiple overwrite cursors. Must unset overwrite before return. self.setOverwriteMode(self.overwrite_mode) self.start_cursor_blink() # reset cursor blink by reseting timer if self.extra_cursors: @@ -4388,6 +4393,7 @@ def keyPressEvent(self, event): # Modifiers should be passed to the parent because they # could be shortcuts event.accept() + self.setOverwriteMode(False) def do_automatic_completions(self): @@ -4757,9 +4763,9 @@ def pos_in_line(pos): def duplicate_line_up(self): """Duplicate current line or selection""" - # TODO selection anchor is wrong (selects original and new line) if - # selection starts or ends at the beginning of a block (for extra - # cursors only, main cursor is fine). + # TODO: Selection anchor is wrong (selects original and new line) if + # selection starts or ends at the beginning of a block (for extra + # cursors only, main cursor is fine). self._unfold_lines() self.for_each_cursor(super().duplicate_line_up)() @@ -4769,7 +4775,7 @@ def duplicate_line_down(self): self.for_each_cursor(super().duplicate_line_down)() def _unfold_lines(self): - """for each cursor: unfold current line if folded""" + """Unfold current line if folded for each cursor.""" for cursor in self.all_cursors: # Unfold any folded code block before duplicating lines up/down fold_start_line = cursor.blockNumber() + 1 @@ -4788,15 +4794,18 @@ def move_line_down(self): self.__move_line_or_selection(after_current_line=True) def __move_line_or_selection(self, after_current_line=True): - # TODO multi-cursor implementation improperly handles moving multiple - # cursors up against the end of the file (lines get swapped) - # TODO multi-cursor implementation improperly handles multiple cursors - # on the same line. + # TODO: Multi-cursor implementation improperly handles moving multiple + # cursors up against the end of the file (lines get swapped) + # TODO: Multi-cursor implementation improperly handles multiple cursors + # on the same line. self.textCursor().beginEditBlock() self.multi_cursor_ignore_history = True - sorted_cursors = sorted(self.all_cursors, - key=lambda cursor: cursor.position(), - reverse=after_current_line) + sorted_cursors = sorted( + self.all_cursors, + key=lambda cursor: cursor.position(), + reverse=after_current_line + ) + new_cursors = [] for cursor in sorted_cursors: self.setTextCursor(cursor) @@ -4822,8 +4831,10 @@ def __move_line_or_selection(self, after_current_line=True): block = cursor.block() offset = 0 if self.has_selected_text(): - ((selection_start, _), - (selection_end)) = self.get_selection_start_end() + ( + (selection_start, _), + (selection_end), + ) = self.get_selection_start_end() if selection_end != selection_start: offset = 1 fold_start_line = block.blockNumber() - 1 - offset @@ -4848,6 +4859,7 @@ def __move_line_or_selection(self, after_current_line=True): after_current_line=after_current_line ) new_cursors.append(self.textCursor()) + self.extra_cursors = new_cursors[:-1] self.setTextCursor(new_cursors[-1]) self.merge_extra_cursors(True) @@ -4924,19 +4936,24 @@ def leaveEvent(self, event): 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 - if (self.multi_cursor_enabled and event.button() == Qt.LeftButton and - ctrl and alt): + if ( + self.multi_cursor_enabled + and event.button() == Qt.LeftButton + and ctrl + and alt + ): # ---- Ctrl-Alt: multi-cursor mouse interactions self.multi_cursor_ignore_history = True if shift: # Ctrl-Shift-Alt click adds colum of cursors towards primary - # cursor + # cursor first_cursor = self.textCursor() anchor_block = first_cursor.block() anchor_col = first_cursor.anchor() - anchor_block.position() @@ -4945,7 +4962,8 @@ def mousePressEvent(self, event: QKeyEvent): # Move primary cursor to pos_col p_col = min(len(anchor_block.text()), pos_col) - # block.length() includes line seperator? just /n? + + # block.length() includes line separator? just \n? # use len(block.text()) instead first_cursor.setPosition(anchor_block.position() + p_col, QTextCursor.MoveMode.KeepAnchor) @@ -4970,14 +4988,13 @@ def mousePressEvent(self, event: QKeyEvent): cursor.setPosition(block.position() + p_col, QTextCursor.MoveMode.KeepAnchor) self.add_cursor(cursor) - else: # Ctrl-Alt click adds and removes cursors - # move existing primary cursor to extra_cursors list and set - # new primary cursor + # Move existing primary cursor to extra_cursors list and set + # new primary cursor old_cursor = self.textCursor() + # Don't attempt to remove cursor if there's only one removed_cursor = False - # don't attempt to remove cursor if there's only one if self.extra_cursors: same_cursor = None for cursor in self.all_cursors: @@ -4998,18 +5015,21 @@ def mousePressEvent(self, event: QKeyEvent): ) self.extra_cursors.remove(new_primary) self.setTextCursor(new_primary) - # possibly clear selection of removed cursor + + # Possibly clear selection of removed cursor self.set_extra_cursor_selections() if not removed_cursor: self.setTextCursor(cursor_for_pos) self.add_cursor(old_cursor) + self.multi_cursor_ignore_history = False self.cursorPositionChanged.emit() else: # ---- not multi-cursor if event.button() == Qt.MouseButton.LeftButton: self.clear_extra_cursors() + if event.button() == Qt.LeftButton and ctrl: TextEditBaseWidget.mousePressEvent(self, event) uri = self._last_hover_pattern_text From bc783edbfe9786b1e5925565b234e79eabed6431 Mon Sep 17 00:00:00 2001 From: athompson673 Date: Thu, 19 Dec 2024 20:50:26 -0500 Subject: [PATCH 73/95] Refactor multi-cursor functions into separate mixin class. --- .../editor/widgets/codeeditor/codeeditor.py | 443 +----------------- 1 file changed, 3 insertions(+), 440 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 0585979c8c3..a7126f85c95 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -24,8 +24,6 @@ import re import sys import textwrap -import functools -import itertools # Third party imports from IPython.core.inputtransformer2 import TransformerManager @@ -34,7 +32,7 @@ from qtpy.compat import to_qvariant from qtpy.QtCore import ( QEvent, QRegularExpression, Qt, QTimer, QUrl, Signal, Slot) -from qtpy.QtGui import (QColor, QCursor, QFont, QFontMetrics, QPaintEvent, +from qtpy.QtGui import (QColor, QCursor, QFont, QPaintEvent, QPainter, QMouseEvent, QTextCursor, QDesktopServices, QKeyEvent, QTextDocument, QTextFormat, QTextOption, QTextCharFormat, QTextLayout) @@ -62,6 +60,7 @@ from spyder.plugins.editor.widgets.gotoline import GoToLineDialog from spyder.plugins.editor.widgets.base import TextEditBaseWidget from spyder.plugins.editor.widgets.codeeditor.lsp_mixin import LSPMixin +from spyder.plugins.editor.widgets.codeeditor.multicursor_mixin import MultiCursorMixin from spyder.plugins.outlineexplorer.api import (OutlineExplorerData as OED, is_cell_header) from spyder.py3compat import to_text_string, is_string @@ -121,7 +120,7 @@ class CodeEditorContextMenuSections: OthersSection = "others_section" -class CodeEditor(LSPMixin, TextEditBaseWidget): +class CodeEditor(LSPMixin, TextEditBaseWidget, MultiCursorMixin): """Source Code Editor Widget based exclusively on Qt""" CONF_SECTION = 'editor' @@ -510,442 +509,6 @@ def __init__(self, parent=None): self._rehighlight_timer.setSingleShot(True) self._rehighlight_timer.setInterval(150) - # ---- Multi Cursor - def init_multi_cursor(self): - """Initialize attrs and callbacks for multi-cursor functionality""" - # actual default comes from setup_editor default args - self.multi_cursor_enabled = False - self.cursor_width = self.get_conf('cursor/width', section='main') - self.overwrite_mode = self.overwriteMode() - # track overwrite manually when for painting reasons with multi-cursor - self.setOverwriteMode(False) - self.setCursorWidth(0) # draw our own cursor - self.extra_cursors = [] - self.cursor_blink_state = False - self.cursor_blink_timer = QTimer(self) - self.cursor_blink_timer.setInterval( - QApplication.cursorFlashTime() // 2 - ) - self.cursor_blink_timer.timeout.connect( - self._on_cursor_blinktimer_timeout - ) - self.focus_in.connect(self.start_cursor_blink) - self.focus_changed.connect(self.stop_cursor_blink) - self.painted.connect(self.paint_cursors) - self.multi_cursor_ignore_history = False - self._drag_cursor = None - - def toggle_multi_cursor(self, enabled): - """Enable/Disable multi-cursor editing""" - self.multi_cursor_enabled = enabled - # TODO any restrictions on enabling? only python-like? only code? - if not enabled: - self.clear_extra_cursors() - - def add_cursor(self, cursor: QTextCursor): - """Add this cursor to the list of extra cursors""" - if self.multi_cursor_enabled: - self.extra_cursors.append(cursor) - self.merge_extra_cursors(True) - - def set_extra_cursor_selections(self): - selections = [] - for cursor in self.extra_cursors: - extra_selection = TextDecoration(cursor, draw_order=5, - kind="extra_cursor_selection") - - # TODO get colors from theme? or from stylesheet? - extra_selection.set_foreground(QColor("#dfe1e2")) - extra_selection.set_background(QColor("#346792")) - selections.append(extra_selection) - self.set_extra_selections('extra_cursor_selections', selections) - - def clear_extra_cursors(self): - """Remove all extra cursors""" - self.extra_cursors = [] - self.set_extra_selections('extra_cursor_selections', []) - - @property - def all_cursors(self): - """Return list of all extra_cursors (if any) plus the primary cursor""" - return self.extra_cursors + [self.textCursor()] - - def merge_extra_cursors(self, increasing_position): - """Merge overlapping cursors""" - if not self.extra_cursors: - return - previous_history = self.multi_cursor_ignore_history - self.multi_cursor_ignore_history = True - while True: - cursor_was_removed = False - - cursors = self.all_cursors - main_cursor = cursors[-1] - cursors.sort(key=lambda cursor: cursor.position()) - - for i, cursor1 in enumerate(cursors[:-1]): - if cursor_was_removed: - break # list will be modified, so re-start at while loop - for cursor2 in cursors[i + 1:]: - # given cursors.sort, pos1 should be <= pos2 - pos1 = cursor1.position() - pos2 = cursor2.position() - anchor1 = cursor1.anchor() - anchor2 = cursor2.anchor() - - if not pos1 == pos2: - continue # only merge coincident cursors - - if cursor1 is main_cursor: - # swap cursors to keep main_cursor - cursor1, cursor2 = cursor2, cursor1 - self.extra_cursors.remove(cursor1) - cursor_was_removed = True - - # reposition cursor we're keeping - positions = sorted([pos1, anchor1, anchor2]) - if not increasing_position: - positions.reverse() - cursor2.setPosition(positions[0], - QTextCursor.MoveMode.MoveAnchor) - cursor2.setPosition(positions[2], - QTextCursor.MoveMode.KeepAnchor) - if cursor2 is main_cursor: - self.setTextCursor(cursor2) - break - - if not cursor_was_removed: - break - self.set_extra_cursor_selections() - self.multi_cursor_ignore_history = previous_history - - @Slot(QKeyEvent) - def handle_multi_cursor_keypress(self, event: QKeyEvent): - """Re-Implement keyEvent handler for multi-cursor""" - - key = event.key() - ctrl = event.modifiers() & Qt.KeyboardModifier.ControlModifier - alt = event.modifiers() & Qt.KeyboardModifier.AltModifier - shift = event.modifiers() & Qt.KeyboardModifier.ShiftModifier - # ---- handle insert - if key == Qt.Key.Key_Insert and not (ctrl or alt or shift): - self.overwrite_mode = not self.overwrite_mode - return - - self.textCursor().beginEditBlock() - self.multi_cursor_ignore_history = True - - cursors = [] - accepted = [] - # Handle all signals before editing text - for cursor in self.all_cursors: - self.setTextCursor(cursor) - event.ignore() - self.sig_key_pressed.emit(event) - cursors.append(self.textCursor()) - accepted.append(event.isAccepted()) - - increasing_position = True - new_cursors = [] - for skip, cursor in zip(accepted, cursors): - self.setTextCursor(cursor) - if skip: - # text folding swallows most input to prevent typing on folded - # lines. - pass - # ---- handle Tab - elif key == Qt.Key.Key_Tab and not ctrl: # ctrl-tab is shortcut - # Don't do intelligent tab with multi-cursor to skip - # calls to do_completion. Avoiding completions with multi - # cursor is much easier than solving all the edge cases. - - self.indent(force=self.tab_mode) - elif key == Qt.Key.Key_Backtab and not ctrl: - increasing_position = False - # TODO ignore indent level of neighboring lines and simply - # indent by 1 level at a time. Cursor update order can - # make this unpredictable otherwise. - self.unindent(force=self.tab_mode) - # ---- handle enter/return - elif key in (Qt.Key_Enter, Qt.Key_Return): - if not shift and not ctrl: - if ( - self.add_colons_enabled and - self.is_python_like() and - self.autoinsert_colons() - ): - self.insert_text(':' + self.get_line_separator()) - if self.strip_trailing_spaces_on_modify: - self.fix_and_strip_indent() - else: - self.fix_indent() - else: - cur_indent = self.get_block_indentation( - self.textCursor().blockNumber()) - self._handle_keypress_event(event) - # Check if we're in a comment or a string at the - # current position - cmt_or_str_cursor = self.in_comment_or_string() - - # Check if the line start with a comment or string - cursor = self.textCursor() - cursor.setPosition(cursor.block().position(), - QTextCursor.KeepAnchor) - cmt_or_str_line_begin = self.in_comment_or_string( - cursor=cursor) - - # Check if we are in a comment or a string - cmt_or_str = cmt_or_str_cursor and \ - cmt_or_str_line_begin - - if self.strip_trailing_spaces_on_modify: - self.fix_and_strip_indent( - comment_or_string=cmt_or_str, - cur_indent=cur_indent) - else: - self.fix_indent(comment_or_string=cmt_or_str, - cur_indent=cur_indent) - # ---- intelligent backspace handling - elif key == Qt.Key_Backspace and not shift and not ctrl: - increasing_position = False - if self.has_selected_text() or not self.intelligent_backspace: - self._handle_keypress_event(event) - else: - leading_text = self.get_text('sol', 'cursor') - leading_length = len(leading_text) - trailing_spaces = ( - leading_length - len(leading_text.rstrip()) - ) - trailing_text = self.get_text('cursor', 'eol') - matches = ('()', '[]', '{}', '\'\'', '""') - if ( - not leading_text.strip() and - (leading_length > len(self.indent_chars)) - ): - if leading_length % len(self.indent_chars) == 0: - self.unindent() - else: - self._handle_keypress_event(event) - elif trailing_spaces and not trailing_text.strip(): - self.remove_suffix(leading_text[-trailing_spaces:]) - elif ( - leading_text and - trailing_text and - (leading_text[-1] + trailing_text[0] in matches) - ): - cursor = self.textCursor() - cursor.movePosition(QTextCursor.PreviousCharacter) - cursor.movePosition(QTextCursor.NextCharacter, - QTextCursor.KeepAnchor, 2) - cursor.removeSelectedText() - else: - self._handle_keypress_event(event) - # ---- handle home, end - elif key == Qt.Key.Key_Home: - increasing_position = False - self.stdkey_home(shift, ctrl) - elif key == Qt.Key.Key_End: - # See spyder-ide/spyder#495: on MacOS X, it is necessary to - # redefine this basic action which should have been implemented - # natively - self.stdkey_end(shift, ctrl) - # ---- use default handler for cursor (text) - else: - if key in (Qt.Key.Key_Up, Qt.Key.Key_Left): - increasing_position = False - if (key in (Qt.Key.Key_Up, Qt.Key.Key_Down) and - cursor.verticalMovementX() == -1): - # Builtin handler somehow does not set verticalMovementX - # when moving up and down (but works fine for single - # cursor somehow) - # TODO why? Am I forgetting something? - x = self.cursorRect(cursor).x() - cursor.setVerticalMovementX(x) - self.setTextCursor(cursor) - self._handle_keypress_event(event) - - # Update edited extra_cursors - new_cursors.append(self.textCursor()) - self.extra_cursors = new_cursors[:-1] - self.merge_extra_cursors(increasing_position) - self.textCursor().endEditBlock() - self.multi_cursor_ignore_history = False - self.cursorPositionChanged.emit() - event.accept() # TODO when to pass along keypress or not - - def _on_cursor_blinktimer_timeout(self): - """ - Text cursor blink timer generates paint events and inverts draw state - """ - self.cursor_blink_state = not self.cursor_blink_state - if self.isVisible(): - self.viewport().update() - - @Slot(QPaintEvent) - def paint_cursors(self, event): - """Paint all cursors""" - if self.overwrite_mode: - font = self.textCursor().block().charFormat().font() - cursor_width = QFontMetrics(font).horizontalAdvance(" ") - else: - cursor_width = self.cursor_width - - qp = QPainter() - qp.begin(self.viewport()) - offset = self.contentOffset() - content_offset_y = offset.y() - qp.setBrushOrigin(offset) - editable = not self.isReadOnly() - flags = (self.textInteractionFlags() & - Qt.TextInteractionFlag.TextSelectableByKeyboard) - - if self._drag_cursor is not None and (editable or flags): - cursor = self._drag_cursor - block = cursor.block() - if block.isVisible(): - block_top = int(self.blockBoundingGeometry(block).top()) - offset.setY(block_top + content_offset_y) - layout = block.layout() - if layout is not None: # Fix exceptions in test_flag_painting - layout.drawCursor(qp, offset, - cursor.positionInBlock(), - cursor_width) - - draw_cursor = self.cursor_blink_state and (editable or flags) - - for cursor in self.all_cursors: - block = cursor.block() - if draw_cursor and block.isVisible(): - block_top = int(self.blockBoundingGeometry(block).top()) - offset.setY(block_top + content_offset_y) - layout = block.layout() - if layout is not None: - layout.drawCursor(qp, offset, - cursor.positionInBlock(), - cursor_width) - qp.end() - - @Slot() - def start_cursor_blink(self): - """start manually updating the cursor(s) blink state: Show cursors""" - self.cursor_blink_state = True - self.cursor_blink_timer.start() - - @Slot() - def stop_cursor_blink(self): - """stop manually updating the cursor(s) blink state: Hide cursors""" - self.cursor_blink_state = False - self.cursor_blink_timer.stop() - - def multi_cursor_copy(self): - """ - Join all cursor selections in position sorted order by line_separator, - and put text to clipboard. - """ - cursors = self.all_cursors - cursors.sort(key=lambda cursor: cursor.position()) - selections = [] - for cursor in cursors: - text = cursor.selectedText().replace(u"\u2029", - self.get_line_separator()) - selections.append(text) - clip_text = self.get_line_separator().join(selections) - QApplication.clipboard().setText(clip_text) - - def multi_cursor_cut(self): - """Multi-cursor copy then removeSelectedText""" - self.multi_cursor_copy() - self.textCursor().beginEditBlock() - for cursor in self.all_cursors: - cursor.removeSelectedText() - # merge direction doesn't matter here as all selections are removed - self.merge_extra_cursors(True) - self.textCursor().endEditBlock() - - def multi_cursor_paste(self, clip_text): - """ - Split clipboard by lines, and paste one line per cursor in position - sorted order. - """ - main_cursor = self.textCursor() - main_cursor.beginEditBlock() - cursors = self.all_cursors - cursors.sort(key=lambda cursor: cursor.position()) - self.skip_rstrip = True - self.sig_will_paste_text.emit(clip_text) - lines = clip_text.splitlines() - if len(lines) == 1: - lines = itertools.repeat(lines[0]) - self.multi_cursor_ignore_history = True - for cursor, text in zip(cursors, lines): - self.setTextCursor(cursor) - cursor.insertText(text) - # handle extra lines or extra cursors? - self.setTextCursor(main_cursor) - self.multi_cursor_ignore_history = False - self.cursorPositionChanged.emit() - # merge direction doesn't matter here as all selections are removed - self.merge_extra_cursors(True) - main_cursor.endEditBlock() - self.sig_text_was_inserted.emit() - self.skip_rstrip = False - - def for_each_cursor(self, method, merge_increasing=True): - """Wrap callable to execute once for each cursor""" - @functools.wraps(method) - def wrapper(): - self.textCursor().beginEditBlock() - new_cursors = [] - self.multi_cursor_ignore_history = True - for cursor in self.all_cursors: - self.setTextCursor(cursor) - # may call setTtextCursor with modified copy - method() - # get modified cursor to re-add to extra_cursors - new_cursors.append(self.textCursor()) - - # re-add extra cursors - self.clear_extra_cursors() - for cursor in new_cursors[:-1]: - self.add_cursor(cursor) - self.setTextCursor(new_cursors[-1]) - self.merge_extra_cursors(merge_increasing) - self.textCursor().endEditBlock() - self.multi_cursor_ignore_history = False - self.cursorPositionChanged.emit() - return wrapper - - def clears_extra_cursors(self, method): - """Wrap callable to clear extra_cursors prior to calling""" - @functools.wraps(method) - def wrapper(): - self.clear_extra_cursors() - method() - return wrapper - - def restrict_single_cursor(self, method): - """Wrap callable to only execute if there is a single cursor""" - @functools.wraps(method) - def wrapper(): - if not self.extra_cursors: - method() - return wrapper - - def go_to_next_cell(self): - """ - reimplements TextEditBaseWidget.go_to_next_cell to clear extra cursors - """ - self.clear_extra_cursors() - super().go_to_next_cell() - - def go_to_previous_cell(self): - """ - reimplements TextEditBaseWidget.go_to_previous_cell to clear extra - cursors - """ - self.clear_extra_cursors() - super().go_to_previous_cell() - # ---- Hover/Hints # ------------------------------------------------------------------------- def _should_display_hover(self, point): From ea811251c69a2cb9aab4fcba8ef048186801439c Mon Sep 17 00:00:00 2001 From: athompson673 Date: Thu, 19 Dec 2024 20:52:59 -0500 Subject: [PATCH 74/95] commit new multicursor_mixin.MultiCursorMixin --- .../widgets/codeeditor/multicursor_mixin.py | 460 ++++++++++++++++++ 1 file changed, 460 insertions(+) create mode 100644 spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py diff --git a/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py b/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py new file mode 100644 index 00000000000..4cded4439c0 --- /dev/null +++ b/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py @@ -0,0 +1,460 @@ +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Editor mixin and utils to manage Multiple cursor editing +""" + +# Standard library imports +import functools +import itertools + +# Third party imports +from qtpy.QtCore import Qt, QTimer, Slot +from qtpy.QtGui import (QColor, QFontMetrics, QPaintEvent, QPainter, + QTextCursor, QKeyEvent) +from qtpy.QtWidgets import QApplication + +# Local imports +from spyder.plugins.editor.api.decoration import TextDecoration + + +class MultiCursorMixin: + # ---- Multi Cursor + def init_multi_cursor(self): + """Initialize attrs and callbacks for multi-cursor functionality""" + # actual default comes from setup_editor default args + self.multi_cursor_enabled = False + self.cursor_width = self.get_conf('cursor/width', section='main') + self.overwrite_mode = self.overwriteMode() + # track overwrite manually when for painting reasons with multi-cursor + self.setOverwriteMode(False) + self.setCursorWidth(0) # draw our own cursor + self.extra_cursors = [] + self.cursor_blink_state = False + self.cursor_blink_timer = QTimer(self) + self.cursor_blink_timer.setInterval( + QApplication.cursorFlashTime() // 2 + ) + self.cursor_blink_timer.timeout.connect( + self._on_cursor_blinktimer_timeout + ) + self.focus_in.connect(self.start_cursor_blink) + self.focus_changed.connect(self.stop_cursor_blink) + self.painted.connect(self.paint_cursors) + self.multi_cursor_ignore_history = False + self._drag_cursor = None + + def toggle_multi_cursor(self, enabled): + """Enable/Disable multi-cursor editing""" + self.multi_cursor_enabled = enabled + # TODO any restrictions on enabling? only python-like? only code? + if not enabled: + self.clear_extra_cursors() + + def add_cursor(self, cursor: QTextCursor): + """Add this cursor to the list of extra cursors""" + if self.multi_cursor_enabled: + self.extra_cursors.append(cursor) + self.merge_extra_cursors(True) + + def set_extra_cursor_selections(self): + selections = [] + for cursor in self.extra_cursors: + extra_selection = TextDecoration(cursor, draw_order=5, + kind="extra_cursor_selection") + + # TODO get colors from theme? or from stylesheet? + extra_selection.set_foreground(QColor("#dfe1e2")) + extra_selection.set_background(QColor("#346792")) + selections.append(extra_selection) + self.set_extra_selections('extra_cursor_selections', selections) + + def clear_extra_cursors(self): + """Remove all extra cursors""" + self.extra_cursors = [] + self.set_extra_selections('extra_cursor_selections', []) + + @property + def all_cursors(self): + """Return list of all extra_cursors (if any) plus the primary cursor""" + return self.extra_cursors + [self.textCursor()] + + def merge_extra_cursors(self, increasing_position): + """Merge overlapping cursors""" + if not self.extra_cursors: + return + previous_history = self.multi_cursor_ignore_history + self.multi_cursor_ignore_history = True + while True: + cursor_was_removed = False + + cursors = self.all_cursors + main_cursor = cursors[-1] + cursors.sort(key=lambda cursor: cursor.position()) + + for i, cursor1 in enumerate(cursors[:-1]): + if cursor_was_removed: + break # list will be modified, so re-start at while loop + for cursor2 in cursors[i + 1:]: + # given cursors.sort, pos1 should be <= pos2 + pos1 = cursor1.position() + pos2 = cursor2.position() + anchor1 = cursor1.anchor() + anchor2 = cursor2.anchor() + + if not pos1 == pos2: + continue # only merge coincident cursors + + if cursor1 is main_cursor: + # swap cursors to keep main_cursor + cursor1, cursor2 = cursor2, cursor1 + self.extra_cursors.remove(cursor1) + cursor_was_removed = True + + # reposition cursor we're keeping + positions = sorted([pos1, anchor1, anchor2]) + if not increasing_position: + positions.reverse() + cursor2.setPosition(positions[0], + QTextCursor.MoveMode.MoveAnchor) + cursor2.setPosition(positions[2], + QTextCursor.MoveMode.KeepAnchor) + if cursor2 is main_cursor: + self.setTextCursor(cursor2) + break + + if not cursor_was_removed: + break + self.set_extra_cursor_selections() + self.multi_cursor_ignore_history = previous_history + + @Slot(QKeyEvent) + def handle_multi_cursor_keypress(self, event: QKeyEvent): + """Re-Implement keyEvent handler for multi-cursor""" + + key = event.key() + ctrl = event.modifiers() & Qt.KeyboardModifier.ControlModifier + alt = event.modifiers() & Qt.KeyboardModifier.AltModifier + shift = event.modifiers() & Qt.KeyboardModifier.ShiftModifier + # ---- handle insert + if key == Qt.Key.Key_Insert and not (ctrl or alt or shift): + self.overwrite_mode = not self.overwrite_mode + return + + self.textCursor().beginEditBlock() + self.multi_cursor_ignore_history = True + + cursors = [] + accepted = [] + # Handle all signals before editing text + for cursor in self.all_cursors: + self.setTextCursor(cursor) + event.ignore() + self.sig_key_pressed.emit(event) + cursors.append(self.textCursor()) + accepted.append(event.isAccepted()) + + increasing_position = True + new_cursors = [] + for skip, cursor in zip(accepted, cursors): + self.setTextCursor(cursor) + if skip: + # text folding swallows most input to prevent typing on folded + # lines. + pass + # ---- handle Tab + elif key == Qt.Key.Key_Tab and not ctrl: # ctrl-tab is shortcut + # Don't do intelligent tab with multi-cursor to skip + # calls to do_completion. Avoiding completions with multi + # cursor is much easier than solving all the edge cases. + + self.indent(force=self.tab_mode) + elif key == Qt.Key.Key_Backtab and not ctrl: + increasing_position = False + # TODO ignore indent level of neighboring lines and simply + # indent by 1 level at a time. Cursor update order can + # make this unpredictable otherwise. + self.unindent(force=self.tab_mode) + # ---- handle enter/return + elif key in (Qt.Key_Enter, Qt.Key_Return): + if not shift and not ctrl: + if ( + self.add_colons_enabled and + self.is_python_like() and + self.autoinsert_colons() + ): + self.insert_text(':' + self.get_line_separator()) + if self.strip_trailing_spaces_on_modify: + self.fix_and_strip_indent() + else: + self.fix_indent() + else: + cur_indent = self.get_block_indentation( + self.textCursor().blockNumber()) + self._handle_keypress_event(event) + # Check if we're in a comment or a string at the + # current position + cmt_or_str_cursor = self.in_comment_or_string() + + # Check if the line start with a comment or string + cursor = self.textCursor() + cursor.setPosition(cursor.block().position(), + QTextCursor.KeepAnchor) + cmt_or_str_line_begin = self.in_comment_or_string( + cursor=cursor) + + # Check if we are in a comment or a string + cmt_or_str = cmt_or_str_cursor and \ + cmt_or_str_line_begin + + if self.strip_trailing_spaces_on_modify: + self.fix_and_strip_indent( + comment_or_string=cmt_or_str, + cur_indent=cur_indent) + else: + self.fix_indent(comment_or_string=cmt_or_str, + cur_indent=cur_indent) + # ---- intelligent backspace handling + elif key == Qt.Key_Backspace and not shift and not ctrl: + increasing_position = False + if self.has_selected_text() or not self.intelligent_backspace: + self._handle_keypress_event(event) + else: + leading_text = self.get_text('sol', 'cursor') + leading_length = len(leading_text) + trailing_spaces = ( + leading_length - len(leading_text.rstrip()) + ) + trailing_text = self.get_text('cursor', 'eol') + matches = ('()', '[]', '{}', '\'\'', '""') + if ( + not leading_text.strip() and + (leading_length > len(self.indent_chars)) + ): + if leading_length % len(self.indent_chars) == 0: + self.unindent() + else: + self._handle_keypress_event(event) + elif trailing_spaces and not trailing_text.strip(): + self.remove_suffix(leading_text[-trailing_spaces:]) + elif ( + leading_text and + trailing_text and + (leading_text[-1] + trailing_text[0] in matches) + ): + cursor = self.textCursor() + cursor.movePosition(QTextCursor.PreviousCharacter) + cursor.movePosition(QTextCursor.NextCharacter, + QTextCursor.KeepAnchor, 2) + cursor.removeSelectedText() + else: + self._handle_keypress_event(event) + # ---- handle home, end + elif key == Qt.Key.Key_Home: + increasing_position = False + self.stdkey_home(shift, ctrl) + elif key == Qt.Key.Key_End: + # See spyder-ide/spyder#495: on MacOS X, it is necessary to + # redefine this basic action which should have been implemented + # natively + self.stdkey_end(shift, ctrl) + # ---- use default handler for cursor (text) + else: + if key in (Qt.Key.Key_Up, Qt.Key.Key_Left): + increasing_position = False + if (key in (Qt.Key.Key_Up, Qt.Key.Key_Down) and + cursor.verticalMovementX() == -1): + # Builtin handler somehow does not set verticalMovementX + # when moving up and down (but works fine for single + # cursor somehow) + # TODO why? Am I forgetting something? + x = self.cursorRect(cursor).x() + cursor.setVerticalMovementX(x) + self.setTextCursor(cursor) + self._handle_keypress_event(event) + + # Update edited extra_cursors + new_cursors.append(self.textCursor()) + self.extra_cursors = new_cursors[:-1] + self.merge_extra_cursors(increasing_position) + self.textCursor().endEditBlock() + self.multi_cursor_ignore_history = False + self.cursorPositionChanged.emit() + event.accept() # TODO when to pass along keypress or not + + def _on_cursor_blinktimer_timeout(self): + """ + Text cursor blink timer generates paint events and inverts draw state + """ + self.cursor_blink_state = not self.cursor_blink_state + if self.isVisible(): + self.viewport().update() + + @Slot(QPaintEvent) + def paint_cursors(self, event): + """Paint all cursors""" + if self.overwrite_mode: + font = self.textCursor().block().charFormat().font() + cursor_width = QFontMetrics(font).horizontalAdvance(" ") + else: + cursor_width = self.cursor_width + + qp = QPainter() + qp.begin(self.viewport()) + offset = self.contentOffset() + content_offset_y = offset.y() + qp.setBrushOrigin(offset) + editable = not self.isReadOnly() + flags = (self.textInteractionFlags() & + Qt.TextInteractionFlag.TextSelectableByKeyboard) + + if self._drag_cursor is not None and (editable or flags): + cursor = self._drag_cursor + block = cursor.block() + if block.isVisible(): + block_top = int(self.blockBoundingGeometry(block).top()) + offset.setY(block_top + content_offset_y) + layout = block.layout() + if layout is not None: # Fix exceptions in test_flag_painting + layout.drawCursor(qp, offset, + cursor.positionInBlock(), + cursor_width) + + draw_cursor = self.cursor_blink_state and (editable or flags) + + for cursor in self.all_cursors: + block = cursor.block() + if draw_cursor and block.isVisible(): + block_top = int(self.blockBoundingGeometry(block).top()) + offset.setY(block_top + content_offset_y) + layout = block.layout() + if layout is not None: + layout.drawCursor(qp, offset, + cursor.positionInBlock(), + cursor_width) + qp.end() + + @Slot() + def start_cursor_blink(self): + """start manually updating the cursor(s) blink state: Show cursors""" + self.cursor_blink_state = True + self.cursor_blink_timer.start() + + @Slot() + def stop_cursor_blink(self): + """stop manually updating the cursor(s) blink state: Hide cursors""" + self.cursor_blink_state = False + self.cursor_blink_timer.stop() + + def multi_cursor_copy(self): + """ + Join all cursor selections in position sorted order by line_separator, + and put text to clipboard. + """ + cursors = self.all_cursors + cursors.sort(key=lambda cursor: cursor.position()) + selections = [] + for cursor in cursors: + text = cursor.selectedText().replace(u"\u2029", + self.get_line_separator()) + selections.append(text) + clip_text = self.get_line_separator().join(selections) + QApplication.clipboard().setText(clip_text) + + def multi_cursor_cut(self): + """Multi-cursor copy then removeSelectedText""" + self.multi_cursor_copy() + self.textCursor().beginEditBlock() + for cursor in self.all_cursors: + cursor.removeSelectedText() + # merge direction doesn't matter here as all selections are removed + self.merge_extra_cursors(True) + self.textCursor().endEditBlock() + + def multi_cursor_paste(self, clip_text): + """ + Split clipboard by lines, and paste one line per cursor in position + sorted order. + """ + main_cursor = self.textCursor() + main_cursor.beginEditBlock() + cursors = self.all_cursors + cursors.sort(key=lambda cursor: cursor.position()) + self.skip_rstrip = True + self.sig_will_paste_text.emit(clip_text) + lines = clip_text.splitlines() + if len(lines) == 1: + lines = itertools.repeat(lines[0]) + self.multi_cursor_ignore_history = True + for cursor, text in zip(cursors, lines): + self.setTextCursor(cursor) + cursor.insertText(text) + # handle extra lines or extra cursors? + self.setTextCursor(main_cursor) + self.multi_cursor_ignore_history = False + self.cursorPositionChanged.emit() + # merge direction doesn't matter here as all selections are removed + self.merge_extra_cursors(True) + main_cursor.endEditBlock() + self.sig_text_was_inserted.emit() + self.skip_rstrip = False + + def for_each_cursor(self, method, merge_increasing=True): + """Wrap callable to execute once for each cursor""" + @functools.wraps(method) + def wrapper(): + self.textCursor().beginEditBlock() + new_cursors = [] + self.multi_cursor_ignore_history = True + for cursor in self.all_cursors: + self.setTextCursor(cursor) + # may call setTtextCursor with modified copy + method() + # get modified cursor to re-add to extra_cursors + new_cursors.append(self.textCursor()) + + # re-add extra cursors + self.clear_extra_cursors() + for cursor in new_cursors[:-1]: + self.add_cursor(cursor) + self.setTextCursor(new_cursors[-1]) + self.merge_extra_cursors(merge_increasing) + self.textCursor().endEditBlock() + self.multi_cursor_ignore_history = False + self.cursorPositionChanged.emit() + return wrapper + + def clears_extra_cursors(self, method): + """Wrap callable to clear extra_cursors prior to calling""" + @functools.wraps(method) + def wrapper(): + self.clear_extra_cursors() + method() + return wrapper + + def restrict_single_cursor(self, method): + """Wrap callable to only execute if there is a single cursor""" + @functools.wraps(method) + def wrapper(): + if not self.extra_cursors: + method() + return wrapper + + def go_to_next_cell(self): + """ + reimplements TextEditBaseWidget.go_to_next_cell to clear extra cursors + """ + self.clear_extra_cursors() + super().go_to_next_cell() + + def go_to_previous_cell(self): + """ + reimplements TextEditBaseWidget.go_to_previous_cell to clear extra + cursors + """ + self.clear_extra_cursors() + super().go_to_previous_cell() From ac15f0e94ce876e254fc4c216e027a8c331f230a Mon Sep 17 00:00:00 2001 From: athompson673 Date: Thu, 19 Dec 2024 21:35:21 -0500 Subject: [PATCH 75/95] import style edits --- spyder/plugins/editor/widgets/codeeditor/codeeditor.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index a7126f85c95..61d092161e3 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -32,9 +32,9 @@ from qtpy.compat import to_qvariant from qtpy.QtCore import ( QEvent, QRegularExpression, Qt, QTimer, QUrl, Signal, Slot) -from qtpy.QtGui import (QColor, QCursor, QFont, QPaintEvent, - QPainter, QMouseEvent, QTextCursor, QDesktopServices, - QKeyEvent, QTextDocument, QTextFormat, QTextOption, +from qtpy.QtGui import (QColor, QCursor, QFont, QPaintEvent, QPainter, + QMouseEvent, QTextCursor, QDesktopServices, QKeyEvent, + QTextDocument, QTextFormat, QTextOption, QTextCharFormat, QTextLayout) from qtpy.QtWidgets import QApplication, QMessageBox, QSplitter, QScrollBar from spyder_kernels.utils.dochelpers import getobj @@ -60,7 +60,8 @@ from spyder.plugins.editor.widgets.gotoline import GoToLineDialog from spyder.plugins.editor.widgets.base import TextEditBaseWidget from spyder.plugins.editor.widgets.codeeditor.lsp_mixin import LSPMixin -from spyder.plugins.editor.widgets.codeeditor.multicursor_mixin import MultiCursorMixin +from spyder.plugins.editor.widgets.codeeditor.multicursor_mixin import ( + MultiCursorMixin,) from spyder.plugins.outlineexplorer.api import (OutlineExplorerData as OED, is_cell_header) from spyder.py3compat import to_text_string, is_string From 2d5ba2348e1e91b0022dd0ec485605e0324968c5 Mon Sep 17 00:00:00 2001 From: athompson673 Date: Thu, 19 Dec 2024 22:54:14 -0500 Subject: [PATCH 76/95] solved TODO: move line with multiple cursors on same line --- .../editor/widgets/codeeditor/codeeditor.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 61d092161e3..d8339471e2b 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -4359,9 +4359,7 @@ def move_line_down(self): def __move_line_or_selection(self, after_current_line=True): # TODO: Multi-cursor implementation improperly handles moving multiple - # cursors up against the end of the file (lines get swapped) - # TODO: Multi-cursor implementation improperly handles multiple cursors - # on the same line. + # cursors up against the start or end of the file (lines get swapped) self.textCursor().beginEditBlock() self.multi_cursor_ignore_history = True sorted_cursors = sorted( @@ -4369,9 +4367,18 @@ def __move_line_or_selection(self, after_current_line=True): key=lambda cursor: cursor.position(), reverse=after_current_line ) + lines_to_move = set() + one_cursor_per_line = [] + for cursor in sorted_cursors: + line_number = cursor.block().blockNumber() + if line_number in lines_to_move: + continue + else: + one_cursor_per_line.append(cursor) + lines_to_move.add(line_number) new_cursors = [] - for cursor in sorted_cursors: + for cursor in one_cursor_per_line: self.setTextCursor(cursor) # Unfold any folded code block before moving lines up/down From 3dea75863956dde0ccd235d2e52d9f9929e51033 Mon Sep 17 00:00:00 2001 From: Aaron Date: Fri, 20 Dec 2024 17:09:37 -0500 Subject: [PATCH 77/95] WIP test_multicursor --- .../codeeditor/tests/test_multicursor.py | 179 ++++++++++++++---- 1 file changed, 143 insertions(+), 36 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py b/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py index 6f9cc757201..4079dc75701 100644 --- a/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py +++ b/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py @@ -16,7 +16,6 @@ def click_at(codeeditor, qtbot, position, ctrl=False, alt=False, shift=False): x, y = codeeditor.get_coordinates(position) - point = codeeditor.calculate_real_position(QPoint(x, y)) point = QPoint(x, y) modifiers = Qt.KeyboardModifier.NoModifier @@ -26,45 +25,47 @@ def click_at(codeeditor, qtbot, position, ctrl=False, alt=False, shift=False): modifiers |= Qt.KeyboardModifier.AltModifier if shift: modifiers |= Qt.KeyboardModifier.ShiftModifier - - qtbot.mouseClick( - codeeditor.viewport(), - Qt.MouseButton.LeftButton, - modifiers, - pos=point - ) + qtbot.mouseClick(codeeditor.viewport(), + Qt.MouseButton.LeftButton, + modifiers, + pos=point) def test_add_cursor(codeeditor, qtbot): - # Enabled by default arg on CodeEditor.setup_editor (which is called in the - # pytest fixture creation in conftest.py) + # enabled by default arg on CodeEditor.setup_editor (which is called in the + # pytest fixture creation in conftest.py) assert codeeditor.multi_cursor_enabled assert codeeditor.cursorWidth() == 0 # required for multi-cursor rendering codeeditor.set_text("0123456789") click_at(codeeditor, qtbot, 6, ctrl=True, alt=True) - # A cursor was added assert bool(codeeditor.extra_cursors) qtbot.keyClick(codeeditor, "a") - # Text was inserted correctly from two cursors assert codeeditor.toPlainText() == "a012345a6789" - # Regular click to set main cursor and clear extra cursors + # regular click to set main cursor and clear extra cursors click_at(codeeditor, qtbot, 6) assert not bool(codeeditor.extra_cursors) qtbot.keyClick(codeeditor, "b") assert codeeditor.toPlainText() == "a01234b5a6789" + # don't add another cursor on top of main cursor + click_at(codeeditor, qtbot, 7, ctrl=True, alt=True) + assert not bool(codeeditor.extra_cursors) + + # test removing cursors + click_at(codeeditor, qtbot, 2, ctrl=True, alt=True) + # remove main cursor + click_at(codeeditor, qtbot, 2, ctrl=True, alt=True) + assert codeeditor.textCursor().position() == 7 def test_column_add_cursor(codeeditor, qtbot): codeeditor.set_text("0123456789\n0123456789\n0123456789\n0123456789\n") cursor = codeeditor.textCursor() - cursor.movePosition( - QTextCursor.MoveOperation.Down, - QTextCursor.MoveMode.MoveAnchor, - 3 - ) + cursor.movePosition(QTextCursor.MoveOperation.Down, + QTextCursor.MoveMode.MoveAnchor, + 3) codeeditor.setTextCursor(cursor) click_at(codeeditor, qtbot, 6, ctrl=True, alt=True, shift=True) @@ -79,19 +80,15 @@ def test_settings_toggle(codeeditor, qtbot): assert codeeditor.cursorWidth() == 0 # required for multi-cursor rendering codeeditor.set_text("0123456789\n0123456789\n") click_at(codeeditor, qtbot, 6, ctrl=True, alt=True) - # A cursor was added assert bool(codeeditor.extra_cursors) codeeditor.toggle_multi_cursor(False) - # Extra cursors removed on settings toggle assert not bool(codeeditor.extra_cursors) click_at(codeeditor, qtbot, 3, ctrl=True, alt=True) - # Extra cursors not added wnen settings "multi-cursor enabled" is False assert not bool(codeeditor.extra_cursors) click_at(codeeditor, qtbot, 13, ctrl=True, alt=True, shift=True) - # Column cursors not added wnen settings "multi-cursor enabled" is False assert not bool(codeeditor.extra_cursors) @@ -99,11 +96,9 @@ def test_settings_toggle(codeeditor, qtbot): def test_extra_selections_decoration(codeeditor, qtbot): codeeditor.set_text("0123456789\n0123456789\n0123456789\n0123456789\n") cursor = codeeditor.textCursor() - cursor.movePosition( - QTextCursor.MoveOperation.Down, - QTextCursor.MoveMode.MoveAnchor, - 3 # column 0 row 4 - ) + cursor.movePosition(QTextCursor.MoveOperation.Down, + QTextCursor.MoveMode.MoveAnchor, + 3) # column 0 row 4 codeeditor.setTextCursor(cursor) click_at(codeeditor, qtbot, 6, ctrl=True, alt=True, shift=True) selections = codeeditor.get_extra_selections("extra_cursor_selections") @@ -148,15 +143,128 @@ def test_overwrite_mode(codeeditor, qtbot): assert codeeditor.toPlainText() == "0123abc6789\n01234bc6789\n" # TODO test folded code - # extra cursor movement (skip past hidden blocks) - # typing on a folded line (should be read-only) - # delete & backspace (& delete line shortcut) should delete folded section - # move/duplicate line up/down should unfold current and previous/next lines -# TODO test drag & drop cursor rendering -# TODO test backspace & smart backspace -# TODO test smart indent, colon insertion, matching () "" '' +# extra cursor movement (skip past hidden blocks) +# typing on a folded line (should be read-only) +# delete & backspace (& delete line shortcut) should delete folded section +# move/duplicate line up/down should unfold current and previous/next lines + + +# def test_drag_and_drop(codeeditor, qtbot): +# # test drag & drop cursor rendering +# codeeditor.set_text("0123456789\nabcdefghij\n") +# cursor = codeeditor.textCursor() +# cursor.movePosition(cursor.MoveOperation.NextBlock, +# cursor.MoveMode.KeepAnchor) + +# assert codeeditor._drag_cursor is None +# point = QPoint(*codeeditor.get_coordinates(5)) +# qtbot.mousePress(codeeditor.viewport(), +# Qt.MouseButton.LeftButton, +# pos=point) + +# point = QPoint(*codeeditor.get_coordinates(22)) +# # TODO not working: this doesn't generate a DragEnter event or DragMove +# # events. Why? +# qtbot.mouseMove(codeeditor.viewport(), +# pos=point) +# assert codeeditor._drag_cursor is not None +# qtbot.mouseRelease(codeeditor.viewport(), +# Qt.MouseButton.LeftButton, +# pos=point) +# assert codeeditor._drag_cursor is None +# assert codeeditor.toPlainText() == "abcdefghij\n0123456789\n" + +def test_smart_text(codeeditor, qtbot): + # test smart backspace, whitespace insertion, colon insertion, and + # parenthesis/quote matching + codeeditor.set_text("def test1\n" + "def test2\n") + click_at(codeeditor, qtbot, 9) + click_at(codeeditor, qtbot, 19, ctrl=True, alt=True) + qtbot.keyClick(codeeditor, Qt.Key.Key_ParenLeft) + # closing paren was inserted? + assert codeeditor.toPlainText() == ("def test1()\n" + "def test2()\n") + qtbot.keyClick(codeeditor, Qt.Key.Key_ParenRight) + # typing close paren advances cursor without adding extra paren? + assert codeeditor.toPlainText() == ("def test1()\n" + "def test2()\n") + qtbot.keyClick(codeeditor, Qt.Key.Key_Return) + # auto colon and indent? + assert codeeditor.toPlainText() == ("def test1():\n" + " \n" + "def test2():\n" + " \n") + # add some extraneous whitespace + qtbot.keyClick(codeeditor, Qt.Key.Key_Tab) + qtbot.keyClick(codeeditor, Qt.Key.Key_Tab) + assert codeeditor.toPlainText() == ("def test1():\n" + " \n" + "def test2():\n" + " \n") + qtbot.keyClick(codeeditor, Qt.Key.Key_Backspace) + # smart backspace to correct indent? + assert codeeditor.toPlainText() == ("def test1():\n" + " \n" + "def test2():\n" + " \n") + for cursor in codeeditor.all_cursors: + cursor.insertText("return") + assert codeeditor.toPlainText() == ("def test1():\n" + " return\n" + "def test2():\n" + " return\n") + codeeditor.set_close_quotes_enabled(True) + qtbot.keyClick(codeeditor, Qt.Key.Key_Space) + qtbot.keyClick(codeeditor, Qt.Key.Key_Apostrophe) + # automatic quote + assert codeeditor.toPlainText() == ("def test1():\n" + " return ''\n" + "def test2():\n" + " return ''\n") + qtbot.keyClick(codeeditor, Qt.Key.Key_Apostrophe) + # automatic close quote + assert codeeditor.toPlainText() == ("def test1():\n" + " return ''\n" + "def test2():\n" + " return ''\n") + qtbot.keyClick(codeeditor, Qt.Key.Key_Return) + # automatic dedent? + assert codeeditor.toPlainText() == ("def test1():\n" + " return ''\n" + "\n" + "def test2():\n" + " return ''\n" + "\n") + + # ---- shortcuts -# TODO test code_completion shourcut (ensure disabled) + +# def test_disable_code_completion(completions_codeeditor, qtbot): +# code_editor, _ = completions_codeeditor +# code_editor.set_text("def test1():\n" +# " return\n") +# completion = code_editor.completion_widget +# delay = 50 +# code_editor.toggle_code_snippets(False) + +# code_editor.moveCursor(QTextCursor.MoveOperation.End) +# qtbot.keyClicks(code_editor, "tes", delay=delay) +# qtbot.wait(2000) # keyboard idle + wait for completion time +# assert not completion.isHidden() +# # TODO doesn't hide completions, why? +# qtbot.keyClick(code_editor, Qt.Key.Key_Escape) +# # TODO doesn't hide completions, why? +# click_at(code_editor, qtbot, 0) +# qtbot.wait(1000) +# click_at(code_editor, qtbot, 27, ctrl=True, alt=True) +# qtbot.keyClicks(code_editor, "t", delay=delay) +# qtbot.wait(1000) # keyboard idle + wait for completion time +# # XXX fails because first completions box is never hidden. +# assert completion.isHidden() +# # TODO test ctrl-space shortcut too + + # TODO test duplicate/move line up/down # TODO test delete line # TODO test goto new line @@ -182,6 +290,5 @@ def test_overwrite_mode(codeeditor, qtbot): # TODO test run Cell (and advance) # TODO test run selection (and advance)(from line)(in debugger) - if __name__ == '__main__': pytest.main(['test_multicursor.py']) From dff9d063d8acf7998501048c9ab96e8d8e008a08 Mon Sep 17 00:00:00 2001 From: Aaron Date: Mon, 30 Dec 2024 16:48:29 -0500 Subject: [PATCH 78/95] Style comments, and a few more tests written --- .../codeeditor/tests/test_multicursor.py | 210 ++++++++++++++---- 1 file changed, 167 insertions(+), 43 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py b/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py index 4079dc75701..2ac0c24d447 100644 --- a/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py +++ b/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py @@ -14,91 +14,122 @@ # Local imports +ControlModifier = Qt.KeyboardModifier.ControlModifier +AltModifier = Qt.KeyboardModifier.AltModifier +ShiftModifier = Qt.KeyboardModifier.ShiftModifier + + def click_at(codeeditor, qtbot, position, ctrl=False, alt=False, shift=False): + """ + Convienience function to generate a mouseClick at a given text position + with the specified modifiers held. + """ + x, y = codeeditor.get_coordinates(position) point = QPoint(x, y) modifiers = Qt.KeyboardModifier.NoModifier if ctrl: - modifiers |= Qt.KeyboardModifier.ControlModifier + modifiers |= ControlModifier if alt: - modifiers |= Qt.KeyboardModifier.AltModifier + modifiers |= AltModifier if shift: - modifiers |= Qt.KeyboardModifier.ShiftModifier - qtbot.mouseClick(codeeditor.viewport(), - Qt.MouseButton.LeftButton, - modifiers, - pos=point) + modifiers |= ShiftModifier + qtbot.mouseClick( + codeeditor.viewport(), + Qt.MouseButton.LeftButton, + modifiers, + pos=point + ) +@pytest.mark.order(1) def test_add_cursor(codeeditor, qtbot): - # enabled by default arg on CodeEditor.setup_editor (which is called in the - # pytest fixture creation in conftest.py) - assert codeeditor.multi_cursor_enabled - assert codeeditor.cursorWidth() == 0 # required for multi-cursor rendering + """Test adding and removing extra cursors with crtl-alt click""" + + # Enabled by default arg on CodeEditor.setup_editor (which is called in the + # pytest fixture creation in conftest.py) + assert codeeditor.multi_cursor_enabled # This is assumed for other tests + assert codeeditor.cursorWidth() == 0 # Required for multi-cursor rendering codeeditor.set_text("0123456789") click_at(codeeditor, qtbot, 6, ctrl=True, alt=True) + # A cursor was added assert bool(codeeditor.extra_cursors) qtbot.keyClick(codeeditor, "a") + # Text was inserted correctly from two cursors assert codeeditor.toPlainText() == "a012345a6789" - # regular click to set main cursor and clear extra cursors + # Regular click to set main cursor and clear extra cursors click_at(codeeditor, qtbot, 6) assert not bool(codeeditor.extra_cursors) qtbot.keyClick(codeeditor, "b") assert codeeditor.toPlainText() == "a01234b5a6789" - # don't add another cursor on top of main cursor + # Don't add another cursor on top of main cursor click_at(codeeditor, qtbot, 7, ctrl=True, alt=True) assert not bool(codeeditor.extra_cursors) - # test removing cursors + # Test removing cursors click_at(codeeditor, qtbot, 2, ctrl=True, alt=True) - # remove main cursor + # Remove main cursor click_at(codeeditor, qtbot, 2, ctrl=True, alt=True) assert codeeditor.textCursor().position() == 7 def test_column_add_cursor(codeeditor, qtbot): + """Test adding a column of extra cursors with ctrl-alt-shift click""" + codeeditor.set_text("0123456789\n0123456789\n0123456789\n0123456789\n") cursor = codeeditor.textCursor() - cursor.movePosition(QTextCursor.MoveOperation.Down, - QTextCursor.MoveMode.MoveAnchor, - 3) + # Move main cursor to bottom left + cursor.movePosition( + QTextCursor.MoveOperation.Down, + QTextCursor.MoveMode.MoveAnchor, + 3 + ) codeeditor.setTextCursor(cursor) + # Column cursor click at top row 6th column click_at(codeeditor, qtbot, 6, ctrl=True, alt=True, shift=True) - assert bool(codeeditor.extra_cursors) + assert codeeditor.extra_cursors assert len(codeeditor.all_cursors) == 4 for cursor in codeeditor.all_cursors: assert cursor.selectedText() == "012345" def test_settings_toggle(codeeditor, qtbot): - assert codeeditor.multi_cursor_enabled - assert codeeditor.cursorWidth() == 0 # required for multi-cursor rendering + """Test toggling multicursor support in settings""" + codeeditor.set_text("0123456789\n0123456789\n") click_at(codeeditor, qtbot, 6, ctrl=True, alt=True) + # A cursor was added assert bool(codeeditor.extra_cursors) codeeditor.toggle_multi_cursor(False) + # Extra cursors removed on settings toggle assert not bool(codeeditor.extra_cursors) click_at(codeeditor, qtbot, 3, ctrl=True, alt=True) + # Extra cursors not added wnen settings "multi-cursor enabled" is False assert not bool(codeeditor.extra_cursors) click_at(codeeditor, qtbot, 13, ctrl=True, alt=True, shift=True) + # Column cursors not added wnen settings "multi-cursor enabled" is False assert not bool(codeeditor.extra_cursors) def test_extra_selections_decoration(codeeditor, qtbot): + """Ensure text decorations are created to paint extra cursor selections.""" + codeeditor.set_text("0123456789\n0123456789\n0123456789\n0123456789\n") cursor = codeeditor.textCursor() - cursor.movePosition(QTextCursor.MoveOperation.Down, - QTextCursor.MoveMode.MoveAnchor, - 3) # column 0 row 4 + cursor.movePosition( + QTextCursor.MoveOperation.Down, + QTextCursor.MoveMode.MoveAnchor, + 3 + ) codeeditor.setTextCursor(cursor) click_at(codeeditor, qtbot, 6, ctrl=True, alt=True, shift=True) selections = codeeditor.get_extra_selections("extra_cursor_selections") @@ -106,8 +137,7 @@ def test_extra_selections_decoration(codeeditor, qtbot): def test_multi_cursor_verticalMovementX(codeeditor, qtbot): - # Ensure extra cursors (and primary cursor) keep column position when - # moving up and down + """Ensure cursors keep their column position when moving up and down.""" codeeditor.set_text("012345678\n012345678\n\n012345678\n012345678\n") click_at(codeeditor, qtbot, 4) click_at(codeeditor, qtbot, 14, ctrl=True, alt=True) @@ -122,20 +152,32 @@ def test_multi_cursor_verticalMovementX(codeeditor, qtbot): def test_overwrite_mode(codeeditor, qtbot): - # Multi-cursor rendering requires overwrite mode be handled manually as - # there is no way to hide the primary textCursor with overwriteMode, and - # there is no way to sync the blinking of extra cursors with native - # rendering. + """ + Multi-cursor rendering requires overwrite mode be handled manually as there + is no way to hide the primary textCursor with overwriteMode, and there is + no way to sync the blinking of extra cursors with native rendering. + + Test overwrite mode (insert key) + """ + codeeditor.set_text("0123456789\n0123456789\n") click_at(codeeditor, qtbot, 4) qtbot.keyClick(codeeditor, Qt.Key.Key_Insert) + + # Overwrite mode is tracked manually, ensure the Qt property is False assert not codeeditor.overwriteMode() assert codeeditor.overwrite_mode + + # Test overwrite mode functionality for single cursor qtbot.keyClick(codeeditor, Qt.Key.Key_A) assert codeeditor.toPlainText() == "0123a56789\n0123456789\n" + + # Test overwrite mode for multiple cursors click_at(codeeditor, qtbot, 16, ctrl=True, alt=True) qtbot.keyClick(codeeditor, Qt.Key.Key_B) assert codeeditor.toPlainText() == "0123ab6789\n01234b6789\n" + + # Test returning to insert mode qtbot.keyClick(codeeditor, Qt.Key.Key_Insert) assert not codeeditor.overwriteMode() assert not codeeditor.overwrite_mode @@ -175,27 +217,30 @@ def test_overwrite_mode(codeeditor, qtbot): # assert codeeditor.toPlainText() == "abcdefghij\n0123456789\n" def test_smart_text(codeeditor, qtbot): - # test smart backspace, whitespace insertion, colon insertion, and - # parenthesis/quote matching + """ + Test smart text features: Smart backspace, whitespace insertion, colon + insertion, parenthesis and quote matching. + """ + codeeditor.set_text("def test1\n" "def test2\n") click_at(codeeditor, qtbot, 9) click_at(codeeditor, qtbot, 19, ctrl=True, alt=True) qtbot.keyClick(codeeditor, Qt.Key.Key_ParenLeft) - # closing paren was inserted? + # Closing paren was inserted? assert codeeditor.toPlainText() == ("def test1()\n" "def test2()\n") qtbot.keyClick(codeeditor, Qt.Key.Key_ParenRight) - # typing close paren advances cursor without adding extra paren? + # Typing close paren advances cursor without adding extra paren? assert codeeditor.toPlainText() == ("def test1()\n" "def test2()\n") qtbot.keyClick(codeeditor, Qt.Key.Key_Return) - # auto colon and indent? + # Auto colon and indent? assert codeeditor.toPlainText() == ("def test1():\n" " \n" "def test2():\n" " \n") - # add some extraneous whitespace + # Add some extraneous whitespace qtbot.keyClick(codeeditor, Qt.Key.Key_Tab) qtbot.keyClick(codeeditor, Qt.Key.Key_Tab) assert codeeditor.toPlainText() == ("def test1():\n" @@ -203,7 +248,7 @@ def test_smart_text(codeeditor, qtbot): "def test2():\n" " \n") qtbot.keyClick(codeeditor, Qt.Key.Key_Backspace) - # smart backspace to correct indent? + # Smart backspace to correct indent? assert codeeditor.toPlainText() == ("def test1():\n" " \n" "def test2():\n" @@ -217,19 +262,19 @@ def test_smart_text(codeeditor, qtbot): codeeditor.set_close_quotes_enabled(True) qtbot.keyClick(codeeditor, Qt.Key.Key_Space) qtbot.keyClick(codeeditor, Qt.Key.Key_Apostrophe) - # automatic quote + # Automatic quote assert codeeditor.toPlainText() == ("def test1():\n" " return ''\n" "def test2():\n" " return ''\n") qtbot.keyClick(codeeditor, Qt.Key.Key_Apostrophe) - # automatic close quote + # Automatic close quote assert codeeditor.toPlainText() == ("def test1():\n" " return ''\n" "def test2():\n" " return ''\n") qtbot.keyClick(codeeditor, Qt.Key.Key_Return) - # automatic dedent? + # Automatic dedent? assert codeeditor.toPlainText() == ("def test1():\n" " return ''\n" "\n" @@ -264,10 +309,89 @@ def test_smart_text(codeeditor, qtbot): # assert completion.isHidden() # # TODO test ctrl-space shortcut too +def test_move_line(codeeditor, qtbot): + """Test multi-cursor move line up and down shortcut""" + + codeeditor.set_text("\n".join("123456")) + click_at(codeeditor, qtbot, 4, ctrl=True, alt=True) + + # Move line down (twice) + qtbot.keyClick( + codeeditor, + Qt.Key.Key_Down, + AltModifier + ) + qtbot.keyClick( + codeeditor, + Qt.Key.Key_Down, + AltModifier + ) + assert codeeditor.toPlainText() == "\n".join("241536") + + # Move line up + qtbot.keyClick( + codeeditor, + Qt.Key.Key_Up, + AltModifier + ) + assert codeeditor.toPlainText() == "\n".join("214356") + + +def test_duplicate_line(codeeditor, qtbot): + """Test multi-cursor duplicate line up and down shortcut""" + + codeeditor.set_text("\n".join("123456")) + click_at(codeeditor, qtbot, 4, ctrl=True, alt=True) + + # Duplicate line down + qtbot.keyClick( + codeeditor, + Qt.Key.Key_Down, + ControlModifier | AltModifier + ) + assert codeeditor.toPlainText() == "\n".join("11233456") + assert codeeditor.textCursor().position() == 8 + assert codeeditor.extra_cursors[0].position() == 2 + + # Duplicate line up + qtbot.keyClick( + codeeditor, + Qt.Key.Key_Up, + ControlModifier | AltModifier + ) + assert codeeditor.toPlainText() == "\n".join("1112333456") + assert codeeditor.textCursor().position() == 10 + assert codeeditor.extra_cursors[0].position() == 2 + + +def test_delete_line(codeeditor, qtbot): + """Test delete line shortcut""" + + codeeditor.set_text("\n".join("123456")) + click_at(codeeditor, qtbot, 4, ctrl=True, alt=True) + # Delete line + qtbot.keyClick( + codeeditor, + Qt.Key.Key_D, + ControlModifier + ) + assert codeeditor.toPlainText() == "\n".join("2456") + + +def test_goto_new_line(codeeditor, qtbot): + """Test 'go to new line' shortcut""" + + codeeditor.set_text("\n".join("123456")) + click_at(codeeditor, qtbot, 4, ctrl=True, alt=True) + # Delete line + qtbot.keyClick( + codeeditor, + Qt.Key.Key_Return, + ControlModifier | ShiftModifier + ) + assert codeeditor.toPlainText() == "1\n\n2\n3\n\n4\n5\n6" + -# TODO test duplicate/move line up/down -# TODO test delete line -# TODO test goto new line # TODO test goto line number / definition / next cell / previous cell # TODO test toggle comment, blockcomment, unblockcomment # TODO test transform to UPPER / lower case From 10cf0f3d8d07ef726b37f6ce5573d80b65229f07 Mon Sep 17 00:00:00 2001 From: Aaron Date: Mon, 30 Dec 2024 16:54:12 -0500 Subject: [PATCH 79/95] fix bad merge edit --- .../editor/widgets/codeeditor/codeeditor.py | 31 ------------------- 1 file changed, 31 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 5b4533b9090..d1bd32d7603 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -599,8 +599,6 @@ def cursor_move_event(): def register_shortcuts(self): """Register shortcuts for this widget.""" shortcuts = ( - # TODO: Should multi-cursor wrappers be applied as decorator to - # function definitions instead where possible? ('code completion', self.restrict_single_cursor( self.do_completion)), ('duplicate line down', self.duplicate_line_down), @@ -4438,35 +4436,6 @@ def __move_line_or_selection(self, after_current_line=True): self.multi_cursor_ignore_history = False self.cursorPositionChanged.emit() - else: - # Unfold any folded region when moving lines up - block = cursor.block() - offset = 0 - if self.has_selected_text(): - ((selection_start, _), - (selection_end)) = self.get_selection_start_end() - if selection_end != selection_start: - offset = 1 - fold_start_line = block.blockNumber() - 1 - offset - - # Find the innermost code folding region for the current position - enclosing_regions = sorted(list( - self.folding_panel.current_tree[fold_start_line])) - - folding_status = self.folding_panel.folding_status - if len(enclosing_regions) > 0: - for region in enclosing_regions: - fold_start_line = region.begin - block = self.document().findBlockByNumber(fold_start_line) - if fold_start_line in folding_status: - fold_status = folding_status[fold_start_line] - if fold_status: - self.folding_panel.toggle_fold_trigger(block) - - self.move_line_or_selection( - after_current_line=after_current_line - ) - def mouseMoveEvent(self, event): """Underline words when pressing """ # Restart timer every time the mouse is moved From 04cd94b2cfa77615c1c30699a5c3f66a4d2107aa Mon Sep 17 00:00:00 2001 From: Aaron Date: Mon, 30 Dec 2024 18:25:51 -0500 Subject: [PATCH 80/95] call shortcuts directly to side-step key input issues with other platforms during testing --- .../codeeditor/tests/test_multicursor.py | 59 ++++++------------- 1 file changed, 18 insertions(+), 41 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py b/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py index 2ac0c24d447..3a3853954aa 100644 --- a/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py +++ b/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py @@ -43,6 +43,17 @@ def click_at(codeeditor, qtbot, position, ctrl=False, alt=False, shift=False): ) +def call_shortcut(codeeditor, name): + """ + Convienience function to call a QShortcut without having to simulate the + key sequence (which may be different for each platform?) + """ + context = codeeditor.CONF_SECTION.lower() + plugin_name = None + qshortcut = codeeditor._shortcuts[(context, name, plugin_name)] + qshortcut.activated.emit() + + @pytest.mark.order(1) def test_add_cursor(codeeditor, qtbot): """Test adding and removing extra cursors with crtl-alt click""" @@ -315,25 +326,11 @@ def test_move_line(codeeditor, qtbot): codeeditor.set_text("\n".join("123456")) click_at(codeeditor, qtbot, 4, ctrl=True, alt=True) - # Move line down (twice) - qtbot.keyClick( - codeeditor, - Qt.Key.Key_Down, - AltModifier - ) - qtbot.keyClick( - codeeditor, - Qt.Key.Key_Down, - AltModifier - ) + call_shortcut(codeeditor, "move line down") + call_shortcut(codeeditor, "move line down") assert codeeditor.toPlainText() == "\n".join("241536") - # Move line up - qtbot.keyClick( - codeeditor, - Qt.Key.Key_Up, - AltModifier - ) + call_shortcut(codeeditor, "move line up") assert codeeditor.toPlainText() == "\n".join("214356") @@ -343,22 +340,12 @@ def test_duplicate_line(codeeditor, qtbot): codeeditor.set_text("\n".join("123456")) click_at(codeeditor, qtbot, 4, ctrl=True, alt=True) - # Duplicate line down - qtbot.keyClick( - codeeditor, - Qt.Key.Key_Down, - ControlModifier | AltModifier - ) + call_shortcut(codeeditor, "duplicate line down") assert codeeditor.toPlainText() == "\n".join("11233456") assert codeeditor.textCursor().position() == 8 assert codeeditor.extra_cursors[0].position() == 2 - # Duplicate line up - qtbot.keyClick( - codeeditor, - Qt.Key.Key_Up, - ControlModifier | AltModifier - ) + call_shortcut(codeeditor, "duplicate line up") assert codeeditor.toPlainText() == "\n".join("1112333456") assert codeeditor.textCursor().position() == 10 assert codeeditor.extra_cursors[0].position() == 2 @@ -369,12 +356,7 @@ def test_delete_line(codeeditor, qtbot): codeeditor.set_text("\n".join("123456")) click_at(codeeditor, qtbot, 4, ctrl=True, alt=True) - # Delete line - qtbot.keyClick( - codeeditor, - Qt.Key.Key_D, - ControlModifier - ) + call_shortcut(codeeditor, "delete line") assert codeeditor.toPlainText() == "\n".join("2456") @@ -383,12 +365,7 @@ def test_goto_new_line(codeeditor, qtbot): codeeditor.set_text("\n".join("123456")) click_at(codeeditor, qtbot, 4, ctrl=True, alt=True) - # Delete line - qtbot.keyClick( - codeeditor, - Qt.Key.Key_Return, - ControlModifier | ShiftModifier - ) + call_shortcut(codeeditor, "go to new line") assert codeeditor.toPlainText() == "1\n\n2\n3\n\n4\n5\n6" From d22e02825a183408e09f638907a8c206953c9a34 Mon Sep 17 00:00:00 2001 From: Aaron Date: Fri, 3 Jan 2025 17:02:45 -0500 Subject: [PATCH 81/95] Apply suggestions from code review Co-authored-by: Carlos Cordoba --- .../editor/widgets/codeeditor/codeeditor.py | 6 +- .../widgets/codeeditor/multicursor_mixin.py | 168 +++++++++++------- .../codeeditor/tests/test_multicursor.py | 32 ++-- 3 files changed, 129 insertions(+), 77 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index d1bd32d7603..a6478b5ece2 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -61,7 +61,8 @@ from spyder.plugins.editor.widgets.base import TextEditBaseWidget from spyder.plugins.editor.widgets.codeeditor.lsp_mixin import LSPMixin from spyder.plugins.editor.widgets.codeeditor.multicursor_mixin import ( - MultiCursorMixin,) + MultiCursorMixin +) from spyder.plugins.outlineexplorer.api import (OutlineExplorerData as OED, is_cell_header) from spyder.py3compat import to_text_string, is_string @@ -4418,7 +4419,7 @@ def __move_line_or_selection(self, after_current_line=True): fold_start_line = region.begin block = self.document().findBlockByNumber( fold_start_line - ) + ) if fold_start_line in folding_status: fold_status = folding_status[fold_start_line] if fold_status: @@ -4539,7 +4540,6 @@ def mousePressEvent(self, event: QKeyEvent): self.setTextCursor(first_cursor) block = anchor_block while block != pos_block: - # Get the next block if anchor_block < pos_block: block = block.next() diff --git a/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py b/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py index 4cded4439c0..94402866b65 100644 --- a/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py +++ b/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py @@ -5,7 +5,7 @@ # (see spyder/__init__.py for details) """ -Editor mixin and utils to manage Multiple cursor editing +Mixin to manage editing with multiple cursors. """ # Standard library imports @@ -23,14 +23,16 @@ class MultiCursorMixin: - # ---- Multi Cursor + """Mixin to manage editing with multiple cursors.""" + def init_multi_cursor(self): """Initialize attrs and callbacks for multi-cursor functionality""" # actual default comes from setup_editor default args self.multi_cursor_enabled = False self.cursor_width = self.get_conf('cursor/width', section='main') self.overwrite_mode = self.overwriteMode() - # track overwrite manually when for painting reasons with multi-cursor + + # Track overwrite manually when for painting reasons with multi-cursor self.setOverwriteMode(False) self.setCursorWidth(0) # draw our own cursor self.extra_cursors = [] @@ -49,9 +51,10 @@ def init_multi_cursor(self): self._drag_cursor = None def toggle_multi_cursor(self, enabled): - """Enable/Disable multi-cursor editing""" + """Enable/disable multi-cursor editing.""" self.multi_cursor_enabled = enabled - # TODO any restrictions on enabling? only python-like? only code? + + # TODO: Any restrictions on enabling? only python-like? only code? if not enabled: self.clear_extra_cursors() @@ -64,8 +67,9 @@ def add_cursor(self, cursor: QTextCursor): def set_extra_cursor_selections(self): selections = [] for cursor in self.extra_cursors: - extra_selection = TextDecoration(cursor, draw_order=5, - kind="extra_cursor_selection") + extra_selection = TextDecoration( + cursor, draw_order=5, kind="extra_cursor_selection" + ) # TODO get colors from theme? or from stylesheet? extra_selection.set_foreground(QColor("#dfe1e2")) @@ -87,8 +91,10 @@ def merge_extra_cursors(self, increasing_position): """Merge overlapping cursors""" if not self.extra_cursors: return + previous_history = self.multi_cursor_ignore_history self.multi_cursor_ignore_history = True + while True: cursor_was_removed = False @@ -99,6 +105,7 @@ def merge_extra_cursors(self, increasing_position): for i, cursor1 in enumerate(cursors[:-1]): if cursor_was_removed: break # list will be modified, so re-start at while loop + for cursor2 in cursors[i + 1:]: # given cursors.sort, pos1 should be <= pos2 pos1 = cursor1.position() @@ -112,6 +119,7 @@ def merge_extra_cursors(self, increasing_position): if cursor1 is main_cursor: # swap cursors to keep main_cursor cursor1, cursor2 = cursor2, cursor1 + self.extra_cursors.remove(cursor1) cursor_was_removed = True @@ -119,16 +127,21 @@ def merge_extra_cursors(self, increasing_position): positions = sorted([pos1, anchor1, anchor2]) if not increasing_position: positions.reverse() - cursor2.setPosition(positions[0], - QTextCursor.MoveMode.MoveAnchor) - cursor2.setPosition(positions[2], - QTextCursor.MoveMode.KeepAnchor) + cursor2.setPosition( + positions[0], + QTextCursor.MoveMode.MoveAnchor + ) + cursor2.setPosition( + positions[2], + QTextCursor.MoveMode.KeepAnchor + ) if cursor2 is main_cursor: self.setTextCursor(cursor2) break if not cursor_was_removed: break + self.set_extra_cursor_selections() self.multi_cursor_ignore_history = previous_history @@ -140,7 +153,8 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): ctrl = event.modifiers() & Qt.KeyboardModifier.ControlModifier alt = event.modifiers() & Qt.KeyboardModifier.AltModifier shift = event.modifiers() & Qt.KeyboardModifier.ShiftModifier - # ---- handle insert + + # ---- Handle insert if key == Qt.Key.Key_Insert and not (ctrl or alt or shift): self.overwrite_mode = not self.overwrite_mode return @@ -148,9 +162,9 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): self.textCursor().beginEditBlock() self.multi_cursor_ignore_history = True + # Handle all signals before editing text cursors = [] accepted = [] - # Handle all signals before editing text for cursor in self.all_cursors: self.setTextCursor(cursor) event.ignore() @@ -163,23 +177,22 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): for skip, cursor in zip(accepted, cursors): self.setTextCursor(cursor) if skip: - # text folding swallows most input to prevent typing on folded - # lines. + # Text folding swallows most input to prevent typing on folded + # lines. pass - # ---- handle Tab + # ---- Handle Tab elif key == Qt.Key.Key_Tab and not ctrl: # ctrl-tab is shortcut # Don't do intelligent tab with multi-cursor to skip - # calls to do_completion. Avoiding completions with multi - # cursor is much easier than solving all the edge cases. - + # calls to do_completion. Avoiding completions with multi + # cursor is much easier than solving all the edge cases. self.indent(force=self.tab_mode) elif key == Qt.Key.Key_Backtab and not ctrl: increasing_position = False - # TODO ignore indent level of neighboring lines and simply - # indent by 1 level at a time. Cursor update order can - # make this unpredictable otherwise. + # TODO: Ignore indent level of neighboring lines and simply + # indent by 1 level at a time. Cursor update order can + # make this unpredictable otherwise. self.unindent(force=self.tab_mode) - # ---- handle enter/return + # ---- Handle enter/return elif key in (Qt.Key_Enter, Qt.Key_Return): if not shift and not ctrl: if ( @@ -196,29 +209,37 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): cur_indent = self.get_block_indentation( self.textCursor().blockNumber()) self._handle_keypress_event(event) + # Check if we're in a comment or a string at the # current position cmt_or_str_cursor = self.in_comment_or_string() # Check if the line start with a comment or string cursor = self.textCursor() - cursor.setPosition(cursor.block().position(), - QTextCursor.KeepAnchor) + cursor.setPosition( + cursor.block().position(), + QTextCursor.KeepAnchor + ) cmt_or_str_line_begin = self.in_comment_or_string( - cursor=cursor) + cursor=cursor + ) # Check if we are in a comment or a string - cmt_or_str = cmt_or_str_cursor and \ - cmt_or_str_line_begin + cmt_or_str = ( + cmt_or_str_cursor and cmt_or_str_line_begin + ) if self.strip_trailing_spaces_on_modify: self.fix_and_strip_indent( comment_or_string=cmt_or_str, - cur_indent=cur_indent) + cur_indent=cur_indent + ) else: - self.fix_indent(comment_or_string=cmt_or_str, - cur_indent=cur_indent) - # ---- intelligent backspace handling + self.fix_indent( + comment_or_string=cmt_or_str, + cur_indent=cur_indent + ) + # ---- Intelligent backspace handling elif key == Qt.Key_Backspace and not shift and not ctrl: increasing_position = False if self.has_selected_text() or not self.intelligent_backspace: @@ -228,7 +249,7 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): leading_length = len(leading_text) trailing_spaces = ( leading_length - len(leading_text.rstrip()) - ) + ) trailing_text = self.get_text('cursor', 'eol') matches = ('()', '[]', '{}', '\'\'', '""') if ( @@ -248,12 +269,14 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): ): cursor = self.textCursor() cursor.movePosition(QTextCursor.PreviousCharacter) - cursor.movePosition(QTextCursor.NextCharacter, - QTextCursor.KeepAnchor, 2) + cursor.movePosition( + QTextCursor.NextCharacter, + QTextCursor.KeepAnchor, 2 + ) cursor.removeSelectedText() else: self._handle_keypress_event(event) - # ---- handle home, end + # ---- Handle home, end elif key == Qt.Key.Key_Home: increasing_position = False self.stdkey_home(shift, ctrl) @@ -262,16 +285,18 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): # redefine this basic action which should have been implemented # natively self.stdkey_end(shift, ctrl) - # ---- use default handler for cursor (text) + # ---- Use default handler for cursor (text) else: if key in (Qt.Key.Key_Up, Qt.Key.Key_Left): increasing_position = False - if (key in (Qt.Key.Key_Up, Qt.Key.Key_Down) and - cursor.verticalMovementX() == -1): + if ( + key in (Qt.Key.Key_Up, Qt.Key.Key_Down) + and cursor.verticalMovementX() == -1 + ): # Builtin handler somehow does not set verticalMovementX - # when moving up and down (but works fine for single - # cursor somehow) - # TODO why? Am I forgetting something? + # when moving up and down (but works fine for single + # cursor somehow) + # TODO: Why? Are we forgetting something? x = self.cursorRect(cursor).x() cursor.setVerticalMovementX(x) self.setTextCursor(cursor) @@ -279,6 +304,7 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): # Update edited extra_cursors new_cursors.append(self.textCursor()) + self.extra_cursors = new_cursors[:-1] self.merge_extra_cursors(increasing_position) self.textCursor().endEditBlock() @@ -309,8 +335,10 @@ def paint_cursors(self, event): content_offset_y = offset.y() qp.setBrushOrigin(offset) editable = not self.isReadOnly() - flags = (self.textInteractionFlags() & - Qt.TextInteractionFlag.TextSelectableByKeyboard) + flags = ( + self.textInteractionFlags() + & Qt.TextInteractionFlag.TextSelectableByKeyboard + ) if self._drag_cursor is not None and (editable or flags): cursor = self._drag_cursor @@ -320,9 +348,12 @@ def paint_cursors(self, event): offset.setY(block_top + content_offset_y) layout = block.layout() if layout is not None: # Fix exceptions in test_flag_painting - layout.drawCursor(qp, offset, - cursor.positionInBlock(), - cursor_width) + layout.drawCursor( + qp, + offset, + cursor.positionInBlock(), + cursor_width + ) draw_cursor = self.cursor_blink_state and (editable or flags) @@ -333,20 +364,23 @@ def paint_cursors(self, event): offset.setY(block_top + content_offset_y) layout = block.layout() if layout is not None: - layout.drawCursor(qp, offset, - cursor.positionInBlock(), - cursor_width) + layout.drawCursor( + qp, + offset, + cursor.positionInBlock(), + cursor_width + ) qp.end() @Slot() def start_cursor_blink(self): - """start manually updating the cursor(s) blink state: Show cursors""" + """Start manually updating the cursor(s) blink state: Show cursors.""" self.cursor_blink_state = True self.cursor_blink_timer.start() @Slot() def stop_cursor_blink(self): - """stop manually updating the cursor(s) blink state: Hide cursors""" + """Stop manually updating the cursor(s) blink state: Hide cursors.""" self.cursor_blink_state = False self.cursor_blink_timer.stop() @@ -359,8 +393,10 @@ def multi_cursor_copy(self): cursors.sort(key=lambda cursor: cursor.position()) selections = [] for cursor in cursors: - text = cursor.selectedText().replace(u"\u2029", - self.get_line_separator()) + text = cursor.selectedText().replace( + "\u2029", + self.get_line_separator() + ) selections.append(text) clip_text = self.get_line_separator().join(selections) QApplication.clipboard().setText(clip_text) @@ -371,7 +407,8 @@ def multi_cursor_cut(self): self.textCursor().beginEditBlock() for cursor in self.all_cursors: cursor.removeSelectedText() - # merge direction doesn't matter here as all selections are removed + + # Merge direction doesn't matter here as all selections are removed self.merge_extra_cursors(True) self.textCursor().endEditBlock() @@ -387,17 +424,21 @@ def multi_cursor_paste(self, clip_text): self.skip_rstrip = True self.sig_will_paste_text.emit(clip_text) lines = clip_text.splitlines() + if len(lines) == 1: lines = itertools.repeat(lines[0]) + self.multi_cursor_ignore_history = True for cursor, text in zip(cursors, lines): self.setTextCursor(cursor) cursor.insertText(text) # handle extra lines or extra cursors? + self.setTextCursor(main_cursor) self.multi_cursor_ignore_history = False self.cursorPositionChanged.emit() - # merge direction doesn't matter here as all selections are removed + + # Merge direction doesn't matter here as all selections are removed self.merge_extra_cursors(True) main_cursor.endEditBlock() self.sig_text_was_inserted.emit() @@ -412,9 +453,11 @@ def wrapper(): self.multi_cursor_ignore_history = True for cursor in self.all_cursors: self.setTextCursor(cursor) - # may call setTtextCursor with modified copy + + # May call setTtextCursor with modified copy method() - # get modified cursor to re-add to extra_cursors + + # Get modified cursor to re-add to extra_cursors new_cursors.append(self.textCursor()) # re-add extra cursors @@ -426,6 +469,7 @@ def wrapper(): self.textCursor().endEditBlock() self.multi_cursor_ignore_history = False self.cursorPositionChanged.emit() + return wrapper def clears_extra_cursors(self, method): @@ -434,6 +478,7 @@ def clears_extra_cursors(self, method): def wrapper(): self.clear_extra_cursors() method() + return wrapper def restrict_single_cursor(self, method): @@ -442,19 +487,20 @@ def restrict_single_cursor(self, method): def wrapper(): if not self.extra_cursors: method() + return wrapper def go_to_next_cell(self): """ - reimplements TextEditBaseWidget.go_to_next_cell to clear extra cursors + Reimplement TextEditBaseWidget.go_to_next_cell to clear extra cursors. """ self.clear_extra_cursors() super().go_to_next_cell() def go_to_previous_cell(self): """ - reimplements TextEditBaseWidget.go_to_previous_cell to clear extra - cursors + Reimplement TextEditBaseWidget.go_to_previous_cell to clear extra + cursors. """ self.clear_extra_cursors() super().go_to_previous_cell() diff --git a/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py b/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py index 3a3853954aa..76055626736 100644 --- a/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py +++ b/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py @@ -35,12 +35,13 @@ def click_at(codeeditor, qtbot, position, ctrl=False, alt=False, shift=False): modifiers |= AltModifier if shift: modifiers |= ShiftModifier + qtbot.mouseClick( - codeeditor.viewport(), - Qt.MouseButton.LeftButton, - modifiers, - pos=point - ) + codeeditor.viewport(), + Qt.MouseButton.LeftButton, + modifiers, + pos=point + ) def call_shortcut(codeeditor, name): @@ -77,12 +78,14 @@ def test_add_cursor(codeeditor, qtbot): assert not bool(codeeditor.extra_cursors) qtbot.keyClick(codeeditor, "b") assert codeeditor.toPlainText() == "a01234b5a6789" + # Don't add another cursor on top of main cursor click_at(codeeditor, qtbot, 7, ctrl=True, alt=True) assert not bool(codeeditor.extra_cursors) # Test removing cursors click_at(codeeditor, qtbot, 2, ctrl=True, alt=True) + # Remove main cursor click_at(codeeditor, qtbot, 2, ctrl=True, alt=True) assert codeeditor.textCursor().position() == 7 @@ -93,13 +96,15 @@ def test_column_add_cursor(codeeditor, qtbot): codeeditor.set_text("0123456789\n0123456789\n0123456789\n0123456789\n") cursor = codeeditor.textCursor() + # Move main cursor to bottom left cursor.movePosition( - QTextCursor.MoveOperation.Down, - QTextCursor.MoveMode.MoveAnchor, - 3 - ) + QTextCursor.MoveOperation.Down, + QTextCursor.MoveMode.MoveAnchor, + 3 + ) codeeditor.setTextCursor(cursor) + # Column cursor click at top row 6th column click_at(codeeditor, qtbot, 6, ctrl=True, alt=True, shift=True) @@ -137,10 +142,10 @@ def test_extra_selections_decoration(codeeditor, qtbot): codeeditor.set_text("0123456789\n0123456789\n0123456789\n0123456789\n") cursor = codeeditor.textCursor() cursor.movePosition( - QTextCursor.MoveOperation.Down, - QTextCursor.MoveMode.MoveAnchor, - 3 - ) + QTextCursor.MoveOperation.Down, + QTextCursor.MoveMode.MoveAnchor, + 3 + ) codeeditor.setTextCursor(cursor) click_at(codeeditor, qtbot, 6, ctrl=True, alt=True, shift=True) selections = codeeditor.get_extra_selections("extra_cursor_selections") @@ -156,6 +161,7 @@ def test_multi_cursor_verticalMovementX(codeeditor, qtbot): qtbot.keyClick(codeeditor, Qt.Key.Key_Down) assert codeeditor.extra_cursors[0].position() == 25 assert codeeditor.textCursor().position() == 35 + for _ in range(3): qtbot.keyClick(codeeditor, Qt.Key.Key_Up) assert codeeditor.extra_cursors[0].position() == 4 From 22fc03adaee58aa00a0950779f1e0b192405c365 Mon Sep 17 00:00:00 2001 From: Aaron Date: Fri, 3 Jan 2025 17:06:05 -0500 Subject: [PATCH 82/95] Apply suggestions from code review missed a few style edits in prior batch Co-authored-by: Carlos Cordoba --- .../plugins/editor/widgets/codeeditor/multicursor_mixin.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py b/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py index 94402866b65..022df2baf2a 100644 --- a/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py +++ b/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py @@ -305,6 +305,7 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): # Update edited extra_cursors new_cursors.append(self.textCursor()) + self.extra_cursors = new_cursors[:-1] self.merge_extra_cursors(increasing_position) self.textCursor().endEditBlock() @@ -425,15 +426,18 @@ def multi_cursor_paste(self, clip_text): self.sig_will_paste_text.emit(clip_text) lines = clip_text.splitlines() + if len(lines) == 1: lines = itertools.repeat(lines[0]) + self.multi_cursor_ignore_history = True for cursor, text in zip(cursors, lines): self.setTextCursor(cursor) cursor.insertText(text) # handle extra lines or extra cursors? + self.setTextCursor(main_cursor) self.multi_cursor_ignore_history = False self.cursorPositionChanged.emit() @@ -470,6 +474,7 @@ def wrapper(): self.multi_cursor_ignore_history = False self.cursorPositionChanged.emit() + return wrapper def clears_extra_cursors(self, method): @@ -479,6 +484,7 @@ def wrapper(): self.clear_extra_cursors() method() + return wrapper def restrict_single_cursor(self, method): @@ -488,6 +494,7 @@ def wrapper(): if not self.extra_cursors: method() + return wrapper def go_to_next_cell(self): From 5a79728ac56065951553bdaa1135046d17a50093 Mon Sep 17 00:00:00 2001 From: Aaron Date: Fri, 3 Jan 2025 17:12:36 -0500 Subject: [PATCH 83/95] Apply suggestions from code review Co-authored-by: Carlos Cordoba --- .../editor/widgets/codeeditor/tests/test_multicursor.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py b/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py index 76055626736..a2d532b2e6b 100644 --- a/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py +++ b/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py @@ -79,6 +79,7 @@ def test_add_cursor(codeeditor, qtbot): qtbot.keyClick(codeeditor, "b") assert codeeditor.toPlainText() == "a01234b5a6789" + # Don't add another cursor on top of main cursor click_at(codeeditor, qtbot, 7, ctrl=True, alt=True) assert not bool(codeeditor.extra_cursors) @@ -86,6 +87,7 @@ def test_add_cursor(codeeditor, qtbot): # Test removing cursors click_at(codeeditor, qtbot, 2, ctrl=True, alt=True) + # Remove main cursor click_at(codeeditor, qtbot, 2, ctrl=True, alt=True) assert codeeditor.textCursor().position() == 7 @@ -105,6 +107,7 @@ def test_column_add_cursor(codeeditor, qtbot): ) codeeditor.setTextCursor(cursor) + # Column cursor click at top row 6th column click_at(codeeditor, qtbot, 6, ctrl=True, alt=True, shift=True) @@ -162,6 +165,7 @@ def test_multi_cursor_verticalMovementX(codeeditor, qtbot): assert codeeditor.extra_cursors[0].position() == 25 assert codeeditor.textCursor().position() == 35 + for _ in range(3): qtbot.keyClick(codeeditor, Qt.Key.Key_Up) assert codeeditor.extra_cursors[0].position() == 4 From 5d1d614fef4eb0fa361dfa7a73c47604b5b8b0ae Mon Sep 17 00:00:00 2001 From: athompson673 Date: Fri, 3 Jan 2025 19:55:35 -0500 Subject: [PATCH 84/95] Remove extra newlines from github review accidentally applied twice. Simplify call path to get font in paint event. --- .../editor/widgets/codeeditor/multicursor_mixin.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py b/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py index 022df2baf2a..b05817bc7b2 100644 --- a/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py +++ b/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py @@ -305,7 +305,6 @@ def handle_multi_cursor_keypress(self, event: QKeyEvent): # Update edited extra_cursors new_cursors.append(self.textCursor()) - self.extra_cursors = new_cursors[:-1] self.merge_extra_cursors(increasing_position) self.textCursor().endEditBlock() @@ -325,7 +324,7 @@ def _on_cursor_blinktimer_timeout(self): def paint_cursors(self, event): """Paint all cursors""" if self.overwrite_mode: - font = self.textCursor().block().charFormat().font() + font = self.font() cursor_width = QFontMetrics(font).horizontalAdvance(" ") else: cursor_width = self.cursor_width @@ -426,18 +425,15 @@ def multi_cursor_paste(self, clip_text): self.sig_will_paste_text.emit(clip_text) lines = clip_text.splitlines() - if len(lines) == 1: lines = itertools.repeat(lines[0]) - self.multi_cursor_ignore_history = True for cursor, text in zip(cursors, lines): self.setTextCursor(cursor) cursor.insertText(text) # handle extra lines or extra cursors? - self.setTextCursor(main_cursor) self.multi_cursor_ignore_history = False self.cursorPositionChanged.emit() @@ -474,7 +470,6 @@ def wrapper(): self.multi_cursor_ignore_history = False self.cursorPositionChanged.emit() - return wrapper def clears_extra_cursors(self, method): @@ -484,7 +479,6 @@ def wrapper(): self.clear_extra_cursors() method() - return wrapper def restrict_single_cursor(self, method): @@ -494,7 +488,6 @@ def wrapper(): if not self.extra_cursors: method() - return wrapper def go_to_next_cell(self): From 0d8693c2c61296387cbc6b070f421784ee124e2d Mon Sep 17 00:00:00 2001 From: athompson673 Date: Fri, 3 Jan 2025 20:48:28 -0500 Subject: [PATCH 85/95] configure extra cursor selection colors from spyder.utils.palette.SpyderPalette --- .../editor/widgets/codeeditor/multicursor_mixin.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py b/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py index b05817bc7b2..ededc10a787 100644 --- a/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py +++ b/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py @@ -20,6 +20,7 @@ # Local imports from spyder.plugins.editor.api.decoration import TextDecoration +from spyder.utils.palette import SpyderPalette class MultiCursorMixin: @@ -72,8 +73,12 @@ def set_extra_cursor_selections(self): ) # TODO get colors from theme? or from stylesheet? - extra_selection.set_foreground(QColor("#dfe1e2")) - extra_selection.set_background(QColor("#346792")) + extra_selection.set_foreground( + QColor(SpyderPalette.COLOR_TEXT_1) + ) + extra_selection.set_background( + QColor(SpyderPalette.COLOR_ACCENT_2) + ) selections.append(extra_selection) self.set_extra_selections('extra_cursor_selections', selections) From 490d5a00a4af6bde593ce97a4b487f19a630e12d Mon Sep 17 00:00:00 2001 From: athompson673 Date: Fri, 3 Jan 2025 22:55:24 -0500 Subject: [PATCH 86/95] Change extra selection color selection to retrieve from CodeEditor.palette on first paintEvent --- .../widgets/codeeditor/multicursor_mixin.py | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py b/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py index ededc10a787..ad410040b96 100644 --- a/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py +++ b/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py @@ -51,6 +51,19 @@ def init_multi_cursor(self): self.multi_cursor_ignore_history = False self._drag_cursor = None + def __selection_colors(self, cache=[]): + """ + Delayed retrival of highlighted text style colors with cached results. + This is needed as the palette hasn't been correctly set at the time + init_multi_cursor is called. + """ + if not cache: + # Text Color + cache.append(self.palette().highlightedText().color()) + # Background Color + cache.append(self.palette().highlight().color()) + return cache + def toggle_multi_cursor(self, enabled): """Enable/disable multi-cursor editing.""" self.multi_cursor_enabled = enabled @@ -72,13 +85,10 @@ def set_extra_cursor_selections(self): cursor, draw_order=5, kind="extra_cursor_selection" ) - # TODO get colors from theme? or from stylesheet? - extra_selection.set_foreground( - QColor(SpyderPalette.COLOR_TEXT_1) - ) - extra_selection.set_background( - QColor(SpyderPalette.COLOR_ACCENT_2) - ) + foreground, background = self.__selection_colors() + extra_selection.set_foreground(foreground) + extra_selection.set_background(background) + selections.append(extra_selection) self.set_extra_selections('extra_cursor_selections', selections) From 908c0368367806bf05dc66d94f18903e7ef360c7 Mon Sep 17 00:00:00 2001 From: athompson673 Date: Fri, 3 Jan 2025 22:59:15 -0500 Subject: [PATCH 87/95] pep8 --- .../plugins/editor/widgets/codeeditor/multicursor_mixin.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py b/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py index ad410040b96..5db21a566cd 100644 --- a/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py +++ b/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py @@ -53,9 +53,9 @@ def init_multi_cursor(self): def __selection_colors(self, cache=[]): """ - Delayed retrival of highlighted text style colors with cached results. - This is needed as the palette hasn't been correctly set at the time - init_multi_cursor is called. + Delayed retrival of highlighted text style colors. This is needed as + the palette hasn't been correctly set at the time init_multi_cursor is + called. """ if not cache: # Text Color From 3735bc5a7db4dc42fdfaae51a0e6ca8aa36ad225 Mon Sep 17 00:00:00 2001 From: athompson673 Date: Fri, 3 Jan 2025 23:00:43 -0500 Subject: [PATCH 88/95] style and removed imports no longer needed --- .../plugins/editor/widgets/codeeditor/multicursor_mixin.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py b/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py index 5db21a566cd..9d0a9b1d2aa 100644 --- a/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py +++ b/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py @@ -14,13 +14,13 @@ # Third party imports from qtpy.QtCore import Qt, QTimer, Slot -from qtpy.QtGui import (QColor, QFontMetrics, QPaintEvent, QPainter, - QTextCursor, QKeyEvent) +from qtpy.QtGui import ( + QFontMetrics, QPaintEvent, QPainter, QTextCursor, QKeyEvent +) from qtpy.QtWidgets import QApplication # Local imports from spyder.plugins.editor.api.decoration import TextDecoration -from spyder.utils.palette import SpyderPalette class MultiCursorMixin: From 2cc8a313060ae693fff5623518e4f2a4a81859ee Mon Sep 17 00:00:00 2001 From: athompson673 Date: Sun, 5 Jan 2025 10:54:31 -0500 Subject: [PATCH 89/95] Change multicursor_mixin to pull text selection colors from SpyderPalette --- .../widgets/codeeditor/multicursor_mixin.py | 25 ++++++------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py b/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py index 9d0a9b1d2aa..a9dc3444b3b 100644 --- a/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py +++ b/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py @@ -15,12 +15,13 @@ # Third party imports from qtpy.QtCore import Qt, QTimer, Slot from qtpy.QtGui import ( - QFontMetrics, QPaintEvent, QPainter, QTextCursor, QKeyEvent + QColor, QFontMetrics, QPaintEvent, QPainter, QTextCursor, QKeyEvent ) from qtpy.QtWidgets import QApplication # Local imports from spyder.plugins.editor.api.decoration import TextDecoration +from spyder.utils.palette import SpyderPalette class MultiCursorMixin: @@ -51,19 +52,6 @@ def init_multi_cursor(self): self.multi_cursor_ignore_history = False self._drag_cursor = None - def __selection_colors(self, cache=[]): - """ - Delayed retrival of highlighted text style colors. This is needed as - the palette hasn't been correctly set at the time init_multi_cursor is - called. - """ - if not cache: - # Text Color - cache.append(self.palette().highlightedText().color()) - # Background Color - cache.append(self.palette().highlight().color()) - return cache - def toggle_multi_cursor(self, enabled): """Enable/disable multi-cursor editing.""" self.multi_cursor_enabled = enabled @@ -85,9 +73,12 @@ def set_extra_cursor_selections(self): cursor, draw_order=5, kind="extra_cursor_selection" ) - foreground, background = self.__selection_colors() - extra_selection.set_foreground(foreground) - extra_selection.set_background(background) + extra_selection.set_foreground( + QColor(SpyderPalette.COLOR_TEXT_1) + ) + extra_selection.set_background( + QColor(SpyderPalette.COLOR_ACCENT_2) + ) selections.append(extra_selection) self.set_extra_selections('extra_cursor_selections', selections) From 0bf7f903c3ceee7e64b5d20d037a4af118533770 Mon Sep 17 00:00:00 2001 From: athompson673 Date: Mon, 6 Jan 2025 21:21:15 -0500 Subject: [PATCH 90/95] Added shortcuts for 'add cursor up/down' --- spyder/config/main.py | 2 ++ .../plugins/editor/widgets/codeeditor/codeeditor.py | 2 ++ .../editor/widgets/codeeditor/multicursor_mixin.py | 12 ++++++++++++ 3 files changed, 16 insertions(+) diff --git a/spyder/config/main.py b/spyder/config/main.py index c61ff70b750..04722081ca8 100644 --- a/spyder/config/main.py +++ b/spyder/config/main.py @@ -514,6 +514,8 @@ 'editor/enter array table': "Ctrl+M", 'editor/run cell in debugger': 'Alt+Shift+Return', 'editor/run selection in debugger': CTRL + '+F9', + 'editor/add cursor up': 'Alt+Shift+Up', + 'editor/add cursor down': 'Alt+Shift+Down', # -- Internal console -- 'internal_console/inspect current object': "Ctrl+I", 'internal_console/clear shell': "Ctrl+L", diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index a6478b5ece2..2c651f023d5 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -668,6 +668,8 @@ def register_shortcuts(self): self.enter_array_inline)), ('enter array table', self.clears_extra_cursors( self.enter_array_table)), + ('add cursor up', self.add_cursor_up), + ('add cursor down', self.add_cursor_down), ) for name, callback in shortcuts: diff --git a/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py b/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py index a9dc3444b3b..4381ff8f336 100644 --- a/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py +++ b/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py @@ -66,6 +66,18 @@ def add_cursor(self, cursor: QTextCursor): self.extra_cursors.append(cursor) self.merge_extra_cursors(True) + def add_cursor_up(self): + if self.multi_cursor_enabled: + self.extra_cursors.append(self.textCursor()) + self.moveCursor(QTextCursor.MoveOperation.Up) + self.merge_extra_cursors(True) + + def add_cursor_down(self): + if self.multi_cursor_enabled: + self.extra_cursors.append(self.textCursor()) + self.moveCursor(QTextCursor.MoveOperation.Down) + self.merge_extra_cursors(True) + def set_extra_cursor_selections(self): selections = [] for cursor in self.extra_cursors: From bc169485463a1668f29a56fb527a094edee9fd18 Mon Sep 17 00:00:00 2001 From: Aaron Date: Wed, 8 Jan 2025 15:33:32 -0500 Subject: [PATCH 91/95] Create Cursor creation functions within MultiCursorMixin --- .../widgets/codeeditor/multicursor_mixin.py | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py b/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py index 4381ff8f336..ba9b121376a 100644 --- a/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py +++ b/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py @@ -66,6 +66,102 @@ def add_cursor(self, cursor: QTextCursor): self.extra_cursors.append(cursor) self.merge_extra_cursors(True) + def add_column_cursor(self, event): + """ + Add a cursor on each row between primary cursor and click location. + """ + + if not self.multi_cursor_enabled: + return + + self.multi_cursor_ignore_history = True + + # Ctrl-Shift-Alt click adds colum of cursors towards primary + # cursor + cursor_for_pos = self.cursorForPosition(event.pos()) + first_cursor = self.textCursor() + anchor_block = first_cursor.block() + anchor_col = first_cursor.anchor() - anchor_block.position() + pos_block = cursor_for_pos.block() + pos_col = cursor_for_pos.positionInBlock() + + # Move primary cursor to pos_col + p_col = min(len(anchor_block.text()), pos_col) + + # block.length() includes line separator? just \n? + # use len(block.text()) instead + first_cursor.setPosition(anchor_block.position() + p_col, + QTextCursor.MoveMode.KeepAnchor) + self.setTextCursor(first_cursor) + block = anchor_block + while block != pos_block: + # Get the next block + if anchor_block < pos_block: + block = block.next() + else: + block = block.previous() + + # Add a cursor for this block + if block.isVisible() and block.isValid(): + cursor = QTextCursor(first_cursor) + + a_col = min(len(block.text()), anchor_col) + cursor.setPosition(block.position() + a_col, + QTextCursor.MoveMode.MoveAnchor) + p_col = min(len(block.text()), pos_col) + cursor.setPosition(block.position() + p_col, + QTextCursor.MoveMode.KeepAnchor) + self.add_cursor(cursor) + + self.multi_cursor_ignore_history = False + self.cursorPositionChanged.emit() + + def add_remove_cursor(self, event): + """Add or remove extra cursor on mouse click event""" + + if not self.multi_cursor_enabled: + return + + self.multi_cursor_ignore_history = True + + # Move existing primary cursor to extra_cursors list and set + # new primary cursor + cursor_for_pos = self.cursorForPosition(event.pos()) + old_cursor = self.textCursor() + + # Don't attempt to remove cursor if there's only one + removed_cursor = False + if self.extra_cursors: + same_cursor = None + for cursor in self.all_cursors: + if cursor_for_pos.position() == cursor.position(): + same_cursor = cursor + break + if same_cursor is not None: + removed_cursor = True + if same_cursor in self.extra_cursors: + # cursor to be removed was not primary + self.extra_cursors.remove(same_cursor) + else: + # cursor to be removed is primary cursor + # pick a new primary by position + new_primary = max( + self.extra_cursors, + key=lambda cursor: cursor.position() + ) + self.extra_cursors.remove(new_primary) + self.setTextCursor(new_primary) + + # Possibly clear selection of removed cursor + self.set_extra_cursor_selections() + + if not removed_cursor: + self.setTextCursor(cursor_for_pos) + self.add_cursor(old_cursor) + + self.multi_cursor_ignore_history = False + self.cursorPositionChanged.emit() + def add_cursor_up(self): if self.multi_cursor_enabled: self.extra_cursors.append(self.textCursor()) From 1ec8b419a2c45008ad2384d561ec9c2cb1808b01 Mon Sep 17 00:00:00 2001 From: Aaron Date: Wed, 8 Jan 2025 15:34:46 -0500 Subject: [PATCH 92/95] Move cursor creation to functions provided by MultiCursorMixin --- .../editor/widgets/codeeditor/codeeditor.py | 70 +------------------ 1 file changed, 2 insertions(+), 68 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 2c651f023d5..21d1f36c88c 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -4522,80 +4522,14 @@ def mousePressEvent(self, event: QKeyEvent): and alt ): # ---- Ctrl-Alt: multi-cursor mouse interactions - self.multi_cursor_ignore_history = True if shift: # Ctrl-Shift-Alt click adds colum of cursors towards primary # cursor - first_cursor = self.textCursor() - anchor_block = first_cursor.block() - anchor_col = first_cursor.anchor() - anchor_block.position() - pos_block = cursor_for_pos.block() - pos_col = cursor_for_pos.positionInBlock() - - # Move primary cursor to pos_col - p_col = min(len(anchor_block.text()), pos_col) - - # block.length() includes line separator? just \n? - # use len(block.text()) instead - first_cursor.setPosition(anchor_block.position() + p_col, - QTextCursor.MoveMode.KeepAnchor) - self.setTextCursor(first_cursor) - block = anchor_block - while block != pos_block: - # Get the next block - if anchor_block < pos_block: - block = block.next() - else: - block = block.previous() - - # Add a cursor for this block - if block.isVisible() and block.isValid(): - cursor = QTextCursor(first_cursor) - - a_col = min(len(block.text()), anchor_col) - cursor.setPosition(block.position() + a_col, - QTextCursor.MoveMode.MoveAnchor) - p_col = min(len(block.text()), pos_col) - cursor.setPosition(block.position() + p_col, - QTextCursor.MoveMode.KeepAnchor) - self.add_cursor(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 - old_cursor = self.textCursor() - - # Don't attempt to remove cursor if there's only one - removed_cursor = False - if self.extra_cursors: - same_cursor = None - for cursor in self.all_cursors: - if cursor_for_pos.position() == cursor.position(): - same_cursor = cursor - break - if same_cursor is not None: - removed_cursor = True - if same_cursor in self.extra_cursors: - # cursor to be removed was not primary - self.extra_cursors.remove(same_cursor) - else: - # cursor to be removed is primary cursor - # pick a new primary by position - new_primary = max( - self.extra_cursors, - key=lambda cursor: cursor.position() - ) - self.extra_cursors.remove(new_primary) - self.setTextCursor(new_primary) - - # Possibly clear selection of removed cursor - self.set_extra_cursor_selections() - - if not removed_cursor: - self.setTextCursor(cursor_for_pos) - self.add_cursor(old_cursor) - - self.multi_cursor_ignore_history = False - self.cursorPositionChanged.emit() + self.add_remove_cursor(event) else: # ---- not multi-cursor if event.button() == Qt.MouseButton.LeftButton: From 3d7a8e9dd5af5b4dbfd2b47f313fd7dd318b5945 Mon Sep 17 00:00:00 2001 From: athompson673 Date: Thu, 9 Jan 2025 11:28:02 -0500 Subject: [PATCH 93/95] change default shortcuts for duplicate line to not conflict with add cursor shortcuts. bump CONF_VERSION. --- spyder/config/main.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/spyder/config/main.py b/spyder/config/main.py index 04722081ca8..154a9f3454e 100644 --- a/spyder/config/main.py +++ b/spyder/config/main.py @@ -430,10 +430,8 @@ 'find_replace/hide find and replace': "Escape", # -- Editor -- 'editor/code completion': CTRL+'+Space', - 'editor/duplicate line up': ( - "Ctrl+Alt+Up" if WIN else "Shift+Alt+Up"), - 'editor/duplicate line down': ( - "Ctrl+Alt+Down" if WIN else "Shift+Alt+Down"), + 'editor/duplicate line up': CTRL + "+Alt+PgUp", + 'editor/duplicate line down': CTRL + "+Alt+PgDown", 'editor/delete line': 'Ctrl+D', 'editor/transform to uppercase': 'Ctrl+Shift+U', 'editor/transform to lowercase': 'Ctrl+U', @@ -679,4 +677,4 @@ # or if you want to *rename* options, then you need to do a MAJOR update in # version, e.g. from 3.0.0 to 4.0.0 # 3. You don't need to touch this value if you're just adding a new option -CONF_VERSION = '85.0.0' +CONF_VERSION = '85.1.0' From 2310863a6c34b2bb17fa33dd6aa96d65e04f8fae Mon Sep 17 00:00:00 2001 From: athompson673 Date: Thu, 9 Jan 2025 11:43:55 -0500 Subject: [PATCH 94/95] hide scrollflag range while ctrl-alt is held --- spyder/plugins/editor/panels/scrollflag.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/spyder/plugins/editor/panels/scrollflag.py b/spyder/plugins/editor/panels/scrollflag.py index fc3c7ed1f10..97f5c1d9098 100644 --- a/spyder/plugins/editor/panels/scrollflag.py +++ b/spyder/plugins/editor/panels/scrollflag.py @@ -51,6 +51,7 @@ def __init__(self): self._unit_testing = False self._range_indicator_is_visible = False self._alt_key_is_down = False + self._ctrl_key_is_down = False self._slider_range_color = QColor(Qt.gray) self._slider_range_color.setAlphaF(.85) @@ -130,7 +131,7 @@ def update_flags(self): 'breakpoint': [], } - # Run this computation in a different thread to prevent freezing + # Run this computation in a different thread to prevent freezing # the interface if not self._update_flags_thread.isRunning(): self._update_flags_thread.start() @@ -280,9 +281,12 @@ def paintEvent(self, event): # Paint the slider range if not self._unit_testing: - alt = QApplication.queryKeyboardModifiers() & Qt.AltModifier + 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 if self.slider: cursor_pos = self.mapFromGlobal(QCursor().pos()) @@ -293,7 +297,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 is_over_editor): + if is_over_self or (alt and not ctrl and is_over_editor): painter.setPen(self._slider_range_color) painter.setBrush(self._slider_range_brush) x, y, width, height = self.make_slider_range( @@ -324,15 +328,21 @@ def mousePressEvent(self, event): def keyReleaseEvent(self, event): """Override Qt method.""" - if event.key() == Qt.Key_Alt: + if event.key() == Qt.Key.Key_Alt: self._alt_key_is_down = False self.update() + elif event.key() == Qt.Key.Key_Control: + self._ctrl_key_is_down = False + self.update() def keyPressEvent(self, event): """Override Qt method""" if event.key() == Qt.Key_Alt: self._alt_key_is_down = True self.update() + elif event.key() == Qt.Key.Key_Control: + self._ctrl_key_is_down = True + self.update() def get_vertical_offset(self): """ From 55c29d63ed3a81ddf8b334d99840e8964c7d1a44 Mon Sep 17 00:00:00 2001 From: athompson673 Date: Fri, 10 Jan 2025 13:17:34 -0500 Subject: [PATCH 95/95] Add shortcut to clear extra cursors --- spyder/config/main.py | 1 + spyder/plugins/editor/widgets/codeeditor/codeeditor.py | 1 + 2 files changed, 2 insertions(+) diff --git a/spyder/config/main.py b/spyder/config/main.py index 154a9f3454e..7df48bdffaf 100644 --- a/spyder/config/main.py +++ b/spyder/config/main.py @@ -514,6 +514,7 @@ 'editor/run selection in debugger': CTRL + '+F9', 'editor/add cursor up': 'Alt+Shift+Up', 'editor/add cursor down': 'Alt+Shift+Down', + 'editor/clear extra cursors': 'Esc', # -- Internal console -- 'internal_console/inspect current object': "Ctrl+I", 'internal_console/clear shell': "Ctrl+L", diff --git a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 21d1f36c88c..6f3beeb231f 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -670,6 +670,7 @@ def register_shortcuts(self): self.enter_array_table)), ('add cursor up', self.add_cursor_up), ('add cursor down', self.add_cursor_down), + ('clear extra cursors', self.clear_extra_cursors) ) for name, callback in shortcuts: