Repository: spyder-ide/spyder

Issue:
Editor jumps to the end of file when saving with autoformat-on-save switched on
<!--- **PLEASE READ:** When submitting here, please ensure you've completed the following checklist and checked the boxes to confirm. Issue reports without it may be closed. Thanks! --->

### Issue Report Checklist

* [x] Searched the [issues page](https://github.com/spyder-ide/spyder/issues?q=is%3Aissue) for similar reports
* [x] Read the relevant sections of the [Spyder Troubleshooting Guide](https://github.com/spyder-ide/spyder/wiki/Troubleshooting-Guide-and-FAQ) and followed its advice
* [x] Reproduced the issue after updating with ``conda update spyder`` (or ``pip``, if not using Anaconda)
* [ ] Could not reproduce inside ``jupyter qtconsole`` (if console-related)
* [x] Tried basic troubleshooting (if a bug/error)
    * [x] Restarted Spyder
    * [x] Reset preferences with ``spyder --reset``
    * [ ] Reinstalled the latest version of [Anaconda](https://www.anaconda.com/download/)
    * [x] Tried the other applicable steps from the Troubleshooting Guide
* [x] Completed the **Problem Description**, **Steps to Reproduce** and **Version** sections below

## Problem Description

This is superficially similar to the previous issue on the editing window jumping (#19939) but is probably simpler.  When I save a file with autoformat-on-save switched on (I'm using the black formatter), the cursor and editing window often jump to the end of the file if the file was reformatted.  I'm guessing that this is some sort of default behaviour when reloading a file?

I could try to reproduce this and make a screencast so you can see the behaviour if that would help!

### What is the expected output? What do you see instead?

It would be great if the cursor stayed in roughly the same place in the file (say the same line number), even if it's impossible to keep it in the same logical position in the file.

## Versions
<!--- You can get this information from Help > About Spyder...
or (if Spyder won't launch) the "conda list" command
from the Anaconda Prompt/Terminal/command line. --->

* Spyder version: 5.3.3  (pip)
* Python version: 3.10.8 64-bit
* Qt version: 5.15.6
* PyQt5 version: 5.15.7
* Operating System: Linux 6.0.0-2-amd64



Discussion:
Ah, I realise that it's actually tied in with #19939 - sorry.  On save, the cursor jumps to the end of the file but the window stays in the same position; it's when hitting any key that the screen then jumps.  It would be good if the cursor didn't jump.
Hey @juliangilbey, thanks for reporting! I'm surprised no one has noticed this bug before (I did some months ago but haven't had time to fix it). I agree it's similar to #19939 but this is specifically related to auto-formatting.

> It would be good if the cursor didn't jump.

Agreed, this is the ideal solution. We'll see if we can implement it for our next version, i.e. the one after 5.4.0, which is almost ready.
I guess the "ideal" thing to do would be to save (in memory) the location of the cursor with respect to the AST before saving, then reposition the cursor at the same place in the AST after saving. But that may be too difficult.

Resolving patch:
diff --git a/spyder/plugins/editor/widgets/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor.py
--- a/spyder/plugins/editor/widgets/codeeditor.py
+++ b/spyder/plugins/editor/widgets/codeeditor.py
@@ -524,6 +524,7 @@ def __init__(self, parent=None):
         self.format_on_save = False
         self.format_eventloop = QEventLoop(None)
         self.format_timer = QTimer(self)
+        self.__cursor_position_before_format = 0
 
         # Mouse tracking
         self.setMouseTracking(True)
@@ -1053,8 +1054,8 @@ def setup_editor(self,
 
         self.set_strip_mode(strip_mode)
 
-    # --- Language Server Protocol methods -----------------------------------
-    # ------------------------------------------------------------------------
+    # ---- LSP: Basic methods
+    # -------------------------------------------------------------------------
     @Slot(str, dict)
     def handle_response(self, method, params):
         if method in self.handler_registry:
@@ -1093,7 +1094,8 @@ def log_lsp_handle_errors(self, message):
             else:
                 logger.error('%', 1, stack_info=True)
 
-    # ------------- LSP: Configuration and protocol start/end ----------------
+    # ---- LSP: Configuration and start/stop
+    # -------------------------------------------------------------------------
     def start_completion_services(self):
         """Start completion services for this instance."""
         self.completions_available = True
@@ -1194,7 +1196,8 @@ def document_did_open(self):
         }
         return params
 
-    # ------------- LSP: Symbols ---------------------------------------
+    # ---- LSP: Symbols
+    # -------------------------------------------------------------------------
     @schedule_request(method=CompletionRequestTypes.DOCUMENT_SYMBOL)
     def request_symbols(self):
         """Request document symbols."""
@@ -1221,7 +1224,8 @@ def process_symbols(self, params):
         except Exception:
             self.log_lsp_handle_errors("Error when processing symbols")
 
-    # ------------- LSP: Linting ---------------------------------------
+    # ---- LSP: Linting and didChange
+    # -------------------------------------------------------------------------
     def _schedule_document_did_change(self):
         """Schedule a document update."""
         self._document_server_needs_update = True
@@ -1434,7 +1438,8 @@ def _process_code_analysis(self, underline):
                     )
                 block.setUserData(data)
 
-    # ------------- LSP: Completion ---------------------------------------
+    # ---- LSP: Completion
+    # -------------------------------------------------------------------------
     @schedule_request(method=CompletionRequestTypes.DOCUMENT_COMPLETION)
     def do_completion(self, automatic=False):
         """Trigger completion."""
@@ -1570,7 +1575,8 @@ def handle_completion_item_resolution(self, response):
             self.log_lsp_handle_errors(
                 "Error when handling completion item resolution")
 
-    # ------------- LSP: Signature Hints ------------------------------------
+    # ---- LSP: Signature Hints
+    # -------------------------------------------------------------------------
     @schedule_request(method=CompletionRequestTypes.DOCUMENT_SIGNATURE)
     def request_signature(self):
         """Ask for signature."""
@@ -1626,7 +1632,8 @@ def process_signatures(self, params):
         except Exception:
             self.log_lsp_handle_errors("Error when processing signature")
 
-    # ------------- LSP: Hover/Mouse ---------------------------------------
+    # ---- LSP: Hover/Cursor
+    # -------------------------------------------------------------------------
     @schedule_request(method=CompletionRequestTypes.DOCUMENT_CURSOR_EVENT)
     def request_cursor_event(self):
         text = self.get_text_with_eol()
@@ -1690,7 +1697,8 @@ def handle_hover_response(self, contents):
         except Exception:
             self.log_lsp_handle_errors("Error when processing hover")
 
-    # ------------- LSP: Go To Definition ----------------------------
+    # ---- LSP: Go To Definition
+    # -------------------------------------------------------------------------
     @Slot()
     @schedule_request(method=CompletionRequestTypes.DOCUMENT_DEFINITION)
     def go_to_definition_from_cursor(self, cursor=None):
@@ -1742,8 +1750,14 @@ def handle_go_to_definition(self, position):
             self.log_lsp_handle_errors(
                 "Error when processing go to definition")
 
-    # ------------- LSP: Document/Selection formatting --------------------
+    # ---- LSP: Document/Selection formatting
+    # -------------------------------------------------------------------------
     def format_document_or_range(self):
+        """Format current document or selected text."""
+        # Save current cursor position to restore it after the current text has
+        # been replaced by the auto-formatted one.
+        self.__cursor_position_before_format = self.textCursor().position()
+
         if self.has_selected_text() and self.range_formatting_enabled:
             self.format_document_range()
         else:
@@ -1751,6 +1765,7 @@ def format_document_or_range(self):
 
     @schedule_request(method=CompletionRequestTypes.DOCUMENT_FORMATTING)
     def format_document(self):
+        """Format current document."""
         if not self.formatting_enabled:
             return
         if self.formatting_in_progress:
@@ -1783,6 +1798,7 @@ def format_document(self):
 
     @schedule_request(method=CompletionRequestTypes.DOCUMENT_RANGE_FORMATTING)
     def format_document_range(self):
+        """Format selected text."""
         if not self.range_formatting_enabled or not self.has_selected_text():
             return
         if self.formatting_in_progress:
@@ -1830,6 +1846,7 @@ def format_document_range(self):
 
     @handles(CompletionRequestTypes.DOCUMENT_FORMATTING)
     def handle_document_formatting(self, edits):
+        """Handle document formatting response."""
         try:
             if self.formatting_in_progress:
                 self._apply_document_edits(edits)
@@ -1851,6 +1868,7 @@ def handle_document_formatting(self, edits):
 
     @handles(CompletionRequestTypes.DOCUMENT_RANGE_FORMATTING)
     def handle_document_range_formatting(self, edits):
+        """Handle document range formatting response."""
         try:
             if self.formatting_in_progress:
                 self._apply_document_edits(edits)
@@ -1926,16 +1944,33 @@ def _apply_document_edits(self, edits):
         if merged_text is not None:
             # Restore eol chars after applying edits.
             merged_text = merged_text.replace('\n', self.get_line_separator())
-
             cursor = self.textCursor()
+
+            # Begin text insertion
             cursor.beginEditBlock()
+
+            # Select current text
             cursor.movePosition(QTextCursor.Start)
             cursor.movePosition(QTextCursor.End,
                                 QTextCursor.KeepAnchor)
+
+            # Insert formatted text in place of the previous one
             cursor.insertText(merged_text)
+
+            # Don't run the lines below when testing because they give
+            # segfaults.
+            if not running_under_pytest():
+                # Restore previous cursor position and center it.
+                # Fixes spyder-ide/spyder#19958
+                cursor.setPosition(self.__cursor_position_before_format)
+                self.setTextCursor(cursor)
+                self.centerCursor()
+
+            # End text insertion
             cursor.endEditBlock()
 
-    # ------------- LSP: Code folding ranges -------------------------------
+    # ---- LSP: Code folding
+    # -------------------------------------------------------------------------
     def compute_whitespace(self, line):
         tab_size = self.tab_stop_width_spaces
         whitespace_regex = re.compile(r'(\s+).*')
@@ -2028,7 +2063,8 @@ def finish_code_folding(self):
             line, column = self.get_cursor_line_column()
             self.update_whitespace_count(line, column)
 
-    # ------------- LSP: Save/close file -----------------------------------
+    # ---- LSP: Save/close file
+    # -------------------------------------------------------------------------
     @schedule_request(method=CompletionRequestTypes.DOCUMENT_DID_SAVE,
              requires_response=False)
     def notify_save(self):
@@ -2069,6 +2105,7 @@ def notify_close(self):
             }
             return params
 
+    # ---- Debug panel
     # -------------------------------------------------------------------------
     def set_debug_panel(self, show_debug_panel, language):
         """Enable/disable debug panel."""
@@ -2094,6 +2131,8 @@ def update_debugger_panel_state(self, state, last_step, force=False):
                 return
         debugger_panel.stop_clean()
 
+    # ---- Set different attributes
+    # -------------------------------------------------------------------------
     def set_folding_panel(self, folding):
         """Enable/disable folding panel."""
         folding_panel = self.panels.get(FoldingPanel)
@@ -2482,7 +2521,8 @@ def delete(self):
             self.setTextCursor(cursor)
         self.remove_selected_text()
 
-    #------Find occurrences
+    # ---- Find occurrences
+    # -------------------------------------------------------------------------
     def __find_first(self, text):
         """Find first occurrence: scan whole document"""
         flags = QTextDocument.FindCaseSensitively|QTextDocument.FindWholeWords
@@ -2615,7 +2655,8 @@ def mark_occurrences(self):
             self.occurrences.pop(-1)
         self.sig_flags_changed.emit()
 
-    #-----highlight found results (find/replace widget)
+    # ---- Highlight found results
+    # -------------------------------------------------------------------------
     def highlight_found_results(self, pattern, word=False, regexp=False,
                                 case=False):
         """Highlight all found patterns"""
@@ -2729,7 +2770,8 @@ def select_lines(self, linenumber_pressed, linenumber_released):
 
         self.setTextCursor(cursor)
 
-    # ----- Code bookmarks
+    # ---- Code bookmarks
+    # -------------------------------------------------------------------------
     def add_bookmark(self, slot_num, line=None, column=None):
         """Add bookmark to current block's userData."""
         if line is None:
@@ -2778,7 +2820,8 @@ def update_bookmarks(self):
         """Emit signal to update bookmarks."""
         self.sig_bookmarks_changed.emit()
 
-    # -----Code introspection
+    # ---- Code introspection
+    # -------------------------------------------------------------------------
     def show_completion_object_info(self, name, signature):
         """Trigger show completion info in Help Pane."""
         force = True
@@ -2789,7 +2832,8 @@ def show_object_info(self):
         """Trigger a calltip"""
         self.sig_show_object_info.emit(True)
 
-    # -----blank spaces
+    # ---- Blank spaces
+    # -------------------------------------------------------------------------
     def set_blanks_enabled(self, state):
         """Toggle blanks visibility"""
         self.blanks_enabled = state
@@ -2823,7 +2867,8 @@ def showEvent(self, event):
         super(CodeEditor, self).showEvent(event)
         self.panels.refresh()
 
-    #-----Misc.
+    # ---- Misc.
+    # -------------------------------------------------------------------------
     def _apply_highlighter_color_scheme(self):
         """Apply color scheme from syntax highlighter to the editor"""
         hl = self.highlighter
@@ -3090,9 +3135,8 @@ def redo(self):
             self.is_redoing = False
             self.skip_rstrip = False
 
-    # =========================================================================
-    #    High-level editor features
-    # =========================================================================
+    # ---- High-level editor features
+    # -------------------------------------------------------------------------
     @Slot()
     def center_cursor_on_next_focus(self):
         """QPlainTextEdit's "centerCursor" requires the widget to be visible"""
@@ -3130,7 +3174,6 @@ def _set_completions_hint_idle(self):
         self._completions_hint_idle = True
         self.completion_widget.trigger_completion_hint()
 
-    # --- Hint for completions
     def show_hint_for_completion(self, word, documentation, at_point):
         """Show hint for completion element."""
         if self.completions_hint and self._completions_hint_idle:
@@ -3349,7 +3392,8 @@ def get_cell_count(self):
         """Get number of cells in document."""
         return 1 + len(list(self.cell_list()))
 
-    #------Tasks management
+    # ---- Tasks management
+    # -------------------------------------------------------------------------
     def go_to_next_todo(self):
         """Go to next todo and return new cursor position"""
         block = self.textCursor().block()
@@ -3387,7 +3431,8 @@ def process_todo(self, todo_results):
             block.setUserData(data)
         self.sig_flags_changed.emit()
 
-    #------Comments/Indentation
+    # ---- Comments/Indentation
+    # -------------------------------------------------------------------------
     def add_prefix(self, prefix):
         """Add prefix to current line or selected line(s)"""
         cursor = self.textCursor()
@@ -4151,10 +4196,11 @@ def __in_block_comment(cursor):
         cursor3.endEditBlock()
         return True
 
-    #------Kill ring handlers
+    # ---- Kill ring handlers
     # Taken from Jupyter's QtConsole
     # Copyright (c) 2001-2015, IPython Development Team
     # Copyright (c) 2015-, Jupyter Development Team
+    # -------------------------------------------------------------------------
     def kill_line_end(self):
         """Kill the text on the current line from the cursor forward"""
         cursor = self.textCursor()
@@ -4228,7 +4274,8 @@ def kill_next_word(self):
         self._kill_ring.kill_cursor(cursor)
         self.setTextCursor(cursor)
 
-    #------Autoinsertion of quotes/colons
+    # ---- Autoinsertion of quotes/colons
+    # -------------------------------------------------------------------------
     def __get_current_color(self, cursor=None):
         """Get the syntax highlighting color for the current cursor position"""
         if cursor is None:
@@ -4405,7 +4452,8 @@ def in_string(self, cursor=None, position=None):
         else:
             return False
 
-    # ------ Qt Event handlers
+    # ---- Qt Event handlers
+    # -------------------------------------------------------------------------
     def setup_context_menu(self):
         """Setup context menu"""
         self.undo_action = create_action(
@@ -5378,7 +5426,8 @@ def _restore_editor_cursor_and_selections(self):
             self._last_hover_pattern_key = None
             self._last_hover_pattern_text = None
 
-    #------ Drag and drop
+    # ---- Drag and drop
+    # -------------------------------------------------------------------------
     def dragEnterEvent(self, event):
         """
         Reimplemented Qt method.
@@ -5409,7 +5458,8 @@ def dropEvent(self, event):
             logger.debug("Call TextEditBaseWidget dropEvent method")
             TextEditBaseWidget.dropEvent(self, event)
 
-    #------ Paint event
+    # ---- Paint event
+    # -------------------------------------------------------------------------
     def paintEvent(self, event):
         """Overrides paint event to update the list of visible blocks"""
         self.update_visible_blocks(event)
diff --git a/spyder/plugins/editor/widgets/editor.py b/spyder/plugins/editor/widgets/editor.py
--- a/spyder/plugins/editor/widgets/editor.py
+++ b/spyder/plugins/editor/widgets/editor.py
@@ -2397,8 +2397,11 @@ def __modify_stack_title(self):
 
     def refresh(self, index=None):
         """Refresh tabwidget"""
+        logger.debug("Refresh EditorStack")
+
         if index is None:
             index = self.get_stack_index()
+
         # Set current editor
         if self.get_stack_count():
             index = self.get_stack_index()
@@ -2414,8 +2417,10 @@ def refresh(self, index=None):
             self.update_plugin_title.emit()
         else:
             editor = None
+
         # Update the modification-state-dependent parameters
         self.modification_changed()
+
         # Update FindReplace binding
         self.find_widget.set_editor(editor, refresh=False)
 
@@ -2429,21 +2434,27 @@ def modification_changed(self, state=None, index=None, editor_id=None):
             for index, _finfo in enumerate(self.data):
                 if id(_finfo.editor) == editor_id:
                     break
+
         # This must be done before refreshing save/save all actions:
         # (otherwise Save/Save all actions will always be enabled)
         self.opened_files_list_changed.emit()
-        # --
+
+        # Get index
         if index is None:
             index = self.get_stack_index()
+
         if index == -1:
             return
+
         finfo = self.data[index]
         if state is None:
             state = finfo.editor.document().isModified() or finfo.newly_created
         self.set_stack_title(index, state)
+
         # Toggle save/save all actions state
         self.save_action.setEnabled(state)
         self.refresh_save_all_action.emit()
+
         # Refreshing eol mode
         eol_chars = finfo.editor.get_line_separator()
         self.refresh_eol_chars(eol_chars)


Test patch: