Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Tree.move_cursor, and ensure Tree.select_node selects the node #4753

Merged
merged 8 commits into from
Jul 15, 2024
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 @@ -23,15 +25,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Fixed programmatically disabling button stuck in hover state https://github.com/Textualize/textual/pull/4724
- 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
Loading