Repository: spyder-ide/spyder

Issue:
Cursor moves position after deleting a character and changing line
<!--- **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)
* [ ] Tried basic troubleshooting (if a bug/error)
    * [x] Restarted Spyder
    * [ ] 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

When replacing a simple string in selected text with `Replace selection`, Spyder takes a long time to replace it and to return control to the user.


### What steps reproduce the problem?

1. Open a new Python file (or use `temp.py` or any existing Python file)
2. Write three lines with `# Spyder is great` (each in its own line)
3. Move the cursor in the first line in front of `great`
4. Press `Del` to delete the character `g` from the word `great`
5. Press the key `Cursor down` once

### What is the expected output? What do you see instead?

I would expect that the cursor stays in the position before the character `g` in the next line, but it moves between `g` and `r`.

If I delete the character `g` with `BACKSPACE`, and move down one line, the cursor stays in the position between `g` and `r`, as expected.

## 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: 4.1.2
* Python version: 3.6.9. 64-bit
* Qt version: 5.12.7
* PyQt version: 5.12.3
* Operating System name/version: Linux Mint 19.3 Cinnamon

### Dependencies
<!--- Please go to the menu entry Help > Dependencies,
press the Copy to clipboard button and paste below --->

```

# Mandatory:
atomicwrites >=1.2.0           :  1.3.0 (OK)
chardet >=2.0.0                :  3.0.4 (OK)
cloudpickle >=0.5.0            :  1.3.0 (OK)
diff_match_patch >=20181111    :  20181111 (OK)
intervaltree                   :  None (OK)
IPython >=4.0                  :  7.13.0 (OK)
jedi =0.15.2                   :  0.15.2 (OK)
keyring                        :  None (OK)
nbconvert >=4.0                :  5.6.1 (OK)
numpydoc >=0.6.0               :  0.9.2 (OK)
parso =0.5.2                   :  0.5.2 (OK)
pexpect >=4.4.0                :  4.8.0 (OK)
pickleshare >=0.4              :  0.7.5 (OK)
psutil >=5.3                   :  5.7.0 (OK)
pygments >=2.0                 :  2.6.1 (OK)
pylint >=0.25                  :  2.4.4 (OK)
pyls >=0.31.9;<0.32.0          :  0.31.9 (OK)
qdarkstyle >=2.8               :  2.8 (OK)
qtawesome >=0.5.7              :  0.7.0 (OK)
qtconsole >=4.6.0              :  4.7.1 (OK)
qtpy >=1.5.0                   :  1.9.0 (OK)
sphinx >=0.6.6                 :  2.4.4 (OK)
spyder_kernels >=1.9.0;<1.10.0 :  1.9.0 (OK)
watchdog                       :  None (OK)
xdg >=0.26                     :  0.26 (OK)
zmq >=17                       :  19.0.0 (OK)

# Optional:
cython >=0.21                  :  None (OK)
matplotlib >=2.0.0             :  None (OK)
numpy >=1.7                    :  1.18.2 (OK)
pandas >=0.13.1                :  None (OK)
scipy >=0.17.0                 :  1.4.1 (OK)
sympy >=0.7.3                  :  None (OK)
```



Discussion:
I can confirm this, and I agree that it should work as described.
Thanks @sphh for reporting this and thanks @bcolsen for verifying it. I will take a look.
I can confirm this issue as well. For Mac devs you can use https://karabiner-elements.pqrs.org/

and map the keys like
<img width="1112" alt="Screen Shot 2020-05-12 at 19 18 14" src="https://user-images.githubusercontent.com/3627835/81757696-57a1bc80-9485-11ea-9d6f-4887fcdadb04.png">

To switch the backspace for a delete
@goanpeca I think fn+delete one the Mac does delete.
Indeed :-p did not know that one! Thanks :-) 


This has been brought up before and we found that it's an (annoying) Qt behavior.

However, @juanis2112 has a very good idea on how to make this work as expected.
Must have missed it.

At the moment I have a .tex file open in TeXstudio, which is qt based (I have 5.9.5 installed), but that is not written in Python. I don't know, if this information can be helpful in narrowing down the cause …
Indeed, this is due to how the QPlainTextEdit widget behaves. Here's a small PyQt5 application where the behaviour can be reproduced:
```
from PyQt5.QtWidgets import QApplication, QPlainTextEdit
app = QApplication([])
widget = QPlainTextEdit()
widget.show()
app.exec_()
```

