diff --git a/src/testium/main_win/test_file_manager.py b/src/testium/main_win/test_file_manager.py index 59c49fa..bc6008c 100644 --- a/src/testium/main_win/test_file_manager.py +++ b/src/testium/main_win/test_file_manager.py @@ -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) diff --git a/src/testium/main_win/test_tree.py b/src/testium/main_win/test_tree.py index c223d6e..305059c 100644 --- a/src/testium/main_win/test_tree.py +++ b/src/testium/main_win/test_tree.py @@ -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 diff --git a/src/testium/main_win/test_tree_items/test_tree_item.py b/src/testium/main_win/test_tree_items/test_tree_item.py index 0469bd4..b39228a 100644 --- a/src/testium/main_win/test_tree_items/test_tree_item.py +++ b/src/testium/main_win/test_tree_items/test_tree_item.py @@ -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=""): diff --git a/src/testium/main_win/testium_win.py b/src/testium/main_win/testium_win.py index 23267f0..8e701c5 100755 --- a/src/testium/main_win/testium_win.py +++ b/src/testium/main_win/testium_win.py @@ -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: