Browse Source

chore(list)!: reworked the TTkList component

pull/595/head
Parodi, Eugenio 🌶 1 month ago
parent
commit
338abab80e
  1. 2
      apps/tlogg/tlogg/app/highlighters.py
  2. 4
      demo/demo.py
  3. 2
      demo/gittk.py
  4. 0
      demo/showcase/list_.py
  5. 5
      docs/MDNotes/beta.checklist.md
  6. 1
      libs/pyTermTk/TermTk/TTkAbstract/__init__.py
  7. 58
      libs/pyTermTk/TermTk/TTkAbstract/abstract_list_item.py
  8. 10
      libs/pyTermTk/TermTk/TTkCore/color.py
  9. 13
      libs/pyTermTk/TermTk/TTkUiTools/properties/list_.py
  10. 4
      libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/table_edit_proxy.py
  11. 1
      libs/pyTermTk/TermTk/TTkWidgets/__init__.py
  12. 479
      libs/pyTermTk/TermTk/TTkWidgets/listwidget.py
  13. 136
      libs/pyTermTk/TermTk/TTkWidgets/listwidget_item.py
  14. 637
      tests/pytest/widgets/test_list.py

2
apps/tlogg/tlogg/app/highlighters.py

@ -149,7 +149,7 @@ def highlightersFormLayout(win):
listColors.itemClicked.connect(_listCallback)
for i,color in enumerate(colors):
# ali = TTkAbstractListItem(text=color['pattern'],data=color)
# ali = TTkListItem(text=color['pattern'],data=color)
listColors.addItem(item=color['pattern'],data=color)
return leftRightLayout

4
demo/demo.py

@ -40,7 +40,7 @@ from showcase.windowsflags import demoWindowsFlags
from showcase.formwidgets02 import demoFormWidgets
from showcase.scrollarea01 import demoScrollArea01
from showcase.scrollarea02 import demoScrollArea02
from showcase.list import demoList
from showcase.list_ import demoList
from showcase.menubar import demoMenuBar
from showcase.filepicker import demoFilePicker
from showcase.colorpicker import demoColorPicker
@ -172,7 +172,7 @@ def demoShowcase(root=None, border=True):
tabWidgets = ttk.TTkTabWidget(parent=mainFrame, border=False, visible=False)
tabWidgets.addTab(demoFormWidgets(), " Form Test ", 'showcase/formwidgets02.py')
tabWidgets.addTab(demoTextEdit(), " Text Edit ", 'showcase/textedit.py')
tabWidgets.addTab(demoList(), " List Test ", 'showcase/list.py')
tabWidgets.addTab(demoList(), " List Test ", 'showcase/list_.py')
tabWidgets.addTab(demoTree(), " Tree Test", 'showcase/tree.py')
tabWidgets.addTab(demoTTkTable(), " Table Test", 'showcase/table.py')
tabWidgets.addTab(demoTab(), " Tab Test ", 'showcase/tab.py')

2
demo/gittk.py

@ -49,7 +49,7 @@ class GitTTK(ttk.TTkAppTemplate):
self._tableCommit = ttk.TTkFancyTable(selectColor=ttk.TTkColor.bg('#882200'))
self._diffText:ttk.TTkTextEditView = ttk.TTkTextEdit(readOnly=False)
self._fileList:ttk.TTkListWidget = ttk.TTkList()
self._fileList:ttk.TTkList = ttk.TTkList()
self._logView = ttk.TTkLogViewer()
w,h = self.size()

0
demo/showcase/list.py → demo/showcase/list_.py

5
docs/MDNotes/beta.checklist.md

@ -0,0 +1,5 @@
# Checklist before moving to Beta
[ ] - Check all the :TODO comments
[ ] - Check all the deprecated mentions
[ ] - rework _TTkAbstractListItem

1
libs/pyTermTk/TermTk/TTkAbstract/__init__.py

@ -2,3 +2,4 @@ from .abstractscrollview import *
from .abstractscrollarea import *
from .abstractitemmodel import *
from .abstracttablemodel import *
from .abstract_list_item import *

58
libs/pyTermTk/TermTk/TTkAbstract/abstract_list_item.py

@ -0,0 +1,58 @@
# MIT License
#
# Copyright (c) 2026 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.
__all__ =['_TTkAbstractListItem']
from typing import Any
from TermTk.TTkCore.signal import pyTTkSignal
from TermTk.TTkCore.string import TTkString
class _TTkAbstractListItem():
'''_TTkAbstractListItem:
.. note::
This is the future abstract base interface for list items.
This class defines the minimal interface that list items must implement.
In a future version, :py:class:`TTkAbstractListItem` will be converted to
inherit from this abstract interface, requiring custom implementations to
provide these methods if they don't use the default :py:class:`TTkListItem`.
Currently used as an internal marker for the planned architecture migration.
'''
__slots__ = ('dataChanged')
def __init__(self):
self.dataChanged = pyTTkSignal()
def data(self) -> Any:
'''
Returns the user data associated with this item.
:return: The custom data object
:rtype: Any
'''
raise NotImplementedError
def toTTkString(self) -> TTkString:
return TTkString(str(self.data()))

10
libs/pyTermTk/TermTk/TTkCore/color.py