However, the Spyder CodeEditor widget already overrides keyboard input in
https://github.com/spyder-ide/spyder/blob/9c84cd89625d9ad07f54810588ae6464fa4efef1/spyder/plugins/editor/widgets/codeeditor.py#L4254
so fixing this is easy. It suffices to replace the lines
https://github.com/spyder-ide/spyder/blob/9c84cd89625d9ad07f54810588ae6464fa4efef1/spyder/plugins/editor/widgets/codeeditor.py#L4365
and
https://github.com/spyder-ide/spyder/blob/9c84cd89625d9ad07f54810588ae6464fa4efef1/spyder/plugins/editor/widgets/codeeditor.py#L4386
by `self.stdkey_backspace()`:
https://github.com/spyder-ide/spyder/blob/9c84cd89625d9ad07f54810588ae6464fa4efef1/spyder/plugins/editor/widgets/base.py#L933-L937

However, Spyder also has a similarly annoying behaviour with the "Delete" key that is not reproduced by the PyQt snippet above. I'll try to track down the reason for that before making a PR. But a similar workaround should be possible.
Thanks @hengin for the help!
> However, Spyder also has a similarly annoying behaviour with the "Delete" key that is not reproduced by the PyQt snippet above. I'll try to track down the reason for that before making a PR. But a similar workaround should be possible.

Thanks for your help with that @hengin! @juanis2112 was on the right track to solve this one, but unfortunately got busy with our docs and the community survey.
For some reason (platform compatibility?), the Delete-key is handled as a shortcut. The problem disappears if I disable the "delete" shortcut in Tools > Preferences > Keyboard shortcuts.

The responsible piece of code is:
https://github.com/spyder-ide/spyder/blob/9c84cd89625d9ad07f54810588ae6464fa4efef1/spyder/plugins/editor/widgets/codeeditor.py#L2218-L2227

