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()
|
||||
test_data = w.test_service.tree()
|
||||
w.treeTests.clear()
|
||||
w._reset_search()
|
||||
QApplication.processEvents()
|
||||
w.treeTests.loadTestRecursively(w.treeTests.invisibleRootItem(), test_data)
|
||||
self._close_progress(progress)
|
||||
|
||||
@@ -163,6 +163,46 @@ class QTestTree(QTreeWidget):
|
||||
def clearGlobalSuccess(self):
|
||||
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):
|
||||
res = None
|
||||
i = 0
|
||||
|
||||
@@ -103,7 +103,7 @@ class QTestTreeItem(QTreeWidgetItem):
|
||||
self.setFlags(self.flags() | Qt.ItemIsUserCheckable)
|
||||
self.setCheckState(self._cols["name"]["index"], Qt.Checked)
|
||||
self._is_highlighted = False
|
||||
self._initial_brush = None
|
||||
self._is_search_match = False
|
||||
self._failure_list = None
|
||||
self._no_breakpoint = False
|
||||
parent.addChild(self)
|
||||
@@ -180,17 +180,44 @@ class QTestTreeItem(QTreeWidgetItem):
|
||||
def isBreakpoint(self):
|
||||
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):
|
||||
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._refresh_highlight()
|
||||
|
||||
def resetHighlighted(self):
|
||||
if self._is_highlighted:
|
||||
self.setBackground(self._cols["name"]["index"], self._initial_brush)
|
||||
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=""):
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import shutil
|
||||
|
||||
# Qt
|
||||
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.QtWidgets import (
|
||||
@@ -16,6 +16,12 @@ from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QFileDialog,
|
||||
QSizePolicy,
|
||||
QWidget,
|
||||
QHBoxLayout,
|
||||
QLineEdit,
|
||||
QCheckBox,
|
||||
QLabel,
|
||||
QToolButton,
|
||||
)
|
||||
|
||||
ourPath = os.path.dirname(__file__)
|
||||
@@ -169,6 +175,13 @@ class MainWindow(QMainWindow, Ui_MainWindow):
|
||||
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)
|
||||
|
||||
# Signal connections
|
||||
@@ -295,6 +308,135 @@ class MainWindow(QMainWindow, Ui_MainWindow):
|
||||
del self.treeTests
|
||||
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):
|
||||
modeSlider_value = prefs.settings.show_checkboxes
|
||||
if modeSlider_value:
|
||||
|
||||
Reference in New Issue
Block a user