Browse Source

chore(tree): add hover

pull/583/head
Parodi, Eugenio 🌶 2 months ago
parent
commit
e8a2a11528
  1. 13
      libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/filetree.py
  2. 15
      libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/tree.py
  3. 44
      libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/treewidget.py
  4. 183
      tests/t.ui/test.ui.032.table.11.pyside.hover.py

13
libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/filetree.py

@ -304,6 +304,19 @@ class TTkFileTree(TTkTree):
:rtype: :py:class:`TTkTreeWidgetItem`
'''
return self._fileTreeWidget.invisibleRootItem()
def itemAt(self, pos:int) -> Optional[TTkTreeWidgetItem]:
'''
.. seealso:: this method is forwarded to :py:meth:`TTkFileTreeWidget.itemAt`
Return the item at the vertical position
:param pos: y coordinate
:type pos: int
:return: The item at the (pos) position if available
:rtype: :py:class:`TTkTreeWidgetItem` or None if no item is available
'''
return self._fileTreeWidget.itemAt(pos=pos)
def addTopLevelItem(self, item:TTkTreeWidgetItem) -> None:
'''
.. seealso:: this method is forwarded to :py:meth:`TTkFileTreeWidget.addTopLevelItem`

15
libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/tree.py

@ -48,7 +48,7 @@ class TTkTree(TTkAbstractScrollArea):
'sortColumn', 'sortItems',
'dragDropMode', 'setDragDropMode',
'expandAll', 'collapseAll',
'invisibleRootItem',
'invisibleRootItem', 'itemAt',
# 'appendItem', 'setAlignment', 'setColumnColors', 'setColumnSize', 'setHeader',
'addTopLevelItem', 'addTopLevelItems', 'takeTopLevelItem', 'topLevelItem', 'indexOfTopLevelItem', 'selectedItems', 'clear']
)
@ -247,6 +247,19 @@ class TTkTree(TTkAbstractScrollArea):
:rtype: :py:class:`TTkTreeWidgetItem`
'''
return self._treeView.invisibleRootItem()
def itemAt(self, pos:int) -> Optional[TTkTreeWidgetItem]:
'''
.. seealso:: this method is forwarded to :py:meth:`TTkTreeWidget.itemAt`
Return the item at the vertical position
:param pos: y coordinate
:type pos: int
:return: The item at the (pos) position if available
:rtype: :py:class:`TTkTreeWidgetItem` or None if no item is available
'''
return self._treeView.itemAt(pos=pos)
def addTopLevelItem(self, item:TTkTreeWidgetItem) -> None:
'''
.. seealso:: this method is forwarded to :py:meth:`TTkTreeWidget.addTopLevelItem`

44
libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/treewidget.py