Apparently QPlainTextEdit handles Delete events differently than the above. Indeed, if I manually perform the steps above in a QPlainTextEdit I can reproduce the behaviour. The recording below shows the behaviour of three different methods of character deletion in a QPlainTextEdit (same snippet as I posted above). In each case I press up/down immediately after pressing backspace/delete.
![spyder-delete](https://user-images.githubusercontent.com/17409078/104061820-98f79100-51f9-11eb-9f12-9b2f7a9ae2eb.gif)

Actually, much larger jumps can be produced:
![spyder-extreme-delete](https://user-images.githubusercontent.com/17409078/104062359-9e091000-51fa-11eb-9996-8c8087b7accd.gif)

The reason is most likely that the QPlainTextEdit remembers the rightmost position the cursor was at just before the text is deleted and wants to place the cursor there again. So we just need to clear this memory. The simplest method I've found so far is
```
cursor = self.textCursor()
cursor.setPosition(cursor.position())
self.setTextCursor(cursor)
```

I'll go ahead and make a PR now.

EDIT: The backspace issue (which likely has the same underlying cause as the delete issue) was reported as [QTBUG-35861](https://bugreports.qt.io/browse/QTBUG-35861?jql=text%20~%20%22QTextEdit%20delete%22) seven years ago.

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
@@ -4295,6 +4295,12 @@ def _handle_keypress_event(self, event):
         # a text change.
         text = to_text_string(event.text())
         if text:
+            # The next three lines are a workaround for a quirk of
+            # QTextEdit. See spyder-ide/spyder#12663 and
+            # https://bugreports.qt.io/browse/QTBUG-35861
+            cursor = self.textCursor()
+            cursor.setPosition(cursor.position())
+            self.setTextCursor(cursor)
             self.document_did_change()
             self.sig_text_was_inserted.emit()
 
@@ -4405,12 +4411,12 @@ def keyPressEvent(self, event):
         elif key == Qt.Key_Insert and not shift and not ctrl:
             self.setOverwriteMode(not self.overwriteMode())
         elif key == Qt.Key_Backspace and not shift and not ctrl:
-            leading_text = self.get_text('sol', 'cursor')
-            leading_length = len(leading_text)
-            trailing_spaces = leading_length - len(leading_text.rstrip())
             if has_selection 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
diff --git a/spyder/widgets/mixins.py b/spyder/widgets/mixins.py
--- a/spyder/widgets/mixins.py
+++ b/spyder/widgets/mixins.py
@@ -1176,6 +1176,12 @@ def get_selected_text(self, cursor=None):
     def remove_selected_text(self):
         """Delete selected text."""
         self.textCursor().removeSelectedText()
+        # The next three lines are a workaround for a quirk of
+        # QTextEdit. See spyder-ide/spyder#12663 and
+        # https://bugreports.qt.io/browse/QTBUG-35861
+        cursor = self.textCursor()
+        cursor.setPosition(cursor.position())
+        self.setTextCursor(cursor)
         self.document_did_change()
 
     def replace(self, text, pattern=None):


Test patch:
diff --git a/spyder/plugins/editor/widgets/tests/test_codeeditor.py b/spyder/plugins/editor/widgets/tests/test_codeeditor.py
--- a/spyder/plugins/editor/widgets/tests/test_codeeditor.py
+++ b/spyder/plugins/editor/widgets/tests/test_codeeditor.py
@@ -6,10 +6,12 @@
 
 # Standard library imports
 import os.path as osp
+import sys
 
 # Third party imports
 from qtpy.QtCore import Qt, QEvent
 from qtpy.QtGui import QFont, QTextCursor, QMouseEvent
+from qtpy.QtWidgets import QTextEdit
 from pytestqt import qtbot
 import pytest
 
@@ -292,7 +294,6 @@ def test_brace_match(editorbot):
     """
     # Create editor with contents loaded from assets/brackets.py
     qtbot, editor = editorbot
-    editor.setup_editor(language='Python')
     with open(osp.join(ASSETS, 'braces.py'), 'r') as file:
         editor.set_text(file.read())
 
@@ -366,5 +367,146 @@ def test_brace_match(editorbot):
         assert editor.bracepos == expected
 
 
+def test_editor_backspace_char(editorbot):
+    """Regression test for issue spyder-ide/spyder#12663."""
+    qtbot, editor = editorbot
+    text = "0123456789\nabcdefghij\n9876543210\njihgfedcba\n"
+    editor.set_text(text)
+    expected_column = 7
+    cursor = editor.textCursor()
+    cursor.setPosition(expected_column)
+    editor.setTextCursor(cursor)
+    for line in range(3):
+        qtbot.keyPress(editor, Qt.Key_Backspace)
+        expected_column -= 1
+        assert editor.textCursor().columnNumber() == expected_column
+        qtbot.keyPress(editor, Qt.Key_Down)
+        assert editor.textCursor().columnNumber() == expected_column
+
+    for line in range(3):
+        qtbot.keyPress(editor, Qt.Key_Backspace)
+        expected_column -= 1
+        assert editor.textCursor().columnNumber() == expected_column
+        qtbot.keyPress(editor, Qt.Key_Up)
+        assert editor.textCursor().columnNumber() == expected_column
+
+
+def test_editor_backspace_selection(editorbot):
+    """Regression test for issue spyder-ide/spyder#12663."""
+    qtbot, editor = editorbot
+    text = "0123456789\nabcdefghij\n9876543210\njihgfedcba\n"
+    editor.set_text(text)
+    expected_column = 5
+    cursor = editor.textCursor()
+    cursor.setPosition(expected_column)
+    editor.setTextCursor(cursor)
+
+    # This first subtest does not trigger the original bug
+    for press in range(3):
+        qtbot.keyPress(editor, Qt.Key_Left, Qt.ShiftModifier)
+    expected_column -= 3
+    qtbot.keyPress(editor, Qt.Key_Backspace)
+    assert editor.textCursor().columnNumber() == expected_column
+    qtbot.keyPress(editor, Qt.Key_Down)
+    assert editor.textCursor().columnNumber() == expected_column
+
+    # However, this second subtest does trigger the original bug
+    for press in range(3):
+        qtbot.keyPress(editor, Qt.Key_Right, Qt.ShiftModifier)
+    qtbot.keyPress(editor, Qt.Key_Backspace)
+    assert editor.textCursor().columnNumber() == expected_column
+    qtbot.keyPress(editor, Qt.Key_Down)
+    assert editor.textCursor().columnNumber() == expected_column
+
+
+def test_editor_delete_char(editorbot):
+    """Regression test for issue spyder-ide/spyder#12663."""
+    qtbot, editor = editorbot
+    text = "0123456789\nabcdefghij\n9876543210\njihgfedcba\n"
+    editor.set_text(text)
+    expected_column = 2
+    cursor = editor.textCursor()
+    cursor.setPosition(expected_column)
+    editor.setTextCursor(cursor)
+    for line in range(3):
+        qtbot.keyPress(editor, Qt.Key_Delete)
+        assert editor.textCursor().columnNumber() == expected_column
+        qtbot.keyPress(editor, Qt.Key_Down)
+        assert editor.textCursor().columnNumber() == expected_column
+
+    for line in range(3):
+        qtbot.keyPress(editor, Qt.Key_Delete)
+        assert editor.textCursor().columnNumber() == expected_column
+        qtbot.keyPress(editor, Qt.Key_Up)
+        assert editor.textCursor().columnNumber() == expected_column
+
+
+# Fails in CI Linux tests, but not necessarily on all Linux installations
+@pytest.mark.skipif(sys.platform.startswith('linux'), reason='Fail on Linux')
+def test_editor_delete_selection(editorbot):
+    """Regression test for issue spyder-ide/spyder#12663."""
+    qtbot, editor = editorbot
+    text = "0123456789\nabcdefghij\n9876543210\njihgfedcba\n"
+    editor.set_text(text)
+    expected_column = 5
+    cursor = editor.textCursor()
+    cursor.setPosition(expected_column)
+    editor.setTextCursor(cursor)
+
+    # This first subtest does not trigger the original bug
+    for press in range(3):
+        qtbot.keyPress(editor, Qt.Key_Left, Qt.ShiftModifier)
+    expected_column -= 3
+    qtbot.keyPress(editor, Qt.Key_Delete)
+    assert editor.textCursor().columnNumber() == expected_column
+    qtbot.keyPress(editor, Qt.Key_Down)
+    assert editor.textCursor().columnNumber() == expected_column
+
+    # However, this second subtest does trigger the original bug
+    for press in range(3):
+        qtbot.keyPress(editor, Qt.Key_Right, Qt.ShiftModifier)
+    qtbot.keyPress(editor, Qt.Key_Delete)
+    assert editor.textCursor().columnNumber() == expected_column
+    qtbot.keyPress(editor, Qt.Key_Up)
+    assert editor.textCursor().columnNumber() == expected_column
+
+
+def test_qtbug35861(qtbot):
+    """This test will detect if upstream QTBUG-35861 is fixed.
+
+    If that happens, then the workarounds for spyder-ide/spyder#12663
+    can be removed. Such a fix would probably only happen in the most
+    recent Qt version however...
+
+    See also https://bugreports.qt.io/browse/QTBUG-35861
+    """
+    widget = QTextEdit()
+    qtbot.addWidget(widget)
+    widget.show()
+
+    cursor = widget.textCursor()
+    cursor.setPosition(0)
+    # Build the text from a single character since a non-fixed width
+    # font is used by default.
+    cursor.insertText("0000000000\n"*5)
+
+    expected_column = 5
+    cursor.setPosition(expected_column)
+    widget.setTextCursor(cursor)
+
+    assert widget.textCursor().columnNumber() == expected_column
+    for line in range(4):
+        qtbot.keyClick(widget, Qt.Key_Backspace)
+        assert widget.textCursor().columnNumber() == (expected_column - 1)
+        qtbot.keyClick(widget, Qt.Key_Down)
+        assert widget.textCursor().columnNumber() == expected_column
+
+    for line in range(4):
+        qtbot.keyClick(widget, Qt.Key_Backspace)
+        assert widget.textCursor().columnNumber() == (expected_column - 1)
+        qtbot.keyClick(widget, Qt.Key_Up)
+        assert widget.textCursor().columnNumber() == expected_column
+
+
 if __name__ == '__main__':
     pytest.main(['test_codeeditor.py'])