Repository: Textualize/textual

Issue:
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:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fbc8be178..69042e4a8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,11 +10,19 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 ### Fixed
 
 - Fixed crash when creating a `DirectoryTree` starting anywhere other than `.`
+- Fixed line drawing in `Tree` when `Tree.show_root` is `True` https://github.com/Textualize/textual/issues/2397
+- Fixed line drawing in `Tree` not marking branches as selected when first getting focus https://github.com/Textualize/textual/issues/2397
 
 ### Changed
 
 - The DataTable cursor is now scrolled into view when the cursor coordinate is changed programmatically https://github.com/Textualize/textual/issues/2459
 - run_worker exclusive parameter is now `False` by default https://github.com/Textualize/textual/pull/2470
+- Added `always_update` as an optional argument for `reactive.var`
+
+
+### Added
+
+- Method `DataTable.move_cursor` https://github.com/Textualize/textual/issues/2472
 
 ## [0.23.0] - 2023-05-03
 
diff --git a/docs/widgets/data_table.md b/docs/widgets/data_table.md
index 215d0541b..6d2100a26 100644
--- a/docs/widgets/data_table.md
+++ b/docs/widgets/data_table.md
@@ -59,9 +59,9 @@ If you want to change the table based solely on coordinates, you can use the [co
 
 ### Cursors
 
-The coordinate of the cursor is exposed via the `cursor_coordinate` reactive attribute.
+The coordinate of the cursor is exposed via the [`cursor_coordinate`][textual.widgets.DataTable.cursor_coordinate] reactive attribute.
 Three types of cursors are supported: `cell`, `row`, and `column`.
-Change the cursor type by assigning to the `cursor_type` reactive attribute.
+Change the cursor type by assigning to the [`cursor_type`][textual.widgets.DataTable.cursor_type] reactive attribute.
 
 === "Column Cursor"
 
diff --git a/src/textual/reactive.py b/src/textual/reactive.py
index 08259200d..998d4079b 100644
--- a/src/textual/reactive.py
+++ b/src/textual/reactive.py
@@ -76,11 +76,13 @@ class Reactive(Generic[ReactiveType]):
     def var(
         cls,
         default: ReactiveType | Callable[[], ReactiveType],
+        always_update: bool = False,
     ) -> Reactive:
         """A reactive variable that doesn't update or layout.
 
         Args:
             default: A default value or callable that returns a default.
+            always_update: Call watchers even when the new value equals the old value.
 
         Returns:
             A Reactive descriptor.
@@ -326,18 +328,21 @@ class var(Reactive[ReactiveType]):
     Args:
         default: A default value or callable that returns a default.
         init: Call watchers on initialize (post mount).
+        always_update: Call watchers even when the new value equals the old value.
     """
 
     def __init__(
         self,
         default: ReactiveType | Callable[[], ReactiveType],
         init: bool = True,
+        always_update: bool = False,
     ) -> None:
         super().__init__(
             default,
             layout=False,
             repaint=False,
             init=init,
+            always_update=always_update,
         )
 
 
diff --git a/src/textual/types.py b/src/textual/types.py
index dd681f657..1b5d3ea25 100644
--- a/src/textual/types.py
+++ b/src/textual/types.py
@@ -7,11 +7,13 @@ from ._context import NoActiveAppError
 from ._types import CallbackType, MessageTarget, WatchCallbackType
 from .actions import ActionParseResult
 from .css.styles import RenderStyles
+from .widgets._data_table import CursorType
 
 __all__ = [
     "ActionParseResult",
     "Animatable",
     "CallbackType",
+    "CursorType",
     "EasingFunction",
     "MessageTarget",
     "NoActiveAppError",
diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py
index 5991c98db..0f40552be 100644
--- a/src/textual/widgets/_data_table.py
+++ b/src/textual/widgets/_data_table.py
@@ -38,6 +38,7 @@ CellCacheKey: TypeAlias = (
 LineCacheKey: TypeAlias = "tuple[int, int, int, int, Coordinate, Coordinate, Style, CursorType, bool, int, PseudoClasses]"
 RowCacheKey: TypeAlias = "tuple[RowKey, int, Style, Coordinate, Coordinate, CursorType, bool, bool, int, PseudoClasses]"
 CursorType = Literal["cell", "row", "column", "none"]
+"""The legal types of cursors for [`DataTable.cursor_type`][textual.widgets.DataTable.cursor_type]."""
 CellType = TypeVar("CellType")
 
 CELL_X_PADDING = 2
@@ -304,14 +305,21 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
     zebra_stripes = Reactive(False)
     header_height = Reactive(1)
     show_cursor = Reactive(True)
-    cursor_type = Reactive("cell")
+    cursor_type: Reactive[CursorType] = Reactive[CursorType]("cell")
+    """The type of the cursor of the `DataTable`."""
 
     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
     )
+    """The coordinate of the `DataTable` that is being hovered."""
 
     class CellHighlighted(Message, bubble=True):
         """Posted when the cursor moves to highlight a new cell.
@@ -950,7 +958,41 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
             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 +2094,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
             self.cursor_coordinate = Coordinate(
                 row_index + rows_to_scroll - 1, column_index
             )
-            self._scroll_cursor_into_view()
         else:
             super().action_page_down()
 
@@ -2076,7 +2117,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
             self.cursor_coordinate = Coordinate(
                 row_index - rows_to_scroll + 1, column_index
             )
-            self._scroll_cursor_into_view()
         else:
             super().action_page_up()
 
@@ -2087,7 +2127,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
         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 +2137,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
         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 +2145,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
         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 +2155,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
         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()
 
diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py
index 91ffe794e..85255cf1e 100644
--- a/src/textual/widgets/_tree.py
+++ b/src/textual/widgets/_tree.py
@@ -429,7 +429,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
     """Show the root of the tree."""
     hover_line = var(-1)
     """The line number under the mouse pointer, or -1 if not under the mouse pointer."""
-    cursor_line = var(-1)
+    cursor_line = var(-1, always_update=True)
     """The line with the cursor, or -1 if no cursor."""
     show_guides = reactive(True)
     """Enable display of tree guide lines."""
@@ -976,8 +976,8 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
                 "tree--guides-selected", partial=True
             )
 
-            hover = self.root._hover
-            selected = self.root._selected and self.has_focus
+            hover = line.path[0]._hover
+            selected = line.path[0]._selected and self.has_focus
 
             def get_guides(style: Style) -> tuple[str, str, str, str]:
                 """Get the guide strings for a given style.


Test patch:
diff --git a/tests/test_data_table.py b/tests/test_data_table.py
index 0bba677cf..a00da77af 100644
--- 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)
diff --git a/tests/toggles/test_checkbox.py b/tests/toggles/test_checkbox.py
index 573953f23..095f87485 100644
--- a/tests/toggles/test_checkbox.py
+++ b/tests/toggles/test_checkbox.py
@@ -15,7 +15,9 @@ class CheckboxApp(App[None]):
         yield Checkbox(value=True, id="cb3")
 
     def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
-        self.events_received.append((event.checkbox.id, event.checkbox.value))
+        self.events_received.append(
+            (event.checkbox.id, event.checkbox.value, event.checkbox == event.control)
+        )
 
 
 async def test_checkbox_initial_state() -> None:
@@ -43,7 +45,7 @@ async def test_checkbox_toggle() -> None:
         ]
         await pilot.pause()
         assert pilot.app.events_received == [
-            ("cb1", True),
-            ("cb2", True),
-            ("cb3", False),
+            ("cb1", True, True),
+            ("cb2", True, True),
+            ("cb3", False, True),
         ]