@ -211,6 +211,7 @@ class TTkTreeWidget(TTkAbstractScrollView):
'lineColor': TTkColor.fg("#444444"),
'lineHeightColor': TTkColor.fg("#666666"),
'headerColor': TTkColor.fg("#ffffff")+TTkColor.bg("#444444")+TTkColor.BOLD,
'hoveredColor': TTkColor.bg('#0088FF'),
'selectedColor': TTkColor.fg("#ffff88")+TTkColor.bg("#000066")+TTkColor.BOLD,
'separatorColor': TTkColor.fg("#444444")},
'disabled': {
@ -218,6 +219,7 @@ class TTkTreeWidget(TTkAbstractScrollView):
'lineColor': TTkColor.fg("#888888"),
'lineHeightColor': TTkColor.fg("#666666"),
'headerColor': TTkColor.fg("#888888"),
'hoveredColor': TTkColor.bg('#777777'),
'selectedColor': TTkColor.fg("#888888"),
'separatorColor': TTkColor.fg("#888888")},
}
@ -225,6 +227,7 @@ class TTkTreeWidget(TTkAbstractScrollView):
__slots__ = ( '_rootItem',
'_header', '_columnsPos',
'_selectionMode',
'_hoverItem',
'_selectedId', '_selected', '_separatorSelected',
'_sortColumn', '_sortOrder', '_sortingEnabled',
'_dndMode',
@ -233,6 +236,7 @@ class TTkTreeWidget(TTkAbstractScrollView):
)
_selected:List[TTkTreeWidgetItem]
_hoverItem:Optional[TTkTreeWidgetItem]
_rootItem:_RootWidgetItem
_separatorSelected:Optional[int]
@ -266,6 +270,7 @@ class TTkTreeWidget(TTkAbstractScrollView):
self._itemCollapsed = pyTTkSignal(TTkTreeWidgetItem)
self._selectionMode = selectionMode
self._dndMode = dragDropMode
self._hoverItem = None
self._selected = []
self._selectedId = None
self._separatorSelected = None
@ -581,6 +586,27 @@ class TTkTreeWidget(TTkAbstractScrollView):
def focusOutEvent(self) -> None:
self._separatorSelected = None
def itemAt(self, pos:int) -> Optional[TTkTreeWidgetItem]:
'''
Return the item at the vertical position
:param pos: y coordinate
:type pos: int
:return: The item at the (pos) position if available
:rtype: :py:class:`TTkTreeWidgetItem` or None if no item is available
'''
y = pos
_, oy = self.getViewOffsets()
# Handle Header Events
if y == 0:
return None
# Handle Tree/Table Events
y += oy-1
if _item_at := self._rootItem._item_at(y):
return _item_at[2]
return None
def mousePressEvent(self, evt:TTkMouseEvent) -> bool:
x,y = evt.x, evt.y
ox, oy = self.getViewOffsets()
@ -682,6 +708,21 @@ class TTkTreeWidget(TTkAbstractScrollView):
return True
return False
def mouseMoveEvent(self, evt) -> None:
y = evt.y
_, oy = self.getViewOffsets()
# Handle Header Events
if y == 0:
return True
# Handle Tree/Table Events
y += oy-1
if _item_at := self._rootItem._item_at(y):
item = _item_at[2]
self._hoverItem = item
self.update()
return True
return True
@pyTTkSlot()
def _alignWidgets(self) -> None:
self.layout().clear()
@ -721,6 +762,7 @@ class TTkTreeWidget(TTkAbstractScrollView):
lineColor= style['lineColor']
lineHeightColor= style['lineHeightColor']
headerColor= style['headerColor']
hoveredColor=style['hoveredColor']
selectedColor= style['selectedColor']
separatorColor= style['separatorColor']
@ -763,4 +805,6 @@ class TTkTreeWidget(TTkAbstractScrollView):
_text=_data[_yi]
if _i in self._selected:
_text = (_text + ' '*_width).completeColor(selectedColor)
elif _i is self._hoverItem:
_text = (_text + ' '*_width).completeColor(hoveredColor)
canvas.drawTTkString(text=_text,pos=(_lx-x,_y+1),width=_width)

183
tests/t.ui/test.ui.032.table.11.pyside.hover.py

