Browse Source

chore(tree): mouse hover support (#583)

pull/588/head
Pier CeccoPierangioliEugenio 2 months ago committed by GitHub
parent
commit
cda68f278a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 13
      libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/filetree.py
  2. 15
      libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/tree.py
  3. 53
      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` :rtype: :py:class:`TTkTreeWidgetItem`
''' '''
return self._fileTreeWidget.invisibleRootItem() 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: def addTopLevelItem(self, item:TTkTreeWidgetItem) -> None:
''' '''
.. seealso:: this method is forwarded to :py:meth:`TTkFileTreeWidget.addTopLevelItem` .. 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', 'sortColumn', 'sortItems',
'dragDropMode', 'setDragDropMode', 'dragDropMode', 'setDragDropMode',
'expandAll', 'collapseAll', 'expandAll', 'collapseAll',
'invisibleRootItem', 'invisibleRootItem', 'itemAt',
# 'appendItem', 'setAlignment', 'setColumnColors', 'setColumnSize', 'setHeader', # 'appendItem', 'setAlignment', 'setColumnColors', 'setColumnSize', 'setHeader',
'addTopLevelItem', 'addTopLevelItems', 'takeTopLevelItem', 'topLevelItem', 'indexOfTopLevelItem', 'selectedItems', 'clear'] 'addTopLevelItem', 'addTopLevelItems', 'takeTopLevelItem', 'topLevelItem', 'indexOfTopLevelItem', 'selectedItems', 'clear']
) )
@ -247,6 +247,19 @@ class TTkTree(TTkAbstractScrollArea):
:rtype: :py:class:`TTkTreeWidgetItem` :rtype: :py:class:`TTkTreeWidgetItem`
''' '''
return self._treeView.invisibleRootItem() 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: def addTopLevelItem(self, item:TTkTreeWidgetItem) -> None:
''' '''
.. seealso:: this method is forwarded to :py:meth:`TTkTreeWidget.addTopLevelItem` .. seealso:: this method is forwarded to :py:meth:`TTkTreeWidget.addTopLevelItem`

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

@ -211,6 +211,7 @@ class TTkTreeWidget(TTkAbstractScrollView):
'lineColor': TTkColor.fg("#444444"), 'lineColor': TTkColor.fg("#444444"),
'lineHeightColor': TTkColor.fg("#666666"), 'lineHeightColor': TTkColor.fg("#666666"),
'headerColor': TTkColor.fg("#ffffff")+TTkColor.bg("#444444")+TTkColor.BOLD, 'headerColor': TTkColor.fg("#ffffff")+TTkColor.bg("#444444")+TTkColor.BOLD,
'hoveredColor': TTkColor.bg('#0088FF'),
'selectedColor': TTkColor.fg("#ffff88")+TTkColor.bg("#000066")+TTkColor.BOLD, 'selectedColor': TTkColor.fg("#ffff88")+TTkColor.bg("#000066")+TTkColor.BOLD,
'separatorColor': TTkColor.fg("#444444")}, 'separatorColor': TTkColor.fg("#444444")},
'disabled': { 'disabled': {
@ -218,6 +219,7 @@ class TTkTreeWidget(TTkAbstractScrollView):
'lineColor': TTkColor.fg("#888888"), 'lineColor': TTkColor.fg("#888888"),
'lineHeightColor': TTkColor.fg("#666666"), 'lineHeightColor': TTkColor.fg("#666666"),
'headerColor': TTkColor.fg("#888888"), 'headerColor': TTkColor.fg("#888888"),
'hoveredColor': TTkColor.bg('#777777'),
'selectedColor': TTkColor.fg("#888888"), 'selectedColor': TTkColor.fg("#888888"),
'separatorColor': TTkColor.fg("#888888")}, 'separatorColor': TTkColor.fg("#888888")},
} }
@ -225,7 +227,8 @@ class TTkTreeWidget(TTkAbstractScrollView):
__slots__ = ( '_rootItem', __slots__ = ( '_rootItem',
'_header', '_columnsPos', '_header', '_columnsPos',
'_selectionMode', '_selectionMode',
'_selectedId', '_selected', '_separatorSelected', '_hoverItem',
'_selected', '_separatorSelected',
'_sortColumn', '_sortOrder', '_sortingEnabled', '_sortColumn', '_sortOrder', '_sortingEnabled',
'_dndMode', '_dndMode',
# Signals # Signals
@ -233,6 +236,7 @@ class TTkTreeWidget(TTkAbstractScrollView):
) )
_selected:List[TTkTreeWidgetItem] _selected:List[TTkTreeWidgetItem]
_hoverItem:Optional[TTkTreeWidgetItem]
_rootItem:_RootWidgetItem _rootItem:_RootWidgetItem
_separatorSelected:Optional[int] _separatorSelected:Optional[int]
@ -266,8 +270,8 @@ class TTkTreeWidget(TTkAbstractScrollView):
self._itemCollapsed = pyTTkSignal(TTkTreeWidgetItem) self._itemCollapsed = pyTTkSignal(TTkTreeWidgetItem)
self._selectionMode = selectionMode self._selectionMode = selectionMode
self._dndMode = dragDropMode self._dndMode = dragDropMode
self._hoverItem = None
self._selected = [] self._selected = []
self._selectedId = None
self._separatorSelected = None self._separatorSelected = None
self._sortingEnabled=sortingEnabled self._sortingEnabled=sortingEnabled
self._sortColumn = -1 self._sortColumn = -1
@ -566,7 +570,6 @@ class TTkTreeWidget(TTkAbstractScrollView):
self.itemExpanded.emit(item) self.itemExpanded.emit(item)
else: else:
self.itemCollapsed.emit(item) self.itemCollapsed.emit(item)
self._selectedId = y
self._selected = [item] self._selected = [item]
col = -1 col = -1
for i, c in enumerate(self._columnsPos): for i, c in enumerate(self._columnsPos):
@ -581,6 +584,27 @@ class TTkTreeWidget(TTkAbstractScrollView):
def focusOutEvent(self) -> None: def focusOutEvent(self) -> None:
self._separatorSelected = 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: def mousePressEvent(self, evt:TTkMouseEvent) -> bool:
x,y = evt.x, evt.y x,y = evt.x, evt.y
ox, oy = self.getViewOffsets() ox, oy = self.getViewOffsets()
@ -621,7 +645,6 @@ class TTkTreeWidget(TTkAbstractScrollView):
_multiSelect = self._selectionMode == TTkK.SelectionMode.MultiSelection _multiSelect = self._selectionMode == TTkK.SelectionMode.MultiSelection
if not ( bool(evt.mod & TTkK.ControlModifier) and _multiSelect ): if not ( bool(evt.mod & TTkK.ControlModifier) and _multiSelect ):
self._selected.clear() self._selected.clear()
self._selectedId = y
# Unselect Items if already selected in multiselect mode # Unselect Items if already selected in multiselect mode
if item in self._selected and _multiSelect: if item in self._selected and _multiSelect:
self._selected.remove(item) self._selected.remove(item)
@ -682,6 +705,25 @@ class TTkTreeWidget(TTkAbstractScrollView):
return True return True
return False return False
def mouseMoveEvent(self, evt) -> bool:
y = evt.y
_, oy = self.getViewOffsets()
y += oy-1
if _item_at := self._rootItem._item_at(y):
item = _item_at[2]
self._hoverItem = item
self.update()
elif self._hoverItem is not None:
self._hoverItem = None
self.update()
return True
def leaveEvent(self, evt:TTkMouseEvent) -> bool:
if self._hoverItem is not None:
self._hoverItem = None
self.update()
return True
@pyTTkSlot() @pyTTkSlot()
def _alignWidgets(self) -> None: def _alignWidgets(self) -> None:
self.layout().clear() self.layout().clear()
@ -721,6 +763,7 @@ class TTkTreeWidget(TTkAbstractScrollView):
lineColor= style['lineColor'] lineColor= style['lineColor']
lineHeightColor= style['lineHeightColor'] lineHeightColor= style['lineHeightColor']
headerColor= style['headerColor'] headerColor= style['headerColor']
hoveredColor=style['hoveredColor']
selectedColor= style['selectedColor'] selectedColor= style['selectedColor']
separatorColor= style['separatorColor'] separatorColor= style['separatorColor']
@ -763,4 +806,6 @@ class TTkTreeWidget(TTkAbstractScrollView):
_text=_data[_yi] _text=_data[_yi]
if _i in self._selected: if _i in self._selected:
_text = (_text + ' '*_width).completeColor(selectedColor) _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) 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