Repository: Textualize/textual

Issue:
DataTable move_cursor animate doesn't do anything?
While working on:
- https://github.com/Textualize/textual/discussions/3606

I wanted to check `animate` for `move_cursor` behaves correctly, but it doesn't seem to do anything?


---

Repro:


```py
from random import randint
from textual.app import App, ComposeResult
from textual.widgets import DataTable, Button
from textual.widgets._data_table import RowKey
from textual.containers import Horizontal
from textual import on
from textual.reactive import Reactive, reactive

ROWS = [
    ("lane", "swimmer", "country", "time"),
    (4, "Joseph Schooling", "Singapore", 50.39),
    (2, "Michael Phelps", "United States", 51.14),
    (5, "Chad le Clos", "South Africa", 51.14),
    (6, "László Cseh", "Hungary", 51.14),
    (3, "Li Zhuhao", "China", 51.26),
    (8, "Mehdy Metella", "France", 51.58),
    (7, "Tom Shields", "United States", 51.73),
    (1, "Aleksandr Sadovnikov", "Russia", 51.84),
    (10, "Darren Burns", "Scotland", 51.84),
]


class TableApp(App):
    CSS = """
    DataTable {
    height: 80%;
    }
    """

    keys: Reactive[RowKey] = reactive([])

    def compose(self) -> ComposeResult:
        yield DataTable(cursor_type="row")
        with Horizontal():
            yield Button("True")
            yield Button("False")

    @on(Button.Pressed)
    def goto(self, event: Button.Pressed):
        row  = randint(0, len(self.keys) - 1)
        print(row)

        animate = str(event.button.label) == "True"
        table = self.query_one(DataTable)
        table.move_cursor(row=row, animate=animate)

    def on_mount(self) -> None:
        table = self.query_one(DataTable)
        table.add_columns(*ROWS[0])
        for _ in range(20):
            keys = table.add_rows(ROWS[1:])
            self.keys.extend(keys)


app = TableApp()
if __name__ == "__main__":
    app.run()
```

---
<!-- This is valid Markdown, do not quote! -->
# Textual Diagnostics

## Versions

| Name    | Value  |
|---------|--------|
| Textual | 0.39.0 |
| Rich    | 13.3.3 |

## Python

| Name           | Value                                             |
|----------------|---------------------------------------------------|
| Version        | 3.11.4                                            |
| Implementation | CPython                                           |
| Compiler       | Clang 14.0.0 (clang-1400.0.29.202)                |
| Executable     | /Users/dave/.pyenv/versions/3.11.4/bin/python3.11 |

## Operating System

| Name    | Value                                                                                                   |
|---------|---------------------------------------------------------------------------------------------------------|
| System  | Darwin                                                                                                  |
| Release | 21.6.0                                                                                                  |
| Version | Darwin Kernel Version 21.6.0: Thu Mar  9 20:08:59 PST 2023; root:xnu-8020.240.18.700.8~1/RELEASE_X86_64 |

## Terminal

| Name                 | Value           |
|----------------------|-----------------|
| Terminal Application | vscode (1.85.0) |
| TERM                 | xterm-256color  |
| COLORTERM            | truecolor       |
| FORCE_COLOR          | *Not set*       |
| NO_COLOR             | *Not set*       |

## Rich Console options

| Name           | Value               |
|----------------|---------------------|
| size           | width=90, height=68 |
| legacy_windows | False               |
| min_width      | 1                   |
| max_width      | 90                  |
| is_terminal    | True                |
| encoding       | utf-8               |
| max_height     | 68                  |
| justify        | None                |
| overflow       | None                |
| no_wrap        | False               |
| highlight      | None                |
| markup         | None                |
| height         | None                |




Discussion:
We found the following entries in the [FAQ](https://textual.textualize.io/FAQ/) which you may find helpful:


- [Why doesn't the `DataTable` scroll programmatically?](https://textual.textualize.io/FAQ/#why-doesn't-the-`datatable`-scroll-programmatically)
- [Why do some key combinations never make it to my app?](https://textual.textualize.io/FAQ/#why-do-some-key-combinations-never-make-it-to-my-app)
- [Why doesn't Textual look good on macOS?](https://textual.textualize.io/FAQ/#why-doesn't-textual-look-good-on-macos)

Feel free to close this issue if you found an answer in the FAQ. Otherwise, please give us a little time to review.

This is an automated reply, generated by [FAQtory](https://github.com/willmcgugan/faqtory)
I thought maybe because neither `duration` nor `speed` were set, but seems to default:
https://github.com/Textualize/textual/blob/82d6e3e05f87939166364e805f35e8260f395c9d/src/textual/widget.py#L1860-L1863

Using `_scroll_cursor_into_view(animate=True)` directly works fine.
So far, it looks like some interesting interaction between `call_after_refresh` and `_scroll_cursor_into_view` inside `move_cursor`.
Turns out it was much more interesting than that.
It's an issue with ordering because inside `move_cursor` we assign to `cursor_coordinate` and its watch method also wants to scroll into view.
Supposedly, there's a comment explaining that we do something inside said watch method so that scrolling from `move_cursor` (which is potentially animated) happens first, but that's not enough.

I _think_ it's because synchronous watch methods are called immediately so the scroll from `watch_cursor_coordinate` gets scheduled before the scroll from `move_cursor`.
I'll double-check & PR a fix!

Resolving patch:
diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py
--- a/src/textual/widgets/_data_table.py
+++ b/src/textual/widgets/_data_table.py
@@ -1094,7 +1094,7 @@ def watch_cursor_coordinate(
                 self._highlight_column(new_coordinate.column)
             # If the coordinate was changed via `move_cursor`, give priority to its
             # scrolling because it may be animated.
-            self.call_next(self._scroll_cursor_into_view)
+            self.call_after_refresh(self._scroll_cursor_into_view)
 
     def move_cursor(
         self,
@@ -1119,20 +1119,24 @@ def move_cursor(
             column: The new column to move the cursor to.
             animate: Whether to animate the change of coordinates.
         """
+
         cursor_row, cursor_column = self.cursor_coordinate
         if row is not None:
             cursor_row = row
         if column is not None:
             cursor_column = column
         destination = Coordinate(cursor_row, cursor_column)
-        self.cursor_coordinate = destination
 
         # Scroll the cursor after refresh to ensure the virtual height
         # (calculated in on_idle) has settled. If we tried to scroll before
         # the virtual size has been set, then it might fail if we added a bunch
         # of rows then tried to immediately move the cursor.
+        # We do this before setting `cursor_coordinate` because its watcher will also
+        # schedule a call to `_scroll_cursor_into_view` without optionally animating.
         self.call_after_refresh(self._scroll_cursor_into_view, animate=animate)
 
+        self.cursor_coordinate = destination
+
     def _highlight_coordinate(self, coordinate: Coordinate) -> None:
         """Apply highlighting to the cell at the coordinate, and post event."""
         self.refresh_coordinate(coordinate)


Test patch:
diff --git a/tests/test_data_table.py b/tests/test_data_table.py
--- a/tests/test_data_table.py
+++ b/tests/test_data_table.py
@@ -1382,3 +1382,41 @@ async def test_cell_padding_cannot_be_negative():
         assert table.cell_padding == 0
         table.cell_padding = -1234
         assert table.cell_padding == 0
+
+
+async def test_move_cursor_respects_animate_parameter():
+    """Regression test for https://github.com/Textualize/textual/issues/3840
+
+    Make sure that the call to `_scroll_cursor_into_view` from `move_cursor` happens
+    before the call from the watcher method from `cursor_coordinate`.
+    The former should animate because we call it with `animate=True` whereas the later
+    should not.
+    """
+
+    scrolls = []
+
+    class _DataTable(DataTable):
+        def _scroll_cursor_into_view(self, animate=False):
+            nonlocal scrolls
+            scrolls.append(animate)
+            super()._scroll_cursor_into_view(animate)
+
+    class LongDataTableApp(App):
+        def compose(self):
+            yield _DataTable()
+
+        def on_mount(self):
+            dt = self.query_one(_DataTable)
+            dt.add_columns("one", "two")
+            for _ in range(100):
+                dt.add_row("one", "two")
+
+        def key_s(self):
+            table = self.query_one(_DataTable)
+            table.move_cursor(row=99, animate=True)
+
+    app = LongDataTableApp()
+    async with app.run_test() as pilot:
+        await pilot.press("s")
+
+    assert scrolls == [True, False]