diff --git a/tests/toggles/test_radiobutton.py b/tests/toggles/test_radiobutton.py
index dc32e8c0f..0dc4fda05 100644
--- a/tests/toggles/test_radiobutton.py
+++ b/tests/toggles/test_radiobutton.py
@@ -15,7 +15,13 @@ class RadioButtonApp(App[None]):
         yield RadioButton(value=True, id="rb3")
 
     def on_radio_button_changed(self, event: RadioButton.Changed) -> None:
-        self.events_received.append((event.radio_button.id, event.radio_button.value))
+        self.events_received.append(
+            (
+                event.radio_button.id,
+                event.radio_button.value,
+                event.radio_button == event.control,
+            )
+        )
 
 
 async def test_radio_button_initial_state() -> None:
@@ -51,7 +57,7 @@ async def test_radio_button_toggle() -> None:
         ]
         await pilot.pause()
         assert pilot.app.events_received == [
-            ("rb1", True),
-            ("rb2", True),
-            ("rb3", False),
+            ("rb1", True, True),
+            ("rb2", True, True),
+            ("rb3", False, True),
         ]
diff --git a/tests/toggles/test_radioset.py b/tests/toggles/test_radioset.py
index dc9de5b1b..95025bf37 100644
--- a/tests/toggles/test_radioset.py
+++ b/tests/toggles/test_radioset.py
@@ -11,7 +11,7 @@ class RadioSetApp(App[None]):
 
     def compose(self) -> ComposeResult:
         with RadioSet(id="from_buttons"):
