Repository: Textualize/textual

Issue:
Add method `move_cursor` to `DataTable`
The method `move_cursor` moves the cursor to a given position and it takes an optional parameter `animate=False` to animate the change.
Moving cursor sideways in the DataTable should animate.
With #2464, I broke the smooth animation you get when moving the cursor sideways in a `DataTable`.
This behaviour should be restored.


Discussion:

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
@@ -309,6 +309,11 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
     cursor_coordinate: Reactive[Coordinate] = Reactive(
         Coordinate(0, 0), repaint=False, always_update=True
     )
+    """Current cursor [`Coordinate`][textual.coordinate.Coordinate].
+
+    This can be set programmatically or changed via the method
+    [`move_cursor`][textual.widgets.DataTable.move_cursor].
+    """
     hover_coordinate: Reactive[Coordinate] = Reactive(
         Coordinate(0, 0), repaint=False, always_update=True
     )
@@ -950,7 +955,41 @@ def watch_cursor_coordinate(
             elif self.cursor_type == "column":
                 self.refresh_column(old_coordinate.column)
                 self._highlight_column(new_coordinate.column)
-            self._scroll_cursor_into_view()
+            # 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)
+
+    def move_cursor(
+        self,
+        *,
+        row: int | None = None,
+        column: int | None = None,
+        animate: bool = False,
+    ) -> None:
+        """Move the cursor to the given position.
+
+        Example:
+            ```py
+            datatable = app.query_one(DataTable)
+            datatable.move_cursor(row=4, column=6)
+            # datatable.cursor_coordinate == Coordinate(4, 6)
+            datatable.move_cursor(row=3)
+            # datatable.cursor_coordinate == Coordinate(3, 6)
+            ```
+
+        Args:
+            row: The new row to move the cursor to.
+            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
+        self._scroll_cursor_into_view(animate=animate)
 
     def _highlight_coordinate(self, coordinate: Coordinate) -> None:
         """Apply highlighting to the cell at the coordinate, and post event."""
@@ -2052,7 +2091,6 @@ def action_page_down(self) -> None:
             self.cursor_coordinate = Coordinate(
                 row_index + rows_to_scroll - 1, column_index
             )
-            self._scroll_cursor_into_view()
         else:
             super().action_page_down()
 
@@ -2076,7 +2114,6 @@ def action_page_up(self) -> None:
             self.cursor_coordinate = Coordinate(
                 row_index - rows_to_scroll + 1, column_index
             )
-            self._scroll_cursor_into_view()
         else:
             super().action_page_up()
 
@@ -2087,7 +2124,6 @@ def action_scroll_home(self) -> None:
         if self.show_cursor and (cursor_type == "cell" or cursor_type == "row"):
             row_index, column_index = self.cursor_coordinate
             self.cursor_coordinate = Coordinate(0, column_index)
-            self._scroll_cursor_into_view()
         else:
             super().action_scroll_home()
 
@@ -2098,7 +2134,6 @@ def action_scroll_end(self) -> None:
         if self.show_cursor and (cursor_type == "cell" or cursor_type == "row"):
             row_index, column_index = self.cursor_coordinate
             self.cursor_coordinate = Coordinate(self.row_count - 1, column_index)
-            self._scroll_cursor_into_view()
         else:
             super().action_scroll_end()
 
@@ -2107,7 +2142,6 @@ def action_cursor_up(self) -> None:
         cursor_type = self.cursor_type
         if self.show_cursor and (cursor_type == "cell" or cursor_type == "row"):
             self.cursor_coordinate = self.cursor_coordinate.up()
-            self._scroll_cursor_into_view()
         else:
             # If the cursor doesn't move up (e.g. column cursor can't go up),
             # then ensure that we instead scroll the DataTable.
@@ -2118,7 +2152,6 @@ def action_cursor_down(self) -> None:
         cursor_type = self.cursor_type
         if self.show_cursor and (cursor_type == "cell" or cursor_type == "row"):
             self.cursor_coordinate = self.cursor_coordinate.down()
-            self._scroll_cursor_into_view()
         else:
             super().action_scroll_down()
 


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
@@ -996,23 +996,34 @@ def test_key_string_lookup():
 async def test_scrolling_cursor_into_view():
     """Regression test for https://github.com/Textualize/textual/issues/2459"""
 
-    class TableApp(App):
+    class ScrollingApp(DataTableApp):
         CSS = "DataTable { height: 100%; }"
 
-        def compose(self):
-            yield DataTable()
-
-        def on_mount(self) -> None:
-            table = self.query_one(DataTable)
-            table.add_column("n")
-            table.add_rows([(n,) for n in range(300)])
-
         def key_c(self):
             self.query_one(DataTable).cursor_coordinate = Coordinate(200, 0)
 
-    app = TableApp()
+    app = ScrollingApp()
 
     async with app.run_test() as pilot:
+        table = app.query_one(DataTable)
+        table.add_column("n")
+        table.add_rows([(n,) for n in range(300)])
+
         await pilot.press("c")
-        await pilot.pause()
-        assert app.query_one(DataTable).scroll_y > 100
+        assert table.scroll_y > 100
+
+
+async def test_move_cursor():
+    app = DataTableApp()
+
+    async with app.run_test():
+        table = app.query_one(DataTable)
+        table.add_columns(*"These are some columns in your nice table".split())
+        table.add_rows(["These are some columns in your nice table".split()] * 10)
+
+        table.move_cursor(row=4, column=6)
+        assert table.cursor_coordinate == Coordinate(4, 6)
+        table.move_cursor(row=3)
+        assert table.cursor_coordinate == Coordinate(3, 6)
+        table.move_cursor(column=3)
+        assert table.cursor_coordinate == Coordinate(3, 3)