Skip to content

Commit

Permalink
Add Tree.move_cursor, and ensure Tree.select_node selects the node (
Browse files Browse the repository at this point in the history
#4753)

* Add `Tree.move_cursor`, and ensure `Tree.select_node` selects the node

* Update changelog

* from future import annotations in test file

* Fix test to account for the fact that TreeNodeSelected now sends before TreeNodeExpanded

* Add note to CHANGELOG about tree message ordering change

* Fix changelog
  • Loading branch information
darrenburns authored Jul 15, 2024
1 parent 74bc89c commit 53adedf
Show file tree
Hide file tree
Showing 4 changed files with 130 additions and 11 deletions.
8 changes: 5 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
27 changes: 21 additions & 6 deletions src/textual/widgets/_tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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": (
Expand Down Expand Up @@ -745,14 +745,31 @@ 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:
node: A tree node, or None to reset cursor.
"""
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.
Expand Down Expand Up @@ -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))
102 changes: 102 additions & 0 deletions tests/tree/test_tree_cursor.py
Original file line number Diff line number Diff line change
@@ -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)`
]
4 changes: 2 additions & 2 deletions tests/tree/test_tree_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
]


Expand Down Expand Up @@ -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"),
]

Expand Down

0 comments on commit 53adedf

Please sign in to comment.