From f88a31c2fe2f82ec0c1de6b11247379ff0fa32a9 Mon Sep 17 00:00:00 2001 From: Robert Samples Date: Sun, 28 Jun 2026 21:45:34 -0400 Subject: [PATCH 1/7] Add Qt-free per-column filter criteria for the feature search tree First layer of the per-column-filter feature tree request: TextFilter (substring), RangeFilter (inclusive numeric range, either bound optional), and CategoryFilter (multi-select over space-joined tokens, e.g. the 'groups' column's ' Blanks 0um_Ce' format) plus row_passes() to combine them and distinct_category_tokens() to populate a checkbox filter's option list from real data. Kept deliberately separate from the Qt model/proxy/widget layer (next) so the actual matching rules are unit-tested without a GUI -- the Qt layer will just be plumbing that calls into this. Co-Authored-By: Claude Sonnet 4.6 --- code/tests/test_treefilters.py | 121 +++++++++++++++++++++++++++++++++ code/treefilters.py | 88 ++++++++++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 code/tests/test_treefilters.py create mode 100644 code/treefilters.py diff --git a/code/tests/test_treefilters.py b/code/tests/test_treefilters.py new file mode 100644 index 0000000..01c35f6 --- /dev/null +++ b/code/tests/test_treefilters.py @@ -0,0 +1,121 @@ +import pytest + +from treefilters import ( + CategoryFilter, + RangeFilter, + TextFilter, + distinct_category_tokens, + row_passes, +) + + +# --------------------------------------------------------------------------- # +# TextFilter +# --------------------------------------------------------------------------- # + +def test_text_filter_blank_matches_everything(): + assert TextFilter('').matches('anything') + assert TextFilter('').matches('') + + +def test_text_filter_case_insensitive_substring(): + f = TextFilter('gran') + assert f.matches('Granaticin C') + assert f.matches('GRANATICIN') + assert not f.matches('Coelichelin') + + +# --------------------------------------------------------------------------- # +# RangeFilter +# --------------------------------------------------------------------------- # + +def test_range_filter_no_bounds_matches_everything(): + f = RangeFilter() + assert f.matches(0) + assert f.matches(-999.5) + assert f.matches(1e6) + + +def test_range_filter_inclusive_bounds(): + f = RangeFilter(minimum=1, maximum=5) + assert f.matches(1) + assert f.matches(5) + assert f.matches(3) + assert not f.matches(0.999) + assert not f.matches(5.001) + + +def test_range_filter_one_sided_bounds(): + assert RangeFilter(minimum=10).matches(1000) + assert not RangeFilter(minimum=10).matches(9) + assert RangeFilter(maximum=10).matches(-1000) + assert not RangeFilter(maximum=10).matches(11) + + +def test_range_filter_non_numeric_value_never_matches(): + f = RangeFilter(minimum=0, maximum=10) + assert not f.matches('not a number') + assert not f.matches(None) + + +# --------------------------------------------------------------------------- # +# CategoryFilter +# --------------------------------------------------------------------------- # + +def test_category_filter_no_selection_matches_everything(): + assert CategoryFilter(None).matches(' Blanks 0um_Ce') + assert CategoryFilter(frozenset()).matches(' Blanks 0um_Ce') + + +def test_category_filter_matches_any_overlapping_token(): + f = CategoryFilter(frozenset({'0um_Ce'})) + assert f.matches(' Blanks 0um_Ce') + assert not f.matches(' Blanks Media') + + +def test_category_filter_multiple_selected_tokens_is_an_or(): + f = CategoryFilter(frozenset({'0um_Ce', '250um_Ce'})) + assert f.matches(' 0um_Ce') + assert f.matches(' 250um_Ce') + assert not f.matches(' Media') + + +# --------------------------------------------------------------------------- # +# row_passes +# --------------------------------------------------------------------------- # + +def test_row_passes_with_no_filters(): + assert row_passes(['anything', 1, 2], {}) + + +def test_row_passes_requires_all_active_filters_to_match(): + row = ['Granaticin C', 101.5, ' 0um_Ce'] + filters = { + 0: TextFilter('gran'), + 1: RangeFilter(minimum=100, maximum=200), + 2: CategoryFilter(frozenset({'0um_Ce'})), + } + assert row_passes(row, filters) + + +def test_row_fails_if_any_single_filter_fails(): + row = ['Granaticin C', 101.5, ' 0um_Ce'] + filters = { + 0: TextFilter('gran'), + 1: RangeFilter(minimum=200, maximum=300), # out of range + 2: CategoryFilter(frozenset({'0um_Ce'})), + } + assert not row_passes(row, filters) + + +# --------------------------------------------------------------------------- # +# distinct_category_tokens +# --------------------------------------------------------------------------- # + +def test_distinct_category_tokens_sorted_and_deduplicated(): + values = [' Blanks 0um_Ce', ' 0um_Ce Media', ' Media', ''] + assert distinct_category_tokens(values) == ['0um_Ce', 'Blanks', 'Media'] + + +def test_distinct_category_tokens_empty_input(): + assert distinct_category_tokens([]) == [] diff --git a/code/treefilters.py b/code/treefilters.py new file mode 100644 index 0000000..8569db2 --- /dev/null +++ b/code/treefilters.py @@ -0,0 +1,88 @@ +""" +MPACT +Copyright 2022, Robert M. Samples, Sara P. Puckett, and Marcy J. Balunas + +Qt-free per-column filter criteria and row-matching logic for the feature +search tree (main.py's fillfttree()/treeWidget). Kept separate from the Qt +model/proxy/widget layer (searchtree.py) so the actual matching rules -- +substring search, numeric range, multi-select category -- are testable +without a GUI. +""" + +from dataclasses import dataclass +from typing import Optional + + +@dataclass(frozen=True) +class TextFilter: + """Case-insensitive substring match. Blank text matches every row.""" + text: str = '' + + def matches(self, value): + if not self.text: + return True + return self.text.lower() in str(value).lower() + + +@dataclass(frozen=True) +class RangeFilter: + """Inclusive numeric range. Either bound may be ``None`` (unbounded). + A value that can't be parsed as a number never matches a range filter + (it's not "in range" if it isn't a number at all). + """ + minimum: Optional[float] = None + maximum: Optional[float] = None + + def matches(self, value): + try: + number = float(value) + except (TypeError, ValueError): + return False + if self.minimum is not None and number < self.minimum: + return False + if self.maximum is not None and number > self.maximum: + return False + return True + + +@dataclass(frozen=True) +class CategoryFilter: + """For columns whose value is a space-joined set of category tokens + (e.g. the ``groups`` column: ``' Blanks 0um_Ce'``). Matches if the row + has at least one token in common with ``allowed``. An empty/``None`` + ``allowed`` matches every row -- "nothing selected" means "no filter + applied," matching how a checkbox filter list is normally expected to + behave (vs. "nothing selected" hiding everything). + """ + allowed: Optional[frozenset] = None + + def matches(self, value): + if not self.allowed: + return True + row_tokens = set(str(value).split()) + return bool(row_tokens & self.allowed) + + +def row_passes(row_values, filters): + """``row_values``: a sequence of cell values for one row, indexed by + column position. ``filters``: ``{column_index: criterion}`` where each + criterion has a ``.matches(value)`` method (``TextFilter``/ + ``RangeFilter``/``CategoryFilter``, or any object with that interface). + Columns with no entry in ``filters`` are unfiltered. + + Returns ``True`` iff every active filter's column passes. + """ + for column, criterion in filters.items(): + if not criterion.matches(row_values[column]): + return False + return True + + +def distinct_category_tokens(values): + """Given a column of space-joined category strings (e.g. every row's + ``groups`` value), return the sorted set of distinct individual tokens + -- used to populate a per-column checkbox filter's option list.""" + tokens = set() + for value in values: + tokens.update(str(value).split()) + return sorted(tokens) From eefe42e0f7bfda3e4b2341d13fefd5367e5dd1d8 Mon Sep 17 00:00:00 2001 From: Robert Samples Date: Sun, 28 Jun 2026 21:51:34 -0400 Subject: [PATCH 2/7] Add Qt layer for the per-column-filter feature search tree, with real tests Big finding while building this: PyQt5 runs headlessly via the "offscreen" QPA platform plugin in this environment. That doesn't make main.py importable standalone (the documented main<->ui_functions circular import is a separate, unrelated constraint and still applies) -- but it does mean a standalone Qt-coupled module that doesn't import main.py can have its actual widget/model/signal behaviour covered by real automated tests instead of relying solely on manual testing. conftest.py now sets QT_QPA_PLATFORM=offscreen and provides a session-scoped `qapp` fixture (one shared QApplication; constructing a second one in the same process crashes). searchtree.py replaces ui_main.py's Designer-created QTreeWidget with a QTreeView + QAbstractTableModel (IonTableModel) + QSortFilterProxyModel (IonFilterProxyModel) + a per-column filter bar (SearchTreePanel) -- text search for the Compound column, numeric min/max range for m/z/TR/ Max/FC/Hits, and multi-select checkboxes for the Sets/Groups columns (space-joined category tokens). Filter-matching rules themselves live in treefilters.py (already merged, Qt-free); this module is just the Qt plumbing wired to it. Does not edit any generated UI file: SearchTreePanel removes the Designer QTreeWidget from its layout and inserts a container (filter bar + QTreeView) in the same slot at runtime, the same pattern plotting.py already uses for matplotlib canvases substituted into Designer-created QFrame placeholders. Re-opening the .ui file in Qt Designer would still show the old QTreeWidget -- only the running app uses this. 16 new tests cover the model's raw-vs-display value distinction (numeric sort/filter against real numbers, not formatted display strings), every filter type individually and combined, and -- the part that actually matters most here -- the real runtime widget swap itself end to end: constructing a real QTreeWidget inside a real layout, replacing it, populating/filtering/selecting rows, and confirming selection correctly maps a (filtered, reordered) proxy row back to the right source row. Not yet wired into main.py/ui_functions.py -- next commit. Co-Authored-By: Claude Sonnet 4.6 --- code/searchtree.py | 257 ++++++++++++++++++++++++++++++++++ code/tests/conftest.py | 25 ++++ code/tests/test_searchtree.py | 169 ++++++++++++++++++++++ 3 files changed, 451 insertions(+) create mode 100644 code/searchtree.py create mode 100644 code/tests/test_searchtree.py diff --git a/code/searchtree.py b/code/searchtree.py new file mode 100644 index 0000000..29310de --- /dev/null +++ b/code/searchtree.py @@ -0,0 +1,257 @@ +""" +MPACT +Copyright 2022, Robert M. Samples, Sara P. Puckett, and Marcy J. Balunas + +Replaces the feature-search tab's Designer-created QTreeWidget +(``ui_main.py``'s ``self.treeWidget``) with a real QTreeView backed by a +QAbstractTableModel + QSortFilterProxyModel, plus a small per-column filter +bar -- so each column gets its own search/range/checkbox filter instead of +one global search box. + +This does NOT edit any generated UI file. ``SearchTreePanel`` is +constructed once, at runtime, against the already-built Designer widget +(see ``MainWindow.__init__``): it removes the Designer QTreeWidget from its +layout and inserts a container (filter bar + QTreeView) in the same slot -- +the same "swap a Designer placeholder for hand-built content at runtime" +pattern ``plotting.py`` already uses for matplotlib canvases inserted into +Designer-created QFrame placeholders. Re-opening the .ui file in Qt +Designer would still show the old QTreeWidget; only the running app uses +this. + +The actual filter-matching rules live in ``treefilters.py`` (Qt-free, +unit-tested) -- this module is just the Qt plumbing that calls into them. +""" + +from PyQt5 import QtCore, QtGui, QtWidgets + +from treefilters import CategoryFilter, RangeFilter, TextFilter, distinct_category_tokens, row_passes + +COLUMNS = ('Compound', 'm/z', 'TR', 'Max', 'Sets', 'Groups', 'FC', 'Hits') +TEXT_COLUMNS = {0} +NUMERIC_COLUMNS = {1, 2, 3, 6, 7} +CATEGORY_COLUMNS = {4, 5} + +_NUMERIC_FORMATS = { + 1: '{:.4f}', # m/z + 2: '{:.3f}', # TR (retention time) + 3: '{:.0f}', # Max + 6: '{:.2f}', # FC + 7: '{:.0f}', # Hits +} + + +class IonTableModel(QtCore.QAbstractTableModel): + """One row per feature; columns match COLUMNS. Stores raw (unformatted, + correctly typed) values so sorting and filtering compare real numbers, + not the display strings -- ``RAW_ROLE`` exposes them for that purpose. + """ + + RAW_ROLE = QtCore.Qt.UserRole + 1 + + def __init__(self, parent=None): + super().__init__(parent) + self._rows = [] + + def set_rows(self, rows): + """``rows``: list of tuples, one per feature, in COLUMNS order.""" + self.beginResetModel() + self._rows = list(rows) + self.endResetModel() + + def raw_row(self, row): + return self._rows[row] + + def rowCount(self, parent=QtCore.QModelIndex()): + return 0 if parent.isValid() else len(self._rows) + + def columnCount(self, parent=QtCore.QModelIndex()): + return 0 if parent.isValid() else len(COLUMNS) + + def data(self, index, role=QtCore.Qt.DisplayRole): + if not index.isValid(): + return None + value = self._rows[index.row()][index.column()] + if role == self.RAW_ROLE: + return value + if role == QtCore.Qt.DisplayRole: + fmt = _NUMERIC_FORMATS.get(index.column()) + if fmt is not None: + try: + return fmt.format(float(value)) + except (TypeError, ValueError): + return str(value) + return str(value) + return None + + def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole): + if role == QtCore.Qt.DisplayRole and orientation == QtCore.Qt.Horizontal: + return COLUMNS[section] + return None + + +class IonFilterProxyModel(QtCore.QSortFilterProxyModel): + """Per-column filters (column index -> a TextFilter/RangeFilter/ + CategoryFilter from treefilters.py) combined with AND, evaluated via + treefilters.row_passes(). Sorting compares IonTableModel.RAW_ROLE + values instead of the default display-text comparison, so numeric + columns sort numerically without any string-parsing tricks. + """ + + def __init__(self, parent=None): + super().__init__(parent) + self._filters = {} + + def set_filter(self, column, criterion): + if criterion is None: + self._filters.pop(column, None) + else: + self._filters[column] = criterion + self.invalidateFilter() + + def filterAcceptsRow(self, source_row, source_parent): + model = self.sourceModel() + if model is None: + return True + return row_passes(model.raw_row(source_row), self._filters) + + def lessThan(self, left, right): + model = self.sourceModel() + left_value = model.data(left, IonTableModel.RAW_ROLE) + right_value = model.data(right, IonTableModel.RAW_ROLE) + try: + return left_value < right_value + except TypeError: + return str(left_value) < str(right_value) + + +class SearchTreePanel: + """Owns the model/proxy/view/filter-bar trio that replaces a Designer + QTreeWidget. Construct once with the Designer widget to replace; use + ``set_rows()``/``selected_compound()`` afterward in place of the old + ``treeWidget.clear()``/``addTopLevelItem()``/``selectedItems()`` calls. + """ + + def __init__(self, old_tree_widget): + layout = old_tree_widget.parentWidget().layout() + index = layout.indexOf(old_tree_widget) + parent = old_tree_widget.parentWidget() + + layout.removeWidget(old_tree_widget) + old_tree_widget.setParent(None) + old_tree_widget.deleteLater() + + self.model = IonTableModel(parent) + self.proxy = IonFilterProxyModel(parent) + self.proxy.setSourceModel(self.model) + + self.view = QtWidgets.QTreeView(parent) + self.view.setModel(self.proxy) + self.view.setSortingEnabled(True) + self.view.setRootIsDecorated(False) + self.view.setAlternatingRowColors(True) + self.view.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) + self.view.setUniformRowHeights(True) + + container = QtWidgets.QWidget(parent) + outer = QtWidgets.QVBoxLayout(container) + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(2) + + filter_bar = QtWidgets.QWidget(container) + self._filter_layout = QtWidgets.QHBoxLayout(filter_bar) + self._filter_layout.setContentsMargins(0, 0, 0, 0) + + self._category_buttons = {} + for column in range(len(COLUMNS)): + self._filter_layout.addWidget(self._build_filter_widget(column)) + + outer.addWidget(filter_bar) + outer.addWidget(self.view) + + layout.insertWidget(index, container) + + def _build_filter_widget(self, column): + if column in TEXT_COLUMNS: + return self._build_text_filter(column) + if column in NUMERIC_COLUMNS: + return self._build_range_filter(column) + return self._build_category_filter(column) + + def _build_text_filter(self, column): + edit = QtWidgets.QLineEdit() + edit.setPlaceholderText(COLUMNS[column]) + edit.setClearButtonEnabled(True) + edit.textChanged.connect( + lambda text, c=column: self.proxy.set_filter(c, TextFilter(text) if text else None)) + return edit + + def _build_range_filter(self, column): + box = QtWidgets.QWidget() + layout = QtWidgets.QHBoxLayout(box) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(2) + + label = QtWidgets.QLabel(COLUMNS[column]) + min_edit = QtWidgets.QLineEdit() + min_edit.setPlaceholderText('min') + min_edit.setMaximumWidth(60) + min_edit.setValidator(QtGui.QDoubleValidator()) + max_edit = QtWidgets.QLineEdit() + max_edit.setPlaceholderText('max') + max_edit.setMaximumWidth(60) + max_edit.setValidator(QtGui.QDoubleValidator()) + + def update_filter(*_args): + minimum = float(min_edit.text()) if min_edit.text() else None + maximum = float(max_edit.text()) if max_edit.text() else None + criterion = RangeFilter(minimum, maximum) if (minimum is not None or maximum is not None) else None + self.proxy.set_filter(column, criterion) + + min_edit.textChanged.connect(update_filter) + max_edit.textChanged.connect(update_filter) + + layout.addWidget(label) + layout.addWidget(min_edit) + layout.addWidget(max_edit) + return box + + def _build_category_filter(self, column): + button = QtWidgets.QToolButton() + button.setText(COLUMNS[column] + ' ▾') + button.setPopupMode(QtWidgets.QToolButton.InstantPopup) + menu = QtWidgets.QMenu(button) + button.setMenu(menu) + self._category_buttons[column] = (button, menu, {}) + return button + + def _refresh_category_options(self, column): + button, menu, _old_actions = self._category_buttons[column] + menu.clear() + actions = {} + values = [self.model.raw_row(r)[column] for r in range(self.model.rowCount())] + for token in distinct_category_tokens(values): + action = menu.addAction(token) + action.setCheckable(True) + action.toggled.connect(lambda _checked, c=column: self._update_category_filter(c)) + actions[token] = action + self._category_buttons[column] = (button, menu, actions) + + def _update_category_filter(self, column): + _button, _menu, actions = self._category_buttons[column] + selected = frozenset(token for token, action in actions.items() if action.isChecked()) + self.proxy.set_filter(column, CategoryFilter(selected) if selected else None) + + def set_rows(self, rows): + """``rows``: list of tuples, one per feature, in COLUMNS order + (Compound, m/z, TR, Max, Sets, Groups, FC, Hits).""" + self.model.set_rows(rows) + for column in CATEGORY_COLUMNS: + self._refresh_category_options(column) + + def selected_compound(self): + """Return the Compound id of the currently selected row, or None.""" + indexes = self.view.selectionModel().selectedRows() + if not indexes: + return None + source_index = self.proxy.mapToSource(indexes[0]) + return self.model.raw_row(source_index.row())[0] diff --git a/code/tests/conftest.py b/code/tests/conftest.py index 16196ec..1fa95c4 100644 --- a/code/tests/conftest.py +++ b/code/tests/conftest.py @@ -11,3 +11,28 @@ CODE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) if CODE_DIR not in sys.path: sys.path.insert(0, CODE_DIR) + +# PyQt5's "offscreen" platform plugin lets QApplication/QWidget subclasses be +# constructed and exercised with no real display -- this does NOT make +# main.py importable headlessly (the main<->ui_functions circular import +# documented in CLAUDE.md is unrelated and still applies), but it does mean +# standalone Qt-coupled modules that don't import main.py (e.g. searchtree.py) +# can have their actual widget/model/signal behaviour covered by real tests +# instead of relying solely on manual testing. Set before any test imports +# PyQt5, and only if the environment hasn't already chosen a platform. +os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen') + +import pytest + + +@pytest.fixture(scope='session') +def qapp(): + """One shared QApplication for the whole test session -- constructing + more than one in the same process crashes, so any test touching real + Qt widgets/models should depend on this fixture rather than creating + its own.""" + from PyQt5.QtWidgets import QApplication + app = QApplication.instance() + if app is None: + app = QApplication([]) + return app diff --git a/code/tests/test_searchtree.py b/code/tests/test_searchtree.py new file mode 100644 index 0000000..c847f65 --- /dev/null +++ b/code/tests/test_searchtree.py @@ -0,0 +1,169 @@ +from PyQt5 import QtCore, QtWidgets + +from searchtree import COLUMNS, IonFilterProxyModel, IonTableModel, SearchTreePanel +from treefilters import CategoryFilter, RangeFilter, TextFilter + +SAMPLE_ROWS = [ + ('c1', 101.5, 1.0, 1000, ' Blanks 0um_Ce', ' Blanks 0um_Ce', 1.5, 2), + ('c2', 250.25, 2.0, 2000, ' 250um_Ce', ' 250um_Ce', 3.0, 0), + ('c3', 99.9, 0.5, 500, ' Media', ' Media', 0.5, 5), +] + + +def make_model_and_proxy(): + model = IonTableModel() + model.set_rows(SAMPLE_ROWS) + proxy = IonFilterProxyModel() + proxy.setSourceModel(model) + return model, proxy + + +# --------------------------------------------------------------------------- # +# IonTableModel +# --------------------------------------------------------------------------- # + +def test_model_row_and_column_counts(qapp): + model, _ = make_model_and_proxy() + assert model.rowCount() == 3 + assert model.columnCount() == len(COLUMNS) + + +def test_model_header_labels(qapp): + model, _ = make_model_and_proxy() + for i, name in enumerate(COLUMNS): + assert model.headerData(i, 1) == name # Qt.Horizontal == 1 + + +def test_model_raw_role_returns_unformatted_value(qapp): + from PyQt5.QtCore import QModelIndex + model, _ = make_model_and_proxy() + index = model.index(0, 1) # c1's m/z + assert model.data(index, IonTableModel.RAW_ROLE) == 101.5 + + +def test_model_display_role_formats_numeric_columns(qapp): + model, _ = make_model_and_proxy() + mz_index = model.index(0, 1) + assert model.data(mz_index) == '101.5000' + hits_index = model.index(0, 7) + assert model.data(hits_index) == '2' + + +# --------------------------------------------------------------------------- # +# IonFilterProxyModel +# --------------------------------------------------------------------------- # + +def visible_compounds(proxy): + return [proxy.data(proxy.index(r, 0)) for r in range(proxy.rowCount())] + + +def test_no_filters_shows_every_row(qapp): + _, proxy = make_model_and_proxy() + assert set(visible_compounds(proxy)) == {'c1', 'c2', 'c3'} + + +def test_text_filter_on_compound_column(qapp): + _, proxy = make_model_and_proxy() + proxy.set_filter(0, TextFilter('c2')) + assert visible_compounds(proxy) == ['c2'] + + +def test_range_filter_on_numeric_column(qapp): + _, proxy = make_model_and_proxy() + proxy.set_filter(7, RangeFilter(minimum=1)) # Hits >= 1 + assert set(visible_compounds(proxy)) == {'c1', 'c3'} + + +def test_category_filter_on_groups_column(qapp): + _, proxy = make_model_and_proxy() + proxy.set_filter(4, CategoryFilter(frozenset({'Media'}))) + assert visible_compounds(proxy) == ['c3'] + + +def test_combined_filters_across_columns(qapp): + _, proxy = make_model_and_proxy() + proxy.set_filter(6, RangeFilter(minimum=1, maximum=2)) # FC in [1,2] + proxy.set_filter(4, CategoryFilter(frozenset({'0um_Ce'}))) + assert visible_compounds(proxy) == ['c1'] + + +def test_clearing_a_filter_restores_rows(qapp): + _, proxy = make_model_and_proxy() + proxy.set_filter(0, TextFilter('c2')) + assert len(visible_compounds(proxy)) == 1 + proxy.set_filter(0, None) + assert len(visible_compounds(proxy)) == 3 + + +def test_sorting_numeric_column_is_numeric_not_lexicographic(qapp): + _, proxy = make_model_and_proxy() + proxy.sort(1, 0) # sort by m/z ascending (Qt.AscendingOrder == 0) + mz_values = [proxy.data(proxy.index(r, 1), IonTableModel.RAW_ROLE) for r in range(proxy.rowCount())] + assert mz_values == sorted(mz_values) + + +# --------------------------------------------------------------------------- # +# SearchTreePanel -- the actual Designer-widget-swap, end to end +# --------------------------------------------------------------------------- # + +def make_host_with_tree_widget(): + """Mimic the relevant slice of ui_main.py's setupUi(): a frame containing + a layout with a QTreeWidget in it -- the exact shape SearchTreePanel + expects to replace.""" + host = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(host) + tree_widget = QtWidgets.QTreeWidget(host) + layout.addWidget(tree_widget) + return host, layout, tree_widget + + +def test_panel_removes_old_widget_and_inserts_view_in_same_slot(qapp): + host, layout, tree_widget = make_host_with_tree_widget() + assert layout.indexOf(tree_widget) == 0 + + panel = SearchTreePanel(tree_widget) + + assert layout.indexOf(tree_widget) == -1 # removed + assert layout.count() == 1 # the new container took its place + assert panel.view.parentWidget() is not None + + +def test_panel_set_rows_populates_the_view(qapp): + host, layout, tree_widget = make_host_with_tree_widget() + panel = SearchTreePanel(tree_widget) + panel.set_rows(SAMPLE_ROWS) + assert panel.proxy.rowCount() == 3 + + +def test_panel_selected_compound_maps_through_proxy_to_source(qapp): + host, layout, tree_widget = make_host_with_tree_widget() + panel = SearchTreePanel(tree_widget) + panel.set_rows(SAMPLE_ROWS) + + # Filter down to just c3, select the only visible row, and confirm + # selected_compound() correctly maps the (filtered, possibly reordered) + # proxy row back to the right source row rather than assuming row 0 + # of the proxy lines up with row 0 of the unfiltered data. + panel.proxy.set_filter(0, TextFilter('c3')) + proxy_index = panel.proxy.index(0, 0) + panel.view.selectionModel().select( + proxy_index, + QtCore.QItemSelectionModel.Select | QtCore.QItemSelectionModel.Rows, + ) + assert panel.selected_compound() == 'c3' + + +def test_panel_selected_compound_none_when_nothing_selected(qapp): + host, layout, tree_widget = make_host_with_tree_widget() + panel = SearchTreePanel(tree_widget) + panel.set_rows(SAMPLE_ROWS) + assert panel.selected_compound() is None + + +def test_panel_category_filter_options_repopulate_on_set_rows(qapp): + host, layout, tree_widget = make_host_with_tree_widget() + panel = SearchTreePanel(tree_widget) + panel.set_rows(SAMPLE_ROWS) + + _button, _menu, actions = panel._category_buttons[4] + assert set(actions.keys()) == {'Blanks', '0um_Ce', '250um_Ce', 'Media'} From eba6688f3a900dd9a727e13af0c63323e7c4383b Mon Sep 17 00:00:00 2001 From: Robert Samples Date: Sun, 28 Jun 2026 21:55:21 -0400 Subject: [PATCH 3/7] Wire SearchTreePanel into MainWindow, replacing the feature-search QTreeWidget self.searchtree = SearchTreePanel(self.ui.treeWidget) constructed once in MainWindow.__init__, right where the old itemSelectionChanged connection used to be (before uiDefinitions() runs, which is where the column-hide call also needed updating to self.searchtree.view.hideColumn(5)). fillfttree() now builds plain row tuples (Compound, m/z, TR, Max, Sets, Groups, FC, Hits -- raw values, not pre-formatted strings) and calls self.searchtree.set_rows() instead of clear()/addTopLevelItem() in a loop. on_tree_item_selection_changed() reads self.searchtree.selected_ compound() instead of treeWidget.selectedItems(). Removed the now-fully- unused NumericalTreeWidgetItem class (the new model/proxy sort numerically via raw typed values instead of parsing display strings, so the old __lt__-override hack isn't needed) and a dead local `itemdict` variable in fillfttree() that was built but never read. The OTHER tree widget (self.ftrdialog.ui.treeWidget, the per-feature NPAtlas "Hits" results list in the compound-details dialog, populated by runsearch()) is untouched -- different widget, different dialog, out of scope for this request. 127 tests passing (includes the 16 added for searchtree.py/treefilters.py last commit). This is the part that needs your hands-on testing: open the feature search tab, confirm the per-column filter bar appears and works (text search on Compound, min/max range on the numeric columns, checkbox dropdown on Sets/Groups), and that selecting a row still highlights the feature everywhere else as before. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 33 +++++++++++++++++++++++---- code/main.py | 53 ++++++++++++++++---------------------------- code/ui_functions.py | 2 +- 3 files changed, 49 insertions(+), 39 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 58696fb..3fd1a72 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,6 +58,20 @@ that way. Required deps (gate startup): `epam.indigo`→`indigo`, `UpSetPlot`→ core), `dbsearch.py` (`fulldbsearch()`'s NPAtlas ppm-window matching core). Each corresponding `MainWindow` method is now a thin wrapper: call the module function, then apply the result to widgets/`self`. +- **Runtime widget substitution into a Designer placeholder** is an + established pattern here, not a one-off — `plotting.py` does it for every + matplotlib canvas (inserted into a Designer-created `QFrame`), and + `searchtree.py`'s `SearchTreePanel` does it for the feature-search tab's + tree: it removes `ui_main.py`'s Designer-created `QTreeWidget` from its + layout at runtime and inserts a real `QTreeView` + per-column filter bar + in the same slot. No generated file is edited; re-opening the `.ui` file + in Qt Designer would still show the old `QTreeWidget`, only the running + app uses the substitute. `SearchTreePanel`'s filter-matching rules + (per-column text/numeric-range/multi-select-category) live in the + Qt-free, unit-tested `treefilters.py`; `searchtree.py` is just the Qt + model/proxy/widget plumbing wired to it. `MainWindow.fillfttree()`/ + `on_tree_item_selection_changed()` talk to `self.searchtree` (`set_rows()`/ + `selected_compound()`) instead of `QTreeWidgetItem`s now. - **Canonical peak table** (what MPACT consumes; Progenesis = native): CSV, 3 header rows; row 2 = `Compound,m/z,Retention time (min),`; col0 = `RT_mz` id, col1 = m/z, col2 = RT. Rows 0–1 are overwritten by @@ -86,15 +100,26 @@ import). ## Testing -Headless unit tests in `code/tests/` (pure-logic only — no GUI). Run: +Headless unit tests in `code/tests/` (mostly Qt-free logic, plus standalone +Qt widget/model tests that don't import `main.py` — see below). Run: ``` python -m pytest code/tests -q ``` -Covers `filter`, `stats`, `importdependencies`, `translators`, `groupsets`. Add -tests here for any new Qt-free logic. GUI behaviour must be verified by running -the app (can't be tested headlessly). +Covers `filter`, `stats`, `importdependencies`, `translators`, `groupsets`, +`searchtree`. Add tests here for any new Qt-free logic. + +`conftest.py` sets `QT_QPA_PLATFORM=offscreen` and provides a session-scoped +`qapp` fixture: PyQt5 widgets/models/signals *can* be exercised headlessly via +Qt's offscreen platform plugin, as long as the module under test doesn't +import `main.py` (the documented `main`⇄`ui_functions` circular import is a +separate, unrelated constraint and still applies — `main.py` itself still +can't be imported standalone). `searchtree.py`'s tests are a real example: +they construct an actual `QTreeWidget` in an actual layout and exercise the +full runtime widget swap, not just the Qt-free filter logic underneath it. +Full end-to-end GUI behaviour (anything that requires `main.py`/`MainWindow`) +still must be verified by running the app. ## Groupsets (`groupsets.py`) diff --git a/code/main.py b/code/main.py index 05e9b4c..5cb3bdf 100644 --- a/code/main.py +++ b/code/main.py @@ -40,6 +40,7 @@ from csvcache import cached_read_csv, invalidate as invalidate_csv_cache from biogroups import compute_biological_groups from dbsearch import search_npatlas +from searchtree import SearchTreePanel from plotting import plot_abund, show_spectrum, show_featureplt, plot_heatmap, plot_mzrt, plot_samplecorr, kendrick, plot_volcano, plot_fc3d, plot_dendrogram, plot_PCA, prev_cv, gen_upsetplt, gen_treemap import getfragdb @@ -111,14 +112,6 @@ generated each time if filtering is off but they are always true ''' -class NumericalTreeWidgetItem(QtWidgets.QTreeWidgetItem): - def __lt__(self, other): - column = self.treeWidget().sortColumn() # the second column - try: - return float(self.text(column)) < float(other.text(column)) - except ValueError: # fallback to alphabetical sorting if the text is not a number - return super().__lt__(other) - class query: """Legacy groupset shape, kept only so old ``.mpct`` files (which pickle this class by qualified name ``main.query``) can still be unpickled. @@ -293,7 +286,11 @@ def __init__(self): self.highlight = self._plotslots.highlight self.ui.btn_run.clicked.connect(self.run_analysis) - self.ui.treeWidget.itemSelectionChanged.connect(self.on_tree_item_selection_changed) + # Replaces the Designer-created QTreeWidget with a real QTreeView + + # per-column filter bar (searchtree.py) -- doesn't edit ui_main.py, + # see SearchTreePanel's docstring for how the runtime swap works. + self.searchtree = SearchTreePanel(self.ui.treeWidget) + self.searchtree.view.selectionModel().selectionChanged.connect(self.on_tree_item_selection_changed) def moveWindow(event): @@ -609,35 +606,23 @@ def fillfttree(self): index_col=None ) iondict = iondict[iondict['hits'] >= 0] - self.ui.treeWidget.setSortingEnabled(True) - itemdict = {} - self.ui.treeWidget.clear() - for i, row in iondict.iterrows(): - item = NumericalTreeWidgetItem([ - row['Compound'], - str(round(row['m/z'], 4)), - str(round(row['Retention time (min)'], 3)), - str(int(row['max'])), - str(row['groups']), - str(row['groups']), - str(round(row['fc'], 2)), - str(int(row['hits'])) - ]) - itemdict[i] = item - self.ui.treeWidget.addTopLevelItem(item) - # Optionally expand all items - # self.ui.treeWidget.expandAll() + # Rows in searchtree.COLUMNS order (Compound, m/z, TR, Max, Sets, + # Groups, FC, Hits) -- raw values, not pre-formatted strings; the + # model formats numeric columns for display and keeps the raw value + # available for proper numeric sort/filter (see IonTableModel). + rows = [ + (row['Compound'], row['m/z'], row['Retention time (min)'], row['max'], + row['groups'], row['groups'], row['fc'], row['hits']) + for _, row in iondict.iterrows() + ] + self.searchtree.set_rows(rows) + - def on_tree_item_selection_changed(self): - selected_items = self.ui.treeWidget.selectedItems() - if selected_items: - item = selected_items[0] - # Get the name from the first column - name = item.text(0) - #print(f"Selection changed: {name}") + name = self.searchtree.selected_compound() + if name: # Call the method to highlight the feature self.highlight_feature(name) diff --git a/code/ui_functions.py b/code/ui_functions.py index 7701a64..e0bbf68 100644 --- a/code/ui_functions.py +++ b/code/ui_functions.py @@ -157,7 +157,7 @@ def uiDefinitions(self): self.ftrdialog.setWindowFlag(QtCore.Qt.FramelessWindowHint) self.ftrdialog.setWindowFlag(QtCore.Qt.WindowStaysOnTopHint) self.ftrdialog.ui.treeWidget.hideColumn(5) - self.ui.treeWidget.hideColumn(5) #hide sets column for now + self.searchtree.view.hideColumn(5) #hide sets column for now self.ftrdialog.hide() self.ftrdialog.ui.btn_related.hide() From f942e9a85268754ad5f1629ff90ed89290055e5a Mon Sep 17 00:00:00 2001 From: Robert Samples Date: Sun, 28 Jun 2026 22:47:06 -0400 Subject: [PATCH 4/7] Fix filter-bar/tree styling to match the app's dark theme The new QTreeView is a different widget instance than the Designer- created QTreeWidget it replaces, so it never inherited the dark-theme stylesheet setupUi() had already applied to the original (rgb(70,70,70) borders/backgrounds, rgb(200,200,200) light-grey text) -- it rendered with default (light) Qt widget styling instead, clashing badly with the rest of the app. Now copies old_tree_widget.styleSheet() onto the new view before discarding the old widget, rather than guessing/hardcoding colours that could drift from whatever Designer actually has set. The new filter-bar widgets (text boxes, range inputs, category dropdown buttons/menus) get a matching dark stylesheet of their own, since there's no pre-existing widget to copy from for those. Co-Authored-By: Claude Sonnet 4.6 --- code/searchtree.py | 44 ++++++++++++++++++++++++++++++++++- code/tests/test_searchtree.py | 13 +++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/code/searchtree.py b/code/searchtree.py index 29310de..a4baa57 100644 --- a/code/searchtree.py +++ b/code/searchtree.py @@ -39,6 +39,38 @@ 7: '{:.0f}', # Hits } +# Matches the dark palette ui_main.py already sets on the Designer treeWidget +# (rgb(70,70,70)-family backgrounds, rgb(200,200,200) light-grey text) -- +# these widgets are newly created at runtime, so they don't pick that up +# automatically the way the treeWidget itself did from setupUi(). +_FILTER_BAR_STYLE = """ +QWidget { + background-color: rgba(70,70,70,25); +} +QLineEdit, QToolButton { + background-color: rgb(50,50,50); + color: rgb(200,200,200); + border: 1px solid rgb(70,70,70); + border-radius: 2px; + padding: 2px; +} +QLabel { + color: rgb(200,200,200); + background: transparent; +} +QToolButton::menu-indicator { + image: none; +} +QMenu { + background-color: rgb(50,50,50); + color: rgb(200,200,200); + border: 1px solid rgb(70,70,70); +} +QMenu::item:selected { + background-color: rgb(70,70,70); +} +""" + class IonTableModel(QtCore.QAbstractTableModel): """One row per feature; columns match COLUMNS. Stores raw (unformatted, @@ -136,6 +168,12 @@ def __init__(self, old_tree_widget): index = layout.indexOf(old_tree_widget) parent = old_tree_widget.parentWidget() + # Reuse the Designer-set dark-theme stylesheet rather than guessing + # colours -- the new QTreeView is a genuinely different widget + # instance, so it doesn't inherit what setupUi() applied to the one + # being replaced. + old_stylesheet = old_tree_widget.styleSheet() + layout.removeWidget(old_tree_widget) old_tree_widget.setParent(None) old_tree_widget.deleteLater() @@ -151,6 +189,8 @@ def __init__(self, old_tree_widget): self.view.setAlternatingRowColors(True) self.view.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) self.view.setUniformRowHeights(True) + if old_stylesheet: + self.view.setStyleSheet(old_stylesheet) container = QtWidgets.QWidget(parent) outer = QtWidgets.QVBoxLayout(container) @@ -158,8 +198,10 @@ def __init__(self, old_tree_widget): outer.setSpacing(2) filter_bar = QtWidgets.QWidget(container) + filter_bar.setStyleSheet(_FILTER_BAR_STYLE) self._filter_layout = QtWidgets.QHBoxLayout(filter_bar) - self._filter_layout.setContentsMargins(0, 0, 0, 0) + self._filter_layout.setContentsMargins(4, 2, 4, 2) + self._filter_layout.setSpacing(4) self._category_buttons = {} for column in range(len(COLUMNS)): diff --git a/code/tests/test_searchtree.py b/code/tests/test_searchtree.py index c847f65..c6d16fa 100644 --- a/code/tests/test_searchtree.py +++ b/code/tests/test_searchtree.py @@ -128,6 +128,19 @@ def test_panel_removes_old_widget_and_inserts_view_in_same_slot(qapp): assert panel.view.parentWidget() is not None +def test_panel_copies_the_old_widgets_stylesheet_onto_the_new_view(qapp): + """The new QTreeView is a different widget instance, so it doesn't + automatically inherit whatever Designer's setupUi() applied to the + widget it's replacing -- confirm it's carried across explicitly rather + than defaulting to unstyled (light-themed) widgets in a dark-themed app.""" + host, layout, tree_widget = make_host_with_tree_widget() + tree_widget.setStyleSheet('QTreeView { background-color: rgb(50,50,50); }') + + panel = SearchTreePanel(tree_widget) + + assert panel.view.styleSheet() == 'QTreeView { background-color: rgb(50,50,50); }' + + def test_panel_set_rows_populates_the_view(qapp): host, layout, tree_widget = make_host_with_tree_widget() panel = SearchTreePanel(tree_widget) From dde27c65db6ef8515cf00a5d7e52561c6266494d Mon Sep 17 00:00:00 2001 From: Robert Samples Date: Sun, 28 Jun 2026 22:55:06 -0400 Subject: [PATCH 5/7] Replace inline filter bar with a collapsible toggle panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8 columns' worth of filter widgets (1 text box, 5 numeric min/max pairs, 2 category dropdowns -- 13 widgets) doesn't fit comfortably in one horizontal strip above a tree that already lives in a narrow side panel. Restructured into a single-line "Filters ▸" toggle button that shows/hides a vertical panel (QFormLayout: one filter per row, each with the column name as its label and room to breathe) instead of fighting for horizontal space. Collapsed by default, so nothing changes for anyone who doesn't open it. Removed the now-redundant per-widget labels (the text filter's placeholder no longer repeats the column name, the range filter drops its inline QLabel, the category button just says "Select...") since the form row itself already shows the column name. Caught a real Qt subtlety while testing the toggle: QWidget.isVisible() reflects the whole ancestor chain, not just a widget's own explicit visibility flag -- the toggle test failed until the test's host widget was actually shown, which is a good reminder of exactly why testing this for real (offscreen Qt) is worth more than reading the code and assuming it works. 130 tests passing. Co-Authored-By: Claude Sonnet 4.6 --- code/searchtree.py | 44 ++++++++++++++++++++++++----------- code/tests/test_searchtree.py | 21 +++++++++++++++++ 2 files changed, 52 insertions(+), 13 deletions(-) diff --git a/code/searchtree.py b/code/searchtree.py index a4baa57..897b32f 100644 --- a/code/searchtree.py +++ b/code/searchtree.py @@ -197,21 +197,43 @@ def __init__(self, old_tree_widget): outer.setContentsMargins(0, 0, 0, 0) outer.setSpacing(2) - filter_bar = QtWidgets.QWidget(container) - filter_bar.setStyleSheet(_FILTER_BAR_STYLE) - self._filter_layout = QtWidgets.QHBoxLayout(filter_bar) - self._filter_layout.setContentsMargins(4, 2, 4, 2) + # A single-line toggle rather than always showing all 8 filters at + # once -- one text box, five min/max range pairs, and two category + # dropdowns doesn't fit comfortably side by side in a narrow side + # panel. Collapsed by default so it doesn't change anything for + # someone who never needs to filter. + toggle_row = QtWidgets.QWidget(container) + toggle_row.setStyleSheet(_FILTER_BAR_STYLE) + toggle_layout = QtWidgets.QHBoxLayout(toggle_row) + toggle_layout.setContentsMargins(4, 2, 4, 2) + self.toggle_button = QtWidgets.QToolButton(toggle_row) + self.toggle_button.setCheckable(True) + self.toggle_button.setText('Filters ▸') + self.toggle_button.toggled.connect(self._on_toggle_filters) + toggle_layout.addWidget(self.toggle_button) + toggle_layout.addStretch() + + self.filter_panel = QtWidgets.QWidget(container) + self.filter_panel.setStyleSheet(_FILTER_BAR_STYLE) + self._filter_layout = QtWidgets.QFormLayout(self.filter_panel) + self._filter_layout.setContentsMargins(6, 4, 6, 4) self._filter_layout.setSpacing(4) + self.filter_panel.setVisible(False) self._category_buttons = {} for column in range(len(COLUMNS)): - self._filter_layout.addWidget(self._build_filter_widget(column)) + self._filter_layout.addRow(COLUMNS[column], self._build_filter_widget(column)) - outer.addWidget(filter_bar) + outer.addWidget(toggle_row) + outer.addWidget(self.filter_panel) outer.addWidget(self.view) layout.insertWidget(index, container) + def _on_toggle_filters(self, checked): + self.filter_panel.setVisible(checked) + self.toggle_button.setText('Filters ▾' if checked else 'Filters ▸') + def _build_filter_widget(self, column): if column in TEXT_COLUMNS: return self._build_text_filter(column) @@ -221,7 +243,7 @@ def _build_filter_widget(self, column): def _build_text_filter(self, column): edit = QtWidgets.QLineEdit() - edit.setPlaceholderText(COLUMNS[column]) + edit.setPlaceholderText('contains…') edit.setClearButtonEnabled(True) edit.textChanged.connect( lambda text, c=column: self.proxy.set_filter(c, TextFilter(text) if text else None)) @@ -231,16 +253,13 @@ def _build_range_filter(self, column): box = QtWidgets.QWidget() layout = QtWidgets.QHBoxLayout(box) layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(2) + layout.setSpacing(4) - label = QtWidgets.QLabel(COLUMNS[column]) min_edit = QtWidgets.QLineEdit() min_edit.setPlaceholderText('min') - min_edit.setMaximumWidth(60) min_edit.setValidator(QtGui.QDoubleValidator()) max_edit = QtWidgets.QLineEdit() max_edit.setPlaceholderText('max') - max_edit.setMaximumWidth(60) max_edit.setValidator(QtGui.QDoubleValidator()) def update_filter(*_args): @@ -252,14 +271,13 @@ def update_filter(*_args): min_edit.textChanged.connect(update_filter) max_edit.textChanged.connect(update_filter) - layout.addWidget(label) layout.addWidget(min_edit) layout.addWidget(max_edit) return box def _build_category_filter(self, column): button = QtWidgets.QToolButton() - button.setText(COLUMNS[column] + ' ▾') + button.setText('Select…') button.setPopupMode(QtWidgets.QToolButton.InstantPopup) menu = QtWidgets.QMenu(button) button.setMenu(menu) diff --git a/code/tests/test_searchtree.py b/code/tests/test_searchtree.py index c6d16fa..8951eec 100644 --- a/code/tests/test_searchtree.py +++ b/code/tests/test_searchtree.py @@ -173,6 +173,27 @@ def test_panel_selected_compound_none_when_nothing_selected(qapp): assert panel.selected_compound() is None +def test_filter_panel_collapsed_by_default(qapp): + host, layout, tree_widget = make_host_with_tree_widget() + panel = SearchTreePanel(tree_widget) + assert panel.filter_panel.isVisible() is False + assert not panel.toggle_button.isChecked() + + +def test_toggle_button_shows_and_hides_the_filter_panel(qapp): + host, layout, tree_widget = make_host_with_tree_widget() + panel = SearchTreePanel(tree_widget) + host.show() # isVisible() reflects the whole ancestor chain, not just + # the widget's own explicit flag -- without showing host, filter_panel + # would never be considered "visible" regardless of setVisible(True). + + panel.toggle_button.setChecked(True) + assert panel.filter_panel.isVisible() is True + + panel.toggle_button.setChecked(False) + assert panel.filter_panel.isVisible() is False + + def test_panel_category_filter_options_repopulate_on_set_rows(qapp): host, layout, tree_widget = make_host_with_tree_widget() panel = SearchTreePanel(tree_widget) From 8a569bb1cb6cf0b967ab20a7f6b9fdd19f470a73 Mon Sep 17 00:00:00 2001 From: Robert Samples Date: Sun, 28 Jun 2026 23:04:32 -0400 Subject: [PATCH 6/7] Remove alternating row colours -- the original table never had them Added setAlternatingRowColors(True) myself, unrequested, when building this. The original QTreeWidget never set it (confirmed: zero references anywhere in the codebase), and the app's QPalette was never themed dark for the AlternateBase role -- only the QSS stylesheets carry the dark theme -- so Qt's default light-grey alternate colour showed through starkly against everything else, looking far more intense than intended. Co-Authored-By: Claude Sonnet 4.6 --- code/searchtree.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/code/searchtree.py b/code/searchtree.py index 897b32f..3d58055 100644 --- a/code/searchtree.py +++ b/code/searchtree.py @@ -186,7 +186,11 @@ def __init__(self, old_tree_widget): self.view.setModel(self.proxy) self.view.setSortingEnabled(True) self.view.setRootIsDecorated(False) - self.view.setAlternatingRowColors(True) + # Not the original QTreeWidget's behaviour -- it never set this, and + # the app's underlying QPalette was never themed dark for the + # AlternateBase role (only the QSS stylesheets are dark), so Qt's + # default (light grey) alternate-row colour clashes badly here. + self.view.setAlternatingRowColors(False) self.view.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) self.view.setUniformRowHeights(True) if old_stylesheet: From 79e38e0007caa66d9ff301a67ebb5ac9a13470e3 Mon Sep 17 00:00:00 2001 From: Robert Samples Date: Sun, 28 Jun 2026 23:39:24 -0400 Subject: [PATCH 7/7] CI: install PyQt5 so test_searchtree.py can be collected test_searchtree.py imports PyQt5 directly -- the CI install step never needed it before (everything tested was Qt-free), so collection failed outright on every OS/Python combination with ModuleNotFoundError: No module named 'PyQt5'. PyQt5 wheels bundle their own Qt libs including the "offscreen" platform plugin conftest.py already selects, so no Xvfb/real display is needed -- but on a minimal Ubuntu CI image they still dynamically link against a few system libraries (libgl1, libxkbcommon-x11-0, libxcb-cursor0) the base image doesn't ship, so added those via apt on Linux specifically. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/tests.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c1c83bf..3990924 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,7 +19,13 @@ jobs: - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - run: pip install "numpy<2" pandas scipy tqdm pytest + # PyQt5 wheels bundle their own Qt libs (including the "offscreen" + # platform plugin conftest.py selects, so no real display/Xvfb is + # needed), but on a minimal Ubuntu image they still dynamically link + # against a few system libraries the base image doesn't ship. + - if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libgl1 libxkbcommon-x11-0 libxcb-cursor0 + - run: pip install "numpy<2" pandas scipy tqdm pytest PyQt5 - run: python -m pytest code/tests -v lint: