From 34ff6bf26077feefad2ca7ae2588ea66e92dc3fc Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 9 Jan 2023 11:47:40 +0000 Subject: [PATCH 1/9] Add some unit tests for Tree messages/event handling I'm about to work on #1400 and it seems like a good idea to put some tests in place first to ensure nothing gets disturbed. --- tests/tree/test_tree_messages.py | 54 ++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 tests/tree/test_tree_messages.py diff --git a/tests/tree/test_tree_messages.py b/tests/tree/test_tree_messages.py new file mode 100644 index 000000000..4a6d65ce2 --- /dev/null +++ b/tests/tree/test_tree_messages.py @@ -0,0 +1,54 @@ +from typing import Any +from textual.app import App, ComposeResult +from textual.widgets import Tree +from textual.message import Message + + +class TreeApp(App[None]): + """Test tree app.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.messages: list[str] = [] + + def compose(self) -> ComposeResult: + """Compose the child widgets.""" + yield Tree[None]("Root") + + def on_mount(self) -> None: + """""" + self.query_one(Tree[None]).root.add("Child") + self.query_one(Tree[None]).focus() + + def record(self, event: Message) -> None: + self.messages.append(event.__class__.__name__) + + def on_tree_node_selected(self, event: Tree.NodeSelected[None]) -> None: + self.record(event) + + def on_tree_node_expanded(self, event: Tree.NodeExpanded[None]) -> None: + self.record(event) + + def on_tree_node_collapsed(self, event: Tree.NodeCollapsed[None]) -> None: + self.record(event) + + +async def test_tree_node_selected_message() -> None: + """Selecting a node should result in a selected message being emitted.""" + async with TreeApp().run_test() as pilot: + await pilot.press("enter") + assert pilot.app.messages[-1] == "NodeSelected" + + +async def test_tree_node_expanded_message() -> None: + """Expanding a node should result in an expanded message being emitted.""" + async with TreeApp().run_test() as pilot: + await pilot.press("enter") + assert pilot.app.messages[0] == "NodeExpanded" + + +async def test_tree_node_collapsed_message() -> None: + """Collapsing a node should result in a collapsed message being emitted.""" + async with TreeApp().run_test() as pilot: + await pilot.press("enter", "enter") + assert pilot.app.messages[-2] == "NodeCollapsed" From 9226e90a5523a4e493fff10021240b37be022e4d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 9 Jan 2023 12:21:21 +0000 Subject: [PATCH 2/9] Give the existing node messages a common base class All three do the same thing, so we may as well give them a common base; especially given that we're about to go and add yet another message that'll do the same thing again but only (because we need it to) differ in name. --- src/textual/widgets/_tree.py | 37 ++++++++++++++++-------------------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 866a4d7ce..14801c039 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -331,45 +331,40 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): ), } - class NodeSelected(Generic[EventTreeDataType], Message, bubble=True): + class NodeMessage(Generic[EventTreeDataType], Message, bubble=True): + """Base class for events sent when something happens with a node. + + Attributes: + TreeNode[EventTreeDataType]: The node involved in the event. + """ + + def __init__( + self, sender: MessageTarget, node: TreeNode[EventTreeDataType] + ) -> None: + self.node = node + super().__init__(sender) + + class NodeSelected(NodeMessage[EventTreeDataType]): """Event sent when a node is selected. Attributes: TreeNode[EventTreeDataType]: The node that was selected. """ - def __init__( - self, sender: MessageTarget, node: TreeNode[EventTreeDataType] - ) -> None: - self.node = node - super().__init__(sender) - - class NodeExpanded(Generic[EventTreeDataType], Message, bubble=True): + class NodeExpanded(NodeMessage[EventTreeDataType]): """Event sent when a node is expanded. Attributes: TreeNode[EventTreeDataType]: The node that was expanded. """ - def __init__( - self, sender: MessageTarget, node: TreeNode[EventTreeDataType] - ) -> None: - self.node = node - super().__init__(sender) - - class NodeCollapsed(Generic[EventTreeDataType], Message, bubble=True): + class NodeCollapsed(NodeMessage[EventTreeDataType]): """Event sent when a node is collapsed. Attributes: TreeNode[EventTreeDataType]: The node that was collapsed. """ - def __init__( - self, sender: MessageTarget, node: TreeNode[EventTreeDataType] - ) -> None: - self.node = node - super().__init__(sender) - def __init__( self, label: TextType, From 6f10c63bb0ff8d9444a0762a40e4ec83c63d8533 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 9 Jan 2023 12:54:26 +0000 Subject: [PATCH 3/9] Perform tests on all recorded tree events --- tests/tree/test_tree_messages.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/tree/test_tree_messages.py b/tests/tree/test_tree_messages.py index 4a6d65ce2..01a2f2645 100644 --- a/tests/tree/test_tree_messages.py +++ b/tests/tree/test_tree_messages.py @@ -37,18 +37,23 @@ async def test_tree_node_selected_message() -> None: """Selecting a node should result in a selected message being emitted.""" async with TreeApp().run_test() as pilot: await pilot.press("enter") - assert pilot.app.messages[-1] == "NodeSelected" + assert pilot.app.messages == ["NodeExpanded", "NodeSelected"] async def test_tree_node_expanded_message() -> None: """Expanding a node should result in an expanded message being emitted.""" async with TreeApp().run_test() as pilot: await pilot.press("enter") - assert pilot.app.messages[0] == "NodeExpanded" + assert pilot.app.messages == ["NodeExpanded", "NodeSelected"] async def test_tree_node_collapsed_message() -> None: """Collapsing a node should result in a collapsed message being emitted.""" async with TreeApp().run_test() as pilot: await pilot.press("enter", "enter") - assert pilot.app.messages[-2] == "NodeCollapsed" + assert pilot.app.messages == [ + "NodeExpanded", + "NodeSelected", + "NodeCollapsed", + "NodeSelected", + ] From 0cf299540fa9d8b8630c0d6fe4a95e0deb8799a8 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 9 Jan 2023 12:54:57 +0000 Subject: [PATCH 4/9] Add a message for when a node is highlighted This is sort of different from selected. Selected is when someone mashes the enter button or clicks on a node. Highlighted is when the cursor moves into a new node. See #1400. --- src/textual/widgets/_tree.py | 9 +++++++++ tests/tree/test_tree_messages.py | 10 ++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 14801c039..7b33db8c8 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -365,6 +365,13 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): TreeNode[EventTreeDataType]: The node that was collapsed. """ + class NodeHighlighted(NodeMessage[EventTreeDataType]): + """Event sent when a node is highlighted. + + Attributes: + TreeNode[EventTreeDataType]: The node that was collapsed. + """ + def __init__( self, label: TextType, @@ -573,6 +580,8 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): self._refresh_node(node) node._selected = True self._cursor_node = node + if previous_node != node: + self.post_message_no_wait(self.NodeHighlighted(self, node)) def watch_guide_depth(self, guide_depth: int) -> None: self._invalidate() diff --git a/tests/tree/test_tree_messages.py b/tests/tree/test_tree_messages.py index 01a2f2645..ff2d8069c 100644 --- a/tests/tree/test_tree_messages.py +++ b/tests/tree/test_tree_messages.py @@ -32,6 +32,9 @@ class TreeApp(App[None]): def on_tree_node_collapsed(self, event: Tree.NodeCollapsed[None]) -> None: self.record(event) + def on_tree_node_highlighted(self, event: Tree.NodeHighlighted[None]) -> None: + self.record(event) + async def test_tree_node_selected_message() -> None: """Selecting a node should result in a selected message being emitted.""" @@ -57,3 +60,10 @@ async def test_tree_node_collapsed_message() -> None: "NodeCollapsed", "NodeSelected", ] + + +async def test_tree_node_highlighted_message() -> None: + """Highlighting a node should result in a highlighted message being emitted.""" + async with TreeApp().run_test() as pilot: + await pilot.press("enter", "down") + assert pilot.app.messages == ["NodeExpanded", "NodeSelected", "NodeHighlighted"] From 57687ccacf013abcacd3745a29519d092863b713 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 9 Jan 2023 12:57:23 +0000 Subject: [PATCH 5/9] Update the ChangeLog with details about Tree.NodeHighlighted --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90bb8ddc2..34d161158 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `TreeNode.parent` -- a read-only property for accessing a node's parent https://github.com/Textualize/textual/issues/1397 - Added public `TreeNode` label access via `TreeNode.label` https://github.com/Textualize/textual/issues/1396 - Added read-only public access to the children of a `TreeNode` via `TreeNode.children` https://github.com/Textualize/textual/issues/1398 +- Added a `Tree.NodeHighlighted` message, giving a `on_tree_node_highlighted` event handler https://github.com/Textualize/textual/issues/1400 ### Changed From 151673cd53d2703b2ac8e5aa30aab428a5dc211e Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 9 Jan 2023 13:12:57 +0000 Subject: [PATCH 6/9] Remove empty docstring --- tests/tree/test_tree_messages.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/tree/test_tree_messages.py b/tests/tree/test_tree_messages.py index ff2d8069c..50d0fa3e8 100644 --- a/tests/tree/test_tree_messages.py +++ b/tests/tree/test_tree_messages.py @@ -16,7 +16,6 @@ class TreeApp(App[None]): yield Tree[None]("Root") def on_mount(self) -> None: - """""" self.query_one(Tree[None]).root.add("Child") self.query_one(Tree[None]).focus() From c00a6da90dff912f6c14d55afa5c4da30f86887b Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 9 Jan 2023 13:13:54 +0000 Subject: [PATCH 7/9] Check if I need to import from the future for Python 3.8 --- tests/tree/test_tree_messages.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/tree/test_tree_messages.py b/tests/tree/test_tree_messages.py index 50d0fa3e8..1537c8c00 100644 --- a/tests/tree/test_tree_messages.py +++ b/tests/tree/test_tree_messages.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import Any from textual.app import App, ComposeResult from textual.widgets import Tree From 346659f47f6533e318d276cb2425b00130e4f8f7 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 9 Jan 2023 13:28:25 +0000 Subject: [PATCH 8/9] Move typing of the tree into its own class --- tests/tree/test_tree_messages.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/tree/test_tree_messages.py b/tests/tree/test_tree_messages.py index 1537c8c00..67620d70e 100644 --- a/tests/tree/test_tree_messages.py +++ b/tests/tree/test_tree_messages.py @@ -6,6 +6,10 @@ from textual.widgets import Tree from textual.message import Message +class MyTree(Tree[None]): + pass + + class TreeApp(App[None]): """Test tree app.""" @@ -15,11 +19,11 @@ class TreeApp(App[None]): def compose(self) -> ComposeResult: """Compose the child widgets.""" - yield Tree[None]("Root") + yield MyTree("Root") def on_mount(self) -> None: - self.query_one(Tree[None]).root.add("Child") - self.query_one(Tree[None]).focus() + self.query_one(MyTree).root.add("Child") + self.query_one(MyTree).focus() def record(self, event: Message) -> None: self.messages.append(event.__class__.__name__) From 489ba10c8aff4ea7c8f71f093e90dc967f89374b Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 9 Jan 2023 13:36:29 +0000 Subject: [PATCH 9/9] Sprinkle some pauses into the node message tests --- tests/tree/test_tree_messages.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/tree/test_tree_messages.py b/tests/tree/test_tree_messages.py index 67620d70e..f271d4e42 100644 --- a/tests/tree/test_tree_messages.py +++ b/tests/tree/test_tree_messages.py @@ -45,6 +45,7 @@ async def test_tree_node_selected_message() -> None: """Selecting a node should result in a selected message being emitted.""" async with TreeApp().run_test() as pilot: await pilot.press("enter") + await pilot.pause(2 / 100) assert pilot.app.messages == ["NodeExpanded", "NodeSelected"] @@ -52,6 +53,7 @@ async def test_tree_node_expanded_message() -> None: """Expanding a node should result in an expanded message being emitted.""" async with TreeApp().run_test() as pilot: await pilot.press("enter") + await pilot.pause(2 / 100) assert pilot.app.messages == ["NodeExpanded", "NodeSelected"] @@ -59,6 +61,7 @@ async def test_tree_node_collapsed_message() -> None: """Collapsing a node should result in a collapsed message being emitted.""" async with TreeApp().run_test() as pilot: await pilot.press("enter", "enter") + await pilot.pause(2 / 100) assert pilot.app.messages == [ "NodeExpanded", "NodeSelected", @@ -71,4 +74,5 @@ async def test_tree_node_highlighted_message() -> None: """Highlighting a node should result in a highlighted message being emitted.""" async with TreeApp().run_test() as pilot: await pilot.press("enter", "down") + await pilot.pause(2 / 100) assert pilot.app.messages == ["NodeExpanded", "NodeSelected", "NodeHighlighted"]