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:
2026-06-14 23:57:02 +02:00
parent 72b207aab6
commit a4377d691f
4 changed files with 216 additions and 6 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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=""):

View File

@@ -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: