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                |



Comments:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e901d67b4..50dc3f19e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](http://keepachangelog.com/)
 and this project adheres to [Semantic Versioning](http://semver.org/).
 
+## Unreleased
+
+### Fixed
+
+- Parameter `animate` from `DataTable.move_cursor` was being ignored https://github.com/Textualize/textual/issues/3840
+
 ## [0.47.1] - 2023-01-05
 
 ### Fixed
diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py
index eef090bfb..e2a5a0022 100644
--- a/src/textual/widgets/_data_table.py
+++ b/src/textual/widgets/_data_table.py
@@ -1094,7 +1094,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
                 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 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
             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
index 70b6ffe47..e09a9c3f9 100644
--- 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]