Repository: Textualize/textual

Issue:
TextArea swallows lines after the Undo event
Hi all,

I was experimenting with the TextArea widget and its Undo/Redo capabilities when I noticed some flaws with the Undo event.

When I select more than one line of any text and press an arbitrary key effectively changing this text with a symbol, the Ctrl+Z replaces the symbol with the previous text, but it chops several lines at the end of the text. I've noticed that it chops a previously replaced number of lines minus one. For example, we have a text of 10 lines, then select three (at the very beginning or in the middle of the text, it does not matter), replace them with a symbol, and strike the Undo. We will lose two lines at the end of the text (9th and 10th lines respectively). There is also an overlapping scenario. When I select 7 lines and replace them, there are 4 lines: one line with a new symbol, and three original lines at the end. 7 > 4. The Undo will return the first 4 lines of the original text, replacing these 4 lines altogether.

Please, find my code via the https://pastebin.com/KTrr00QR. Also, my diagnostic info is below.

# Textual Diagnostics

## Versions

| Name    | Value  |
|---------|--------|
| Textual | 0.52.1 |
| Rich    | 13.3.5 |

## Python

| Name           | Value                                 |
|----------------|---------------------------------------|
| Version        | 3.11.1                                |
| Implementation | CPython                               |
| Compiler       | MSC v.1934 64 bit (AMD64)             |
| Executable     | C:\Program Files\Python311\python.exe |

## Operating System

| Name    | Value      |
|---------|------------|
| System  | Windows    |
| Release | 10         |
| Version | 10.0.22631 |

## Terminal

| Name                 | Value            |
|----------------------|------------------|
| Terminal Application | Windows Terminal |
| TERM                 | *Not set*        |
| COLORTERM            | *Not set*        |
| FORCE_COLOR          | *Not set*        |
| NO_COLOR             | *Not set*        |

## Rich Console options

| Name           | Value                |
|----------------|----------------------|
| size           | width=209, height=61 |
| legacy_windows | False                |
| min_width      | 1                    |
| max_width      | 209                  |
| is_terminal    | True                 |
| encoding       | utf-8                |
| max_height     | 61                   |
| justify        | None                 |
| overflow       | None                 |
| no_wrap        | False                |
| highlight      | None                 |
| markup         | None                 |
| height         | None                 |



Discussion:
I've tried to follow what you've reported above, but I can't seem to reproduce what you're seeing. Just to be clear:

1. I select 3 lines.
2. I type the letter `a`, thus replacing that selection with that one character.
3. I press <kbd>ctrl</kbd>+<kbd>z</kbd>

As you'll see below, nothing untoward seems to happen:

https://github.com/Textualize/textual/assets/28237/e557ce58-42d8-4825-a7ee-6c03c005a605

Do you think you could give slightly more detailed directions so we can exactly reproduce what you're doing?
Hi, thanks for your comment. Your video shows the exact behavior that I expected to see. Please, see also mine which shows what I have with the same steps (selection, typing, undoing).

https://github.com/Textualize/textual/assets/1499024/4957b3ef-e8a6-4bbd-9618-6c863acb8dad


Okay, I think I can recreate now:

https://github.com/Textualize/textual/assets/28237/54571da9-39a2-4fdc-9550-9741699bdc04

The issue seems to be to do with selecting "backwards" and/or from part way through a line. As well as now managing to drop a line on undo, I can also cause the application to crash by attempting to cursor past the end of the document.
Reducing this to a test:

```python
from textual.app import App, ComposeResult
from textual.widgets import TextArea
from textual.widgets.text_area import Selection

TEST_TEXT = "\n".join(f"01234567890 - {n}" for n in range(10))
TEST_SELECTION = Selection((2, 5), (0, 5))
TEST_SELECTED_TEXT = "567890 - 0\n01234567890 - 1\n01234"
TEST_REPLACEMENT = "A"
TEST_RESULT_TEXT = TEST_TEXT.replace(TEST_SELECTED_TEXT, TEST_REPLACEMENT)

class TextAreaApp(App[None]):

    def compose(self) -> ComposeResult:
        yield TextArea(TEST_TEXT)


async def test_issue_4301_reproduction() -> None:

    async with (app := TextAreaApp()).run_test() as pilot:
        assert app.query_one(TextArea).text == TEST_TEXT
        app.query_one(TextArea).selection = TEST_SELECTION
        assert app.query_one(TextArea).selected_text == TEST_SELECTED_TEXT
        await pilot.press("A")
        assert app.query_one(TextArea).text == TEST_RESULT_TEXT
        await pilot.press("ctrl+z")
        assert app.query_one(TextArea).text == TEST_TEXT
```

