Repository: spyder-ide/spyder

Issue:
Shift-Tab does not remove final indent level for non 4-multiple indents (e.g. continuations)
## Description

If I have the cursor on an indented line or (particularly troublesome) a block of code selected, that doesn't have indent perfectly a multiple of 4, e.g. continuation lines/blocks, Shift-Tab will not remove the last indent level of >4 spaces and instead do nothing. Instead, I must go to every line and delete the last 1-3 spaces manually, which is rather tedious and error prone and takes far more time than tapping shift-tab one more time. A relatively common case is when a block picks up an undesired indent from the block above, or I otherwise want to shift it all the way to the left quickly, and I cannot. I don't see an immediate reason why this wouldn't be the desired behavior, as it is in most (most all?) other editors and applications, but perhaps there is some use case I'm missing.

**What steps will reproduce the problem?**

1. Copy a block of code with a non mod 4 = 0 indent. E.g.
```
       (1.2e-5, 2.0e8),
       (1.6e-5, 2.3e8),
       (1.8e-5, 1.5e7)]
```
2. Select the block (or place the cursor at the beginning of a line)
3. Hit shift-tab until it stops before the left margin

**What is the expected output? What do you see instead?**
For the above example
Expected:
```
(1.2e-5, 2.0e8),
(1.6e-5, 2.3e8),
(1.8e-5, 1.5e7)]
```
Actual:
```
   (1.2e-5, 2.0e8),
   (1.6e-5, 2.3e8),
   (1.8e-5, 1.5e7)]
```

**Please provide any additional information below**

As usual, occurred on multiple systems, installs and OS versions so clearly not specific to that.

## Version and main components

* Spyder Version: 3.2.4 
* Python Version: 3.6.3
* Qt Versions: 5.6.2, PyQt5 5.6 on Windows 8.1 Pro x64 and Win 10 Ent x64

## Dependencies
```
pyflakes >=0.6.0 :  1.6.0 (OK)
pycodestyle >=2.3:  2.3.1 (OK)
pygments >=2.0   :  2.2.0 (OK)
pandas >=0.13.1  :  0.20.3 (OK)
numpy >=1.7      :  1.13.3 (OK)
sphinx >=0.6.6   :  1.6.3 (OK)
rope >=0.9.4     :  0.10.5 (OK)
jedi >=0.9.0     :  0.10.2 (OK)
nbconvert >=4.0  :  5.3.1 (OK)
sympy >=0.7.3    :  1.1.1 (OK)
cython >=0.21    :  0.26.1 (OK)
qtconsole >=4.2.0:  4.3.1 (OK)
IPython >=4.0    :  6.1.0 (OK)
pylint >=0.25    :  1.7.4 (OK)

```



Discussion:
Thanks for reporting. We'll take a look at this for Spyder 4.

@rlaverde, please work on this one.
Currently, Spyder just removes a fixed number of spaces `indent`.

I propose to change the unindent behavior to reduce the number of leading spaces to the next smaller `n * indent`. This way, a first unindent will automatically correct odd-number indents. This also covers the above case with `n = 0`.

*Example:* Assuming a regular `indent = 4`, the number of leading spaces for two consecutive unindents would be:

- current behavior: `5 -> 1 -> 1`
- current behavior + request above: `5 -> 1 -> 0`
- my proposal: `5 -> 4 -> 0`
Sounds even better, @timhoffm !
@timhoffm, do you want to work on this one?
@ccordoba12 Yep, can do this.
Sorry, not having time to work on this in the coming few weeks. If someone else wants to tackle this, you're welcome. Otherwise I'll pick this up later on.

*Note:* For a single line, the "unindent to next regular tab position" is a good and obvious choice. I'm not yet clear, what to do for a selection of multiple lines. Probably we should find the least indented line, determine how many spaces need to be removed to "unindent to next regular tab position" for this line and apply this unindent to all lines. Do you agree?




