From e8a2a1152844310bd627e2cf34b789bedd165841 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Parodi=2C=20Eugenio=20=F0=9F=8C=B6?= Date: Mon, 19 Jan 2026 08:15:36 +0000 Subject: [PATCH] chore(tree): add hover --- .../TTkWidgets/TTkModelView/filetree.py | 13 ++ .../TermTk/TTkWidgets/TTkModelView/tree.py | 15 +- .../TTkWidgets/TTkModelView/treewidget.py | 44 +++++ .../t.ui/test.ui.032.table.11.pyside.hover.py | 183 ++++++++++++++++++ 4 files changed, 254 insertions(+), 1 deletion(-) create mode 100755 tests/t.ui/test.ui.032.table.11.pyside.hover.py diff --git a/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/filetree.py b/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/filetree.py index 4430df8f..fdde3365 100644 --- a/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/filetree.py +++ b/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` diff --git a/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/tree.py b/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/tree.py index ef76fdb6..ce6349bb 100644 --- a/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/tree.py +++ b/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` diff --git a/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/treewidget.py b/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/treewidget.py index 22cbe727..d6b2623b 100644 --- a/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/treewidget.py +++ b/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) diff --git a/tests/t.ui/test.ui.032.table.11.pyside.hover.py b/tests/t.ui/test.ui.032.table.11.pyside.hover.py new file mode 100755 index 00000000..b9239455 --- /dev/null +++ b/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 +# +# 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())