this is the automated equivalent of the above and it passes without an issue. This then suggests that the issue isn't with the underlying document -- which remains intact -- but is instead the display of the document.
Furthermore, if I turn the above into a quick test application, like this:

```python
from textual.app import App, ComposeResult
from textual.widgets import TextArea
from textual.widgets.text_area import Selection

TEST_TEXT = "\n".join(f"01234567890 - {n}" for n in range(10))
TEST_SELECTION = Selection((2, 5), (0, 5))

class TextAreaApp(App[None]):

    def compose(self) -> ComposeResult:
        yield TextArea(TEST_TEXT)

    def on_mount(self) -> None:
        self.query_one(TextArea).selection = TEST_SELECTION

if __name__ == "__main__":
    TextAreaApp().run()
```

type `a`, then undo, the problem appears and attempting to go past the end of the document with the down arrow causes the crash mentioned earlier. On the other hand if I swap this line:

```python
TEST_SELECTION = Selection((2, 5), (0, 5))
```

for this:

```python
TEST_SELECTION = Selection((0, 5), (2, 5))
```

everything works as expected. The issue does seem to be down to a *backward* selection, replaced, and then undone.
@davep I've not looked into this in much detail yet, but perhaps the fix in cdd08ed just also needs to be applied to the undo?
So I don't forget: a failing test can be made of this by adding the following to the end of the test:

```python
        await pilot.press(*(["down"] * 10))
```
@TomJGooding Thanks, I'll take a look.

Resolving patch:
diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py
--- a/src/textual/widgets/_text_area.py
+++ b/src/textual/widgets/_text_area.py
@@ -1291,7 +1291,7 @@ def _undo_batch(self, edits: Sequence[Edit]) -> None:
             return
 
         old_gutter_width = self.gutter_width
-        minimum_from = edits[-1].from_location
+        minimum_from = edits[-1].top
         maximum_old_end = (0, 0)
         maximum_new_end = (0, 0)
         for edit in reversed(edits):
@@ -1304,7 +1304,7 @@ def _undo_batch(self, edits: Sequence[Edit]) -> None:
             if end_location > maximum_old_end:
                 maximum_old_end = end_location
             if edit.to_location > maximum_new_end:
-                maximum_new_end = edit.to_location
+                maximum_new_end = edit.bottom
 
         new_gutter_width = self.gutter_width
         if old_gutter_width != new_gutter_width:


Test patch:
diff --git a/tests/text_area/test_issue_4301.py b/tests/text_area/test_issue_4301.py
new file mode 100644
--- /dev/null
+++ b/tests/text_area/test_issue_4301.py
@@ -0,0 +1,38 @@
+from itertools import product
+
+import pytest
+
+from textual.app import App, ComposeResult
+from textual.widgets import TextArea
+from textual.widgets.text_area import Selection
+
+TEST_TEXT = "\n".join(f"01234567890 - {n}" for n in range(10))
+TEST_SELECTED_TEXT = "567890 - 0\n01234567890 - 1\n01234"
+
+
+class TextAreaApp(App[None]):
+
+    def compose(self) -> ComposeResult:
+        yield TextArea(TEST_TEXT)
+
+
+@pytest.mark.parametrize(
+    "selection, edit",
+    product(
+        [Selection((0, 5), (2, 5)), Selection((2, 5), (0, 5))],
+        ["A", "delete", "backspace"],
+    ),
+)
+async def test_issue_4301_reproduction(selection: Selection, edit: str) -> None:
+    """Test https://github.com/Textualize/textual/issues/4301"""
+
+    async with (app := TextAreaApp()).run_test() as pilot:
+        assert app.query_one(TextArea).text == TEST_TEXT
+        app.query_one(TextArea).selection = selection
+        assert app.query_one(TextArea).selected_text == TEST_SELECTED_TEXT
+        await pilot.press(edit)
+        await pilot.press("ctrl+z")
+        assert app.query_one(TextArea).text == TEST_TEXT
+        # Note that the real test here is that the following code doesn't
+        # result in a crash; everything above should really be a given.
+        await pilot.press(*(["down"] * 10))