@ -425,6 +425,8 @@ class TTkColor:
# self | other
def __or__(self, other) -> TTkColor:
if self is other:
return self
c = self.copy()
c._clean = False
return other + c
@ -547,6 +549,8 @@ class _TTkColor_mod(TTkColor):
# self | other
def __or__(self, other) -> TTkColor:
if self is other:
return self
c = self.copy()
c._clean = False
return other + c
@ -560,7 +564,7 @@ class _TTkColor_mod(TTkColor):
clean = self._clean
fg = other._fg or self._fg
bg = other._bg or self._bg
mod = self._mod + otherMod
mod = self._mod | otherMod
colorMod = other._colorMod or self._colorMod
return _TTkColor_mod(
fg=fg, bg=bg, mod=mod,
@ -642,6 +646,8 @@ class _TTkColor_mod_link(_TTkColor_mod):
# self | other
def __or__(self, other) -> TTkColor:
if self is other:
return self
c = self.copy()
c._clean = False
return other + c
@ -656,7 +662,7 @@ class _TTkColor_mod_link(_TTkColor_mod):
clean = self._clean
fg = other._fg or self._fg
bg = other._bg or self._bg
mod = self._mod + otherMod
mod = self._mod | otherMod
link:str = self._link or otherLink
colorMod = other._colorMod or self._colorMod
return _TTkColor_mod_link(

13
libs/pyTermTk/TermTk/TTkUiTools/properties/list_.py

@ -22,28 +22,27 @@
__all__ = ['TTkListProperties']
from typing import Dict
from typing import Dict, Any
from TermTk.TTkCore.constant import TTkK
from TermTk.TTkWidgets.list_ import TTkList
from TermTk.TTkWidgets.listwidget import TTkListWidget, TTkAbstractListItem
from TermTk.TTkWidgets.listwidget import TTkAbstractListItem
TTkListProperties:Dict[str,Dict] = {
TTkListProperties:Dict[str,Dict[str,Any]] = {
'properties' : {
'Selection Mode' : {
'init': {'name':'selectionMode', 'type':'singleflag',
'flags':{
'Single Seelction' : TTkK.SingleSelection,
'Single Selection' : TTkK.SingleSelection,
'Multi Selection' : TTkK.MultiSelection,
}},
'get': {'cb':lambda w: w.selectionMode(), 'type':'singleflag',
'flags':{
'Single Seelction' : TTkK.SingleSelection,
'Single Selection' : TTkK.SingleSelection,
'Multi Selection' : TTkK.MultiSelection,
}},
'set': {'cb':lambda w,v: w.setSelectionMode(v), 'type':'singleflag',
'flags':{
'Single Seelction' : TTkK.SingleSelection,
'Single Selection' : TTkK.SingleSelection,
'Multi Selection' : TTkK.MultiSelection,
}}},
'DnD Mode' : {

4
libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/table_edit_proxy.py

@ -314,7 +314,7 @@ class _ListBaseProxy(TTkResizableFrame, TTkTableProxyEditWidget):
''' Handle item click event from the list
:param item: The clicked list item
:type item: TTkAbstractListItem
:type item: :py:class:`TTkAbstractListItem`
'''
self.dataChanged.emit(self._value.factory(value=item.data(), items=self._items))
@ -372,7 +372,7 @@ class _BoolListProxy(_ListBaseProxy):
''' Handle boolean item selection
:param item: The clicked list item (True or False)
:type item: TTkAbstractListItem
:type item: :py:class:`TTkAbstractListItem`
'''
self.dataChanged.emit(item.data())

1
libs/pyTermTk/TermTk/TTkWidgets/__init__.py

@ -21,6 +21,7 @@ from .label import *
from .lineedit import *
from .list_ import *
from .listwidget import *
from .listwidget_item import *
from .menubar import *
from .menu import *
from .radiobutton import *

479
libs/pyTermTk/TermTk/TTkWidgets/listwidget.py

@ -20,173 +20,27 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
from __future__ import annotations
__all__ = ['TTkAbstractListItem', 'TTkListWidget', 'TTkAbstractListItemType']
from dataclasses import dataclass
from typing import Union, Optional, List, Any
from typing import Optional, List, Any, Tuple
from TermTk.TTkCore.constant import TTkK
from TermTk.TTkCore.log import TTkLog
from TermTk.TTkCore.signal import pyTTkSlot, pyTTkSignal
from TermTk.TTkCore.color import TTkColor
from TermTk.TTkCore.canvas import TTkCanvas
from TermTk.TTkCore.string import TTkString,TTkStringType
from TermTk.TTkCore.string import TTkString
from TermTk.TTkCore.TTkTerm.inputkey import TTkKeyEvent
from TermTk.TTkCore.TTkTerm.inputmouse import TTkMouseEvent
from TermTk.TTkGui.drag import TTkDrag, TTkDnDEvent
from TermTk.TTkWidgets.widget import TTkWidget
from TermTk.TTkAbstract.abstractscrollview import TTkAbstractScrollView
class TTkAbstractListItem(TTkWidget):
'''TTkAbstractListItem:
Base class for items in a :py:class:`TTkListWidget`.
This widget represents a single selectable item that can be highlighted,
selected, and clicked. It supports custom styling for different states
(default, highlighted, selected, hover, disabled).
::
Normal Item Default state
Highlighted Item Highlighted (navigation)
Selected Item Selected by user
'''
classStyle = TTkWidget.classStyle | {
'default': {'color': TTkColor.RST},
'highlighted': {'color': TTkColor.bg('#008855')+TTkColor.UNDERLINE},
'hover': {'color': TTkColor.bg('#0088FF')},
'selected': {'color': TTkColor.bg('#0055FF')},
'clicked': {'color': TTkColor.fg('#FFFF00')},
'disabled': {'color': TTkColor.fg('#888888')},
}
__slots__ = ('_text', '_selected', '_highlighted', '_data',
'_lowerText', '_quickVisible',
'listItemClicked')
def __init__(self, *, text:TTkStringType='', data=None, **kwargs) -> None:
'''
:param text: The display text for this item, defaults to ''
:type text: str or :py:class:`TTkString`, optional
:param data: Optional user data associated with this item, defaults to None
:type data: Any, optional
'''
self.listItemClicked = pyTTkSignal(TTkAbstractListItem)
'''
This signal is emitted when the item is clicked.
:param item: The item that was clicked
:type item: :py:class:`TTkAbstractListItem`
'''
self._selected = False
self._highlighted = False
if isinstance(text,str):
self._text = TTkString(text)
else:
self._text = text
self._lowerText = str(self._text).lower()
self._quickVisible = True
self._data = data
super().__init__(**kwargs)
self.setFocusPolicy(TTkK.ParentFocus)
def text(self) -> TTkString:
'''
Returns the item's display text.
:return: The text displayed by this item
:rtype: :py:class:`TTkString`
'''
return self._text
def setText(self, text: str) -> None:
'''
Sets the item's display text.
:param text: The new text to display
:type text: str or :py:class:`TTkString`
'''
self._text = TTkString(text)
self._lowerText = str(self._text).lower()
self.update()
def data(self) -> Any:
'''
Returns the user data associated with this item.
:return: The custom data object
:rtype: Any
'''
return self._data
def setData(self, data: Any) -> None:
'''
Sets the user data associated with this item.
:param data: The custom data object to store
:type data: Any
'''
if self._data == data: return
self._data = data
self.update()
def mousePressEvent(self, evt: TTkMouseEvent) -> bool:
self.listItemClicked.emit(self)
return True
def _setSelected(self, selected: bool) -> None:
'''
Internal method to set the selected state.
:param selected: True to select, False to deselect
:type selected: bool
'''
if self._selected == selected: return
self._selected = selected
self._highlighted = not selected
self.update()
def _setHighlighted(self, highlighted: bool) -> None:
'''
Internal method to set the highlighted state.
:param highlighted: True to highlight, False to unhighlight
:type highlighted: bool
'''
if self._highlighted == highlighted: return
self._highlighted = highlighted
self.update()
def geometry(self):
if self._quickVisible:
return super().geometry()
else:
return 0,0,0,0
def paintEvent(self, canvas: TTkCanvas) -> None:
color = (style:=self.currentStyle())['color']
if self._highlighted:
color = color+self.style()['highlighted']['color']
if self._selected:
color = color+self.style()['selected']['color']
if style==self.style()['hover']:
color = color+self.style()['hover']['color']
w = self.width()
canvas.drawTTkString(pos=(0,0), width=w, color=color ,text=self._text)
TTkAbstractListItemType = Union[TTkAbstractListItem, Any]
from TermTk.TTkAbstract.abstract_list_item import _TTkAbstractListItem
from TermTk.TTkWidgets.listwidget_item import TTkListItem, TTkAbstractListItemType, TTkAbstractListItem
class TTkListWidget(TTkAbstractScrollView):
'''TTkListWidget:
@ -257,6 +111,18 @@ class TTkListWidget(TTkAbstractScrollView):
- Signals for item selection and search events
'''
classStyle = {
'default': {
'color': TTkColor.RST,
'highlighted': TTkColor.bg("#004433"),
'hovered': TTkColor.bg('#0088FF'),
'selected': TTkColor.bg('#0055FF'),
'clicked': TTkColor.fg('#FFFF00'),
'disabled': TTkColor.fg('#888888'),
'searchColor': TTkColor.fg("#FFFF00")+TTkColor.UNDERLINE,
}
}
@property
def itemClicked(self) -> pyTTkSignal:
'''
@ -287,26 +153,25 @@ class TTkListWidget(TTkAbstractScrollView):
'''
return self._searchModified
classStyle = {
'default':{'searchColor': TTkColor.fg("#FFFF00") + TTkColor.UNDERLINE}}
@dataclass(frozen=True)
class _DropListData:
widget: TTkAbstractScrollView
items: list
widget: TTkListWidget
items: List[TTkAbstractListItem]
__slots__ = ('_selectedItems', '_selectionMode',
'_highlighted', '_items', '_filteredItems',
'_hovered', '_highlighted', '_items', '_filteredItems',
'_dragPos', '_dndMode',
'_searchText', '_showSearch',
# Signals
'_itemClicked', '_textClicked', '_searchModified')
_items:List[TTkAbstractListItem]
_showSearch:bool
_highlighted:Optional[TTkAbstractListItem]
_dragPos:Optional[Tuple[int,int]]
_items:List[TTkAbstractListItem]
_selectedItems:List[TTkAbstractListItem]
_filteredItems:List[TTkAbstractListItem]
_highlighted:Optional[TTkAbstractListItem]
_hovered:Optional[TTkAbstractListItem]
def __init__(self, *,
items:List[TTkAbstractListItemType]=[],
@ -335,6 +200,7 @@ class TTkListWidget(TTkAbstractScrollView):
self._items = []
self._filteredItems = self._items
self._highlighted = None
self._hovered = None
self._dragPos = None
self._dndMode = dragDropMode
self._searchText:str = ''
@ -342,57 +208,34 @@ class TTkListWidget(TTkAbstractScrollView):
# Init Super
super().__init__(**kwargs)
self.addItemsAt(items=items, pos=0)
self.viewChanged.connect(self._viewChangedHandler)
self.setFocusPolicy(TTkK.ClickFocus | TTkK.TabFocus)
self.searchModified.connect(self._searchModifiedHandler)
@pyTTkSlot()
def _viewChangedHandler(self):
x,y = self.getViewOffsets()
self.layout().setOffset(-x,-y)
@pyTTkSlot(TTkAbstractListItem)
def _labelSelectedHandler(self, label:TTkAbstractListItem):
if self._selectionMode == TTkK.SingleSelection:
for item in self._selectedItems:
item._setSelected(False)
item._setHighlighted(False)
self._selectedItems = [label]
label._setSelected(True)
elif self._selectionMode == TTkK.MultiSelection:
for item in self._selectedItems:
item._setHighlighted(False)
label._setSelected(not label._selected)
if label._selected:
self._selectedItems.append(label)
else:
self._selectedItems.remove(label)
if self._highlighted:
self._highlighted._setHighlighted(False)
label._setHighlighted(True)
self._highlighted = label
self.itemClicked.emit(label)
self.textClicked.emit(label.text())
@pyTTkSlot(str)
def _searchModifiedHandler(self) -> None:
if self._showSearch and self._searchText:
self.setPadding(1,0,0,0)
else:
self.setPadding(0,0,0,0)
if self._searchText:
text = self._searchText.lower()
self._filteredItems = [i for i in self._items if text in i._lowerText]
for item in self._items:
item._quickVisible = text in item._lowerText
else:
self._filteredItems = self._items
for item in self._items:
item._quickVisible = True
item.setVisible(True)
self.viewChanged.emit()
self.update()
@pyTTkSlot()
def _itemChangedHandler(self):
self.viewChanged.emit()
def viewFullAreaSize(self) -> Tuple[int,int]:
''' Return the full area size including padding
self._placeItems()
:return: the (width, height) of the full area
:rtype: tuple[int,int]
'''
width = 0
height = len(self._filteredItems) + ( 1 if self._showSearch and self._searchText else 0 )
if self._filteredItems:
width = max(_i.toTTkString().termWidth() for _i in self._filteredItems)
return width, height
def search(self) -> str:
'''
@ -503,16 +346,6 @@ class TTkListWidget(TTkAbstractScrollView):
'''
return self._filteredItems
def resizeEvent(self, w:int, h:int) -> None:
maxw = 0
for item in self.layout().children():
maxw = max(maxw,item.minimumWidth())
maxw = max(self.width(),maxw)
for item in self.layout().children():
x,y,_,h = item.geometry()
item.setGeometry(x,y,maxw,h)
TTkAbstractScrollView.resizeEvent(self, w, h)
def addItem(self, item:TTkAbstractListItemType, data:Any=None) -> None:
'''
Appends a single item to the end of the list.
@ -533,19 +366,6 @@ class TTkListWidget(TTkAbstractScrollView):
'''
self.addItemsAt(items=items, pos=len(self._items))
def _placeItems(self) -> None:
'''
Internal method to position items in the layout.
'''
minw = self.width()
for item in self._items:
if item in self._filteredItems:
minw = max(minw,item.minimumWidth())
for y,item in enumerate(self._filteredItems):
item.setGeometry(0,y,minw,1)
self.viewChanged.emit()
self.update()
def addItemAt(self, item:TTkAbstractListItemType, pos:int, data:Any=None) -> None:
'''
Inserts a single item at the specified position.
@ -558,7 +378,7 @@ class TTkListWidget(TTkAbstractScrollView):
:type data: Any, optional
'''
if isinstance(item, str) or isinstance(item, TTkString):
item = TTkAbstractListItem(text=item, data=data)
item = TTkListItem(text=item, data=data)
self.addItemsAt([item],pos)
def addItemsAt(self, items:List[TTkAbstractListItemType], pos:int) -> None:
@ -570,22 +390,21 @@ class TTkListWidget(TTkAbstractScrollView):
:param pos: The index position to insert at
:type pos: int
'''
items = [
_i if isinstance(_i, TTkAbstractListItem)
else TTkAbstractListItem(
list_items = [
_i if isinstance(_i, _TTkAbstractListItem)
else TTkListItem(
text=TTkString(_i if isinstance(_i,TTkString) else str(_i)),
data=_i)
for _i in items
]
for item in items:
if not issubclass(type(item),TTkAbstractListItem):
for item in list_items:
if not isinstance(item,_TTkAbstractListItem):
TTkLog.error(f"{item=} is not an TTkAbstractListItem")
return
for item in items:
item.listItemClicked.connect(self._labelSelectedHandler)
self._items[pos:pos] = items
self.layout().addWidgets(items)
self._placeItems()
for item in list_items:
item.dataChanged.connect(self._itemChangedHandler)
self._items[pos:pos] = list_items
self._searchModifiedHandler()
def indexOf(self, item:TTkAbstractListItemType) -> int:
'''
@ -596,7 +415,7 @@ class TTkListWidget(TTkAbstractScrollView):
:return: The index of the item, or -1 if not found
:rtype: int
'''
if isinstance(item, TTkAbstractListItem):
if isinstance(item, _TTkAbstractListItem):
return self._items.index(item)
for i, it in enumerate(self._items):
if it.data() == item or it.text() == item:
@ -627,7 +446,7 @@ class TTkListWidget(TTkAbstractScrollView):
to = max(min(to,len(self._items)-1),0)
# Swap
self._items[to] , self._items[fr] = self._items[fr] , self._items[to]
self._placeItems()
self._searchModifiedHandler()
def removeItem(self, item:TTkAbstractListItem) -> None:
'''
@ -647,15 +466,13 @@ class TTkListWidget(TTkAbstractScrollView):
'''
self.layout().removeWidgets(items)
for item in items.copy():
item.listItemClicked.disconnect(self._labelSelectedHandler)
item._setSelected(False)
item._setHighlighted(False)
item.dataChanged.disconnect(self._itemChangedHandler)
self._items.remove(item)
if item in self._selectedItems:
self._selectedItems.remove(item)
if item == self._highlighted:
if item is self._highlighted:
self._highlighted = None
self._placeItems()
self._searchModifiedHandler()
def removeAt(self, pos:int) -> None:
'''
@ -684,7 +501,21 @@ class TTkListWidget(TTkAbstractScrollView):
:param item: The item to select
:type item: :py:class:`TTkAbstractListItem`
'''
item.listItemClicked.emit(item)
if self._selectionMode is TTkK.SelectionMode.MultiSelection:
if item not in self._selectedItems:
self._selectedItems.append(item)
else:
self._selectedItems = [item]
self._itemClicked.emit(item)
self._textClicked.emit(item.text())
def _itemTriggered(self, item:TTkAbstractListItem) -> None:
if item in self._selectedItems:
index = self._selectedItems.index(item)
self._selectedItems.pop(index)
self.update()
else:
self.setCurrentItem(item)
def _moveToHighlighted(self) -> None:
'''
@ -698,13 +529,49 @@ class TTkListWidget(TTkAbstractScrollView):
elif index <= offy:
self.viewMoveTo(offx, index)
def _to_list_coordinates(self, pos:Tuple[int,int]) -> Tuple[int,int]:
x,y = pos
ox,oy = self.getViewOffsets()
if self._showSearch and self._searchText:
y-=1
return (x+ox, y+oy)
def leaveEvent(self, evt):
self._hovered = None
self.update()
return True
def mouseMoveEvent(self, evt:TTkMouseEvent) -> bool:
x,y = self._to_list_coordinates(pos=(evt.x,evt.y))
self._hovered = None
if 0<y<len(self._filteredItems):
self._hovered = self._filteredItems[y]
self.update()
return True
def mousePressEvent(self, evt:TTkMouseEvent) -> bool:
x,y = self._to_list_coordinates(pos=(evt.x,evt.y))
if 0<y<len(self._filteredItems):
self._highlighted = self._filteredItems[y]
self.update()
return True
def mouseReleaseEvent(self, evt:TTkMouseEvent):
if self._highlighted:
self._itemTriggered(self._highlighted)
self.update()
return True
def mouseDragEvent(self, evt:TTkMouseEvent) -> bool:
if not(self._dndMode & TTkK.DragDropMode.AllowDrag):
return False
if not (items:=self._selectedItems.copy()):
items = []
if self._selectionMode is TTkK.SelectionMode.MultiSelection:
items = self._selectedItems.copy()
if self._highlighted and self._highlighted not in items:
items.append(self._highlighted)
if not items:
return True
drag = TTkDrag()
data =TTkListWidget._DropListData(widget=self,items=items)
@ -734,9 +601,11 @@ class TTkListWidget(TTkAbstractScrollView):
return False
def dragMoveEvent(self, evt:TTkDnDEvent) -> bool:
offx,offy = self.getViewOffsets()
y=min(evt.y+offy,len(self._items))
self._dragPos = (offx+evt.x, y)
if not(self._dndMode & TTkK.DragDropMode.AllowDrop):
return False
x,y = self._to_list_coordinates(pos=(evt.x,evt.y))
y=max(0,min(y,len(self._items)))
self._dragPos = (x,y)
self.update()
return True
@ -749,42 +618,40 @@ class TTkListWidget(TTkAbstractScrollView):
if not(self._dndMode & TTkK.DragDropMode.AllowDrop):
return False
self._dragPos = None
if not issubclass(type(evt.data()) ,TTkListWidget._DropListData):
data = evt.data()
if not isinstance(data ,TTkListWidget._DropListData):
return False
t,b,l,r = self.getPadding()
offx,offy = self.getViewOffsets()
wid = evt.data().widget
items = evt.data().items
if wid and items:
wid.removeItems(items)
wid._searchModifiedHandler()
for it in items:
it.setCurrentStyle(it.style()['default'])
yPos = offy+evt.y-t
if self._filteredItems:
if yPos < 0:
yPos = 0
elif yPos > len(self._filteredItems):
yPos = len(self._items)
elif yPos == len(self._filteredItems):
filteredItemAt = self._filteredItems[-1]
yPos = self._items.index(filteredItemAt)+1
else:
filteredItemAt = self._filteredItems[yPos]
yPos = self._items.index(filteredItemAt)
else:
yPos = 0
self.addItemsAt(items,yPos)
self._searchModifiedHandler()
return True
return False
x,y = self._to_list_coordinates(pos=(evt.x,evt.y))
wid = data.widget
items = data.items
if not (wid and items):
return False
wid.removeItems(items)
wid._searchModifiedHandler()
if y <= 0:
y = 0
elif y > len(self._filteredItems):
y = len(self._items)
elif y == len(self._filteredItems):
filteredItemAt = self._filteredItems[-1]
y = self._items.index(filteredItemAt)+1
else:
filteredItemAt = self._filteredItems[y]
y = self._items.index(filteredItemAt)
self.addItemsAt(items,y)
self._searchModifiedHandler()
return True
def keyEvent(self, evt:TTkKeyEvent) -> bool:
# if not self._highlighted: return False
if ( not self._searchText and evt.type == TTkK.Character and evt.key==" " ) or \
( evt.type == TTkK.SpecialKey and evt.key == TTkK.Key_Enter ):
if self._highlighted:
self._highlighted.listItemClicked.emit(self._highlighted)
self._itemTriggered(self._highlighted)
elif evt.type == TTkK.Character:
# Add this char to the search text
@ -809,7 +676,6 @@ class TTkListWidget(TTkAbstractScrollView):
# Handle the arrow/movement keys
index = 0
if self._highlighted:
self._highlighted._setHighlighted(False)
if self._highlighted not in self._filteredItems:
self._highlighted = self._filteredItems[0]
index = self._filteredItems.index(self._highlighted)
@ -837,45 +703,58 @@ class TTkListWidget(TTkAbstractScrollView):
self.update()
self.searchModified.emit(self._searchText)
self._highlighted = self._filteredItems[index]
self._highlighted._setHighlighted(True)
self._moveToHighlighted()
self.update()
else:
return False
return True
def focusInEvent(self):
if not self._items: return
if not self._highlighted:
self._highlighted = self._items[0]
self._highlighted._setHighlighted(True)
if not self._highlighted and self._filteredItems:
self._highlighted = self._filteredItems[0]
def focusOutEvent(self):
if self._highlighted:
self._highlighted._setHighlighted(False)
self._dragPos = None
# Stupid hack to paint on top of the child widgets
def paintChildCanvas(self):
super().paintChildCanvas()
def paintEvent(self, canvas: TTkCanvas) -> None:
w,h = self.size()
ox,oy = self.getViewOffsets()
search_offset = 0
style = self.currentStyle()
color_base = style['color']
color_search = style['searchColor']
color_hovered = style['hovered']
color_selected = style['selected']
color_highlighted = style['highlighted']
if self._showSearch and self._searchText:
search_offset = 1
if len(self._searchText) > w:
text = TTkString("",TTkColor.BG_BLUE+TTkColor.FG_CYAN)+TTkString(self._searchText[-w+1:], color_search)
else:
text = TTkString(self._searchText, color_search)
canvas.drawTTkString(pos=(0,0),text=text, color=color_search, width=w)
for i,item in enumerate(self._filteredItems[oy:oy+h-search_offset], search_offset):
if item in self._selectedItems:
item_color = color_selected
elif item is self._highlighted:
item_color = color_highlighted
elif item is self._hovered:
item_color = color_hovered
else:
item_color = color_base
canvas.drawTTkString(text=item.toTTkString(), pos=(-ox,i), width=w+ox, color=item_color)
# Draw the drop visual feedback
if self._dragPos:
canvas = self.getCanvas()
x,y = self._dragPos
y+=search_offset
offx,offy = self.getViewOffsets()
p1 = (0,y-offy-1)
p2 = (0,y-offy)
canvas.drawText(pos=p1,text="╙─╼", color=TTkColor.fg("#FFFF00")+TTkColor.bg("#008855"))
canvas.drawText(pos=p2,text="╓─╼", color=TTkColor.fg("#FFFF00")+TTkColor.bg("#008855"))
def paintEvent(self, canvas: TTkCanvas) -> None:
if self._showSearch and self._searchText:
w,h = self.size()
color = self.currentStyle()['searchColor']
if len(self._searchText) > w:
text = TTkString("",TTkColor.BG_BLUE+TTkColor.FG_CYAN)+TTkString(self._searchText[-w+1:],color)
else:
text = TTkString(self._searchText,color)
canvas.drawTTkString(pos=(0,0),text=text, color=color, width=w)

136
libs/pyTermTk/TermTk/TTkWidgets/listwidget_item.py

@ -0,0 +1,136 @@
# 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.
__all__ = ['TTkAbstractListItem', 'TTkListItem', 'TTkAbstractListItemType']
from typing import Union, Any
from TermTk.TTkCore.log import TTkLog
from TermTk.TTkCore.string import TTkString,TTkStringType
from TermTk.TTkAbstract.abstract_list_item import _TTkAbstractListItem
class TTkListItem(_TTkAbstractListItem):
'''TTkAbstractListItem:
Base class for items in a :py:class:`TTkListWidget`.
This widget represents a single selectable item that can be highlighted,
selected, and clicked. It supports custom styling for different states
(default, highlighted, selected, hover, disabled).
::
Normal Item Default state
Highlighted Item Highlighted (navigation)
Selected Item Selected by user
'''
__slots__ = ('_text', '_data', '_lowerText')
_text: TTkString
_data: Any
def __init__(self, *, text:TTkStringType='', data:Any=None) -> None:
super().__init__()
if isinstance(text,str):
self._text = TTkString(text)
elif isinstance(text, TTkString):
self._text = text
else:
self._text = TTkString(str(text))
self._lowerText = str(self._text).lower()
self._data = data
def text(self) -> TTkString:
'''
Returns the item's display text.
:return: The text displayed by this item
:rtype: :py:class:`TTkString`
'''
return self._text
def setText(self, text: str) -> None:
'''
Sets the item's display text.
:param text: The new text to display
:type text: str or :py:class:`TTkString`
'''
self._text = TTkString(text)
self._lowerText = str(self._text).lower()
self.dataChanged.emit()
def data(self) -> Any:
'''
Returns the user data associated with this item.
:return: The custom data object
:rtype: Any
'''
return self._data
def setData(self, data: Any) -> None:
'''
Sets the user data associated with this item.
:param data: The custom data object to store
:type data: Any
'''
if self._data == data: return
self._data = data
self.dataChanged.emit()
def toTTkString(self):
return self._text
class TTkAbstractListItem(TTkListItem):
'''TTkAbstractListItem:
.. warning::
**DEPRECATED:** This concrete implementation is deprecated. In a future version,
this class will become an abstract base class requiring implementation.
Use :py:class:`TTkListItem` for the default implementation.
This class currently provides a concrete implementation for backward compatibility.
In future versions, it will be converted to an abstract interface that requires
implementation of specific methods if you want custom behavior beyond the default
:py:class:`TTkListItem` implementation.
For new code:
- Use :py:class:`TTkListItem` directly for the default list item implementation
- Inherit from :py:class:`TTkListItem` for custom list items
- Avoid using :py:class:`TTkAbstractListItem` directly
.. deprecated:: 0.50.0
Direct instantiation deprecated. Use :py:class:`TTkListItem` instead.
'''
def __init__(self, **kwargs):
TTkLog.warn('TTkAbstractListItem direct usage is deprecated. This will become an abstract class in a future version. Use TTkListItem instead.')
super().__init__(**kwargs)
TTkAbstractListItemType = Union[TTkAbstractListItem, Any]

637
tests/pytest/widgets/test_list.py

@ -0,0 +1,637 @@
# MIT License
#
# Copyright (c) 2026 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 sys, os
sys.path.append(os.path.join(sys.path[0],'../../../libs/pyTermTk'))
import TermTk as ttk
# ============================================================================
# TTkListWidget Tests
# ============================================================================
def test_listwidget_add_items():
'''
Test adding items to TTkListWidget using addItem() and addItems().
Verifies that items are added correctly to the list.
'''
listWidget = ttk.TTkListWidget()
assert len(listWidget.items()) == 0
# Add single item
listWidget.addItem("Item 1")
assert len(listWidget.items()) == 1
assert listWidget.itemAt(0).text() == "Item 1"
# Add item with data
listWidget.addItem("Item 2", data="data2")
assert len(listWidget.items()) == 2
assert listWidget.itemAt(1).text() == "Item 2"
assert listWidget.itemAt(1).data() == "data2"
# Add multiple items
listWidget.addItems(["Item 3", "Item 4", "Item 5"])
assert len(listWidget.items()) == 5
assert listWidget.itemAt(2).text() == "Item 3"
assert listWidget.itemAt(3).text() == "Item 4"
assert listWidget.itemAt(4).text() == "Item 5"
def test_listwidget_add_items_at():
'''
Test inserting items at specific positions using addItemAt() and addItemsAt().
'''
listWidget = ttk.TTkListWidget()
listWidget.addItems(["Item 0", "Item 1", "Item 2"])
assert len(listWidget.items()) == 3
# Insert at beginning
listWidget.addItemAt("Item -1", 0)
assert len(listWidget.items()) == 4
assert listWidget.itemAt(0).text() == "Item -1"
assert listWidget.itemAt(1).text() == "Item 0"
# Insert in middle
listWidget.addItemAt("Item 0.5", 2)
assert len(listWidget.items()) == 5
assert listWidget.itemAt(2).text() == "Item 0.5"
# Insert at end
listWidget.addItemsAt(["Item 3", "Item 4"], 5)
assert len(listWidget.items()) == 7
assert listWidget.itemAt(5).text() == "Item 3"
assert listWidget.itemAt(6).text() == "Item 4"
def test_listwidget_remove_items():
'''
Test removing items from TTkListWidget.
'''
listWidget = ttk.TTkListWidget()
listWidget.addItems(["Item 0", "Item 1", "Item 2", "Item 3"])
assert len(listWidget.items()) == 4
# Remove by index
listWidget.removeAt(1)
assert len(listWidget.items()) == 3
assert listWidget.itemAt(0).text() == "Item 0"
assert listWidget.itemAt(1).text() == "Item 2"
assert listWidget.itemAt(2).text() == "Item 3"
# Remove by item
item = listWidget.itemAt(1)
listWidget.removeItem(item)
assert len(listWidget.items()) == 2
assert listWidget.itemAt(0).text() == "Item 0"
assert listWidget.itemAt(1).text() == "Item 3"
# Remove multiple items
items = [listWidget.itemAt(0), listWidget.itemAt(1)]
listWidget.removeItems(items)
assert len(listWidget.items()) == 0
def test_listwidget_indexOf_itemAt():
'''
Test finding items by index and value.
'''
listWidget = ttk.TTkListWidget()
listWidget.addItem("Item 0")
listWidget.addItem("Item 1", data="data1")
listWidget.addItem("Item 2")
# Test itemAt
item0 = listWidget.itemAt(0)
assert item0.text() == "Item 0"
item1 = listWidget.itemAt(1)
assert item1.text() == "Item 1"
assert item1.data() == "data1"
# Test indexOf with item object
assert listWidget.indexOf(item0) == 0
assert listWidget.indexOf(item1) == 1
# Test indexOf with text
assert listWidget.indexOf("Item 0") == 0
assert listWidget.indexOf("Item 2") == 2
# Test indexOf with data
assert listWidget.indexOf("data1") == 1
# Test indexOf with non-existent item
assert listWidget.indexOf("Not Found") == -1
def test_listwidget_move_item():
'''
Test moving items within the list.
'''
listWidget = ttk.TTkListWidget()
listWidget.addItems(["Item 0", "Item 1", "Item 2", "Item 3"])
# Move item from position 0 to position 2
listWidget.moveItem(0, 2)
assert listWidget.itemAt(0).text() == "Item 2"
assert listWidget.itemAt(1).text() == "Item 1"
assert listWidget.itemAt(2).text() == "Item 0"
assert listWidget.itemAt(3).text() == "Item 3"
# Move item from position 3 to position 1
listWidget.moveItem(3, 1)
assert listWidget.itemAt(0).text() == "Item 2"
assert listWidget.itemAt(1).text() == "Item 3"
assert listWidget.itemAt(2).text() == "Item 0"
assert listWidget.itemAt(3).text() == "Item 1"
def test_listwidget_selection_mode():
'''
Test single and multi-selection modes.
'''
# Test SingleSelection mode
listWidget = ttk.TTkListWidget(selectionMode=ttk.TTkK.SingleSelection)
assert listWidget.selectionMode() == ttk.TTkK.SingleSelection
listWidget.addItems(["Item 0", "Item 1", "Item 2"])
item0 = listWidget.itemAt(0)
item1 = listWidget.itemAt(1)
item2 = listWidget.itemAt(2)
listWidget.setCurrentItem(item0)
assert len(listWidget.selectedItems()) == 1
assert item0 in listWidget.selectedItems()
listWidget.setCurrentItem(item1)
assert len(listWidget.selectedItems()) == 1
assert item1 in listWidget.selectedItems()
assert item0 not in listWidget.selectedItems()
# Test MultiSelection mode
listWidget2 = ttk.TTkListWidget(selectionMode=ttk.TTkK.MultiSelection)
assert listWidget2.selectionMode() == ttk.TTkK.MultiSelection
listWidget2.addItems(["Item 0", "Item 1", "Item 2"])
item0_2 = listWidget2.itemAt(0)
item1_2 = listWidget2.itemAt(1)
item2_2 = listWidget2.itemAt(2)
listWidget2.setCurrentItem(item0_2)
assert len(listWidget2.selectedItems()) == 1
assert item0_2 in listWidget2.selectedItems()
listWidget2.setCurrentItem(item1_2)
assert len(listWidget2.selectedItems()) == 2
assert item0_2 in listWidget2.selectedItems()
assert item1_2 in listWidget2.selectedItems()
listWidget2.setCurrentItem(item2_2)
assert len(listWidget2.selectedItems()) == 3
def test_listwidget_change_selection_mode():
'''
Test dynamically changing selection mode.
'''
listWidget = ttk.TTkListWidget(selectionMode=ttk.TTkK.SingleSelection)
assert listWidget.selectionMode() == ttk.TTkK.SingleSelection
listWidget.setSelectionMode(ttk.TTkK.MultiSelection)
assert listWidget.selectionMode() == ttk.TTkK.MultiSelection
listWidget.setSelectionMode(ttk.TTkK.SingleSelection)
assert listWidget.selectionMode() == ttk.TTkK.SingleSelection
def test_listwidget_selected_items():
'''
Test getting selected items and labels.
'''
listWidget = ttk.TTkListWidget(selectionMode=ttk.TTkK.MultiSelection)
listWidget.addItems(["Item 0", "Item 1", "Item 2"])
# Initially no selection
assert len(listWidget.selectedItems()) == 0
assert len(listWidget.selectedLabels()) == 0
# Select items
item0 = listWidget.itemAt(0)
item1 = listWidget.itemAt(1)
listWidget.setCurrentItem(item0)
assert len(listWidget.selectedItems()) == 1
assert listWidget.selectedLabels() == ["Item 0"]
listWidget.setCurrentItem(item1)
assert len(listWidget.selectedItems()) == 2
assert set([str(_l) for _l in listWidget.selectedLabels()]) == {"Item 0", "Item 1"}
def test_listwidget_set_current_row():
'''
Test setting current row by index.
'''
listWidget = ttk.TTkListWidget()
listWidget.addItems(["Item 0", "Item 1", "Item 2"])
signal_received = []
listWidget.itemClicked.connect(lambda item: signal_received.append(item))
listWidget.setCurrentRow(0)
assert len(signal_received) == 1
assert signal_received[0].text() == "Item 0"
listWidget.setCurrentRow(2)
assert len(signal_received) == 2
assert signal_received[1].text() == "Item 2"
def test_listwidget_signals():
'''
Test that itemClicked and textClicked signals are emitted correctly.
'''
listWidget = ttk.TTkListWidget()
listWidget.addItems(["Item 0", "Item 1", "Item 2"])
item_clicked = []
text_clicked = []
listWidget.itemClicked.connect(lambda item: item_clicked.append(item))
listWidget.textClicked.connect(lambda text: text_clicked.append(text))
item0 = listWidget.itemAt(0)
item1 = listWidget.itemAt(1)
listWidget.setCurrentItem(item0)
assert len(item_clicked) == 1
assert item_clicked[0] == item0
assert len(text_clicked) == 1
assert text_clicked[0] == "Item 0"
listWidget.setCurrentItem(item1)
assert len(item_clicked) == 2
assert item_clicked[1] == item1
assert len(text_clicked) == 2
assert text_clicked[1] == "Item 1"
def test_listwidget_search():
'''
Test search functionality.
'''
listWidget = ttk.TTkListWidget()
listWidget.addItems(["Apple", "Banana", "Cherry", "Date", "Elderberry"])
assert listWidget.search() == ""
assert len(listWidget.filteredItems()) == 5
# Search for items containing 'e'
listWidget.setSearch("e")
assert listWidget.search() == "e"
filtered = listWidget.filteredItems()
assert len(filtered) == 4 # Apple, Cherry, Date, Elderberry
# Search for items containing 'an'
listWidget.setSearch("an")
assert listWidget.search() == "an"
filtered = listWidget.filteredItems()
assert len(filtered) == 1 # Banana
assert filtered[0].text() == "Banana"
# Clear search
listWidget.setSearch("")
assert listWidget.search() == ""
assert len(listWidget.filteredItems()) == 5
def test_listwidget_search_signal():
'''
Test that searchModified signal is emitted when search text changes.
'''
listWidget = ttk.TTkListWidget()
listWidget.addItems(["Item 0", "Item 1", "Item 2"])
search_texts = []
listWidget.searchModified.connect(lambda text: search_texts.append(text))
listWidget.setSearch("test")
assert len(search_texts) == 1
assert search_texts[0] == "test"
listWidget.setSearch("test2")
assert len(search_texts) == 2
assert search_texts[1] == "test2"
listWidget.setSearch("")
assert len(search_texts) == 3
assert search_texts[2] == ""
def test_listwidget_search_visibility():
'''
Test search visibility setting.
'''
listWidget1 = ttk.TTkListWidget(showSearch=True)
assert listWidget1.searchVisibility() is True
listWidget2 = ttk.TTkListWidget(showSearch=False)
assert listWidget2.searchVisibility() is False
# Test changing visibility
listWidget1.setSearchVisibility(False)
assert listWidget1.searchVisibility() is False
listWidget2.setSearchVisibility(True)
assert listWidget2.searchVisibility() is True
def test_listwidget_dragdrop_mode():
'''
Test drag-drop mode settings.
'''
listWidget = ttk.TTkListWidget()
assert listWidget.dragDropMode() == ttk.TTkK.DragDropMode.NoDragDrop
listWidget.setDragDropMode(ttk.TTkK.DragDropMode.AllowDrag)
assert listWidget.dragDropMode() == ttk.TTkK.DragDropMode.AllowDrag
listWidget.setDragDropMode(ttk.TTkK.DragDropMode.AllowDrop)
assert listWidget.dragDropMode() == ttk.TTkK.DragDropMode.AllowDrop
listWidget.setDragDropMode(ttk.TTkK.DragDropMode.AllowDragDrop)
assert listWidget.dragDropMode() == ttk.TTkK.DragDropMode.AllowDragDrop
def test_listwidget_list_item():
'''
Test TTkListItem functionality.
'''
item = ttk.TTkListItem(text="Test Item", data="test_data")
assert item.text() == "Test Item"
assert item.data() == "test_data"
# Test setText
item.setText("New Text")
assert item.text() == "New Text"
# Test setData
item.setData("new_data")
assert item.data() == "new_data"
def test_listwidget_list_item_signal():
'''
Test that TTkListItem.dataChanged signal is emitted when item changes.
'''
item = ttk.TTkListItem(text="Test Item", data="test_data")
signal_count = []
item.dataChanged.connect(lambda: signal_count.append(1))
item.setText("New Text")
assert len(signal_count) == 1
item.setData("new_data")
assert len(signal_count) == 2
# Setting same data shouldn't emit signal
item.setData("new_data")
assert len(signal_count) == 2
def test_listwidget_with_ttk_string():
'''
Test adding items with TTkString objects.
'''
listWidget = ttk.TTkListWidget()
colored_text = ttk.TTkString("Colored Item", ttk.TTkColor.fg("#FF0000"))
listWidget.addItem(colored_text)
assert len(listWidget.items()) == 1
assert listWidget.itemAt(0).text() == "Colored Item"
def test_listwidget_custom_list_items():
'''
Test adding custom TTkListItem objects.
'''
listWidget = ttk.TTkListWidget()
item1 = ttk.TTkListItem(text="Custom Item 1", data={"id": 1})
item2 = ttk.TTkListItem(text="Custom Item 2", data={"id": 2})
listWidget.addItem(item1)
listWidget.addItem(item2)
assert len(listWidget.items()) == 2
assert listWidget.itemAt(0).data() == {"id": 1}
assert listWidget.itemAt(1).data() == {"id": 2}
def test_listwidget_items_vs_filtered_items():
'''
Test the difference between items() and filteredItems() with search.
'''
listWidget = ttk.TTkListWidget()
listWidget.addItems(["Apple", "Apricot", "Banana", "Cherry"])
# Without search, both should be the same
assert len(listWidget.items()) == 4
assert len(listWidget.filteredItems()) == 4
# With search, items() should return all, filteredItems() should return matches
listWidget.setSearch("Ap")
assert len(listWidget.items()) == 4
assert len(listWidget.filteredItems()) == 2
assert listWidget.filteredItems()[0].text() == "Apple"
assert listWidget.filteredItems()[1].text() == "Apricot"
def test_listwidget_empty_list():
'''
Test operations on empty list.
'''
listWidget = ttk.TTkListWidget()
assert len(listWidget.items()) == 0
assert len(listWidget.filteredItems()) == 0
assert len(listWidget.selectedItems()) == 0
assert len(listWidget.selectedLabels()) == 0
assert listWidget.search() == ""
# ============================================================================
# TTkList (ScrollArea wrapper) Tests
# ============================================================================
def test_list_add_items():
'''
Test adding items to TTkList (which wraps TTkListWidget in a scroll area).
'''
ttkList = ttk.TTkList()
assert len(ttkList.items()) == 0
ttkList.addItem("Item 1")
assert len(ttkList.items()) == 1
ttkList.addItems(["Item 2", "Item 3"])
assert len(ttkList.items()) == 3
def test_list_initial_items():
'''
Test creating TTkList with initial items.
'''
ttkList = ttk.TTkList(items=["Item 0", "Item 1", "Item 2"])
assert len(ttkList.items()) == 3
assert ttkList.itemAt(0).text() == "Item 0"
assert ttkList.itemAt(1).text() == "Item 1"
assert ttkList.itemAt(2).text() == "Item 2"
def test_list_selection_mode():
'''
Test TTkList with different selection modes.
'''
list1 = ttk.TTkList(selectionMode=ttk.TTkK.SingleSelection)
assert list1.selectionMode() == ttk.TTkK.SingleSelection
list2 = ttk.TTkList(selectionMode=ttk.TTkK.MultiSelection)
assert list2.selectionMode() == ttk.TTkK.MultiSelection
def test_list_signals_forwarding():
'''
Test that signals are properly forwarded from TTkListWidget to TTkList.
'''
ttkList = ttk.TTkList()
ttkList.addItems(["Item 0", "Item 1", "Item 2"])
item_clicked = []
text_clicked = []
ttkList.itemClicked.connect(lambda item: item_clicked.append(item))
ttkList.textClicked.connect(lambda text: text_clicked.append(text))
ttkList.setCurrentRow(0)
assert len(item_clicked) == 1
assert len(text_clicked) == 1
assert text_clicked[0] == "Item 0"
ttkList.setCurrentRow(1)
assert len(item_clicked) == 2
assert len(text_clicked) == 2
assert text_clicked[1] == "Item 1"
def test_list_dragdrop_mode():
'''
Test TTkList with drag-drop mode.
'''
ttkList = ttk.TTkList(dragDropMode=ttk.TTkK.DragDropMode.AllowDragDrop)
assert ttkList.dragDropMode() == ttk.TTkK.DragDropMode.AllowDragDrop
def test_list_search():
'''
Test search functionality in TTkList.
'''
ttkList = ttk.TTkList()
ttkList.addItems(["Apple", "Banana", "Cherry"])
assert ttkList.search() == ""
ttkList.setSearch("an")
assert ttkList.search() == "an"
assert len(ttkList.items()) == 3 # All items still exist
# Note: filteredItems() is a TTkListWidget method, not forwarded to TTkList
def test_list_show_search():
'''
Test showSearch parameter in TTkList.
'''
list1 = ttk.TTkList(showSearch=True)
assert list1.searchVisibility() is True
list2 = ttk.TTkList(showSearch=False)
assert list2.searchVisibility() is False
def test_list_remove_selected():
'''
Test removing selected items from list.
'''
ttkList = ttk.TTkList(selectionMode=ttk.TTkK.MultiSelection)
ttkList.addItems(["Item 0", "Item 1", "Item 2", "Item 3"])
item0 = ttkList.itemAt(0)
item2 = ttkList.itemAt(2)
ttkList.setCurrentItem(item0)
ttkList.setCurrentItem(item2)
assert len(ttkList.selectedItems()) == 2
ttkList.removeItems(ttkList.selectedItems().copy())
assert len(ttkList.items()) == 2
assert ttkList.itemAt(0).text() == "Item 1"
assert ttkList.itemAt(1).text() == "Item 3"
assert len(ttkList.selectedItems()) == 0
def test_list_move_items_between_lists():
'''
Test moving items from one list to another (typical use case).
'''
list1 = ttk.TTkList(selectionMode=ttk.TTkK.MultiSelection)
list2 = ttk.TTkList()
list1.addItems(["Item 0", "Item 1", "Item 2"])
item0 = list1.itemAt(0)
item1 = list1.itemAt(1)
list1.setCurrentItem(item0)
list1.setCurrentItem(item1)
selected = list1.selectedItems().copy()
assert len(selected) == 2
# Move selected items to list2
list1.removeItems(selected)
for item in selected:
list2.addItem(item)
assert len(list1.items()) == 1
assert len(list2.items()) == 2
assert list2.itemAt(0).text() == "Item 0"
assert list2.itemAt(1).text() == "Item 1"
def test_list_mixed_data_types():
'''
Test adding items with mixed data types (strings, integers, custom objects).
'''
ttkList = ttk.TTkList()
ttkList.addItem(123)
ttkList.addItem(456.789)
ttkList.addItem("String Item")
ttkList.addItem(None)
assert len(ttkList.items()) == 4
assert ttkList.itemAt(0).text() == "123"
assert ttkList.itemAt(1).text() == "456.789"
assert ttkList.itemAt(2).text() == "String Item"
assert ttkList.itemAt(3).text() == "None"
Loading…
Cancel
Save