Browse Source

chore(tree): add selection api (#611)

main
Pier CeccoPierangioliEugenio 3 days ago committed by GitHub
parent
commit
bb9475a6c1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 14
      .github/workflows/testing.yml
  2. 49
      libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/filetree.py
  3. 51
      libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/tree.py
  4. 99
      libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/treewidget.py
  5. 15
      libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/treewidgetitem.py
  6. 4
      pyproject.toml
  7. 370
      tests/pytest/modelView/test_treewidget.py
  8. 138
      tests/t.ui/test.ui.011.tree.06.selection.py
  9. 4
      tools/docker/github-runner/Dockerfile
  10. 8
      tools/docker/github-runner/README.md

14
.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_*

49
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:
'''

51
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:
'''

99
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:

15
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:

4
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]

370
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"""

138
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 <ceccopierangiolieugenio AT googlemail DOT com>
#
# 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()

4
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

8
tools/docker/github-runner/README.md

@ -1,7 +1,11 @@
# Build the Docker
```bash
_GITHUB_TOKEN=<your_github_runner_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=<your_github_runner_token>
# docker build --build-arg RUNNER_TOKEN=${_GITHUB_TOKEN} -t github-runner .
docker build -t github-runner .
```
# Run the runner

Loading…
Cancel
Save