@ -0,0 +1,183 @@
#!/usr/bin/env python3
# MIT License
#
# Copyright (c) 2026 Eugenio Parodi <ceccopierangiolieugenio AT googlemail DOT com>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# Requires: PySide6 (or adapt imports to PyQt5)
import sys
from PySide6.QtCore import Qt, QModelIndex, QPoint
from PySide6.QtGui import QStandardItemModel, QStandardItem
from PySide6.QtWidgets import (
QApplication, QTreeView, QWidget, QToolButton,
QHBoxLayout, QStyle, QVBoxLayout
)
class HoverActionBar(QWidget):
"""A small right-aligned bar with action buttons that floats over the view."""
def __init__(self, parent=None):
super().__init__(parent)
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False)
self.setWindowFlags(Qt.WindowType.FramelessWindowHint)
self.setAutoFillBackground(True)
self.setObjectName("HoverActionBar")
# Layout & buttons
layout = QHBoxLayout(self)
layout.setContentsMargins(6, 2, 6, 2)
layout.setSpacing(6)
self.runBtn = QToolButton(self)
self.runBtn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay))
self.runBtn.setToolTip("Run test")
self.runBtn.setCursor(Qt.CursorShape.PointingHandCursor)
self.debugBtn = QToolButton(self)
self.debugBtn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_CommandLink))
self.debugBtn.setToolTip("Debug test")
self.debugBtn.setCursor(Qt.CursorShape.PointingHandCursor)
layout.addWidget(self.runBtn)
layout.addWidget(self.debugBtn)
# Optional: light styling
self.setStyleSheet("""
#HoverActionBar {
background: rgba(240, 240, 240, 0.95);
border: 1px solid rgba(0,0,0,0.08);
border-radius: 6px;
}
QToolButton { border: none; padding: 2px; }
QToolButton::hover { background: rgba(0,0,0,0.06); border-radius: 4px; }
""")
self._index = QModelIndex()
def setIndex(self, index: QModelIndex):
self._index = index
def index(self):
return self._index
class HoverTree(QTreeView):
"""QTreeView that shows an action bar aligned to the right side of the hovered row."""
def __init__(self, parent=None):
self._bar = None
super().__init__(parent)
self.setMouseTracking(True)
self.setUniformRowHeights(True)
self.setHeaderHidden(True)
self._hovered = QModelIndex()
self._bar = HoverActionBar(self.viewport())
self._bar.hide()
# Wire buttons to actions
self._bar.runBtn.clicked.connect(self._onRunClicked)
self._bar.debugBtn.clicked.connect(self._onDebugClicked)
def leaveEvent(self, event):
super().leaveEvent(event)
self._hovered = QModelIndex()
self._bar.hide()
def mouseMoveEvent(self, event):
super().mouseMoveEvent(event)
pos = event.position().toPoint() if hasattr(event, 'position') else event.pos()
idx = self.indexAt(pos)
if idx != self._hovered:
self._hovered = idx
self._updateBarGeometry()
def viewportEvent(self, event):
"""Keep bar positioned if the view scrolls or resizes."""
res = super().viewportEvent(event)
# Reposition on paint/scroll/resize
if self._bar and self._bar.isVisible() and self._hovered.isValid():
self._updateBarGeometry()
return res
def _updateBarGeometry(self):
if not self._hovered.isValid():
self._bar.hide()
return
rect = self.visualRect(self._hovered)
if not rect.isValid() or not self.viewport().rect().intersects(rect):
self._bar.hide()
return
# Size the bar to content
self._bar.adjustSize()
bar_w = self._bar.width()
bar_h = self._bar.height()
# Right-align inside the row rect, with a small right margin
right_margin = 6
x = rect.right() - bar_w - right_margin
y = rect.top() + (rect.height() - bar_h) // 2
# Ensure it doesn’t overlap the text too aggressively (optional)
min_left = rect.left() + 120 # tweak depending on your content/indentation
x = max(x, min_left)
self._bar.setIndex(self._hovered)
self._bar.move(QPoint(x, y))
self._bar.show()
# Example slots that use the current index
def _onRunClicked(self):
idx = self._bar.index()
if idx.isValid():
print(f"Run: {idx.data()}")
def _onDebugClicked(self):
idx = self._bar.index()
if idx.isValid():
print(f"Debug: {idx.data()}")
def build_model():
model = QStandardItemModel()
root = model.invisibleRootItem()
for suite in range(3):
suite_item = QStandardItem(f"Suite {suite+1}")
suite_item.setEditable(False)
for case in range(4):
case_item = QStandardItem(f"Test {suite+1}.{case+1}")
case_item.setEditable(False)
suite_item.appendRow(case_item)
root.appendRow(suite_item)
return model
if __name__ == "__main__":
app = QApplication(sys.argv)
view = HoverTree()
view.setModel(build_model())
view.expandAll()
view.resize(420, 300)
view.show()
sys.exit(app.exec())
Loading…
Cancel
Save