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.
397 lines
15 KiB
397 lines
15 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''' |
|
|
|
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', '_header', '_columnsPos', '_cache', |
|
'_selectedId', '_selected', '_separatorSelected', '_mouseDelta', |
|
'_sortColumn', '_sortOrder', |
|
# 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, *args, **kwargs): |
|
# 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__(*args, **kwargs) |
|
self._selected = None |
|
self._selectedId = None |
|
self._separatorSelected = None |
|
self._header = kwargs.get('header',[]) |
|
self._columnsPos = [] |
|
self._cache = [] |
|
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): |
|
x,y = self.getViewOffsets() |
|
self.layout().setOffset(-x,-y) |
|
|
|
# Overridden function |
|
def viewFullAreaSize(self) -> (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) -> (int, int): |
|
# TTkLog.debug(f"{self.size()=}") |
|
return self.size() |
|
|
|
def clear(self): |
|
# 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): |
|
self._rootItem.addChild(item) |
|
item.setTreeItemParent(self) |
|
self._refreshCache() |
|
self.viewChanged.emit() |
|
self.update() |
|
|
|
def addTopLevelItems(self, items): |
|
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): |
|
self._rootItem.takeChild(index) |
|
self._refreshCache() |
|
self.viewChanged.emit() |
|
self.update() |
|
|
|
def topLevelItem(self, index): |
|
return self._rootItem.child(index) |
|
|
|
def indexOfTopLevelItem(self, item): |
|
return self._rootItem.indexOfChild(item) |
|
|
|
def selectedItems(self): |
|
if self._selected: |
|
return [self._selected] |
|
return None |
|
|
|
def setHeaderLabels(self, labels): |
|
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 sortColumn(self): |
|
'''Returns the column used to sort the contents of the widget.''' |
|
return self._sortColumn |
|
|
|
def sortItems(self, col, order): |
|
'''Sorts the items in the widget in the specified order by the values in the given column.''' |
|
self._sortColumn = col |
|
self._sortOrder = order |
|
self._rootItem.sortChildren(col, order) |
|
|
|
def mouseDoubleClickEvent(self, evt): |
|
x,y = evt.x, evt.y |
|
ox, oy = self.getViewOffsets() |
|
y += oy-1 |
|
x += ox |
|
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): |
|
self._separatorSelected = None |
|
|
|
def mousePressEvent(self, evt): |
|
x,y = evt.x, evt.y |
|
ox, oy = self.getViewOffsets() |
|
|
|
x += ox |
|
|
|
self._separatorSelected = None |
|
self._mouseDelta = (evt.x, evt.y) |
|
|
|
# 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): |
|
''' |
|
:: |
|
|
|
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.update() |
|
self.viewChanged.emit() |
|
return True |
|
return False |
|
|
|
def _alignWidgets(self): |
|
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): |
|
''' 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): |
|
_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): |
|
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 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))
|
|
|