-            yield RadioButton()
+            yield RadioButton(id="clickme")
             yield RadioButton()
             yield RadioButton(value=True)
         yield RadioSet("One", "True", "Three", id="from_strings")
@@ -36,6 +36,14 @@ async def test_radio_sets_initial_state():
         assert pilot.app.events_received == []
 
 
+async def test_click_sets_focus():
+    """Clicking within a radio set should set focus."""
+    async with RadioSetApp().run_test() as pilot:
+        assert pilot.app.screen.focused is None
+        await pilot.click("#clickme")
+        assert pilot.app.screen.focused == pilot.app.query_one("#from_buttons")
+
+
 async def test_radio_sets_toggle():
     """Test the status of the radio sets after they've been toggled."""
     async with RadioSetApp().run_test() as pilot:
@@ -52,17 +60,42 @@ async def test_radio_sets_toggle():
         ]
 
 
+async def test_radioset_same_button_mash():
+    """Mashing the same button should have no effect."""
+    async with RadioSetApp().run_test() as pilot:
+        assert pilot.app.query_one("#from_buttons", RadioSet).pressed_index == 2
+        pilot.app.query_one("#from_buttons", RadioSet)._nodes[2].toggle()
+        assert pilot.app.query_one("#from_buttons", RadioSet).pressed_index == 2
+        assert pilot.app.events_received == []
+
+
 async def test_radioset_inner_navigation():
     """Using the cursor keys should navigate between buttons in a set."""
     async with RadioSetApp().run_test() as pilot:
         assert pilot.app.screen.focused is None
         await pilot.press("tab")
-        for key, landing in (("down", 1), ("up", 0), ("right", 1), ("left", 0)):
+        for key, landing in (
+            ("down", 1),
+            ("up", 0),
+            ("right", 1),
+            ("left", 0),
+            ("up", 2),
+            ("down", 0),
+        ):
             await pilot.press(key, "enter")
             assert (
                 pilot.app.query_one("#from_buttons", RadioSet).pressed_button
                 == pilot.app.query_one("#from_buttons").children[landing]
             )
+    async with RadioSetApp().run_test() as pilot:
+        assert pilot.app.screen.focused is None
+        await pilot.press("tab")
+        assert pilot.app.screen.focused is pilot.app.screen.query_one("#from_buttons")
+        await pilot.press("tab")
+        assert pilot.app.screen.focused is pilot.app.screen.query_one("#from_strings")
+        assert pilot.app.query_one("#from_strings", RadioSet)._selected == 0
+        await pilot.press("down")
+        assert pilot.app.query_one("#from_strings", RadioSet)._selected == 1
 
 
 async def test_radioset_breakout_navigation():