You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
531 lines
21 KiB
531 lines
21 KiB
# 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__ = ['TTkTreeWidget'] |
|
|
|
from TermTk.TTkCore.cfg import TTkCfg |
|
from TermTk.TTkCore.constant import TTkK |
|
from TermTk.TTkCore.string import TTkString |
|
from TermTk.TTkCore.color import TTkColor |
|
from TermTk.TTkWidgets.TTkModelView.treewidgetitem import TTkTreeWidgetItem |
|
from TermTk.TTkAbstract.abstractscrollview import TTkAbstractScrollView |
|
from TermTk.TTkCore.signal import pyTTkSignal, pyTTkSlot |
|
|
|
from dataclasses import dataclass |
|
|
|
class TTkTreeWidget(TTkAbstractScrollView): |
|
'''TTkTreeWidget |
|
|
|
The :class:`TTkTreeWidget` class is a convenience class that provides a standard tree |
|
widget with a classic item-based interface. |
|
This class is based on TTk's Model/View architecture and uses a default model to hold items, |
|
each of which is a :class:`~TermTk.TTkWidgets.TTkModelView.treewidgetitem.TTkTreeWidgetItem`. |
|
|
|
In its simplest form, a tree widget can be constructed in the following way: |
|
|
|
.. code-block:: python |
|
|
|
tree = ttk.TTkTree(parent=root) |
|
tree.setHeaderLabels(["Column 1", "Column 2", "Column 3"]) |
|
|
|
top = ttk.TTkTreeWidgetItem(["String A", "String B", "String C"]) |
|
|
|
tree.addTopLevelItem(top) |
|
|
|
for i in range(5): |
|
child = ttk.TTkTreeWidgetItem(["Child A" + str(i), "Child B" + str(i), "Child C" + str(i)]) |
|
top.addChild(child) |
|
|
|
Before items can be added to the tree widget, |
|
the number of columns must be set with :meth:`setHeaderLabels`. |
|
This allows each item to have one label. |
|
|
|
The tree can have a header that contains a section for each column in the widget. |
|
It is easiest to set up the labels for each section by supplying a list of strings with :meth:`setHeaderLabels`. |
|
|
|
The items in the tree can be sorted by column according to a predefined sort order. |
|
If sorting is enabled, the user can sort the items by clicking on a column header. |
|
Sorting can be enabled or disabled by calling setSortingEnabled(). |
|
The isSortingEnabled() function indicates whether sorting is enabled. |
|
''' |
|
|
|
itemActivated:pyTTkSignal[TTkTreeWidgetItem, int] |
|
''' |
|
This signal is emitted when the user activates an item by double-clicking |
|
or pressing a special key (e.g., Enter). |
|
|
|
:param item: the item that was clicked. |
|
:type item: :class:`~TermTk.TTkWidgets.TTkModelView.treewidgetitem.TTkTreeWidgetItem` |
|
:param col: the item's column that was clicked. |
|
:type col: int |
|
''' |
|
itemChanged:pyTTkSignal[TTkTreeWidgetItem, int] |
|
''' |
|
This signal is emitted when the contents of the column in the specified item changes. |
|
|
|
:param item: the item reported by this signal |
|
:type item: :class:`~TermTk.TTkWidgets.TTkModelView.treewidgetitem.TTkTreeWidgetItem` |
|
:param col: the item's column |
|
:type col: int |
|
''' |
|
itemClicked:pyTTkSignal[TTkTreeWidgetItem, int] |
|
''' |
|
This signal is emitted when the user clicks inside the widget. |
|
|
|
If no item was clicked, no signal will be emitted. |
|
|
|
:param item: the item that was clicked. |
|
:type item: :class:`~TermTk.TTkWidgets.TTkModelView.treewidgetitem.TTkTreeWidgetItem` |
|
:param col: the item's column that was clicked. |
|
:type col: int |
|
''' |
|
itemDoubleClicked:pyTTkSignal[TTkTreeWidgetItem, int] |
|
''' |
|
This signal is emitted when the user double clicks inside the widget. |
|
|
|
If no item was double clicked, no signal will be emitted. |
|
|
|
:param item: the item that was clicked. |
|
:type item: :class:`~TermTk.TTkWidgets.TTkModelView.treewidgetitem.TTkTreeWidgetItem` |
|
:param col: the item's column that was clicked. |
|
:type col: int |
|
''' |
|
itemExpanded:pyTTkSignal[TTkTreeWidgetItem] |
|
''' |
|
This signal is emitted when the specified item is expanded so that all of its children are displayed. |
|
|
|
:param item: the item reported by this signal |
|
:type item: :class:`~TermTk.TTkWidgets.TTkModelView.treewidgetitem.TTkTreeWidgetItem` |
|
''' |
|
itemCollapsed:pyTTkSignal[TTkTreeWidgetItem] |
|
''' |
|
This signal is emitted when the specified item is collapsed so that none of its children are displayed. |
|
|
|
:param item: the item reported by this signal |
|
:type item: :class:`~TermTk.TTkWidgets.TTkModelView.treewidgetitem.TTkTreeWidgetItem` |
|
''' |
|
|
|
classStyle = { |
|
'default': { |
|
'color': TTkColor.RST, |
|
'lineColor': TTkColor.fg("#444444"), |
|
'headerColor': TTkColor.fg("#ffffff")+TTkColor.bg("#444444")+TTkColor.BOLD, |
|
'selectedColor': TTkColor.fg("#ffff88")+TTkColor.bg("#000066")+TTkColor.BOLD, |
|
'separatorColor': TTkColor.fg("#444444")}, |
|
'disabled': { |
|
'color': TTkColor.fg("#888888"), |
|
'lineColor': TTkColor.fg("#888888"), |
|
'headerColor': TTkColor.fg("#888888"), |
|
'selectedColor': TTkColor.fg("#888888"), |
|
'separatorColor': TTkColor.fg("#888888")}, |
|
} |
|
|
|
__slots__ = ( '_rootItem', '_cache', |
|
'_header', '_columnsPos', |
|
'_selectedId', '_selected', '_separatorSelected', |
|
'_sortColumn', '_sortOrder', '_sortingEnabled', |
|
# Signals |
|
'itemChanged', 'itemClicked', 'itemDoubleClicked', 'itemExpanded', 'itemCollapsed', 'itemActivated' |
|
) |
|
@dataclass(frozen=True) |
|
class _Cache: |
|
item: TTkTreeWidgetItem |
|
level: int |
|
data: list |
|
widgets: list |
|
firstLine: bool |
|
|
|
def __init__(self, *, header=[], sortingEnabled=True, **kwargs) -> None: |
|
# Signals |
|
self.itemActivated = pyTTkSignal(TTkTreeWidgetItem, int) |
|
self.itemChanged = pyTTkSignal(TTkTreeWidgetItem, int) |
|
self.itemClicked = pyTTkSignal(TTkTreeWidgetItem, int) |
|
self.itemDoubleClicked = pyTTkSignal(TTkTreeWidgetItem, int) |
|
self.itemExpanded = pyTTkSignal(TTkTreeWidgetItem) |
|
self.itemCollapsed = pyTTkSignal(TTkTreeWidgetItem) |
|
|
|
super().__init__(**kwargs) |
|
self._selected = None |
|
self._selectedId = None |
|
self._separatorSelected = None |
|
self._header = kwargs.get('header',[]) |
|
self._columnsPos = [] |
|
self._cache = [] |
|
self._sortingEnabled=sortingEnabled |
|
self._sortColumn = -1 |
|
self._sortOrder = TTkK.AscendingOrder |
|
self.setMinimumHeight(1) |
|
self.setFocusPolicy(TTkK.ClickFocus) |
|
self._rootItem = TTkTreeWidgetItem(expanded=True) |
|
self.clear() |
|
self.setPadding(1,0,0,0) |
|
self.viewChanged.connect(self._viewChangedHandler) |
|
|
|
@pyTTkSlot() |
|
def _viewChangedHandler(self) -> None: |
|
x,y = self.getViewOffsets() |
|
self.layout().setOffset(-x,-y) |
|
|
|
# Overridden function |
|
def viewFullAreaSize(self) -> tuple[int, int]: |
|
w = self._columnsPos[-1]+1 if self._columnsPos else 0 |
|
h = self._rootItem.size() |
|
# TTkLog.debug(f"{w=} {h=}") |
|
return w,h |
|
|
|
# Overridden function |
|
def viewDisplayedSize(self) -> tuple[int, int]: |
|
# TTkLog.debug(f"{self.size()=}") |
|
return self.size() |
|
|
|
def clear(self) -> None: |
|
'''clear''' |
|
# Remove all the widgets |
|
for ri in self._rootItem.children(): |
|
ri.setTreeItemParent(None) |
|
if self._rootItem: |
|
self._rootItem.dataChanged.disconnect(self._refreshCache) |
|
self._rootItem = TTkTreeWidgetItem(expanded=True) |
|
self._rootItem.dataChanged.connect(self._refreshCache) |
|
self.sortItems(self._sortColumn, self._sortOrder) |
|
self._refreshCache() |
|
self.viewChanged.emit() |
|
self.update() |
|
|
|
def addTopLevelItem(self, item:TTkTreeWidgetItem) -> None: |
|
'''addTopLevelItem''' |
|
self._rootItem.addChild(item) |
|
item.setTreeItemParent(self) |
|
self._refreshCache() |
|
self.viewChanged.emit() |
|
self.update() |
|
|
|
def addTopLevelItems(self, items:TTkTreeWidgetItem) -> None: |
|
'''addTopLevelItems''' |
|
self._rootItem.addChildren(items) |
|
self._rootItem.setTreeItemParent(self) |
|
#for item in items: |
|
# item.setTreeItemParent(self) |
|
self._refreshCache() |
|
self.viewChanged.emit() |
|
self.update() |
|
|
|
def takeTopLevelItem(self, index) -> None: |
|
'''takeTopLevelItem''' |
|
self._rootItem.takeChild(index) |
|
self._refreshCache() |
|
self.viewChanged.emit() |
|
self.update() |
|
|
|
def topLevelItem(self, index) -> TTkTreeWidgetItem: |
|
'''topLevelItem''' |
|
return self._rootItem.child(index) |
|
|
|
def indexOfTopLevelItem(self, item:TTkTreeWidgetItem) -> int: |
|
'''indexOfTopLevelItem''' |
|
return self._rootItem.indexOfChild(item) |
|
|
|
def selectedItems(self) -> list[TTkTreeWidgetItem]: |
|
'''selectedItems''' |
|
if self._selected: |
|
return [self._selected] |
|
return None |
|
|
|
def setHeaderLabels(self, labels:str) -> None: |
|
'''setHeaderLabels''' |
|
self._header = labels |
|
# Set 20 as default column size |
|
self._columnsPos = [20+x*20 for x in range(len(labels))] |
|
self.viewChanged.emit() |
|
self.update() |
|
|
|
def isSortingEnabled(self) -> bool: |
|
'isSortingEnabled' |
|
return self._sortingEnabled |
|
|
|
def setSortingEnabled(self, enabled) -> None: |
|
'setSortingEnabled' |
|
if enabled != self._sortingEnabled: |
|
self._sortingEnabled = enabled |
|
self.update() |
|
|
|
def sortColumn(self): |
|
'''Returns the column used to sort the contents of the widget.''' |
|
return self._sortColumn |
|
|
|
def sortItems(self, col:int, order:TTkK.SortOrder) -> None: |
|
'''Sorts the items in the widget in the specified order by the values in the given column.''' |
|
if not self._sortingEnabled: return |
|
self._sortColumn = col |
|
self._sortOrder = order |
|
self._rootItem.sortChildren(col, order) |
|
|
|
def columnWidth(self, column:int) -> int: |
|
'''columnWidth''' |
|
if column==0: |
|
return self._columnsPos[column] |
|
else: |
|
return self._columnsPos[column]-self._columnsPos[column-1]-1 |
|
|
|
def setColumnWidth(self, column:int, width: int) -> None: |
|
'''setColumnWidth''' |
|
i = column |
|
newSize = ((1+self._columnsPos[i-1]) if i>0 else 0) + width |
|
oldSize = self._columnsPos[i] |
|
for ii in range(i,len(self._columnsPos)): |
|
self._columnsPos[ii] += newSize-oldSize+1 |
|
self._alignWidgets() |
|
self.viewChanged.emit() |
|
self.update() |
|
|
|
def resizeColumnToContents(self, column:int) -> None: |
|
'''resizeColumnToContents''' |
|
contentSize = max(row.data[column].termWidth() for row in self._cache) |
|
self.setColumnWidth(column, contentSize) |
|
|
|
def mouseDoubleClickEvent(self, evt) -> bool: |
|
x,y = evt.x, evt.y |
|
ox, oy = self.getViewOffsets() |
|
x += ox |
|
|
|
# Handle Header Events |
|
# Doubleclick resize to the content size |
|
if y == 0: |
|
for i, c in enumerate(self._columnsPos): |
|
if x == c: |
|
self.resizeColumnToContents(i) |
|
break |
|
return True |
|
|
|
y += oy-1 |
|
if 0 <= y < len(self._cache): |
|
item = self._cache[y].item |
|
if item.childIndicatorPolicy() == TTkK.DontShowIndicatorWhenChildless and item.children() or \ |
|
item.childIndicatorPolicy() == TTkK.ShowIndicator: |
|
item.setExpanded(not item.isExpanded()) |
|
if item.isExpanded(): |
|
self.itemExpanded.emit(item) |
|
else: |
|
self.itemCollapsed.emit(item) |
|
if self._selected: |
|
self._selected.setSelected(False) |
|
self._selectedId = y |
|
self._selected = item |
|
self._selected.setSelected(True) |
|
col = -1 |
|
for i, c in enumerate(self._columnsPos): |
|
if x < c: |
|
col = i |
|
break |
|
self.itemDoubleClicked.emit(item, col) |
|
self.itemActivated.emit(item, col) |
|
|
|
self.update() |
|
return True |
|
|
|
def focusOutEvent(self) -> None: |
|
self._separatorSelected = None |
|
|
|
def mousePressEvent(self, evt) -> bool: |
|
x,y = evt.x, evt.y |
|
ox, oy = self.getViewOffsets() |
|
x += ox |
|
self._separatorSelected = None |
|
|
|
# Handle Header Events |
|
if y == 0: |
|
for i, c in enumerate(self._columnsPos): |
|
if x == c: |
|
# I-th separator selected |
|
self._separatorSelected = i |
|
self.update() |
|
break |
|
elif x < c: |
|
# I-th header selected |
|
order = not self._sortOrder if self._sortColumn == i else TTkK.AscendingOrder |
|
self.sortItems(i, order) |
|
break |
|
return True |
|
# Handle Tree/Table Events |
|
y += oy-1 |
|
if 0 <= y < len(self._cache): |
|
item = self._cache[y].item |
|
level = self._cache[y].level |
|
# check if the expand button is pressed with +-1 tollerance |
|
if level*2 <= x < level*2+3 and \ |
|
( item.childIndicatorPolicy() == TTkK.DontShowIndicatorWhenChildless and item.children() or |
|
item.childIndicatorPolicy() == TTkK.ShowIndicator ): |
|
item.setExpanded(not item.isExpanded()) |
|
if item.isExpanded(): |
|
self.itemExpanded.emit(item) |
|
else: |
|
self.itemCollapsed.emit(item) |
|
else: |
|
if self._selected: |
|
self._selected.setSelected(False) |
|
self._selectedId = y |
|
self._selected = item |
|
self._selected.setSelected(True) |
|
col = -1 |
|
for i, c in enumerate(self._columnsPos): |
|
if x < c: |
|
col = i |
|
break |
|
self.itemClicked.emit(item, col) |
|
self.update() |
|
return True |
|
|
|
def mouseDragEvent(self, evt) -> bool: |
|
# columnPos (Selected = 2) |
|
# 0 1 2 3 4 |
|
# ----|-------|--------|----------|---| |
|
# Mouse (Drag) Pos |
|
# ^ |
|
# I consider at least 4 char (3+1) as spacing |
|
# Min Selected Pos = (Selected+1) * 4 |
|
if self._separatorSelected is not None: |
|
x,y = evt.x, evt.y |
|
ox, oy = self.getViewOffsets() |
|
y += oy |
|
x += ox |
|
ss = self._separatorSelected |
|
pos = max((ss+1)*4, x) |
|
diff = pos - self._columnsPos[ss] |
|
# Align the previous Separators if pushed |
|
for i in range(ss): |
|
self._columnsPos[i] = min(self._columnsPos[i], pos-(ss-i)*4) |
|
# Align all the other Separators relative to the selection |
|
for i in range(ss, len(self._columnsPos)): |
|
self._columnsPos[i] += diff |
|
self._alignWidgets() |
|
self.viewChanged.emit() |
|
self.update() |
|
return True |
|
return False |
|
|
|
def _alignWidgets(self) -> None: |
|
for y,c in enumerate(self._cache): |
|
if not c.firstLine: |
|
continue |
|
for i,w in enumerate(c.widgets): |
|
if w: |
|
_pos = self._columnsPos[i-1]+1 if i else 3 + c.level*2 |
|
_width = self._columnsPos[i] - _pos |
|
_height = w.height() |
|
w.setGeometry(_pos,y,_width,_height) |
|
w.show() |
|
|
|
@pyTTkSlot() |
|
def _refreshCache(self) -> None: |
|
# I save a representation of the displayed tree in a cache array |
|
# to avoid eccessve recursion over the items and |
|
# identify quickly the nth displayed line to improve the interaction |
|
# |
|
# _cache is an array of TTkTreeWidget._Cache: |
|
# [ item, level, data=[txtCol1, txtCol2, txtCol3, ... ]] |
|
self._cache = [] |
|
def _addToCache(_child, _level:int) -> None: |
|
_data = [] |
|
_widgets = [] |
|
_h =_child.height() |
|
for _il in range(len(self._header)): |
|
_lines = _child.data(_il).split('\n') |
|
if _il==0: |
|
_data0 = [] |
|
for _id in range(_h): |
|
# Trying to define an icon to obtain this results on multiline field |
|
# ▶ Label |
|
# ┊ NewLine 1 |
|
# │ NewLine 2 |
|
# ╽ |
|
if _id == 0: |
|
_icon = " "+_child.icon(_il)+" " |
|
elif _id == _h-1: |
|
_icon = TTkString(" ╽ ", TTkColor.fg("#666666")) |
|
elif _id == 1: |
|
_icon = TTkString(" ┊ ", TTkColor.fg("#666666")) |
|
else: |
|
_icon = TTkString(" │ ", TTkColor.fg("#666666")) |
|
_text = _lines[_id] if _id<len(_lines) else "" |
|
_data0.append(' '*_level+_icon+_text) |
|
_data.append(_data0) |
|
_widgets.append(_child.widget(_il)) |
|
else: |
|
_data.append([TTkString(s) for s in _lines]+[TTkString()]*(_h-len(_lines))) |
|
_widgets.append(_child.widget(_il)) |
|
|
|
for _id in range(_h): |
|
self._cache.append(TTkTreeWidget._Cache( |
|
item = _child, |
|
level = _level, |
|
data = [ dt[_id] for dt in _data], |
|
widgets = _widgets, |
|
firstLine=_id==0)) |
|
if _child.isExpanded(): |
|
for _c in _child.children(): |
|
_addToCache(_c, _level+1) |
|
for c in self._rootItem.children(): |
|
_addToCache(c,0) |
|
self._alignWidgets() |
|
self.update() |
|
self.viewChanged.emit() |
|
|
|
def paintEvent(self, canvas) -> None: |
|
style = self.currentStyle() |
|
|
|
color= style['color'] |
|
lineColor= style['lineColor'] |
|
headerColor= style['headerColor'] |
|
selectedColor= style['selectedColor'] |
|
separatorColor= style['separatorColor'] |
|
|
|
x,y = self.getViewOffsets() |
|
w,h = self.size() |
|
tt = TTkCfg.theme.tree |
|
|
|
# Draw header first: |
|
for i,l in enumerate(self._header): |
|
hx = 0 if i==0 else self._columnsPos[i-1]+1 |
|
hx1 = self._columnsPos[i] |
|
canvas.drawText(pos=(hx-x,0), text=l, width=hx1-hx, color=headerColor) |
|
if self._sortingEnabled and i == self._sortColumn: |
|
s = tt[6] if self._sortOrder == TTkK.AscendingOrder else tt[7] |
|
canvas.drawText(pos=(hx1-x-1,0), text=s, color=headerColor) |
|
# Draw header separators |
|
for sx in self._columnsPos: |
|
canvas.drawChar(pos=(sx-x,0), char=tt[5], color=headerColor) |
|
for sy in range(1,h): |
|
canvas.drawChar(pos=(sx-x,sy), char=tt[4], color=lineColor) |
|
|
|
# Draw cache |
|
for i, c in enumerate(self._cache): |
|
if i-y<0: continue |
|
item = c.item |
|
for il in range(len(self._header)): |
|
lx = 0 if il==0 else self._columnsPos[il-1]+1 |
|
lx1 = self._columnsPos[il] |
|
|
|
text = c.data[il] |
|
if item.isSelected(): |
|
canvas.drawText(pos=(lx-x,i-y+1), text=text.completeColor(selectedColor), width=lx1-lx, alignment=item.textAlignment(il), color=selectedColor) |
|
else: |
|
canvas.drawText(pos=(lx-x,i-y+1), text=text, width=lx1-lx, alignment=item.textAlignment(il))
|
|
|