From 1ed974b6e9eeee05486f94024010bcfe6431e4f9 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Wed, 28 Jun 2023 14:25:39 +0800 Subject: [PATCH 01/30] Update docs and tests for TreeSource. --- core/src/toga/sources/list_source.py | 101 +- core/src/toga/sources/tree_source.py | 367 +++++- core/tests/sources/test_list_source.py | 26 +- core/tests/sources/test_node.py | 431 +++++++ core/tests/sources/test_row.py | 23 + core/tests/sources/test_tree_source.py | 1144 ++++++++--------- .../api/resources/sources/list_source.rst | 38 +- .../api/resources/sources/tree_source.rst | 129 +- examples/tree/tree/app.py | 23 +- 9 files changed, 1508 insertions(+), 774 deletions(-) create mode 100644 core/tests/sources/test_node.py create mode 100644 core/tests/sources/test_row.py diff --git a/core/src/toga/sources/list_source.py b/core/src/toga/sources/list_source.py index dd6612dc73..707a0a29c1 100644 --- a/core/src/toga/sources/list_source.py +++ b/core/src/toga/sources/list_source.py @@ -5,24 +5,55 @@ from .base import Source +def _find_item(candidates: list, data: Any, accessors: list[str], start, error: str): + """Find-by-value implementation helper; find an item matching ``data`` in + ``candidates``, starting with item ``start``.""" + if start is not None: + start_index = candidates.index(start) + 1 + else: + start_index = 0 + + for item in candidates[start_index:]: + try: + if isinstance(data, dict): + found = all( + getattr(item, attr) == value for attr, value in data.items() + ) + elif hasattr(data, "__iter__") and not isinstance(data, str): + found = all( + getattr(item, attr) == value for value, attr in zip(data, accessors) + ) + else: + found = getattr(item, accessors[0]) == data + + if found: + return item + except AttributeError: + # Attribute didn't exist, so it's not a match + pass + + raise ValueError(error) + + class Row: def __init__(self, **data): """Create a new Row object. - The keyword arguments specified in the constructor will be converted - into attributes on the new Row object. + The keyword arguments specified in the constructor will be converted into + attributes on the new Row object. - When any of the named attributes are modified, the source to which the - row belongs will be notified. + When any public attributes of the Row are modified (i.e., any attribute whose + name doesn't start with ``_``), the source to which the row belongs will be + notified. """ - self._source = None + self._source: Source = None for name, value in data.items(): setattr(self, name, value) def __repr__(self): descriptor = " ".join( f"{attr}={getattr(self, attr)!r}" - for attr in sorted(dir(self)) + for attr in sorted(self.__dict__) if not attr.startswith("_") ) return f"" @@ -51,7 +82,6 @@ def __init__(self, accessors: list[str], data: list[Any] | None = None): in each column of the row. :param data: The initial list of items in the source. """ - super().__init__() if isinstance(accessors, str) or not hasattr(accessors, "__iter__"): raise ValueError("accessors should be a list of attribute names") @@ -77,7 +107,7 @@ def __len__(self) -> int: def __getitem__(self, index: int) -> Row: return self._data[index] - def __delitem__(self, index): + def __delitem__(self, index: int): row = self._data[index] del self._data[index] self.notify("remove", index=index, item=row) @@ -141,7 +171,8 @@ def insert(self, index: int, data: Any): :param index: The index at which to insert the item. :param data: The data to insert into the ListSource. This data will be converted - into a Row for storage. + into a Row object. + :returns: The newly constructed Row object. """ row = self._create_row(data) self._data.insert(index, row) @@ -152,20 +183,19 @@ def append(self, data): """Insert a row at the end of the data source. :param data: The data to append to the ListSource. This data will be converted - into a Row for storage. + into a Row object. + :returns: The newly constructed Row object. """ return self.insert(len(self), data) def remove(self, row: Row): - """Remove an item from the data source. + """Remove a row from the data source. :param row: The row to remove from the data source. """ - i = self._data.index(row) - del self[i] - return row + del self[self._data.index(row)] - def index(self, row): + def index(self, row: Row) -> int: """The index of a specific row in the data source. This search uses Row instances, and searches for an *instance* match. @@ -173,14 +203,13 @@ def index(self, row): same Python instance will match. To search for values based on equality, use :meth:`~toga.sources.ListSource.find`. - Raises ValueError if the row cannot be found in the data source. - :param row: The row to find in the data source. :returns: The index of the row in the data source. + :raises ValueError: If the row cannot be found in the data source. """ return self._data.index(row) - def find(self, data, start=None): + def find(self, data: Any, start: None | None = None): """Find the first item in the data that matches all the provided attributes. @@ -190,38 +219,18 @@ def find(self, data, start=None): as the ``start`` argument. To search for a specific Row instance, use the :meth:`~toga.sources.ListSource.index`. - Raises ValueError if no match is found. - :param data: The data to search for. Only the values specified in data will be used as matching criteria; if the row contains additional data attributes, they won't be considered as part of the match. :param start: The instance from which to start the search. Defaults to ``None``, indicating that the first match should be returned. :return: The matching Row object + :raises ValueError: If no match is found. """ - if start: - start_index = self._data.index(start) + 1 - else: - start_index = 0 - - for item in self._data[start_index:]: - try: - if isinstance(data, dict): - found = all( - getattr(item, attr) == value for attr, value in data.items() - ) - elif hasattr(data, "__iter__") and not isinstance(data, str): - found = all( - getattr(item, attr) == value - for value, attr in zip(data, self._accessors) - ) - else: - found = getattr(item, self._accessors[0]) == data - - if found: - return item - except AttributeError: - # Attribute didn't exist, so it's not a match - pass - - raise ValueError(f"No row matching {data!r} in data") + return _find_item( + candidates=self._data, + data=data, + accessors=self._accessors, + start=start, + error=f"No row matching {data!r} in data", + ) diff --git a/core/src/toga/sources/tree_source.py b/core/src/toga/sources/tree_source.py index fadf9fe720..208817e1c4 100644 --- a/core/src/toga/sources/tree_source.py +++ b/core/src/toga/sources/tree_source.py @@ -1,27 +1,78 @@ +from __future__ import annotations + +from typing import Any + from .base import Source -from .list_source import Row +from .list_source import Row, _find_item class Node(Row): def __init__(self, **data): + """Create a new Node object. + + The keyword arguments specified in the constructor will be converted into + attributes on the new Row object. + + When initially constructed, the Node will be a leaf node (i.e., no children, + and marked unable to have children). + + When any public attributes of the Row are modified (i.e., any attribute whose + name doesn't start with ``_``), the source to which the row belongs will be + notified. + """ super().__init__(**data) - self._children = None - self._parent = None + self._children: list[Node] | None = None + self._parent: Node | None = None + + def __repr__(self): + descriptor = " ".join( + f"{attr}={getattr(self, attr)!r}" + for attr in sorted(self.__dict__) + if not attr.startswith("_") + ) + if not descriptor: + descriptor = "(no attributes)" + if self._children is not None: + descriptor += f"; {len(self)} children" + + return f"<{'Leaf ' if self._children is None else ''}Node {id(self):x} {descriptor}>" ###################################################################### # Methods required by the TreeSource interface ###################################################################### - def __getitem__(self, index): + def __getitem__(self, index: int) -> None: + if self._children is None: + raise ValueError(f"{self} is a leaf node") + return self._children[index] - def __len__(self): + def __delitem__(self, index: int): + if self._children is None: + raise ValueError(f"{self} is a leaf node") + + child = self._children[index] + del self._children[index] + + # Child isn't part of this source, or a child of this node any more. + child._parent = None + child._source = None + + self._source.notify("remove", parent=self, index=index, item=child) + + def __len__(self) -> int: if self.can_have_children(): return len(self._children) else: return 0 - def can_have_children(self): + def can_have_children(self) -> bool: + """Can the node have children? + + A value of :any:`True` does not necessarily mean the node *has* any children, + only that the node is *allowd* to have children. The value of ``len()`` for + the node indicates the number of actual children. + """ return self._children is not None ###################################################################### @@ -31,118 +82,312 @@ def can_have_children(self): def __iter__(self): return iter(self._children or []) - def __setitem__(self, index, value): - node = self._source._create_node(value) + def __setitem__(self, index: int, data: Any): + """Set the value of a specific child in the Node. + + :param index: The index of the child to change + :param data: The data for the updated child. This data will be converted + into a Node object. + """ + if self._children is None: + raise ValueError(f"{self} is a leaf node") + + old_node = self._children[index] + old_node._parent = None + old_node._source = None + + node = self._source._create_node(parent=self, data=data) self._children[index] = node self._source.notify("change", item=node) - def insert(self, index, value): - self._source.insert(self, index, value) + def insert(self, index: int, data: Any, children: Any = None): + """Insert a node as a child of this node a specific index. + + :param index: The index at which to insert the new child. + :param data: The data to insert into the Node as a child. This data will be + converted into a Node object. + :param children: The data for the children of the new child node. + :returns: The new added child Node object. + """ + if self._children is None: + self._children = [] - def append(self, value): - self._source.append(self, value) + if index < 0: + index = max(len(self) + index, 0) + else: + index = min(len(self), index) + + node = self._source._create_node(parent=self, data=data, children=children) + self._children.insert(index, node) + self._source.notify("insert", parent=self, index=index, item=node) + return node - def remove(self, node): - self._source.remove(self, node) + def append(self, data: Any, children: Any = None): + """Append a node to the end of the list of children of this node. + + :param data: The data to append as a child of this node. This data will be + converted into a Node object. + :param children: The data for the children of the new child node. + :returns: The new added child Node object. + """ + return self.insert(len(self), data=data, children=children) + + def remove(self, child: Node): + """Remove a child node from this node. + + :param child: The child node to remove from this node. + """ + # Index will raise ValueError if the node is a leaf + del self[self.index(child)] + + def index(self, child: Node): + """The index of a specific node in children of this node. + + This search uses Node instances, and searches for an *instance* match. + If two Node instances have the same values, only the Node that is the + same Python instance will match. To search for values based on equality, + use :meth:`~toga.sources.Node.find`. + + :param child: The node to find in the children of this node. + :returns: The index of the row in the children of this node. + :raises ValueError: If the node cannot be found in children of this node. + """ + if self._children is None: + raise ValueError(f"{self} is a leaf node") + + return self._children.index(child) + + def find(self, data: Any, start: Node = None): + """Find the first item in the child nodes of this node that matches all the + provided attributes. + + This is a value based search, rather than an instance search. If two Node + instances have the same values, the first instance that matches will be + returned. To search for a second instance, provide the first found instance as + the ``start`` argument. To search for a specific Node instance, use the + :meth:`~toga.sources.Node.index`. + + :param data: The data to search for. Only the values specified in data will be + used as matching criteria; if the row contains additional data attributes, + they won't be considered as part of the match. + :param start: The instance from which to start the search. Defaults to ``None``, + indicating that the first match should be returned. + :return: The matching Node object. + :raises ValueError: If no match is found. + :raises ValueError: If the node is a leaf node. + """ + if self._children is None: + raise ValueError(f"{self} is a leaf node") + + return _find_item( + candidates=self._children, + data=data, + accessors=self._source._accessors, + start=start, + error=f"No child matching {data!r} in {self}", + ) class TreeSource(Source): - def __init__(self, data, accessors): + def __init__(self, accessors: list[str], data: dict | list[tuple] | None = None): super().__init__() - self._accessors = accessors - self._roots = self._create_nodes(data) + if isinstance(accessors, str) or not hasattr(accessors, "__iter__"): + raise ValueError("accessors should be a list of attribute names") + + # Copy the list of accessors + self._accessors = [a for a in accessors] + if len(self._accessors) == 0: + raise ValueError("TreeSource must be provided a list of accessors") + + if data: + self._roots = self._create_nodes(parent=None, value=data) + else: + self._roots = [] ###################################################################### # Methods required by the TreeSource interface ###################################################################### - def __len__(self): + def __len__(self) -> int: return len(self._roots) - def __getitem__(self, index): + def __getitem__(self, index: int) -> Node: return self._roots[index] - def can_have_children(self): + def __delitem__(self, index: int): + node = self._roots[index] + del self._roots[index] + node._source = None + self.notify("remove", parent=None, index=index, item=node) + + def can_have_children(self) -> bool: + """Can the tree have children. + + This method is required for consistency with the Node interface; always returns + True. + """ return True ###################################################################### # Factory methods for new nodes ###################################################################### - def _create_node(self, data, children=None): + def _create_node( + self, + parent: Node | None, + data: Any, + children: list | dict | None = None, + ): if isinstance(data, dict): node = Node(**data) - else: + elif hasattr(data, "__iter__") and not isinstance(data, str): node = Node(**dict(zip(self._accessors, data))) + else: + node = Node(**{self._accessors[0]: data}) + node._parent = parent node._source = self if children is not None: - node._children = [] - for child_node in self._create_nodes(children): - node._children.append(child_node) - child_node._parent = node - child_node._source = self + node._children = self._create_nodes(parent=node, value=children) return node - def _create_nodes(self, data): - if isinstance(data, dict): + def _create_nodes(self, parent: Node | None, value: Any): + if isinstance(value, dict): return [ - self._create_node(value, children) - for value, children in sorted(data.items()) + self._create_node(parent=parent, data=data, children=children) + for data, children in sorted(value.items()) + ] + elif hasattr(value, "__iter__") and not isinstance(value, str): + return [ + self._create_node(parent=parent, data=item[0], children=item[1]) + for item in value ] else: - return [self._create_node(value) for value in data] + return [self._create_node(parent=parent, data=value)] ###################################################################### # Utility methods to make TreeSources more dict-like ###################################################################### - def __setitem__(self, index, value): - root = self._create_node(value) + def __setitem__(self, index: int, data: Any): + """Set the value of a specific root item in the data source. + + :param index: The root item to change + :param data: The data for the updated item. This data will be converted + into a Node object. + """ + old_root = self._roots[index] + old_root._parent = None + old_root._source = None + + root = self._create_node(parent=None, data=data) self._roots[index] = root self.notify("change", item=root) def __iter__(self): + """Obtain an iterator over all root Nodes in the data source.""" return iter(self._roots) def clear(self): + """Clear all data from the data source.""" self._roots = [] self.notify("clear") - def insert(self, parent, index, value): - node = self._create_node(value) - - if parent is None: - self._roots.insert(index, node) + def insert( + self, + index: int, + data: Any, + children: Any = None, + ): + """Insert a root node into the data source at a specific index. + + If the node is a leaf node, it will be converted into a non-leaf node. + + :param index: The index into the list of children at which to insert the item. + :param data: The data to insert into the TreeSource. This data will be converted + into a Node object. + :param children: The data for the children to insert into the TreeSource. + :returns: The newly constructed Node object. + :raises ValueError: If the provided parent is not part of this TreeSource. + """ + if index < 0: + index = max(len(self) + index, 0) else: - if parent._children is None: - parent._children = [] - parent._children.insert(index, node) + index = min(len(self), index) - node._parent = parent - self.notify("insert", parent=parent, index=index, item=node) + node = self._create_node(parent=None, data=data, children=children) + self._roots.insert(index, node) + node._parent = None + self.notify("insert", parent=None, index=index, item=node) return node - def append(self, parent, value): - return self.insert(parent, len(parent or self), value) + def append(self, data: Any, children: Any = None): + """Append a root node at the end of the list of children of this source. - def remove(self, node): - i = self.index(node) - parent = node._parent - if node._parent is None: - del self._roots[i] - else: - del node._parent._children[i] - # node is not in parent's children so it shouldn't keep a link to parent - del node._parent + If the node is a leaf node, it will be converted into a non-leaf node. - self.notify("remove", parent=parent, index=i, item=node) - return node + :param data: The data to append onto the list of children of the given parent. + This data will be converted into a Node object. + :param children: The data for the children to insert into the TreeSource. + :returns: The newly constructed Node object. + :raises ValueError: If the provided parent is not part of this TreeSource. + """ + return self.insert(len(self), data=data, children=children) + + def remove(self, node: Node): + """Remove a node from the data source. + + This will also remove the node if it is a descendent of a root node. - def index(self, node): - if node._parent: - return node._parent._children.index(node) + :param node: The node to remove from the data source. + """ + if node._source != self: + raise ValueError(f"{node} is not managed by this data source") + + if node._parent is None: + del self[self.index(node)] else: - return self._roots.index(node) + node._parent.remove(node) + + def index(self, node: Node) -> int: + """The index of a specific root node in the data source. + + This search uses Node instances, and searches for an *instance* match. + If two Node instances have the same values, only the Node that is the + same Python instance will match. To search for values based on equality, + use :meth:`~toga.sources.TreeSource.find`. + + :param node: The node to find in the data source. + :returns: The index of the node in the child list it is a part of. + :raises ValueError: If the node cannot be found in the data source. + """ + return self._roots.index(node) + + def find(self, data: Any, start: Node = None): + """Find the first item in the child nodes of the given node that matches all the + provided attributes. + + This is a value based search, rather than an instance search. If two Node + instances have the same values, the first instance that matches will be + returned. To search for a second instance, provide the first found instance as + the ``start`` argument. To search for a specific Node instance, use the + :meth:`~toga.sources.TreeSource.index`. + + :param data: The data to search for. Only the values specified in data will be + used as matching criteria; if the row contains additional data attributes, + they won't be considered as part of the match. + :param start: The instance from which to start the search. Defaults to ``None``, + indicating that the first match should be returned. + :return: The matching Node object. + :raises ValueError: If no match is found. + :raises ValueError: If the provided parent is not part of this TreeSource. + """ + return _find_item( + candidates=self._roots, + data=data, + accessors=self._accessors, + start=start, + error=f"No root node matching {data!r} in {self}", + ) diff --git a/core/tests/sources/test_list_source.py b/core/tests/sources/test_list_source.py index 110d57bb9c..725c7a3951 100644 --- a/core/tests/sources/test_list_source.py +++ b/core/tests/sources/test_list_source.py @@ -2,8 +2,7 @@ import pytest -from toga.sources import ListSource -from toga.sources.list_source import Row +from toga.sources import ListSource, Row @pytest.fixture @@ -18,26 +17,6 @@ def source(): ) -def test_row(): - "A row can be created and modified" - source = Mock() - row = Row(val1="value 1", val2=42) - row._source = source - - assert row.val1 == "value 1" - assert row.val2 == 42 - - row.val1 = "new value" - source.notify.assert_called_once_with("change", item=row) - source.notify.reset_mock() - - # An attribute that wasn't in the original attribute set - # still causes a change notification - row.val3 = "other value" - source.notify.assert_called_once_with("change", item=row) - source.notify.reset_mock() - - @pytest.mark.parametrize( "value", [ @@ -363,7 +342,8 @@ def test_remove(source): source.add_listener(listener) # Remove the second element - row = source.remove(source[1]) + row = source[1] + source.remove(row) assert len(source) == 2 assert source[0].val1 == "first" diff --git a/core/tests/sources/test_node.py b/core/tests/sources/test_node.py new file mode 100644 index 0000000000..f6f503f723 --- /dev/null +++ b/core/tests/sources/test_node.py @@ -0,0 +1,431 @@ +import re +from unittest.mock import Mock + +import pytest + +from toga.sources import Node + + +def _create_node(source, parent, data, children=None): + "A very simplified _create_node for mock purposes" + node = Node(**data) + node._source = source + node._parent = parent + if children: + node._children = [ + _create_node(source, parent=node, data=item[0], children=item[1]) + for item in children + ] + return node + + +@pytest.fixture +def source(): + source = Mock() + source._accessors = ["val1", "val2"] + source._create_node.side_effect = lambda *args, **kwargs: _create_node( + source, *args, **kwargs + ) + return source + + +@pytest.fixture +def leaf_node(source): + node = Node(val1="value 1", val2=42) + node._source = source + return node + + +@pytest.fixture +def empty_node(source): + node = Node() + node._source = source + node._children = [] + return node + + +@pytest.fixture +def child_a(source, leaf_node): + child = Node(val1="value a", val2=111) + child._source = source + child._parent = leaf_node + return child + + +@pytest.fixture +def child_b(source, leaf_node): + child = Node(val1="value b", val2=222) + child._source = source + child._parent = leaf_node + return child + + +@pytest.fixture +def node(leaf_node, child_a, child_b): + leaf_node._children = [child_a, child_b] + + return leaf_node + + +def test_node_properties(node): + "An node with children can be created and modified" + assert node.val1 == "value 1" + assert node.val2 == 42 + assert node.can_have_children() + assert len(node) == 2 + assert ( + re.match(r"", repr(node)) + is not None + ) + + +def test_empty_node_properties(empty_node): + "An empty Node can be created" + assert empty_node.can_have_children() + assert len(empty_node) == 0 + assert ( + re.match(r"", repr(empty_node)) + is not None + ) + + +def test_leaf_node_properties(leaf_node): + "A Leaf Node can be created" + assert leaf_node.val1 == "value 1" + assert leaf_node.val2 == 42 + assert not leaf_node.can_have_children() + assert len(leaf_node) == 0 + assert ( + re.match(r"", repr(leaf_node)) is not None + ) + + +def test_modify_attributes(source, node): + """If node attributes are modified, a change notification is sent""" + node.val1 = "new value" + source.notify.assert_called_once_with("change", item=node) + source.notify.reset_mock() + + # An attribute that wasn't in the original attribute set + # still causes a change notification + node.val3 = "other value" + source.notify.assert_called_once_with("change", item=node) + source.notify.reset_mock() + + +def test_modify_children(source, node): + """Node children can be retrieved and modified""" + child = node[1] + assert node[1].val1 == "value b" + + # Delete the child + del node[1] + + # Removal notification was sent + source.notify.assert_called_once_with("remove", parent=node, index=1, item=child) + source.notify.reset_mock() + + # Child is no longer associated with the source + assert child._parent is None + assert child._source is None + + # Node child count has dropped + assert len(node) == 1 + + old_child_0 = node[0] + + # A child can be modified by index + node[0] = {"val1": "new"} + + # Node 0 has changed instance + assert old_child_0 is not node[0] + + # Old child 0 is no longer associated with this node + assert old_child_0._source is None + assert old_child_0._parent is None + + # A child node was created using the source's factory + source._create_node.assert_called_with(parent=node, data={"val1": "new"}) + + # Change notification was sent, the change is associated with the new item + source.notify.assert_called_once_with("change", item=node[0]) + + # Node child count hasn't changed + assert len(node) == 1 + + +def test_modify_leaf_children(leaf_node): + """Attempts to modifying Leaf Node children raise an error""" + with pytest.raises( + ValueError, + match=r" is a leaf node", + ): + leaf_node[1] + + with pytest.raises( + ValueError, + match=r" is a leaf node", + ): + del leaf_node[1] + + with pytest.raises( + ValueError, + match=r" is a leaf node", + ): + leaf_node[1] = {"val1": "new"} + + +def test_iterate(node): + """Node can be iterated""" + assert "|".join(child.val1 for child in node) == "value a|value b" + + +def test_iterate_leaf(leaf_node): + """Node can be iterated""" + assert "|".join(child.val1 for child in leaf_node) == "" + + +@pytest.mark.parametrize( + "index, actual_index", + [ + (1, 1), # Positive, in range + (10, 2), # Positive, past positive limit + (-1, 1), # Negative, in range + (-10, 0), # Negative, past negative limit + ], +) +def test_insert(source, node, index, actual_index): + """A child can be inserted into a node""" + new_child = node.insert(index, {"val1": "new"}) + + # Node has one more child. + assert len(node) == 3 + assert node[actual_index] == new_child + + # A child node was created using the source's factory + source._create_node.assert_called_with( + parent=node, data={"val1": "new"}, children=None + ) + + # insert notification was sent, the change is associated with the new item + source.notify.assert_called_once_with( + "insert", + parent=node, + index=actual_index, + item=new_child, + ) + + +def test_insert_with_children(source, node): + """A child with children can be inserted into a node""" + new_child = node.insert( + 1, + {"val1": "new"}, + children=[ + ({"val1": "new child 1"}, None), + ({"val1": "new child 2"}, None), + ], + ) + + # Node has one more child. + assert len(node) == 3 + assert node[1] == new_child + + # A child node was created using the source's factory, and the child data was passed + # to that call. + source._create_node.assert_called_with( + parent=node, + data={"val1": "new"}, + children=[ + ({"val1": "new child 1"}, None), + ({"val1": "new child 2"}, None), + ], + ) + + # The children of the new child are as expected + assert len(new_child) == 2 + assert new_child[0].val1 == "new child 1" + assert not new_child[0].can_have_children() + + # insert notification was sent, the change is associated with the new item + source.notify.assert_called_once_with( + "insert", + parent=node, + index=1, + item=new_child, + ) + + +def test_insert_leaf(leaf_node, source): + """Inserting a child into a leaf makes the node not a leaf any more""" + new_child = leaf_node.insert(0, {"val1": "new"}) + + # Leaf node isn't a leaf any more + assert leaf_node.can_have_children() + assert len(leaf_node) == 1 + assert leaf_node[0] == new_child + + # A child node was created using the source's factory + source._create_node.assert_called_with( + parent=leaf_node, data={"val1": "new"}, children=None + ) + + # insert notification was sent, the change is associated with the new item + source.notify.assert_called_once_with( + "insert", parent=leaf_node, index=0, item=leaf_node[0] + ) + + +def test_append(source, node): + """A child can be appended onto a node""" + new_child = node.append({"val1": "new"}) + + # Node has one more child. + assert len(node) == 3 + assert node[2] == new_child + + # A child node was created using the source's factory + source._create_node.assert_called_with( + parent=node, data={"val1": "new"}, children=None + ) + + # insert notification was sent, the change is associated with the new item + source.notify.assert_called_once_with( + "insert", + parent=node, + index=2, + item=new_child, + ) + + +def test_append_with_children(source, node): + """A child with children can be appended onto a node""" + new_child = node.append( + {"val1": "new"}, + children=[ + ({"val1": "new child 1"}, None), + ({"val1": "new child 2"}, None), + ], + ) + + # Node has one more child. + assert len(node) == 3 + assert node[2] == new_child + + # A child node was created using the source's factory, and the child data was passed + # to that call. Since our source is a mock, we won't get actual children. + source._create_node.assert_called_with( + parent=node, + data={"val1": "new"}, + children=[ + ({"val1": "new child 1"}, None), + ({"val1": "new child 2"}, None), + ], + ) + + # insert notification was sent, the change is associated with the new item + source.notify.assert_called_once_with( + "insert", + parent=node, + index=2, + item=new_child, + ) + + +def test_append_leaf(leaf_node, source): + """Appending to a leaf makes the node not a leaf any more""" + new_child = leaf_node.append({"val1": "new"}) + + # Leaf node isn't a leaf any more + assert leaf_node.can_have_children() + assert len(leaf_node) == 1 + assert leaf_node[0] == new_child + + # A child node was created using the source's factory + source._create_node.assert_called_with( + parent=leaf_node, data={"val1": "new"}, children=None + ) + + # insert notification was sent, the change is associated with the new item + source.notify.assert_called_once_with( + "insert", parent=leaf_node, index=0, item=leaf_node[0] + ) + + +def test_index(node, child_b): + """A child can be found it it's parent""" + assert node.index(child_b) == 1 + + +def test_index_not_child(node): + """If a node isn't a child of this node, it can't be found by index""" + other = Node(val1="other") + + with pytest.raises( + ValueError, + match=r" is not in list", + ): + node.index(other) + + +def test_index_leaf(leaf_node, child_b): + """A child cannot be found in a leaf node""" + with pytest.raises( + ValueError, + match=r" is a leaf node", + ): + leaf_node.index(child_b) + + +def test_remove(source, node, child_b): + """A node can be removed from it's parent""" + # Child is initially associated with the node + assert child_b._parent == node + assert child_b._source == node._source + + # Remove the child + node.remove(child_b) + + # Child isn't associated with the node any more + assert child_b._parent is None + assert child_b._source is None + + # The node has less children + assert len(node) == 1 + + # The source was notified + source.notify.assert_called_once_with("remove", parent=node, index=1, item=child_b) + + +def test_remove_leaf(leaf_node, child_b): + """A child cannot be removed from a leaf node""" + with pytest.raises( + ValueError, + match=r" is a leaf node", + ): + leaf_node.index(child_b) + + +def test_find(node, child_b): + """A node can be found by value in it's parent's list of children""" + # Append some additional children + child_c = node.append({"val1": "value a", "val2": 333}) + child_d = node.append({"val1": "value b", "val2": 444}) + + # Find the child by a partial match of values. + assert node.find({"val1": "value b"}) == child_b + + # Find the child by a partial match of values, starting at the first match + assert node.find({"val1": "value b"}, start=child_b) == child_d + + # Find the child by a full match of values, starting at the first match + assert node.find({"val1": "value a", "val2": 333}) == child_c + + +def test_found_leaf(leaf_node): + """A child cannot be foundd from a leaf node""" + with pytest.raises( + ValueError, + match=r" is a leaf node", + ): + leaf_node.find({"val1": "value 1"}) diff --git a/core/tests/sources/test_row.py b/core/tests/sources/test_row.py new file mode 100644 index 0000000000..3fd37e0de6 --- /dev/null +++ b/core/tests/sources/test_row.py @@ -0,0 +1,23 @@ +from unittest.mock import Mock + +from toga.sources import Row + + +def test_row(): + "A row can be created and modified" + source = Mock() + row = Row(val1="value 1", val2=42) + row._source = source + + assert row.val1 == "value 1" + assert row.val2 == 42 + + row.val1 = "new value" + source.notify.assert_called_once_with("change", item=row) + source.notify.reset_mock() + + # An attribute that wasn't in the original attribute set + # still causes a change notification + row.val3 = "other value" + source.notify.assert_called_once_with("change", item=row) + source.notify.reset_mock() diff --git a/core/tests/sources/test_tree_source.py b/core/tests/sources/test_tree_source.py index da08f1ee39..536c58210f 100644 --- a/core/tests/sources/test_tree_source.py +++ b/core/tests/sources/test_tree_source.py @@ -1,676 +1,616 @@ -from unittest import TestCase from unittest.mock import Mock -from toga.sources import TreeSource -from toga.sources.tree_source import Node - - -class LeafNodeTests(TestCase): - def setUp(self): - self.source = Mock() - self.example = Node(val1="value 1", val2=42) - self.example._source = self.source - - def test_initial_state(self): - "A node holds values as expected" - self.assertEqual(self.example.val1, "value 1") - self.assertEqual(self.example.val2, 42) - self.assertFalse(self.example.can_have_children()) - self.assertEqual(len(self.example), 0) - - def test_change_value(self): - "If a node value changes, the source is notified" - self.example.val1 = "new value" - - self.assertEqual(self.example.val1, "new value") - self.source.notify.assert_called_once_with("change", item=self.example) - - def test_iterate_children(self): - "Children of a node can be iterated over -- should have no children" - result = 0 - - for child in self.example: - result += child.val2 - - self.assertEqual(result, 0) - - -class NodeTests(TestCase): - def setUp(self): - self.source = Mock() - - def bound_create_node(s): - def create_node(value): - return Node(source=s, **value) - - return create_node - - self.source._create_node = bound_create_node(self.source) - - self.parent = Node(val1="value 1", val2=42) - self.parent._source = self.source - self.parent._children = [] - for datum in [{"val1": "child 1", "val2": 11}, {"val1": "child 2", "val2": 22}]: - child = Node(**datum) - child.source = self.source - self.parent._children.append(child) - - def test_initial_state(self): - "A node holds values as expected" - - self.assertEqual(self.parent.val1, "value 1") - self.assertEqual(self.parent.val2, 42) - self.assertTrue(self.parent.can_have_children()) - self.assertEqual(len(self.parent), 2) - - def test_change_value(self): - "If a node value changes, the source is notified" - self.parent.val1 = "new value" - - self.assertEqual(self.parent.val1, "new value") - self.source.notify.assert_called_once_with("change", item=self.parent) - - def test_empty_children(self): - "A parent with 0 children isn't the same as a parent who *can't* have children" - parent = Node(source=self.source, val1="value 1", val2=42) - parent._children = [] - - self.assertTrue(parent.can_have_children()) - self.assertEqual(len(parent), 0) - - def test_change_child(self): - "Changing a child notifies the source" - # Check initial value - self.assertEqual(len(self.parent), 2) - self.assertEqual(self.parent[1].val1, "child 2") - self.assertEqual(self.parent[1].val2, 22) - - # Change the value - self.parent[1] = {"val1": "new child", "val2": 33} - - # Check the values after modification - self.assertEqual(len(self.parent), 2) - self.assertEqual(self.parent[1].val1, "new child") - self.assertEqual(self.parent[1].val2, 33) - - def test_insert_child(self): - "A new child can be inserted; defers to the source" - self.parent.insert(1, dict(val1="inserted 1", val2=33)) - self.source.insert.assert_called_once_with( - self.parent, 1, dict(val1="inserted 1", val2=33) - ) - - def test_append_child(self): - "A new child can be appended; defers to the source" - self.parent.append(dict(val1="appended 1", val2=33)) - self.source.append.assert_called_once_with( - self.parent, dict(val1="appended 1", val2=33) - ) - - def test_remove_child(self): - "A child can be removed; defers to the source" - child = self.parent[1] - self.parent.remove(child) - self.source.remove.assert_called_once_with(self.parent, child) - - def test_iterate_children(self): - "Children of a node can be iterated over" - result = 0 - - for child in self.parent: - result += child.val2 - - self.assertEqual(result, 33) - - -class TreeSourceTests(TestCase): - def test_init_with_list_of_tuples(self): - "TreeSources can be instantiated from lists of tuples" - source = TreeSource( - data=[ - ("first", 111), - ("second", 222), - ("third", 333), +import pytest + +from toga.sources import Node, TreeSource + + +@pytest.fixture +def listener(): + return Mock() + + +@pytest.fixture +def source(listener): + source = TreeSource( + data={ + ("group1", 1): [ + ( + {"val1": "A first", "val2": 110}, + None, + ), + ( + {"val1": "A second", "val2": 120}, + [], + ), + ( + {"val1": "A third", "val2": 130}, + [ + ({"val1": "A third-first", "val2": 131}, None), + ({"val1": "A third-second", "val2": 132}, None), + ], + ), ], - accessors=["val1", "val2"], - ) - - self.assertEqual(len(source), 3) - - self.assertEqual(source[0].val1, "first") - self.assertEqual(source[0].val2, 111) - self.assertFalse(source[0].can_have_children()) - self.assertEqual(len(source[0]), 0) - - self.assertEqual(source[1].val1, "second") - self.assertEqual(source[1].val2, 222) - self.assertFalse(source[1].can_have_children()) - self.assertEqual(len(source[1]), 0) - - listener = Mock() - source.add_listener(listener) - - # Set element 1 - source[1] = ("new element", 999) - - self.assertEqual(len(source), 3) - - self.assertEqual(source[1].val1, "new element") - self.assertEqual(source[1].val2, 999) - self.assertFalse(source[1].can_have_children()) - self.assertEqual(len(source[1]), 0) - - listener.change.assert_called_once_with(item=source[1]) - - def test_init_with_list_of_dicts(self): - "TreeSource nodes can be instantiated from lists of dicts" - source = TreeSource( - data=[ - {"val1": "first", "val2": 111}, - {"val1": "second", "val2": 222}, - {"val1": "third", "val2": 333}, + ("group2", 2): [ + ( + {"val1": "B first", "val2": 210}, + None, + ), + ( + {"val1": "B second", "val2": 220}, + [], + ), + ( + {"val1": "B third", "val2": 230}, + [ + ({"val1": "B third-first", "val2": 231}, None), + ({"val1": "B third-second", "val2": 232}, None), + ], + ), ], - accessors=["val1", "val2"], - ) - - self.assertEqual(len(source), 3) - - self.assertEqual(source[0].val1, "first") - self.assertEqual(source[0].val2, 111) - self.assertFalse(source[0].can_have_children()) - self.assertEqual(len(source[0]), 0) - - self.assertEqual(source[1].val1, "second") - self.assertEqual(source[1].val2, 222) - self.assertFalse(source[1].can_have_children()) - self.assertEqual(len(source[1]), 0) - - listener = Mock() - source.add_listener(listener) - - # Set element 1 - source[1] = {"val1": "new element", "val2": 999} - - self.assertEqual(len(source), 3) - - self.assertEqual(source[1].val1, "new element") - self.assertEqual(source[1].val2, 999) - self.assertFalse(source[1].can_have_children()) - self.assertEqual(len(source[1]), 0) - - listener.change.assert_called_once_with(item=source[1]) - - def test_init_with_dict_of_lists(self): - "TreeSource nodes can be instantiated from dicts of lists" - source = TreeSource( - data={ - ("first", 111): None, - ("second", 222): [], - ("third", 333): [ - ("third.one", 331), - {"val1": "third.two", "val2": 332}, - ], - }, - accessors=["val1", "val2"], - ) - - self.assertEqual(len(source), 3) - - self.assertEqual(source[0].val1, "first") - self.assertEqual(source[0].val2, 111) - self.assertFalse(source[0].can_have_children()) - self.assertEqual(len(source[0]), 0) - - self.assertEqual(source[1].val1, "second") - self.assertEqual(source[1].val2, 222) - self.assertTrue(source[1].can_have_children()) - self.assertEqual(len(source[1]), 0) - - self.assertEqual(source[2].val1, "third") - self.assertEqual(source[2].val2, 333) - self.assertTrue(source[2].can_have_children()) - self.assertEqual(len(source[2]), 2) - - self.assertEqual(source[2].val1, "third") - self.assertEqual(source[2].val2, 333) - self.assertTrue(source[2].can_have_children()) - self.assertEqual(len(source[2]), 2) - - self.assertEqual(source[2][0].val1, "third.one") - self.assertEqual(source[2][0].val2, 331) - self.assertFalse(source[2][0].can_have_children()) - self.assertEqual(len(source[2][0]), 0) - - self.assertEqual(source[2][1].val1, "third.two") - self.assertEqual(source[2][1].val2, 332) - self.assertFalse(source[2][1].can_have_children()) - self.assertEqual(len(source[2][1]), 0) - - listener = Mock() - source.add_listener(listener) - - # Set element 2 - source[2] = {"val1": "new element", "val2": 999} - - self.assertEqual(len(source), 3) - - self.assertEqual(source[2].val1, "new element") - self.assertEqual(source[2].val2, 999) - - listener.change.assert_called_once_with(item=source[2]) - - def test_init_with_dict_of_dicts(self): - "TreeSource nodes can be instantiated from dicts of dicts" - source = TreeSource( - data={ - ("first", 111): None, - ("second", 222): [], - ("third", 333): { - ("third.one", 331): None, - ("third.two", 332): [("third.two.sub", 321)], + }, + accessors=["val1", "val2"], + ) + + source.add_listener(listener) + return source + + +@pytest.mark.parametrize( + "value", + [ + None, + 42, + "not a list", + ], +) +def test_invalid_accessors(value): + "Accessors for a list source must be a list of attribute names" + with pytest.raises( + ValueError, + match=r"accessors should be a list of attribute names", + ): + TreeSource(accessors=value) + + +def test_accessors_required(): + "A list source must specify *some* accessors" + with pytest.raises( + ValueError, + match=r"TreeSource must be provided a list of accessors", + ): + TreeSource(accessors=[], data=[1, 2, 3]) + + +def test_accessors_copied(): + "A list source must specify *some* accessors" + accessors = ["foo", "bar"] + source = TreeSource(accessors) + + assert source._accessors == ["foo", "bar"] + + # The accessors have been copied. + accessors.append("whiz") + assert source._accessors == ["foo", "bar"] + + +@pytest.mark.parametrize( + "data", + [ + {}, + [], + ], +) +def test_create_empty(data): + """An empty TreeSource can be created""" + source = TreeSource(data=data, accessors=["val1", "val2"]) + + assert len(source) == 0 + assert source.can_have_children() + + +@pytest.mark.parametrize( + "data, all_accessor_levels", + [ + # Dictionaries all the way down + ( + { + "root0": { + "child00": None, + "child01": {}, + "child02": {"child020": None, "child021": None}, + }, + "root1": { + "child10": None, + "child11": {}, + "child12": {"child120": None, "child121": None}, }, }, - accessors=["val1", "val2"], - ) - - self.assertEqual(len(source), 3) - - self.assertEqual(source[0].val1, "first") - self.assertEqual(source[0].val2, 111) - self.assertFalse(source[0].can_have_children()) - self.assertEqual(len(source[0]), 0) - - self.assertEqual(source[1].val1, "second") - self.assertEqual(source[1].val2, 222) - self.assertTrue(source[1].can_have_children()) - self.assertEqual(len(source[1]), 0) - - self.assertEqual(source[2].val1, "third") - self.assertEqual(source[2].val2, 333) - self.assertTrue(source[2].can_have_children()) - self.assertEqual(len(source[2]), 2) - - self.assertEqual(source[2].val1, "third") - self.assertEqual(source[2].val2, 333) - self.assertTrue(source[2].can_have_children()) - self.assertEqual(len(source[2]), 2) - - self.assertEqual(source[2][0].val1, "third.one") - self.assertEqual(source[2][0].val2, 331) - self.assertFalse(source[2][0].can_have_children()) - self.assertEqual(len(source[2][0]), 0) - - self.assertEqual(source[2][1].val1, "third.two") - self.assertEqual(source[2][1].val2, 332) - self.assertTrue(source[2][1].can_have_children()) - self.assertEqual(len(source[2][1]), 1) - - self.assertEqual(source[2][1][0].val1, "third.two.sub") - self.assertEqual(source[2][1][0].val2, 321) - self.assertFalse(source[2][1][0].can_have_children()) - self.assertEqual(len(source[2][1][0]), 0) - - listener = Mock() - source.add_listener(listener) - - # Set element 2 - source[2] = {"val1": "new element", "val2": 999} - - self.assertEqual(len(source), 3) - - self.assertEqual(source[2].val1, "new element") - self.assertEqual(source[2].val2, 999) - - listener.change.assert_called_once_with(item=source[2]) - - def test_iter(self): - "TreeSource roots can be iterated over" - source = TreeSource( - data={ - ("first", 111): None, - ("second", 222): [], - ("third", 333): [("third.one", 331), ("third.two", 332)], + set(), # Only the first accessor is ever used + ), + # Dictionaries with tuples as keys + ( + { + ("root0", 1): { + ("child00", 11): None, + ("child01", 12): {}, + ("child02", 13): {("child020", 131): None, ("child021", 132): None}, + }, + ("root1", 2): { + ("child10", 21): None, + ("child11", 22): {}, + ("child12", 23): {("child120", 231): None, ("child121", 232): None}, + }, }, - accessors=["val1", "val2"], - ) - - result = 0 - for root in source: - result += root.val2 - - self.assertEqual(result, 666) - - def test_insert_root_args(self): - "A new root can be inserted using value args" - source = TreeSource( - data={ - ("first", 111): None, - ("second", 222): [], - ("third", 333): [("third.one", 331), ("third.two", 332)], + {0, 1, 2}, # All accessors at all levels + ), + # List of dictionary data, list children + ( + [ + ( + {"val1": "root0", "val2": 1}, + [ + ({"val1": "child00", "val2": 11}, None), + ({"val1": "child01", "val2": 12}, []), + ( + {"val1": "child02", "val2": 13}, + [ + ({"val1": "child020", "val2": 131}, None), + ({"val1": "child021", "val2": 132}, None), + ], + ), + ], + ), + ( + {"val1": "root1", "val2": 2}, + [ + ({"val1": "child10", "val2": 21}, None), + ({"val1": "child11", "val2": 22}, []), + ( + {"val1": "child12", "val2": 23}, + [ + ({"val1": "child120", "val2": 231}, None), + ({"val1": "child121", "val2": 232}, None), + ], + ), + ], + ), + ], + {0, 1, 2}, # all accessors at all levels. + ), + # List of tuple data, list children + ( + [ + ( + ("root0", 1), + [ + (("child00", 11), None), + (("child01", 12), []), + ( + ("child02", 13), + [ + (("child020", 131), None), + (("child021", 132), None), + ], + ), + ], + ), + ( + ("root1", 2), + [ + (("child10", 21), None), + (("child11", 22), []), + ( + ("child12", 23), + [ + (("child120", 231), None), + (("child121", 232), None), + ], + ), + ], + ), + ], + {0, 1, 2}, # all accessors at all levels. + ), + # Dictionary of lists of dictionary data, list children + ( + { + "root0": [ + ({"val1": "child00", "val2": 11}, None), + ({"val1": "child01", "val2": 12}, []), + ( + {"val1": "child02", "val2": 13}, + [ + ({"val1": "child020", "val2": 131}, None), + ({"val1": "child021", "val2": 132}, None), + ], + ), + ], + "root1": [ + ({"val1": "child10", "val2": 21}, None), + ({"val1": "child11", "val2": 22}, []), + ( + {"val1": "child12", "val2": 23}, + [ + ({"val1": "child120", "val2": 231}, None), + ({"val1": "child121", "val2": 232}, None), + ], + ), + ], }, - accessors=["val1", "val2"], - ) + {1, 2}, # Accessors everywhere except the root. + ), + # List of dictionary data, dictionary children at level 1 + ( + [ + ( + {"val1": "root0", "val2": 1}, + { + "child00": None, + "child01": {}, + "child02": [ + ({"val1": "child020", "val2": 131}, None), + ({"val1": "child021", "val2": 132}, None), + ], + }, + ), + ( + {"val1": "root1", "val2": 2}, + { + "child10": None, + "child11": {}, + "child12": [ + ({"val1": "child120", "val2": 231}, None), + ({"val1": "child121", "val2": 232}, None), + ], + }, + ), + ], + {0, 2}, # all accessors at first and last level + ), + ], +) +def test_create(data, all_accessor_levels): + """A tree source can be created from data in different formats""" + source = TreeSource(data=data, accessors=["val1", "val2"]) + + # Source has 2 roots + assert len(source) == 2 + assert source.can_have_children() + + # Root0 has 2 children + assert source[0].val1 == "root0" + assert len(source[0]) == 3 + assert source[0].can_have_children() + + # Root1 has 2 children + assert source[1].val1 == "root1" + assert len(source[1]) == 3 + assert source[1].can_have_children() + + # If level 0 has all accessors, check them as well. + if 0 in all_accessor_levels: + assert source[0].val2 == 1 + assert source[1].val2 == 2 + + # Children of root 0 + assert source[0][0].val1 == "child00" + assert len(source[0][0]) == 0 + assert not source[0][0].can_have_children() + + assert source[0][1].val1 == "child01" + assert len(source[0][1]) == 0 + assert source[0][1].can_have_children() + + assert source[0][2].val1 == "child02" + assert len(source[0][2]) == 2 + assert source[0][2].can_have_children() + + # Children of root 1 + assert source[1][0].val1 == "child10" + assert len(source[1][0]) == 0 + assert not source[1][0].can_have_children() + + assert source[1][1].val1 == "child11" + assert len(source[1][1]) == 0 + assert source[1][1].can_have_children() + + assert source[1][2].val1 == "child12" + assert len(source[1][2]) == 2 + assert source[1][2].can_have_children() - self.assertEqual(len(source), 3) + # If level 1 has all accessors, check them as well. + if 1 in all_accessor_levels: + assert source[0][0].val2 == 11 + assert source[0][1].val2 == 12 + assert source[0][2].val2 == 13 - listener = Mock() - source.add_listener(listener) + assert source[1][0].val2 == 21 + assert source[1][1].val2 == 22 + assert source[1][2].val2 == 23 - # Insert the new element - node = source.insert(None, 1, ("new element", 999)) + # Children of root 0, child 2 + assert source[0][2][0].val1 == "child020" + assert len(source[0][2][0]) == 0 + assert not source[0][2][0].can_have_children() - self.assertEqual(len(source), 4) - self.assertEqual(source[1], node) - self.assertEqual(node.val1, "new element") - self.assertEqual(node.val2, 999) + assert source[0][2][1].val1 == "child021" + assert len(source[0][2][1]) == 0 + assert not source[0][2][1].can_have_children() - listener.insert.assert_called_once_with(parent=None, index=1, item=node) + # Children of root 1, child 2 + assert source[1][2][0].val1 == "child120" + assert len(source[1][2][0]) == 0 + assert not source[1][2][0].can_have_children() - def test_insert_root_kwargs(self): - "A new root can be inserted using kwargs" - source = TreeSource( - data={ - ("first", 111): None, - ("second", 222): [], - ("third", 333): [("third.one", 331), ("third.two", 332)], - }, - accessors=["val1", "val2"], - ) + assert source[1][2][1].val1 == "child121" + assert len(source[1][2][1]) == 0 + assert not source[1][2][1].can_have_children() - self.assertEqual(len(source), 3) + # If level 2 has all accessors, check them as well. + if 2 in all_accessor_levels: + assert source[0][2][0].val2 == 131 + assert source[0][2][1].val2 == 132 - listener = Mock() - source.add_listener(listener) + assert source[1][2][0].val2 == 231 + assert source[1][2][1].val2 == 232 - # Insert the new element - node = source.insert(None, 1, dict(val1="new element", val2=999)) - self.assertEqual(len(source), 4) - self.assertEqual(source[1], node) - self.assertEqual(node.val1, "new element") - self.assertEqual(node.val2, 999) +def test_source_single_object(): + """A single object can be passed as root data""" + source = TreeSource(accessors=["val1", "val2"], data="A string") - listener.insert.assert_called_once_with(parent=None, index=1, item=node) + assert len(source) == 1 + assert source[0].val1 == "A string" - def test_insert_child_args(self): - "A new child can be inserted using value args" - source = TreeSource( - data={ - ("first", 111): None, - ("second", 222): [], - ("third", 333): [("third.one", 331), ("third.two", 332)], - }, - accessors=["val1", "val2"], - ) - self.assertEqual(len(source), 3) - self.assertEqual(len(source[2]), 2) +def test_single_object_child(): + """A single object can be passed as child data""" + source = TreeSource( + accessors=["val1", "val2"], + data={("root1", 1): "A string"}, + ) - listener = Mock() - source.add_listener(listener) + assert len(source) == 1 + assert source[0].val1 == "root1" + assert source[0].val2 == 1 + assert source[0].can_have_children() - # Insert the new element - node = source.insert(source[2], 1, dict(val1="new element", val2=999)) + assert len(source[0]) == 1 + assert len(source[0][0]) == 0 + assert source[0][0].val1 == "A string" + assert not source[0][0].can_have_children() - self.assertEqual(len(source), 3) - self.assertEqual(len(source[2]), 3) - self.assertEqual(source[2][1], node) - self.assertEqual(node.val1, "new element") - self.assertEqual(node.val2, 999) - listener.insert.assert_called_once_with(parent=source[2], index=1, item=node) +def test_modify_roots(source, listener): + """The roots of a source can be modified.""" + root = source[1] + assert root.val1 == "group2" - def test_insert_child_kwargs(self): - "A new child can be inserted using kwargs" - source = TreeSource( - data={ - ("first", 111): None, - ("second", 222): [], - ("third", 333): [("third.one", 331), ("third.two", 332)], - }, - accessors=["val1", "val2"], - ) + # delete the root + del source[1] - self.assertEqual(len(source), 3) - self.assertEqual(len(source[2]), 2) + # Removal notification was sent + listener.remove.assert_called_once_with(parent=None, index=1, item=root) + listener.reset_mock() + + # Root is no longer associated with the source + assert root._parent is None + assert root._source is None - listener = Mock() - source.add_listener(listener) + # Root count has dropped + assert len(source) == 1 - # Insert the new element - node = source.insert(source[2], 1, dict(val1="new element", val2=999)) + old_root_0 = source[0] - self.assertEqual(len(source), 3) - self.assertEqual(len(source[2]), 3) - self.assertEqual(source[2][1], node) - self.assertEqual(node.val1, "new element") - self.assertEqual(node.val2, 999) + # A child can be modified by index + source[0] = {"val1": "new"} - listener.insert.assert_called_once_with(parent=source[2], index=1, item=node) + # Root 0 has changed instance + assert old_root_0 is not source[0] - def test_insert_first_child(self): - "If a node previously didn't allow children, inserting changes this" - source = TreeSource( - data={ - ("first", 111): None, - ("second", 222): [], - ("third", 333): [("third.one", 331), ("third.two", 332)], - }, - accessors=["val1", "val2"], - ) - - self.assertEqual(len(source), 3) - self.assertFalse(source[0].can_have_children()) - self.assertEqual(len(source[0]), 0) - - listener = Mock() - source.add_listener(listener) - - # Insert the new element - node = source.insert(source[0], 0, dict(val1="new element", val2=999)) - - self.assertEqual(len(source), 3) - self.assertTrue(source[0].can_have_children()) - self.assertEqual(len(source[0]), 1) - self.assertEqual(source[0][0], node) - self.assertEqual(node.val1, "new element") - self.assertEqual(node.val2, 999) - - listener.insert.assert_called_once_with(parent=source[0], index=0, item=node) - - def test_append_root(self): - "A new root can be appended" - source = TreeSource( - data={ - ("first", 111): None, - ("second", 222): [], - ("third", 333): [("third.one", 331), ("third.two", 332)], - }, - accessors=["val1", "val2"], - ) + # Old child 0 is no longer associated with this node + assert old_root_0._source is None + assert old_root_0._parent is None - self.assertEqual(len(source), 3) + # Change notification was sent, the change is associated with the new item + listener.change.assert_called_once_with(item=source[0]) - listener = Mock() - source.add_listener(listener) + # Source's root count hasn't changed + assert len(source) == 1 - # Insert the new element - node = source.append(None, dict(val1="new element", val2=999)) - self.assertEqual(len(source), 4) - self.assertEqual(source[3], node) - self.assertEqual(node.val1, "new element") - self.assertEqual(node.val2, 999) +def test_iter_root(source): + """The roots of a source can be iterated over""" + assert "|".join(root.val1 for root in source) == "group1|group2" - listener.insert.assert_called_once_with(parent=None, index=3, item=node) - def test_append_child(self): - "A new child can be appended" - source = TreeSource( - data={ - ("first", 111): None, - ("second", 222): [], - ("third", 333): [("third.one", 331), ("third.two", 332)], - }, - accessors=["val1", "val2"], - ) +def test_clear(source, listener): + """A TreeSource can be cleared""" + source.clear() - self.assertEqual(len(source), 3) - self.assertEqual(len(source[2]), 2) + assert len(source) == 0 - listener = Mock() - source.add_listener(listener) + # Clear notification was sent + listener.clear.assert_called_once_with() - # Insert the new element - node = source.append(source[2], dict(val1="new element", val2=999)) - self.assertEqual(len(source), 3) - self.assertEqual(len(source[2]), 3) - self.assertEqual(source[2][2], node) - self.assertEqual(node.val1, "new element") - self.assertEqual(node.val2, 999) +@pytest.mark.parametrize( + "index, actual_index", + [ + (1, 1), # Positive, in range + (10, 2), # Positive, past positive limit + (-1, 1), # Negative, in range + (-10, 0), # Negative, past negative limit + ], +) +def test_insert(source, listener, index, actual_index): + """A new root node can be inserted""" + new_child = source.insert(index, {"val1": "new"}) - listener.insert.assert_called_once_with(parent=source[2], index=2, item=node) + # Source has one more root. + assert len(source) == 3 + assert source[actual_index] == new_child - def test_remove_root(self): - "A root can be removed" - source = TreeSource( - data={ - ("first", 111): None, - ("second", 222): [], - ("third", 333): [("third.one", 331), ("third.two", 332)], - }, - accessors=["val1", "val2"], - ) + # Root data is as expected + assert source[actual_index].val1 == "new" - self.assertEqual(len(source), 3) - self.assertEqual(len(source[2]), 2) + # Insert notification was sent, the change is associated with the new item + listener.insert.assert_called_once_with( + parent=None, + index=actual_index, + item=new_child, + ) - listener = Mock() - source.add_listener(listener) - # Remove the root element - node = source.remove(source[1]) +def test_insert_with_children(source, listener): + """A new root node can be inserted with children""" + new_child = source.insert( + 1, + {"val1": "new"}, + children=[ + ({"val1": "new child 1"}, None), + ({"val1": "new child 2"}, None), + ], + ) - self.assertEqual(len(source), 2) - self.assertEqual(len(source[1]), 2) + # Source has one more root. + assert len(source) == 3 + assert source[1] == new_child - listener.remove.assert_called_once_with(item=node, index=1, parent=None) + # Root data is as expected + assert source[1].val1 == "new" + assert len(source[1]) == 2 - def test_remove_child(self): - "A child can be removed" - source = TreeSource( - data={ - ("first", 111): None, - ("second", 222): [], - ("third", 333): [("third.one", 331), ("third.two", 332)], - }, - accessors=["val1", "val2"], - ) - - self.assertEqual(len(source), 3) - self.assertEqual(len(source[2]), 2) - - listener = Mock() - source.add_listener(listener) - - # Remove "third.two" - node = source.remove(source[2][1]) + # Children are also present + assert source[1][0].val1 == "new child 1" + assert not source[1][0].can_have_children() + assert source[1][1].val1 == "new child 2" + assert not source[1][1].can_have_children() - self.assertEqual(len(source), 3) - self.assertEqual(len(source[2]), 1) + # Insert notification was sent, the change is associated with the new item + listener.insert.assert_called_once_with( + parent=None, + index=1, + item=new_child, + ) - listener.remove.assert_called_once_with(item=node, index=1, parent=source[2]) - # Remove "third.one" - node = source.remove(source[2][0]) +def test_append(source, listener): + """A new root node can be appended""" + new_child = source.append({"val1": "new"}) - self.assertEqual(len(source), 3) - self.assertEqual(len(source[2]), 0) + # Source has one more root. + assert len(source) == 3 + assert source[2] == new_child - listener.remove.assert_any_call(item=node, index=0, parent=source[2]) + # Root data is as expected + assert source[2].val1 == "new" - def test___setitem___for_root(self): - "A root can be set (changed) with __setitem__" - source = TreeSource( - data={ - ("first", 111): None, - ("second", 222): [], - ("third", 333): [("third.one", 331), ("third.two", 332)], - }, - accessors=["val1", "val2"], - ) - - self.assertEqual(len(source), 3) - self.assertEqual(len(source[2]), 2) + # Insert notification was sent, the change is associated with the new item + listener.insert.assert_called_once_with( + parent=None, + index=2, + item=new_child, + ) - listener = Mock() - source.add_listener(listener) - # Re-assign the first root - source[0] = ("first_new", -111) +def test_append_with_children(source, listener): + """A new root node can be inserted with children""" + new_child = source.append( + {"val1": "new"}, + children=[ + ({"val1": "new child 1"}, None), + ({"val1": "new child 2"}, None), + ], + ) + + # Source has one more root. + assert len(source) == 3 + assert source[2] == new_child + + # Root data is as expected + assert source[2].val1 == "new" + assert len(source[2]) == 2 + + # Children are also present + assert source[2][0].val1 == "new child 1" + assert not source[2][0].can_have_children() + assert source[2][1].val1 == "new child 2" + assert not source[2][1].can_have_children() + + # Insert notification was sent, the change is associated with the new item + listener.insert.assert_called_once_with( + parent=None, + index=2, + item=new_child, + ) + + +def test_remove_root(source, listener): + """A root node can be removed""" + root = source[1] + source.remove(root) - self.assertEqual(len(source), 3) - self.assertEqual(source[0].val1, "first_new") - self.assertEqual(source[0].val2, -111) + # One less item in the source + assert len(source) == 1 + + # The root is no longer associated with the source + assert root._source is None + + # Removal notification was sent + listener.remove.assert_called_once_with(parent=None, index=1, item=root) - listener.change.assert_called_once_with(item=source[0]) - def test___setitem___for_child(self): - "A child can be set (changed) with __setitem__" - source = TreeSource( - data={ - ("first", 111): None, - ("second", 222): [], - ("third", 333): [("third.one", 331), ("third.two", 332)], - }, - accessors=["val1", "val2"], - ) +def test_remove_child(source, listener): + """A child node can be removed from a source""" + node = source[1][1] + source.remove(node) + + # The source still has 2 roots + assert len(source) == 2 + # ... but there's 1 less child + assert len(source[1]) == 2 - self.assertEqual(len(source), 3) - self.assertEqual(len(source[2]), 2) + # The child is no longer associated with the source, + # and the child isn't associated with it's parent. + assert node._source is None + assert node._parent is None - listener = Mock() - source.add_listener(listener) + # Removal notification was sent + listener.remove.assert_called_once_with(parent=source[1], index=1, item=node) - # Re-assign the first root - source[2][0] = ("third.one_new", -331) - self.assertEqual(len(source), 3) - self.assertEqual(source[2][0].val1, "third.one_new") - self.assertEqual(source[2][0].val2, -331) +def test_remove_non_root(source, listener): + """If a node isn't associated with this source, remove raises an error""" + other = Node(val="other") - listener.change.assert_called_once_with(item=source[2][0]) + with pytest.raises( + ValueError, + match=r" is not managed by this data source", + ): + source.remove(other) - def test_get_node_index(self): - "You can get the index of any node within a tree source, relative to its parent" - source = TreeSource( - data={ - ("first", 111): None, - ("second", 222): [], - ("third", 333): [("third.one", 331), ("third.two", 332)], - }, - accessors=["val1", "val2"], - ) +def test_index(source): + """A root can be found in a TreeSource""" + root = source[1] + assert source.index(root) == 1 - for i, node in enumerate(source): - self.assertEqual(i, source.index(node)) - # Test indices on deep nodes, too - third = source[2] - for i, node in enumerate(third): - self.assertEqual(i, source.index(node)) +def test_find(source): + """A node can be found by value""" + root1 = source[1] - # look-alike nodes are not equal, so index lookup should fail - with self.assertRaises(ValueError): - lookalike_node = Node(val1="second", val2=222) - source.index(lookalike_node) + # Append some additional roots + root2 = source.append({"val1": "group1", "val2": 333}) + root3 = source.append({"val1": "group2", "val2": 444}) - # Describe how edge cases are handled + # Find the child by a partial match of values. + assert source.find({"val1": "group2"}) == root1 - with self.assertRaises(AttributeError): - source.index(None) + # Find the child by a partial match of values, starting at the first match + assert source.find({"val1": "group2"}, start=root1) == root3 - with self.assertRaises(ValueError): - source.index(Node()) + # Find the child by a full match of values, starting at the first match + assert source.find({"val1": "group1", "val2": 333}) == root2 diff --git a/docs/reference/api/resources/sources/list_source.rst b/docs/reference/api/resources/sources/list_source.rst index 744ae8f1e7..da4f1155d9 100644 --- a/docs/reference/api/resources/sources/list_source.rst +++ b/docs/reference/api/resources/sources/list_source.rst @@ -33,35 +33,41 @@ the operations you'd expect on a normal Python list, such as ``insert``, ``remov item = source[0] print(f"Animal's name is {item.name}") - # Find a row with a name of "Thylacine" - row = source.find(name="Thylacine") + # Find an item with a name of "Thylacine" + item = source.find({"name": "Thylacine"}) - # Remove that row from the data - source.remove(row) + # Remove that item from the data + source.remove(item) - # Insert a new row at the start of the data - source.insert(0, name="Bettong", weight=1.2) + # Insert a new item at the start of the data + source.insert(0, {"name": "Bettong", "weight": 1.2}) -When initially constructing the ListSource, or when assigning a specific item in -the ListSource, each item can be: +The ListSource manages a list of :class:`~toga.sources.Row` objects. Each Row object in +the ListSource is an object that has all the attributes described by the ``accessors``. +A Row object will be constructed by the source for each item that is added or removed +from the ListSource. + +When creating a single Row for a ListSource (e.g., when inserting a new +item), the data for the Row can be specified as: * A dictionary, with the accessors mapping to the keys in the dictionary * Any iterable object (except for a string), with the accessors being mapped - onto the items in the iterable in order of definition + onto the items in the iterable in order of definition. This requires that the + iterable object have *at least* as many values as the number of accessors + defined on the TreeSource. * Any other object, which will be mapped onto the *first* accessor. -The ListSource manages a list of :class:`~toga.sources.Row` objects. Each Row object in -the ListSource is an object that has all the attributes described by the ``accessors``. -A Row object will be constructed by the source for each item that is added or removed -from the ListSource. +When initially constructing the ListSource, the data must be an iterable of values, each +of which can be converted into a Row. Although Toga provides ListSource, you are not required to use it directly. A ListSource will be transparently constructed for you if you provide a Python ``list`` object to a -GUI widget that displays list-like data (e.g., Table or Selection). Any object that -adheres to the same interface can be used as an alternative source of data for widgets -that support using a ListSource. See the background guide on :ref:`custom data sources +GUI widget that displays list-like data (i.e., :class:`toga.Table`, +:class:`toga.Selection`, or :class:`toga.DetailedList`). Any object that adheres to the +same interface can be used as an alternative source of data for widgets that support +using a ListSource. See the background guide on :ref:`custom data sources ` for more details. Custom List Sources diff --git a/docs/reference/api/resources/sources/tree_source.rst b/docs/reference/api/resources/sources/tree_source.rst index 471125685a..415d00d2a0 100644 --- a/docs/reference/api/resources/sources/tree_source.rst +++ b/docs/reference/api/resources/sources/tree_source.rst @@ -10,32 +10,127 @@ Data sources are abstractions that allow you to define the data being managed by application independent of the GUI representation of that data. For details on the use of data sources, see the :doc:`background guide `. -TreeSource is an implementation of an ordered hierarchical tree of values. Each node in -the tree can have children; those children can in turn have their own children. +TreeSource is an implementation of an ordered hierarchical tree of values. When a +TreeSource is created, it is given a list of ``accessors`` - these are the attributes +that all items managed by the TreeSource will have. The API provided by TreeSource is +:any:`list`-like; the operations you'd expect on a normal Python list, such as +``insert``, ``remove``, ``index``, and indexing with ``[]``, are also possible on a +TreeSource. These methods are available on the TreeSource itself to manipulate root +nodes, and also on each item of the TreeSource to manipulate children. + +.. code-block:: python + + from toga.sources import TreeSource + + source = TreeSource( + accessors=["name", "height"], + data={ + "Animals": [ + {"name": "Numbat", "height": 0.15}, + {"name": "Thylacine", "height": 0.6}, + ], + "Plants": [ + {"name": "Woollybush", "height": 2.4}, + {"name": "Boronia", "height": 0.9}, + ], + } + ) + + # Get the Animal group in the source. + # The Animal group won't have a "height" attribute. + group = source[0] + print(f"Group's name is {group.name}") + + # Get the second item in the animal group + animal = group[1] + print(f"Animals's name is {animal.name}; it is {animal.height}m tall.") + + # Find an animal with a name of "Thylacine" + row = source.find(parent=source[0], {"name": "Thylacine"}) + + # Remove that row from the data. Even though "Thylacine" isn't a root node, + # remove will find it and remove it from the list of animals. + source.remove(row) + + # Insert a new item at the start of the list of animals. + group.insert(0, {"name": "Bettong", "height": 0.35}) + + # Insert a new root item in the middle of the list of root nodes + source.insert(1, {"name": "Minerals"}) + +The TreeSource manages a tree of :class:`~toga.sources.Node` objects. Each Node object +in the TreeSource is an object that has all the attributes described by the +``accessors`` for the TreeSource. A Node object will be constructed by the source for +each item that is added or removed from the ListSource. + +Each Node object in the TreeSource can have children; those children can in turn have +their own children. A child that *cannot* have children is called a *leaf Node*. Whether +a child *can* have children is independent of whether it *does* have children - it is +possible for a Node to have no children and *not* be a leaf node. This is analogous to +files and directories on a filesystem: a file is a leaf Node, as it cannot have +children; a directory *can* contain files and other directories in it, but it can also +be empty. An empty directory would *not* be a leaf Node. + +When creating a single Node for a TreeSource (e.g., when inserting a new item), the data +for the Node can be specified as: + +* A dictionary, with the accessors mapping to the keys in the dictionary + +* Any iterable object (except for a string), with the accessors being mapped + onto the items in the iterable in order of definition. This requires that the + iterable object have *at least* as many values as the number of accessors + defined on the TreeSource. + +* Any other object, which will be mapped onto the *first* accessor. + +When constructing an entire ListSource, the data can be specified as: + +* A dictionary. The keys of the dictionary will be converted into Nodes, and used as + parents; the values of the dictionary will become the children of their corresponding + parent. + +* Any iterable object (except a string). Each value in the iterable will be treated as + a 2-item tuple, with first item being data for the parent Node, and the second item + being the child data. + +* Any other object. The object will be converted into a list containing a single node + with no children. + +When specifying children, a value of :any:`None` for the children will result in the +creation of a leaf node. Any other value will be processed recursively - so, a child +specifier can itself be a dictionary, an iterable of 2-tuples, or data for a single +child; each of which can specify their own children, and so on. + +Although Toga provides TreeSource, you are not required to use it directly. A TreeSource +will be transparently constructed for you if you provide Python primitives (e.g. +:any:`list`, :any:`dict`, etc) to a GUI widget that displays tree-like data (i.e., +:class:`toga.Tree`). Any object that adheres to the same interface can be used as an +alternative source of data for widgets that support using a TreeSource. See the +background guide on :ref:`custom data sources ` for more details. Custom TreeSources ------------------ -Any object that adheres to the TreeSource interface can be used as a data source. -Tree data sources must provide the following methods: +Any object that adheres to the TreeSource interface can be used as a data source. The +TreeSource, plus every node managed by the TreeSource, must provide the following +methods: -* ``__len__(self)`` - returns the number of root nodes in the tree +* ``__len__(self)`` - returns the number of children of this node, or the number of root + nodes for the TreeSource. -* ``__getitem__(self, index)`` - returns the root node at position ``index`` of the - tree. +* ``__getitem__(self, index)`` - returns the child at position ``index`` of a node, or + the root node at position ``index`` of the TreeSource. -Each node returned by the Tree source is required to expose attributes matching the -accessors for any widget using the source. The node is also required to implement the -following methods: +* ``can_have_children(self)`` - returns ``False`` if the node is a leaf node. TreeSource + should always return ``True``. -* ``__len__(self)`` - returns the number of children of the node. - -* ``__getitem__(self, index)`` - returns the child at position ``index`` of the node. - -* ``can_have_children(self)`` - returns True if the node is allowed to have children. - The result of this method does *not* depend on whether the node actually has any - children; it only describes whether it is allowed to store children. +A custom TreeSource must also generate ``insert``, ``remove`` and ``clear`` +notifications when items are added or removed from the source, or when children are +added or removed to nodes managed by the TreeSource. +Each node returned by the custom TreeSource is required to expose attributes matching +the accessors for any widget using the source. Any change to the values of these attributes +must generate a ``change`` notification on any listener to the custom ListSource. Reference --------- diff --git a/examples/tree/tree/app.py b/examples/tree/tree/app.py index 31fd275d95..0757f0cdaa 100644 --- a/examples/tree/tree/app.py +++ b/examples/tree/tree/app.py @@ -42,7 +42,12 @@ "rating": "5.4", "genre": "Comedy, Musical", }, - {"year": 1947, "title": "Keeper of the Bees", "rating": "6.3", "genre": "Drama"}, + { + "year": 1947, + "title": "Keeper of the Bees", + "rating": "6.3", + "genre": "Drama", + }, ] @@ -74,7 +79,7 @@ def insert_handler(self, widget, **kwargs): else: root = self.decade_1940s - self.tree.data.append(root, **item) + self.tree.data.append(root, item) def remove_handler(self, widget, **kwargs): selection = self.tree.selection @@ -96,25 +101,25 @@ def startup(self): ) self.decade_1940s = self.tree.data.append( - None, year="1940s", title="", rating="", genre="" + None, dict(year="1940s", title="", rating="", genre="") ) self.decade_1950s = self.tree.data.append( - None, year="1950s", title="", rating="", genre="" + None, dict(year="1950s", title="", rating="", genre="") ) self.decade_1960s = self.tree.data.append( - None, year="1960s", title="", rating="", genre="" + None, dict(year="1960s", title="", rating="", genre="") ) self.decade_1970s = self.tree.data.append( - None, year="1970s", title="", rating="", genre="" + None, dict(year="1970s", title="", rating="", genre="") ) self.decade_1980s = self.tree.data.append( - None, year="1980s", title="", rating="", genre="" + None, dict(year="1980s", title="", rating="", genre="") ) self.decade_1990s = self.tree.data.append( - None, year="1990s", title="", rating="", genre="" + None, dict(year="1990s", title="", rating="", genre="") ) self.decade_2000s = self.tree.data.append( - None, year="2000s", title="", rating="", genre="" + None, dict(year="2000s", title="", rating="", genre="") ) # Buttons From 855dd094fc51578870bdb54a7695727a796be30c Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Wed, 28 Jun 2023 14:42:03 +0800 Subject: [PATCH 02/30] Add changenotes and docs for Tree widget. --- changes/2017.feature.rst | 1 + changes/2017.removal.1.rst | 1 + core/src/toga/sources/tree_source.py | 2 +- docs/reference/api/index.rst | 2 +- .../api/resources/sources/tree_source.rst | 2 +- docs/reference/api/widgets/tree.rst | 14 +++++++++++--- docs/reference/data/widgets_by_platform.csv | 2 +- docs/reference/images/Tree.png | Bin 0 -> 45012 bytes examples/tree/tree/app.py | 16 ++++++++-------- 9 files changed, 25 insertions(+), 15 deletions(-) create mode 100644 changes/2017.feature.rst create mode 100644 changes/2017.removal.1.rst create mode 100644 docs/reference/images/Tree.png diff --git a/changes/2017.feature.rst b/changes/2017.feature.rst new file mode 100644 index 0000000000..9e581442ce --- /dev/null +++ b/changes/2017.feature.rst @@ -0,0 +1 @@ +The Tree widget now has 100% test coverage and complete API documentation. diff --git a/changes/2017.removal.1.rst b/changes/2017.removal.1.rst new file mode 100644 index 0000000000..882b3b9d01 --- /dev/null +++ b/changes/2017.removal.1.rst @@ -0,0 +1 @@ +The ``parent`` argument has been removed from the ``insert`` and ``append`` calls on ``TreeSource``. This improves consistency between the API for ``TreeSource`` and the API for ``list``. To insert or append a row in to a descendent of a TreeSource root, use ``insert`` and ``append`` on the parent node itself - i.e., ``source.insert(parent, index, ...)`` becomes ``parent.insert(index, ...)``, and ``source.insert(None, index, ...)`` becomes ``source.insert(index, ...)``. diff --git a/core/src/toga/sources/tree_source.py b/core/src/toga/sources/tree_source.py index 208817e1c4..89fc4a1b17 100644 --- a/core/src/toga/sources/tree_source.py +++ b/core/src/toga/sources/tree_source.py @@ -339,7 +339,7 @@ def append(self, data: Any, children: Any = None): def remove(self, node: Node): """Remove a node from the data source. - This will also remove the node if it is a descendent of a root node. + This will also remove the node if it is a descendant of a root node. :param node: The node to remove from the data source. """ diff --git a/docs/reference/api/index.rst b/docs/reference/api/index.rst index b846ddb34f..2c8a6a3b93 100644 --- a/docs/reference/api/index.rst +++ b/docs/reference/api/index.rst @@ -47,7 +47,7 @@ General widgets :doc:`Table ` A widget for displaying columns of tabular data. :doc:`TextInput ` A widget for the display and editing of a single line of text. :doc:`TimeInput ` A widget to select a clock time - :doc:`Tree ` Tree of data + :doc:`Tree ` A widget for displaying a hierarchical tree of tabular data. :doc:`WebView ` An embedded web browser. :doc:`Widget ` The abstract base class of all widgets. This class should not be be instantiated directly. diff --git a/docs/reference/api/resources/sources/tree_source.rst b/docs/reference/api/resources/sources/tree_source.rst index 415d00d2a0..1d37d53492 100644 --- a/docs/reference/api/resources/sources/tree_source.rst +++ b/docs/reference/api/resources/sources/tree_source.rst @@ -67,7 +67,7 @@ Each Node object in the TreeSource can have children; those children can in turn their own children. A child that *cannot* have children is called a *leaf Node*. Whether a child *can* have children is independent of whether it *does* have children - it is possible for a Node to have no children and *not* be a leaf node. This is analogous to -files and directories on a filesystem: a file is a leaf Node, as it cannot have +files and directories on a file system: a file is a leaf Node, as it cannot have children; a directory *can* contain files and other directories in it, but it can also be empty. An empty directory would *not* be a leaf Node. diff --git a/docs/reference/api/widgets/tree.rst b/docs/reference/api/widgets/tree.rst index a3703da2c2..82d2b38faa 100644 --- a/docs/reference/api/widgets/tree.rst +++ b/docs/reference/api/widgets/tree.rst @@ -1,6 +1,12 @@ Tree ==== +A widget for displaying a hierarchical tree of tabular data. + +.. figure:: /reference/images/Tree.png + :width: 300px + :align: center + .. rst-class:: widget-support .. csv-filter:: Availability (:ref:`Key `) :header-rows: 1 @@ -8,11 +14,14 @@ Tree :included_cols: 4,5,6,7,8,9 :exclude: {0: '(?!^(Tree|Component)$)'} -The tree widget is still under development. - Usage ----- +A Table uses a :class:`~toga.sources.TreeSource` to manage the data being displayed. +options. If ``data`` is not specified as a TreeSource, it will be converted into a +TreeSource at runtime. + + .. code-block:: python import toga @@ -30,7 +39,6 @@ Usage tree.insert(root2_2, None, 'root2.2.2') tree.insert(root2_2, None, 'root2.2.3') - Reference --------- diff --git a/docs/reference/data/widgets_by_platform.csv b/docs/reference/data/widgets_by_platform.csv index 5056890d3a..990fea9540 100644 --- a/docs/reference/data/widgets_by_platform.csv +++ b/docs/reference/data/widgets_by_platform.csv @@ -20,7 +20,7 @@ Switch,General Widget,:class:`~toga.Switch`,Switch,|y|,|y|,|y|,|y|,|y|,|b| Table,General Widget,:class:`~toga.Table`,A widget for displaying columns of tabular data.,|b|,|b|,|b|,,|b|, TextInput,General Widget,:class:`~toga.TextInput`,A widget for the display and editing of a single line of text.,|y|,|y|,|y|,|y|,|y|,|b| TimeInput,General Widget,:class:`~toga.TimeInput`,A widget to select a clock time,,,|y|,,|y|, -Tree,General Widget,:class:`~toga.Tree`,Tree of data,|b|,|b|,|b|,,, +Tree,General Widget,:class:`~toga.Tree`,A widget for displaying a hierarchical tree of tabular data.,|b|,|b|,|b|,,, WebView,General Widget,:class:`~toga.WebView`,A panel for displaying HTML,|y|,|y|,|y|,|y|,|y|, Widget,General Widget,:class:`~toga.Widget`,The base widget,|y|,|y|,|y|,|y|,|y|,|b| Box,Layout Widget,:class:`~toga.Box`,Container for components,|y|,|y|,|y|,|y|,|y|,|b| diff --git a/docs/reference/images/Tree.png b/docs/reference/images/Tree.png new file mode 100644 index 0000000000000000000000000000000000000000..9dd61810ca77dc159c8c9335a18b6efb9af6c03c GIT binary patch literal 45012 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 T_XF~!JK#q_Mg>+SWg7B70Fl}c literal 0 HcmV?d00001 diff --git a/examples/tree/tree/app.py b/examples/tree/tree/app.py index 0757f0cdaa..3376853d62 100644 --- a/examples/tree/tree/app.py +++ b/examples/tree/tree/app.py @@ -79,7 +79,7 @@ def insert_handler(self, widget, **kwargs): else: root = self.decade_1940s - self.tree.data.append(root, item) + root.append(item) def remove_handler(self, widget, **kwargs): selection = self.tree.selection @@ -101,25 +101,25 @@ def startup(self): ) self.decade_1940s = self.tree.data.append( - None, dict(year="1940s", title="", rating="", genre="") + dict(year="1940s", title="", rating="", genre="") ) self.decade_1950s = self.tree.data.append( - None, dict(year="1950s", title="", rating="", genre="") + dict(year="1950s", title="", rating="", genre="") ) self.decade_1960s = self.tree.data.append( - None, dict(year="1960s", title="", rating="", genre="") + dict(year="1960s", title="", rating="", genre="") ) self.decade_1970s = self.tree.data.append( - None, dict(year="1970s", title="", rating="", genre="") + dict(year="1970s", title="", rating="", genre="") ) self.decade_1980s = self.tree.data.append( - None, dict(year="1980s", title="", rating="", genre="") + dict(year="1980s", title="", rating="", genre="") ) self.decade_1990s = self.tree.data.append( - None, dict(year="1990s", title="", rating="", genre="") + dict(year="1990s", title="", rating="", genre="") ) self.decade_2000s = self.tree.data.append( - None, dict(year="2000s", title="", rating="", genre="") + dict(year="2000s", title="", rating="", genre="") ) # Buttons From 4d3a7100ad6a5b1be365434d3cc7de5953de4466 Mon Sep 17 00:00:00 2001 From: Bruno Rino Date: Thu, 8 Jun 2023 15:54:03 +0200 Subject: [PATCH 03/30] Made headings optional. If not provided, the header will not be displayed Also made headings a read-only property, to reflect that changes to it had no effect. --- cocoa/src/toga_cocoa/widgets/tree.py | 11 ++++---- core/src/toga/sources/accessors.py | 17 ++++++++++-- core/src/toga/widgets/tree.py | 40 +++++++++++++++++++++++++--- gtk/src/toga_gtk/widgets/tree.py | 7 ++++- 4 files changed, 63 insertions(+), 12 deletions(-) diff --git a/cocoa/src/toga_cocoa/widgets/tree.py b/cocoa/src/toga_cocoa/widgets/tree.py index e024e3f4a8..acd4cbba22 100644 --- a/cocoa/src/toga_cocoa/widgets/tree.py +++ b/cocoa/src/toga_cocoa/widgets/tree.py @@ -218,6 +218,8 @@ def create(self): self.tree.columnAutoresizingStyle = NSTableViewColumnAutoresizingStyle.Uniform self.tree.usesAlternatingRowBackgroundColors = True self.tree.allowsMultipleSelection = self.interface.multiple_select + if self.interface.headings is None: + self.tree.headerView = None # Create columns for the tree self.columns = [] @@ -225,9 +227,7 @@ def create(self): # conversion from ObjC string to Python String, create the # ObjC string once and cache it. self.column_identifiers = {} - for i, (heading, accessor) in enumerate( - zip(self.interface.headings, self.interface._accessors) - ): + for accessor in self.interface._accessors: column_identifier = at(accessor) self.column_identifiers[id(column_identifier)] = accessor column = NSTableColumn.alloc().initWithIdentifier(column_identifier) @@ -238,8 +238,9 @@ def create(self): # column.sortDescriptorPrototype = sort_descriptor self.tree.addTableColumn(column) self.columns.append(column) - - column.headerCell.stringValue = heading + if self.interface.headings: + for i, heading in enumerate(self.interface.headings): + self.columns[i].headerCell.stringValue = heading # Put the tree arrows in the first column. self.tree.outlineTableColumn = self.columns[0] diff --git a/core/src/toga/sources/accessors.py b/core/src/toga/sources/accessors.py index 3e1cf4e83f..548a2ac3e1 100644 --- a/core/src/toga/sources/accessors.py +++ b/core/src/toga/sources/accessors.py @@ -63,7 +63,17 @@ def build_accessors( :returns: The final list of accessors. """ if accessors: - if isinstance(accessors, dict): + if headings is None: + if not isinstance(accessors, (list, tuple)): + raise TypeError( + "When no headings are provided, accessors must be a list or tuple" + ) + if not all(accessors): + raise ValueError( + "When no headings are provided, all accessors must be defined" + ) + result = accessors + elif isinstance(accessors, dict): result = [ accessors[h] if h in accessors else to_accessor(h) for h in headings ] @@ -76,6 +86,9 @@ def build_accessors( for h, a in zip(headings, accessors) ] else: - result = [to_accessor(h) for h in headings] + if headings: + result = [to_accessor(h) for h in headings] + else: + raise ValueError("Either headings or accessors must be provided") return result diff --git a/core/src/toga/widgets/tree.py b/core/src/toga/widgets/tree.py index e10ec3d0b1..cd2429a46f 100644 --- a/core/src/toga/widgets/tree.py +++ b/core/src/toga/widgets/tree.py @@ -1,7 +1,7 @@ import warnings from toga.handlers import wrapped_handler -from toga.sources import TreeSource +from toga.sources import Source, TreeSource from toga.sources.accessors import build_accessors from .base import Widget @@ -10,7 +10,8 @@ class Tree(Widget): """Tree Widget. - :param headings: The list of headings for the interface. + :param headings: The list of headings for the interface. If not provided, + the header will not be displayed :param id: An identifier for this widget. :param style: An optional style object. If no style is provided then a new one will be created for the widget. @@ -45,7 +46,7 @@ class Tree(Widget): def __init__( self, - headings, + headings=None, id=None, style=None, data=None, @@ -67,7 +68,31 @@ def __init__( # End backwards compatibility. ###################################################################### - self.headings = headings + # Synthesize accessors if needed + if not headings and not accessors: + if not data or isinstance(data, Source): + raise ValueError( + "Either headings or accessors must be set for the provided data." + ) + if isinstance(data, (list, tuple)): + node_data = data[0] + if isinstance(node_data, dict): + accessors = list(node_data.keys()) + elif isinstance(node_data, (list, tuple)): + accessors = [f"_{i}" for i in range(len(node_data))] + else: + raise TypeError( + "The values inside the data argument must be a list, tuple, dict" + ) + elif isinstance(data, dict): + node_data = list(data.keys())[0] + accessors = [f"_{i}" for i in range(len(node_data))] + else: + raise TypeError( + "The data argument must be a list, tuple, dict, or inherit toga.sources.Source" + ) + + self._headings = headings self._accessors = build_accessors(headings, accessors) self._multiple_select = multiple_select self._data = None @@ -111,6 +136,13 @@ def data(self, data): self._data.add_listener(self._impl) self._impl.change_source(source=self._data) + @property + def headings(self): + """ + :returns: The headings of the tree + """ + return self._headings + @property def multiple_select(self): """Does the table allow multiple rows to be selected?""" diff --git a/gtk/src/toga_gtk/widgets/tree.py b/gtk/src/toga_gtk/widgets/tree.py index 5d0e2d2f38..51a0e9b7b4 100644 --- a/gtk/src/toga_gtk/widgets/tree.py +++ b/gtk/src/toga_gtk/widgets/tree.py @@ -28,7 +28,12 @@ def create(self): self.selection.set_mode(Gtk.SelectionMode.SINGLE) self.selection.connect("changed", self.gtk_on_select) - for i, heading in enumerate(self.interface.headings): + if self.interface.headings: + _headings = self.interface.headings + else: + _headings = self.interface._accessors + self.treeview.set_headers_visible(False) + for i, heading in enumerate(_headings): renderer = Gtk.CellRendererText() column = Gtk.TreeViewColumn(heading, renderer, text=i + 1) self.treeview.append_column(column) From f708bbd1ba7b16f84e3ac0e23cd5a2d7cf3ecd9a Mon Sep 17 00:00:00 2001 From: Bruno Rino Date: Thu, 8 Jun 2023 15:56:50 +0200 Subject: [PATCH 04/30] Tree nodes made from a dict-type data are now kept in order, instead of being sorted. --- core/src/toga/sources/tree_source.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/core/src/toga/sources/tree_source.py b/core/src/toga/sources/tree_source.py index 89fc4a1b17..179daa366f 100644 --- a/core/src/toga/sources/tree_source.py +++ b/core/src/toga/sources/tree_source.py @@ -257,12 +257,11 @@ def _create_nodes(self, parent: Node | None, value: Any): if isinstance(value, dict): return [ self._create_node(parent=parent, data=data, children=children) - for data, children in sorted(value.items()) + for data, children in value.items() ] elif hasattr(value, "__iter__") and not isinstance(value, str): return [ - self._create_node(parent=parent, data=item[0], children=item[1]) - for item in value + self._create_node(data, children) for data, children in value.items() ] else: return [self._create_node(parent=parent, data=value)] From e5a428ceac5e5c27e74c2ce17865fe29a20739c4 Mon Sep 17 00:00:00 2001 From: Bruno Rino Date: Mon, 12 Jun 2023 15:42:33 +0200 Subject: [PATCH 05/30] Improve test coverage --- changes/1767.feature.txt | 2 + core/tests/widgets/test_tree.py | 612 ++++++++++++++++++++++++--- dummy/src/toga_dummy/widgets/tree.py | 6 + 3 files changed, 558 insertions(+), 62 deletions(-) create mode 100644 changes/1767.feature.txt diff --git a/changes/1767.feature.txt b/changes/1767.feature.txt new file mode 100644 index 0000000000..f53e314286 --- /dev/null +++ b/changes/1767.feature.txt @@ -0,0 +1,2 @@ +Headings are no longer mandatory for Tree widgets. +If headings are not provided, the widget will not display its header bar. diff --git a/core/tests/widgets/test_tree.py b/core/tests/widgets/test_tree.py index a1725b99be..29bc615544 100644 --- a/core/tests/widgets/test_tree.py +++ b/core/tests/widgets/test_tree.py @@ -1,85 +1,573 @@ +from unittest.mock import Mock + +import pytest + import toga -from toga.sources import TreeSource -from toga_dummy.utils import TestCase +from toga.sources import Source, TreeSource +from toga.sources.tree_source import Node +from toga_dummy.utils import assert_action_performed + + +@pytest.fixture +def headings(): + return [f"Heading {x}" for x in range(3)] + + +@pytest.fixture +def accessors(): + return [f"heading{i}" for i in range(3)] + + +@pytest.fixture +def missing_value(): + return "---" + + +@pytest.fixture +def dict_data(): + return { + ("one", 1): [ + ("one.one", 1.1), + ("one.two", 2.1), + ], + ("two", 2): None, + } + + +@pytest.fixture +def treesource_data(dict_data, accessors): + return TreeSource(dict_data, accessors) + + +@pytest.fixture +def list_data(dict_data): + return list(dict_data.keys()) + + +@pytest.fixture +def tuple_data(dict_data): + return tuple(dict_data.keys()) + + +@pytest.fixture +def mysource_headings(): + return ["Item name", "Item value"] + + +@pytest.fixture +def mysource_accessors(): + return ["name", "value"] + + +@pytest.fixture +def mysource_data(): + class MySource(Source): + def __init__(self, data): + super().__init__() + self._data = data + + def __len__(self): + return len(self._data) + + def __getitem__(self, index): + return self._data[index] + + def can_have_children(self): + return True + + class MyNode: + def __init__(self, name, value, children=None): + self._name = name + self._value = value + self._children = children + + @property + def name(self): + return self._name + + @property + def value(self): + return self._value + + def __len__(self): + return len(self._children) + + def __getitem__(self, index): + return self._children[index] + + def can_have_children(self): + return self._children is not None + + return MySource( + [ + MyNode( + "one", + 1, + children=[ + MyNode("one.one", 1.1), + MyNode("one.two", 1.2), + ], + ), + MyNode("two", 2, children=[]), + ] + ) + + +@pytest.fixture +def tree(headings, missing_value): + return toga.Tree(headings=headings, missing_value=missing_value) + + +def test_widget_created(tree, headings, missing_value): + "A tree widget can be created" + assert tree._impl.interface is tree + assert_action_performed(tree, "create Tree") + assert isinstance(tree.data, TreeSource) + assert tree.headings is headings + assert tree.missing_value is missing_value + assert len(tree.data) == 0 + + +def test_widget_not_created(): + "A tree widget cannot be created without arguments" + with pytest.raises(ValueError): + _ = toga.Tree() + + +@pytest.mark.parametrize( + "value, expected", + [ + (False, False), + (True, True), + ], +) +def test_multiselect(headings, value, expected): + "The multiselect status of the widget can be set." + # Widget is initially not multiselect. + tree = toga.Tree(headings=headings, data=None) + assert tree.multiple_select is False + + # Set multiselect explicitly. + tree = toga.Tree(headings=headings, data=None, multiple_select=value) + assert tree.multiple_select is expected + + # Cannot change multiselect + with pytest.raises(AttributeError): + tree.multiple_select = not value + + +def test_selection(tree): + "The selection property can be read." + _ = tree.selection + assert_action_performed(tree, "get selection") + + +def test_on_select(tree): + "The on_select handler can be invoked." + # No handler initially + assert tree._on_select._raw is None + + # Define and set a new callback + handler = Mock() + + tree.on_select = handler + + assert tree.on_select._raw == handler + + # Invoke the callback + tree._impl.simulate_select() + + # Callback was invoked + handler.assert_called_once_with(tree) + + +def test_on_double_click(tree): + "The on_double_click handler can be invoked." + # No handler initially + assert tree._on_double_click._raw is None + + # Define and set a new callback + handler = Mock() + + tree.on_double_click = handler + + assert tree.on_double_click._raw == handler + + # Invoke the callback + tree._impl.simulate_double_click() + + # Callback was invoked + handler.assert_called_once_with(tree) + + +def test_accessor_synthesis_list_of_tuples(): + "Accessors are synthesized from a list of tuples, when no headings nor accessors are provided." + data = [ + ("one", 1), + ("two", 2), + ] + tree = toga.Tree(data=data) + assert isinstance(tree.data, TreeSource) + assert isinstance(tree.data[0], Node) + + # Accessors are syntesized + assert len(tree._accessors) == 2 + with pytest.raises(AttributeError): + _ = tree.data[0].heading0 == "one" + assert tree.data[0].can_have_children() is False -class TreeTests(TestCase): - def setUp(self): - super().setUp() +def test_accessor_synthesis_dict_of_tuples(): + "Accessors are synthesized from a dict of tuples, when no headings nor accessors are provided." + data = { + ("one", 1): None, + ("two", 2): None, + } + tree = toga.Tree(data=data) + assert isinstance(tree.data, TreeSource) + assert isinstance(tree.data[0], Node) - self.headings = [f"Heading {x}" for x in range(3)] + # Accessors are syntesized + assert len(tree._accessors) == 2 + with pytest.raises(AttributeError): + _ = tree.data[0].heading0 == "one" + assert tree.data[0].can_have_children() is False - self.data = None - self.tree = toga.Tree( - headings=self.headings, - data=self.data, - ) - def test_widget_created(self): - self.assertEqual(self.tree._impl.interface, self.tree) - self.assertActionPerformed(self.tree, "create Tree") - self.assertIsInstance(self.tree.data, TreeSource) +def test_accessor_inference_list_of_dicts(): + "Accessors are infered from a list of dicts, if no headings nor accessors are provided." + data = [ + {"heading0": "one", "heading1": 1}, + {"heading0": "two", "heading1": 2}, + ] + tree = toga.Tree(data=data) + assert isinstance(tree.data, TreeSource) + assert isinstance(tree.data[0], Node) - self.assertEqual(self.tree.headings, self.headings) + # Accessors are taken from the data + assert len(tree._accessors) == 2 + assert tree.data[0].heading0 == "one" + assert tree.data[0].can_have_children() is False - def test_setter_creates_tree_with_TreeSource_data(self): - data = {("one", 1): [("one.one", 1.1), ("one.two", 2.1)], ("two", 2): None} - accessors = [f"heading{i}" for i in range(3)] +def test_invalid_data(): + "Accessors cannot be infered from data of wrong type." + data = { + ("one", 1), + ("two", 2), + } + with pytest.raises(TypeError): + _ = toga.Tree(data=data) - self.tree.data = TreeSource(data=data, accessors=accessors) - self.assertIsInstance(self.tree.data, TreeSource) - self.assertEqual(self.tree.data[0].heading0, "one") - self.assertEqual(self.tree.data[0][0].heading1, 1.1) - self.assertEqual(self.tree.data[1].heading1, 2) +def test_invalid_values(): + "Accessors cannot be infered from data values of wrong type." + data = [ + "one", + "two", + ] + with pytest.raises(TypeError): + _ = toga.Tree(data=data) - def test_setter_creates_tree_with_dict_data(self): - self.data = { - ("first", 111): None, - ("second", 222): [], - ("third", 333): [ - ("third.one", 331), - {"heading_0": "third.two", "heading_1": 332}, + +def test_mixed_native_data(headings, accessors): + "Heterogeneous data can be be provided." + data = { + ("one", 1): [ + ("one.one", 1.1), + ("one.two", (toga.Icon.DEFAULT_ICON, 1.2)), + ((toga.Icon.DEFAULT_ICON, "one.three"), 1.3), + {accessors[0]: "one.four", accessors[1]: 1.4}, + {accessors[1]: (toga.Icon.DEFAULT_ICON, 1.5), accessors[0]: "one.five"}, + ], + ("two", 2): { + ("two.one", 2.1): [ + ("two.one.one", "2.1.1"), ], - } - self.tree.data = self.data + ("two.two", 2.2): None, + }, + ("three", 3): None, + ("four", 4): {}, + ("five", 5): [], + } + tree = toga.Tree(headings=headings, data=data, accessors=accessors) + assert isinstance(tree.data, TreeSource) + assert isinstance(tree.data[0], Node) + assert tree.data[0].heading0 == "one" + assert tree.data[0][0].heading0 == "one.one" + assert tree.data[0][0].heading1 == 1.1 + assert tree.data[0][1].heading1 == (toga.Icon.DEFAULT_ICON, 1.2) + assert tree.data[0][2].heading0 == (toga.Icon.DEFAULT_ICON, "one.three") + assert tree.data[0][3].heading1 == 1.4 + assert tree.data[0][4].heading1 == (toga.Icon.DEFAULT_ICON, 1.5) + assert tree.data[1].heading1 == 2 + assert tree.data[1][0][0].heading1 == "2.1.1" + assert tree.data[0].can_have_children() is True + assert tree.data[0][0].can_have_children() is False + assert tree.data[1][0].can_have_children() is True + assert tree.data[1][1].can_have_children() is False + assert tree.data[2].can_have_children() is False + assert tree.data[3].can_have_children() is True + assert tree.data[4].can_have_children() is True + + +################## +# Check that the tree accessors connect to the data +################## + + +def _check_data_access(tree): + assert getattr(tree.data[0], tree._accessors[0]) == "one" + assert getattr(tree.data[0], tree._accessors[1]) == 1 + + +def test_constructor_without_data_nor_headings_nor_accessors(): + "A tree cannot be created without data, headings, accessors" + with pytest.raises(ValueError): + _ = toga.Tree() + + +# mysource_data +################## + + +def test_constructor_with_source_data( + mysource_headings, mysource_accessors, mysource_data +): + "A tree can be created with custom Source data, headings, accessors" + tree = toga.Tree( + headings=mysource_headings, data=mysource_data, accessors=mysource_accessors + ) + _check_data_access(tree) + + +def test_constructor_with_source_data_without_headings( + mysource_accessors, mysource_data +): + "A tree can be created with custom Source data, accessors, without headings" + tree = toga.Tree(data=mysource_data, accessors=mysource_accessors) + _check_data_access(tree) + + +def test_constructor_with_source_data_with_headings_without_accessors( + mysource_headings, mysource_data +): + "A tree can be created with custom Source data, with headings, without accessors" + tree = toga.Tree(headings=mysource_headings, data=mysource_data) + with pytest.raises(AttributeError): + # unfortunately, the headings do not match the data + _check_data_access(tree) + + +def test_constructor_with_source_data_without_headings_nor_accessors(mysource_data): + "A tree cannot be created with custom Source data, without headings, accessors" + with pytest.raises(ValueError): + _ = toga.Tree(data=mysource_data) + + +def test_data_setter_with_source_data( + mysource_headings, mysource_accessors, mysource_data +): + "A custom Source can be assigned to .data on a tree with accessors, headings" + tree = toga.Tree(headings=mysource_headings, accessors=mysource_accessors) + tree.data = mysource_data + _check_data_access(tree) + + +def test_data_setter_with_source_data_without_headings( + mysource_accessors, mysource_data +): + "A custom Source can be assigned to .data on a tree with accessors, without headings" + tree = toga.Tree(accessors=mysource_accessors) + tree.data = mysource_data + _check_data_access(tree) + + +def test_data_setter_with_source_data_with_headings_without_accessors( + mysource_headings, mysource_data +): + "A custom Source data cannot be assigned to .data on a tree without accessors" + tree = toga.Tree(headings=mysource_headings) + tree.data = mysource_data + with pytest.raises(AttributeError): + _check_data_access(tree) + + +# treesource_data +################## + + +def test_constructor_with_treesource_data(headings, accessors, treesource_data): + "A tree can be created with TreeSource data, headings, accessors" + tree = toga.Tree(headings=headings, data=treesource_data, accessors=accessors) + _check_data_access(tree) + + +def test_constructor_with_treesource_data_without_accessors(headings, treesource_data): + "A tree can be created with TreeSource data, headings, without accessors" + tree = toga.Tree(headings=headings, data=treesource_data) + with pytest.raises(AttributeError): + # unfortunately, the headings do not match the data + _check_data_access(tree) + + +def test_constructor_with_treesource_data_without_headings(accessors, treesource_data): + "A tree can be created with TreeSource data, accessors, without headings" + tree = toga.Tree(data=treesource_data, accessors=accessors) + _check_data_access(tree) + + +def test_constructor_with_treesource_data_without_headings_nor_accessors( + treesource_data, +): + "A tree cannot be created with TreeSource data, without headings, accessors" + with pytest.raises(ValueError): + _ = toga.Tree(data=treesource_data) + + +def test_data_setter_with_treesource_data(headings, accessors, treesource_data): + "A TreeSource can be assigned to .data on a tree with accessors, headings" + tree = toga.Tree(headings=headings, accessors=accessors) + tree.data = treesource_data + _check_data_access(tree) + + +def test_data_setter_with_treesource_data_without_accessors(headings, treesource_data): + "A TreeSource can be assigned to .data on a tree with headings, without accessors" + tree = toga.Tree(headings=headings) + tree.data = treesource_data + with pytest.raises(AttributeError): + # unfortunately, the headings do not match the data + _check_data_access(tree) + + +def test_data_setter_with_treesource_data_without_headings(accessors, treesource_data): + "A TreeSource can be assigned to .data on a tree with accessors, without headings" + tree = toga.Tree(accessors=accessors) + tree.data = treesource_data + _check_data_access(tree) + + +# dict_data +################## + + +def test_constructor_with_dict_data(headings, accessors, dict_data): + "A tree can be created with dict data, headings, accessors" + tree = toga.Tree(headings=headings, data=dict_data, accessors=accessors) + _check_data_access(tree) + + +def test_constructor_with_dict_data_without_accessors(headings, dict_data): + "A tree can be created with dict data, headings, without accessors" + tree = toga.Tree(headings=headings, data=dict_data) + _check_data_access(tree) + # accessors are derived from headings + assert tree._accessors[0] == "heading_0" + + +def test_constructor_with_dict_data_without_headings(accessors, dict_data): + "A tree can be created with dict data, accessors, without headings" + tree = toga.Tree(data=dict_data, accessors=accessors) + _check_data_access(tree) + + +def test_constructor_with_dict_data_without_headings_nor_accessors(dict_data): + "A tree can be created with dict data, without accessors, headings" + tree = toga.Tree(data=dict_data) + _check_data_access(tree) + # accessors are syntesized + with pytest.raises(AttributeError): + _ = tree.data[0].heading0 + with pytest.raises(AttributeError): + _ = tree.data[0].heading_0 + + +def test_data_setter_with_dict_data(headings, accessors, dict_data): + "A dict can be assigned to .data on a tree with accessors, headings" + tree = toga.Tree(headings=headings, accessors=accessors) + tree.data = dict_data + _check_data_access(tree) + + +def test_data_setter_with_dict_data_without_accessors(headings, dict_data): + "A dict can be assigned to .data on a tree with headings, without accessors" + tree = toga.Tree(headings=headings) + tree.data = dict_data + _check_data_access(tree) + # accessors are derived from headings + assert tree._accessors[0] == "heading_0" + + +def test_data_setter_with_dict_data_without_headings(accessors, dict_data): + "A dict can be assigned to .data on a tree with accessors, without headings" + tree = toga.Tree(accessors=accessors) + tree.data = dict_data + _check_data_access(tree) + + +# list_data / tuple_data +################## + + +@pytest.mark.parametrize("data", ["list_data", "tuple_data"]) +def test_constructor_with_list_data(headings, accessors, data, request): + "A tree can be created with list or tuple data, headings, accessors" + tree = toga.Tree( + headings=headings, data=request.getfixturevalue(data), accessors=accessors + ) + _check_data_access(tree) + + +@pytest.mark.parametrize("data", ["list_data", "tuple_data"]) +def test_constructor_with_list_data_without_accessors(headings, data, request): + "A tree can be created with list or tuple data, headings, without accessors" + tree = toga.Tree(headings=headings, data=request.getfixturevalue(data)) + _check_data_access(tree) + # accessors are derived from headings + assert tree._accessors[0] == "heading_0" - self.assertIsInstance(self.tree.data, TreeSource) - self.assertFalse(self.tree.data[0].can_have_children()) - self.assertTrue(self.tree.data[1].can_have_children()) - self.assertEqual(self.tree.data[1].heading_1, 222) - self.assertEqual(self.tree.data[2][0].heading_1, 331) - def test_data_setter_creates_tree_with_tuple_data(self): - pass +@pytest.mark.parametrize("data", ["list_data", "tuple_data"]) +def test_constructor_with_list_data_without_headings(accessors, data, request): + "A tree can be created with list or tuple data, accessors, without headings" + tree = toga.Tree(data=request.getfixturevalue(data), accessors=accessors) + _check_data_access(tree) - def test_data_setter_creates_tree_with_list_data(self): - pass - def test_data_setter_creates_tree_with_data_source(self): - pass +@pytest.mark.parametrize("data", ["list_data", "tuple_data"]) +def test_constructor_with_list_data_without_headings_nor_accessors(data, request): + "A tree can be created with list or tuple data, without accessors, headings" + tree = toga.Tree(data=request.getfixturevalue(data)) + _check_data_access(tree) + # accessors are syntesized + with pytest.raises(AttributeError): + _ = tree.data[0].heading0 + with pytest.raises(AttributeError): + _ = tree.data[0].heading_0 - def test_data_setter_creates_tree_with_data_none(self): - pass - def test_multiselect_getter(self): - super().setUp() - self.headings = [f"Heading {x}" for x in range(3)] +@pytest.mark.parametrize("data", ["list_data", "tuple_data"]) +def test_data_setter_with_list_data(headings, accessors, data, request): + "A list or tuple can be assigned to .data on a tree with accessors, headings" + tree = toga.Tree(headings=headings, accessors=accessors) + tree.data = request.getfixturevalue(data) + _check_data_access(tree) - self.data = None - self.tree = toga.Tree( - headings=self.headings, - data=self.data, - multiple_select=True, - ) - self.assertEqual(self.tree.multiple_select, True) +@pytest.mark.parametrize("data", ["list_data", "tuple_data"]) +def test_data_setter_with_list_data_without_accessors(headings, data, request): + "A list or tuple can be assigned to .data on a tree with headings, without accessors" + tree = toga.Tree(headings=headings) + tree.data = request.getfixturevalue(data) + _check_data_access(tree) + # accessors are derived from headings + assert tree._accessors[0] == "heading_0" - self.tree = toga.Tree( - headings=self.headings, - data=self.data, - multiple_select=False, - ) - self.assertEqual(self.tree.multiple_select, False) +@pytest.mark.parametrize("data", ["list_data", "tuple_data"]) +def test_data_setter_with_list_data_without_headings(accessors, data, request): + "A list or tuple can be assigned to .data on a tree with accessors, without headings" + tree = toga.Tree(accessors=accessors) + tree.data = request.getfixturevalue(data) + _check_data_access(tree) diff --git a/dummy/src/toga_dummy/widgets/tree.py b/dummy/src/toga_dummy/widgets/tree.py index cd3a4c00f3..1e0f143acb 100644 --- a/dummy/src/toga_dummy/widgets/tree.py +++ b/dummy/src/toga_dummy/widgets/tree.py @@ -29,3 +29,9 @@ def set_on_select(self, handler): def set_on_double_click(self, handler): self._set_value("on_double_click", handler) + + def simulate_select(self): + self.interface.on_select(None) + + def simulate_double_click(self): + self.interface.on_double_click(None) From de4d0fd540cbe187b90acd9d15a0a46baeb65b57 Mon Sep 17 00:00:00 2001 From: Bruno Rino Date: Mon, 12 Jun 2023 16:15:16 +0200 Subject: [PATCH 06/30] Improved documentation --- core/src/toga/widgets/tree.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/core/src/toga/widgets/tree.py b/core/src/toga/widgets/tree.py index cd2429a46f..8c8b548818 100644 --- a/core/src/toga/widgets/tree.py +++ b/core/src/toga/widgets/tree.py @@ -16,9 +16,22 @@ class Tree(Widget): :param style: An optional style object. If no style is provided then a new one will be created for the widget. :param data: The data to display in the widget. Can be an instance of - :class:`~toga.sources.TreeSource`, a list, dict or tuple with data to - display in the tree widget, or a class instance which implements the - interface of :class:`~toga.sources.TreeSource`. Entries can be: + :class:`~toga.sources.TreeSource` or a class instance which implements + the interface of :class:`~toga.sources.TreeSource`. + It can also be a list (or tuple), in this case each list item will + populate a row, without hierarchy. + It can also be a dict, in this case a key will populate a row, and its + value will populate children rows. + + Each of these entries (list item, dict key and values) must consist of + a collection of cells, either: + + - a list (or tuple), in which case the accessors will be matched by + index. + + - a dict, in which case the key is used as the accessor. + + The data displayed in a tree cell are: - any Python object ``value`` with a string representation. This string will be shown in the widget. If ``value`` has an attribute @@ -29,7 +42,8 @@ class Tree(Widget): ``value`` will be used as text. :param accessors: Optional; a list of attributes to access the value in the - columns. If not given, the headings will be taken. + columns. If not given, the headings will be taken. If no headings were + given, accessors wil be sintesized (only for list, dict or tuple data). :param multiple_select: Boolean; if ``True``, allows for the selection of multiple rows. Defaults to ``False``. :param on_select: A handler to be invoked when the user selects one or From b94cb139a3f463515f33842db64def8542418e31 Mon Sep 17 00:00:00 2001 From: Bruno Rino Date: Tue, 13 Jun 2023 10:12:32 +0200 Subject: [PATCH 07/30] Fix typos --- core/src/toga/widgets/tree.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/toga/widgets/tree.py b/core/src/toga/widgets/tree.py index 8c8b548818..674bd43c23 100644 --- a/core/src/toga/widgets/tree.py +++ b/core/src/toga/widgets/tree.py @@ -43,7 +43,7 @@ class Tree(Widget): :param accessors: Optional; a list of attributes to access the value in the columns. If not given, the headings will be taken. If no headings were - given, accessors wil be sintesized (only for list, dict or tuple data). + given, accessors will be synthesized (only for list, dict or tuple data). :param multiple_select: Boolean; if ``True``, allows for the selection of multiple rows. Defaults to ``False``. :param on_select: A handler to be invoked when the user selects one or From 14e55c46e9504c036321edfcb09858af2746484e Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 29 Jun 2023 09:40:14 +0800 Subject: [PATCH 08/30] Core Tree API fully documented and tested. --- core/src/toga/widgets/tree.py | 387 ++++++----- core/tests/test_deprecated_factory.py | 6 - core/tests/widgets/test_tree.py | 912 +++++++++++++------------- dummy/src/toga_dummy/widgets/tree.py | 43 +- 4 files changed, 725 insertions(+), 623 deletions(-) diff --git a/core/src/toga/widgets/tree.py b/core/src/toga/widgets/tree.py index 674bd43c23..1f569496cd 100644 --- a/core/src/toga/widgets/tree.py +++ b/core/src/toga/widgets/tree.py @@ -1,216 +1,301 @@ +from __future__ import annotations + import warnings +from typing import Any from toga.handlers import wrapped_handler -from toga.sources import Source, TreeSource -from toga.sources.accessors import build_accessors +from toga.sources import Node, Source, TreeSource +from toga.sources.accessors import build_accessors, to_accessor from .base import Widget class Tree(Widget): - """Tree Widget. - - :param headings: The list of headings for the interface. If not provided, - the header will not be displayed - :param id: An identifier for this widget. - :param style: An optional style object. If no style is provided then a new - one will be created for the widget. - :param data: The data to display in the widget. Can be an instance of - :class:`~toga.sources.TreeSource` or a class instance which implements - the interface of :class:`~toga.sources.TreeSource`. - It can also be a list (or tuple), in this case each list item will - populate a row, without hierarchy. - It can also be a dict, in this case a key will populate a row, and its - value will populate children rows. - - Each of these entries (list item, dict key and values) must consist of - a collection of cells, either: - - - a list (or tuple), in which case the accessors will be matched by - index. - - - a dict, in which case the key is used as the accessor. - - The data displayed in a tree cell are: - - - any Python object ``value`` with a string representation. This - string will be shown in the widget. If ``value`` has an attribute - ``icon``, instance of (:class:`~toga.Icon`), the icon will be - shown in front of the text. - - - a tuple ``(icon, value)`` where again the string representation of - ``value`` will be used as text. - - :param accessors: Optional; a list of attributes to access the value in the - columns. If not given, the headings will be taken. If no headings were - given, accessors will be synthesized (only for list, dict or tuple data). - :param multiple_select: Boolean; if ``True``, allows for the selection of - multiple rows. Defaults to ``False``. - :param on_select: A handler to be invoked when the user selects one or - multiple rows. - :param on_double_click: A handler to be invoked when the user double clicks - a row. - :param missing_value: value for replacing a missing value in the data - source. (Default: None). When 'None', a warning message will be - shown. - """ - - MIN_WIDTH = 100 - MIN_HEIGHT = 100 - def __init__( self, - headings=None, + headings: list[str] | None = None, id=None, style=None, - data=None, - accessors=None, - multiple_select=False, - on_select=None, - on_double_click=None, - missing_value=None, - factory=None, # DEPRECATED! + data: Any = None, + accessors: list[str] | None = None, + multiple_select: bool = False, + on_select: callable | None = None, + on_activate: callable | None = None, + missing_value: str = "", + on_double_click=None, # DEPRECATED ): + """Create a new Tree Widget. + + Inherits from :class:`~toga.widgets.base.Widget`. + + :param headings: The list of headings for the tree. A value of :any:`None` + can be used to specify a tree without headings. Individual headings cannot + include newline characters; any text after a newline will be ignored + :param id: The ID for the widget. + :param style: A style object. If no style is provided, a default style will be + applied to the widget. + :param data: The data to be displayed on the tree. Can be a list of values or a + TreeSource. See the definition of the :attr:`data` property for details on + how data can be specified and used. + :param accessors: A list of names, with same length as :attr:`headings`, that + describes the attributes of the data source that will be used to populate + each column. If unspecified, accessors will be automatically derived from + the tree headings. + :param multiple_select: Does the tree allow multiple selection? + :param on_select: Initial :any:`on_select` handler. + :param on_activate: Initial :any:`on_activate` handler. + :param missing_value: The string that will be used to populate a cell when a + data source doesn't provided a value for a given attribute. + :param on_double_click: **DEPRECATED**; use :attr:`on_activate`. + """ super().__init__(id=id, style=style) + ###################################################################### - # 2022-09: Backwards compatibility + # 2023-06: Backwards compatibility ###################################################################### - # factory no longer used - if factory: - warnings.warn("The factory argument is no longer used.", DeprecationWarning) + if on_double_click: + if on_activate: + raise ValueError("Cannot specify both on_double_click and on_activate") + else: + warnings.warn( + "Tree.on_double_click has been renamed Tree.on_activate.", + DeprecationWarning, + ) + on_activate = on_double_click ###################################################################### # End backwards compatibility. ###################################################################### - # Synthesize accessors if needed - if not headings and not accessors: - if not data or isinstance(data, Source): - raise ValueError( - "Either headings or accessors must be set for the provided data." - ) - if isinstance(data, (list, tuple)): - node_data = data[0] - if isinstance(node_data, dict): - accessors = list(node_data.keys()) - elif isinstance(node_data, (list, tuple)): - accessors = [f"_{i}" for i in range(len(node_data))] - else: - raise TypeError( - "The values inside the data argument must be a list, tuple, dict" - ) - elif isinstance(data, dict): - node_data = list(data.keys())[0] - accessors = [f"_{i}" for i in range(len(node_data))] - else: - raise TypeError( - "The data argument must be a list, tuple, dict, or inherit toga.sources.Source" - ) - - self._headings = headings - self._accessors = build_accessors(headings, accessors) - self._multiple_select = multiple_select - self._data = None - if missing_value is None: - print( - "WARNING: Using empty string for missing value in data. " - "Define a 'missing_value' on the table to silence this message" + if headings is not None: + self._headings = [heading.split("\n")[0] for heading in headings] + self._accessors = build_accessors(self._headings, accessors) + elif accessors is not None: + self._headings = None + self._accessors = accessors + else: + raise ValueError( + "Cannot create a tree without either headings or accessors" ) + self._multiple_select = multiple_select self._missing_value = missing_value or "" - self._on_select = None - self._on_double_click = None + # Prime some properties that need to exist before the tree is created. + self.on_select = None + self.on_activate = None + self._data = None self._impl = self.factory.Tree(interface=self) self.data = data self.on_select = on_select - self.on_double_click = on_double_click + self.on_activate = on_activate @property - def data(self): + def enabled(self) -> bool: + """Is the widget currently enabled? i.e., can the user interact with the widget? + Tree widgets cannot be disabled; this property will always return True; any + attempt to modify it will be ignored. """ - :returns: The data source of the tree + return True + + @enabled.setter + def enabled(self, value): + pass + + def focus(self): + "No-op; Tree cannot accept input focus" + pass + + @property + def data(self) -> TreeSource: + """The data to display in the tree, as a TreeSource. + + When specifying data: + + * A TreeSource will be used as-is + + * A value of None is turned into an empty TreeSource. + + * A dictionary will be converted so that the keys of the dictionary are + converted into Nodes, and the values are processed recursively as child nodes. + + * Any iterable object (except a string). Each value in the iterable will be + treated as a 2-item tuple, with first item being data for the parent Node, and + the second item being processed recursively as child nodes. + + * Any other object will be converted into a list containing a single node with + no children. + + When converting individual values into Nodes: + + * If the value is a dictionary, the keys of the dictionary will become the + attributes of the Node. + + * All other values will be converted into a Node with attributes matching the + ``accessors`` provided at time of construction (or the ``accessors`` that were + derived from the ``headings`` that were provided at construction). + + If the value is a string, or any other a non-iterable object, the Node will + have a single attribute matching the first accessor. + + If the value is a list, tuple, or any other iterable, values in the iterable + will be mapped in order to the accessors. + """ return self._data @data.setter - def data(self, data): - """Set the data source of the data. - - :param data: Data source - :type data: ``dict`` or ``class`` - """ + def data(self, data: Any): if data is None: self._data = TreeSource(accessors=self._accessors, data=[]) - elif isinstance(data, (list, tuple, dict)): - self._data = TreeSource(accessors=self._accessors, data=data) - else: + elif isinstance(data, Source): self._data = data + else: + self._data = TreeSource(accessors=self._accessors, data=data) self._data.add_listener(self._impl) self._impl.change_source(source=self._data) @property - def headings(self): - """ - :returns: The headings of the tree - """ - return self._headings - - @property - def multiple_select(self): - """Does the table allow multiple rows to be selected?""" + def multiple_select(self) -> bool: + """Does the tree allow multiple rows to be selected?""" return self._multiple_select @property - def selection(self): - """The current selection of the table. + def selection(self) -> list[Node] | Node | None: + """The current selection of the tree. + + If multiple selection is enabled, returns a list of Tree objects from the data + source matching the current selection. An empty list is returned if no rows are + selected. - A value of None indicates no selection. If the tree allows multiple selection, - returns a list of selected data nodes. Otherwise, returns a single data node. + If multiple selection is *not* enabled, returns the selected Node object, or + :any:`None` if no row is currently selected. """ return self._impl.get_selection() + def append_column(self, heading: str, accessor: str | None = None): + """Append a column to the end of the tree. + + :param heading: The heading for the new column. + :param accessor: The accessor to use on the data source when populating + the tree. If not specified, an accessor will be derived from the + heading. + """ + self.insert_column(len(self._accessors), heading, accessor=accessor) + + def insert_column( + self, + index: int, + heading: str | None, + accessor: str | None = None, + ): + """Insert an additional column into the tree. + + :param index: The index at which to insert the column, or the accessor of the + column before which the column should be inserted. + :param heading: The heading for the new column. If the tree doesn't have + headings, the value will be ignored. + :param accessor: The accessor to use on the data source when populating the + tree. If not specified, an accessor will be derived from the heading. An + accessor *must* be specified if the tree doesn't have headings. + """ + if self._headings is None: + if accessor is None: + raise ValueError("Must specify an accessor on a tree without headings") + heading = None + elif not accessor: + accessor = to_accessor(heading) + + if isinstance(index, str): + index = self._accessors.index(index) + else: + # Re-interpret negative indicies, and clip indicies outside valid range. + if index < 0: + index = max(len(self._accessors) + index, 0) + else: + index = min(len(self._accessors), index) + + if self._headings is not None: + self._headings.insert(index, heading) + self._accessors.insert(index, accessor) + + self._impl.insert_column(index, heading, accessor) + + def remove_column(self, column: int | str): + """Remove a tree column. + + :param column: The index of the column to remove, or the accessor of the column + to remove. + """ + if isinstance(column, str): + # Column is a string; use as-is + index = self._accessors.index(column) + else: + if column < 0: + index = len(self._accessors) + column + else: + index = column + + # Remove column + if self._headings is not None: + del self._headings[index] + del self._accessors[index] + self._impl.remove_column(index) + @property - def missing_value(self): - return self._missing_value + def headings(self) -> list[str]: + """The column headings for the tree""" + return self._headings @property - def on_select(self): - """The callable function for when a node on the Tree is selected. The - provided callback function has to accept two arguments tree - (:obj:`Tree`) and node (``Node`` or ``None``). + def accessors(self) -> list[str]: + """The accessors used to populate the tree""" + return self._accessors - :rtype: ``callable`` + @property + def missing_value(self) -> str: + """The value that will be used when a data row doesn't provide an value for an + attribute. """ + return self._missing_value + + @property + def on_select(self) -> callable: + """The callback function that is invoked when a row of the tree is selected.""" return self._on_select @on_select.setter - def on_select(self, handler): - """Set the function to be executed on node select. - - :param handler: callback function - :type handler: ``callable`` - """ + def on_select(self, handler: callable): self._on_select = wrapped_handler(self, handler) - self._impl.set_on_select(self._on_select) @property - def on_double_click(self): - """The callable function for when a node on the Tree is selected. The - provided callback function has to accept two arguments tree - (:obj:`Tree`) and node (``Node`` or ``None``). + def on_activate(self) -> callable: + """The callback function that is invoked when a row of the tree is activated, + usually with a double click or similar action.""" + return self._on_activate - :rtype: ``callable`` - """ - return self._on_double_click + @on_activate.setter + def on_activate(self, handler): + self._on_activate = wrapped_handler(self, handler) + + ###################################################################### + # 2023-06: Backwards compatibility + ###################################################################### + + @property + def on_double_click(self): + """**DEPRECATED**: Use ``on_activate``""" + warnings.warn( + "Tree.on_double_click has been renamed Tree.on_activate.", + DeprecationWarning, + ) + return self.on_activate @on_double_click.setter def on_double_click(self, handler): - """Set the function to be executed on node double click. - - :param handler: callback function - :type handler: ``callable`` - """ - self._on_double_click = wrapped_handler(self, handler) - self._impl.set_on_double_click(self._on_double_click) + warnings.warn( + "Tree.on_double_click has been renamed Tree.on_activate.", + DeprecationWarning, + ) + self.on_activate = handler diff --git a/core/tests/test_deprecated_factory.py b/core/tests/test_deprecated_factory.py index 227497b3cf..c9fd925aea 100644 --- a/core/tests/test_deprecated_factory.py +++ b/core/tests/test_deprecated_factory.py @@ -91,12 +91,6 @@ def test_split_container_created(self): self.assertEqual(widget._impl.interface, widget) self.assertNotEqual(widget.factory, self.factory) - def test_tree_created(self): - with self.assertWarns(DeprecationWarning): - widget = toga.Tree(headings=["Test"], factory=self.factory) - self.assertEqual(widget._impl.interface, widget) - self.assertNotEqual(widget.factory, self.factory) - ###################################################################### # End backwards compatibility. ###################################################################### diff --git a/core/tests/widgets/test_tree.py b/core/tests/widgets/test_tree.py index 29bc615544..392eb33ee5 100644 --- a/core/tests/widgets/test_tree.py +++ b/core/tests/widgets/test_tree.py @@ -3,571 +3,575 @@ import pytest import toga -from toga.sources import Source, TreeSource -from toga.sources.tree_source import Node -from toga_dummy.utils import assert_action_performed +from toga.sources import TreeSource +from toga_dummy.utils import ( + assert_action_not_performed, + assert_action_performed, + assert_action_performed_with, +) @pytest.fixture -def headings(): - return [f"Heading {x}" for x in range(3)] +def on_select_handler(): + return Mock() @pytest.fixture -def accessors(): - return [f"heading{i}" for i in range(3)] +def on_activate_handler(): + return Mock() @pytest.fixture -def missing_value(): - return "---" +def source(): + source = TreeSource( + data={ + ("group1", 1, "A**"): [ + ( + {"key": "A first", "value": 110, "other": "AA*"}, + None, + ), + ( + {"key": "A second", "value": 120, "other": "AB*"}, + [], + ), + ( + {"key": "A third", "value": 130, "other": "AC*"}, + [ + ({"key": "A third-first", "value": 131, "other": "ACA"}, None), + ({"key": "A third-second", "value": 132, "other": "ACB"}, None), + ], + ), + ], + ("group2", 2, "B**"): [ + ( + {"key": "B first", "value": 210, "other": "BA*"}, + None, + ), + ( + {"key": "B second", "value": 220, "other": "BB*"}, + [], + ), + ( + {"key": "B third", "value": 230, "other": "BC*"}, + [ + ({"key": "B third-first", "value": 231, "other": "BCA"}, None), + ({"key": "B third-second", "value": 232, "other": "BCB"}, None), + ], + ), + ], + }, + accessors=["key", "value"], + ) + return source @pytest.fixture -def dict_data(): - return { - ("one", 1): [ - ("one.one", 1.1), - ("one.two", 2.1), - ], - ("two", 2): None, - } - +def tree(source, on_select_handler, on_activate_handler): + return toga.Tree( + ["Title", "Value"], + accessors=["key", "value"], + data=source, + on_select=on_select_handler, + on_activate=on_activate_handler, + ) -@pytest.fixture -def treesource_data(dict_data, accessors): - return TreeSource(dict_data, accessors) +def test_tree_created(): + "An minimal Tree can be created" + tree = toga.Tree(["First", "Second"]) + assert tree._impl.interface is tree + assert_action_performed(tree, "create Tree") -@pytest.fixture -def list_data(dict_data): - return list(dict_data.keys()) + assert len(tree.data) == 0 + assert tree.headings == ["First", "Second"] + assert tree.accessors == ["first", "second"] + assert not tree.multiple_select + assert tree.missing_value == "" + assert tree.on_select._raw is None + assert tree.on_activate._raw is None -@pytest.fixture -def tuple_data(dict_data): - return tuple(dict_data.keys()) +def test_create_with_values(source, on_select_handler, on_activate_handler): + "A Tree can be created with initial values" + tree = toga.Tree( + ["First", "Second"], + data=source, + accessors=["primus", "secondus"], + multiple_select=True, + on_select=on_select_handler, + on_activate=on_activate_handler, + missing_value="Boo!", + ) + assert tree._impl.interface == tree + assert_action_performed(tree, "create Tree") + assert len(tree.data) == 2 + assert tree.headings == ["First", "Second"] + assert tree.accessors == ["primus", "secondus"] + assert tree.multiple_select + assert tree.missing_value == "Boo!" + assert tree.on_select._raw == on_select_handler + assert tree.on_activate._raw == on_activate_handler -@pytest.fixture -def mysource_headings(): - return ["Item name", "Item value"] +def test_create_with_acessor_overrides(): + "A Tree can partially override accessors" + tree = toga.Tree( + ["First", "Second"], + accessors={"First": "override"}, + ) + assert tree._impl.interface == tree + assert_action_performed(tree, "create Tree") -@pytest.fixture -def mysource_accessors(): - return ["name", "value"] + assert len(tree.data) == 0 + assert tree.headings == ["First", "Second"] + assert tree.accessors == ["override", "second"] -@pytest.fixture -def mysource_data(): - class MySource(Source): - def __init__(self, data): - super().__init__() - self._data = data - - def __len__(self): - return len(self._data) - - def __getitem__(self, index): - return self._data[index] - - def can_have_children(self): - return True - - class MyNode: - def __init__(self, name, value, children=None): - self._name = name - self._value = value - self._children = children - - @property - def name(self): - return self._name - - @property - def value(self): - return self._value - - def __len__(self): - return len(self._children) - - def __getitem__(self, index): - return self._children[index] - - def can_have_children(self): - return self._children is not None - - return MySource( - [ - MyNode( - "one", - 1, - children=[ - MyNode("one.one", 1.1), - MyNode("one.two", 1.2), - ], - ), - MyNode("two", 2, children=[]), - ] +def test_create_no_headings(): + "A Tree can be created with no headings" + tree = toga.Tree( + headings=None, + accessors=["primus", "secondus"], ) + assert tree._impl.interface == tree + assert_action_performed(tree, "create Tree") + assert len(tree.data) == 0 + assert tree.headings is None + assert tree.accessors == ["primus", "secondus"] -@pytest.fixture -def tree(headings, missing_value): - return toga.Tree(headings=headings, missing_value=missing_value) +def test_create_headings_required(): + "A Tree requires either headingscan be created with no headings" + with pytest.raises( + ValueError, + match=r"Cannot create a tree without either headings or accessors", + ): + toga.Tree() -def test_widget_created(tree, headings, missing_value): - "A tree widget can be created" - assert tree._impl.interface is tree - assert_action_performed(tree, "create Tree") - assert isinstance(tree.data, TreeSource) - assert tree.headings is headings - assert tree.missing_value is missing_value - assert len(tree.data) == 0 +def test_disable_no_op(tree): + "Tree doesn't have a disabled state" + # Enabled by default + assert tree.enabled + + # Try to disable the widget + tree.enabled = False -def test_widget_not_created(): - "A tree widget cannot be created without arguments" - with pytest.raises(ValueError): - _ = toga.Tree() + # Still enabled. + assert tree.enabled + + +def test_focus_noop(tree): + "Focus is a no-op." + + tree.focus() + assert_action_not_performed(tree, "focus") @pytest.mark.parametrize( - "value, expected", + "data, all_attributes, extra_attributes", [ - (False, False), - (True, True), + # Dictionary of single values + ( + { + "People": { + "Alice": None, + "Bob": None, + "Charlie": None, + } + }, + False, + False, + ), + # Dictionary of tuples + ( + { + ("People", None, None): { + ("Alice", 123, "extra1"): None, + ("Bob", 234, "extra2"): None, + ("Charlie", 345, "extra4"): None, + } + }, + True, + False, + ), + # List of tuples with tuples + ( + [ + ( + ("People", None, None), + [ + (("Alice", 123, "extra1"), None), + (("Bob", 234, "extra2"), None), + (("Charlie", 345, "extra3"), None), + ], + ), + ], + True, + False, + ), + # List of tuples with Dictionaries + ( + [ + ( + {"key": "People"}, + [ + ({"key": "Alice", "value": 123, "other": "extra1"}, None), + ({"key": "Bob", "value": 234, "other": "extra2"}, None), + ({"key": "Charlie", "value": 345, "other": "extra3"}, None), + ], + ), + ], + True, + True, + ), ], ) -def test_multiselect(headings, value, expected): - "The multiselect status of the widget can be set." - # Widget is initially not multiselect. - tree = toga.Tree(headings=headings, data=None) - assert tree.multiple_select is False +def test_set_data(tree, on_select_handler, data, all_attributes, extra_attributes): + "Data can be set from a variety of sources" - # Set multiselect explicitly. - tree = toga.Tree(headings=headings, data=None, multiple_select=value) - assert tree.multiple_select is expected + # The selection hasn't changed yet. + on_select_handler.assert_not_called() - # Cannot change multiselect - with pytest.raises(AttributeError): - tree.multiple_select = not value + # Change the data + tree.data = data + # This triggered the select handler + on_select_handler.assert_called_once_with(tree) -def test_selection(tree): - "The selection property can be read." - _ = tree.selection - assert_action_performed(tree, "get selection") + # A TreeSource has been constructed + assert isinstance(tree.data, TreeSource) + assert len(tree.data) == 1 + assert len(tree.data[0]) == 3 + # The accessors are mapped in order. + assert tree.data[0].key == "People" -def test_on_select(tree): - "The on_select handler can be invoked." - # No handler initially - assert tree._on_select._raw is None + assert tree.data[0][0].key == "Alice" + assert tree.data[0][1].key == "Bob" + assert tree.data[0][2].key == "Charlie" - # Define and set a new callback - handler = Mock() + if all_attributes: + assert tree.data[0][0].value == 123 + assert tree.data[0][1].value == 234 + assert tree.data[0][2].value == 345 - tree.on_select = handler + if extra_attributes: + assert tree.data[0][0].other == "extra1" + assert tree.data[0][1].other == "extra2" + assert tree.data[0][2].other == "extra3" - assert tree.on_select._raw == handler - # Invoke the callback - tree._impl.simulate_select() +def test_single_selection(tree, on_select_handler): + "The current selection can be retrieved" + # Selection is initially empty + assert tree.selection is None + on_select_handler.assert_not_called() - # Callback was invoked - handler.assert_called_once_with(tree) + # Select an item + tree._impl.simulate_selection((0, 1)) + # Selection returns a single row + assert tree.selection == tree.data[0][1] -def test_on_double_click(tree): - "The on_double_click handler can be invoked." - # No handler initially - assert tree._on_double_click._raw is None + # Selection handler was triggered + on_select_handler.assert_called_once_with(tree) - # Define and set a new callback - handler = Mock() - tree.on_double_click = handler +def test_multiple_selection(source, on_select_handler): + "A multi-select tree can have the selection retrieved" + tree = toga.Tree( + ["Title", "Value"], + data=source, + multiple_select=True, + on_select=on_select_handler, + ) + # Selection is initially empty + assert tree.selection == [] + on_select_handler.assert_not_called() - assert tree.on_double_click._raw == handler + # Select an item + tree._impl.simulate_selection([(0, 1), (1, 2, 1)]) - # Invoke the callback - tree._impl.simulate_double_click() + # Selection returns a list of rows + assert tree.selection == [tree.data[0][1], tree.data[1][2][1]] - # Callback was invoked - handler.assert_called_once_with(tree) + # Selection handler was triggered + on_select_handler.assert_called_once_with(tree) -def test_accessor_synthesis_list_of_tuples(): - "Accessors are synthesized from a list of tuples, when no headings nor accessors are provided." - data = [ - ("one", 1), - ("two", 2), - ] - tree = toga.Tree(data=data) - assert isinstance(tree.data, TreeSource) - assert isinstance(tree.data[0], Node) - - # Accessors are syntesized - assert len(tree._accessors) == 2 - with pytest.raises(AttributeError): - _ = tree.data[0].heading0 == "one" - assert tree.data[0].can_have_children() is False - - -def test_accessor_synthesis_dict_of_tuples(): - "Accessors are synthesized from a dict of tuples, when no headings nor accessors are provided." - data = { - ("one", 1): None, - ("two", 2): None, - } - tree = toga.Tree(data=data) - assert isinstance(tree.data, TreeSource) - assert isinstance(tree.data[0], Node) - - # Accessors are syntesized - assert len(tree._accessors) == 2 - with pytest.raises(AttributeError): - _ = tree.data[0].heading0 == "one" - assert tree.data[0].can_have_children() is False - - -def test_accessor_inference_list_of_dicts(): - "Accessors are infered from a list of dicts, if no headings nor accessors are provided." - data = [ - {"heading0": "one", "heading1": 1}, - {"heading0": "two", "heading1": 2}, - ] - tree = toga.Tree(data=data) - assert isinstance(tree.data, TreeSource) - assert isinstance(tree.data[0], Node) - - # Accessors are taken from the data - assert len(tree._accessors) == 2 - assert tree.data[0].heading0 == "one" - assert tree.data[0].can_have_children() is False - - -def test_invalid_data(): - "Accessors cannot be infered from data of wrong type." - data = { - ("one", 1), - ("two", 2), - } - with pytest.raises(TypeError): - _ = toga.Tree(data=data) - - -def test_invalid_values(): - "Accessors cannot be infered from data values of wrong type." - data = [ - "one", - "two", - ] - with pytest.raises(TypeError): - _ = toga.Tree(data=data) - - -def test_mixed_native_data(headings, accessors): - "Heterogeneous data can be be provided." - data = { - ("one", 1): [ - ("one.one", 1.1), - ("one.two", (toga.Icon.DEFAULT_ICON, 1.2)), - ((toga.Icon.DEFAULT_ICON, "one.three"), 1.3), - {accessors[0]: "one.four", accessors[1]: 1.4}, - {accessors[1]: (toga.Icon.DEFAULT_ICON, 1.5), accessors[0]: "one.five"}, - ], - ("two", 2): { - ("two.one", 2.1): [ - ("two.one.one", "2.1.1"), - ], - ("two.two", 2.2): None, - }, - ("three", 3): None, - ("four", 4): {}, - ("five", 5): [], - } - tree = toga.Tree(headings=headings, data=data, accessors=accessors) - assert isinstance(tree.data, TreeSource) - assert isinstance(tree.data[0], Node) - assert tree.data[0].heading0 == "one" - assert tree.data[0][0].heading0 == "one.one" - assert tree.data[0][0].heading1 == 1.1 - assert tree.data[0][1].heading1 == (toga.Icon.DEFAULT_ICON, 1.2) - assert tree.data[0][2].heading0 == (toga.Icon.DEFAULT_ICON, "one.three") - assert tree.data[0][3].heading1 == 1.4 - assert tree.data[0][4].heading1 == (toga.Icon.DEFAULT_ICON, 1.5) - assert tree.data[1].heading1 == 2 - assert tree.data[1][0][0].heading1 == "2.1.1" - assert tree.data[0].can_have_children() is True - assert tree.data[0][0].can_have_children() is False - assert tree.data[1][0].can_have_children() is True - assert tree.data[1][1].can_have_children() is False - assert tree.data[2].can_have_children() is False - assert tree.data[3].can_have_children() is True - assert tree.data[4].can_have_children() is True +def test_activation(tree, on_activate_handler): + "A row can be activated" + # Activate an item + tree._impl.simulate_activate((0, 1)) -################## -# Check that the tree accessors connect to the data -################## + # Activate handler was triggered; the activated node is provided + on_activate_handler.assert_called_once_with(tree, node=tree.data[0][1]) -def _check_data_access(tree): - assert getattr(tree.data[0], tree._accessors[0]) == "one" - assert getattr(tree.data[0], tree._accessors[1]) == 1 +def test_insert_column_accessor(tree): + """A column can be inserted at an accessor""" + tree.insert_column("value", "New Column", accessor="extra") + # The column was added + assert_action_performed_with( + tree, + "insert column", + index=1, + heading="New Column", + accessor="extra", + ) + assert tree.headings == ["Title", "New Column", "Value"] + assert tree.accessors == ["key", "extra", "value"] -def test_constructor_without_data_nor_headings_nor_accessors(): - "A tree cannot be created without data, headings, accessors" - with pytest.raises(ValueError): - _ = toga.Tree() +def test_insert_column_unknown_accessor(tree): + """If the insertion index accessor is unknown, an error is raised""" + with pytest.raises(ValueError, match=r"'unknown' is not in list"): + tree.insert_column("unknown", "New Column", accessor="extra") -# mysource_data -################## +def test_insert_column_index(tree): + """A column can be inserted""" -def test_constructor_with_source_data( - mysource_headings, mysource_accessors, mysource_data -): - "A tree can be created with custom Source data, headings, accessors" - tree = toga.Tree( - headings=mysource_headings, data=mysource_data, accessors=mysource_accessors - ) - _check_data_access(tree) - + tree.insert_column(1, "New Column", accessor="extra") -def test_constructor_with_source_data_without_headings( - mysource_accessors, mysource_data -): - "A tree can be created with custom Source data, accessors, without headings" - tree = toga.Tree(data=mysource_data, accessors=mysource_accessors) - _check_data_access(tree) + # The column was added + assert_action_performed_with( + tree, + "insert column", + index=1, + heading="New Column", + accessor="extra", + ) + assert tree.headings == ["Title", "New Column", "Value"] + assert tree.accessors == ["key", "extra", "value"] -def test_constructor_with_source_data_with_headings_without_accessors( - mysource_headings, mysource_data -): - "A tree can be created with custom Source data, with headings, without accessors" - tree = toga.Tree(headings=mysource_headings, data=mysource_data) - with pytest.raises(AttributeError): - # unfortunately, the headings do not match the data - _check_data_access(tree) +def test_insert_column_big_index(tree): + """A column can be inserted at an index bigger than the number of columns""" + tree.insert_column(100, "New Column", accessor="extra") -def test_constructor_with_source_data_without_headings_nor_accessors(mysource_data): - "A tree cannot be created with custom Source data, without headings, accessors" - with pytest.raises(ValueError): - _ = toga.Tree(data=mysource_data) + # The column was added + assert_action_performed_with( + tree, + "insert column", + index=2, + heading="New Column", + accessor="extra", + ) + assert tree.headings == ["Title", "Value", "New Column"] + assert tree.accessors == ["key", "value", "extra"] -def test_data_setter_with_source_data( - mysource_headings, mysource_accessors, mysource_data -): - "A custom Source can be assigned to .data on a tree with accessors, headings" - tree = toga.Tree(headings=mysource_headings, accessors=mysource_accessors) - tree.data = mysource_data - _check_data_access(tree) +def test_insert_column_negative_index(tree): + """A column can be inserted at a negative index""" + tree.insert_column(-2, "New Column", accessor="extra") -def test_data_setter_with_source_data_without_headings( - mysource_accessors, mysource_data -): - "A custom Source can be assigned to .data on a tree with accessors, without headings" - tree = toga.Tree(accessors=mysource_accessors) - tree.data = mysource_data - _check_data_access(tree) + # The column was added + assert_action_performed_with( + tree, + "insert column", + index=0, + heading="New Column", + accessor="extra", + ) + assert tree.headings == ["New Column", "Title", "Value"] + assert tree.accessors == ["extra", "key", "value"] -def test_data_setter_with_source_data_with_headings_without_accessors( - mysource_headings, mysource_data -): - "A custom Source data cannot be assigned to .data on a tree without accessors" - tree = toga.Tree(headings=mysource_headings) - tree.data = mysource_data - with pytest.raises(AttributeError): - _check_data_access(tree) +def test_insert_column_big_negative_index(tree): + """A column can be inserted at a negative index larger than the number of columns""" + tree.insert_column(-100, "New Column", accessor="extra") -# treesource_data -################## + # The column was added + assert_action_performed_with( + tree, + "insert column", + index=0, + heading="New Column", + accessor="extra", + ) + assert tree.headings == ["New Column", "Title", "Value"] + assert tree.accessors == ["extra", "key", "value"] -def test_constructor_with_treesource_data(headings, accessors, treesource_data): - "A tree can be created with TreeSource data, headings, accessors" - tree = toga.Tree(headings=headings, data=treesource_data, accessors=accessors) - _check_data_access(tree) +def test_insert_column_no_accessor(tree): + """A column can be inserted with a default accessor""" + tree.insert_column(1, "New Column") -def test_constructor_with_treesource_data_without_accessors(headings, treesource_data): - "A tree can be created with TreeSource data, headings, without accessors" - tree = toga.Tree(headings=headings, data=treesource_data) - with pytest.raises(AttributeError): - # unfortunately, the headings do not match the data - _check_data_access(tree) + # The column was added + assert_action_performed_with( + tree, + "insert column", + index=1, + heading="New Column", + accessor="new_column", + ) + assert tree.headings == ["Title", "New Column", "Value"] + assert tree.accessors == ["key", "new_column", "value"] -def test_constructor_with_treesource_data_without_headings(accessors, treesource_data): - "A tree can be created with TreeSource data, accessors, without headings" - tree = toga.Tree(data=treesource_data, accessors=accessors) - _check_data_access(tree) +def test_insert_column_no_headings(source): + """A column can be inserted into a tree with no headings""" + tree = toga.Tree(headings=None, accessors=["key", "value"], data=source) + tree.insert_column(1, "New Column", accessor="extra") -def test_constructor_with_treesource_data_without_headings_nor_accessors( - treesource_data, -): - "A tree cannot be created with TreeSource data, without headings, accessors" - with pytest.raises(ValueError): - _ = toga.Tree(data=treesource_data) + # The column was added + assert_action_performed_with( + tree, + "insert column", + index=1, + heading=None, + accessor="extra", + ) + assert tree.headings is None + assert tree.accessors == ["key", "extra", "value"] -def test_data_setter_with_treesource_data(headings, accessors, treesource_data): - "A TreeSource can be assigned to .data on a tree with accessors, headings" - tree = toga.Tree(headings=headings, accessors=accessors) - tree.data = treesource_data - _check_data_access(tree) +def test_insert_column_no_headings_missing_accessor(source): + """An accessor is mandatory when adding a column to a tree with no headings""" + tree = toga.Tree(headings=None, accessors=["key", "value"], data=source) + with pytest.raises( + ValueError, + match=r"Must specify an accessor on a tree without headings", + ): + tree.insert_column(1, "New Column") -def test_data_setter_with_treesource_data_without_accessors(headings, treesource_data): - "A TreeSource can be assigned to .data on a tree with headings, without accessors" - tree = toga.Tree(headings=headings) - tree.data = treesource_data - with pytest.raises(AttributeError): - # unfortunately, the headings do not match the data - _check_data_access(tree) +def test_append_column(tree): + """A column can be appended""" + tree.append_column("New Column", accessor="extra") -def test_data_setter_with_treesource_data_without_headings(accessors, treesource_data): - "A TreeSource can be assigned to .data on a tree with accessors, without headings" - tree = toga.Tree(accessors=accessors) - tree.data = treesource_data - _check_data_access(tree) + # The column was added + assert_action_performed_with( + tree, + "insert column", + index=2, + heading="New Column", + accessor="extra", + ) + assert tree.headings == ["Title", "Value", "New Column"] + assert tree.accessors == ["key", "value", "extra"] -# dict_data -################## +def test_remove_column_accessor(tree): + "A column can be removed by accessor" + tree.remove_column("value") -def test_constructor_with_dict_data(headings, accessors, dict_data): - "A tree can be created with dict data, headings, accessors" - tree = toga.Tree(headings=headings, data=dict_data, accessors=accessors) - _check_data_access(tree) + # The column was removed + assert_action_performed_with( + tree, + "remove column", + index=1, + ) + assert tree.headings == ["Title"] + assert tree.accessors == ["key"] -def test_constructor_with_dict_data_without_accessors(headings, dict_data): - "A tree can be created with dict data, headings, without accessors" - tree = toga.Tree(headings=headings, data=dict_data) - _check_data_access(tree) - # accessors are derived from headings - assert tree._accessors[0] == "heading_0" +def test_remove_column_unknown_accessor(tree): + "If the column named for removal doesn't exist, an error is raised" + with pytest.raises(ValueError, match=r"'unknown' is not in list"): + tree.remove_column("unknown") -def test_constructor_with_dict_data_without_headings(accessors, dict_data): - "A tree can be created with dict data, accessors, without headings" - tree = toga.Tree(data=dict_data, accessors=accessors) - _check_data_access(tree) +def test_remove_column_invalid_index(tree): + "If the index specified doesn't exist, an error is raised" + with pytest.raises(IndexError, match=r"list assignment index out of range"): + tree.remove_column(100) -def test_constructor_with_dict_data_without_headings_nor_accessors(dict_data): - "A tree can be created with dict data, without accessors, headings" - tree = toga.Tree(data=dict_data) - _check_data_access(tree) - # accessors are syntesized - with pytest.raises(AttributeError): - _ = tree.data[0].heading0 - with pytest.raises(AttributeError): - _ = tree.data[0].heading_0 +def test_remove_column_index(tree): + "A column can be removed by index" + tree.remove_column(1) -def test_data_setter_with_dict_data(headings, accessors, dict_data): - "A dict can be assigned to .data on a tree with accessors, headings" - tree = toga.Tree(headings=headings, accessors=accessors) - tree.data = dict_data - _check_data_access(tree) + # The column was removed + assert_action_performed_with( + tree, + "remove column", + index=1, + ) + assert tree.headings == ["Title"] + assert tree.accessors == ["key"] -def test_data_setter_with_dict_data_without_accessors(headings, dict_data): - "A dict can be assigned to .data on a tree with headings, without accessors" - tree = toga.Tree(headings=headings) - tree.data = dict_data - _check_data_access(tree) - # accessors are derived from headings - assert tree._accessors[0] == "heading_0" +def test_remove_column_negative_index(tree): + "A column can be removed by index" + tree.remove_column(-2) -def test_data_setter_with_dict_data_without_headings(accessors, dict_data): - "A dict can be assigned to .data on a tree with accessors, without headings" - tree = toga.Tree(accessors=accessors) - tree.data = dict_data - _check_data_access(tree) + # The column was removed + assert_action_performed_with( + tree, + "remove column", + index=0, + ) + assert tree.headings == ["Value"] + assert tree.accessors == ["value"] -# list_data / tuple_data -################## +def test_remove_column_no_headings(tree): + "A column can be removed when there are no headings" + tree = toga.Tree( + headings=None, + accessors=["primus", "secondus"], + ) + tree.remove_column(1) -@pytest.mark.parametrize("data", ["list_data", "tuple_data"]) -def test_constructor_with_list_data(headings, accessors, data, request): - "A tree can be created with list or tuple data, headings, accessors" - tree = toga.Tree( - headings=headings, data=request.getfixturevalue(data), accessors=accessors + # The column was removed + assert_action_performed_with( + tree, + "remove column", + index=1, ) - _check_data_access(tree) - - -@pytest.mark.parametrize("data", ["list_data", "tuple_data"]) -def test_constructor_with_list_data_without_accessors(headings, data, request): - "A tree can be created with list or tuple data, headings, without accessors" - tree = toga.Tree(headings=headings, data=request.getfixturevalue(data)) - _check_data_access(tree) - # accessors are derived from headings - assert tree._accessors[0] == "heading_0" - - -@pytest.mark.parametrize("data", ["list_data", "tuple_data"]) -def test_constructor_with_list_data_without_headings(accessors, data, request): - "A tree can be created with list or tuple data, accessors, without headings" - tree = toga.Tree(data=request.getfixturevalue(data), accessors=accessors) - _check_data_access(tree) - - -@pytest.mark.parametrize("data", ["list_data", "tuple_data"]) -def test_constructor_with_list_data_without_headings_nor_accessors(data, request): - "A tree can be created with list or tuple data, without accessors, headings" - tree = toga.Tree(data=request.getfixturevalue(data)) - _check_data_access(tree) - # accessors are syntesized - with pytest.raises(AttributeError): - _ = tree.data[0].heading0 - with pytest.raises(AttributeError): - _ = tree.data[0].heading_0 - - -@pytest.mark.parametrize("data", ["list_data", "tuple_data"]) -def test_data_setter_with_list_data(headings, accessors, data, request): - "A list or tuple can be assigned to .data on a tree with accessors, headings" - tree = toga.Tree(headings=headings, accessors=accessors) - tree.data = request.getfixturevalue(data) - _check_data_access(tree) - - -@pytest.mark.parametrize("data", ["list_data", "tuple_data"]) -def test_data_setter_with_list_data_without_accessors(headings, data, request): - "A list or tuple can be assigned to .data on a tree with headings, without accessors" - tree = toga.Tree(headings=headings) - tree.data = request.getfixturevalue(data) - _check_data_access(tree) - # accessors are derived from headings - assert tree._accessors[0] == "heading_0" - - -@pytest.mark.parametrize("data", ["list_data", "tuple_data"]) -def test_data_setter_with_list_data_without_headings(accessors, data, request): - "A list or tuple can be assigned to .data on a tree with accessors, without headings" - tree = toga.Tree(accessors=accessors) - tree.data = request.getfixturevalue(data) - _check_data_access(tree) + assert tree.headings is None + assert tree.accessors == ["primus"] + + +def test_deprecated_names(on_activate_handler): + "Deprecated names still work" + + # Can't specify both on_double_click and on_activate + with pytest.raises( + ValueError, + match=r"Cannot specify both on_double_click and on_activate", + ): + toga.Tree(["First", "Second"], on_double_click=Mock(), on_activate=Mock()) + + # on_double_click is redirected at construction + with pytest.warns( + DeprecationWarning, + match="Tree.on_double_click has been renamed Tree.on_activate", + ): + tree = toga.Tree(["First", "Second"], on_double_click=on_activate_handler) + + # on_double_click accessor is redirected to on_activate + with pytest.warns( + DeprecationWarning, + match="Tree.on_double_click has been renamed Tree.on_activate", + ): + assert tree.on_double_click._raw == on_activate_handler + + assert tree.on_activate._raw == on_activate_handler + + # on_double_click mutator is redirected to on_activate + new_handler = Mock() + with pytest.warns( + DeprecationWarning, + match="Tree.on_double_click has been renamed Tree.on_activate", + ): + tree.on_double_click = new_handler + + assert tree.on_activate._raw == new_handler diff --git a/dummy/src/toga_dummy/widgets/tree.py b/dummy/src/toga_dummy/widgets/tree.py index 1e0f143acb..3760442847 100644 --- a/dummy/src/toga_dummy/widgets/tree.py +++ b/dummy/src/toga_dummy/widgets/tree.py @@ -1,12 +1,23 @@ from .base import Widget +def node_for_path(data, path): + "Convert a path tuple into a specific node" + if path is None: + return None + result = data + for index in path: + result = result[index] + return result + + class Tree(Widget): def create(self): self._action("create Tree") def change_source(self, source): self._action("change source", source=source) + self.interface.on_select(None) def insert(self, parent, index, item): self._action("insert node", parent=parent, index=index, item=item) @@ -21,17 +32,25 @@ def clear(self): self._action("clear") def get_selection(self): - self._action("get selection") - return None - - def set_on_select(self, handler): - self._set_value("on_select", handler) - - def set_on_double_click(self, handler): - self._set_value("on_double_click", handler) - - def simulate_select(self): + if self.interface.multiple_select: + return [ + node_for_path(self.interface.data, path) + for path in self._get_value("selection", []) + ] + else: + return node_for_path( + self.interface.data, self._get_value("selection", None) + ) + + def insert_column(self, index, heading, accessor): + self._action("insert column", index=index, heading=heading, accessor=accessor) + + def remove_column(self, index): + self._action("remove column", index=index) + + def simulate_selection(self, path): + self._set_value("selection", path) self.interface.on_select(None) - def simulate_double_click(self): - self.interface.on_double_click(None) + def simulate_activate(self, path): + self.interface.on_activate(None, node=node_for_path(self.interface.data, path)) From 0b4d6b355f68e3f2692bc7d4f90e9f7d36a86c58 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 29 Jun 2023 10:42:56 +0800 Subject: [PATCH 09/30] Add docs for Tree. --- docs/reference/api/widgets/table.rst | 8 +- docs/reference/api/widgets/tree.rst | 127 +++++++++++++++++++++++++-- 2 files changed, 123 insertions(+), 12 deletions(-) diff --git a/docs/reference/api/widgets/table.rst b/docs/reference/api/widgets/table.rst index ad486a9ca9..9243916057 100644 --- a/docs/reference/api/widgets/table.rst +++ b/docs/reference/api/widgets/table.rst @@ -24,8 +24,10 @@ ListSource at runtime. The simplest instantiation of a Table is to use a list of lists (or list of tuples), containing the items to display in the table. When creating the table, you must also specify the headings to use on the table; those headings will be converted into -accessors on the Row data objects created for the table data. In this example, -we will display a table of 2 columns, with 3 initial rows of data: +accessors on the Row data objects created for the table data. The values in the tuples +provided will then be mapped sequentially to the accessors. + +In this example, we will display a table of 2 columns, with 3 initial rows of data: .. code-block:: python @@ -59,7 +61,7 @@ to control the display order of columns independent of the storage of that data. data=[ {"name": "Arthur Dent", "age": 42, "planet": "Earth"}, {"name", "Ford Prefect", "age": 37, "planet": "Betelgeuse Five"}, - {"name": "Tricia McMillan", "age": 38, "plaent": "Earth"}, + {"name": "Tricia McMillan", "age": 38, "planet": "Earth"}, ] ) diff --git a/docs/reference/api/widgets/tree.rst b/docs/reference/api/widgets/tree.rst index 82d2b38faa..aac40e6e9a 100644 --- a/docs/reference/api/widgets/tree.rst +++ b/docs/reference/api/widgets/tree.rst @@ -17,27 +17,136 @@ A widget for displaying a hierarchical tree of tabular data. Usage ----- -A Table uses a :class:`~toga.sources.TreeSource` to manage the data being displayed. +A Tree uses a :class:`~toga.sources.TreeSource` to manage the data being displayed. options. If ``data`` is not specified as a TreeSource, it will be converted into a TreeSource at runtime. +The simplest instantiation of a Tree is to use a dictionary, where the keys are the data +for each node, and the values describe the children for that node. When creating the +Tree, you must also specify the headings to use on the tree; those headings will be +converted into accessors on the Node data objects created for the tree data. The values +in the tuples provided as keys in the data will then be mapped sequentially to the +accessors; or, if an atomic value has been provided as a key, only the first accessor +will be populated. + +In this example, we will display a tree with 2 columns. The tree will have 2 root +nodes; the first root node will have 1 child node; the second root node will have 2 +children. The root nodes will only populate the "name" column; the other column will be +blank: + +.. code-block:: python + + import toga + + tree = toga.Tree( + headings=["Name", "Age"], + data={ + "Earth": { + ("Arthur Dent", 42): None, + }, + "Betelgeuse Five": { + ("Ford Prefect", 37): None, + ("Zaphod Beeblebrox", 47): None, + }, + } + ) + + # Get the details of the first child of the second root node: + print(f"{tree.data[1][0].name} is age {tree.data[1][0].age}") + + # Append new data to the first root node in the tree + tree.data[0].append(("Tricia McMillan", 38)) + +You can also specify data for a Tree using a list of 2-tuples, with dictionaries +providing serving as data values. This allows to to store data in the data source that +won't be displayed in the tree. It also allows you to control the display order of +columns independent of the storage of that data. + +.. code-block:: python + + import toga + + tree = toga.Tree( + headings=["Name", "Age"], + data=[ + ({"name": "Earth"}), [ + ({"name": "Arthur Dent", "age": 42, "status": "Anxious"}, None) + ], + ({"name": "Betelgeuse Five"}), [ + ({"name": "Ford Prefect", "age": 37, "status": "Hoopy"}, None) + ({"name": "Zaphod Beeblebrox", "age": 47, "status": "Oblivious"}, None) + ], + ] + ) + + # Get the details of the first child of the second root node: + print(f"{tree.data[1][0].name} is age {tree.data[1][0].age}") + + # Append new data to the first root node in the tree + tree.data[0].append({"name": "Tricia McMillan", "age": 38, "status": "Overqualified"}) + +The attribute names used on each row of data (called "accessors") are created automatically from +the name of the headings that you provide. This is done by: + +1. Converting the heading to lower case; +2. Removing any character that can't be used in a Python identifier; +3. Replacing all whitespace with "_"; +4. Prepending ``_`` if the first character is a digit. + +If you want to use different accessors to the ones that are automatically generated, you +can override them by providing an ``accessors`` argument. This can be either: + +* A list of the same size as the list of headings, specifying the accessors for each + heading. A value of :any:`None` will fall back to the default generated accessor; or +* A dictionary mapping heading names to accessor names. + +In this example, the tree will use "Name" as the visible header, but internally, the +attribute "character" will be used: .. code-block:: python import toga - tree = toga.Tree(['Navigate']) + tree = toga.Tree( + headings=["Name", "Age"], + accessors={"Name", 'character'}, + data=[ + ({"character": "Earth"}), [ + ({"character": "Arthur Dent", "age": 42, "status": "Anxious"}, None) + ], + ({"character": "Betelgeuse Five"}), [ + ({"character": "Ford Prefect", "age": 37, "status": "Hoopy"}, None) + ({"character": "Zaphod Beeblebrox", "age": 47, "status": "Oblivious"}, None) + ], + ] + ) + + # Get the details of the first child of the second root node: + print(f"{tree.data[1][0].character} is age {tree.data[1][0].age}") + + # Get the details of the first item in the data: + print(f"{tree.data[0].character}, who is age {tree.data[0].age}, is from {tree.data[0].planet}") + +You can also create a tree *without* a heading row. However, if you do this, you *must* +specify accessors. - tree.insert(None, None, 'root1') +If the value provided by an accessor is :any:`None`, or the accessor isn't defined for a +given row, the value of ``missing_value`` provided when constructing the Tree will +be used to populate the cell in the Tree. - root2 = tree.insert(None, None, 'root2') +If the value provided by an accessor is any type other than a tuple :any:`tuple` or +:any:`toga.Widget`, the value will be converted into a string. If the value has an +``icon`` attribute, the cell will use that icon in the Tree cell, displayed to the left +of the text label. If the value of the ``icon`` attribute is :any:`None`, no icon will +be displayed. - tree.insert(root2, None, 'root2.1') - root2_2 = tree.insert(root2, None, 'root2.2') +If the value provided by an accessor is a :any:`tuple`, the first element in the tuple +must be an :class:`toga.Icon`, and the second value in the tuple will be used to provide +the text label (again, by converting the value to a string, or using ``missing_value`` +if the value is :any:`None`, as appropriate). - tree.insert(root2_2, None, 'root2.2.1') - tree.insert(root2_2, None, 'root2.2.2') - tree.insert(root2_2, None, 'root2.2.3') +If the value provided by an accessor is a :class:`toga.Widget`, that widget will be displayed +in the tree. Note that this is currently a beta API, and may change in future. Reference --------- From 4ca54964c40c0228f740205e7aca54df3925afa3 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 29 Jun 2023 12:38:13 +0800 Subject: [PATCH 10/30] Cocoa Tree to 100%. --- cocoa/src/toga_cocoa/widgets/tree.py | 226 +++++------ cocoa/tests_backend/widgets/tree.py | 163 ++++++++ examples/tree/tree/app.py | 3 +- examples/tree_source/tree_source/app.py | 6 +- testbed/tests/widgets/test_tree.py | 515 ++++++++++++++++++++++++ 5 files changed, 784 insertions(+), 129 deletions(-) create mode 100644 cocoa/tests_backend/widgets/tree.py create mode 100644 testbed/tests/widgets/test_tree.py diff --git a/cocoa/src/toga_cocoa/widgets/tree.py b/cocoa/src/toga_cocoa/widgets/tree.py index acd4cbba22..fc494bbbf1 100644 --- a/cocoa/src/toga_cocoa/widgets/tree.py +++ b/cocoa/src/toga_cocoa/widgets/tree.py @@ -1,13 +1,12 @@ from ctypes import c_void_p +from rubicon.objc import SEL, at, objc_method, objc_property, send_super from travertino.size import at_least import toga from toga.keys import Key from toga_cocoa.keys import toga_key -from toga_cocoa.libs import CGRectMake # NSSortDescriptor, from toga_cocoa.libs import ( - SEL, NSBezelBorder, NSIndexSet, NSOutlineView, @@ -15,10 +14,6 @@ NSTableColumn, NSTableViewAnimation, NSTableViewColumnAutoresizingStyle, - at, - objc_method, - objc_property, - send_super, ) from toga_cocoa.widgets.base import Widget from toga_cocoa.widgets.internal.cells import TogaIconView @@ -52,10 +47,7 @@ def outlineView_child_ofItem_(self, tree, child: int, item): @objc_method def outlineView_isItemExpandable_(self, tree, item) -> bool: - try: - return item.attrs["node"].can_have_children() - except AttributeError: - return False + return item.attrs["node"].can_have_children() @objc_method def outlineView_numberOfChildrenOfItem_(self, tree, item) -> int: @@ -85,23 +77,16 @@ def outlineView_viewForTableColumn_item_(self, tree, column, item): # for encoding an icon in a table cell. Otherwise, look # for an icon attribute. elif isinstance(value, tuple): - icon_iface, value = value + icon, value = value else: try: - icon_iface = value.icon + icon = value.icon except AttributeError: - icon_iface = None + icon = None except AttributeError: # If the node doesn't have a property with the # accessor name, assume an empty string value. value = self.interface.missing_value - icon_iface = None - - # If the value has an icon, get the _impl. - # Icons are deferred resources, so we provide the factory. - if icon_iface: - icon = icon_iface._impl - else: icon = None # creates a NSTableCellView from interface-builder template (does not exist) @@ -111,9 +96,8 @@ def outlineView_viewForTableColumn_item_(self, tree, column, item): tcv = self.makeViewWithIdentifier(identifier, owner=self) if not tcv: # there is no existing view to reuse so create a new one - tcv = TogaIconView.alloc().initWithFrame_( - CGRectMake(0, 0, column.width, 16) - ) + # tcv = TogaIconView.alloc().initWithFrame(CGRectMake(0, 0, column.width, 16)) + tcv = TogaIconView.alloc().init() tcv.identifier = identifier # Prevent tcv from being deallocated prematurely when no Python references @@ -123,34 +107,41 @@ def outlineView_viewForTableColumn_item_(self, tree, column, item): tcv.setText(str(value)) if icon: - tcv.setImage(icon.native) + tcv.setImage(icon._impl.native) else: tcv.setImage(None) return tcv - @objc_method - def outlineView_heightOfRowByItem_(self, tree, item) -> float: - default_row_height = self.rowHeight + # 2023-06-29: Commented out this method because it appears to be a + # source of significant slowdown when the table has a lot of data + # (10k rows). AFAICT, it's only needed if we want custom row heights + # for each row. Since we don't currently support custom row heights, + # we're paying the cost for no benefit. + # @objc_method + # def outlineView_heightOfRowByItem_(self, tree, item) -> float: + # default_row_height = self.rowHeight - if item is self: - return default_row_height + # if item is self: + # return default_row_height - heights = [default_row_height] + # heights = [default_row_height] - for column in self.tableColumns: - value = getattr( - item.attrs["node"], str(column.identifier), self.interface.missing_value - ) + # for column in self.tableColumns: + # value = getattr( + # item.attrs["node"], str(column.identifier), self.interface.missing_value + # ) - if isinstance(value, toga.Widget): - # if the cell value is a widget, use its height - heights.append(value._impl.native.intrinsicContentSize().height) + # if isinstance(value, toga.Widget): + # # if the cell value is a widget, use its height + # heights.append(value._impl.native.intrinsicContentSize().height) - return max(heights) + # return max(heights) @objc_method - def outlineView_pasteboardWriterForItem_(self, tree, item) -> None: + def outlineView_pasteboardWriterForItem_( + self, tree, item + ) -> None: # pragma: no cover # this seems to be required to prevent issue 21562075 in AppKit return None @@ -181,24 +172,13 @@ def keyDown_(self, event) -> None: # OutlineViewDelegate methods @objc_method def outlineViewSelectionDidChange_(self, notification) -> None: - if notification.object.selectedRow == -1: - selected = None - else: - selected = self.itemAtRow(notification.object.selectedRow).attrs["node"] - - if self.interface.on_select: - self.interface.on_select(self.interface, node=selected) + self.interface.on_select(self.interface) # target methods @objc_method def onDoubleClick_(self, sender) -> None: - if self.clickedRow == -1: - node = None - else: - node = self.itemAtRow(self.clickedRow).attrs["node"] - - if self.interface.on_select: - self.interface.on_double_click(self.interface, node=node) + node = self.itemAtRow(self.clickedRow).attrs["node"] + self.interface.on_activate(self.interface, node=node) class Tree(Widget): @@ -212,114 +192,114 @@ def create(self): self.native.borderType = NSBezelBorder # Create the Tree widget - self.tree = TogaTree.alloc().init() - self.tree.interface = self.interface - self.tree.impl = self - self.tree.columnAutoresizingStyle = NSTableViewColumnAutoresizingStyle.Uniform - self.tree.usesAlternatingRowBackgroundColors = True - self.tree.allowsMultipleSelection = self.interface.multiple_select - if self.interface.headings is None: - self.tree.headerView = None - - # Create columns for the tree + self.native_tree = TogaTree.alloc().init() + self.native_tree.interface = self.interface + self.native_tree.impl = self + self.native_tree.columnAutoresizingStyle = ( + NSTableViewColumnAutoresizingStyle.Uniform + ) + self.native_tree.usesAlternatingRowBackgroundColors = True + self.native_tree.allowsMultipleSelection = self.interface.multiple_select + + # Create columns for the table self.columns = [] - # Cocoa identifies columns by an accessor; to avoid repeated - # conversion from ObjC string to Python String, create the - # ObjC string once and cache it. - self.column_identifiers = {} - for accessor in self.interface._accessors: - column_identifier = at(accessor) - self.column_identifiers[id(column_identifier)] = accessor - column = NSTableColumn.alloc().initWithIdentifier(column_identifier) - # column.editable = False - column.minWidth = 16 - # if self.interface.sorting: - # sort_descriptor = NSSortDescriptor.sortDescriptorWithKey(column_identifier, ascending=True) - # column.sortDescriptorPrototype = sort_descriptor - self.tree.addTableColumn(column) - self.columns.append(column) if self.interface.headings: - for i, heading in enumerate(self.interface.headings): - self.columns[i].headerCell.stringValue = heading + for index, (heading, accessor) in enumerate( + zip(self.interface.headings, self.interface.accessors) + ): + self._insert_column(index, heading, accessor) + else: + self.native_tree.setHeaderView(None) + for index, accessor in enumerate(self.interface.accessors): + self._insert_column(index, None, accessor) # Put the tree arrows in the first column. - self.tree.outlineTableColumn = self.columns[0] + self.native_tree.outlineTableColumn = self.columns[0] - self.tree.delegate = self.tree - self.tree.dataSource = self.tree - self.tree.target = self.tree - self.tree.doubleAction = SEL("onDoubleClick:") + self.native_tree.delegate = self.native_tree + self.native_tree.dataSource = self.native_tree + self.native_tree.target = self.native_tree + self.native_tree.doubleAction = SEL("onDoubleClick:") # Embed the tree view in the scroll view - self.native.documentView = self.tree + self.native.documentView = self.native_tree # Add the layout constraints self.add_constraints() def change_source(self, source): - self.tree.reloadData() + self.native_tree.reloadData() def insert(self, parent, index, item): - # set parent = None if inserting to the root item index_set = NSIndexSet.indexSetWithIndex(index) - if parent is self.interface.data: - parent = None - else: - parent = getattr(parent, "_impl", None) - - self.tree.insertItemsAtIndexes( + self.native_tree.insertItemsAtIndexes( index_set, - inParent=parent, + inParent=parent._impl if parent else None, withAnimation=NSTableViewAnimation.SlideDown.value, ) def change(self, item): - try: - self.tree.reloadItem(item._impl) - except AttributeError: - pass + self.native_tree.reloadItem(item._impl) def remove(self, parent, index, item): - try: - index = self.tree.childIndexForItem(item._impl) - except AttributeError: - pass - else: - index_set = NSIndexSet.indexSetWithIndex(index) - parent = self.tree.parentForItem(item._impl) - self.tree.removeItemsAtIndexes( - index_set, - inParent=parent, - withAnimation=NSTableViewAnimation.SlideUp.value, - ) + index = self.native_tree.childIndexForItem(item._impl) + index_set = NSIndexSet.indexSetWithIndex(index) + parent = self.native_tree.parentForItem(item._impl) + self.native_tree.removeItemsAtIndexes( + index_set, + inParent=parent, + withAnimation=NSTableViewAnimation.SlideUp.value, + ) def clear(self): - self.tree.reloadData() + self.native_tree.reloadData() def get_selection(self): if self.interface.multiple_select: selection = [] - current_index = self.tree.selectedRowIndexes.firstIndex - for i in range(self.tree.selectedRowIndexes.count): - selection.append(self.tree.itemAtRow(current_index).attrs["node"]) - current_index = self.tree.selectedRowIndexes.indexGreaterThanIndex( - current_index + current_index = self.native_tree.selectedRowIndexes.firstIndex + for i in range(self.native_tree.selectedRowIndexes.count): + selection.append( + self.native_tree.itemAtRow(current_index).attrs["node"] + ) + current_index = ( + self.native_tree.selectedRowIndexes.indexGreaterThanIndex( + current_index + ) ) return selection else: - index = self.tree.selectedRow + index = self.native_tree.selectedRow if index != -1: - return self.tree.itemAtRow(index).attrs["node"] + return self.native_tree.itemAtRow(index).attrs["node"] else: return None - def set_on_select(self, handler): - pass + def _insert_column(self, index, heading, accessor): + column = NSTableColumn.alloc().initWithIdentifier(accessor) + column.minWidth = 16 + + self.columns.insert(index, column) + self.native_tree.addTableColumn(column) + if index != len(self.columns) - 1: + self.native_tree.moveColumn(len(self.columns) - 1, toColumn=index) + + if heading is not None: + column.headerCell.stringValue = heading + + def insert_column(self, index, heading, accessor): + self._insert_column(index, heading, accessor) + self.native_tree.sizeToFit() + + def remove_column(self, index): + column = self.columns[index] + self.native_tree.removeTableColumn(column) - def set_on_double_click(self, handler): - pass + # delete column and identifier + self.columns.remove(column) + self.native_tree.sizeToFit() def rehint(self): self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) diff --git a/cocoa/tests_backend/widgets/tree.py b/cocoa/tests_backend/widgets/tree.py new file mode 100644 index 0000000000..6bd2015f4a --- /dev/null +++ b/cocoa/tests_backend/widgets/tree.py @@ -0,0 +1,163 @@ +import asyncio + +from rubicon.objc import NSPoint + +from toga_cocoa.libs import NSEventType, NSOutlineView, NSScrollView + +from .base import SimpleProbe +from .properties import toga_color + +NSEventModifierFlagCommand = 1 << 20 + + +class TreeProbe(SimpleProbe): + native_class = NSScrollView + supports_keyboard_shortcuts = True + + def __init__(self, widget): + super().__init__(widget) + self.native_tree = widget._impl.native_tree + assert isinstance(self.native_tree, NSOutlineView) + + @property + def background_color(self): + if self.native.drawsBackground: + return toga_color(self.native.backgroundColor) + else: + return None + + async def expand_tree(self): + self.native_tree.expandItem(None, expandChildren=True) + await asyncio.sleep(0.1) + + def item_for_row_path(self, row_path): + item = self.native_tree.outlineView( + self.native_tree, + child=row_path[0], + ofItem=None, + ) + for index in row_path[1:]: + item = self.native_tree.outlineView( + self.native_tree, + child=index, + ofItem=item, + ) + return item + + def child_count(self, row_path=None): + if row_path: + item = self.item_for_row_path(row_path) + else: + item = None + + return int(self.native_tree.numberOfChildrenOfItem(item)) + + @property + def column_count(self): + return len(self.native_tree.tableColumns) + + def assert_cell_content(self, row_path, col, value=None, icon=None, widget=None): + view = self.native_tree.outlineView( + self.native_tree, + viewForTableColumn=self.native_tree.tableColumns[col], + item=self.item_for_row_path(row_path), + ) + if widget: + assert view == widget._impl.native + else: + assert str(view.textField.stringValue) == value + + if icon: + assert view.imageView.image == icon._impl.native + else: + assert view.imageView.image is None + + @property + def max_scroll_position(self): + return int(self.native.documentView.bounds.size.height) - int( + self.native.contentView.bounds.size.height + ) + + @property + def scroll_position(self): + return int(self.native.contentView.bounds.origin.y) + + async def wait_for_scroll_completion(self): + # No animation associated with scroll, so this is a no-op + pass + + @property + def header_visible(self): + return self.native_tree.headerView is not None + + @property + def header_titles(self): + return [ + str(col.headerCell.stringValue) for col in self.native_tree.tableColumns + ] + + def column_width(self, col): + return self.native_tree.tableColumns[col].width + + def row_position(self, row): + # Pick a point half way across horizontally, and half way down the row, + # taking into account the size of the rows and the header + row_height = self.native_tree.rowHeight + return self.native_tree.convertPoint( + NSPoint( + self.width / 2, + (row * row_height) + (row_height / 2), + ), + toView=None, + ) + + async def select_all(self): + await self.type_character("A", modifierFlags=NSEventModifierFlagCommand), + + async def select_row(self, row, add=False): + point = self.row_position(row) + # Table maintains an inner mouse event loop, so we can't + # use the "wait for another event" approach for the mouse events. + # Use a short delay instead. + await self.mouse_event( + NSEventType.LeftMouseDown, + point, + delay=0.1, + modifierFlags=NSEventModifierFlagCommand if add else 0, + ) + await self.mouse_event( + NSEventType.LeftMouseUp, + point, + delay=0.1, + modifierFlags=NSEventModifierFlagCommand if add else 0, + ) + + async def activate_row(self, row): + point = self.row_position(row) + # Table maintains an inner mouse event loop, so we can't + # use the "wait for another event" approach for the mouse events. + # Use a short delay instead. + await self.mouse_event( + NSEventType.LeftMouseDown, + point, + delay=0.1, + ) + await self.mouse_event( + NSEventType.LeftMouseUp, + point, + delay=0.1, + ) + + # Second click, with a click count. + await self.mouse_event( + NSEventType.LeftMouseDown, + point, + delay=0.1, + clickCount=2, + ) + await self.mouse_event( + NSEventType.LeftMouseUp, + point, + delay=0.1, + clickCount=2, + ) diff --git a/examples/tree/tree/app.py b/examples/tree/tree/app.py index 3376853d62..77d6c1d3a9 100644 --- a/examples/tree/tree/app.py +++ b/examples/tree/tree/app.py @@ -53,7 +53,8 @@ class ExampleTreeApp(toga.App): # Table callback functions - def on_select_handler(self, widget, node): + def on_select_handler(self, widget): + node = widget.selection if node is not None and node.title: self.label.text = f"You selected node: {node.title}" self.btn_remove.enabled = True diff --git a/examples/tree_source/tree_source/app.py b/examples/tree_source/tree_source/app.py index 3d1644c16a..8e06a40e2c 100644 --- a/examples/tree_source/tree_source/app.py +++ b/examples/tree_source/tree_source/app.py @@ -112,11 +112,7 @@ def __init__(self, path): class ExampleTreeSourceApp(toga.App): - def selection_handler(self, widget, node): - # A node is a dictionary of the last item that was clicked in the tree. - # node['node'].path would get you the file path to only that one item. - # self.label.text = f'Selected {node["node"].path}' - + def selection_handler(self, widget): # If you iterate over widget.selection, you can get the names and the # paths of everything selected (if multiple_select is enabled.) # filepaths = [node.path for node in widget.selection] diff --git a/testbed/tests/widgets/test_tree.py b/testbed/tests/widgets/test_tree.py new file mode 100644 index 0000000000..d5e0bd0fb0 --- /dev/null +++ b/testbed/tests/widgets/test_tree.py @@ -0,0 +1,515 @@ +from unittest.mock import Mock + +import pytest + +import toga +from toga.sources import TreeSource +from toga.style.pack import Pack + +from ..conftest import skip_on_platforms +from .probe import get_probe +from .properties import ( # noqa: F401 + test_background_color, + test_background_color_reset, + test_enable_noop, + test_flex_widget_size, + test_focus_noop, +) + + +@pytest.fixture +def on_select_handler(): + return Mock() + + +@pytest.fixture +def on_activate_handler(): + return Mock() + + +@pytest.fixture +def source(): + return TreeSource( + accessors=["a", "b", "c", "d", "e"], + data=[ + ( + { + "a": f"A{x}", + "b": f"B{x}", + "c": f"C{x}", + "d": f"D{x}", + "e": f"E{x}", + }, + [ + ( + { + "a": f"A{x}0", + "b": f"B{x}0", + "c": f"C{x}0", + "d": f"D{x}0", + "e": f"E{x}0", + }, + None, + ), + ( + { + "a": f"A{x}1", + "b": f"B{x}1", + "c": f"C{x}1", + "d": f"D{x}1", + "e": f"E{x}1", + }, + [], + ), + ( + { + "a": f"A{x}2", + "b": f"B{x}2", + "c": f"C{x}2", + "d": f"D{x}2", + "e": f"E{x}2", + }, + [ + ( + { + "a": f"A{x}2{y}", + "b": f"B{x}2{y}", + "c": f"C{x}2{y}", + "d": f"D{x}2{y}", + "e": f"E{x}2{y}", + }, + None, + ) + for y in range(0, 3) + ], + ), + ], + ) + for x in range(0, 10) + ], + ) + + +@pytest.fixture +async def widget(source, on_select_handler, on_activate_handler): + skip_on_platforms("iOS", "android") + return toga.Tree( + ["A", "B", "C"], + data=source, + missing_value="MISSING!", + on_select=on_select_handler, + on_activate=on_activate_handler, + style=Pack(flex=1), + ) + + +@pytest.fixture +def headerless_widget(source, on_select_handler): + skip_on_platforms("iOS", "android") + return toga.Tree( + data=source, + missing_value="MISSING!", + accessors=["a", "b", "c"], + on_select=on_select_handler, + style=Pack(flex=1), + ) + + +@pytest.fixture +async def headerless_probe(main_window, headerless_widget): + old_content = main_window.content + + box = toga.Box(children=[headerless_widget]) + main_window.content = box + probe = get_probe(headerless_widget) + await probe.redraw("Constructing headerless Tree probe") + probe.assert_container(box) + yield probe + + main_window.content = old_content + + +@pytest.fixture +def multiselect_widget(source, on_select_handler): + # Although Android *has* a table implementation, it needs to be rebuilt. + skip_on_platforms("iOS", "android") + return toga.Tree( + ["A", "B", "C"], + data=source, + multiple_select=True, + on_select=on_select_handler, + style=Pack(flex=1), + ) + + +@pytest.fixture +async def multiselect_probe(main_window, multiselect_widget): + old_content = main_window.content + + box = toga.Box(children=[multiselect_widget]) + main_window.content = box + probe = get_probe(multiselect_widget) + await probe.redraw("Constructing multiselect Tree probe") + probe.assert_container(box) + yield probe + + main_window.content = old_content + + +async def test_select(widget, probe, source, on_select_handler): + """Rows can be selected""" + # Initial selection is empty + assert widget.selection is None + await probe.redraw("No row is selected") + on_select_handler.assert_not_called() + + await probe.expand_tree() + + # A single row can be selected + await probe.select_row(1) + await probe.redraw("Second row is selected") + assert widget.selection == source[0][0] + on_select_handler.assert_called_once_with(widget) + on_select_handler.reset_mock() + + # Trying to multi-select only does a single select + await probe.select_row(2, add=True) + await probe.redraw("Third row is selected") + assert widget.selection == source[0][1] + on_select_handler.assert_called_once_with(widget) + on_select_handler.reset_mock() + + # A deeper row can be selected + await probe.select_row(11) + await probe.redraw("Deep row in second group is selected") + assert widget.selection == source[1][2][0] + on_select_handler.assert_called_once_with(widget) + on_select_handler.reset_mock() + + if probe.supports_keyboard_shortcuts: + # Keyboard responds to selectAll + await probe.select_all() + await probe.redraw("Select all keyboard shortcut is ignored") + assert widget.selection == source[1][2][0] + + # Other keystrokes are ignored + await probe.type_character("x") + await probe.redraw("A non-shortcut key was pressed") + assert widget.selection == source[1][2][0] + + +async def test_activate( + widget, + probe, + source, + on_select_handler, + on_activate_handler, +): + """Rows can be activated""" + await probe.expand_tree() + + await probe.activate_row(1) + await probe.redraw("Second row is activated") + + # Activation selects the row. + assert widget.selection == source[0][0] + on_select_handler.assert_called_once_with(widget) + on_select_handler.reset_mock() + + on_activate_handler.assert_called_once_with(widget, node=source[0][0]) + on_activate_handler.reset_mock() + + +async def test_multiselect( + multiselect_widget, + multiselect_probe, + source, + on_select_handler, +): + """A table can be set up for multi-select""" + await multiselect_probe.redraw("No row is selected in multiselect table") + + # Initial selection is empty + assert multiselect_widget.selection == [] + on_select_handler.assert_not_called() + + await multiselect_probe.expand_tree() + + # A single row can be selected + await multiselect_probe.select_row(1) + assert multiselect_widget.selection == [source[0][0]] + await multiselect_probe.redraw("One row is selected in multiselect table") + on_select_handler.assert_called_once_with(multiselect_widget) + on_select_handler.reset_mock() + + # A row can be added to the selection + await multiselect_probe.select_row(2, add=True) + await multiselect_probe.redraw("Two rows are selected in multiselect table") + assert multiselect_widget.selection == [source[0][0], source[0][1]] + on_select_handler.assert_called_once_with(multiselect_widget) + on_select_handler.reset_mock() + + # A deeper row can be added to the selection + await multiselect_probe.select_row(11, add=True) + await multiselect_probe.redraw("Three rows are selected in multiselect table") + assert multiselect_widget.selection == [source[0][0], source[0][1], source[1][2][0]] + on_select_handler.assert_called_once_with(multiselect_widget) + on_select_handler.reset_mock() + + # A row can be removed from the selection + await multiselect_probe.select_row(1, add=True) + await multiselect_probe.redraw("First row has been removed from the selection") + assert multiselect_widget.selection == [source[0][1], source[1][2][0]] + on_select_handler.assert_called_once_with(multiselect_widget) + on_select_handler.reset_mock() + + if multiselect_probe.supports_keyboard_shortcuts: + # Keyboard responds to selectAll + await multiselect_probe.select_all() + await multiselect_probe.redraw("All rows selected by keyboard") + assert len(multiselect_widget.selection) == 70 + + +class MyData: + def __init__(self, text): + self.text = text + + def __str__(self): + return f"" + + +async def _row_change_test(widget, probe): + """Meta test for adding and removing data to the table""" + + # Change the data source for something smaller + widget.data = [ + ( + {"a": "A0", "b": "", "c": ""}, + [({"a": f"A{i}", "b": i, "c": MyData(i)}, None) for i in range(0, 5)], + ) + ] + await probe.expand_tree() + await probe.redraw("Data source has been changed") + + assert probe.child_count() == 1 + assert probe.child_count((0,)) == 5 + + # All cell contents are strings + probe.assert_cell_content((0, 4), 0, "A4") + probe.assert_cell_content((0, 4), 1, "4") + probe.assert_cell_content((0, 4), 2, "") + + # Append a row to the table + widget.data[0].append({"a": "AX", "b": "BX", "c": "CX"}) + await probe.redraw("Full row has been appended") + + assert probe.child_count((0,)) == 6 + probe.assert_cell_content((0, 4), 0, "A4") + probe.assert_cell_content((0, 5), 0, "AX") + + # Insert a row into the middle of the table; + # Row is missing a B accessor + widget.data[0].insert(2, {"a": "AY", "c": "CY"}) + await probe.redraw("Partial row has been appended") + + assert probe.child_count((0,)) == 7 + probe.assert_cell_content((0, 2), 0, "AY") + probe.assert_cell_content((0, 5), 0, "A4") + probe.assert_cell_content((0, 6), 0, "AX") + + # Missing value has been populated + probe.assert_cell_content((0, 2), 1, "MISSING!") + + # Change content on the partial row + widget.data[0][2].a = "ANEW" + widget.data[0][2].b = "BNEW" + await probe.redraw("Partial row has been updated") + + assert probe.child_count((0,)) == 7 + probe.assert_cell_content((0, 2), 0, "ANEW") + probe.assert_cell_content((0, 5), 0, "A4") + probe.assert_cell_content((0, 6), 0, "AX") + + # Missing value has the default empty string + probe.assert_cell_content((0, 2), 1, "BNEW") + + # Delete a row + del widget.data[0][3] + await probe.redraw("Row has been removed") + assert probe.child_count((0,)) == 6 + probe.assert_cell_content((0, 2), 0, "ANEW") + probe.assert_cell_content((0, 4), 0, "A4") + probe.assert_cell_content((0, 5), 0, "AX") + + # Insert a new root; + # Row is missing a B accessor + widget.data.insert(0, {"a": "A!", "b": "B!", "c": "C!"}) + await probe.redraw("New root row has been appended") + + assert probe.child_count() == 2 + assert probe.child_count((0,)) == 0 + probe.assert_cell_content((0,), 0, "A!") + + # Clear the table + widget.data.clear() + await probe.redraw("Data has been cleared") + assert probe.child_count() == 0 + + +async def test_row_changes(widget, probe): + """Rows can be added and removed""" + # Header is visible + assert probe.header_visible + await _row_change_test(widget, probe) + + +async def test_headerless_row_changes(headerless_widget, headerless_probe): + """Rows can be added and removed to a headerless table""" + # Header doesn't exist + assert not headerless_probe.header_visible + await _row_change_test(headerless_widget, headerless_probe) + + +async def _column_change_test(widget, probe): + """Meta test for adding and removing columns""" + # Initially 3 columns; Cell 0,2 contains C0 + assert probe.column_count == 3 + probe.assert_cell_content((0,), 2, "C0") + + widget.append_column("E", accessor="e") + await probe.redraw("E column appended") + + # 4 columns; the new content on row 0 is "E0" + assert probe.column_count == 4 + probe.assert_cell_content((0,), 2, "C0") + probe.assert_cell_content((0,), 3, "E0") + + widget.insert_column(3, "D", accessor="d") + await probe.redraw("E column appended") + + # 5 columns; the new content on row 0 is "D0", between C0 and E0 + assert probe.column_count == 5 + probe.assert_cell_content((0,), 2, "C0") + probe.assert_cell_content((0,), 3, "D0") + probe.assert_cell_content((0,), 4, "E0") + + widget.remove_column(2) + await probe.redraw("C column removed") + + # 4 columns; C0 has gone + assert probe.column_count == 4 + probe.assert_cell_content((0,), 2, "D0") + probe.assert_cell_content((0,), 3, "E0") + + +async def test_column_changes(widget, probe): + """Columns can be added and removed""" + # Header is visible, and has the right titles + assert probe.header_visible + assert probe.header_titles == ["A", "B", "C"] + # Columns should be roughly equal in width; there's a healthy allowance for + # inter-column padding etc. + assert probe.column_width(0) == pytest.approx(probe.width / 3, abs=25) + assert probe.column_width(1) == pytest.approx(probe.width / 3, abs=25) + assert probe.column_width(2) == pytest.approx(probe.width / 3, abs=25) + + await _column_change_test(widget, probe) + + assert probe.header_titles == ["A", "B", "D", "E"] + # The specific behavior for resizing is undefined; however, the columns should add + # up to near the full width (allowing for inter-column padding, etc), and no single + # column should be tiny. + total_width = sum(probe.column_width(i) for i in range(0, 4)) + assert total_width == pytest.approx(probe.width, abs=100) + assert all(probe.column_width(i) > 80 for i in range(0, 4)) + + +async def test_headerless_column_changes(headerless_widget, headerless_probe): + """Columns can be added and removed to a headerless table""" + # Header is not visible + assert not headerless_probe.header_visible + + await _column_change_test(headerless_widget, headerless_probe) + + +class MyIconData: + def __init__(self, text, icon): + self.text = text + self.icon = icon + + def __str__(self): + return f"" + + +async def test_cell_icon(widget, probe): + "An icon can be used as a cell value" + red = toga.Icon("resources/icons/red") + green = toga.Icon("resources/icons/green") + widget.data = [ + ( + {"a": "A0", "b": "", "c": ""}, + [ + ( + { + # Normal text, + "a": f"A{i}", + # A tuple + "b": ({0: None, 1: red, 2: green}[i % 3], f"B{i}"), + # An object with an icon attribute. + "c": MyIconData(f"C{i}", {0: red, 1: green, 2: None}[i % 3]), + }, + None, + ) + for i in range(0, 50) + ], + ) + ] + await probe.expand_tree() + + await probe.redraw("Tree has data with icons") + + probe.assert_cell_content((0, 0), 0, "A0") + probe.assert_cell_content((0, 0), 1, "B0", icon=None) + probe.assert_cell_content((0, 0), 2, "", icon=red) + + probe.assert_cell_content((0, 1), 0, "A1") + probe.assert_cell_content((0, 1), 1, "B1", icon=red) + probe.assert_cell_content((0, 1), 2, "", icon=green) + + probe.assert_cell_content((0, 2), 0, "A2") + probe.assert_cell_content((0, 2), 1, "B2", icon=green) + probe.assert_cell_content((0, 2), 2, "", icon=None) + + +async def test_cell_widget(widget, probe): + "A widget can be used as a cell value" + widget.data = [ + ( + {"a": "A0", "b": "", "c": ""}, + [ + ( + { + # Normal text, + "a": f"A{i}", + "b": f"B{i}", + # Toga widgets. + "c": toga.Button(f"C{i}") + if i % 2 == 0 + else toga.TextInput(value=f"edit C{i}"), + }, + None, + ) + for i in range(0, 50) + ], + ), + ] + await probe.expand_tree() + await probe.redraw("Tree has data with widgets") + + probe.assert_cell_content((0, 0), 0, "A0") + probe.assert_cell_content((0, 0), 1, "B0") + probe.assert_cell_content((0, 0), 2, widget=widget.data[0][0].c) + + probe.assert_cell_content((0, 1), 0, "A1") + probe.assert_cell_content((0, 1), 1, "B1") + probe.assert_cell_content((0, 1), 2, widget=widget.data[0][1].c) From ebf8a13a71912b27a89be69589184cfe104b90e3 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 29 Jun 2023 13:40:24 +0800 Subject: [PATCH 11/30] Update release notes. --- changes/2011.removal.2.rst | 2 +- changes/{2017.feature.rst => 2017.feature.1.rst} | 0 changes/2017.feature.2.rst | 1 + changes/2017.removal.2.rst | 1 + changes/2017.removal.3.rst | 1 + 5 files changed, 4 insertions(+), 1 deletion(-) rename changes/{2017.feature.rst => 2017.feature.1.rst} (100%) create mode 100644 changes/2017.feature.2.rst create mode 100644 changes/2017.removal.2.rst create mode 100644 changes/2017.removal.3.rst diff --git a/changes/2011.removal.2.rst b/changes/2011.removal.2.rst index e75e04e516..950ee3c64c 100644 --- a/changes/2011.removal.2.rst +++ b/changes/2011.removal.2.rst @@ -1 +1 @@ -Tables now use an empty string for the default missing value on a Table. +Tables now use an empty string for the default missing value, rather than warning about missing values. diff --git a/changes/2017.feature.rst b/changes/2017.feature.1.rst similarity index 100% rename from changes/2017.feature.rst rename to changes/2017.feature.1.rst diff --git a/changes/2017.feature.2.rst b/changes/2017.feature.2.rst new file mode 100644 index 0000000000..65351dd337 --- /dev/null +++ b/changes/2017.feature.2.rst @@ -0,0 +1 @@ +Columns can now be added and removed from a Tree. diff --git a/changes/2017.removal.2.rst b/changes/2017.removal.2.rst new file mode 100644 index 0000000000..150389a5c1 --- /dev/null +++ b/changes/2017.removal.2.rst @@ -0,0 +1 @@ +Trees now use an empty string for the default missing value, rather than warning about missing values. diff --git a/changes/2017.removal.3.rst b/changes/2017.removal.3.rst new file mode 100644 index 0000000000..28d434d518 --- /dev/null +++ b/changes/2017.removal.3.rst @@ -0,0 +1 @@ +``Table.on_double_click`` has been renamed ``Table.on_activate``. From 3508c7e163002b0ada5b4f49cbee541c1d4f8035 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 29 Jun 2023 13:40:52 +0800 Subject: [PATCH 12/30] Disable Winforms Tree, on the basis it doesn't exist yet. --- testbed/tests/widgets/test_tree.py | 4 ++-- winforms/src/toga_winforms/widgets/tree.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/testbed/tests/widgets/test_tree.py b/testbed/tests/widgets/test_tree.py index d5e0bd0fb0..dc2c4acdc1 100644 --- a/testbed/tests/widgets/test_tree.py +++ b/testbed/tests/widgets/test_tree.py @@ -92,7 +92,7 @@ def source(): @pytest.fixture async def widget(source, on_select_handler, on_activate_handler): - skip_on_platforms("iOS", "android") + skip_on_platforms("iOS", "android", "windows") return toga.Tree( ["A", "B", "C"], data=source, @@ -105,7 +105,7 @@ async def widget(source, on_select_handler, on_activate_handler): @pytest.fixture def headerless_widget(source, on_select_handler): - skip_on_platforms("iOS", "android") + skip_on_platforms("iOS", "android", "windows") return toga.Tree( data=source, missing_value="MISSING!", diff --git a/winforms/src/toga_winforms/widgets/tree.py b/winforms/src/toga_winforms/widgets/tree.py index d9ca37455d..1830579899 100644 --- a/winforms/src/toga_winforms/widgets/tree.py +++ b/winforms/src/toga_winforms/widgets/tree.py @@ -3,7 +3,7 @@ from .base import Widget -class Tree(Widget): +class Tree(Widget): # pragma: no cover def create(self): self.native = WinForms.TreeView() From 9d052dbb737cb509798f4a84331145cb39111af4 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 29 Jun 2023 13:48:02 +0800 Subject: [PATCH 13/30] Correct merge error. --- core/src/toga/sources/accessors.py | 17 ++--------------- core/src/toga/sources/tree_source.py | 3 ++- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/core/src/toga/sources/accessors.py b/core/src/toga/sources/accessors.py index 548a2ac3e1..3e1cf4e83f 100644 --- a/core/src/toga/sources/accessors.py +++ b/core/src/toga/sources/accessors.py @@ -63,17 +63,7 @@ def build_accessors( :returns: The final list of accessors. """ if accessors: - if headings is None: - if not isinstance(accessors, (list, tuple)): - raise TypeError( - "When no headings are provided, accessors must be a list or tuple" - ) - if not all(accessors): - raise ValueError( - "When no headings are provided, all accessors must be defined" - ) - result = accessors - elif isinstance(accessors, dict): + if isinstance(accessors, dict): result = [ accessors[h] if h in accessors else to_accessor(h) for h in headings ] @@ -86,9 +76,6 @@ def build_accessors( for h, a in zip(headings, accessors) ] else: - if headings: - result = [to_accessor(h) for h in headings] - else: - raise ValueError("Either headings or accessors must be provided") + result = [to_accessor(h) for h in headings] return result diff --git a/core/src/toga/sources/tree_source.py b/core/src/toga/sources/tree_source.py index 179daa366f..c1320cdef8 100644 --- a/core/src/toga/sources/tree_source.py +++ b/core/src/toga/sources/tree_source.py @@ -261,7 +261,8 @@ def _create_nodes(self, parent: Node | None, value: Any): ] elif hasattr(value, "__iter__") and not isinstance(value, str): return [ - self._create_node(data, children) for data, children in value.items() + self._create_node(parent=parent, data=item[0], children=item[1]) + for item in value ] else: return [self._create_node(parent=parent, data=value)] From c12ed3c6c6aaaf9954f6f2f61eab957c45658750 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 29 Jun 2023 14:03:04 +0800 Subject: [PATCH 14/30] Miscellanous easy coverage wins. --- core/src/toga/constants/__init__.py | 2 +- core/src/toga/keys.py | 2 +- core/tests/test_key.py | 11 ----------- core/tests/test_keys.py | 21 +++++++++++++++++++++ 4 files changed, 23 insertions(+), 13 deletions(-) delete mode 100644 core/tests/test_key.py create mode 100644 core/tests/test_keys.py diff --git a/core/src/toga/constants/__init__.py b/core/src/toga/constants/__init__.py index c8d0b5da7b..ae946a8835 100644 --- a/core/src/toga/constants/__init__.py +++ b/core/src/toga/constants/__init__.py @@ -1 +1 @@ -from travertino.constants import * # noqa: F401, F403 +from travertino.constants import * # noqa: F401, F403 pragma: no cover diff --git a/core/src/toga/keys.py b/core/src/toga/keys.py index 9c00621ea8..d6d02f6fd8 100644 --- a/core/src/toga/keys.py +++ b/core/src/toga/keys.py @@ -164,4 +164,4 @@ def __add__(self, other): def __radd__(self, other): """Same as add.""" - return self + other + return other + self.value diff --git a/core/tests/test_key.py b/core/tests/test_key.py deleted file mode 100644 index d73caf68d0..0000000000 --- a/core/tests/test_key.py +++ /dev/null @@ -1,11 +0,0 @@ -import unittest - -from toga.keys import Key - - -class TestKey(unittest.TestCase): - def test_is_printable(self): - self.assertFalse(Key.is_printable(Key.SHIFT)) - self.assertTrue(Key.is_printable(Key.LESS_THAN)) - self.assertTrue(Key.is_printable(Key.GREATER_THAN)) - self.assertTrue(Key.is_printable(Key.NUMPAD_0)) diff --git a/core/tests/test_keys.py b/core/tests/test_keys.py new file mode 100644 index 0000000000..fd5afef715 --- /dev/null +++ b/core/tests/test_keys.py @@ -0,0 +1,21 @@ +from toga.keys import Key + + +def test_is_printable(): + "Key printability can be checked" + assert not Key.is_printable(Key.SHIFT) + assert Key.is_printable(Key.LESS_THAN) + assert Key.is_printable(Key.GREATER_THAN) + assert Key.is_printable(Key.NUMPAD_0) + + +def test_modifiers(): + "Keys can be added with modifiers" + # Mod + Key + assert Key.MOD_1 + Key.A == "a" + + # Multiple modifiers can be used + assert Key.MOD_1 + Key.SHIFT + Key.A == "a" + + # Bare characters can be used + assert Key.MOD_1 + "a" == "a" From 7070e1539498605ef24617de6b5af24f256582ca Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 30 Jun 2023 10:06:26 +0800 Subject: [PATCH 15/30] GTK Tree coverage at 100%. --- cocoa/tests_backend/widgets/table.py | 1 + cocoa/tests_backend/widgets/tree.py | 17 +- docs/reference/data/widgets_by_platform.csv | 2 +- gtk/src/toga_gtk/widgets/table.py | 25 ++- gtk/src/toga_gtk/widgets/tree.py | 187 ++++++++++++-------- gtk/tests_backend/widgets/table.py | 1 + gtk/tests_backend/widgets/tree.py | 94 ++++++++++ testbed/tests/widgets/test_table.py | 24 ++- testbed/tests/widgets/test_tree.py | 51 ++++-- 9 files changed, 295 insertions(+), 107 deletions(-) create mode 100644 gtk/tests_backend/widgets/tree.py diff --git a/cocoa/tests_backend/widgets/table.py b/cocoa/tests_backend/widgets/table.py index 55157a9561..7ebdd3eb0d 100644 --- a/cocoa/tests_backend/widgets/table.py +++ b/cocoa/tests_backend/widgets/table.py @@ -11,6 +11,7 @@ class TableProbe(SimpleProbe): native_class = NSScrollView supports_keyboard_shortcuts = True + supports_cell_widgets = True def __init__(self, widget): super().__init__(widget) diff --git a/cocoa/tests_backend/widgets/tree.py b/cocoa/tests_backend/widgets/tree.py index 6bd2015f4a..bdeca2cff9 100644 --- a/cocoa/tests_backend/widgets/tree.py +++ b/cocoa/tests_backend/widgets/tree.py @@ -13,6 +13,7 @@ class TreeProbe(SimpleProbe): native_class = NSScrollView supports_keyboard_shortcuts = True + supports_cell_widgets = True def __init__(self, widget): super().__init__(widget) @@ -99,7 +100,13 @@ def header_titles(self): def column_width(self, col): return self.native_tree.tableColumns[col].width - def row_position(self, row): + def row_position(self, row_path): + # Convert the row path in to an absolute row index + item = self.native_tree.child(row_path[0], ofItem=None) + for index in row_path[1:]: + item = self.native_tree.child(index, ofItem=item) + row = self.native_tree.rowForItem(item) + # Pick a point half way across horizontally, and half way down the row, # taking into account the size of the rows and the header row_height = self.native_tree.rowHeight @@ -114,8 +121,8 @@ def row_position(self, row): async def select_all(self): await self.type_character("A", modifierFlags=NSEventModifierFlagCommand), - async def select_row(self, row, add=False): - point = self.row_position(row) + async def select_row(self, row_path, add=False): + point = self.row_position(row_path) # Table maintains an inner mouse event loop, so we can't # use the "wait for another event" approach for the mouse events. # Use a short delay instead. @@ -132,8 +139,8 @@ async def select_row(self, row, add=False): modifierFlags=NSEventModifierFlagCommand if add else 0, ) - async def activate_row(self, row): - point = self.row_position(row) + async def activate_row(self, row_path): + point = self.row_position(row_path) # Table maintains an inner mouse event loop, so we can't # use the "wait for another event" approach for the mouse events. # Use a short delay instead. diff --git a/docs/reference/data/widgets_by_platform.csv b/docs/reference/data/widgets_by_platform.csv index 990fea9540..e85990764f 100644 --- a/docs/reference/data/widgets_by_platform.csv +++ b/docs/reference/data/widgets_by_platform.csv @@ -20,7 +20,7 @@ Switch,General Widget,:class:`~toga.Switch`,Switch,|y|,|y|,|y|,|y|,|y|,|b| Table,General Widget,:class:`~toga.Table`,A widget for displaying columns of tabular data.,|b|,|b|,|b|,,|b|, TextInput,General Widget,:class:`~toga.TextInput`,A widget for the display and editing of a single line of text.,|y|,|y|,|y|,|y|,|y|,|b| TimeInput,General Widget,:class:`~toga.TimeInput`,A widget to select a clock time,,,|y|,,|y|, -Tree,General Widget,:class:`~toga.Tree`,A widget for displaying a hierarchical tree of tabular data.,|b|,|b|,|b|,,, +Tree,General Widget,:class:`~toga.Tree`,A widget for displaying a hierarchical tree of tabular data.,|y|,|y|,,,, WebView,General Widget,:class:`~toga.WebView`,A panel for displaying HTML,|y|,|y|,|y|,|y|,|y|, Widget,General Widget,:class:`~toga.Widget`,The base widget,|y|,|y|,|y|,|y|,|y|,|b| Box,Layout Widget,:class:`~toga.Box`,Container for components,|y|,|y|,|y|,|y|,|y|,|b| diff --git a/gtk/src/toga_gtk/widgets/table.py b/gtk/src/toga_gtk/widgets/table.py index de65166e31..919ab9b69a 100644 --- a/gtk/src/toga_gtk/widgets/table.py +++ b/gtk/src/toga_gtk/widgets/table.py @@ -1,16 +1,20 @@ +import warnings + from travertino.size import at_least +import toga + from ..libs import GdkPixbuf, GObject, Gtk from .base import Widget class TogaRow(GObject.Object): - def __init__(self, row): + def __init__(self, value): super().__init__() - self.row = row + self.value = value def icon(self, attr): - data = getattr(self.row, attr, None) + data = getattr(self.value, attr, None) if isinstance(data, tuple): if data[0] is not None: return data[0]._impl.native_16 @@ -22,8 +26,11 @@ def icon(self, attr): return None def text(self, attr, missing_value): - data = getattr(self.row, attr, None) - if isinstance(data, tuple): + data = getattr(self.value, attr, None) + if isinstance(data, toga.Widget): + warnings.warn("GTK does not support the use of widgets in cells") + text = None + elif isinstance(data, tuple): text = data[1] else: text = data @@ -82,8 +89,8 @@ def _create_columns(self): self.native_table.append_column(column) def gtk_on_row_activated(self, widget, path, column): - row = self.store.get(self.store.get_iter(path[-1]), 0)[0] - self.interface.on_activate(None, row=row.row) + row = self.store[path][0].value + self.interface.on_activate(None, row=row) def gtk_on_select(self, selection): self.interface.on_select(None) @@ -136,12 +143,12 @@ def clear(self): def get_selection(self): if self.interface.multiple_select: store, itrs = self.selection.get_selected_rows() - return [self.interface.data.index(store[itr][0].row) for itr in itrs] + return [self.interface.data.index(store[itr][0].value) for itr in itrs] else: store, iter = self.selection.get_selected() if iter is None: return None - return self.interface.data.index(store[iter][0].row) + return self.interface.data.index(store[iter][0].value) def scroll_to_row(self, row): # Core API guarantees row exists, and there's > 1 row. diff --git a/gtk/src/toga_gtk/widgets/tree.py b/gtk/src/toga_gtk/widgets/tree.py index 51a0e9b7b4..cad2de3217 100644 --- a/gtk/src/toga_gtk/widgets/tree.py +++ b/gtk/src/toga_gtk/widgets/tree.py @@ -1,118 +1,153 @@ -import toga +from travertino.size import at_least -from ..libs import Gtk +from ..libs import GdkPixbuf, Gtk from .base import Widget -from .internal.sourcetreemodel import SourceTreeModel +from .table import TogaRow class Tree(Widget): def create(self): - # Tree is reused for table, where it's a ListSource, not a tree - # so check here if the actual widget is a Tree or a Table. - # It can't be based on the source, since it determines flags - # and GtkTreeModel.flags is not allowed to change after creation - is_tree = isinstance(self.interface, toga.Tree) - self.store = SourceTreeModel( - [{"type": str, "attr": a} for a in self.interface._accessors], - is_tree=is_tree, - missing_value=self.interface.missing_value, - ) + self.store = None # Create a tree view, and put it in a scroll view. # The scroll view is the _impl, because it's the outer container. - self.treeview = Gtk.TreeView(model=self.store) - self.selection = self.treeview.get_selection() + self.native_tree = Gtk.TreeView(model=self.store) + self.native_tree.connect("row-activated", self.gtk_on_row_activated) + + self.selection = self.native_tree.get_selection() if self.interface.multiple_select: self.selection.set_mode(Gtk.SelectionMode.MULTIPLE) else: self.selection.set_mode(Gtk.SelectionMode.SINGLE) self.selection.connect("changed", self.gtk_on_select) - if self.interface.headings: - _headings = self.interface.headings - else: - _headings = self.interface._accessors - self.treeview.set_headers_visible(False) - for i, heading in enumerate(_headings): - renderer = Gtk.CellRendererText() - column = Gtk.TreeViewColumn(heading, renderer, text=i + 1) - self.treeview.append_column(column) + self._create_columns() self.native = Gtk.ScrolledWindow() self.native.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) - self.native.add(self.treeview) + self.native.add(self.native_tree) self.native.set_min_content_width(200) self.native.set_min_content_height(200) + def _create_columns(self): + if self.interface.headings: + headings = self.interface.headings + self.native_tree.set_headers_visible(True) + else: + headings = self.interface.accessors + self.native_tree.set_headers_visible(False) + + for i, heading in enumerate(headings): + column = Gtk.TreeViewColumn(heading) + column.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE) + column.set_expand(True) + column.set_resizable(True) + column.set_min_width(16) + + icon = Gtk.CellRendererPixbuf() + column.pack_start(icon, False) + column.add_attribute(icon, "pixbuf", i * 2 + 1) + + value = Gtk.CellRendererText() + column.pack_start(value, True) + column.add_attribute(value, "text", i * 2 + 2) + + self.native_tree.append_column(column) + def gtk_on_select(self, selection): - if self.interface.on_select: - if self.interface.multiple_select: - tree_model, tree_path = selection.get_selected_rows() - if tree_path: - tree_iter = tree_model.get_iter(tree_path[-1]) - else: - tree_iter = None - else: - tree_model, tree_iter = selection.get_selected() - - # Covert the tree iter into the actual node. - if tree_iter: - node = tree_model.get(tree_iter, 0)[0] - else: - node = None - self.interface.on_select(None, node=node) + self.interface.on_select(None) + + def gtk_on_row_activated(self, widget, path, column): + node = self.store[path][0].value + self.interface.on_activate(None, node=node) def change_source(self, source): # Temporarily disconnecting the TreeStore improves performance for large # updates by deferring row rendering until the update is complete. - self.treeview.set_model(None) - - self.store.change_source(source) - - def append_children(data, parent=None): - if data.can_have_children(): - for i, node in enumerate(data): - self.insert(parent, i, node) - append_children(node, parent=node) - - append_children(source, parent=None) + self.native_tree.set_model(None) + + for column in self.native_tree.get_columns(): + self.native_tree.remove_column(column) + self._create_columns() + + types = [TogaRow] + for accessor in self.interface._accessors: + types.extend([GdkPixbuf.Pixbuf, str]) + self.store = Gtk.TreeStore(*types) + + for i, row in enumerate(self.interface.data): + self.insert(None, i, row) + + self.native_tree.set_model(self.store) + + def path_for_node(self, node): + root = node._parent + row_path = [] + while root is not None: + row_path.append(root.index(node)) + node = root + root = root._parent + row_path.append(self.interface.data.index(node)) + row_path.reverse() + + return row_path + + def insert(self, parent, index, item): + row = TogaRow(item) + values = [row] + for accessor in self.interface.accessors: + values.extend( + [ + row.icon(accessor), + row.text(accessor, self.interface.missing_value), + ] + ) + + if parent is None: + iter = None + else: + path = self.path_for_node(parent) + iter = self.store.get_iter(Gtk.TreePath(path)) - self.treeview.set_model(self.store) + self.store.insert(iter, index, values) - def insert(self, parent, index, item, **kwargs): - self.store.insert(item) + for i, child in enumerate(item): + self.insert(item, i, child) def change(self, item): - self.store.change(item) + row = self.store[self.path_for_node(item)] + for i, accessor in enumerate(self.interface.accessors): + row[i * 2 + 1] = row[0].icon(accessor) + row[i * 2 + 2] = row[0].text(accessor, self.interface.missing_value) def remove(self, item, index, parent): - self.store.remove(item, index=index, parent=parent) + if parent is None: + path = [index] + else: + path = self.path_for_node(parent) + [index] + + del self.store[path] def clear(self): self.store.clear() def get_selection(self): if self.interface.multiple_select: - tree_model, tree_paths = self.selection.get_selected_rows() - return [ - tree_model.get(tree_model.get_iter(path), 0)[0] for path in tree_paths - ] + store, itrs = self.selection.get_selected_rows() + return [store[itr][0].value for itr in itrs] else: - tree_model, tree_iter = self.selection.get_selected() - if tree_iter: - row = tree_model.get(tree_iter, 0)[0] - else: - row = None - - return row + store, iter = self.selection.get_selected() + if iter is None: + return None + return store[iter][0].value - def set_on_select(self, handler): - # No special handling required - pass + def insert_column(self, position, heading, accessor): + # Adding/removing a column means completely rebuilding the ListStore + self.change_source(self.interface.data) - def set_on_double_click(self, handler): - self.interface.factory.not_implemented("Tree.set_on_double_click()") + def remove_column(self, accessor): + self.change_source(self.interface.data) - def scroll_to_node(self, node): - path = self.store.path_to_node(node) - self.treeview.scroll_to_cell(path) + def rehint(self): + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) diff --git a/gtk/tests_backend/widgets/table.py b/gtk/tests_backend/widgets/table.py index ef8220b4e3..7b90d9f2bb 100644 --- a/gtk/tests_backend/widgets/table.py +++ b/gtk/tests_backend/widgets/table.py @@ -8,6 +8,7 @@ class TableProbe(SimpleProbe): native_class = Gtk.ScrolledWindow supports_keyboard_shortcuts = False + supports_cell_widgets = False def __init__(self, widget): super().__init__(widget) diff --git a/gtk/tests_backend/widgets/tree.py b/gtk/tests_backend/widgets/tree.py new file mode 100644 index 0000000000..601c8c4575 --- /dev/null +++ b/gtk/tests_backend/widgets/tree.py @@ -0,0 +1,94 @@ +import asyncio + +import pytest + +from toga_gtk.libs import Gtk + +from .base import SimpleProbe + + +class TreeProbe(SimpleProbe): + native_class = Gtk.ScrolledWindow + supports_keyboard_shortcuts = False + supports_cell_widgets = False + + def __init__(self, widget): + super().__init__(widget) + self.native_tree = widget._impl.native_tree + assert isinstance(self.native_tree, Gtk.TreeView) + + @property + def background_color(self): + pytest.skip("Can't set background color on GTK Tables") + + async def expand_tree(self): + self.native_tree.expand_all() + await asyncio.sleep(0.1) + + def child_count(self, row_path=None): + if row_path: + row = self.native_tree.get_model()[row_path] + return len(list(row.iterchildren())) + else: + return len(self.native_tree.get_model()) + + @property + def column_count(self): + return self.native_tree.get_n_columns() + + @property + def header_visible(self): + return self.native_tree.get_headers_visible() + + @property + def header_titles(self): + return [col.get_title() for col in self.native_tree.get_columns()] + + def column_width(self, col): + return self.native_tree.get_column(col).get_width() + + def assert_cell_content(self, row_path, col, value=None, icon=None, widget=None): + if widget: + pytest.skip("GTK doesn't support widgets in Tables") + else: + gtk_row = self.native_tree.get_model()[row_path] + assert gtk_row[col * 2 + 2] + + if icon: + assert gtk_row[col * 2 + 1] == icon._impl.native_16 + else: + assert gtk_row[col * 2 + 1] is None + + @property + def max_scroll_position(self): + return int( + self.native.get_vadjustment().get_upper() + - self.native.get_vadjustment().get_page_size() + ) + + @property + def scroll_position(self): + return int(self.native.get_vadjustment().get_value()) + + async def wait_for_scroll_completion(self): + # No animation associated with scroll, so this is a no-op + pass + + async def select_row(self, row_path, add=False): + path = Gtk.TreePath(row_path) + + if add: + if path in self.native_tree.get_selection().get_selected_rows()[1]: + self.native_tree.get_selection().unselect_path(path) + else: + self.native_tree.get_selection().select_path(path) + else: + self.native_tree.get_selection().select_path(path) + + async def activate_row(self, row_path): + await self.select_row(row_path) + self.native_tree.emit( + "row-activated", + Gtk.TreePath(row_path), + self.native_tree.get_columns()[0], + ) diff --git a/testbed/tests/widgets/test_table.py b/testbed/tests/widgets/test_table.py index 012c8070ab..a26aec914e 100644 --- a/testbed/tests/widgets/test_table.py +++ b/testbed/tests/widgets/test_table.py @@ -1,3 +1,4 @@ +import contextlib from unittest.mock import Mock import pytest @@ -421,7 +422,7 @@ async def test_cell_icon(widget, probe): async def test_cell_widget(widget, probe): "A widget can be used as a cell value" - widget.data = [ + data = [ { # Normal text, "a": f"A{i}", @@ -433,12 +434,29 @@ async def test_cell_widget(widget, probe): } for i in range(0, 50) ] + if probe.supports_cell_widgets: + warning_check = contextlib.nullcontext() + else: + warning_check = pytest.warns( + match=".* does not support the use of widgets in cells" + ) + + with warning_check: + widget.data = data + await probe.redraw("Table has data with widgets") probe.assert_cell_content(0, 0, "A0") probe.assert_cell_content(0, 1, "B0") - probe.assert_cell_content(0, 2, widget=widget.data[0].c) probe.assert_cell_content(1, 0, "A1") probe.assert_cell_content(1, 1, "B1") - probe.assert_cell_content(1, 2, widget=widget.data[1].c) + + if probe.supports_cell_widgets: + probe.assert_cell_content(0, 2, widget=widget.data[0].c) + probe.assert_cell_content(1, 2, widget=widget.data[1].c) + else: + # If the platform doesn't support cell widgets, the test should still *run* - + # we just won't have widgets in the cells. + probe.assert_cell_content(0, 2, "MISSING!") + probe.assert_cell_content(1, 2, "MISSING!") diff --git a/testbed/tests/widgets/test_tree.py b/testbed/tests/widgets/test_tree.py index dc2c4acdc1..61840c07f9 100644 --- a/testbed/tests/widgets/test_tree.py +++ b/testbed/tests/widgets/test_tree.py @@ -1,3 +1,4 @@ +import contextlib from unittest.mock import Mock import pytest @@ -166,21 +167,21 @@ async def test_select(widget, probe, source, on_select_handler): await probe.expand_tree() # A single row can be selected - await probe.select_row(1) + await probe.select_row((0, 0)) await probe.redraw("Second row is selected") assert widget.selection == source[0][0] on_select_handler.assert_called_once_with(widget) on_select_handler.reset_mock() # Trying to multi-select only does a single select - await probe.select_row(2, add=True) + await probe.select_row((0, 1), add=True) await probe.redraw("Third row is selected") assert widget.selection == source[0][1] on_select_handler.assert_called_once_with(widget) on_select_handler.reset_mock() # A deeper row can be selected - await probe.select_row(11) + await probe.select_row((1, 2, 0)) await probe.redraw("Deep row in second group is selected") assert widget.selection == source[1][2][0] on_select_handler.assert_called_once_with(widget) @@ -208,7 +209,7 @@ async def test_activate( """Rows can be activated""" await probe.expand_tree() - await probe.activate_row(1) + await probe.activate_row((0, 0)) await probe.redraw("Second row is activated") # Activation selects the row. @@ -236,28 +237,28 @@ async def test_multiselect( await multiselect_probe.expand_tree() # A single row can be selected - await multiselect_probe.select_row(1) + await multiselect_probe.select_row((0, 0)) assert multiselect_widget.selection == [source[0][0]] await multiselect_probe.redraw("One row is selected in multiselect table") on_select_handler.assert_called_once_with(multiselect_widget) on_select_handler.reset_mock() # A row can be added to the selection - await multiselect_probe.select_row(2, add=True) + await multiselect_probe.select_row((0, 1), add=True) await multiselect_probe.redraw("Two rows are selected in multiselect table") assert multiselect_widget.selection == [source[0][0], source[0][1]] on_select_handler.assert_called_once_with(multiselect_widget) on_select_handler.reset_mock() # A deeper row can be added to the selection - await multiselect_probe.select_row(11, add=True) + await multiselect_probe.select_row((1, 2, 0), add=True) await multiselect_probe.redraw("Three rows are selected in multiselect table") assert multiselect_widget.selection == [source[0][0], source[0][1], source[1][2][0]] on_select_handler.assert_called_once_with(multiselect_widget) on_select_handler.reset_mock() # A row can be removed from the selection - await multiselect_probe.select_row(1, add=True) + await multiselect_probe.select_row((0, 0), add=True) await multiselect_probe.redraw("First row has been removed from the selection") assert multiselect_widget.selection == [source[0][1], source[1][2][0]] on_select_handler.assert_called_once_with(multiselect_widget) @@ -341,8 +342,7 @@ async def _row_change_test(widget, probe): probe.assert_cell_content((0, 4), 0, "A4") probe.assert_cell_content((0, 5), 0, "AX") - # Insert a new root; - # Row is missing a B accessor + # Insert a new root widget.data.insert(0, {"a": "A!", "b": "B!", "c": "C!"}) await probe.redraw("New root row has been appended") @@ -350,6 +350,14 @@ async def _row_change_test(widget, probe): assert probe.child_count((0,)) == 0 probe.assert_cell_content((0,), 0, "A!") + # Delete a root + del widget.data[1] + await probe.redraw("Old root row has been removed") + + assert probe.child_count() == 1 + assert probe.child_count((0,)) == 0 + probe.assert_cell_content((0,), 0, "A!") + # Clear the table widget.data.clear() await probe.redraw("Data has been cleared") @@ -483,7 +491,7 @@ async def test_cell_icon(widget, probe): async def test_cell_widget(widget, probe): "A widget can be used as a cell value" - widget.data = [ + data = [ ( {"a": "A0", "b": "", "c": ""}, [ @@ -503,13 +511,30 @@ async def test_cell_widget(widget, probe): ], ), ] + if probe.supports_cell_widgets: + warning_check = contextlib.nullcontext() + else: + warning_check = pytest.warns( + match=".* does not support the use of widgets in cells" + ) + + with warning_check: + widget.data = data + await probe.expand_tree() await probe.redraw("Tree has data with widgets") probe.assert_cell_content((0, 0), 0, "A0") probe.assert_cell_content((0, 0), 1, "B0") - probe.assert_cell_content((0, 0), 2, widget=widget.data[0][0].c) probe.assert_cell_content((0, 1), 0, "A1") probe.assert_cell_content((0, 1), 1, "B1") - probe.assert_cell_content((0, 1), 2, widget=widget.data[0][1].c) + + if probe.supports_cell_widgets: + probe.assert_cell_content((0, 0), 2, widget=widget.data[0][0].c) + probe.assert_cell_content((0, 1), 2, widget=widget.data[0][1].c) + else: + # If the platform doesn't support cell widgets, the test should still *run* - + # we just won't have widgets in the cells. + probe.assert_cell_content((0, 0), 2, "MISSING!") + probe.assert_cell_content((0, 1), 2, "MISSING!") From a67ae06d8fd261e2e8467fc1edf39c74530d7f05 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 30 Jun 2023 10:58:02 +0800 Subject: [PATCH 16/30] Simplify the GTK implementation by tracking insertion iterators. --- core/src/toga/sources/tree_source.py | 8 ------ core/tests/sources/test_tree_source.py | 2 -- .../api/resources/sources/tree_source.rst | 11 ++++---- examples/tree_source/tree_source/app.py | 4 +-- gtk/src/toga_gtk/widgets/tree.py | 27 ++++--------------- 5 files changed, 13 insertions(+), 39 deletions(-) diff --git a/core/src/toga/sources/tree_source.py b/core/src/toga/sources/tree_source.py index c1320cdef8..086cfb5b57 100644 --- a/core/src/toga/sources/tree_source.py +++ b/core/src/toga/sources/tree_source.py @@ -220,14 +220,6 @@ def __delitem__(self, index: int): node._source = None self.notify("remove", parent=None, index=index, item=node) - def can_have_children(self) -> bool: - """Can the tree have children. - - This method is required for consistency with the Node interface; always returns - True. - """ - return True - ###################################################################### # Factory methods for new nodes ###################################################################### diff --git a/core/tests/sources/test_tree_source.py b/core/tests/sources/test_tree_source.py index 536c58210f..9b05ee9b73 100644 --- a/core/tests/sources/test_tree_source.py +++ b/core/tests/sources/test_tree_source.py @@ -106,7 +106,6 @@ def test_create_empty(data): source = TreeSource(data=data, accessors=["val1", "val2"]) assert len(source) == 0 - assert source.can_have_children() @pytest.mark.parametrize( @@ -276,7 +275,6 @@ def test_create(data, all_accessor_levels): # Source has 2 roots assert len(source) == 2 - assert source.can_have_children() # Root0 has 2 children assert source[0].val1 == "root0" diff --git a/docs/reference/api/resources/sources/tree_source.rst b/docs/reference/api/resources/sources/tree_source.rst index 1d37d53492..83bac8d219 100644 --- a/docs/reference/api/resources/sources/tree_source.rst +++ b/docs/reference/api/resources/sources/tree_source.rst @@ -115,14 +115,15 @@ Any object that adheres to the TreeSource interface can be used as a data source TreeSource, plus every node managed by the TreeSource, must provide the following methods: -* ``__len__(self)`` - returns the number of children of this node, or the number of root +* ``__len__()`` - returns the number of children of this node, or the number of root nodes for the TreeSource. -* ``__getitem__(self, index)`` - returns the child at position ``index`` of a node, or - the root node at position ``index`` of the TreeSource. +* ``__getitem__(index)`` - returns the child at position ``index`` of a node, or the + root node at position ``index`` of the TreeSource. -* ``can_have_children(self)`` - returns ``False`` if the node is a leaf node. TreeSource - should always return ``True``. +Every node on the TreeSource must also provide: + +* ``can_have_children()`` - returns ``False`` if the node is a leaf node. A custom TreeSource must also generate ``insert``, ``remove`` and ``clear`` notifications when items are added or removed from the source, or when children are diff --git a/examples/tree_source/tree_source/app.py b/examples/tree_source/tree_source/app.py index 8e06a40e2c..32728b644f 100644 --- a/examples/tree_source/tree_source/app.py +++ b/examples/tree_source/tree_source/app.py @@ -127,7 +127,7 @@ def selection_handler(self, widget): else: self.label.text = f"You selected {files} items" - def double_click_handler(self, widget, node): + def activate_handler(self, widget, node): # open the file or folder in the platform's default app self.label.text = f"You started {node.path}" if platform.system() == "Darwin": @@ -149,7 +149,7 @@ def startup(self): style=Pack(flex=1), multiple_select=True, on_select=self.selection_handler, - on_double_click=self.double_click_handler, + on_activate=self.activate_handler, ) self.label = toga.Label( "A view of the current directory!", style=Pack(padding=10) diff --git a/gtk/src/toga_gtk/widgets/tree.py b/gtk/src/toga_gtk/widgets/tree.py index cad2de3217..64657e82ac 100644 --- a/gtk/src/toga_gtk/widgets/tree.py +++ b/gtk/src/toga_gtk/widgets/tree.py @@ -80,18 +80,6 @@ def change_source(self, source): self.native_tree.set_model(self.store) - def path_for_node(self, node): - root = node._parent - row_path = [] - while root is not None: - row_path.append(root.index(node)) - node = root - root = root._parent - row_path.append(self.interface.data.index(node)) - row_path.reverse() - - return row_path - def insert(self, parent, index, item): row = TogaRow(item) values = [row] @@ -106,27 +94,22 @@ def insert(self, parent, index, item): if parent is None: iter = None else: - path = self.path_for_node(parent) - iter = self.store.get_iter(Gtk.TreePath(path)) + iter = parent._impl - self.store.insert(iter, index, values) + item._impl = self.store.insert(iter, index, values) for i, child in enumerate(item): self.insert(item, i, child) def change(self, item): - row = self.store[self.path_for_node(item)] + row = self.store[item._impl] for i, accessor in enumerate(self.interface.accessors): row[i * 2 + 1] = row[0].icon(accessor) row[i * 2 + 2] = row[0].text(accessor, self.interface.missing_value) def remove(self, item, index, parent): - if parent is None: - path = [index] - else: - path = self.path_for_node(parent) + [index] - - del self.store[path] + del self.store[item._impl] + item._impl = None def clear(self): self.store.clear() From 7bf4a16456e128b8ff7487d32a6f9f4f418ea599 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 30 Jun 2023 11:40:17 +0800 Subject: [PATCH 17/30] Removed some GTK platform tests. --- gtk/tests/widgets/test_tree.py | 300 --------------------------------- gtk/tests/widgets/utils.py | 34 ---- 2 files changed, 334 deletions(-) delete mode 100644 gtk/tests/widgets/test_tree.py delete mode 100644 gtk/tests/widgets/utils.py diff --git a/gtk/tests/widgets/test_tree.py b/gtk/tests/widgets/test_tree.py deleted file mode 100644 index 61889b0ef1..0000000000 --- a/gtk/tests/widgets/test_tree.py +++ /dev/null @@ -1,300 +0,0 @@ -import unittest - -try: - import gi - - gi.require_version("Gtk", "3.0") - from gi.repository import Gtk -except ImportError: - import sys - - # If we're on Linux, Gtk *should* be available. If it isn't, make - # Gtk an object... but in such a way that every test will fail, - # because the object isn't actually the Gtk interface. - if sys.platform == "linux": - Gtk = object() - else: - Gtk = None - -import toga - -from .utils import TreeModelListener - - -def handle_events(): - while Gtk.events_pending(): - Gtk.main_iteration_do(blocking=False) - - -@unittest.skipIf( - Gtk is None, "Can't run GTK implementation tests on a non-Linux platform" -) -class TestGtkTree(unittest.TestCase): - def setUp(self): - self.tree = toga.Tree(headings=("one", "two")) - - # make a shortcut for easy use - self.gtk_tree = self.tree._impl - - self.window = Gtk.Window() - self.window.add(self.tree._impl.native) - - def assertNodeEqual(self, node, data): - self.assertEqual(tuple(node)[1:], data) - - def test_change_source(self): - # Clear the tree directly - self.gtk_tree.clear() - - # Assign pre-constructed data - self.tree.data = {("A1", "A2"): [], ("B1", "B2"): [("B1.1", "B2.1")]} - - # Make sure the data was stored correctly - store = self.gtk_tree.store - self.assertNodeEqual(store[0], ("A1", "A2")) - self.assertNodeEqual(store[1], ("B1", "B2")) - self.assertNodeEqual(store[(1, 0)], ("B1.1", "B2.1")) - - # Clear the table with empty assignment - self.tree.data = [] - - # Make sure the table is empty - self.assertEqual(len(store), 0) - - # Repeat with a few different cases - self.tree.data = None - self.assertEqual(len(store), 0) - - self.tree.data = () - self.assertEqual(len(store), 0) - - def test_insert_root_node(self): - listener = TreeModelListener(self.gtk_tree.store) - - # Insert a node - node_data = ("1", "2") - node = self.tree.data.insert(None, 0, node_data) - - # Make sure it's in there - self.assertIsNotNone(listener.inserted_it) - - # Get the Gtk.TreeIter - tree_iter = listener.inserted_it - - # Make sure it's a Gtk.TreeIter - self.assertTrue(isinstance(tree_iter, Gtk.TreeIter)) - - # Make sure it's the correct Gtk.TreeIter - self.assertEqual(node, self.gtk_tree.store.get(tree_iter, 0)[0]) - - # Get the Gtk.TreePath of the Gtk.TreeIter - path = self.gtk_tree.store.get_path(tree_iter) - - # Make sure it's the correct Gtk.TreePath - self.assertTrue(isinstance(path, Gtk.TreePath)) - self.assertEqual(path, Gtk.TreePath(0)) - self.assertEqual(listener.inserted_path, Gtk.TreePath(0)) - # self.assertEqual(str(path), "0") - # self.assertNodeEqual(path), (0,)) - - # Make sure the node got stored correctly - self.assertNodeEqual(self.gtk_tree.store[path], node_data) - - def test_insert_child_node(self): - listener = TreeModelListener(self.gtk_tree.store) - - self.tree.data = [] - - # Insert blank node as parent - parent = self.tree.data.insert(None, 0, (None, None)) - - listener.clear() - - # Insert a child node - node_data = ("1", "2") - node = self.tree.data.insert(parent, 0, node_data) - - # Make sure it's in there - self.assertIsNotNone(listener.inserted_path) - - # Get the Gtk.TreeIter - tree_iter = listener.inserted_it - - # Make sure it's a Gtk.TreeIter - self.assertTrue(isinstance(tree_iter, Gtk.TreeIter)) - - # Make sure it's the correct Gtk.TreeIter - self.assertEqual(node, self.gtk_tree.store.get(tree_iter, 0)[0]) - - # Get the Gtk.TreePath of the Gtk.TreeIter - path = self.gtk_tree.store.get_path(tree_iter) - - # Make sure it's the correct Gtk.TreePath - self.assertTrue(isinstance(path, Gtk.TreePath)) - self.assertEqual(str(path), "0:0") - self.assertEqual(tuple(path), (0, 0)) - self.assertEqual(path, Gtk.TreePath((0, 0))) - self.assertEqual(listener.inserted_path, Gtk.TreePath((0, 0))) - - # Make sure the node got stored correctly - self.assertNodeEqual(self.gtk_tree.store[path], node_data) - - def test_remove(self): - listener = TreeModelListener(self.gtk_tree.store) - - # Insert a node - node = self.tree.data.insert(None, 0, ("1", "2")) - - # Make sure it's in there - self.assertIsNotNone(listener.inserted_it) - - # Then remove it - self.gtk_tree.remove(node, index=0, parent=None) - - # Make sure its gone - self.assertIsNone(self.gtk_tree.store.do_get_value(listener.inserted_it, 0)) - - def test_change(self): - listener = TreeModelListener(self.gtk_tree.store) - - # Insert a node - node = self.tree.data.insert(None, 0, ("1", "2")) - - # Make sure it's in there - self.assertIsNotNone(listener.inserted_path) - self.assertEqual([0], listener.inserted_path.get_indices()) - - # Change a column - node.one = "something_changed" - - self.assertIsNotNone(listener.changed_path) - self.assertIsNotNone(listener.changed_it) - - # Get the Gtk.TreeIter - tree_iter = listener.changed_it - - # Make sure it's a Gtk.TreeIter - self.assertTrue(isinstance(tree_iter, Gtk.TreeIter)) - - # Make sure it's the correct Gtk.TreeIter - self.assertEqual(node, self.gtk_tree.store.get(tree_iter, 0)[0]) - - # Make sure the value changed - path = self.gtk_tree.store.get_path(tree_iter) - self.assertNodeEqual(self.gtk_tree.store[path], (node.one, node.two)) - - def test_node_persistence_for_replacement(self): - self.tree.data = [] - self.tree.data.insert(None, 0, dict(one="A1", two="A2")) - self.tree.data.insert(None, 0, dict(one="B1", two="B2")) - - # B should now precede A - # test passes if A "knows" it has moved to index 1 - - self.assertNodeEqual(self.gtk_tree.store[0], ("B1", "B2")) - self.assertNodeEqual(self.gtk_tree.store[1], ("A1", "A2")) - - def test_node_persistence_for_deletion(self): - self.tree.data = [] - a = self.tree.data.append(None, dict(one="A1", two="A2")) - self.tree.data.append(None, dict(one="B1", two="B2")) - - self.tree.data.remove(a) - - # test passes if B "knows" it has moved to index 0 - self.assertNodeEqual(self.gtk_tree.store[0], ("B1", "B2")) - - def test_on_select_root_node(self): - listener = TreeModelListener(self.gtk_tree.store) - - # Insert dummy nodes - self.tree.data = [] - self.tree.data.append(None, dict(one="A1", two="A2")) - listener.clear() - b = self.tree.data.append(None, dict(one="B1", two="B2")) - - # Create a flag - succeed = False - - def on_select(tree, node): - # Make sure the right node was selected - self.assertEqual(node, b) - - nonlocal succeed - succeed = True - - self.tree.on_select = on_select - - # Select node B - self.gtk_tree.selection.select_iter(listener.inserted_it) - - # Allow on_select to call - handle_events() - - self.assertTrue(succeed) - - def test_on_select_child_node(self): - listener = TreeModelListener(self.gtk_tree.store) - - # Insert two nodes - self.tree.data = [] - a = self.tree.data.append(None, dict(one="A1", two="A2")) - a_iter = listener.inserted_it - listener.clear() - b = self.tree.data.append(a, dict(one="B1", two="B2")) - - # Create a flag - succeed = False - - def on_select(tree, node): - # Make sure the right node was selected - self.assertEqual(node, b) - - nonlocal succeed - succeed = True - - self.tree.on_select = on_select - - # Expand parent node (a) on Gtk.TreeView to allow selection - path = self.gtk_tree.store.get_path(a_iter) - self.gtk_tree.treeview.expand_row(path, True) - - # Select node B - self.gtk_tree.selection.select_iter(listener.inserted_it) - # Allow on_select to call - handle_events() - - self.assertTrue(succeed) - - def test_on_select_deleted_node(self): - listener = TreeModelListener(self.gtk_tree.store) - - # Insert two nodes - self.tree.data = [] - self.tree.data.append(None, dict(one="A1", two="A2")) - b = self.tree.data.append(None, dict(one="B1", two="B2")) - - # Create a flag - succeed = False - - def on_select(tree, node): - nonlocal succeed - if node is not None: - # Make sure the right node was selected - self.assertEqual(node, b) - - # Remove node B. This should trigger on_select again - tree.data.remove(node) - else: - self.assertEqual(node, None) - succeed = True - - self.tree.on_select = on_select - - # Select node B - self.gtk_tree.selection.select_iter(listener.inserted_it) - - # Allow on_select to call - handle_events() - - self.assertTrue(succeed) diff --git a/gtk/tests/widgets/utils.py b/gtk/tests/widgets/utils.py deleted file mode 100644 index 6e6ba07122..0000000000 --- a/gtk/tests/widgets/utils.py +++ /dev/null @@ -1,34 +0,0 @@ -class TreeModelListener: - """useful to access paths and iterators from signals.""" - - def __init__(self, store=None): - self.changed_path = None - self.changed_it = None - self.inserted_path = None - self.inserted_it = None - self.deleted_path = None - if store is not None: - self.connect(store) - - def on_change(self, model, path, it): - self.changed_path = path - self.changed_it = it - - def on_inserted(self, model, path, it): - self.inserted_path = path - self.inserted_it = it - - def on_deleted(self, model, path): - self.deleted_path = path - - def connect(self, store): - store.connect("row-changed", self.on_change) - store.connect("row-inserted", self.on_inserted) - store.connect("row-deleted", self.on_deleted) - - def clear(self): - self.changed_path = None - self.changed_it = None - self.inserted_path = None - self.inserted_it = None - self.deleted_path = None From 740b5194e869ba338e6ed2e10091ecf4f44ae072 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 30 Jun 2023 11:41:10 +0800 Subject: [PATCH 18/30] Ensure Windows Tree tests are completely skipped. --- testbed/tests/widgets/test_tree.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testbed/tests/widgets/test_tree.py b/testbed/tests/widgets/test_tree.py index 61840c07f9..2a315a3b62 100644 --- a/testbed/tests/widgets/test_tree.py +++ b/testbed/tests/widgets/test_tree.py @@ -133,7 +133,7 @@ async def headerless_probe(main_window, headerless_widget): @pytest.fixture def multiselect_widget(source, on_select_handler): # Although Android *has* a table implementation, it needs to be rebuilt. - skip_on_platforms("iOS", "android") + skip_on_platforms("iOS", "android", "windows") return toga.Tree( ["A", "B", "C"], data=source, From 9d838ce5145e275cf8257a5c07c33ccef6e562cc Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 30 Jun 2023 11:57:53 +0800 Subject: [PATCH 19/30] Remove vestigial SourceTreeModel. --- .../widgets/internal/sourcetreemodel.py | 342 ------------------ 1 file changed, 342 deletions(-) delete mode 100644 gtk/src/toga_gtk/widgets/internal/sourcetreemodel.py diff --git a/gtk/src/toga_gtk/widgets/internal/sourcetreemodel.py b/gtk/src/toga_gtk/widgets/internal/sourcetreemodel.py deleted file mode 100644 index 95612cd9a3..0000000000 --- a/gtk/src/toga_gtk/widgets/internal/sourcetreemodel.py +++ /dev/null @@ -1,342 +0,0 @@ -import copy - -from toga_gtk.libs import GObject, Gtk - - -class SourceTreeModel(GObject.Object, Gtk.TreeModel): - """A full Gtk.TreeModel implementation backed by a toga.source.ListSource or - toga.source.TreeSource. - - It stores a reference to every node in the source. - TODO: If the source is a TreeSource, it uses the Node._parent attribute. - Maybe an method could be added (like index()) to the TreeSource to access it. - """ - - def __init__(self, columns, is_tree, missing_value): - """ - Args: - columns (list(dict(str, any))): the columns excluding first column which is always the row object. - each ``dict`` must have: - - an ``attr`` entry, with a string value naming the attribute to get from the row - - a ``type`` entry, with the column type (``str``, ``Gtk.Pixbuf``, ...) - is_tree (bool): the model must know if it's for a tree or a list to set flags - """ - super().__init__() - self.source = None - self.columns = columns - self.is_tree = is_tree - self.missing_value = missing_value - # by storing the row and calling index later, we can opt-in for this performance - # boost and don't have to track iterators (we would have to if we stored indices). - self.flags = Gtk.TreeModelFlags.ITERS_PERSIST - if not is_tree: - self.flags |= Gtk.TreeModelFlags.LIST_ONLY - # stamp will be increased each time the data source changes. -1 is always invalid - self.stamp = 0 - # the pool maps integer (the only thing we can store in Gtk.TreeIter) to row object. - # It's purged on data source change and on remove - self.pool = {} - # roots is an array of root elements in the data source. - # they are kept here to support the clear() notification without parameters - self.roots = ( - [] - ) # maybe a deque would be more efficient. This can be changed later - self.index_in_parent = {} - - def clear(self): - """Called from toga impl widget.""" - if self.is_tree: - self._remove_children_rec([], self.roots) - else: - for i, node in reversed(list(enumerate(self.roots))): - self.row_deleted(Gtk.TreePath.new_from_indices([i])) - self._clear_user_data(node) - - def change_source(self, source): - """Called from toga impl widget.""" - if self.source: - self.clear() - self.source = source - self.stamp += 1 - - def insert(self, row): - """Called from toga impl widget.""" - it = self._create_iter(user_data=row) - index = self.source.index(row) - if not self.is_tree or self.is_root(row): - self.roots.insert(index, row) - parent = self.source - else: - parent = row._parent - self._update_index_in_parent(parent, index) - parent_indices = self._get_indices(parent) if parent is not self.source else [] - if self.is_tree and not self.is_root(row) and (len(row._parent) == 1): - parent_it = self._create_iter(user_data=row._parent) - parent_p = Gtk.TreePath.new_from_indices(parent_indices) - self.row_has_child_toggled(parent_p, parent_it) - p = Gtk.TreePath.new_from_indices(parent_indices + [index]) - self.row_inserted(p, it) - - def change(self, row): - """Called from toga impl widget.""" - indices = self._get_indices(row) - self.row_changed( - Gtk.TreePath.new_from_indices(indices), self._create_iter(user_data=row) - ) - - def remove(self, row, index, parent=None): - """Called from toga impl widget.""" - # todo: could get index from index_in_parent - if parent is None: - indices = [] - del self.roots[index] - parent = self.source - else: - indices = self._get_indices(parent) - indices.append(index) - if self.is_tree and row.can_have_children(): - self._remove_children_rec(indices, row) - self.row_deleted(Gtk.TreePath.new_from_indices(indices)) - self._clear_user_data(row) - self._update_index_in_parent(parent, index) - if self.is_tree and parent is not None and (len(parent) == 0): - parent_it = self._create_iter(user_data=parent) - parent_indices = copy.copy(indices[:-1]) - parent_p = Gtk.TreePath.new_from_indices(parent_indices) - self.row_has_child_toggled(parent_p, parent_it) - - def _remove_children_rec(self, indices, parent): - for i, node in reversed(list(enumerate(parent))): - indices.append(i) - if node.can_have_children(): - self._remove_children_rec(indices, node) - self.row_deleted(Gtk.TreePath.new_from_indices(indices)) - self._clear_user_data(node) - del indices[-1] - - def path_to_node(self, row): - """Called from toga impl widget.""" - indices = self._get_indices(row) - if indices is not None: - return Gtk.TreePath.new_from_indices(indices) - return Gtk.TreePath() - - def do_get_column_type(self, index_): - """Gtk.TreeModel.""" - if index_ == 0: - return object - return self.columns[index_ - 1]["type"] - - def do_get_flags(self): - """Gtk.TreeModel.""" - return self.flags - - def do_get_iter(self, path): - """Gtk.TreeModel.""" - indices = path.get_indices() - r = self._get_row(indices) - if r is None: - return False, Gtk.TreeIter(stamp=-1) - return True, self._create_iter(user_data=r) - - def do_get_n_columns(self): - """Gtk.TreeModel.""" - return len(self.columns) + 1 - - def do_get_path(self, iter_): - """Gtk.TreeModel.""" - if iter_ is None or iter_.stamp != self.stamp: - return Gtk.TreePath() - r = self._get_user_data(iter_) - indices = self._get_indices(r) - if indices is None: - return Gtk.TreePath() - return Gtk.TreePath.new_from_indices(indices) - - def do_get_value(self, iter_, column): - """Gtk.TreeModel.""" - if iter_ is None or iter_.stamp != self.stamp: - return None - row = self._get_user_data(iter_) - if column == 0: - return row - if row is None: - return None - - # workaround icon+name tuple breaking gtk tree - ret = getattr(row, self.columns[column - 1]["attr"], self.missing_value) - if isinstance(ret, tuple): - ret = ret[1] - return ret - - def do_iter_children(self, parent): - """Gtk.TreeModel.""" - if parent is None: - r = self.source - else: - r = self._get_user_data(parent) - if self._row_has_child(r, 0): - return True, self._create_iter(user_data=r[0]) - return False, Gtk.TreeIter(stamp=-1) - - def do_iter_has_child(self, iter_): - """Gtk.TreeModel.""" - if iter_ is None: - return len(self.source) > 0 - if iter_.stamp == self.stamp: - r = self._get_user_data(iter_) - ret = self._row_has_child(r, 0) - return ret - return False - - def do_iter_n_children(self, iter_): - """Gtk.TreeModel.""" - if iter_ is None: - r = self.source - elif iter_.stamp == self.stamp: - r = self._get_user_data(iter_) - else: - r = None - if self._row_has_child(r, 0): - return len(r) - return 0 - - def do_iter_next(self, iter_): - """Gtk.TreeModel.""" - if iter_ is not None and iter_.stamp == self.stamp: - r = self._get_user_data(iter_) - if r is not None: - if self.is_tree: - parent = r._parent or self.source - else: - parent = self.source - if len(parent) and r is not parent[-1]: - try: - index = self.index_in_parent[r] - self._set_user_data(iter_, parent[index + 1]) - return True - except ValueError: - pass - if iter_ is not None: - iter_.stamp = -1 # invalidate - return False - - def do_iter_previous(self, iter_): - """Gtk.TreeModel.""" - if iter_ is not None and iter_.stamp == self.stamp: - r = self._get_user_data(iter_) - if r is not None: - if self.is_tree: - parent = r._parent or self.source - else: - parent = self.source - if len(parent) and r is not parent[0]: - try: - index = self.index_in_parent[r] - self._set_user_data(iter_, parent[index - 1]) - return True - except ValueError: - pass - if iter_ is not None: - iter_.stamp = -1 - return False - - def do_iter_nth_child(self, parent, n): - """Gtk.TreeModel.""" - if parent is None: - r = self.source - elif parent.stamp != self.stamp: - return False, Gtk.TreeIter(stamp=-1) - else: - r = self._get_user_data(parent) - if self._row_has_child(r, n): - return True, self._create_iter(user_data=r[n]) - return False, Gtk.TreeIter(stamp=-1) - - def do_iter_parent(self, child): - """Gtk.TreeModel.""" - if not self.is_tree or child is None or (child.stamp != self.stamp): - return False, Gtk.TreeIter(stamp=-1) - r = self._get_user_data(child) - if r is None or r is self.source: - return False, Gtk.TreeIter(stamp=-1) - parent = r._parent or self.source - if parent is self.source: - return False, Gtk.TreeIter(stamp=-1) - return True, self._create_iter(user_data=parent) - - def do_ref_node(self, iter_): - """Gtk.TreeModel.""" - pass - - def do_unref_node(self, iter_): - """Gtk.TreeModel.""" - pass - - def _get_row(self, indices): - if self.source is None: - return None - s = self.source - if self.is_tree: - for i in indices: - if s.can_have_children(): - if i < len(s): - s = s[i] - else: - return None - else: - return None - return s - else: - if len(indices) == 1: - i = indices[0] - if i < len(s): - return s[i] - return None - - def _get_indices(self, row): - if row is None or self.source is None: - return None - if self.is_tree: - indices = [] - while row not in (None, self.source): - indices.insert(0, self.index_in_parent[row]) - row = row._parent - return indices - else: - return [self.source.index(row)] - - def _row_has_child(self, row, n): - return ( - row is not None - and ((self.is_tree and row.can_have_children()) or (row is self.source)) - and len(row) > n - ) - - def _set_user_data(self, it, user_data): - data_id = id(user_data) - it.user_data = data_id - self.pool[data_id] = user_data - - def _get_user_data(self, it): - return self.pool.get(it.user_data) - - def _clear_user_data(self, user_data): - data_id = id(user_data) - if data_id in self.pool: - del self.pool[data_id] - if user_data in self.index_in_parent: - del self.index_in_parent[user_data] - - def _create_iter(self, user_data): - it = Gtk.TreeIter() - it.stamp = self.stamp - self._set_user_data(it, user_data) - return it - - def _update_index_in_parent(self, parent, index): - for i in range(index, len(parent)): - self.index_in_parent[parent[i]] = i - - def is_root(self, node): - return node._parent in (None, self.source) From 21617c66f08db0b204a9f11de9c52402e30bfa8f Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 30 Jun 2023 12:21:37 +0800 Subject: [PATCH 20/30] Mark the dummy tree interface as not required. --- dummy/src/toga_dummy/widgets/tree.py | 3 +++ gtk/src/toga_gtk/widgets/table.py | 2 +- gtk/src/toga_gtk/widgets/tree.py | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/dummy/src/toga_dummy/widgets/tree.py b/dummy/src/toga_dummy/widgets/tree.py index 3760442847..5c7359246a 100644 --- a/dummy/src/toga_dummy/widgets/tree.py +++ b/dummy/src/toga_dummy/widgets/tree.py @@ -1,6 +1,8 @@ +from ..utils import not_required from .base import Widget +@not_required def node_for_path(data, path): "Convert a path tuple into a specific node" if path is None: @@ -11,6 +13,7 @@ def node_for_path(data, path): return result +@not_required # Testbed coverage is complete for this widget. class Tree(Widget): def create(self): self._action("create Tree") diff --git a/gtk/src/toga_gtk/widgets/table.py b/gtk/src/toga_gtk/widgets/table.py index 919ab9b69a..3ae7dc1847 100644 --- a/gtk/src/toga_gtk/widgets/table.py +++ b/gtk/src/toga_gtk/widgets/table.py @@ -156,7 +156,7 @@ def scroll_to_row(self, row): pos = row / n_rows * self.native.get_vadjustment().get_upper() self.native.get_vadjustment().set_value(pos) - def insert_column(self, position, heading, accessor): + def insert_column(self, index, heading, accessor): # Adding/removing a column means completely rebuilding the ListStore self.change_source(self.interface.data) diff --git a/gtk/src/toga_gtk/widgets/tree.py b/gtk/src/toga_gtk/widgets/tree.py index 64657e82ac..d70555220c 100644 --- a/gtk/src/toga_gtk/widgets/tree.py +++ b/gtk/src/toga_gtk/widgets/tree.py @@ -124,7 +124,7 @@ def get_selection(self): return None return store[iter][0].value - def insert_column(self, position, heading, accessor): + def insert_column(self, index, heading, accessor): # Adding/removing a column means completely rebuilding the ListStore self.change_source(self.interface.data) From 71d1e52a3c03fa3c14768d78b3883e9fc194509c Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 30 Jun 2023 12:22:25 +0800 Subject: [PATCH 21/30] Fixes for DetailedList caused by changes to ListSource and Icon. --- examples/beeliza/beeliza/app.py | 16 ++++++++++------ examples/detailedlist/detailedlist/app.py | 4 ++-- .../toga_gtk/widgets/internal/rows/texticon.py | 4 +++- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/examples/beeliza/beeliza/app.py b/examples/beeliza/beeliza/app.py index fce026a640..d3bf430ff4 100644 --- a/examples/beeliza/beeliza/app.py +++ b/examples/beeliza/beeliza/app.py @@ -15,9 +15,11 @@ async def handle_input(self, widget, **kwargs): self.chat.data.append( # User's avatar is from http://avatars.adorable.io # using user@beeware.org - icon=toga.Icon("resources/user.png"), - title="You", - subtitle=input_text, + dict( + icon=toga.Icon("resources/user.png"), + title="You", + subtitle=input_text, + ) ) # Clear the current input, ready for more input. self.text_input.value = "" @@ -31,9 +33,11 @@ async def handle_input(self, widget, **kwargs): response = self.partner.respond(input_text) # Display the response self.chat.data.append( - icon=toga.Icon("resources/brutus.png"), - title="Brutus", - subtitle=response, + dict( + icon=toga.Icon("resources/brutus.png"), + title="Brutus", + subtitle=response, + ) ) # Scroll so the most recent entry is visible. diff --git a/examples/detailedlist/detailedlist/app.py b/examples/detailedlist/detailedlist/app.py index 60dd6fe131..83cbc6f28b 100644 --- a/examples/detailedlist/detailedlist/app.py +++ b/examples/detailedlist/detailedlist/app.py @@ -32,10 +32,10 @@ def insert_handler(self, widget, **kwargs): item = {"icon": None, "subtitle": "The Hive", "title": "Bzzz!"} if self.dl.selection: index = self.dl.data.index(self.dl.selection) + 1 - self.dl.data.insert(index, **item) + self.dl.data.insert(index, item) else: index = len(self.dl.data) - self.dl.data.append(**item) + self.dl.data.append(item) self.dl.scroll_to_row(index) def remove_handler(self, widget, **kwargs): diff --git a/gtk/src/toga_gtk/widgets/internal/rows/texticon.py b/gtk/src/toga_gtk/widgets/internal/rows/texticon.py index 6e3bfc2a9d..e0db3a9a65 100644 --- a/gtk/src/toga_gtk/widgets/internal/rows/texticon.py +++ b/gtk/src/toga_gtk/widgets/internal/rows/texticon.py @@ -76,7 +76,9 @@ def get_icon( return None else: dpr = self.get_scale_factor() - return getattr(row.icon._impl, "native_" + str(32 * dpr)) + return Gtk.Image.new_from_pixbuf( + getattr(row.icon._impl, "native_" + str(32 * dpr)) + ) @staticmethod def markup(row): From d3802f3308c5d6e3ae1efc6a589ce29341b33a55 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 30 Jun 2023 12:58:02 +0800 Subject: [PATCH 22/30] Add font checks to table and tree. --- cocoa/tests_backend/widgets/table.py | 5 +++++ cocoa/tests_backend/widgets/tree.py | 5 +++++ docs/reference/api/widgets/table.rst | 2 ++ docs/reference/api/widgets/tree.rst | 10 ++++++++++ testbed/tests/widgets/test_table.py | 7 +++++++ testbed/tests/widgets/test_tree.py | 7 +++++++ 6 files changed, 36 insertions(+) diff --git a/cocoa/tests_backend/widgets/table.py b/cocoa/tests_backend/widgets/table.py index 7ebdd3eb0d..3a10d55964 100644 --- a/cocoa/tests_backend/widgets/table.py +++ b/cocoa/tests_backend/widgets/table.py @@ -1,3 +1,4 @@ +from pytest import skip from rubicon.objc import NSPoint from toga_cocoa.libs import NSEventType, NSScrollView, NSTableView @@ -18,6 +19,10 @@ def __init__(self, widget): self.native_table = widget._impl.native_table assert isinstance(self.native_table, NSTableView) + @property + def font(self): + skip("Font changes not implemented for Tree on macOS") + @property def background_color(self): if self.native.drawsBackground: diff --git a/cocoa/tests_backend/widgets/tree.py b/cocoa/tests_backend/widgets/tree.py index bdeca2cff9..578814d73e 100644 --- a/cocoa/tests_backend/widgets/tree.py +++ b/cocoa/tests_backend/widgets/tree.py @@ -1,5 +1,6 @@ import asyncio +from pytest import skip from rubicon.objc import NSPoint from toga_cocoa.libs import NSEventType, NSOutlineView, NSScrollView @@ -20,6 +21,10 @@ def __init__(self, widget): self.native_tree = widget._impl.native_tree assert isinstance(self.native_tree, NSOutlineView) + @property + def font(self): + skip("Font changes not implemented for Tree on macOS") + @property def background_color(self): if self.native.drawsBackground: diff --git a/docs/reference/api/widgets/table.rst b/docs/reference/api/widgets/table.rst index 9243916057..5d3016d61c 100644 --- a/docs/reference/api/widgets/table.rst +++ b/docs/reference/api/widgets/table.rst @@ -130,6 +130,8 @@ Notes * The use of Widgets as table values is currently a beta API. It is currently only supported on macOS; the API is subject to change. +* On macOS, you cannot change the font used in a Table. + Reference --------- diff --git a/docs/reference/api/widgets/tree.rst b/docs/reference/api/widgets/tree.rst index aac40e6e9a..9248dd4ea6 100644 --- a/docs/reference/api/widgets/tree.rst +++ b/docs/reference/api/widgets/tree.rst @@ -148,6 +148,16 @@ if the value is :any:`None`, as appropriate). If the value provided by an accessor is a :class:`toga.Widget`, that widget will be displayed in the tree. Note that this is currently a beta API, and may change in future. + +Notes +----- + +* The use of Widgets as tree values is currently a beta API. It is currently only + supported on macOS; the API is subject to change. + +* On macOS, you cannot change the font used in a Tree. + + Reference --------- diff --git a/testbed/tests/widgets/test_table.py b/testbed/tests/widgets/test_table.py index a26aec914e..efbc73c94e 100644 --- a/testbed/tests/widgets/test_table.py +++ b/testbed/tests/widgets/test_table.py @@ -15,9 +15,16 @@ test_enable_noop, test_flex_widget_size, test_focus_noop, + test_font, ) +@pytest.fixture +def verify_font_sizes(): + # We can't verify font sizes inside the Table + return False, False + + @pytest.fixture def on_select_handler(): return Mock() diff --git a/testbed/tests/widgets/test_tree.py b/testbed/tests/widgets/test_tree.py index 2a315a3b62..aa68ad7c8d 100644 --- a/testbed/tests/widgets/test_tree.py +++ b/testbed/tests/widgets/test_tree.py @@ -15,9 +15,16 @@ test_enable_noop, test_flex_widget_size, test_focus_noop, + test_font, ) +@pytest.fixture +def verify_font_sizes(): + # We can't verify font sizes inside the Tree + return False, False + + @pytest.fixture def on_select_handler(): return Mock() From e7fb3804fcc20cee1714548d9ee040b16c11cd27 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 6 Jul 2023 11:46:32 +0800 Subject: [PATCH 23/30] Modify the tree source demo to do lazy loading of children. --- examples/tree_source/tree_source/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/tree_source/tree_source/app.py b/examples/tree_source/tree_source/app.py index 32728b644f..9a0c7f8769 100644 --- a/examples/tree_source/tree_source/app.py +++ b/examples/tree_source/tree_source/app.py @@ -70,7 +70,7 @@ def __getitem__(self, index): def can_have_children(self): # this will trigger loading of children, if not yet done - return len(self.children) > 0 + return not self.path.is_file() # Property that returns the first column value as (icon, label) @property From 1cd91bd6915bf474b75d251f33ec6bcdc17342de Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sat, 26 Aug 2023 13:14:49 +0800 Subject: [PATCH 24/30] Add API to expand/contract nodes on the tree. --- cocoa/src/toga_cocoa/widgets/tree.py | 12 +++ cocoa/tests_backend/widgets/tree.py | 8 ++ core/src/toga/widgets/tree.py | 33 ++++++ core/tests/widgets/test_tree.py | 30 +++++- dummy/src/toga_dummy/widgets/tree.py | 12 +++ testbed/tests/widgets/test_tree.py | 153 +++++++++++++++++++++++++++ 6 files changed, 247 insertions(+), 1 deletion(-) diff --git a/cocoa/src/toga_cocoa/widgets/tree.py b/cocoa/src/toga_cocoa/widgets/tree.py index fc494bbbf1..d60bbc4d77 100644 --- a/cocoa/src/toga_cocoa/widgets/tree.py +++ b/cocoa/src/toga_cocoa/widgets/tree.py @@ -277,6 +277,18 @@ def get_selection(self): else: return None + def expand_node(self, node): + self.native_tree.expandItem(node._impl, expandChildren=False) + + def expand_all(self): + self.native_tree.expandItem(None, expandChildren=True) + + def collapse_node(self, node): + self.native_tree.collapseItem(node._impl, collapseChildren=False) + + def collapse_all(self): + self.native_tree.collapseItem(None, collapseChildren=True) + def _insert_column(self, index, heading, accessor): column = NSTableColumn.alloc().initWithIdentifier(accessor) column.minWidth = 16 diff --git a/cocoa/tests_backend/widgets/tree.py b/cocoa/tests_backend/widgets/tree.py index 578814d73e..eb1bcbf587 100644 --- a/cocoa/tests_backend/widgets/tree.py +++ b/cocoa/tests_backend/widgets/tree.py @@ -36,6 +36,14 @@ async def expand_tree(self): self.native_tree.expandItem(None, expandChildren=True) await asyncio.sleep(0.1) + def is_expanded(self, node): + try: + return self.native_tree.isItemExpanded(node._impl) + except AttributeError: + # If there's no _impl, the node hasn't been visualized yet, + # so it must be collapsed. + return False + def item_for_row_path(self, row_path): item = self.native_tree.outlineView( self.native_tree, diff --git a/core/src/toga/widgets/tree.py b/core/src/toga/widgets/tree.py index a9b274669d..68037b5b25 100644 --- a/core/src/toga/widgets/tree.py +++ b/core/src/toga/widgets/tree.py @@ -174,6 +174,39 @@ def selection(self) -> list[Node] | Node | None: """ return self._impl.get_selection() + def expand(self, node: Node | None = None): + """Expand the specified node of the tree. + + If no node is provided, all nodes of the tree will be expanded. + + If the provided node is a leaf node, or the node is already expanded, this is a + no-op. + + If a node is specified, the children of that node will not be automatically + expanded. + + :param node: The node to expand + """ + if node is None: + self._impl.expand_all() + else: + self._impl.expand_node(node) + + def collapse(self, node: Node | None = None): + """Expand the specified node of the tree. + + If no node is provided, all nodes of the tree will be expanded. + + If the provided node is a leaf node, or the node is already collapsed, + this is a no-op. + + :param node: The node to collapse + """ + if node is None: + self._impl.collapse_all() + else: + self._impl.collapse_node(node) + def append_column(self, heading: str, accessor: str | None = None): """Append a column to the end of the tree. diff --git a/core/tests/widgets/test_tree.py b/core/tests/widgets/test_tree.py index 392eb33ee5..c938910da6 100644 --- a/core/tests/widgets/test_tree.py +++ b/core/tests/widgets/test_tree.py @@ -302,8 +302,36 @@ def test_multiple_selection(source, on_select_handler): on_select_handler.assert_called_once_with(tree) +def test_expand_collapse(tree): + """The rows on a tree can be expanded and collapsed""" + + # Expand the full tree + tree.expand() + assert_action_performed_with(tree, "expand all") + + # Collapse a single node + tree.collapse(tree.data[1][2]) + assert_action_performed_with(tree, "collapse node", node=tree.data[1][2]) + + # Expand a single node + tree.expand(tree.data[1][2]) + assert_action_performed_with(tree, "expand node", node=tree.data[1][2]) + + # Collapse a leaf node + tree.collapse(tree.data[1][2][1]) + assert_action_performed_with(tree, "collapse node", node=tree.data[1][2][1]) + + # Expand a leaf node + tree.expand(tree.data[1][2][1]) + assert_action_performed_with(tree, "expand node", node=tree.data[1][2][1]) + + # Collapse the full tree + tree.collapse() + assert_action_performed_with(tree, "collapse all") + + def test_activation(tree, on_activate_handler): - "A row can be activated" + """A row can be activated""" # Activate an item tree._impl.simulate_activate((0, 1)) diff --git a/dummy/src/toga_dummy/widgets/tree.py b/dummy/src/toga_dummy/widgets/tree.py index 5c7359246a..e35868a57d 100644 --- a/dummy/src/toga_dummy/widgets/tree.py +++ b/dummy/src/toga_dummy/widgets/tree.py @@ -45,6 +45,18 @@ def get_selection(self): self.interface.data, self._get_value("selection", None) ) + def expand_node(self, node): + self._action("expand node", node=node) + + def expand_all(self): + self._action("expand all") + + def collapse_node(self, node): + self._action("collapse node", node=node) + + def collapse_all(self): + self._action("collapse all") + def insert_column(self, index, heading, accessor): self._action("insert column", index=index, heading=heading, accessor=accessor) diff --git a/testbed/tests/widgets/test_tree.py b/testbed/tests/widgets/test_tree.py index aa68ad7c8d..3235d9dc26 100644 --- a/testbed/tests/widgets/test_tree.py +++ b/testbed/tests/widgets/test_tree.py @@ -206,6 +206,159 @@ async def test_select(widget, probe, source, on_select_handler): assert widget.selection == source[1][2][0] +async def test_expand_collapse(widget, probe, source): + """Nodes can be expanded and collapsed""" + + # Initially unexpanded + assert not probe.is_expanded(source[0]) + assert not probe.is_expanded(source[0][2]) + assert not probe.is_expanded(source[1]) + assert not probe.is_expanded(source[1][2]) + assert not probe.is_expanded(source[2]) + assert not probe.is_expanded(source[2][2]) + + # Fully expand the tree + widget.expand() + await probe.redraw("All nodes have been expanded") + assert probe.is_expanded(source[0]) + assert probe.is_expanded(source[0][2]) + assert probe.is_expanded(source[1]) + assert probe.is_expanded(source[1][2]) + assert probe.is_expanded(source[2]) + assert probe.is_expanded(source[2][2]) + + # Fully expand when already fully expanded + widget.expand() + await probe.redraw("All nodes are still expanded") + assert probe.is_expanded(source[0]) + assert probe.is_expanded(source[0][2]) + assert probe.is_expanded(source[1]) + assert probe.is_expanded(source[1][2]) + assert probe.is_expanded(source[2]) + assert probe.is_expanded(source[2][2]) + + # Collapse a single root node + widget.collapse(source[1]) + await probe.redraw("Root Node 1 has been collapsed") + assert probe.is_expanded(source[0]) + assert probe.is_expanded(source[0][2]) + assert not probe.is_expanded(source[1]) + # State of source[1][2] is ambiguous + assert probe.is_expanded(source[2]) + assert probe.is_expanded(source[2][2]) + + # Collapse the same single root node again + widget.collapse(source[1]) + await probe.redraw("Root Node 1 is still collapsed") + assert probe.is_expanded(source[0]) + assert probe.is_expanded(source[0][2]) + assert not probe.is_expanded(source[1]) + # State of source[1][2] is ambiguous + assert probe.is_expanded(source[2]) + assert probe.is_expanded(source[2][2]) + + # Collapse a single child node + widget.collapse(source[0][2]) + await probe.redraw("Child Node 0:2 has been collapsed") + assert probe.is_expanded(source[0]) + assert not probe.is_expanded(source[0][2]) + assert not probe.is_expanded(source[1]) + # State of source[1][2] is ambiguous + assert probe.is_expanded(source[2]) + assert probe.is_expanded(source[2][2]) + + # Collapse the same child node + widget.collapse(source[0][2]) + await probe.redraw("Child Node 0:2 is still collapsed") + assert probe.is_expanded(source[0]) + assert not probe.is_expanded(source[0][2]) + assert not probe.is_expanded(source[1]) + # State of source[1][2] is ambiguous + assert probe.is_expanded(source[2]) + assert probe.is_expanded(source[2][2]) + + # Expand a single root node + widget.expand(source[1]) + await probe.redraw("Root Node 1 has been expanded") + assert probe.is_expanded(source[0]) + assert not probe.is_expanded(source[0][2]) + assert probe.is_expanded(source[1]) + assert probe.is_expanded(source[1][2]) # Restores previous expansion state + assert probe.is_expanded(source[2]) + assert probe.is_expanded(source[2][2]) + + # Expand a single root node again + widget.expand(source[1]) + await probe.redraw("Root Node 1 is still expanded") + assert probe.is_expanded(source[0]) + assert not probe.is_expanded(source[0][2]) + assert probe.is_expanded(source[1]) + assert probe.is_expanded(source[1][2]) + assert probe.is_expanded(source[2]) + assert probe.is_expanded(source[2][2]) + + # Expand a single child node + widget.expand(source[0][2]) + await probe.redraw("Child Node 0:2 has been expanded") + assert probe.is_expanded(source[0]) + assert probe.is_expanded(source[0][2]) + assert probe.is_expanded(source[1]) + assert probe.is_expanded(source[1][2]) + assert probe.is_expanded(source[2]) + assert probe.is_expanded(source[2][2]) + + # Expand the same child node again + widget.expand(source[0][2]) + await probe.redraw("Child Node 0:2 is still expanded") + assert probe.is_expanded(source[0]) + assert probe.is_expanded(source[0][2]) + assert probe.is_expanded(source[1]) + assert probe.is_expanded(source[1][2]) + assert probe.is_expanded(source[2]) + assert probe.is_expanded(source[2][2]) + + # Attempt to collapse a leaf node + widget.collapse(source[0][2][1]) + print("COLLAPSE", source[0][2][1]) + await probe.redraw("Leaf node collapse is a no-op") + assert probe.is_expanded(source[0]) + assert probe.is_expanded(source[0][2]) + assert probe.is_expanded(source[1]) + assert probe.is_expanded(source[1][2]) + assert probe.is_expanded(source[2]) + assert probe.is_expanded(source[2][2]) + + # Attempt to expand a leaf node + widget.collapse(source[0][2][1]) + await probe.redraw("Leaf node collapse is a no-op") + assert probe.is_expanded(source[0]) + assert probe.is_expanded(source[0][2]) + assert probe.is_expanded(source[1]) + assert probe.is_expanded(source[1][2]) + assert probe.is_expanded(source[2]) + assert probe.is_expanded(source[2][2]) + + # Fully collapse the tree + widget.collapse() + await probe.redraw("All nodes have been collapsed") + assert not probe.is_expanded(source[0]) + assert not probe.is_expanded(source[0][2]) + assert not probe.is_expanded(source[1]) + assert not probe.is_expanded(source[1][2]) + assert not probe.is_expanded(source[2]) + assert not probe.is_expanded(source[2][2]) + + # Fully collapse when already fully collapsed + widget.collapse() + await probe.redraw("All nodes are still collapsed") + assert not probe.is_expanded(source[0]) + assert not probe.is_expanded(source[0][2]) + assert not probe.is_expanded(source[1]) + assert not probe.is_expanded(source[1][2]) + assert not probe.is_expanded(source[2]) + assert not probe.is_expanded(source[2][2]) + + async def test_activate( widget, probe, From 526ad16c68bf2234db5930cc6cd5ab7b3c7bb52c Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sat, 26 Aug 2023 13:34:05 +0800 Subject: [PATCH 25/30] Add GTK implementaiton of tree expansion. --- cocoa/src/toga_cocoa/widgets/tree.py | 4 ++-- core/src/toga/widgets/tree.py | 3 +-- gtk/src/toga_gtk/widgets/tree.py | 14 ++++++++++++++ gtk/tests_backend/widgets/tree.py | 5 +++++ testbed/tests/widgets/test_tree.py | 1 - 5 files changed, 22 insertions(+), 5 deletions(-) diff --git a/cocoa/src/toga_cocoa/widgets/tree.py b/cocoa/src/toga_cocoa/widgets/tree.py index d60bbc4d77..6af99f1774 100644 --- a/cocoa/src/toga_cocoa/widgets/tree.py +++ b/cocoa/src/toga_cocoa/widgets/tree.py @@ -278,13 +278,13 @@ def get_selection(self): return None def expand_node(self, node): - self.native_tree.expandItem(node._impl, expandChildren=False) + self.native_tree.expandItem(node._impl, expandChildren=True) def expand_all(self): self.native_tree.expandItem(None, expandChildren=True) def collapse_node(self, node): - self.native_tree.collapseItem(node._impl, collapseChildren=False) + self.native_tree.collapseItem(node._impl, collapseChildren=True) def collapse_all(self): self.native_tree.collapseItem(None, collapseChildren=True) diff --git a/core/src/toga/widgets/tree.py b/core/src/toga/widgets/tree.py index 68037b5b25..9f533342f9 100644 --- a/core/src/toga/widgets/tree.py +++ b/core/src/toga/widgets/tree.py @@ -182,8 +182,7 @@ def expand(self, node: Node | None = None): If the provided node is a leaf node, or the node is already expanded, this is a no-op. - If a node is specified, the children of that node will not be automatically - expanded. + If a node is specified, the children of that node will also be expanded. :param node: The node to expand """ diff --git a/gtk/src/toga_gtk/widgets/tree.py b/gtk/src/toga_gtk/widgets/tree.py index d70555220c..5b9c5c0ae5 100644 --- a/gtk/src/toga_gtk/widgets/tree.py +++ b/gtk/src/toga_gtk/widgets/tree.py @@ -124,6 +124,20 @@ def get_selection(self): return None return store[iter][0].value + def expand_node(self, node): + self.native_tree.expand_row( + self.native_tree.get_model().get_path(node._impl), True + ) + + def expand_all(self): + self.native_tree.expand_all() + + def collapse_node(self, node): + self.native_tree.collapse_row(self.native_tree.get_model().get_path(node._impl)) + + def collapse_all(self): + self.native_tree.collapse_all() + def insert_column(self, index, heading, accessor): # Adding/removing a column means completely rebuilding the ListStore self.change_source(self.interface.data) diff --git a/gtk/tests_backend/widgets/tree.py b/gtk/tests_backend/widgets/tree.py index 601c8c4575..e4bf72e1af 100644 --- a/gtk/tests_backend/widgets/tree.py +++ b/gtk/tests_backend/widgets/tree.py @@ -25,6 +25,11 @@ async def expand_tree(self): self.native_tree.expand_all() await asyncio.sleep(0.1) + def is_expanded(self, node): + return self.native_tree.row_expanded( + self.native_tree.get_model().get_path(node._impl) + ) + def child_count(self, row_path=None): if row_path: row = self.native_tree.get_model()[row_path] diff --git a/testbed/tests/widgets/test_tree.py b/testbed/tests/widgets/test_tree.py index 3235d9dc26..8bf819b4a8 100644 --- a/testbed/tests/widgets/test_tree.py +++ b/testbed/tests/widgets/test_tree.py @@ -319,7 +319,6 @@ async def test_expand_collapse(widget, probe, source): # Attempt to collapse a leaf node widget.collapse(source[0][2][1]) - print("COLLAPSE", source[0][2][1]) await probe.redraw("Leaf node collapse is a no-op") assert probe.is_expanded(source[0]) assert probe.is_expanded(source[0][2]) From d44a1dd97bae15a34057ffd138c76c7bd78887e7 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sun, 27 Aug 2023 09:03:42 +0800 Subject: [PATCH 26/30] Force a refresh when the source changes. --- gtk/src/toga_gtk/widgets/table.py | 1 + gtk/src/toga_gtk/widgets/tree.py | 1 + 2 files changed, 2 insertions(+) diff --git a/gtk/src/toga_gtk/widgets/table.py b/gtk/src/toga_gtk/widgets/table.py index 3ae7dc1847..6c76e88882 100644 --- a/gtk/src/toga_gtk/widgets/table.py +++ b/gtk/src/toga_gtk/widgets/table.py @@ -113,6 +113,7 @@ def change_source(self, source): self.insert(i, row) self.native_table.set_model(self.store) + self.refresh() def insert(self, index, item): row = TogaRow(item) diff --git a/gtk/src/toga_gtk/widgets/tree.py b/gtk/src/toga_gtk/widgets/tree.py index 5b9c5c0ae5..3c6e945cce 100644 --- a/gtk/src/toga_gtk/widgets/tree.py +++ b/gtk/src/toga_gtk/widgets/tree.py @@ -79,6 +79,7 @@ def change_source(self, source): self.insert(None, i, row) self.native_tree.set_model(self.store) + self.refresh() def insert(self, parent, index, item): row = TogaRow(item) From e69e4e98ea4b1823810beafdc00fcf51ae0901ae Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Tue, 29 Aug 2023 09:24:10 +0100 Subject: [PATCH 27/30] Remove empty Winforms Tree implementation, so the user gets a clear error when trying to use it --- winforms/src/toga_winforms/factory.py | 2 -- winforms/src/toga_winforms/widgets/tree.py | 41 ---------------------- 2 files changed, 43 deletions(-) delete mode 100644 winforms/src/toga_winforms/widgets/tree.py diff --git a/winforms/src/toga_winforms/factory.py b/winforms/src/toga_winforms/factory.py index b074c543f2..af80f4a67a 100644 --- a/winforms/src/toga_winforms/factory.py +++ b/winforms/src/toga_winforms/factory.py @@ -26,7 +26,6 @@ from .widgets.table import Table from .widgets.textinput import TextInput from .widgets.timeinput import TimeInput -from .widgets.tree import Tree from .widgets.webview import WebView from .window import Window @@ -68,7 +67,6 @@ def not_implemented(feature): "Table", "TextInput", "TimeInput", - "Tree", "WebView", "Window", ] diff --git a/winforms/src/toga_winforms/widgets/tree.py b/winforms/src/toga_winforms/widgets/tree.py deleted file mode 100644 index 447f62f6ba..0000000000 --- a/winforms/src/toga_winforms/widgets/tree.py +++ /dev/null @@ -1,41 +0,0 @@ -import System.Windows.Forms as WinForms - -from .base import Widget - - -class Tree(Widget): # pragma: no cover - def create(self): - self.native = WinForms.TreeView() - - def row_data(self, item): - self.interface.factory.not_implemented("Tree.row_data()") - - def on_select(self, selection): - self.interface.factory.not_implemented("Tree.on_select()") - - def change_source(self, source): - self.interface.factory.not_implemented("Tree.change_source()") - - def insert(self, parent, index, item): - self.interface.factory.not_implemented("Tree.insert()") - - def change(self, item): - self.interface.factory.not_implemented("Tree.change()") - - def remove(self, parent, index, item): - self.interface.factory.not_implemented("Tree.remove()") - - def clear(self): - self.interface.factory.not_implemented("Tree.clear()") - - def get_selection(self): - self.interface.factory.not_implemented("Tree.get_selection()") - - def set_on_select(self, handler): - self.interface.factory.not_implemented("Tree.set_on_select()") - - def set_on_double_click(self, handler): - self.interface.factory.not_implemented("Table.set_on_double_click()") - - def scroll_to_node(self, node): - self.interface.factory.not_implemented("Tree.scroll_to_node()") From ab64eabc4e3bc6ba3aa882c455c2900cac336a90 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Tue, 29 Aug 2023 09:34:31 +0100 Subject: [PATCH 28/30] Update Winforms and Android Table for changes in this PR --- android/src/toga_android/widgets/table.py | 11 ++++++++--- android/tests_backend/widgets/table.py | 7 +++++++ cocoa/tests_backend/widgets/table.py | 2 +- cocoa/tests_backend/widgets/tree.py | 2 +- gtk/tests_backend/widgets/table.py | 2 +- gtk/tests_backend/widgets/tree.py | 2 +- testbed/tests/widgets/test_table.py | 4 ++-- testbed/tests/widgets/test_tree.py | 4 ++-- winforms/src/toga_winforms/widgets/table.py | 7 +++++++ winforms/tests_backend/widgets/table.py | 1 + 10 files changed, 31 insertions(+), 11 deletions(-) diff --git a/android/src/toga_android/widgets/table.py b/android/src/toga_android/widgets/table.py index 574c54c664..2a9004c58a 100644 --- a/android/src/toga_android/widgets/table.py +++ b/android/src/toga_android/widgets/table.py @@ -1,5 +1,9 @@ +from warnings import warn + from travertino.size import at_least +import toga + from ..libs.activity import MainActivity from ..libs.android import R__attr from ..libs.android.graphics import Rect, Typeface @@ -162,13 +166,14 @@ def create_table_row(self, row_index): return table_row def get_data_value(self, row_index, col_index): - row_object = self.interface.data[row_index] value = getattr( - row_object, + self.interface.data[row_index], self.interface._accessors[col_index], None, ) - + if isinstance(value, toga.Widget): + warn("This backend does not support the use of widgets in cells") + value = None if isinstance(value, tuple): # TODO: support icons value = value[1] if value is None: diff --git a/android/tests_backend/widgets/table.py b/android/tests_backend/widgets/table.py index e813cf8640..a00de1269c 100644 --- a/android/tests_backend/widgets/table.py +++ b/android/tests_backend/widgets/table.py @@ -3,6 +3,7 @@ from android.widget import ScrollView, TableLayout, TextView from .base import SimpleProbe +from .properties import toga_font HEADER = "HEADER" @@ -11,6 +12,7 @@ class TableProbe(SimpleProbe): native_class = ScrollView supports_icons = False supports_keyboard_shortcuts = False + supports_widgets = False def __init__(self, widget): super().__init__(widget) @@ -86,3 +88,8 @@ async def select_row(self, row, add=False): async def activate_row(self, row): self._row_view(row).performLongClick() + + @property + def font(self): + tv = self._row_view(0).getChildAt(0) + return toga_font(tv.getTypeface(), tv.getTextSize(), tv.getResources()) diff --git a/cocoa/tests_backend/widgets/table.py b/cocoa/tests_backend/widgets/table.py index a71c2afd94..53765c5559 100644 --- a/cocoa/tests_backend/widgets/table.py +++ b/cocoa/tests_backend/widgets/table.py @@ -13,7 +13,7 @@ class TableProbe(SimpleProbe): native_class = NSScrollView supports_icons = True supports_keyboard_shortcuts = True - supports_cell_widgets = True + supports_widgets = True def __init__(self, widget): super().__init__(widget) diff --git a/cocoa/tests_backend/widgets/tree.py b/cocoa/tests_backend/widgets/tree.py index eb1bcbf587..e19d673447 100644 --- a/cocoa/tests_backend/widgets/tree.py +++ b/cocoa/tests_backend/widgets/tree.py @@ -14,7 +14,7 @@ class TreeProbe(SimpleProbe): native_class = NSScrollView supports_keyboard_shortcuts = True - supports_cell_widgets = True + supports_widgets = True def __init__(self, widget): super().__init__(widget) diff --git a/gtk/tests_backend/widgets/table.py b/gtk/tests_backend/widgets/table.py index 9d95d2c388..8be5ff1ed2 100644 --- a/gtk/tests_backend/widgets/table.py +++ b/gtk/tests_backend/widgets/table.py @@ -9,7 +9,7 @@ class TableProbe(SimpleProbe): native_class = Gtk.ScrolledWindow supports_icons = True supports_keyboard_shortcuts = False - supports_cell_widgets = False + supports_widgets = False def __init__(self, widget): super().__init__(widget) diff --git a/gtk/tests_backend/widgets/tree.py b/gtk/tests_backend/widgets/tree.py index e4bf72e1af..1e6cdc3874 100644 --- a/gtk/tests_backend/widgets/tree.py +++ b/gtk/tests_backend/widgets/tree.py @@ -10,7 +10,7 @@ class TreeProbe(SimpleProbe): native_class = Gtk.ScrolledWindow supports_keyboard_shortcuts = False - supports_cell_widgets = False + supports_widgets = False def __init__(self, widget): super().__init__(widget) diff --git a/testbed/tests/widgets/test_table.py b/testbed/tests/widgets/test_table.py index e3bbf64711..a11ca1fb84 100644 --- a/testbed/tests/widgets/test_table.py +++ b/testbed/tests/widgets/test_table.py @@ -474,7 +474,7 @@ async def test_cell_widget(widget, probe): } for i in range(0, 50) ] - if probe.supports_cell_widgets: + if probe.supports_widgets: warning_check = contextlib.nullcontext() else: warning_check = pytest.warns( @@ -492,7 +492,7 @@ async def test_cell_widget(widget, probe): probe.assert_cell_content(1, 0, "A1") probe.assert_cell_content(1, 1, "B1") - if probe.supports_cell_widgets: + if probe.supports_widgets: probe.assert_cell_content(0, 2, widget=widget.data[0].c) probe.assert_cell_content(1, 2, widget=widget.data[1].c) else: diff --git a/testbed/tests/widgets/test_tree.py b/testbed/tests/widgets/test_tree.py index 8bf819b4a8..19555bc9bf 100644 --- a/testbed/tests/widgets/test_tree.py +++ b/testbed/tests/widgets/test_tree.py @@ -670,7 +670,7 @@ async def test_cell_widget(widget, probe): ], ), ] - if probe.supports_cell_widgets: + if probe.supports_widgets: warning_check = contextlib.nullcontext() else: warning_check = pytest.warns( @@ -689,7 +689,7 @@ async def test_cell_widget(widget, probe): probe.assert_cell_content((0, 1), 0, "A1") probe.assert_cell_content((0, 1), 1, "B1") - if probe.supports_cell_widgets: + if probe.supports_widgets: probe.assert_cell_content((0, 0), 2, widget=widget.data[0][0].c) probe.assert_cell_content((0, 1), 2, widget=widget.data[0][1].c) else: diff --git a/winforms/src/toga_winforms/widgets/table.py b/winforms/src/toga_winforms/widgets/table.py index 6d3096e0e5..8f56b41e71 100644 --- a/winforms/src/toga_winforms/widgets/table.py +++ b/winforms/src/toga_winforms/widgets/table.py @@ -1,6 +1,10 @@ +from warnings import warn + import System.Windows.Forms as WinForms from travertino.size import at_least +import toga + from .base import Widget @@ -114,6 +118,9 @@ def row_data(self, item): # workaround is in https://stackoverflow.com/a/46128593. def strip_icon(item, attr): val = getattr(item, attr, None) + if isinstance(val, toga.Widget): + warn("This backend does not support the use of widgets in cells") + val = None if isinstance(val, tuple): val = val[1] if val is None: diff --git a/winforms/tests_backend/widgets/table.py b/winforms/tests_backend/widgets/table.py index 99405d5c2e..d2ec7916a2 100644 --- a/winforms/tests_backend/widgets/table.py +++ b/winforms/tests_backend/widgets/table.py @@ -14,6 +14,7 @@ class TableProbe(SimpleProbe): background_supports_alpha = False supports_icons = False supports_keyboard_shortcuts = False + supports_widgets = False @property def row_count(self): From 489bd2c9268d0385710b6c69cd54cb0e22ae9b77 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Tue, 29 Aug 2023 09:59:48 +0100 Subject: [PATCH 29/30] Expand scope of table widget warning check --- testbed/tests/widgets/test_table.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/testbed/tests/widgets/test_table.py b/testbed/tests/widgets/test_table.py index a11ca1fb84..7f797432fd 100644 --- a/testbed/tests/widgets/test_table.py +++ b/testbed/tests/widgets/test_table.py @@ -482,12 +482,13 @@ async def test_cell_widget(widget, probe): ) with warning_check: + # Winforms creates rows on demand, so the warning may not appear until we try to + # access the row. widget.data = data + await probe.redraw("Table has data with widgets") - await probe.redraw("Table has data with widgets") - - probe.assert_cell_content(0, 0, "A0") - probe.assert_cell_content(0, 1, "B0") + probe.assert_cell_content(0, 0, "A0") + probe.assert_cell_content(0, 1, "B0") probe.assert_cell_content(1, 0, "A1") probe.assert_cell_content(1, 1, "B1") From 3911cadc68b51cc00aeac4f4901e81032c34cf7a Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Tue, 29 Aug 2023 17:08:48 +0100 Subject: [PATCH 30/30] Documentation cleanups --- core/src/toga/sources/tree_source.py | 22 ++-- core/src/toga/widgets/table.py | 20 +-- core/src/toga/widgets/tree.py | 85 ++++++------- .../api/resources/sources/list_source.rst | 37 ++---- .../api/resources/sources/tree_source.rst | 70 +++++------ .../reference/api/widgets/table-accessors.rst | 7 ++ docs/reference/api/widgets/table-values.rst | 15 +++ docs/reference/api/widgets/table.rst | 45 +++---- docs/reference/api/widgets/tree.rst | 115 +++++++----------- 9 files changed, 166 insertions(+), 250 deletions(-) create mode 100644 docs/reference/api/widgets/table-accessors.rst create mode 100644 docs/reference/api/widgets/table-values.rst diff --git a/core/src/toga/sources/tree_source.py b/core/src/toga/sources/tree_source.py index 8e5a574916..ad488408e7 100644 --- a/core/src/toga/sources/tree_source.py +++ b/core/src/toga/sources/tree_source.py @@ -11,13 +11,13 @@ def __init__(self, **data): """Create a new Node object. The keyword arguments specified in the constructor will be converted into - attributes on the new Row object. + attributes on the new object. When initially constructed, the Node will be a leaf node (i.e., no children, and marked unable to have children). - When any public attributes of the Row are modified (i.e., any attribute whose - name doesn't start with ``_``), the source to which the row belongs will be + When any public attributes of the node are modified (i.e., any attribute whose + name doesn't start with ``_``), the source to which the node belongs will be notified. """ super().__init__(**data) @@ -41,7 +41,7 @@ def __repr__(self): # Methods required by the TreeSource interface ###################################################################### - def __getitem__(self, index: int) -> None: + def __getitem__(self, index: int) -> Node: if self._children is None: raise ValueError(f"{self} is a leaf node") @@ -76,7 +76,7 @@ def can_have_children(self) -> bool: return self._children is not None ###################################################################### - # Utility methods to make TreeSource more dict-like + # Utility methods to make TreeSource more list-like ###################################################################### def __iter__(self): @@ -149,7 +149,7 @@ def index(self, child: Node): use :meth:`~toga.sources.Node.find`. :param child: The node to find in the children of this node. - :returns: The index of the row in the children of this node. + :returns: The index of the node in the children of this node. :raises ValueError: If the node cannot be found in children of this node. """ if self._children is None: @@ -168,7 +168,7 @@ def find(self, data: Any, start: Node = None): :meth:`~toga.sources.Node.index`. :param data: The data to search for. Only the values specified in data will be - used as matching criteria; if the row contains additional data attributes, + used as matching criteria; if the node contains additional data attributes, they won't be considered as part of the match. :param start: The instance from which to start the search. Defaults to ``None``, indicating that the first match should be returned. @@ -260,7 +260,7 @@ def _create_nodes(self, parent: Node | None, value: Any): return [self._create_node(parent=parent, data=value)] ###################################################################### - # Utility methods to make TreeSources more dict-like + # Utility methods to make TreeSources more list-like ###################################################################### def __setitem__(self, index: int, data: Any): @@ -278,10 +278,6 @@ def __setitem__(self, index: int, data: Any): self._roots[index] = root self.notify("change", item=root) - def __iter__(self): - """Obtain an iterator over all root Nodes in the data source.""" - return iter(self._roots) - def clear(self): """Clear all data from the data source.""" self._roots = [] @@ -368,7 +364,7 @@ def find(self, data: Any, start: Node = None): :meth:`~toga.sources.TreeSource.index`. :param data: The data to search for. Only the values specified in data will be - used as matching criteria; if the row contains additional data attributes, + used as matching criteria; if the node contains additional data attributes, they won't be considered as part of the match. :param start: The instance from which to start the search. Defaults to ``None``, indicating that the first match should be returned. diff --git a/core/src/toga/widgets/table.py b/core/src/toga/widgets/table.py index 602d732106..0f8f90d600 100644 --- a/core/src/toga/widgets/table.py +++ b/core/src/toga/widgets/table.py @@ -26,7 +26,7 @@ def __init__( ): """Create a new Table widget. - :param headings: The list of headings for the table. Headings can only contain + :param headings: The column headings for the table. Headings can only contain one line; any text after a newline will be ignored. A value of :any:`None` will produce a table without headings. @@ -35,19 +35,12 @@ def __init__( :param id: The ID for the widget. :param style: A style object. If no style is provided, a default style will be applied to the widget. - :param data: Initial :any:`data` to be displayed on the table. + :param data: Initial :any:`data` to be displayed in the table. :param accessors: Defines the attributes of the data source that will be used to - populate each column. If unspecified, accessors will be derived from the - headings by: - - 1. Converting the heading to lower case; - 2. Removing any character that can't be used in a Python identifier; - 3. Replacing all whitespace with "_"; - 4. Prepending ``_`` if the first character is a digit. - - Otherwise, ``accessors`` must be either: + populate each column. Must be either: + * ``None`` to derive accessors from the headings, as described above; or * A list of the same size as ``headings``, specifying the accessors for each heading. A value of :any:`None` will fall back to the default generated accessor; or @@ -133,8 +126,7 @@ def data(self) -> ListSource: * A value of None is turned into an empty ListSource. * Otherwise, the value must be an iterable, which is copied into a new - ListSource using the widget's accessors. Items are converted as shown - :ref:`here `. + ListSource. Items are converted as shown :ref:`here `. """ return self._data @@ -229,7 +221,7 @@ def append_column(self, heading: str, accessor: str | None = None): def insert_column( self, - index: int, + index: int | str, heading: str | None, accessor: str | None = None, ): diff --git a/core/src/toga/widgets/tree.py b/core/src/toga/widgets/tree.py index 9f533342f9..a61af6b1cb 100644 --- a/core/src/toga/widgets/tree.py +++ b/core/src/toga/widgets/tree.py @@ -24,28 +24,35 @@ def __init__( missing_value: str = "", on_double_click=None, # DEPRECATED ): - """Create a new Tree Widget. + """Create a new Tree widget. - Inherits from :class:`~toga.widgets.base.Widget`. + :param headings: The column headings for the tree. Headings can only contain one + line; any text after a newline will be ignored. + + A value of :any:`None` will produce a table without headings. + However, if you do this, you *must* give a list of accessors. - :param headings: The list of headings for the tree. A value of :any:`None` - can be used to specify a tree without headings. Individual headings cannot - include newline characters; any text after a newline will be ignored :param id: The ID for the widget. :param style: A style object. If no style is provided, a default style will be applied to the widget. - :param data: The data to be displayed on the tree. Can be a list of values or a - TreeSource. See the definition of the :attr:`data` property for details on - how data can be specified and used. - :param accessors: A list of names, with same length as :attr:`headings`, that - describes the attributes of the data source that will be used to populate - each column. If unspecified, accessors will be automatically derived from - the tree headings. + :param data: Initial :any:`data` to be displayed in the tree. + + :param accessors: Defines the attributes of the data source that will be used to + populate each column. Must be either: + + * ``None`` to derive accessors from the headings, as described above; or + * A list of the same size as ``headings``, specifying the accessors for each + heading. A value of :any:`None` will fall back to the default generated + accessor; or + * A dictionary mapping headings to accessors. Any missing headings will fall + back to the default generated accessor. + :param multiple_select: Does the tree allow multiple selection? :param on_select: Initial :any:`on_select` handler. :param on_activate: Initial :any:`on_activate` handler. - :param missing_value: The string that will be used to populate a cell when a - data source doesn't provided a value for a given attribute. + :param missing_value: The string that will be used to populate a cell when the + value provided by its accessor is :any:`None`, or the accessor isn't + defined. :param on_double_click: **DEPRECATED**; use :attr:`on_activate`. """ super().__init__(id=id, style=style) @@ -108,39 +115,17 @@ def focus(self): @property def data(self) -> TreeSource: - """The data to display in the tree, as a TreeSource. + """The data to display in the tree. - When specifying data: + When setting this property: - * A TreeSource will be used as-is + * A :any:`Source` will be used as-is. It must either be a :any:`TreeSource`, or + a custom class that provides the same methods. * A value of None is turned into an empty TreeSource. - * A dictionary will be converted so that the keys of the dictionary are - converted into Nodes, and the values are processed recursively as child nodes. - - * Any iterable object (except a string). Each value in the iterable will be - treated as a 2-item tuple, with first item being data for the parent Node, and - the second item being processed recursively as child nodes. - - * Any other object will be converted into a list containing a single node with - no children. - - When converting individual values into Nodes: - - * If the value is a dictionary, the keys of the dictionary will become the - attributes of the Node. - - * All other values will be converted into a Node with attributes matching the - ``accessors`` provided at time of construction (or the ``accessors`` that were - derived from the ``headings`` that were provided at construction). - - If the value is a string, or any other a non-iterable object, the Node will - have a single attribute matching the first accessor. - - If the value is a list, tuple, or any other iterable, values in the iterable - will be mapped in order to the accessors. - + * Otherwise, the value must be an dictionary or an iterable, which is copied + into a new TreeSource as shown :ref:`here `. """ return self._data @@ -165,12 +150,12 @@ def multiple_select(self) -> bool: def selection(self) -> list[Node] | Node | None: """The current selection of the tree. - If multiple selection is enabled, returns a list of Tree objects from the data - source matching the current selection. An empty list is returned if no rows are + If multiple selection is enabled, returns a list of Node objects from the data + source matching the current selection. An empty list is returned if no nodes are selected. If multiple selection is *not* enabled, returns the selected Node object, or - :any:`None` if no row is currently selected. + :any:`None` if no node is currently selected. """ return self._impl.get_selection() @@ -192,9 +177,9 @@ def expand(self, node: Node | None = None): self._impl.expand_node(node) def collapse(self, node: Node | None = None): - """Expand the specified node of the tree. + """Collapse the specified node of the tree. - If no node is provided, all nodes of the tree will be expanded. + If no node is provided, all nodes of the tree will be collapsed. If the provided node is a leaf node, or the node is already collapsed, this is a no-op. @@ -218,7 +203,7 @@ def append_column(self, heading: str, accessor: str | None = None): def insert_column( self, - index: int, + index: int | str, heading: str | None, accessor: str | None = None, ): @@ -277,12 +262,12 @@ def remove_column(self, column: int | str): @property def headings(self) -> list[str]: - """The column headings for the tree""" + """The column headings for the tree (read-only)""" return self._headings @property def accessors(self) -> list[str]: - """The accessors used to populate the tree""" + """The accessors used to populate the tree (read-only)""" return self._accessors @property diff --git a/docs/reference/api/resources/sources/list_source.rst b/docs/reference/api/resources/sources/list_source.rst index 5536df3091..3b891b96bc 100644 --- a/docs/reference/api/resources/sources/list_source.rst +++ b/docs/reference/api/resources/sources/list_source.rst @@ -44,38 +44,19 @@ the operations you'd expect on a normal Python list, such as ``insert``, ``remov .. _listsource-item: -When initially constructing the ListSource, or when assigning a specific item in -the ListSource, each item can be: +The ListSource manages a list of :class:`~toga.sources.Row` objects. Each Row has all +the attributes described by the source's ``accessors``. A Row object will be constructed +for each item that is added to the ListSource, and each item can be: -* A dictionary, with the accessors mapping to the keys in the dictionary +* A dictionary, with the accessors mapping to the keys in the dictionary. -* Any iterable object (except for a string), with the accessors being mapped - onto the items in the iterable in order of definition +* Any other iterable object (except for a string), with the accessors being mapped + onto the items in the iterable in order of definition. * Any other object, which will be mapped onto the *first* accessor. -The ListSource manages a list of :class:`~toga.sources.Row` objects. Each Row object in -the ListSource is an object that has all the attributes described by the ``accessors``. -A Row object will be constructed by the source for each item that is added or removed -from the ListSource. - -When creating a single Row for a ListSource (e.g., when inserting a new -item), the data for the Row can be specified as: - -* A dictionary, with the accessors mapping to the keys in the dictionary - -* Any iterable object (except for a string), with the accessors being mapped - onto the items in the iterable in order of definition. This requires that the - iterable object have *at least* as many values as the number of accessors - defined on the TreeSource. - -* Any other object, which will be mapped onto the *first* accessor. - -When initially constructing the ListSource, the data must be an iterable of values, each -of which can be converted into a Row. - -Although Toga provides ListSource, you are not required to use it directly. A ListSource -will be transparently constructed for you if you provide a Python ``list`` object to a +Although Toga provides ListSource, you are not required to create one directly. A +ListSource will be transparently constructed if you provide an iterable object to a GUI widget that displays list-like data (i.e., :class:`toga.Table`, :class:`toga.Selection`, or :class:`toga.DetailedList`). @@ -89,7 +70,7 @@ source ` class. Such a class must: * Provide the same methods as :any:`ListSource` -* Return items whose attributes match the accessors for any widget using the source +* Return items whose attributes match the accessors expected by the widget * Generate a ``change`` notification when any of those attributes change diff --git a/docs/reference/api/resources/sources/tree_source.rst b/docs/reference/api/resources/sources/tree_source.rst index 8e98a68f1a..d38e0807db 100644 --- a/docs/reference/api/resources/sources/tree_source.rst +++ b/docs/reference/api/resources/sources/tree_source.rst @@ -16,7 +16,7 @@ that all items managed by the TreeSource will have. The API provided by TreeSour :any:`list`-like; the operations you'd expect on a normal Python list, such as ``insert``, ``remove``, ``index``, and indexing with ``[]``, are also possible on a TreeSource. These methods are available on the TreeSource itself to manipulate root -nodes, and also on each item of the TreeSource to manipulate children. +nodes, and also on each node within the tree. .. code-block:: python @@ -26,12 +26,12 @@ nodes, and also on each item of the TreeSource to manipulate children. accessors=["name", "height"], data={ "Animals": [ - {"name": "Numbat", "height": 0.15}, - {"name": "Thylacine", "height": 0.6}, + ({"name": "Numbat", "height": 0.15}, None), + ({"name": "Thylacine", "height": 0.6}, None), ], "Plants": [ - {"name": "Woollybush", "height": 2.4}, - {"name": "Boronia", "height": 0.9}, + ({"name": "Woollybush", "height": 2.4}, None), + ({"name": "Boronia", "height": 0.9}, None), ], } ) @@ -58,10 +58,9 @@ nodes, and also on each item of the TreeSource to manipulate children. # Insert a new root item in the middle of the list of root nodes source.insert(1, {"name": "Minerals"}) -The TreeSource manages a tree of :class:`~toga.sources.Node` objects. Each Node object -in the TreeSource is an object that has all the attributes described by the -``accessors`` for the TreeSource. A Node object will be constructed by the source for -each item that is added or removed from the ListSource. +The TreeSource manages a tree of :class:`~toga.sources.Node` objects. Each Node has all +the attributes described by the source's ``accessors``. A Node object will be +constructed for each item that is added to the TreeSource. Each Node object in the TreeSource can have children; those children can in turn have their own children. A child that *cannot* have children is called a *leaf Node*. Whether @@ -71,71 +70,62 @@ files and directories on a file system: a file is a leaf Node, as it cannot have children; a directory *can* contain files and other directories in it, but it can also be empty. An empty directory would *not* be a leaf Node. +.. _treesource-item: + When creating a single Node for a TreeSource (e.g., when inserting a new item), the data for the Node can be specified as: * A dictionary, with the accessors mapping to the keys in the dictionary * Any iterable object (except for a string), with the accessors being mapped - onto the items in the iterable in order of definition. This requires that the - iterable object have *at least* as many values as the number of accessors - defined on the TreeSource. + onto the items in the iterable in order of definition. * Any other object, which will be mapped onto the *first* accessor. -When constructing an entire ListSource, the data can be specified as: +When constructing an entire TreeSource, the data can be specified as: * A dictionary. The keys of the dictionary will be converted into Nodes, and used as parents; the values of the dictionary will become the children of their corresponding parent. -* Any iterable object (except a string). Each value in the iterable will be treated as - a 2-item tuple, with first item being data for the parent Node, and the second item - being the child data. +* Any other iterable object (except a string). Each value in the iterable will be + treated as a 2-item tuple, with the first item being data for the parent Node, and the + second item being the child data. -* Any other object. The object will be converted into a list containing a single node - with no children. +* Any other object will be converted into a single node with no children. When specifying children, a value of :any:`None` for the children will result in the creation of a leaf node. Any other value will be processed recursively - so, a child specifier can itself be a dictionary, an iterable of 2-tuples, or data for a single -child; each of which can specify their own children, and so on. +child, and so on. -Although Toga provides TreeSource, you are not required to use it directly. A TreeSource -will be transparently constructed for you if you provide Python primitives (e.g. +Although Toga provides TreeSource, you are not required to create one directly. A TreeSource +will be transparently constructed for you if you provide one of the items listed above (e.g. :any:`list`, :any:`dict`, etc) to a GUI widget that displays tree-like data (i.e., -:class:`toga.Tree`). Any object that adheres to the same interface can be used as an -alternative source of data for widgets that support using a TreeSource. See the -background guide on :ref:`custom data sources ` for more details. +:class:`toga.Tree`). Custom TreeSources ------------------ -Any object that adheres to the TreeSource interface can be used as a data source. The -TreeSource, plus every node managed by the TreeSource, must provide the following -methods: - -* ``__len__()`` - returns the number of children of this node, or the number of root - nodes for the TreeSource. +For more complex applications, you can replace TreeSource with a :ref:`custom data +source ` class. Such a class must: -* ``__getitem__(index)`` - returns the child at position ``index`` of a node, or the - root node at position ``index`` of the TreeSource. +* Inherit from :any:`Source` -Every node on the TreeSource must also provide: +* Provide the same methods as :any:`TreeSource` -* ``can_have_children()`` - returns ``False`` if the node is a leaf node. +* Return items whose attributes match the accessors expected by the widget -A custom TreeSource must also generate ``insert``, ``remove`` and ``clear`` -notifications when items are added or removed from the source, or when children are -added or removed to nodes managed by the TreeSource. +* Generate a ``change`` notification when any of those attributes change -Each node returned by the custom TreeSource is required to expose attributes matching -the accessors for any widget using the source. Any change to the values of these attributes -must generate a ``change`` notification on any listener to the custom ListSource. +* Generate ``insert``, ``remove`` and ``clear`` notifications when nodes are added or + removed Reference --------- .. autoclass:: toga.sources.Node + :special-members: __len__, __getitem__, __setitem__, __delitem__ .. autoclass:: toga.sources.TreeSource + :special-members: __len__, __getitem__, __setitem__, __delitem__ diff --git a/docs/reference/api/widgets/table-accessors.rst b/docs/reference/api/widgets/table-accessors.rst new file mode 100644 index 0000000000..eabb4d30fe --- /dev/null +++ b/docs/reference/api/widgets/table-accessors.rst @@ -0,0 +1,7 @@ +The attribute names used on each row (called "accessors") are created automatically from +the headings, by: + +1. Converting the heading to lower case +2. Removing any character that can't be used in a Python identifier +3. Replacing all whitespace with ``_`` +4. Prepending ``_`` if the first character is a digit diff --git a/docs/reference/api/widgets/table-values.rst b/docs/reference/api/widgets/table-values.rst new file mode 100644 index 0000000000..549b06d0c4 --- /dev/null +++ b/docs/reference/api/widgets/table-values.rst @@ -0,0 +1,15 @@ +The value provided by an accessor is interpreted as follows: + +* If the value is a :any:`Widget`, that widget will be displayed in the cell. Note that + this is currently a beta API: see the Notes section. + +* If the value is a :any:`tuple`, it must have two elements: an icon, and a second + element which will be interpreted as one of the options below. + +* If the value is ``None``, then ``missing_value`` will be displayed. + +* Any other value will be converted into a string. If an icon has not already been + provided in a tuple, it can also be provided using the value's ``icon`` attribute. + +Icon values must either be an :any:`Icon`, which will be displayed on the left of the +cell, or ``None`` to display no icon. diff --git a/docs/reference/api/widgets/table.rst b/docs/reference/api/widgets/table.rst index bf1bef805c..1202490060 100644 --- a/docs/reference/api/widgets/table.rst +++ b/docs/reference/api/widgets/table.rst @@ -1,7 +1,8 @@ Table ===== -A widget for displaying columns of tabular data. +A widget for displaying columns of tabular data. Scroll bars will be provided if +necessary. .. figure:: /reference/images/Table.png :width: 300px @@ -17,13 +18,9 @@ A widget for displaying columns of tabular data. Usage ----- -A Table will automatically provide scroll bars when necessary. - -The simplest instantiation of a Table is to use a list of lists (or list of tuples), -containing the items to display in the table. When creating the table, you can also -specify the headings to use on the table; those headings will be converted into -accessors on the Row data objects created for the table data. The values in the tuples -provided will then be mapped sequentially to the accessors. +The simplest way to create a Table is to pass a list of tuples containing the items to +display, and a list of column headings. The values in the tuples will then be mapped +sequentially to the columns. In this example, we will display a table of 2 columns, with 3 initial rows of data: @@ -67,11 +64,11 @@ to control the display order of columns independent of the storage of that data. row = table.data[0] print(f"{row.name}, who is age {row.age}, is from {row.planet}") -The attribute names used on each row of data (called "accessors") are created -automatically from the headings that you provide. If you want to use different -attributes, you can override them by providing an ``accessors`` argument. In this -example, the table will use "Name" as the visible header, but internally, the attribute -"character" will be used: +.. include:: table-accessors.rst + +If you want to use different attributes, you can override them by providing an +``accessors`` argument. In this example, the table will use "Name" as the visible +header, but internally, the attribute "character" will be used: .. code-block:: python @@ -83,7 +80,7 @@ example, the table will use "Name" as the visible header, but internally, the at data=[ {"character": "Arthur Dent", "age": 42, "planet": "Earth"}, {"character", "Ford Prefect", "age": 37, "planet": "Betelgeuse Five"}, - {"name": "Tricia McMillan", "age": 38, "plaent": "Earth"}, + {"name": "Tricia McMillan", "age": 38, "planet": "Earth"}, ] ) @@ -91,31 +88,17 @@ example, the table will use "Name" as the visible header, but internally, the at row = table.data[0] print(f"{row.character}, who is age {row.age}, is from {row.planet}") -The value provided by an accessor is interpreted as follows: - -* If the value is a :any:`Widget`, that widget will be displayed in the cell. Note that - this is currently a beta API: see the Notes section. - -* If the value is a :any:`tuple`, it must have two elements: an icon, and a second - element which will be interpreted as one of the options below. - -* If the value is ``None``, then ``missing_value`` will be displayed. - -* Any other value will be converted into a string. If an icon has not already been - provided in a tuple, it can also be provided using the value's ``icon`` attribute. - -Icon values must either be an :any:`Icon`, which will be displayed on the left of the -cell, or ``None`` to display no icon. +.. include:: table-values.rst Notes ----- -* Widgets in tables is a beta API which may change in future, and is currently only +* Widgets in cells is a beta API which may change in future, and is currently only supported on macOS. * macOS does not support changing the font used to render table content. -* Icons in tables are not currently supported on Android or Winforms. +* Icons in cells are not currently supported on Android or Winforms. * The Android implementation is `not scalable `_ beyond about 1,000 cells. diff --git a/docs/reference/api/widgets/tree.rst b/docs/reference/api/widgets/tree.rst index b76541f97e..07a1da4318 100644 --- a/docs/reference/api/widgets/tree.rst +++ b/docs/reference/api/widgets/tree.rst @@ -1,7 +1,8 @@ Tree ==== -A widget for displaying a hierarchical tree of tabular data. +A widget for displaying a hierarchical tree of tabular data. Scroll bars will be +provided if necessary. .. figure:: /reference/images/Tree.png :width: 300px @@ -17,17 +18,11 @@ A widget for displaying a hierarchical tree of tabular data. Usage ----- -A Tree uses a :class:`~toga.sources.TreeSource` to manage the data being displayed. -options. If ``data`` is not specified as a TreeSource, it will be converted into a -TreeSource at runtime. - -The simplest instantiation of a Tree is to use a dictionary, where the keys are the data -for each node, and the values describe the children for that node. When creating the -Tree, you must also specify the headings to use on the tree; those headings will be -converted into accessors on the Node data objects created for the tree data. The values -in the tuples provided as keys in the data will then be mapped sequentially to the -accessors; or, if an atomic value has been provided as a key, only the first accessor -will be populated. +The simplest way to create a Tree is to pass a dictionary and a list of column headings. +Each key in the dictionary can be either a tuple, whose contents will be mapped +sequentially to the columns of a node, or a single object, which will be mapped to the +first column. And each value in the dictionary can be either another dictionary +containing the children of that node, or ``None`` if there are no children. In this example, we will display a tree with 2 columns. The tree will have 2 root nodes; the first root node will have 1 child node; the second root node will have 2 @@ -58,7 +53,7 @@ blank: tree.data[0].append(("Tricia McMillan", 38)) You can also specify data for a Tree using a list of 2-tuples, with dictionaries -providing serving as data values. This allows to to store data in the data source that +providing data values. This allows you to store data in the data source that won't be displayed in the tree. It also allows you to control the display order of columns independent of the storage of that data. @@ -69,39 +64,29 @@ columns independent of the storage of that data. tree = toga.Tree( headings=["Name", "Age"], data=[ - ({"name": "Earth"}), [ - ({"name": "Arthur Dent", "age": 42, "status": "Anxious"}, None) - ], - ({"name": "Betelgeuse Five"}), [ - ({"name": "Ford Prefect", "age": 37, "status": "Hoopy"}, None) - ({"name": "Zaphod Beeblebrox", "age": 47, "status": "Oblivious"}, None) - ], + ( + {"name": "Earth"}, + [({"name": "Arthur Dent", "age": 42, "status": "Anxious"}, None)] + ), + ( + {"name": "Betelgeuse Five"}, + [ + ({"name": "Ford Prefect", "age": 37, "status": "Hoopy"}, None), + ({"name": "Zaphod Beeblebrox", "age": 47, "status": "Oblivious"}, None), + ] + ), ] ) # Get the details of the first child of the second root node: - print(f"{tree.data[1][0].name} is age {tree.data[1][0].age}") - - # Append new data to the first root node in the tree - tree.data[0].append({"name": "Tricia McMillan", "age": 38, "status": "Overqualified"}) - -The attribute names used on each row of data (called "accessors") are created automatically from -the name of the headings that you provide. This is done by: - -1. Converting the heading to lower case; -2. Removing any character that can't be used in a Python identifier; -3. Replacing all whitespace with "_"; -4. Prepending ``_`` if the first character is a digit. - -If you want to use different accessors to the ones that are automatically generated, you -can override them by providing an ``accessors`` argument. This can be either: + node = tree.data[1][0] + print(f"{node.name}, who is age {node.age}, is {node.status}") -* A list of the same size as the list of headings, specifying the accessors for each - heading. A value of :any:`None` will fall back to the default generated accessor; or -* A dictionary mapping heading names to accessor names. +.. include:: table-accessors.rst -In this example, the tree will use "Name" as the visible header, but internally, the -attribute "character" will be used: +If you want to use different attributes, you can override them by providing an +``accessors`` argument. In this example, the tree will use "Name" as the visible header, +but internally, the attribute "character" will be used: .. code-block:: python @@ -111,49 +96,31 @@ attribute "character" will be used: headings=["Name", "Age"], accessors={"Name", 'character'}, data=[ - ({"character": "Earth"}), [ - ({"character": "Arthur Dent", "age": 42, "status": "Anxious"}, None) - ], - ({"character": "Betelgeuse Five"}), [ - ({"character": "Ford Prefect", "age": 37, "status": "Hoopy"}, None) - ({"character": "Zaphod Beeblebrox", "age": 47, "status": "Oblivious"}, None) - ], + ( + {"character": "Earth"}, + [({"character": "Arthur Dent", "age": 42, "status": "Anxious"}, None)] + ), + ( + {"character": "Betelgeuse Five"}, + [ + ({"character": "Ford Prefect", "age": 37, "status": "Hoopy"}, None), + ({"character": "Zaphod Beeblebrox", "age": 47, "status": "Oblivious"}, None), + ] + ), ] ) # Get the details of the first child of the second root node: - print(f"{tree.data[1][0].character} is age {tree.data[1][0].age}") - - # Get the details of the first item in the data: - print(f"{tree.data[0].character}, who is age {tree.data[0].age}, is from {tree.data[0].planet}") - -You can also create a tree *without* a heading row. However, if you do this, you *must* -specify accessors. - -If the value provided by an accessor is :any:`None`, or the accessor isn't defined for a -given row, the value of ``missing_value`` provided when constructing the Tree will -be used to populate the cell in the Tree. - -If the value provided by an accessor is any type other than a tuple :any:`tuple` or -:any:`toga.Widget`, the value will be converted into a string. If the value has an -``icon`` attribute, the cell will use that icon in the Tree cell, displayed to the left -of the text label. If the value of the ``icon`` attribute is :any:`None`, no icon will -be displayed. - -If the value provided by an accessor is a :any:`tuple`, the first element in the tuple -must be an :class:`toga.Icon`, and the second value in the tuple will be used to provide -the text label (again, by converting the value to a string, or using ``missing_value`` -if the value is :any:`None`, as appropriate). - -If the value provided by an accessor is a :class:`toga.Widget`, that widget will be displayed -in the tree. Note that this is currently a beta API, and may change in future. + node = tree.data[1][0] + print(f"{node.character}, who is age {node.age}, is {node.status}") +.. include:: table-values.rst Notes ----- -* The use of Widgets as tree values is currently a beta API. It is currently only - supported on macOS; the API is subject to change. +* Widgets in cells is a beta API which may change in future, and is currently only + supported on macOS. * On macOS, you cannot change the font used in a Tree.