diff --git a/apps/tlogg/tlogg/app/highlighters.py b/apps/tlogg/tlogg/app/highlighters.py index 501a50ff..54681a34 100644 --- a/apps/tlogg/tlogg/app/highlighters.py +++ b/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 diff --git a/demo/demo.py b/demo/demo.py index fb781181..53c020ba 100755 --- a/demo/demo.py +++ b/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') diff --git a/demo/gittk.py b/demo/gittk.py index 4c0ffebf..a64f1498 100755 --- a/demo/gittk.py +++ b/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() diff --git a/demo/showcase/list.py b/demo/showcase/list_.py similarity index 100% rename from demo/showcase/list.py rename to demo/showcase/list_.py diff --git a/docs/MDNotes/beta.checklist.md b/docs/MDNotes/beta.checklist.md new file mode 100644 index 00000000..2fe3b277 --- /dev/null +++ b/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 \ No newline at end of file diff --git a/libs/pyTermTk/TermTk/TTkAbstract/__init__.py b/libs/pyTermTk/TermTk/TTkAbstract/__init__.py index 480cdc90..6a77f783 100644 --- a/libs/pyTermTk/TermTk/TTkAbstract/__init__.py +++ b/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 * diff --git a/libs/pyTermTk/TermTk/TTkAbstract/abstract_list_item.py b/libs/pyTermTk/TermTk/TTkAbstract/abstract_list_item.py new file mode 100644 index 00000000..0ed3ce0f --- /dev/null +++ b/libs/pyTermTk/TermTk/TTkAbstract/abstract_list_item.py @@ -0,0 +1,58 @@ +# MIT License +# +# Copyright (c) 2026 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. + +__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())) \ No newline at end of file diff --git a/libs/pyTermTk/TermTk/TTkCore/color.py b/libs/pyTermTk/TermTk/TTkCore/color.py index 2d494017..6f18c3c6 100644 --- a/libs/pyTermTk/TermTk/TTkCore/color.py +++ b/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( diff --git a/libs/pyTermTk/TermTk/TTkUiTools/properties/list_.py b/libs/pyTermTk/TermTk/TTkUiTools/properties/list_.py index ba993838..26bb9227 100644 --- a/libs/pyTermTk/TermTk/TTkUiTools/properties/list_.py +++ b/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' : { diff --git a/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/table_edit_proxy.py b/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/table_edit_proxy.py index ed01153c..5832d676 100644 --- a/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/table_edit_proxy.py +++ b/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()) diff --git a/libs/pyTermTk/TermTk/TTkWidgets/__init__.py b/libs/pyTermTk/TermTk/TTkWidgets/__init__.py index 771e06d5..d443827b 100644 --- a/libs/pyTermTk/TermTk/TTkWidgets/__init__.py +++ b/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 * diff --git a/libs/pyTermTk/TermTk/TTkWidgets/listwidget.py b/libs/pyTermTk/TermTk/TTkWidgets/listwidget.py index 2fa7e21a..5b702ba2 100644 --- a/libs/pyTermTk/TermTk/TTkWidgets/listwidget.py +++ b/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() - self._placeItems() + def viewFullAreaSize(self) -> Tuple[int,int]: + ''' Return the full area size including padding + + :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: ''' @@ -645,17 +464,14 @@ class TTkListWidget(TTkAbstractScrollView): :param items: List of items to remove :type items: list[:py:class:`TTkAbstractListItem`] ''' - 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 +500,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 +528,48 @@ 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 bool: + x,y = self._to_list_coordinates(pos=(evt.x,evt.y)) + if 0<=y 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 +599,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 +616,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 +674,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 +701,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) - - - diff --git a/libs/pyTermTk/TermTk/TTkWidgets/listwidget_item.py b/libs/pyTermTk/TermTk/TTkWidgets/listwidget_item.py new file mode 100644 index 00000000..3b5a17e4 --- /dev/null +++ b/libs/pyTermTk/TermTk/TTkWidgets/listwidget_item.py @@ -0,0 +1,136 @@ +# 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. + +__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): + '''TTkListItem: + + 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] diff --git a/tests/pytest/widgets/test_list.py b/tests/pytest/widgets/test_list.py new file mode 100644 index 00000000..f264c68e --- /dev/null +++ b/tests/pytest/widgets/test_list.py @@ -0,0 +1,637 @@ +# MIT License +# +# Copyright (c) 2026 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 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"