4 changed files with 254 additions and 1 deletions
@ -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…
Reference in new issue