From bb9475a6c17ed90bdbed6cd2ca368e6267262d2a Mon Sep 17 00:00:00 2001 From: Pier CeccoPierangioliEugenio Date: Fri, 27 Mar 2026 23:09:40 +0000 Subject: [PATCH] chore(tree): add selection api (#611) --- .github/workflows/testing.yml | 14 +- .../TTkWidgets/TTkModelView/filetree.py | 49 +++ .../TermTk/TTkWidgets/TTkModelView/tree.py | 51 +++ .../TTkWidgets/TTkModelView/treewidget.py | 99 ++++- .../TTkWidgets/TTkModelView/treewidgetitem.py | 15 +- pyproject.toml | 4 +- tests/pytest/modelView/test_treewidget.py | 370 +++++++++++++++++- tests/t.ui/test.ui.011.tree.06.selection.py | 138 +++++++ tools/docker/github-runner/Dockerfile | 4 +- tools/docker/github-runner/README.md | 8 +- 10 files changed, 726 insertions(+), 26 deletions(-) create mode 100644 tests/t.ui/test.ui.011.tree.06.selection.py diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index bed79012..a0b2c73e 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -61,30 +61,30 @@ jobs: shell: bash run: | python -m pip install --upgrade pip - python -m pip install -e '.[test]' + python -m pip install uv + uv venv + uv pip install -e '.[test]' - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --exclude .venv,build,tmp + uv run flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --exclude .venv,build,tmp # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide # flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - name: pytest run: | DDDD=$(pwd) - pytest ${DDDD}/tests/pytest - pytest ${DDDD}/apps + uv run pytest ${DDDD}/tests/pytest + uv run pytest ${DDDD}/apps - name: Dumb Smoke Test run: | # To fix a folder permissin issue let's try to run the test from /tmp DDDD=$(pwd) - mkdir -p /tmp/Eugenio - cd /tmp/Eugenio # Download the input test mkdir -p tmp wget -O tmp/test.input.001.bin https://github.com/ceccopierangiolieugenio/binaryRepo/raw/master/pyTermTk/tests/test.input.001.bin wget -O tmp/test.input.002.bin https://github.com/ceccopierangiolieugenio/binaryRepo/raw/master/pyTermTk/tests/test.input.002.bin wget -O tmp/test.input.003.bin https://github.com/ceccopierangiolieugenio/binaryRepo/raw/master/pyTermTk/tests/test.input.003.bin - pytest ${DDDD}/tests/pytest/run_* + uv run pytest ${DDDD}/tests/pytest/run_* diff --git a/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/filetree.py b/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/filetree.py index fdde3365..2d481741 100644 --- a/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/filetree.py +++ b/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/filetree.py @@ -274,6 +274,55 @@ class TTkFileTree(TTkTree): setDragDropMode ''' return self._fileTreeWidget.setDragDropMode(dndMode=dndMode) + def setSelectionMode(self, mode:TTkK.SelectionMode) -> None: + ''' + .. seealso:: this method is forwarded to :py:meth:`TTkFileTreeWidget.setSelectionMode` + + Sets the current selection model to the given selectionModel. + + :param mode: the selection mode used in this tree + :type mode: :py:class:`TTkK.SelectionMode` + ''' + return self._fileTreeWidget.setSelectionMode(mode=mode) + def clearSelection(self) -> None: + ''' + .. seealso:: this method is forwarded to :py:meth:`TTkFileTreeWidget.clearSelection` + + Deselects all selected items. + ''' + return self._fileTreeWidget.clearSelection() + def setCurrentItem(self, item:Optional[TTkTreeWidgetItem]) -> None: + ''' + .. seealso:: this method is forwarded to :py:meth:`TTkFileTreeWidget.setCurrentItem` + + Selects the specified item as the current one. + + :param item: the item to be selected, None clears the selection + :type item: :py:class:`TTkTreeWidgetItem` or None + ''' + return self._fileTreeWidget.setCurrentItem(item=item) + def selectItem(self, item:TTkTreeWidgetItem) -> None: + ''' + .. seealso:: this method is forwarded to :py:meth:`TTkFileTreeWidget.selectItem` + + Adds the specified item to the current selection. + + In single selection mode this replaces the previous selection. + + :param item: the item to be selected + :type item: :py:class:`TTkTreeWidgetItem` + ''' + return self._fileTreeWidget.selectItem(item=item) + def deselectItem(self, item:TTkTreeWidgetItem) -> None: + ''' + .. seealso:: this method is forwarded to :py:meth:`TTkFileTreeWidget.deselectItem` + + Removes the specified item from the current selection. + + :param item: the item to be deselected + :type item: :py:class:`TTkTreeWidgetItem` + ''' + return self._fileTreeWidget.deselectItem(item=item) @pyTTkSlot() def expandAll(self) -> None: ''' diff --git a/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/tree.py b/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/tree.py index ce6349bb..28239a59 100644 --- a/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/tree.py +++ b/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/tree.py @@ -47,6 +47,8 @@ class TTkTree(TTkAbstractScrollArea): 'setColumnWidth', 'resizeColumnToContents', 'sortColumn', 'sortItems', 'dragDropMode', 'setDragDropMode', + 'setSelectionMode', + 'clearSelection', 'setCurrentItem', 'selectItem', 'deselectItem', 'expandAll', 'collapseAll', 'invisibleRootItem', 'itemAt', # 'appendItem', 'setAlignment', 'setColumnColors', 'setColumnSize', 'setHeader', @@ -217,6 +219,55 @@ class TTkTree(TTkAbstractScrollArea): setDragDropMode ''' return self._treeView.setDragDropMode(dndMode=dndMode) + def setSelectionMode(self, mode:TTkK.SelectionMode) -> None: + ''' + .. seealso:: this method is forwarded to :py:meth:`TTkTreeWidget.setSelectionMode` + + Sets the current selection model to the given selectionModel. + + :param mode: the selection mode used in this tree + :type mode: :py:class:`TTkK.SelectionMode` + ''' + return self._treeView.setSelectionMode(mode=mode) + def clearSelection(self) -> None: + ''' + .. seealso:: this method is forwarded to :py:meth:`TTkTreeWidget.clearSelection` + + Deselects all selected items. + ''' + return self._treeView.clearSelection() + def setCurrentItem(self, item:Optional[TTkTreeWidgetItem]) -> None: + ''' + .. seealso:: this method is forwarded to :py:meth:`TTkTreeWidget.setCurrentItem` + + Selects the specified item as the current one. + + :param item: the item to be selected, None clears the selection + :type item: :py:class:`TTkTreeWidgetItem` or None + ''' + return self._treeView.setCurrentItem(item=item) + def selectItem(self, item:TTkTreeWidgetItem) -> None: + ''' + .. seealso:: this method is forwarded to :py:meth:`TTkTreeWidget.selectItem` + + Adds the specified item to the current selection. + + In single selection mode this replaces the previous selection. + + :param item: the item to be selected + :type item: :py:class:`TTkTreeWidgetItem` + ''' + return self._treeView.selectItem(item=item) + def deselectItem(self, item:TTkTreeWidgetItem) -> None: + ''' + .. seealso:: this method is forwarded to :py:meth:`TTkTreeWidget.deselectItem` + + Removes the specified item from the current selection. + + :param item: the item to be deselected + :type item: :py:class:`TTkTreeWidgetItem` + ''' + return self._treeView.deselectItem(item=item) @pyTTkSlot() def expandAll(self) -> None: ''' diff --git a/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/treewidget.py b/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/treewidget.py index 76968090..f592b097 100644 --- a/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/treewidget.py +++ b/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/treewidget.py @@ -60,9 +60,9 @@ class _RootWidgetItem(TTkTreeWidgetItem): if not limited_page: return 0 if column==0: - size = max(max(_l+_i.icon(column).termWidth()+_t.termWidth() for _t in _i.data(column).split('\n')) for _l,_y,_i in limited_page if not _y) + size = max((max(_l+_i.icon(column).termWidth()+_t.termWidth() for _t in _i.data(column).split('\n')) for _l,_y,_i in limited_page if not _y), default=1) else: - size = max(max((_i.icon(column)+_t).termWidth() for _t in _i.data(column).split('\n')) for _l,_y,_i in limited_page if not _y) + size = max((max((_i.icon(column)+_t).termWidth() for _t in _i.data(column).split('\n')) for _l,_y,_i in limited_page if not _y), default=1) return size-1 def _get_page_root(self, index:int, size:int) -> List[Tuple[int,int,TTkTreeWidgetItem]]: @@ -246,7 +246,7 @@ class TTkTreeWidget(TTkAbstractScrollView): items: List[TTkTreeWidgetItem] def __init__(self, *, - header:List[TTkString]=[], + header:Optional[List[TTkString]]=None, sortingEnabled:bool=True, selectionMode:TTkK.SelectionMode=TTkK.SelectionMode.SingleSelection, dragDropMode:TTkK.DragDropMode=TTkK.DragDropMode.NoDragDrop, @@ -278,7 +278,7 @@ class TTkTreeWidget(TTkAbstractScrollView): self._sortOrder = TTkK.AscendingOrder self._rootItem = _RootWidgetItem() super().__init__(**kwargs) - self.setHeaderLabels(header) + self.setHeaderLabels(header if header is not None else []) self.setMinimumHeight(1) self.setFocusPolicy(TTkK.ClickFocus) self.clear() @@ -321,6 +321,7 @@ class TTkTreeWidget(TTkAbstractScrollView): if self._rootItem: self._rootItem.dataChanged.disconnect(self._refreshCache) self._rootItem = _RootWidgetItem() + self._selected = [] self._rootItem.dataChanged.connect(self._refreshCache) self.sortItems(self._sortColumn, self._sortOrder) self.viewChanged.emit() @@ -348,6 +349,24 @@ class TTkTreeWidget(TTkAbstractScrollView): self.viewChanged.emit() self.update() + def _itemInTree(self, item:TTkTreeWidgetItem) -> bool: + if not item: + return False + if item is self._rootItem: + return True + # Traverse up from the item to the root using parent chain + current = item + while current is not None: + if current is self._rootItem: + return True + current = current._parent + return False + + def _pruneSelection(self) -> None: + if not self._selected: + return + self._selected = [_i for _i in self._selected if self._itemInTree(_i)] + def takeTopLevelItem(self, index:int) -> Optional[TTkTreeWidgetItem]: ''' Removes the top-level item at the given index in the tree and returns it, otherwise returns None; @@ -358,6 +377,7 @@ class TTkTreeWidget(TTkAbstractScrollView): :rtype: Optional[:py:class:`TTkTreeWidgetItem`] ''' ret = self._rootItem.takeChild(index) + self._pruneSelection() self.viewChanged.emit() self.update() return ret @@ -396,7 +416,13 @@ class TTkTreeWidget(TTkAbstractScrollView): :param mode: the selection mode used in this tree :type mode: :py:class:`TTkK.SelectionMode` ''' + self._pruneSelection() self._selectionMode = mode + if mode == TTkK.SelectionMode.NoSelection: + self.clearSelection() + elif mode == TTkK.SelectionMode.SingleSelection and len(self._selected) > 1: + self._selected = self._selected[:1] + self.update() def selectedItems(self) -> List[TTkTreeWidgetItem]: ''' @@ -404,10 +430,73 @@ class TTkTreeWidget(TTkAbstractScrollView): :rtype: List[:py:class:`TTkTreeWidgetItem`] ''' + self._pruneSelection() if self._selected: return self._selected return [] + def clearSelection(self) -> None: + ''' + Deselects all selected items. + ''' + if not self._selected: + return + self._selected = [] + self.update() + + def setCurrentItem(self, item:Optional[TTkTreeWidgetItem]) -> None: + ''' + Selects the specified item as the current one. + + :param item: the item to be selected, None clears the selection + :type item: :py:class:`TTkTreeWidgetItem` or None + ''' + if item is None: + self.clearSelection() + return + if not self._itemInTree(item): + return + if self._selectionMode == TTkK.SelectionMode.NoSelection: + return + self._selected = [item] + self.update() + + def selectItem(self, item:TTkTreeWidgetItem) -> None: + ''' + Adds the specified item to the current selection. + + In single selection mode this replaces the previous selection. + + :param item: the item to be selected + :type item: :py:class:`TTkTreeWidgetItem` + ''' + if not self._itemInTree(item): + return + if self._selectionMode == TTkK.SelectionMode.NoSelection: + return + if self._selectionMode == TTkK.SelectionMode.SingleSelection: + self._selected = [item] + self.update() + return + if item not in self._selected: + self._selected.append(item) + self.update() + + def deselectItem(self, item:TTkTreeWidgetItem) -> None: + ''' + Removes the specified item from the current selection. + + :param item: the item to be deselected + :type item: :py:class:`TTkTreeWidgetItem` + ''' + if not self._selected: + return + if item in self._selected: + self._selected.remove(item) + self.update() + return + self._pruneSelection() + def setHeaderLabels(self, labels:List[TTkString]) -> None: ''' Adds a column in the header for each item in the labels list, and sets the label for each column. @@ -570,7 +659,7 @@ class TTkTreeWidget(TTkAbstractScrollView): self.itemExpanded.emit(item) else: self.itemCollapsed.emit(item) - self._selected = [item] + self.setCurrentItem(item) col = -1 for i, c in enumerate(self._columnsPos): if x < c: diff --git a/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/treewidgetitem.py b/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/treewidgetitem.py index 54c23cbe..75eac688 100644 --- a/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/treewidgetitem.py +++ b/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/treewidgetitem.py @@ -135,9 +135,9 @@ class _TTkTreeChildren(TTkAbstractItemModel): def _addChild(self, parent:TTkTreeWidgetItem, child:TTkTreeWidgetItem): self._children.append(child) - child._parent = self._parent - child._sortOrder = self._parent._sortOrder - child._sortColumn = self._parent._sortColumn + child._parent = parent + child._sortOrder = parent._sortOrder + child._sortColumn = parent._sortColumn child.dataChanged.connect(self.emitDataChanged) child._sizeChanged.connect(self._childrenSizeChangedHandler) @@ -165,6 +165,7 @@ class _TTkTreeChildren(TTkAbstractItemModel): 0<= index < len(self._children) ): return None child = self._children.pop(index) + child._parent = None child.dataChanged.disconnect(self.emitDataChanged) child._sizeChanged.disconnect(self._childrenSizeChangedHandler) self._childrenSizeChangedHandler(None, -child.size()) @@ -172,11 +173,13 @@ class _TTkTreeChildren(TTkAbstractItemModel): return child def takeChildren(self) -> List[TTkTreeWidgetItem]: - children = self._children + children = self._children.copy() for child in children: + child._parent = None child.dataChanged.disconnect(self.emitDataChanged) child._sizeChanged.disconnect(self._childrenSizeChangedHandler) self._childrenSizeChangedHandler(None, -self._total_size) + self._children = [] self.emitDataChanged() return children @@ -421,7 +424,7 @@ class TTkTreeWidgetItem(TTkAbstractItemModel): self._children = _TTkTreeChildren(self) self._children._childrenSizeChanged.connect(self._sizeChangedHandler) self._children.dataChanged.connect(self.emitDataChanged) - child = self._children.addChild(self, child) + self._children.addChild(self, child) self._setDefaultIcon() def addChildren(self, children:List[TTkTreeWidgetItem]) -> None: @@ -429,7 +432,7 @@ class TTkTreeWidgetItem(TTkAbstractItemModel): self._children = _TTkTreeChildren(self) self._children._childrenSizeChanged.connect(self._sizeChangedHandler) self._children.dataChanged.connect(self.emitDataChanged) - children = self._children.addChildren(self, children) + self._children.addChildren(self, children) self._setDefaultIcon() def removeChild(self, child:TTkTreeWidgetItem) -> None: diff --git a/pyproject.toml b/pyproject.toml index f53b80c1..9eaa755f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,8 +36,8 @@ "mypy>=1.15.0" ] docs = [ - "Sphinx==8.2.3", - "sphinx-book-theme==1.1.4" + "Sphinx==8.2.3; python_version>='3.11'", + "sphinx-book-theme==1.1.4; python_version>='3.11'" ] [tool.setuptools] diff --git a/tests/pytest/modelView/test_treewidget.py b/tests/pytest/modelView/test_treewidget.py index fd2243e6..d92ee9e9 100644 --- a/tests/pytest/modelView/test_treewidget.py +++ b/tests/pytest/modelView/test_treewidget.py @@ -1,5 +1,4 @@ -import pytest -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock from TermTk.TTkCore.constant import TTkK from TermTk.TTkCore.string import TTkString from TermTk.TTkWidgets.TTkModelView.treewidget import TTkTreeWidget @@ -88,6 +87,18 @@ class TestTTkTreeWidgetBasic: assert taken == item assert tree._rootItem.size() == 0 + def test_parent_link_set_and_cleared_for_top_level_item(self): + """Test top-level item parent link is set on add and cleared on remove""" + tree = TTkTreeWidget() + item = TTkTreeWidgetItem(["Item 1"]) + + tree.addTopLevelItem(item) + assert item._parent == tree._rootItem + + taken = tree.takeTopLevelItem(0) + assert taken == item + assert item._parent is None + def test_index_of_top_level_item(self): """Test finding index of top-level item""" tree = TTkTreeWidget() @@ -121,6 +132,361 @@ class TestTTkTreeWidgetSelection: tree.setSelectionMode(TTkK.SelectionMode.MultiSelection) assert tree.selectionMode() == TTkK.SelectionMode.MultiSelection + def test_set_selection_mode_to_no_selection_clears(self): + """Test switching to NoSelection clears all selected items""" + tree = TTkTreeWidget(selectionMode=TTkK.SelectionMode.MultiSelection) + item1 = TTkTreeWidgetItem(["Item 1"]) + item2 = TTkTreeWidgetItem(["Item 2"]) + tree.addTopLevelItems([item1, item2]) + + tree.selectItem(item1) + tree.selectItem(item2) + assert len(tree.selectedItems()) == 2 + + tree.setSelectionMode(TTkK.SelectionMode.NoSelection) + assert tree.selectedItems() == [] + + def test_set_selection_mode_single_to_multi_preserves(self): + """Test switching from Single to Multi preserves the selected item""" + tree = TTkTreeWidget(selectionMode=TTkK.SelectionMode.SingleSelection) + item = TTkTreeWidgetItem(["Item"]) + tree.addTopLevelItem(item) + + tree.selectItem(item) + assert tree.selectedItems() == [item] + + tree.setSelectionMode(TTkK.SelectionMode.MultiSelection) + assert tree.selectedItems() == [item] + + def test_set_selection_mode_multi_to_single_one_item(self): + """Test switching Multi→Single with exactly one item keeps it""" + tree = TTkTreeWidget(selectionMode=TTkK.SelectionMode.MultiSelection) + item = TTkTreeWidgetItem(["Item"]) + tree.addTopLevelItem(item) + + tree.selectItem(item) + tree.setSelectionMode(TTkK.SelectionMode.SingleSelection) + assert tree.selectedItems() == [item] + + def test_set_selection_mode_multi_to_single_empty(self): + """Test switching Multi→Single with no selection stays empty""" + tree = TTkTreeWidget(selectionMode=TTkK.SelectionMode.MultiSelection) + tree.addTopLevelItem(TTkTreeWidgetItem(["Item"])) + + tree.setSelectionMode(TTkK.SelectionMode.SingleSelection) + assert tree.selectedItems() == [] + + def test_set_selection_mode_no_to_single(self): + """Test switching from NoSelection to Single allows selection again""" + tree = TTkTreeWidget(selectionMode=TTkK.SelectionMode.NoSelection) + item = TTkTreeWidgetItem(["Item"]) + tree.addTopLevelItem(item) + + tree.selectItem(item) + assert tree.selectedItems() == [] + + tree.setSelectionMode(TTkK.SelectionMode.SingleSelection) + tree.selectItem(item) + assert tree.selectedItems() == [item] + + def test_set_selection_mode_trims_keeps_first(self): + """Test switching Multi→Single keeps the first selected item""" + tree = TTkTreeWidget(selectionMode=TTkK.SelectionMode.MultiSelection) + items = [TTkTreeWidgetItem([f"Item {i}"]) for i in range(5)] + tree.addTopLevelItems(items) + + for item in items: + tree.selectItem(item) + assert len(tree.selectedItems()) == 5 + + tree.setSelectionMode(TTkK.SelectionMode.SingleSelection) + assert tree.selectedItems() == [items[0]] + + def test_programmatic_single_selection(self): + """Test select/deselect API in single selection mode""" + tree = TTkTreeWidget(selectionMode=TTkK.SelectionMode.SingleSelection) + item1 = TTkTreeWidgetItem(["Item 1"]) + item2 = TTkTreeWidgetItem(["Item 2"]) + tree.addTopLevelItems([item1, item2]) + + tree.selectItem(item1) + assert tree.selectedItems() == [item1] + + tree.selectItem(item2) + assert tree.selectedItems() == [item2] + + tree.deselectItem(item2) + assert tree.selectedItems() == [] + + def test_programmatic_multi_selection(self): + """Test select/deselect API in multi selection mode""" + tree = TTkTreeWidget(selectionMode=TTkK.SelectionMode.MultiSelection) + item1 = TTkTreeWidgetItem(["Item 1"]) + item2 = TTkTreeWidgetItem(["Item 2"]) + tree.addTopLevelItems([item1, item2]) + + tree.selectItem(item1) + tree.selectItem(item2) + assert tree.selectedItems() == [item1, item2] + + tree.deselectItem(item1) + assert tree.selectedItems() == [item2] + + tree.clearSelection() + assert tree.selectedItems() == [] + + def test_programmatic_no_selection_mode(self): + """Test selection API is ignored in no selection mode""" + tree = TTkTreeWidget(selectionMode=TTkK.SelectionMode.NoSelection) + item = TTkTreeWidgetItem(["Item 1"]) + + tree.selectItem(item) + tree.setCurrentItem(item) + assert tree.selectedItems() == [] + + def test_set_selection_mode_trims_selected_items(self): + """Test selection gets trimmed when switching to single mode""" + tree = TTkTreeWidget(selectionMode=TTkK.SelectionMode.MultiSelection) + item1 = TTkTreeWidgetItem(["Item 1"]) + item2 = TTkTreeWidgetItem(["Item 2"]) + tree.addTopLevelItems([item1, item2]) + + tree.selectItem(item1) + tree.selectItem(item2) + tree.setSelectionMode(TTkK.SelectionMode.SingleSelection) + + assert tree.selectedItems() == [item1] + + def test_programmatic_selection_ignores_detached_items(self): + """Test selection API ignores items not belonging to this tree""" + tree = TTkTreeWidget(selectionMode=TTkK.SelectionMode.MultiSelection) + in_tree = TTkTreeWidgetItem(["In tree"]) + detached = TTkTreeWidgetItem(["Detached"]) + tree.addTopLevelItem(in_tree) + + tree.selectItem(detached) + tree.setCurrentItem(detached) + tree.deselectItem(detached) + + assert tree.selectedItems() == [] + + def test_multilevel_single_selection(self): + """Test single selection mode with nested items""" + tree = TTkTreeWidget(selectionMode=TTkK.SelectionMode.SingleSelection) + parent = TTkTreeWidgetItem(["Parent"]) + child1 = TTkTreeWidgetItem(["Child 1"]) + child2 = TTkTreeWidgetItem(["Child 2"]) + grandchild = TTkTreeWidgetItem(["Grandchild"]) + + parent.addChildren([child1, child2]) + child1.addChild(grandchild) + tree.addTopLevelItem(parent) + + # Select parent + tree.selectItem(parent) + assert tree.selectedItems() == [parent] + + # Select child (replaces parent) + tree.selectItem(child1) + assert tree.selectedItems() == [child1] + + # Select grandchild (replaces child) + tree.selectItem(grandchild) + assert tree.selectedItems() == [grandchild] + + def test_multilevel_multi_selection(self): + """Test multi selection mode with nested items""" + tree = TTkTreeWidget(selectionMode=TTkK.SelectionMode.MultiSelection) + parent = TTkTreeWidgetItem(["Parent"]) + child1 = TTkTreeWidgetItem(["Child 1"]) + child2 = TTkTreeWidgetItem(["Child 2"]) + grandchild = TTkTreeWidgetItem(["Grandchild"]) + + parent.addChildren([child1, child2]) + child1.addChild(grandchild) + tree.addTopLevelItem(parent) + + # Select multiple items at different levels + tree.selectItem(parent) + tree.selectItem(child1) + tree.selectItem(grandchild) + assert tree.selectedItems() == [parent, child1, grandchild] + + # Deselect middle item + tree.deselectItem(child1) + assert tree.selectedItems() == [parent, grandchild] + + # Clear selection + tree.clearSelection() + assert tree.selectedItems() == [] + + def test_multilevel_deselect_nested_item(self): + """Test deselecting deeply nested items""" + tree = TTkTreeWidget(selectionMode=TTkK.SelectionMode.MultiSelection) + root = TTkTreeWidgetItem(["Root"]) + level1 = TTkTreeWidgetItem(["Level 1"]) + level2 = TTkTreeWidgetItem(["Level 2"]) + level3 = TTkTreeWidgetItem(["Level 3"]) + + root.addChild(level1) + level1.addChild(level2) + level2.addChild(level3) + tree.addTopLevelItem(root) + + # Select all levels + tree.selectItem(root) + tree.selectItem(level1) + tree.selectItem(level2) + tree.selectItem(level3) + assert len(tree.selectedItems()) == 4 + + # Deselect items at different levels + tree.deselectItem(level3) + assert tree.selectedItems() == [root, level1, level2] + + tree.deselectItem(level1) + assert tree.selectedItems() == [root, level2] + + def test_multilevel_selection_after_tree_modification(self): + """Test selection is pruned after removing items from tree""" + tree = TTkTreeWidget(selectionMode=TTkK.SelectionMode.MultiSelection) + parent = TTkTreeWidgetItem(["Parent"]) + child = TTkTreeWidgetItem(["Child"]) + parent.addChild(child) + tree.addTopLevelItem(parent) + + # Select both items + tree.selectItem(parent) + tree.selectItem(child) + assert len(tree.selectedItems()) == 2 + + # Remove child from parent + parent.removeChild(child) + # Selection should be pruned to only include items still in tree + tree._pruneSelection() + assert tree.selectedItems() == [parent] + + def test_detached_item_from_another_tree_multilevel(self): + """Test selection ignores detached items from another tree (multilevel)""" + tree1 = TTkTreeWidget(selectionMode=TTkK.SelectionMode.MultiSelection) + tree2 = TTkTreeWidget(selectionMode=TTkK.SelectionMode.MultiSelection) + + # Create structured trees + parent1 = TTkTreeWidgetItem(["Parent 1"]) + child1 = TTkTreeWidgetItem(["Child 1"]) + parent1.addChild(child1) + tree1.addTopLevelItem(parent1) + + parent2 = TTkTreeWidgetItem(["Parent 2"]) + child2 = TTkTreeWidgetItem(["Child 2"]) + parent2.addChild(child2) + tree2.addTopLevelItem(parent2) + + # Try to select items from tree2 in tree1 + tree1.selectItem(parent2) + tree1.selectItem(child2) + assert tree1.selectedItems() == [] + + # Select valid item from tree1 + tree1.selectItem(parent1) + assert tree1.selectedItems() == [parent1] + + # Deselect items from tree2 in tree1 (should do nothing) + tree1.deselectItem(child2) + assert tree1.selectedItems() == [parent1] + + def test_orphaned_item_after_reparenting(self): + """Test selection handles orphaned items that were reparented""" + tree = TTkTreeWidget(selectionMode=TTkK.SelectionMode.MultiSelection) + parent1 = TTkTreeWidgetItem(["Parent 1"]) + parent2 = TTkTreeWidgetItem(["Parent 2"]) + child = TTkTreeWidgetItem(["Child"]) + + parent1.addChild(child) + tree.addTopLevelItems([parent1, parent2]) + + # Select all items + tree.selectItem(parent1) + tree.selectItem(parent2) + tree.selectItem(child) + assert len(tree.selectedItems()) == 3 + + # Reparent child to parent2 + parent1.removeChild(child) + parent2.addChild(child) + + # Selection should still be valid (item is still in tree) + tree._pruneSelection() + assert len(tree.selectedItems()) == 3 + + def test_selection_after_take_child(self): + """Test selection after using takeChild API""" + tree = TTkTreeWidget(selectionMode=TTkK.SelectionMode.MultiSelection) + parent = TTkTreeWidgetItem(["Parent"]) + child1 = TTkTreeWidgetItem(["Child 1"]) + child2 = TTkTreeWidgetItem(["Child 2"]) + + parent.addChildren([child1, child2]) + tree.addTopLevelItem(parent) + + # Select both children + tree.selectItem(child1) + tree.selectItem(child2) + assert len(tree.selectedItems()) == 2 + + # Take child1 from tree + taken = parent.takeChild(0) + assert taken == child1 + + # Prune selection + tree._pruneSelection() + # Only child2 should remain in selection + assert tree.selectedItems() == [child2] + + def test_selection_after_take_children(self): + """Test selection pruning after using takeChildren API""" + tree = TTkTreeWidget(selectionMode=TTkK.SelectionMode.MultiSelection) + parent = TTkTreeWidgetItem(["Parent"]) + child1 = TTkTreeWidgetItem(["Child 1"]) + child2 = TTkTreeWidgetItem(["Child 2"]) + + parent.addChildren([child1, child2]) + tree.addTopLevelItem(parent) + + # Select parent and children + tree.selectItem(parent) + tree.selectItem(child1) + tree.selectItem(child2) + assert len(tree.selectedItems()) == 3 + + # Detach all children from parent + taken = parent.takeChildren() + assert taken == [child1, child2] + + # Selection should keep only items still in the tree + tree._pruneSelection() + assert tree.selectedItems() == [parent] + + def test_parent_link_set_and_cleared_for_nested_manipulation(self): + """Test nested item parent link across remove and reparent operations""" + tree = TTkTreeWidget() + parent1 = TTkTreeWidgetItem(["Parent 1"]) + parent2 = TTkTreeWidgetItem(["Parent 2"]) + child = TTkTreeWidgetItem(["Child"]) + tree.addTopLevelItems([parent1, parent2]) + + parent1.addChild(child) + assert child._parent == parent1 + + parent1.removeChild(child) + assert child._parent is None + + parent2.addChild(child) + assert child._parent == parent2 + + parent2.removeChild(child) + assert child._parent is None + class TestTTkTreeWidgetSorting: """Test sorting functionality""" diff --git a/tests/t.ui/test.ui.011.tree.06.selection.py b/tests/t.ui/test.ui.011.tree.06.selection.py new file mode 100644 index 00000000..07795450 --- /dev/null +++ b/tests/t.ui/test.ui.011.tree.06.selection.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 + +# MIT License +# +# Copyright (c) 2021 Eugenio Parodi +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import os +import sys +import argparse + +sys.path.append(os.path.join(sys.path[0],'../../libs/pyTermTk')) +import TermTk as ttk + +ttk.TTkLog.use_default_file_logging() + +parser = argparse.ArgumentParser() +parser.add_argument('-f', help='Full Screen', action='store_true') +args = parser.parse_args() + +root = ttk.TTk(mouseTrack=True) + +if args.f: + rootWidget = root + root.setLayout(ttk.TTkGridLayout()) +else: + rootWidget = ttk.TTkWindow(parent=root, pos=(0,0), size=(120,40), title="Test Tree Selection", layout=ttk.TTkGridLayout(), border=True) + +# Left panel: tree +tw = ttk.TTkTree() +tw.setHeaderLabels(["Name", "Type", "Value"]) + +# Populate tree items +items = [] +for i in range(5): + item = ttk.TTkTreeWidgetItem([f"Item {i}", f"Type {chr(65+i)}", f"Val {i*10}"]) + for j in range(3): + child = ttk.TTkTreeWidgetItem([f"Child {i}.{j}", f"Sub {chr(65+i)}", f"{i*10+j}"]) + item.addChild(child) + tw.addTopLevelItem(item) + items.append(item) +items[0].setExpanded(True) +items[2].setExpanded(True) + +# Right panel: controls +controlLayout = ttk.TTkGridLayout() +controlFrame = ttk.TTkFrame(border=True, title="Controls", layout=controlLayout) + +# Selection mode radio buttons +row = 0 +controlLayout.addWidget(ttk.TTkLabel(text="Selection Mode:", maxHeight=1), row, 0, 1, 2) +row += 1 +rbNoSel = ttk.TTkRadioButton(text="NoSelection", radiogroup="selMode", maxHeight=1) +rbSingle = ttk.TTkRadioButton(text="SingleSelection", radiogroup="selMode", maxHeight=1, checked=True) +rbMulti = ttk.TTkRadioButton(text="MultiSelection", radiogroup="selMode", maxHeight=1) +controlLayout.addWidget(rbNoSel, row, 0, 1, 2) ; row += 1 +controlLayout.addWidget(rbSingle, row, 0, 1, 2) ; row += 1 +controlLayout.addWidget(rbMulti, row, 0, 1, 2) ; row += 1 + +@ttk.pyTTkSlot() +def _setNoSel(): + tw.setSelectionMode(ttk.TTkK.SelectionMode.NoSelection) + ttk.TTkLog.debug("Selection mode: NoSelection") + +@ttk.pyTTkSlot() +def _setSingle(): + tw.setSelectionMode(ttk.TTkK.SelectionMode.SingleSelection) + ttk.TTkLog.debug("Selection mode: SingleSelection") + +@ttk.pyTTkSlot() +def _setMulti(): + tw.setSelectionMode(ttk.TTkK.SelectionMode.MultiSelection) + ttk.TTkLog.debug("Selection mode: MultiSelection") + +rbNoSel.clicked.connect(_setNoSel) +rbSingle.clicked.connect(_setSingle) +rbMulti.clicked.connect(_setMulti) + +# Selection API buttons +controlLayout.addWidget(ttk.TTkLabel(text="Selection API:", maxHeight=1), row, 0, 1, 2) ; row += 1 + +btnSelectItem0 = ttk.TTkButton(text="selectItem(Item 0)", maxHeight=3, border=True) +btnSelectItem2 = ttk.TTkButton(text="selectItem(Item 2)", maxHeight=3, border=True) +btnDeselectItem0 = ttk.TTkButton(text="deselectItem(Item 0)", maxHeight=3, border=True) +btnDeselectItem2 = ttk.TTkButton(text="deselectItem(Item 2)", maxHeight=3, border=True) +btnSetCurrent1 = ttk.TTkButton(text="setCurrentItem(Item 1)", maxHeight=3, border=True) +btnSetCurrentNone= ttk.TTkButton(text="setCurrentItem(None)", maxHeight=3, border=True) +btnClearSel = ttk.TTkButton(text="clearSelection()", maxHeight=3, border=True) +btnShowSel = ttk.TTkButton(text="Show selectedItems()", maxHeight=3, border=True) + +controlLayout.addWidget(btnSelectItem0, row, 0) ; controlLayout.addWidget(btnSelectItem2, row, 1) ; row += 1 +controlLayout.addWidget(btnDeselectItem0, row, 0) ; controlLayout.addWidget(btnDeselectItem2, row, 1) ; row += 1 +controlLayout.addWidget(btnSetCurrent1, row, 0) ; controlLayout.addWidget(btnSetCurrentNone,row, 1) ; row += 1 +controlLayout.addWidget(btnClearSel, row, 0) ; controlLayout.addWidget(btnShowSel, row, 1) ; row += 1 + +btnSelectItem0.clicked.connect( lambda: (tw.selectItem(items[0]), ttk.TTkLog.debug(f"selectItem: {items[0].data(0)}"))) +btnSelectItem2.clicked.connect( lambda: (tw.selectItem(items[2]), ttk.TTkLog.debug(f"selectItem: {items[2].data(0)}"))) +btnDeselectItem0.clicked.connect(lambda: (tw.deselectItem(items[0]), ttk.TTkLog.debug(f"deselectItem: {items[0].data(0)}"))) +btnDeselectItem2.clicked.connect(lambda: (tw.deselectItem(items[2]), ttk.TTkLog.debug(f"deselectItem: {items[2].data(0)}"))) +btnSetCurrent1.clicked.connect( lambda: (tw.setCurrentItem(items[1]), ttk.TTkLog.debug(f"setCurrentItem: {items[1].data(0)}"))) +btnSetCurrentNone.clicked.connect(lambda: (tw.setCurrentItem(None), ttk.TTkLog.debug("setCurrentItem: None"))) +btnClearSel.clicked.connect( lambda: (tw.clearSelection(), ttk.TTkLog.debug("clearSelection"))) +btnShowSel.clicked.connect( lambda: ttk.TTkLog.debug(f"selectedItems: {[i.data(0) for i in tw.selectedItems()]}")) + +controlLayout.addWidget(ttk.TTkSpacer(), row, 0, 1, 2) + +# Log viewer at the bottom +logViewer = ttk.TTkLogViewer() + +# Layout: tree left, controls right, log bottom +splitterH = ttk.TTkSplitter() +splitterH.addWidget(tw) +splitterH.addWidget(controlFrame) + +splitterV = ttk.TTkSplitter(orientation=ttk.TTkK.VERTICAL) +splitterV.addWidget(splitterH) +splitterV.addWidget(logViewer) + +rootWidget.layout().addWidget(splitterV) + +root.mainloop() diff --git a/tools/docker/github-runner/Dockerfile b/tools/docker/github-runner/Dockerfile index f7e749d4..9f88eb66 100644 --- a/tools/docker/github-runner/Dockerfile +++ b/tools/docker/github-runner/Dockerfile @@ -26,8 +26,8 @@ USER ubuntu WORKDIR /home/ubuntu # x86-64 -RUN curl -o actions-runner-linux-x64-2.323.0.tar.gz -L https://github.com/actions/runner/releases/download/v2.323.0/actions-runner-linux-x64-2.323.0.tar.gz -RUN echo "0dbc9bf5a58620fc52cb6cc0448abcca964a8d74b5f39773b7afcad9ab691e19 actions-runner-linux-x64-2.323.0.tar.gz" | shasum -a 256 -c +RUN curl -o actions-runner-linux-x64-2.333.0.tar.gz -L https://github.com/actions/runner/releases/download/v2.333.0/actions-runner-linux-x64-2.333.0.tar.gz +RUN echo "7ce6b3fd8f879797fcc252c2918a23e14a233413dc6e6ab8e0ba8768b5d54475 actions-runner-linux-x64-2.333.0.tar.gz" | shasum -a 256 -c # arm-64 # RUN curl -o actions-runner-linux-arm64-2.323.0.tar.gz -L https://github.com/actions/runner/releases/download/v2.323.0/actions-runner-linux-arm64-2.323.0.tar.gz diff --git a/tools/docker/github-runner/README.md b/tools/docker/github-runner/README.md index 76b56d9c..37a0d971 100644 --- a/tools/docker/github-runner/README.md +++ b/tools/docker/github-runner/README.md @@ -1,7 +1,11 @@ # Build the Docker ```bash -_GITHUB_TOKEN= -docker build --build-arg RUNNER_TOKEN=${_GITHUB_TOKEN} -t github-runner . +# This is in case you want to build the Docker with the token embedded +# Require to change the Dockerfile +# _GITHUB_TOKEN= +# docker build --build-arg RUNNER_TOKEN=${_GITHUB_TOKEN} -t github-runner . + +docker build -t github-runner . ``` # Run the runner