From 6b235094453ba0065ea790ca491bbd13f7c6d0bc Mon Sep 17 00:00:00 2001 From: appleb_m Date: Mon, 3 Nov 2025 16:27:05 +0100 Subject: [PATCH] GUI:" tell_sample_panel.py added filters to sample spreadsheet --- gui/src/aaregui/models/user_sample_model.py | 146 +++++++++++++++++++- gui/src/aaregui/panels/tell_sample_panel.py | 82 ++++++++++- 2 files changed, 224 insertions(+), 4 deletions(-) diff --git a/gui/src/aaregui/models/user_sample_model.py b/gui/src/aaregui/models/user_sample_model.py index dd9661c1..c7085c66 100644 --- a/gui/src/aaregui/models/user_sample_model.py +++ b/gui/src/aaregui/models/user_sample_model.py @@ -1,3 +1,5 @@ +import re + from PySide6.QtCore import QAbstractTableModel, Qt, QMimeData from PySide6.QtGui import QBrush, QColor @@ -30,7 +32,7 @@ class UserSampleSpreadsheet(QAbstractTableModel): parent=None, samples: list[SampleShortInfo] | None = None, current_puck: str | None = None, - current_sample: int | None = None, + current_sample: int | None = None ): super().__init__(parent) if samples is None: @@ -50,8 +52,16 @@ class UserSampleSpreadsheet(QAbstractTableModel): self.current_puck = current_puck self.__sort_col = 3 self.__sort_order = Qt.SortOrder.AscendingOrder + + self.__filters: dict[int, str] = {} + self.__filter_col: int | None = 5 + self.__filter_value: str | None = None + self.current_pgroup: str | None = None + self.show_all_pgroups: bool = False + self._sort() + def rowCount(self, parent=None): return len(self.__sorted_samples) @@ -104,9 +114,10 @@ class UserSampleSpreadsheet(QAbstractTableModel): def _sort(self): + filtered = self._apply_filter(self.samples) if self.__sort_col == 3: self.__sorted_samples = sorted( - self.samples, + filtered, key=lambda row: row.loc_str_sort(), reverse=( self.__sort_order == Qt.SortOrder.DescendingOrder @@ -115,13 +126,35 @@ class UserSampleSpreadsheet(QAbstractTableModel): else: # Sort the samples based on the specified column and order self.__sorted_samples = sorted( - self.samples, + filtered, key=lambda row: get_entry(row, self.__sort_col), reverse=( self.__sort_order == Qt.SortOrder.DescendingOrder ), # Reverse for descending order ) + def _apply_filter(self, rows: list[SampleShortInfo]) -> list[SampleShortInfo]: + # Default filter by User using current p-group if no explicit filter set + filters: dict[int, str] = {col: v for col, v in (self.__filters or {}).items() if (v or "").strip()} + + if self.__filter_col is not None and (self.__filter_value or "").strip(): + filters[self.__filter_col] = self.__filter_value + + if 5 not in filters and self.current_pgroup and not self.show_all_pgroups: + filters[5] = self.current_pgroup + + if not filters: + return rows + + def matches(sample: SampleShortInfo) -> bool: + for col, txt in filters.items(): + val = get_entry(sample, col) + s = "" if val is None else str(val) + if str(txt).strip().lower() not in s.strip().lower(): + return False + return True + + return [r for r in rows if matches(r)] def flags(self, index): default_flags = super().flags(index) @@ -145,3 +178,110 @@ class UserSampleSpreadsheet(QAbstractTableModel): def get_id(self, row: int) -> SampleShortInfo: return self.__sorted_samples[row] + + + def set_filter(self, field: str, text: str | None): + try: + col = self.header.index(field) + except ValueError: + col = None + self.layoutAboutToBeChanged.emit() + self.__filter_col = col if text and text.strip() and col is not None else None + self.__filter_value = text.strip() if text else None + # mirror into multi-filter map + if col is not None: + if text and text.strip(): + self.__filters[col] = text.strip() + else: + self.__filters.pop(col, None) + self._sort() + self.layoutChanged.emit() + + def clear_filter(self): + self.layoutAboutToBeChanged.emit() + self.__filter_col = None + self.__filter_value = None + self.__filters.clear() + self._sort() + self.layoutChanged.emit() + + def set_default_user_filter(self, pgroup: str | None): + self.layoutAboutToBeChanged.emit() + self.current_pgroup = (pgroup or "").strip() or None + self._sort() + self.layoutChanged.emit() + + def set_column_filter(self, column: int, text: str | None): + self.layoutAboutToBeChanged.emit() + if text and text.strip(): + self.__filters[column] = text.strip() + else: + self.__filters.pop(column, None) + self._sort() + self.layoutChanged.emit() + + def clear_all_column_filters(self): + self.clear_filter() + + def set_show_all_pgroups(self, show_all: bool): + self.layoutAboutToBeChanged.emit() + self.show_all_pgroups = bool(show_all) + self._sort() + self.layoutChanged.emit() + + def unique_values_for_column(self, column: int, limit: int = 200) -> list[str]: + seen: set[str] = set() + out: list[str] = [] + for s in self.samples: + v = get_entry(s, column) + if v is None: + continue + txt = str(v).strip() + if not txt: + continue + if txt not in seen: + seen.add(txt) + out.append(txt) + if len(out) >= limit: + break + if column == 5: + try: + out.sort(key=lambda x: int(x[1:]) if x and x[0].lower() == "p" and x[1:].isdigit() else float("inf")) + except Exception: + out.sort() + else: + out.sort() + return out + + def suggested_prefixes_for_sample_name(self, limit: int = 200) -> list[str]: + rx = re.compile(r"^([A-Za-z]+)") + counts: dict[str, int] = {} + for s in self.samples: + name = (s.sample_name or "").strip() + if not name: + continue + m = rx.match(name) + pref = m.group(1) if m else name.split("_")[0] + pref = pref.strip() + if not pref: + continue + counts[pref] = counts.get(pref, 0) + 1 + # Sort by frequency desc, then alpha + items = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0])) + return [k for k, _ in items[:limit]] + + def suggested_prefixes_for_location(self, limit: int = 200) -> tuple[list[str], list[str]]: + seg_seen: set[str] = set() + segpos_seen: set[str] = set() + for s in self.samples: + loc = s.loc_str() # "-" or like "B3-10" + if loc and loc != "-" and isinstance(loc, str) and "-" in loc: + left = loc.split("-", 1)[0] # e.g., "B3" + if left: + segpos_seen.add(left) + seg = left[0] + if seg: + seg_seen.add(seg) + segs = sorted(seg_seen) + segpos = sorted(segpos_seen, key=lambda x: (x[0], int(x[1:]) if x[1:].isdigit() else 0)) + return (segs[:limit], segpos[:limit]) \ No newline at end of file diff --git a/gui/src/aaregui/panels/tell_sample_panel.py b/gui/src/aaregui/panels/tell_sample_panel.py index 2254f9db..9984bcf7 100644 --- a/gui/src/aaregui/panels/tell_sample_panel.py +++ b/gui/src/aaregui/panels/tell_sample_panel.py @@ -9,11 +9,14 @@ from PySide6.QtWidgets import ( QPushButton, QAbstractItemView, ) + +from aaredaqlib.logger_config import setup_logger from aaredaqlib.models import BeamlineStateEnum, SampleShortInfo, SampleShortInfoList, DAQStatusModel from aaregui.models.user_sample_model import UserSampleSpreadsheet from aaregui.widgets.title_label import TitleLabel +logger = setup_logger("aareGUI") class TellSamplePanel(QFrame): mount = Signal(SampleShortInfo) @@ -70,9 +73,12 @@ class TellSamplePanel(QFrame): self.table_view.setDragEnabled(True) header = self.table_view.horizontalHeader() header.setSectionResizeMode(QHeaderView.ResizeMode.Interactive) - header.setStretchLastSection(True) # Optional: Make last column stretch to fit + header.setStretchLastSection(True) self.table_view.verticalHeader().setVisible(True) + header.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + header.customContextMenuRequested.connect(self.header_context_menu) + def unmount_button_clicked(self): self.unmount.emit() @@ -103,6 +109,76 @@ class TellSamplePanel(QFrame): if action == mount_action: self.mount.emit(sample) + def header_context_menu(self, pos): + header = self.table_view.horizontalHeader() + logical_index = header.logicalIndexAt(pos) + if logical_index < 0: + return + + col_name = self.table_model.header[logical_index] + + menu = QMenu(self) + + # Column-specific preset submenus (existing logic) ... + if logical_index == 0: + presets = self.table_model.suggested_prefixes_for_sample_name() + if presets: + prefix_menu = menu.addMenu("Filter by name prefix") + for p in presets: + act = prefix_menu.addAction(p) + act.triggered.connect( + lambda checked=False, vv=p: self.table_model.set_column_filter(logical_index, vv)) + elif logical_index == 3: + segs, segpos = self.table_model.suggested_prefixes_for_location() + if segs: + seg_menu = menu.addMenu("Filter by segment (A..F,X,R)") + for s in segs: + act = seg_menu.addAction(s) + act.triggered.connect( + lambda checked=False, vv=s: self.table_model.set_column_filter(logical_index, vv)) + if segpos: + sp_menu = menu.addMenu("Filter by segment+position (e.g. B3)") + for sp in segpos: + act = sp_menu.addAction(sp) + act.triggered.connect( + lambda checked=False, vv=sp: self.table_model.set_column_filter(logical_index, vv)) + else: + values = self.table_model.unique_values_for_column(logical_index) + if values: + choose_menu = menu.addMenu(f"Choose {col_name}") + for v in values: + act = choose_menu.addAction(v) + act.triggered.connect( + lambda checked=False, vv=v: self.table_model.set_column_filter(logical_index, vv)) + + # Manual entry and clear options + set_filter_action = menu.addAction(f"Filter column: {col_name}...") + clear_filter_action = menu.addAction(f"Clear filter: {col_name}") + clear_all_action = menu.addAction("Clear all filters") + + if logical_index == 5: + menu.addSeparator() + toggle_all = menu.addAction("Show all pgroups (ignore current p-group)") + toggle_all.setCheckable(True) + toggle_all.setChecked(self.table_model.show_all_pgroups) + def _toggle_all(): + self.table_model.set_show_all_pgroups(not self.table_model.show_all_pgroups) + toggle_all.triggered.connect(_toggle_all) + + action = menu.exec_(header.mapToGlobal(pos)) + if action is None: + return + + if action == set_filter_action: + from PySide6.QtWidgets import QInputDialog + text, ok = QInputDialog.getText(self, "Set filter", f"Filter for '{col_name}':") + if ok: + self.table_model.set_column_filter(logical_index, text) + elif action == clear_filter_action: + self.table_model.set_column_filter(logical_index, None) + elif action == clear_all_action: + self.table_model.clear_all_column_filters() + @Slot(DAQStatusModel) def update_daq_status(self, status: DAQStatusModel): sample = status.sample @@ -124,3 +200,7 @@ class TellSamplePanel(QFrame): ) except Exception as e: self.curr_sample_label.setText(f"Confusing information :/ {e}") + + if status.session.current_pgroup is not None: + self.__current_pgroup = status.session.current_pgroup + self.table_model.set_default_user_filter(self.__current_pgroup) \ No newline at end of file