Source code for TermTk.TTkWidgets.listwidget
# 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.
from __future__ import annotations
__all__ = ['TTkAbstractListItem', 'TTkListWidget', 'TTkAbstractListItemType']
from dataclasses import dataclass
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
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
from TermTk.TTkAbstract.abstract_list_item import _TTkAbstractListItem
from TermTk.TTkWidgets.listwidget_item import TTkListItem, TTkAbstractListItemType, TTkAbstractListItem
[docs]
class TTkListWidget(TTkAbstractScrollView):
'''TTkListWidget:
A widget that displays a scrollable list of selectable items with optional search functionality.
This widget supports single/multiple selection modes, drag-and-drop reordering,
keyboard navigation, and incremental search. Items can be strings or custom
:py:class:`TTkAbstractListItem` widgets.
::
╔════════════════════════════════╗
║Search: te_ ▲║ ← Search bar (optional)
║S-0) --Zero-3- officia ▓║
║S-1) ad ipsum ┊║
║S-2) irure nisi ┊║ ← Scrollable items
║S-3) minim --Zero-3- ┊║
║S-4) ea sunt ┊║
║S-5) qui mollit ┊║
║S-6) magna sunt ┊║
║S-7) sunt officia ▼║
╚════════════════════════════════╝
Demo: `list.py <https://github.com/ceccopierangiolieugenio/pyTermTk/blob/main/demo/showcase/list.py>`_
(`online <https://ceccopierangiolieugenio.github.io/pyTermTk-Docs/sandbox/sandbox.html?filePath=demo/showcase/list.py>`__)
.. code-block:: python
import TermTk as ttk
root = ttk.TTk(layout=ttk.TTkGridLayout(), mouseTrack=True)
# Simple string list
l1 = ttk.TTkList(parent=root, items=[123, 456, 789])
id1 = l1.indexOf(456)
l1.setCurrentRow(id1)
# List with many items (scrollable)
ttk.TTkList(parent=root, items=[f"Item 0x{i:03X}" for i in range(100)])
# Multi-selection list with drag-drop
ttkList = ttk.TTkList(
parent=root,
selectionMode=ttk.TTkK.SelectionMode.MultiSelection,
dragDropMode=ttk.TTkK.DragDropMode.AllowDragDrop
)
ttkList.addItems([f"Item 0x{i:04X}" for i in range(50)])
root.layout().addWidget(ttk.TTkLogViewer(),1,0,1,3)
# Handle selection
@ttk.pyTTkSlot(str)
def on_item_clicked(text):
ttk.TTkLog.debug(f"Clicked: {text}")
ttkList.textClicked.connect(on_item_clicked)
root.mainloop()
**Features:**
- Single or multiple selection modes
- Keyboard navigation (arrows, page up/down, home/end)
- Incremental search by typing
- Drag-and-drop reordering (optional)
- Custom item widgets via :py:class:`TTkAbstractListItem`
- 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:
'''
This signal is emitted whenever an item is clicked.
:param item: the item selected
:type item: :py:class:`TTkAbstractListItem`
'''
return self._itemClicked
@property
def textClicked(self) -> pyTTkSignal:
'''
This signal is emitted whenever an item is clicked.
:param text: the text of the item selected
:type text: str
'''
return self._textClicked
@property
def searchModified(self) -> pyTTkSignal:
'''
This signal is emitted whenever the search text is modified.
:param text: the search text
:type text: str
'''
return self._searchModified
@dataclass(frozen=True)
class _DropListData:
widget: TTkListWidget
items: List[TTkAbstractListItem]
__slots__ = ('_selectedItems', '_selectionMode',
'_hovered', '_highlighted', '_items', '_filteredItems',
'_dragPos', '_dndMode',
'_searchText', '_showSearch',
# Signals
'_itemClicked', '_textClicked', '_searchModified')
_showSearch:bool
_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]=[],
selectionMode:TTkK.SelectionMode=TTkK.SelectionMode.SingleSelection,
dragDropMode:TTkK.DragDropMode=TTkK.DragDropMode.NoDragDrop,
showSearch:bool=True,
**kwargs) -> None:
'''
:param items: Initial list of items (Any or :py:class:`TTkAbstractListItem` objects), defaults to []
:type items: list, optional
:param selectionMode: Selection behavior (:py:class:`TTkK.SelectionMode.SingleSelection` or :py:class:`TTkK.SelectionMode.MultiSelection`), defaults to :py:class:`TTkK.SelectionMode.SingleSelection`
:type selectionMode: :py:class:`TTkK.SelectionMode`, optional
:param dragDropMode: Drag and drop behavior (NoDragDrop, InternalMove, DragOnly, DropOnly, DragDrop), defaults to NoDragDrop
:type dragDropMode: :py:class:`TTkK.DragDropMode`, optional
:param showSearch: Whether to show the search hint at the top, defaults to True
:type showSearch: bool, optional
'''
# Signals
self._itemClicked = pyTTkSignal(TTkAbstractListItem)
self._textClicked = pyTTkSignal(str)
self._searchModified = pyTTkSignal(str)
# Default Class Specific Values
self._selectionMode = selectionMode
self._selectedItems = []
self._items = []
self._filteredItems = self._items
self._highlighted = None
self._hovered = None
self._dragPos = None
self._dndMode = dragDropMode
self._searchText:str = ''
self._showSearch:bool = showSearch
# Init Super
super().__init__(**kwargs)
self.addItemsAt(items=items, pos=0)
self.setFocusPolicy(TTkK.ClickFocus | TTkK.TabFocus)
self.searchModified.connect(self._searchModifiedHandler)
@pyTTkSlot(str)
def _searchModifiedHandler(self) -> None:
if self._searchText:
text = self._searchText.lower()
self._filteredItems = [i for i in self._items if text in i._lowerText]
else:
self._filteredItems = self._items
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
: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
[docs]
def search(self) -> str:
'''
Returns the current search text.
:return: The active search filter string
:rtype: str
'''
return self._searchText
[docs]
def setSearch(self, search:str) -> None:
'''
Sets the search text to filter items.
:param search: The search string to filter by
:type search: str
'''
self._searchText = search
self.searchModified.emit(search)
[docs]
def searchVisibility(self) -> bool:
'''
Returns whether the search hint is visible.
:return: True if search hint is shown
:rtype: bool
'''
return self._showSearch
[docs]
def setSearchVisibility(self, visibility:bool) -> None:
'''
Sets the visibility of the search hint at the top of the list.
:param visibility: True to show search hint, False to hide
:type visibility: bool
'''
self._showSearch = visibility
[docs]
def dragDropMode(self) -> TTkK.DragDropMode:
'''
Returns the current drag-drop mode.
:return: The drag-drop behavior setting
:rtype: :py:class:`TTkK.DragDropMode`
'''
return self._dndMode
[docs]
def setDragDropMode(self, dndMode:TTkK.DragDropMode) -> None:
'''
Sets the drag-drop mode for this list.
:param dndMode: The new drag-drop behavior
:type dndMode: :py:class:`TTkK.DragDropMode`
'''
self._dndMode = dndMode
[docs]
def selectionMode(self) -> TTkK.SelectionMode:
'''
Returns the current selection mode.
:return: The selection behavior setting
:rtype: :py:class:`TTkK.SelectionMode`
'''
return self._selectionMode
[docs]
def setSelectionMode(self, mode:TTkK.SelectionMode) -> None:
'''
Sets the selection mode for this list.
:param mode: The new selection behavior (SingleSelection or MultiSelection)
:type mode: :py:class:`TTkK.SelectionMode`
'''
self._selectionMode = mode
[docs]
def selectedItems(self) -> List[TTkAbstractListItem]:
'''
Returns the list of currently selected items.
:return: List of selected item widgets
:rtype: list[:py:class:`TTkAbstractListItem`]
'''
return self._selectedItems
[docs]
def selectedLabels(self) -> List[str]:
'''
Returns the text of all selected items.
:return: List of selected item texts
:rtype: list[str]
'''
return [i.text() for i in self._selectedItems]
[docs]
def items(self) -> List[TTkAbstractListItem]:
'''
Returns all items in the list.
:return: Complete list of items
:rtype: list[:py:class:`TTkAbstractListItem`]
'''
return self._items
[docs]
def filteredItems(self) -> List[TTkAbstractListItem]:
'''
Returns items matching the current search filter.
:return: Filtered list of visible items
:rtype: list[:py:class:`TTkAbstractListItem`]
'''
return self._filteredItems
[docs]
def addItem(self, item:TTkAbstractListItemType, data:Any=None) -> None:
'''
Appends a single item to the end of the list.
:param item: The item to add (string or :py:class:`TTkAbstractListItem`)
:type item: str or :py:class:`TTkAbstractListItem`
:param data: Optional user data to associate with the item
:type data: Any, optional
'''
self.addItemAt(item, len(self._items), data)
[docs]
def addItems(self, items:List[TTkAbstractListItemType]) -> None:
'''
Appends multiple items to the end of the list.
:param items: List of items to add (strings or :py:class:`TTkAbstractListItem` objects)
:type items: list
'''
self.addItemsAt(items=items, pos=len(self._items))
[docs]
def addItemAt(self, item:TTkAbstractListItemType, pos:int, data:Any=None) -> None:
'''
Inserts a single item at the specified position.
:param item: The item to insert (string or :py:class:`TTkAbstractListItem`)
:type item: str or :py:class:`TTkAbstractListItem`
:param pos: The index position to insert at
:type pos: int
:param data: Optional user data to associate with the item
:type data: Any, optional
'''
if isinstance(item, str) or isinstance(item, TTkString):
item = TTkListItem(text=item, data=data)
self.addItemsAt([item],pos)
[docs]
def addItemsAt(self, items:List[TTkAbstractListItemType], pos:int) -> None:
'''
Inserts multiple items at the specified position.
:param items: List of items to insert (strings or :py:class:`TTkAbstractListItem` objects)
:type items: list
:param pos: The index position to insert at
:type pos: int
'''
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 list_items:
if not isinstance(item,_TTkAbstractListItem):
TTkLog.error(f"{item=} is not an TTkAbstractListItem")
return
for item in list_items:
item.dataChanged.connect(self._itemChangedHandler)
self._items[pos:pos] = list_items
self._searchModifiedHandler()
[docs]
def indexOf(self, item:TTkAbstractListItemType) -> int:
'''
Returns the index of the given item.
:param item: The item to find
:type item: :py:class:`TTkAbstractListItem` or the data or the text to be searched
:return: The index of the item, or -1 if not found
:rtype: int
'''
if isinstance(item, _TTkAbstractListItem):
return self._items.index(item)
for i, it in enumerate(self._items):
if it.data() == item or it.text() == item:
return i
return -1
[docs]
def itemAt(self, pos:int) -> TTkAbstractListItem:
'''
Returns the item at the specified index.
:param pos: The index position
:type pos: int
:return: The item at that position
:rtype: :py:class:`TTkAbstractListItem`
'''
return self._items[pos]
[docs]
def moveItem(self, fr:int, to:int) -> None:
'''
Moves an item from one position to another.
:param fr: The source index
:type fr: int
:param to: The destination index
:type to: int
'''
fr = max(min(fr,len(self._items)-1),0)
to = max(min(to,len(self._items)-1),0)
# Swap
self._items[to] , self._items[fr] = self._items[fr] , self._items[to]
self._searchModifiedHandler()
[docs]
def removeItem(self, item:TTkAbstractListItem) -> None:
'''
Removes a single item from the list.
:param item: The item to remove
:type item: :py:class:`TTkAbstractListItem`
'''
self.removeItems([item])
[docs]
def removeItems(self, items:List[TTkAbstractListItem]) -> None:
'''
Removes multiple items from the list.
:param items: List of items to remove
:type items: list[:py:class:`TTkAbstractListItem`]
'''
for item in items.copy():
item.dataChanged.disconnect(self._itemChangedHandler)
self._items.remove(item)
if item in self._selectedItems:
self._selectedItems.remove(item)
if item is self._highlighted:
self._highlighted = None
self._searchModifiedHandler()
[docs]
def removeAt(self, pos:int) -> None:
'''
Removes the item at the specified index.
:param pos: The index of the item to remove
:type pos: int
'''
self.removeItem(self._items[pos])
[docs]
def setCurrentRow(self, row:int) -> None:
'''
Selects the item at the specified row.
:param row: The row index to select
:type row: int
'''
if row<len(self._items):
item = self._items[row]
self.setCurrentItem(item)
[docs]
def setCurrentItem(self, item:TTkAbstractListItem) -> None:
'''
Selects the specified item and emits the itemClicked signal.
:param item: The item to select
:type item: :py:class:`TTkAbstractListItem`
'''
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:
'''
Internal method to scroll the view to show the highlighted item.
'''
index = self._items.index(self._highlighted)
h = self.height()
offx,offy = self.getViewOffsets()
if index >= h+offy-1:
self.viewMoveTo(offx, index-h+1)
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
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)
h = min(3,ih:=len(items)) + 2 + (1 if ih>3 else 0)
w = min(20,iw:=max([it.text().termWidth() for it in items[:3]])) + 2
pm = TTkCanvas(width=w,height=h)
for y,it in enumerate(items[:3],1):
txt = it.text()
if txt.termWidth() < 20:
pm.drawText(pos=(1,y), text=it.text())
else:
pm.drawText(pos=(1,y), text=it.text(), width=17)
pm.drawText(pos=(18,y), text='...')
if ih>3:
pm.drawText(pos=(1,4), text='...')
pm.drawBox(pos=(0,0),size=(w,h))
drag.setPixmap(pm)
drag.setData(data)
drag.exec()
return True
def dragEnterEvent(self, evt:TTkDnDEvent) -> bool:
if not(self._dndMode & TTkK.DragDropMode.AllowDrop):
return False
if issubclass(type(evt.data()),TTkListWidget._DropListData):
return self.dragMoveEvent(evt)
return False
def dragMoveEvent(self, evt:TTkDnDEvent) -> bool:
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
def dragLeaveEvent(self, evt:TTkDnDEvent) -> bool:
self._dragPos = None
self.update()
return True
def dropEvent(self, evt:TTkDnDEvent) -> bool:
if not(self._dndMode & TTkK.DragDropMode.AllowDrop):
return False
self._dragPos = None
data = evt.data()
if not isinstance(data ,TTkListWidget._DropListData):
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 ):
self._itemTriggered(self._highlighted)
elif evt.type == TTkK.Character:
# Add this char to the search text
self._searchText += evt.key
self.update()
self.searchModified.emit(self._searchText)
elif ( evt.type == TTkK.SpecialKey and
evt.key == TTkK.Key_Tab ):
return False
elif ( evt.type == TTkK.SpecialKey and
evt.key in (TTkK.Key_Delete,TTkK.Key_Backspace) and
self._searchText ):
# Handle the backspace to remove the last char from the search text
self._searchText = self._searchText[:-1]
self.update()
self.searchModified.emit(self._searchText)
elif ( evt.type == TTkK.SpecialKey and
self._filteredItems):
# Handle the arrow/movement keys
index = 0
if self._highlighted:
if self._highlighted not in self._filteredItems:
self._highlighted = self._filteredItems[0]
index = self._filteredItems.index(self._highlighted)
offx,offy = self.getViewOffsets()
h = self.height()
if evt.key == TTkK.Key_Up:
index = max(0, index-1)
elif evt.key == TTkK.Key_Down:
index = min(len(self._filteredItems)-1, index+1)
elif evt.key == TTkK.Key_PageUp:
index = max(0, index-h)
elif evt.key == TTkK.Key_PageDown:
index = min(len(self._filteredItems)-1, index+h)
elif evt.key == TTkK.Key_Right:
self.viewMoveTo(offx+1, offy)
elif evt.key == TTkK.Key_Left:
self.viewMoveTo(offx-1, offy)
elif evt.key == TTkK.Key_Home:
self.viewMoveTo(0, offy)
elif evt.key == TTkK.Key_End:
self.viewMoveTo(0x10000, offy)
elif evt.key in (TTkK.Key_Delete,TTkK.Key_Backspace):
if self._searchText:
self._searchText = self._searchText[:-1]
self.update()
self.searchModified.emit(self._searchText)
self._highlighted = self._filteredItems[index]
self._moveToHighlighted()
self.update()
else:
return False
return True
def focusInEvent(self):
if not self._items: return
if not self._highlighted and self._filteredItems:
self._highlighted = self._filteredItems[0]
def focusOutEvent(self):
self._dragPos = None
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:
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"))