diff --git a/CHANGELOG.md b/CHANGELOG.md index 61685679a2..5a8732bd92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `textual.color.Gradient.get_rich_color` https://github.com/Textualize/textual/pull/4739 - `Widget.remove_children` now accepts an iterable if widgets in addition to a selector https://github.com/Textualize/textual/issues/4735 - Raise `ValueError` with improved error message when number of cells inserted using `DataTable.add_row` doesn't match the number of columns in the table https://github.com/Textualize/textual/pull/4742 +- Add `Tree.move_cursor` to programmatically move the cursor without selecting the node https://github.com/Textualize/textual/pull/4753 +- Added `Footer` component style handling of padding for the key/description https://github.com/Textualize/textual/pull/4651 ### Fixed @@ -24,15 +26,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed `DataTable` poor performance on startup and focus change when rows contain multi-line content https://github.com/Textualize/textual/pull/4748 - Fixed `Tree` and `DirectoryTree` horizontal scrolling off-by-2 https://github.com/Textualize/textual/pull/4744 - Fixed text-opacity in component styles https://github.com/Textualize/textual/pull/4747 +- Ensure `Tree.select_node` sends `NodeSelected` message https://github.com/Textualize/textual/pull/4753 ### Changed - "Discover" hits in the command palette are no longer sorted alphabetically https://github.com/Textualize/textual/pull/4720 +- `TreeNodeSelected` messages are now posted before `TreeNodeExpanded` messages +when an expandable node is selected https://github.com/Textualize/textual/pull/4753 - `Markdown.LinkClicked.href` is now automatically unquoted https://github.com/Textualize/textual/pull/4749 -### Added - -- Added `Footer` component style handling of padding for the key/description https://github.com/Textualize/textual/pull/4651 ## [0.72.0] - 2024-07-09 diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 94e582f538..9ee5227444 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -9,7 +9,7 @@ from rich.style import NULL_STYLE, Style from rich.text import Text, TextType -from .. import events +from .. import events, on from .._immutable_sequence_view import ImmutableSequenceView from .._loop import loop_last from .._segment_tools import line_pad @@ -495,7 +495,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): guide_depth = reactive(4, init=False) """The indent depth of tree nodes.""" auto_expand = var(True) - """Auto expand tree nodes when clicked.""" + """Auto expand tree nodes when they are selected.""" LINES: dict[str, tuple[str, str, str, str]] = { "default": ( @@ -745,7 +745,7 @@ def reset(self, label: TextType, data: TreeDataType | None = None) -> Self: self.root.data = data return self - def select_node(self, node: TreeNode[TreeDataType] | None) -> None: + def move_cursor(self, node: TreeNode[TreeDataType] | None) -> None: """Move the cursor to the given node, or reset cursor. Args: @@ -753,6 +753,23 @@ def select_node(self, node: TreeNode[TreeDataType] | None) -> None: """ self.cursor_line = -1 if node is None else node._line + def select_node(self, node: TreeNode[TreeDataType] | None) -> None: + """Move the cursor to the given node and select it, or reset cursor. + + Args: + node: A tree node to move the cursor to and select, or None to reset cursor. + """ + self.move_cursor(node) + if node is not None: + self.post_message(Tree.NodeSelected(node)) + + @on(NodeSelected) + def _expand_node_on_select(self, event: NodeSelected[TreeDataType]) -> None: + """When the node is selected, expand the node if `auto_expand` is True.""" + node = event.node + if self.auto_expand: + self._toggle_node(node) + def get_node_at_line(self, line_no: int) -> TreeNode[TreeDataType] | None: """Get the node for a given line. @@ -1233,6 +1250,4 @@ def action_select_cursor(self) -> None: pass else: node = line.path[-1] - if self.auto_expand: - self._toggle_node(node) - self.post_message(self.NodeSelected(node)) + self.post_message(Tree.NodeSelected(node)) diff --git a/tests/tree/test_tree_cursor.py b/tests/tree/test_tree_cursor.py new file mode 100644 index 0000000000..ada1a41c57 --- /dev/null +++ b/tests/tree/test_tree_cursor.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from typing import Any + +from textual import on +from textual.app import App, ComposeResult +from textual.widgets import Tree +from textual.widgets.tree import NodeID, TreeNode + + +class TreeApp(App[None]): + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.messages: list[tuple[str, NodeID]] = [] + + def compose(self) -> ComposeResult: + tree = Tree[str](label="tree") + self._node = tree.root.add_leaf("leaf") + tree.root.expand() + yield tree + + @property + def node(self) -> TreeNode[str]: + return self._node + + @on(Tree.NodeHighlighted) + @on(Tree.NodeSelected) + @on(Tree.NodeCollapsed) + @on(Tree.NodeExpanded) + def record_event( + self, + event: ( + Tree.NodeHighlighted[str] + | Tree.NodeSelected[str] + | Tree.NodeCollapsed[str] + | Tree.NodeExpanded[str] + ), + ) -> None: + self.messages.append((event.__class__.__name__, event.node.id)) + + +async def test_move_cursor() -> None: + """Test moving the cursor to a node (updating the highlighted node).""" + async with TreeApp().run_test() as pilot: + app = pilot.app + tree: Tree[str] = app.query_one(Tree) + node_to_move_to = app.node + tree.move_cursor(node_to_move_to) + await pilot.pause() + + # Note there are no Selected messages. We only move the cursor. + assert app.messages == [ + ("NodeExpanded", 0), # From the call to `tree.root.expand()` in compose + ("NodeHighlighted", 0), # From the initial highlight of the root node + ("NodeHighlighted", 1), # From the call to `tree.move_cursor` + ] + + +async def test_move_cursor_reset() -> None: + async with TreeApp().run_test() as pilot: + app = pilot.app + tree: Tree[str] = app.query_one(Tree) + tree.move_cursor(app.node) + tree.move_cursor(None) + await pilot.pause() + assert app.messages == [ + ("NodeExpanded", 0), # From the call to `tree.root.expand()` in compose + ("NodeHighlighted", 0), # From the initial highlight of the root node + ("NodeHighlighted", 1), # From the 1st call to `tree.move_cursor` + ("NodeHighlighted", 0), # From the call to `tree.move_cursor(None)` + ] + + +async def test_select_node() -> None: + async with TreeApp().run_test() as pilot: + app = pilot.app + tree: Tree[str] = app.query_one(Tree) + tree.select_node(app.node) + await pilot.pause() + assert app.messages == [ + ("NodeExpanded", 0), # From the call to `tree.root.expand()` in compose + ("NodeHighlighted", 0), # From the initial highlight of the root node + ("NodeHighlighted", 1), # From the `tree.select_node` call + ("NodeSelected", 1), # From the call to `tree.select_node` + ] + + +async def test_select_node_reset() -> None: + async with TreeApp().run_test() as pilot: + app = pilot.app + tree: Tree[str] = app.query_one(Tree) + tree.move_cursor(app.node) + tree.select_node(None) + await pilot.pause() + + # Notice no Selected messages. + assert app.messages == [ + ("NodeExpanded", 0), # From the call to `tree.root.expand()` in compose + ("NodeHighlighted", 0), # From the initial highlight of the root node + ("NodeHighlighted", 1), # From the `tree.move_cursor` call + ("NodeHighlighted", 0), # From the call to `tree.select_node(None)` + ] diff --git a/tests/tree/test_tree_messages.py b/tests/tree/test_tree_messages.py index 1262721100..448d883889 100644 --- a/tests/tree/test_tree_messages.py +++ b/tests/tree/test_tree_messages.py @@ -58,8 +58,8 @@ async def test_tree_node_selected_message() -> None: await pilot.press("enter") await pilot.pause() assert pilot.app.messages == [ - ("NodeExpanded", "test-tree"), ("NodeSelected", "test-tree"), + ("NodeExpanded", "test-tree"), ] @@ -151,8 +151,8 @@ async def test_tree_node_highlighted_message() -> None: await pilot.press("enter", "down") await pilot.pause() assert pilot.app.messages == [ - ("NodeExpanded", "test-tree"), ("NodeSelected", "test-tree"), + ("NodeExpanded", "test-tree"), ("NodeHighlighted", "test-tree"), ]