Sure, I think it's a good approach.
Sounds great. Thanks much for working on this.

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
@@ -2643,7 +2643,21 @@ def unindent(self, force=False):
         force=True: unindent even if cursor is not a the beginning of the line
         """
         if self.has_selected_text():
-            self.remove_prefix(self.indent_chars)
+            if self.indent_chars == "\t":
+                # Tabs, remove one tab
+                self.remove_prefix(self.indent_chars)
+            else:
+                # Spaces
+                space_count = len(self.indent_chars)
+                leading_spaces = self.__spaces_for_prefix()
+                remainder = leading_spaces % space_count
+                if remainder:
+                    # Get block on "space multiple grid".
+                    # See spyder-ide/spyder#5734.
+                    self.remove_prefix(" "*remainder)
+                else:
+                    # Unindent one space multiple
+                    self.remove_prefix(self.indent_chars)
         else:
             leading_text = self.get_text('sol', 'cursor')
             if force or not leading_text.strip() \


Test patch:
diff --git a/spyder/plugins/editor/widgets/tests/test_indentation.py b/spyder/plugins/editor/widgets/tests/test_indentation.py
--- a/spyder/plugins/editor/widgets/tests/test_indentation.py
+++ b/spyder/plugins/editor/widgets/tests/test_indentation.py
@@ -29,6 +29,15 @@ def make_indent(editor, single_line=True, start_line=1):
     return to_text_string(text)
 
 
+def make_unindent(editor, single_line=True, start_line=1):
+    """Unindent and return code."""
+    editor.go_to_line(start_line)
+    if not single_line:
+        editor.moveCursor(QTextCursor.End, mode=QTextCursor.KeepAnchor)
+    editor.unindent()
+    text = editor.toPlainText()
+    return to_text_string(text)
+
 # --- Fixtures
 # -----------------------------------------------------------------------------
 @pytest.fixture
@@ -56,14 +65,15 @@ def test_single_line_indent(code_editor_indent_bot):
             "print(self.b)\n"
             "\n"
             )
+    expected = ("class a():\n"
+                "  self.b = 1\n"
+                "print(self.b)\n"
+                "\n"
+                )
     editor.set_text(text)
     # Indent line without spaces
-    text = make_indent(editor, start_line=2)
-    assert text == ("class a():\n"
-                    "  self.b = 1\n"
-                    "print(self.b)\n"
-                    "\n"
-                    )
+    new_text = make_indent(editor, start_line=2)
+    assert new_text == expected
 
 
 def test_selection_indent(code_editor_indent_bot):
@@ -74,14 +84,16 @@ def test_selection_indent(code_editor_indent_bot):
             "print(self.b)\n"
             "\n"
             )
+    expected = ("class a():\n"
+                "  self.b = 1\n"
+                "  print(self.b)\n"
+                "  \n"
+                )
+
     editor.set_text(text)
     # Toggle manually commented code
-    text = make_indent(editor, single_line=False, start_line=2)
-    assert text == ("class a():\n"
-                    "  self.b = 1\n"
-                    "  print(self.b)\n"
-                    "  \n"
-                    )
+    new_text = make_indent(editor, single_line=False, start_line=2)
+    assert new_text == expected
 
 
 def test_fix_indentation(code_editor_indent_bot):
@@ -114,5 +126,97 @@ def test_fix_indentation(code_editor_indent_bot):
     assert editor.document().isModified()
 
 
+def test_single_line_unindent(code_editor_indent_bot):
+    """Test unindentation in a single line."""
+    editor, qtbot = code_editor_indent_bot
+    text = ("class a():\n"
+            "  self.b = 1\n"
+            "print(self.b)\n"
+            "\n"
+            )
+    expected = ("class a():\n"
+                "self.b = 1\n"
+                "print(self.b)\n"
+                "\n"
+                )
+    editor.set_text(text)
+    # Indent line without spaces
+    new_text = make_unindent(editor, start_line=2)
+    assert new_text == expected
+
+
+def test_selection_unindent(code_editor_indent_bot):
+    """Test unindentation with selection of more than one line."""
+    editor, qtbot = code_editor_indent_bot
+    text = ("class a():\n"
+            "  self.b = 1\n"
+            "  print(self.b)\n"
+            "  \n"
+            )
+    expected = ("class a():\n"
+                "self.b = 1\n"
+                "print(self.b)\n"
+                "\n"
+                )
+    editor.set_text(text)
+    # Toggle manually commented code
+    new_text = make_unindent(editor, single_line=False, start_line=2)
+    assert new_text == expected
+
+
+def test_single_line_unindent_to_grid(code_editor_indent_bot):
+    """Test unindentation in a single line."""
+    editor, qtbot = code_editor_indent_bot
+    text = ("class a():\n"
+            "   self.b = 1\n"
+            "print(self.b)\n"
+            "\n"
+            )
+    expected = ("class a():\n"
+                "  self.b = 1\n"
+                "print(self.b)\n"
+                "\n"
+                )
+    editor.set_text(text)
+    # Indent line without spaces
+    new_text = make_unindent(editor, start_line=2)
+    assert new_text == expected
+
+    expected2 = ("class a():\n"
+                 "self.b = 1\n"
+                 "print(self.b)\n"
+                 "\n"
+                 )
+    new_text2 = make_unindent(editor, start_line=2)
+    assert new_text2 == expected2
+
+
+def test_selection_unindent_to_grid(code_editor_indent_bot):
+    """Test unindentation with selection of more than one line."""
+    editor, qtbot = code_editor_indent_bot
+    text = ("class a():\n"
+            "   self.b = 1\n"
+            "   print(self.b)\n"
+            "\n"
+            )
+    expected = ("class a():\n"
+                "  self.b = 1\n"
+                "  print(self.b)\n"
+                "\n"
+                )
+    editor.set_text(text)
+    # Toggle manually commented code
+    new_text = make_unindent(editor, single_line=False, start_line=2)
+    assert new_text == expected
+
+    expected2 = ("class a():\n"
+                 "self.b = 1\n"
+                 "print(self.b)\n"
+                 "\n"
+                 )
+    new_text2 = make_unindent(editor, single_line=False, start_line=2)
+    assert new_text2 == expected2
+
+
 if __name__ == "__main__":
     pytest.main()