feat(gui): search the test tree (Ctrl+F)
Find bar over the test tree: highlight matches and navigate them (Enter / ◂ ▸), with Name/Type/Doc checkboxes to choose the searched fields. Ctrl+F toggles the bar (clearing the highlight); Esc / ✕ close. - QTestTreeItem: matches_search(needle, fields) + a search highlight that shares one _refresh_highlight() with the green run highlight, recomputed from state flags (run > search > default) so the two layers never leave a stale/permanent colour. Amber bg + forced black text → readable in any theme. - QTestTree.search()/clear_search(): single signal-blocked pass (setBackground fires itemChanged → on_testChecked, a controller storm otherwise); expands ancestors of matches; returns matches in visual order. - MainWindow: the find bar widget + Ctrl+F shortcut + navigation; search is reset when a new test file is loaded. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -134,6 +134,7 @@ class TestFileManager:
|
|||||||
QApplication.processEvents()
|
QApplication.processEvents()
|
||||||
test_data = w.test_service.tree()
|
test_data = w.test_service.tree()
|
||||||
w.treeTests.clear()
|
w.treeTests.clear()
|
||||||
|
w._reset_search()
|
||||||
QApplication.processEvents()
|
QApplication.processEvents()
|
||||||
w.treeTests.loadTestRecursively(w.treeTests.invisibleRootItem(), test_data)
|
w.treeTests.loadTestRecursively(w.treeTests.invisibleRootItem(), test_data)
|
||||||
self._close_progress(progress)
|
self._close_progress(progress)
|
||||||
|
|||||||
@@ -163,6 +163,46 @@ class QTestTree(QTreeWidget):
|
|||||||
def clearGlobalSuccess(self):
|
def clearGlobalSuccess(self):
|
||||||
self._global_success = True
|
self._global_success = True
|
||||||
|
|
||||||
|
def _all_items(self):
|
||||||
|
"""Pre-order (visual, top-to-bottom) iteration over every tree item."""
|
||||||
|
def walk(parent):
|
||||||
|
for i in range(parent.childCount()):
|
||||||
|
child = parent.child(i)
|
||||||
|
yield child
|
||||||
|
yield from walk(child)
|
||||||
|
yield from walk(self.invisibleRootItem())
|
||||||
|
|
||||||
|
def clear_search(self):
|
||||||
|
# Block signals: setBackground -> itemChanged -> on_testChecked storm.
|
||||||
|
self.blockSignals(True)
|
||||||
|
try:
|
||||||
|
for it in self._all_items():
|
||||||
|
it.setSearchMatch(False)
|
||||||
|
finally:
|
||||||
|
self.blockSignals(False)
|
||||||
|
|
||||||
|
def search(self, text, fields):
|
||||||
|
"""Highlight items matching *text* in *fields*, expand ancestors, return matches."""
|
||||||
|
matches = []
|
||||||
|
text = (text or "").strip()
|
||||||
|
needle = text.lower()
|
||||||
|
active = bool(text and fields)
|
||||||
|
# One blocked pass: clear stale + set new matches without firing signals.
|
||||||
|
self.blockSignals(True)
|
||||||
|
try:
|
||||||
|
for it in self._all_items():
|
||||||
|
matched = active and it.matches_search(needle, fields)
|
||||||
|
it.setSearchMatch(matched)
|
||||||
|
if matched:
|
||||||
|
matches.append(it)
|
||||||
|
p = it.parent()
|
||||||
|
while p is not None:
|
||||||
|
self.expandItem(p)
|
||||||
|
p = p.parent()
|
||||||
|
finally:
|
||||||
|
self.blockSignals(False)
|
||||||
|
return matches
|
||||||
|
|
||||||
def __findItemByIdRecursively(self, item_id, parent):
|
def __findItemByIdRecursively(self, item_id, parent):
|
||||||
res = None
|
res = None
|
||||||
i = 0
|
i = 0
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ class QTestTreeItem(QTreeWidgetItem):
|
|||||||
self.setFlags(self.flags() | Qt.ItemIsUserCheckable)
|
self.setFlags(self.flags() | Qt.ItemIsUserCheckable)
|
||||||
self.setCheckState(self._cols["name"]["index"], Qt.Checked)
|
self.setCheckState(self._cols["name"]["index"], Qt.Checked)
|
||||||
self._is_highlighted = False
|
self._is_highlighted = False
|
||||||
self._initial_brush = None
|
self._is_search_match = False
|
||||||
self._failure_list = None
|
self._failure_list = None
|
||||||
self._no_breakpoint = False
|
self._no_breakpoint = False
|
||||||
parent.addChild(self)
|
parent.addChild(self)
|
||||||
@@ -180,17 +180,44 @@ class QTestTreeItem(QTreeWidgetItem):
|
|||||||
def isBreakpoint(self):
|
def isBreakpoint(self):
|
||||||
return self._display_pause
|
return self._display_pause
|
||||||
|
|
||||||
|
def _refresh_highlight(self):
|
||||||
|
"""Recompute name-column colours from flags: run (green) > search (amber) > none."""
|
||||||
|
col = self._cols["name"]["index"]
|
||||||
|
if self._is_highlighted:
|
||||||
|
self.setBackground(col, QBrush(QColor(153, 255, 153)))
|
||||||
|
self.setForeground(col, QBrush())
|
||||||
|
elif self._is_search_match:
|
||||||
|
self.setBackground(col, QBrush(QColor(255, 224, 130)))
|
||||||
|
self.setForeground(col, QBrush(QColor(0, 0, 0)))
|
||||||
|
else:
|
||||||
|
self.setBackground(col, QBrush())
|
||||||
|
self.setForeground(col, QBrush())
|
||||||
|
|
||||||
def setHighlighted(self):
|
def setHighlighted(self):
|
||||||
if not self._is_highlighted:
|
if not self._is_highlighted:
|
||||||
self._initial_brush = self.background(self._cols["name"]["index"])
|
|
||||||
color = QBrush(QColor(153, 255, 153))
|
|
||||||
self.setBackground(self._cols["name"]["index"], color)
|
|
||||||
self._is_highlighted = True
|
self._is_highlighted = True
|
||||||
|
self._refresh_highlight()
|
||||||
|
|
||||||
def resetHighlighted(self):
|
def resetHighlighted(self):
|
||||||
if self._is_highlighted:
|
if self._is_highlighted:
|
||||||
self.setBackground(self._cols["name"]["index"], self._initial_brush)
|
|
||||||
self._is_highlighted = False
|
self._is_highlighted = False
|
||||||
|
self._refresh_highlight()
|
||||||
|
|
||||||
|
def matches_search(self, needle, fields):
|
||||||
|
"""True if *needle* (lowercase) is in any enabled field (name/type/doc)."""
|
||||||
|
if "name" in fields and needle in (self.name or "").lower():
|
||||||
|
return True
|
||||||
|
if "type" in fields and needle in (self.test_type or "").lower():
|
||||||
|
return True
|
||||||
|
if "doc" in fields and needle in str(self.doc or "").lower():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def setSearchMatch(self, on):
|
||||||
|
"""Search highlight (amber bg + black text), readable in any theme."""
|
||||||
|
if on != self._is_search_match:
|
||||||
|
self._is_search_match = on
|
||||||
|
self._refresh_highlight()
|
||||||
|
|
||||||
def setRowIcon(self, resource_off, resource_on=""):
|
def setRowIcon(self, resource_off, resource_on=""):
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import shutil
|
|||||||
|
|
||||||
# Qt
|
# Qt
|
||||||
from PySide6 import QtGui
|
from PySide6 import QtGui
|
||||||
from PySide6.QtGui import QAction, QShortcut, QIcon, QPixmap, QTextCursor, QDesktopServices, QTextCursor
|
from PySide6.QtGui import QAction, QShortcut, QIcon, QPixmap, QTextCursor, QDesktopServices, QTextCursor, QKeySequence
|
||||||
from PySide6.QtCore import Slot, QUrl, Qt, QTimer
|
from PySide6.QtCore import Slot, QUrl, Qt, QTimer
|
||||||
|
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
@@ -16,6 +16,12 @@ from PySide6.QtWidgets import (
|
|||||||
QDialog,
|
QDialog,
|
||||||
QFileDialog,
|
QFileDialog,
|
||||||
QSizePolicy,
|
QSizePolicy,
|
||||||
|
QWidget,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLineEdit,
|
||||||
|
QCheckBox,
|
||||||
|
QLabel,
|
||||||
|
QToolButton,
|
||||||
)
|
)
|
||||||
|
|
||||||
ourPath = os.path.dirname(__file__)
|
ourPath = os.path.dirname(__file__)
|
||||||
@@ -169,6 +175,13 @@ class MainWindow(QMainWindow, Ui_MainWindow):
|
|||||||
activated=self.on_F1Pressed,
|
activated=self.on_F1Pressed,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self._search_matches = []
|
||||||
|
self._search_idx = 0
|
||||||
|
self._build_search_bar()
|
||||||
|
self.shortcut_find = QShortcut(
|
||||||
|
QKeySequence.Find, self, activated=self._toggle_search
|
||||||
|
)
|
||||||
|
|
||||||
self.actionRefresh_test.setDisabled(True)
|
self.actionRefresh_test.setDisabled(True)
|
||||||
|
|
||||||
# Signal connections
|
# Signal connections
|
||||||
@@ -295,6 +308,135 @@ class MainWindow(QMainWindow, Ui_MainWindow):
|
|||||||
del self.treeTests
|
del self.treeTests
|
||||||
self.treeTests = None
|
self.treeTests = None
|
||||||
|
|
||||||
|
# ---- test-tree search ---------------------------------------------------
|
||||||
|
|
||||||
|
def _build_search_bar(self):
|
||||||
|
"""Find bar (Ctrl+F): highlight + navigate matches; Name/Type/Doc pick fields."""
|
||||||
|
self.searchBar = QWidget(self.widget)
|
||||||
|
lay = QHBoxLayout(self.searchBar)
|
||||||
|
lay.setContentsMargins(2, 2, 2, 2)
|
||||||
|
lay.setSpacing(4)
|
||||||
|
|
||||||
|
self.searchEdit = QLineEdit(self.searchBar)
|
||||||
|
self.searchEdit.setPlaceholderText("Search the test tree…")
|
||||||
|
self.searchEdit.setClearButtonEnabled(True)
|
||||||
|
lay.addWidget(self.searchEdit, 1)
|
||||||
|
|
||||||
|
self.cbSearchName = QCheckBox("Name", self.searchBar)
|
||||||
|
self.cbSearchType = QCheckBox("Type", self.searchBar)
|
||||||
|
self.cbSearchDoc = QCheckBox("Doc", self.searchBar)
|
||||||
|
for cb in (self.cbSearchName, self.cbSearchType, self.cbSearchDoc):
|
||||||
|
cb.setChecked(True)
|
||||||
|
cb.toggled.connect(self._do_search)
|
||||||
|
lay.addWidget(cb)
|
||||||
|
|
||||||
|
self.searchCount = QLabel("", self.searchBar)
|
||||||
|
lay.addWidget(self.searchCount)
|
||||||
|
|
||||||
|
self.searchPrev = QToolButton(self.searchBar)
|
||||||
|
self.searchPrev.setArrowType(Qt.UpArrow)
|
||||||
|
self.searchPrev.setToolTip("Previous match")
|
||||||
|
self.searchPrev.clicked.connect(self._search_prev)
|
||||||
|
lay.addWidget(self.searchPrev)
|
||||||
|
|
||||||
|
self.searchNext = QToolButton(self.searchBar)
|
||||||
|
self.searchNext.setArrowType(Qt.DownArrow)
|
||||||
|
self.searchNext.setToolTip("Next match (Enter)")
|
||||||
|
self.searchNext.clicked.connect(self._search_next)
|
||||||
|
lay.addWidget(self.searchNext)
|
||||||
|
|
||||||
|
self.searchClose = QToolButton(self.searchBar)
|
||||||
|
self.searchClose.setText("✕")
|
||||||
|
self.searchClose.setToolTip("Close (Esc)")
|
||||||
|
self.searchClose.clicked.connect(self._close_search)
|
||||||
|
lay.addWidget(self.searchClose)
|
||||||
|
|
||||||
|
self.searchEdit.textChanged.connect(self._do_search)
|
||||||
|
self.searchEdit.returnPressed.connect(self._search_next)
|
||||||
|
QShortcut(Qt.Key_Escape, self.searchEdit,
|
||||||
|
context=Qt.WidgetShortcut, activated=self._close_search)
|
||||||
|
|
||||||
|
# Insert above the tree (index 0 is the control row from setupUi).
|
||||||
|
self.verticalLayout.insertWidget(1, self.searchBar)
|
||||||
|
self.searchBar.setVisible(False)
|
||||||
|
|
||||||
|
def _search_fields(self):
|
||||||
|
fields = set()
|
||||||
|
if self.cbSearchName.isChecked():
|
||||||
|
fields.add("name")
|
||||||
|
if self.cbSearchType.isChecked():
|
||||||
|
fields.add("type")
|
||||||
|
if self.cbSearchDoc.isChecked():
|
||||||
|
fields.add("doc")
|
||||||
|
return fields
|
||||||
|
|
||||||
|
def _toggle_search(self):
|
||||||
|
"""Ctrl+F: open the find bar, or close it (clearing the highlight)."""
|
||||||
|
if self.searchBar.isVisible():
|
||||||
|
self._close_search()
|
||||||
|
else:
|
||||||
|
self._open_search()
|
||||||
|
|
||||||
|
def _open_search(self):
|
||||||
|
self.searchBar.setVisible(True)
|
||||||
|
self.searchEdit.setFocus()
|
||||||
|
self.searchEdit.selectAll()
|
||||||
|
if self.searchEdit.text():
|
||||||
|
self._do_search()
|
||||||
|
|
||||||
|
def _do_search(self):
|
||||||
|
if self.treeTests is None:
|
||||||
|
return
|
||||||
|
self._search_matches = self.treeTests.search(
|
||||||
|
self.searchEdit.text(), self._search_fields()
|
||||||
|
)
|
||||||
|
self._search_idx = 0
|
||||||
|
if self._search_matches:
|
||||||
|
self._goto_match(0)
|
||||||
|
else:
|
||||||
|
self._update_search_count()
|
||||||
|
|
||||||
|
def _update_search_count(self):
|
||||||
|
n = len(self._search_matches)
|
||||||
|
if n == 0:
|
||||||
|
self.searchCount.setText(
|
||||||
|
"0/0" if self.searchEdit.text().strip() else ""
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.searchCount.setText("{}/{}".format(self._search_idx + 1, n))
|
||||||
|
|
||||||
|
def _goto_match(self, idx):
|
||||||
|
if not self._search_matches:
|
||||||
|
return
|
||||||
|
self._search_idx = idx % len(self._search_matches)
|
||||||
|
it = self._search_matches[self._search_idx]
|
||||||
|
self.treeTests.scrollToItem(it)
|
||||||
|
self.treeTests.setCurrentItem(it)
|
||||||
|
self._update_search_count()
|
||||||
|
|
||||||
|
def _search_next(self):
|
||||||
|
if self._search_matches:
|
||||||
|
self._goto_match(self._search_idx + 1)
|
||||||
|
|
||||||
|
def _search_prev(self):
|
||||||
|
if self._search_matches:
|
||||||
|
self._goto_match(self._search_idx - 1)
|
||||||
|
|
||||||
|
def _close_search(self):
|
||||||
|
if self.treeTests is not None:
|
||||||
|
self.treeTests.clear_search()
|
||||||
|
self.treeTests.setFocus()
|
||||||
|
self.searchBar.setVisible(False)
|
||||||
|
self._search_matches = []
|
||||||
|
|
||||||
|
def _reset_search(self):
|
||||||
|
"""New test file loaded: drop stale matches and hide the bar."""
|
||||||
|
self._search_matches = []
|
||||||
|
self._search_idx = 0
|
||||||
|
if hasattr(self, "searchBar"):
|
||||||
|
self.searchBar.setVisible(False)
|
||||||
|
self.searchCount.setText("")
|
||||||
|
|
||||||
def file_loaded_at_startup(self):
|
def file_loaded_at_startup(self):
|
||||||
modeSlider_value = prefs.settings.show_checkboxes
|
modeSlider_value = prefs.settings.show_checkboxes
|
||||||
if modeSlider_value:
|
if modeSlider_value:
|
||||||
|
|||||||
Reference in New Issue
Block a user