diff --git a/changelogs/Spyder-6.md b/changelogs/Spyder-6.md index b244e9c8e74..e6fd6ac3efc 100644 --- a/changelogs/Spyder-6.md +++ b/changelogs/Spyder-6.md @@ -1,5 +1,12 @@ # History of changes for Spyder 6 +## Version 6.0.4 (Unreleased) + +### API changes + +* Add `give_focus` kwarg to the `create_client_for_kernel` method of the + IPython console plugin. + ## Version 6.0.3 (2024/12/10) ### Important fixes diff --git a/spyder/api/_version.py b/spyder/api/_version.py index 9f0658846e2..80205c98f49 100644 --- a/spyder/api/_version.py +++ b/spyder/api/_version.py @@ -22,5 +22,5 @@ updated. """ -VERSION_INFO = (1, 2, 0) +VERSION_INFO = (1, 3, 0) __version__ = '.'.join(map(str, VERSION_INFO)) diff --git a/spyder/app/cli_options.py b/spyder/app/cli_options.py index 9dee61ab853..aa1cb2feefb 100644 --- a/spyder/app/cli_options.py +++ b/spyder/app/cli_options.py @@ -157,6 +157,16 @@ def get_options(argv=None): default=None, help="Choose a configuration directory to use for Spyder." ) + parser.add_argument( + '--connect-to-kernel', + type=str, + dest="connection_file", + default=None, + help=( + "Connect to an existing kernel whose info is available in a " + "kernel-*.json file" + ) + ) parser.add_argument('files', nargs='*') options = parser.parse_args(argv) diff --git a/spyder/config/main.py b/spyder/config/main.py index ec6f7fe8df0..7df48bdffaf 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, @@ -429,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', @@ -513,6 +512,9 @@ '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', + 'editor/clear extra cursors': 'Esc', # -- Internal console -- 'internal_console/inspect current object': "Ctrl+I", 'internal_console/clear shell': "Ctrl+L", @@ -676,4 +678,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' 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/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) 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): """ 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/codeeditor/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py index 8f40f35389a..6f3beeb231f 100644 --- a/spyder/plugins/editor/widgets/codeeditor/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/codeeditor.py @@ -60,6 +60,9 @@ 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 @@ -119,7 +122,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' @@ -254,7 +257,7 @@ def __init__(self, parent=None): self.current_project_path = None # Caret (text cursor) - self.setCursorWidth(self.get_conf('cursor/width', section='main')) + self.init_multi_cursor() self.text_helper = TextHelper(self) @@ -597,51 +600,77 @@ def cursor_move_event(): def register_shortcuts(self): """Register shortcuts for this widget.""" shortcuts = ( - ('code completion', self.do_completion), + ('code completion', self.restrict_single_cursor( + self.do_completion)), ('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), ('move line down', self.move_line_down), - ('go to new line', self.go_to_new_line), + ('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.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.for_each_cursor(self.toggle_comment)), + ('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)), + ('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'))), + ('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( + 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), + ('select all', self.clears_extra_cursors(self.selectAll)), + ('docstring', self.for_each_cursor( + self.writer_docstring.write_docstring_for_shortcut)), + ('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.enter_array_inline), - ('enter array table', 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)), + ('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: @@ -729,7 +758,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. @@ -809,6 +839,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) @@ -926,6 +958,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): @@ -1307,40 +1341,63 @@ 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 - ) + self.textCursor().beginEditBlock() + new_cursors = [] + self.multi_cursor_ignore_history = True + for cursor in self.all_cursors: self.setTextCursor(cursor) - - self.remove_selected_text() + 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() + self.multi_cursor_ignore_history = False + self.cursorPositionChanged.emit() def delete_line(self): """Delete current line.""" - cursor = self.textCursor() + self.textCursor().beginEditBlock() + self.multi_cursor_ignore_history = True + cursors = [] + for cursor in self.all_cursors: + start, end = cursor.selectionStart(), cursor.selectionEnd() + cursor.setPosition(start) + cursor.movePosition(QTextCursor.StartOfBlock) + while cursor.position() <= end: + cursor.movePosition(QTextCursor.EndOfBlock, + QTextCursor.KeepAnchor) + if cursor.atEnd(): + break + cursor.movePosition(QTextCursor.NextBlock, + QTextCursor.KeepAnchor) - 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() + self.setTextCursor(cursor) - 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) + # 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() + self.multi_cursor_ignore_history = False + self.cursorPositionChanged.emit() # ---- Scrolling # ------------------------------------------------------------------------- @@ -1841,6 +1898,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. @@ -1949,10 +2009,13 @@ 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: 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() @@ -1963,6 +2026,10 @@ 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() @@ -2002,6 +2069,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""" + # 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) @@ -2412,8 +2481,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) @@ -2867,8 +2935,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: @@ -2990,16 +3057,16 @@ 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 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): @@ -3334,7 +3401,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, @@ -3342,7 +3409,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, @@ -3374,7 +3441,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, @@ -3389,14 +3456,14 @@ 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.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 + triggered=self.clears_extra_cursors(self.convert_notebook) ) self.gotodef_action = self.create_action( CodeEditorActions.GoToDefinition, @@ -3479,7 +3546,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.clears_extra_cursors(self.format_document_or_range) ) self.format_action.setEnabled(False) @@ -3667,6 +3734,15 @@ 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) + 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) @@ -3689,10 +3765,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()) @@ -3759,7 +3837,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) @@ -3882,9 +3960,11 @@ def keyPressEvent(self, event): # could be shortcuts event.accept() + self.setOverwriteMode(False) + 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() @@ -4247,6 +4327,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): + """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 + 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) @@ -4256,56 +4360,85 @@ 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() + # TODO: Multi-cursor implementation improperly handles moving multiple + # 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( + self.all_cursors, + 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) - 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) + new_cursors = [] + for cursor in one_cursor_per_line: + 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.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.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() + self.multi_cursor_ignore_history = False + self.cursorPositionChanged.emit() def mouseMoveEvent(self, event): """Underline words when pressing """ @@ -4373,27 +4506,47 @@ 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 - pos = event.pos() + 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 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) + 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 + # cursor + self.add_column_cursor(event) + else: # Ctrl-Alt click adds and removes cursors + # Move existing primary cursor to extra_cursors list and set + # new primary cursor + self.add_remove_cursor(event) else: - TextEditBaseWidget.mousePressEvent(self, event) + # ---- 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 + if uri: + self.go_to_uri_from_cursor(uri) + else: + self.go_to_definition_from_cursor(cursor_for_pos) + elif event.button() == Qt.LeftButton and alt: + self.sig_alt_left_mouse_pressed.emit(event) + else: + TextEditBaseWidget.mousePressEvent(self, event) def mouseReleaseEvent(self, event): """Override Qt method.""" @@ -4467,6 +4620,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 @@ -4476,6 +4630,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. @@ -4483,6 +4646,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() @@ -4490,6 +4654,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): @@ -4570,7 +4745,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) ) self.menu_docstring.addAction(self.docstring_action) self.menu_docstring.setActiveAction(self.docstring_action) @@ -4635,8 +4810,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')) 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..ba9b121376a --- /dev/null +++ b/spyder/plugins/editor/widgets/codeeditor/multicursor_mixin.py @@ -0,0 +1,620 @@ +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Mixin to manage editing with multiple cursors. +""" + +# 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 +from spyder.utils.palette import SpyderPalette + + +class MultiCursorMixin: + """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 + 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 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()) + 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: + extra_selection = TextDecoration( + cursor, draw_order=5, kind="extra_cursor_selection" + ) + + 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) + + 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 + + # Handle all signals before editing text + cursors = [] + accepted = [] + 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? Are we 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.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( + "\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): + """ + 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): + """ + 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 new file mode 100644 index 00000000000..2bb09e8d619 --- /dev/null +++ b/spyder/plugins/editor/widgets/codeeditor/tests/test_multicursor.py @@ -0,0 +1,424 @@ +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# + +# Standard library imports + +# Third party imports +import pytest +from qtpy.QtCore import Qt, QPoint +from qtpy.QtGui import QTextCursor + +# 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 |= ControlModifier + if alt: + modifiers |= AltModifier + if shift: + modifiers |= ShiftModifier + + qtbot.mouseClick( + codeeditor.viewport(), + Qt.MouseButton.LeftButton, + modifiers, + pos=point + ) + + +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""" + + # 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 + 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): + """Test adding a column of extra cursors with ctrl-alt-shift click""" + + 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 + ) + codeeditor.setTextCursor(cursor) + + # Column cursor click at top row 6th column + click_at(codeeditor, qtbot, 6, ctrl=True, alt=True, shift=True) + + 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): + """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 + ) + 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 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) + 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. + + 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 + 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 + + +# 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" + +# fmt: off +# Disable formatting so that Black/Ruff don't incorrectly format the multiline +# strings below. +def test_smart_text(codeeditor, qtbot): + """ + Test smart text features: Smart backspace, whitespace insertion, colon + insertion, parenthesis and quote matching. + """ + + # Closing paren was inserted? + codeeditor.set_text("def test1\ndef test2\n") + click_at(codeeditor, qtbot, 9) + click_at(codeeditor, qtbot, 19, ctrl=True, alt=True) + qtbot.keyClick(codeeditor, Qt.Key.Key_ParenLeft) + assert codeeditor.toPlainText() == ("def test1()\ndef test2()\n") + + # Typing close paren advances cursor without adding extra paren? + qtbot.keyClick(codeeditor, Qt.Key.Key_ParenRight) + assert codeeditor.toPlainText() == ("def test1()\ndef test2()\n") + + # Auto colon and indent? + qtbot.keyClick(codeeditor, Qt.Key.Key_Return) + 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" + ) + + # Smart backspace to correct indent? + qtbot.keyClick(codeeditor, Qt.Key.Key_Backspace) + 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" + ) + + # Automatic quote + codeeditor.set_close_quotes_enabled(True) + qtbot.keyClick(codeeditor, Qt.Key.Key_Space) + qtbot.keyClick(codeeditor, Qt.Key.Key_Apostrophe) + assert codeeditor.toPlainText() == ( + "def test1():\n" + " return ''\n" + "def test2():\n" + " return ''\n" + ) + + # Automatic close quote + qtbot.keyClick(codeeditor, Qt.Key.Key_Apostrophe) + assert codeeditor.toPlainText() == ( + "def test1():\n" + " return ''\n" + "def test2():\n" + " return ''\n" + ) + + # Automatic dedent? + qtbot.keyClick(codeeditor, Qt.Key.Key_Return) + assert codeeditor.toPlainText() == ( + "def test1():\n" + " return ''\n" + "\n" + "def test2():\n" + " return ''\n" + "\n" + ) + +# fmt: on + +# ---- shortcuts + +# 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 + +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) + + call_shortcut(codeeditor, "move line down") + call_shortcut(codeeditor, "move line down") + assert codeeditor.toPlainText() == "\n".join("241536") + + call_shortcut(codeeditor, "move line up") + 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) + + call_shortcut(codeeditor, "duplicate line down") + assert codeeditor.toPlainText() == "\n".join("11233456") + assert codeeditor.textCursor().position() == 8 + assert codeeditor.extra_cursors[0].position() == 2 + + call_shortcut(codeeditor, "duplicate line up") + 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) + call_shortcut(codeeditor, "delete line") + 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) + call_shortcut(codeeditor, "go to new line") + assert codeeditor.toPlainText() == "1\n\n2\n3\n\n4\n5\n6" + + +# 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']) diff --git a/spyder/plugins/editor/widgets/editorstack/editorstack.py b/spyder/plugins/editor/widgets/editorstack/editorstack.py index 02453cc8bdc..cece88e3cf8 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) @@ -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 @@ -1092,6 +1093,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. @@ -2584,15 +2593,16 @@ 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: 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) @@ -2915,6 +2925,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.""" diff --git a/spyder/plugins/editor/widgets/editorstack/helpers.py b/spyder/plugins/editor/widgets/editorstack/helpers.py index 26bd90fd610..e737110da3d 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,9 @@ def filename(self, value): def text_changed(self): """Editor's text has changed.""" self.default = False - self.text_changed_at.emit(self.filename, - self.editor.get_position('cursor')) + all_cursors = self.editor.all_cursors + positions = tuple(cursor.position() for cursor in all_cursors) + 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 ca70aa67c25..b8ead4fdf45 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? @@ -1540,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: @@ -2698,18 +2700,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,11 +2727,11 @@ 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)) 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() @@ -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() @@ -2772,7 +2782,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,41 +2794,54 @@ 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]) - def _pop_next_cursor_diff(self, history, current_filename, current_cursor): + 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_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. @@ -2830,27 +2853,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): @@ -2858,7 +2876,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() @@ -2873,13 +2895,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): diff --git a/spyder/plugins/ipythonconsole/plugin.py b/spyder/plugins/ipythonconsole/plugin.py index cd7374b3789..d63e7281c68 100644 --- a/spyder/plugins/ipythonconsole/plugin.py +++ b/spyder/plugins/ipythonconsole/plugin.py @@ -559,7 +559,19 @@ def on_close(self, cancelable=False): return self.get_widget().close_all_clients() def on_mainwindow_visible(self): - self.create_new_client(give_focus=False) + """ + Connect to an existing kernel if a `kernel-*.json` file is given via + command line options. Otherwise create a new client. + """ + cli_options = self.get_command_line_options() + connection_file = cli_options.connection_file + if connection_file is not None: + self.create_client_for_kernel( + self.get_widget().find_connection_file(connection_file), + give_focus=False, + ) + else: + self.create_new_client(give_focus=False) # ---- Private methods # ------------------------------------------------------------------------- @@ -715,6 +727,7 @@ def create_client_for_kernel( sshkey=None, password=None, server_id=None, + give_focus=False, can_close=True, ): """ @@ -736,6 +749,9 @@ def create_client_for_kernel( running. server_id: str, optional The remote server id to which this client is connected to. + give_focus : bool, optional + True if the new client should gain the window + focus, False otherwise. The default is True. can_close: bool, optional Whether the client can be closed. This is useful to prevent closing the client that will be connected to a remote kernel before the @@ -747,7 +763,13 @@ def create_client_for_kernel( The created client. """ return self.get_widget().create_client_for_kernel( - connection_file, hostname, sshkey, password, server_id, can_close + connection_file, + hostname, + sshkey, + password, + server_id, + give_focus, + can_close, ) def get_client_for_file(self, filename): diff --git a/spyder/plugins/ipythonconsole/widgets/main_widget.py b/spyder/plugins/ipythonconsole/widgets/main_widget.py index 5fd06d36a78..974eaf454b4 100644 --- a/spyder/plugins/ipythonconsole/widgets/main_widget.py +++ b/spyder/plugins/ipythonconsole/widgets/main_widget.py @@ -1726,7 +1726,8 @@ def create_new_client(self, give_focus=True, filename='', special=None, return client def create_client_for_kernel(self, connection_file, hostname, sshkey, - password, server_id=None, can_close=True): + password, server_id=None, give_focus=False, + can_close=True): """Create a client connected to an existing kernel.""" given_name = None master_client = None @@ -1772,6 +1773,7 @@ def create_client_for_kernel(self, connection_file, hostname, sshkey, additional_options=self.additional_options(), handlers=self.registered_spyder_kernel_handlers, server_id=server_id, + give_focus=give_focus, can_close=can_close, ) diff --git a/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py b/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py index 90baa0b9b01..bff48818dad 100644 --- a/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py +++ b/spyder/plugins/variableexplorer/widgets/collectionsdelegate.py @@ -16,10 +16,30 @@ # Third party imports from qtpy.compat import to_qvariant -from qtpy.QtCore import QDateTime, QModelIndex, Qt, Signal +from qtpy.QtCore import ( + QDateTime, + QEvent, + QItemSelection, + QItemSelectionModel, + QModelIndex, + QRect, + QSize, + Qt, + Signal, +) +from qtpy.QtGui import QMouseEvent from qtpy.QtWidgets import ( - QAbstractItemDelegate, QDateEdit, QDateTimeEdit, QItemDelegate, QLineEdit, - QMessageBox, QTableView) + QAbstractItemDelegate, + QApplication, + QDateEdit, + QDateTimeEdit, + QItemDelegate, + QLineEdit, + QMessageBox, + QStyle, + QStyleOptionButton, + QTableView, +) from spyder_kernels.utils.lazymodules import ( FakeObject, numpy as np, pandas as pd, PIL) from spyder_kernels.utils.nsview import (display_to_value, is_editable_type, @@ -33,10 +53,12 @@ from spyder.plugins.variableexplorer.widgets.dataframeeditor import ( DataFrameEditor) from spyder.plugins.variableexplorer.widgets.texteditor import TextEditor +from spyder.utils.icon_manager import ima LARGE_COLLECTION = 1e5 LARGE_ARRAY = 5e6 +SELECT_ROW_BUTTON_SIZE = 22 class CollectionsDelegate(QItemDelegate, SpyderFontsMixin): @@ -472,6 +494,86 @@ def updateEditorGeometry(self, editor, option, index): super(CollectionsDelegate, self).updateEditorGeometry( editor, option, index) + def paint(self, painter, option, index): + """Actions to take when painting a cell.""" + if ( + # Do this only for the last column + index.column() == 3 + # Do this when the row is hovered or if it's selected + and ( + index.row() == self.parent().hovered_row + or index.row() in self.parent().selected_rows() + ) + ): + # Paint regular contents + super().paint(painter, option, index) + + # Paint an extra button to select the entire row. This is necessary + # because in Spyder 6 is not intuitive how to do that since we use + # a single click to open the editor associated to the cell. + # Fixes spyder-ide/spyder#22524 + # Solution adapted from https://stackoverflow.com/a/11778012/438386 + + # Getting the cell's rectangle + rect = option.rect + + # Button left/top coordinates + x = rect.left() + rect.width() - SELECT_ROW_BUTTON_SIZE + y = rect.top() + rect.height() // 2 - SELECT_ROW_BUTTON_SIZE // 2 + + # Create and paint button + button = QStyleOptionButton() + button.rect = QRect( + x, y, SELECT_ROW_BUTTON_SIZE, SELECT_ROW_BUTTON_SIZE + ) + button.text = "" + button.icon = ( + ima.icon("select_row") + if index.row() not in self.parent().selected_rows() + else ima.icon("deselect_row") + ) + button.iconSize = QSize(20, 20) + button.state = QStyle.State_Enabled + QApplication.style().drawControl( + QStyle.CE_PushButtonLabel, button, painter + ) + else: + super().paint(painter, option, index) + + def editorEvent(self, event, model, option, index): + """Actions to take when interacting with a cell.""" + if event.type() == QEvent.MouseButtonRelease and index.column() == 3: + # Getting the position of the mouse click + click_x = QMouseEvent(event).x() + click_y = QMouseEvent(event).y() + + # Getting the cell's rectangle + rect = option.rect + + # Region for the select row button + x = rect.left() + rect.width() - SELECT_ROW_BUTTON_SIZE + y = rect.top() + + # Select/deselect row when clicking on the button + if click_x > x and (y < click_y < (y + SELECT_ROW_BUTTON_SIZE)): + row = index.row() + if row in self.parent().selected_rows(): + # Deselect row if selected + index_left = index.sibling(row, 0) + index_right = index.sibling(row, 3) + selection = QItemSelection(index_left, index_right) + self.parent().selectionModel().select( + selection, QItemSelectionModel.Deselect + ) + else: + self.parent().selectRow(row) + else: + super().editorEvent(event, model, option, index) + else: + super().editorEvent(event, model, option, index) + + return False + class ToggleColumnDelegate(CollectionsDelegate): """ToggleColumn Item Delegate""" diff --git a/spyder/plugins/variableexplorer/widgets/objectexplorer/toggle_column_mixin.py b/spyder/plugins/variableexplorer/widgets/objectexplorer/toggle_column_mixin.py index 548c7bf9fcd..11288e88856 100644 --- a/spyder/plugins/variableexplorer/widgets/objectexplorer/toggle_column_mixin.py +++ b/spyder/plugins/variableexplorer/widgets/objectexplorer/toggle_column_mixin.py @@ -9,6 +9,7 @@ # ----------------------------------------------------------------------------- # Standard library imports +from functools import lru_cache import logging from typing import Any, Callable, Optional @@ -161,6 +162,8 @@ class ToggleColumnTreeView(QTreeView, ToggleColumnMixIn): A QTreeView where right clicking on the header allows the user to show/hide columns. """ + # Dummy conf section to avoid a warning + CONF_SECTION = "" def __init__( self, @@ -180,11 +183,19 @@ def __init__( self.expanded.connect(self.resize_columns_to_contents) self.collapsed.connect(self.resize_columns_to_contents) + # Dummy attribute to be compatible with BaseTableView + self.hovered_row = -1 + @Slot() def resize_columns_to_contents(self): """Resize all the columns to its contents.""" self._horizontal_header().resizeSections(QHeaderView.ResizeToContents) + @lru_cache(maxsize=1) + def selected_rows(self): + """Dummy method to be compatible with BaseTableView.""" + return set() + def _horizontal_header(self): """ Returns the horizontal header (of type QHeaderView). diff --git a/spyder/utils/icon_manager.py b/spyder/utils/icon_manager.py index 9289d879a48..1ce13d1216c 100644 --- a/spyder/utils/icon_manager.py +++ b/spyder/utils/icon_manager.py @@ -214,6 +214,8 @@ def __init__(self): 'rename': [('mdi.rename-box',), {'color': self.MAIN_FG_COLOR}], 'move': [('mdi.file-move',), {'color': self.MAIN_FG_COLOR}], 'edit_add': [('mdi.plus-box',), {'color': self.MAIN_FG_COLOR}], + 'select_row': [('mdi.plus-box-outline',), {'color': self.MAIN_FG_COLOR}], + 'deselect_row': [('mdi.minus-box-outline',), {'color': self.MAIN_FG_COLOR}], 'duplicate_row': [('ph.rows',), {'color': self.MAIN_FG_COLOR}], 'duplicate_column': [('ph.columns',), {'color': self.MAIN_FG_COLOR}], 'collapse_column': [('mdi.arrow-collapse-horizontal',), {'color': self.MAIN_FG_COLOR}], diff --git a/spyder/widgets/collectionseditor.py b/spyder/widgets/collectionseditor.py index be858820529..4eed99ad384 100644 --- a/spyder/widgets/collectionseditor.py +++ b/spyder/widgets/collectionseditor.py @@ -20,11 +20,13 @@ # Standard library imports import datetime +from functools import lru_cache import io import re import sys -import warnings +import textwrap from typing import Any, Callable, Optional +import warnings # Third party imports from qtpy.compat import getsavefilename, to_qvariant @@ -55,7 +57,9 @@ from spyder.utils.qthelpers import mimedata2url from spyder.utils.stringmatching import get_search_scores, get_search_regex from spyder.plugins.variableexplorer.widgets.collectionsdelegate import ( - CollectionsDelegate) + CollectionsDelegate, + SELECT_ROW_BUTTON_SIZE, +) from spyder.plugins.variableexplorer.widgets.importwizard import ImportWizard from spyder.widgets.helperwidgets import CustomSortFilterProxy from spyder.plugins.variableexplorer.widgets.basedialog import BaseDialog @@ -479,6 +483,15 @@ def data(self, index, role=Qt.DisplayRole): else: display = value if role == Qt.ToolTipRole: + if self.parent().over_select_row_button: + if index.row() in self.parent().selected_rows(): + tooltip = _("Click to deselect this row") + else: + tooltip = _( + "Click to select this row. Maintain pressed Ctrl (Cmd " + "on macOS) for multiple rows" + ) + return '\n'.join(textwrap.wrap(tooltip, 50)) return display if role == Qt.UserRole: if isinstance(value, NUMERIC_TYPES): @@ -653,6 +666,7 @@ class BaseTableView(QTableView, SpyderWidgetMixin): def __init__(self, parent): super().__init__(parent=parent) + # Main attributes self.array_filename = None self.menu = None self.empty_ws_menu = None @@ -678,6 +692,8 @@ def __init__(self, parent): self.source_model = None self.setAcceptDrops(True) self.automatic_column_width = True + + # Headder attributes self.setHorizontalHeader(BaseHeaderView(parent=self)) self.horizontalHeader().sig_user_resized_section.connect( self.user_resize_columns) @@ -698,15 +714,26 @@ def __init__(self, parent): self._edit_value_timer.setSingleShot(True) self._edit_value_timer.timeout.connect(self._edit_value) + # To paint the select row button and check if we are over it + self.hovered_row = -1 + self.over_select_row_button = False + def setup_table(self): """Setup table""" self.horizontalHeader().setStretchLastSection(True) self.horizontalHeader().setSectionsMovable(True) self.adjust_columns() + # Sorting columns self.setSortingEnabled(True) self.sortByColumn(0, Qt.AscendingOrder) + + # Actions to take when the selection changes self.selectionModel().selectionChanged.connect(self.refresh_menu) + self.selectionModel().selectionChanged.connect( + # We need this because selected_rows is cached + self.selected_rows.cache_clear + ) def setup_menu(self): """Setup actions and context menu""" @@ -1037,9 +1064,17 @@ def set_data(self, data): def _edit_value(self): self.edit(self.__index_clicked) + def _update_hovered_row(self, event): + current_index = self.indexAt(event.pos()) + if current_index.isValid(): + self.hovered_row = current_index.row() + self.viewport().update() + else: + self.hovered_row = -1 + def mousePressEvent(self, event): """Reimplement Qt method""" - if event.button() != Qt.LeftButton: + if event.button() != Qt.LeftButton or self.over_select_row_button: QTableView.mousePressEvent(self, event) return @@ -1070,9 +1105,26 @@ def mouseDoubleClickEvent(self, event): pass def mouseMoveEvent(self, event): - """Change cursor shape.""" + """Actions to take when the mouse moves over the widget.""" + self.over_select_row_button = False + self._update_hovered_row(event) + if self.rowAt(event.y()) != -1: - self.setCursor(Qt.PointingHandCursor) + # The +3 here is necessary to avoid mismatches when trying to click + # the button in a position too close to its left border. + select_row_button_width = SELECT_ROW_BUTTON_SIZE + 3 + + # Include scrollbar width when computing the select row button + # width + if self.verticalScrollBar().isVisible(): + select_row_button_width += self.verticalScrollBar().width() + + # Decide if the cursor is on top of the select row button + if (self.width() - event.x()) < select_row_button_width: + self.over_select_row_button = True + self.setCursor(Qt.ArrowCursor) + else: + self.setCursor(Qt.PointingHandCursor) else: self.setCursor(Qt.ArrowCursor) @@ -1124,6 +1176,16 @@ def dropEvent(self, event): else: event.ignore() + def leaveEvent(self, event): + """Actions to take when the mouse leaves the widget.""" + self.hovered_row = -1 + super().leaveEvent(event) + + def wheelEvent(self, event): + """Actions to take on mouse wheel.""" + self._update_hovered_row(event) + super().wheelEvent(event) + def showEvent(self, event): """Resize columns when the widget is shown.""" # This is probably the best we can do to adjust the columns width to @@ -1384,13 +1446,32 @@ def save_array(self): @Slot() def copy(self): - """Copy text to clipboard""" + """ + Copy text representation of objects to clipboard. + + Notes + ----- + For Numpy arrays and dataframes we try to get a better representation + by using their `savetxt` and `to_csv` methods, respectively. + """ clipboard = QApplication.clipboard() clipl = [] + retrieve_failed = False + array_failed = False + dataframe_failed = False + for idx in self.selectedIndexes(): if not idx.isValid(): continue - obj = self.delegate.get_value(idx) + + # Prevent error when it's not possible to get the object's value + # Fixes spyder-ide/spyder#12913 + try: + obj = self.delegate.get_value(idx) + except Exception: + retrieve_failed = True + continue + # Check if we are trying to copy a numpy array, and if so make sure # to copy the whole thing in a tab separated format if (isinstance(obj, (np.ndarray, np.ma.MaskedArray)) and @@ -1399,10 +1480,8 @@ def copy(self): try: np.savetxt(output, obj, delimiter='\t') except Exception: - QMessageBox.warning(self, _("Warning"), - _("It was not possible to copy " - "this array")) - return + array_failed = True + continue obj = output.getvalue().decode('utf-8') output.close() elif (isinstance(obj, (pd.DataFrame, pd.Series)) and @@ -1411,19 +1490,60 @@ def copy(self): try: obj.to_csv(output, sep='\t', index=True, header=True) except Exception: - QMessageBox.warning(self, _("Warning"), - _("It was not possible to copy " - "this dataframe")) - return + dataframe_failed = True + continue obj = output.getvalue() output.close() elif is_binary_string(obj): obj = to_text_string(obj, 'utf8') else: - obj = to_text_string(obj) + obj = str(obj) + clipl.append(obj) + + # Copy to clipboard the final result clipboard.setText('\n'.join(clipl)) + # Show appropriate error messages after we tried to copy all objects + # selected by users. + if retrieve_failed: + QMessageBox.warning( + self.parent(), + _("Warning"), + _( + "It was not possible to retrieve the value of one or more " + "of the variables you selected in order to copy them." + ), + ) + + if array_failed and dataframe_failed: + QMessageBox.warning( + self, + _("Warning"), + _( + "It was not possible to copy one or more of the " + "dataframes and Numpy arrays you selected" + ), + ) + elif array_failed: + QMessageBox.warning( + self, + _("Warning"), + _( + "It was not possible to copy one or more of the " + "Numpy arrays you selected" + ), + ) + elif dataframe_failed: + QMessageBox.warning( + self, + _("Warning"), + _( + "It was not possible to copy one or more of the " + "dataframes you selected" + ), + ) + def import_from_string(self, text, title=None): """Import data from string""" data = self.source_model.get_data() @@ -1450,6 +1570,21 @@ def paste(self): QMessageBox.warning(self, _( "Empty clipboard"), _("Nothing to be imported from clipboard.")) + @lru_cache(maxsize=1) + def selected_rows(self): + """ + Get the rows currently selected. + + Notes + ----- + The result of this function is cached because it's called in the paint + method of CollectionsDelegate. So, we need it to run as quickly as + possible. + """ + return { + index.row() for index in self.selectionModel().selectedRows() + } + class CollectionsEditorTableView(BaseTableView): """CollectionsEditor table view""" diff --git a/spyder/widgets/tests/test_collectioneditor.py b/spyder/widgets/tests/test_collectioneditor.py index dcd2e7914f7..696d085a197 100644 --- a/spyder/widgets/tests/test_collectioneditor.py +++ b/spyder/widgets/tests/test_collectioneditor.py @@ -33,6 +33,9 @@ CollectionsEditor, CollectionsEditorTableView, CollectionsEditorWidget, CollectionsModel, LARGE_NROWS, natsort, RemoteCollectionsEditorTableView, ROWS_TO_LOAD) +from spyder.plugins.variableexplorer.widgets.collectionsdelegate import ( + SELECT_ROW_BUTTON_SIZE +) from spyder.plugins.variableexplorer.widgets.tests.test_dataframeeditor import ( generate_pandas_indexes) from spyder_kernels.utils.nsview import get_size @@ -1082,5 +1085,45 @@ def test_collectioneditor_plot(qtbot): mock_namespacebrowser.plot.assert_called_once_with(my_list, 'plot') +def test_collectionseditor_select_row_button(qtbot): + """Test that the button to select rows is working as expected.""" + data = {"a": 10, "b": "This is a string"} + editor = CollectionsEditor() + editor.setup(data) + editor.show() + + # This is necessary so that Qt paints + qtbot.wait(300) + + # Coordinates to position the cursor on top of the select row button for + # the first row + table_view = editor.widget.editor + x = ( + # Left x ccordinate for the first row + + table_view.columnViewportPosition(0) + + table_view.width() + - SELECT_ROW_BUTTON_SIZE // 2 + ) + + y = ( + # Top y ccordinate for the first row + + table_view.rowViewportPosition(0) + + table_view.rowHeight(0) // 2 + ) + + # Move cursor + qtbot.mouseMove(table_view.viewport(), QPoint(x, y), delay=100) + + # Click on that posiiton and check the first row was selected. + # Note: We can't use LeftButton here because it edits the row. However, it + # works as exoected in regular usage. + qtbot.mouseClick(table_view.viewport(), Qt.MiddleButton, pos=QPoint(x, y)) + assert table_view.selected_rows() == {0} + + # Click again and check the row was deselected + qtbot.mouseClick(table_view.viewport(), Qt.MiddleButton, pos=QPoint(x, y)) + assert table_view.selected_rows() == set() + + if __name__ == "__main__": pytest.main()