From c2f75220d463bd294e6cd9e45ed16bf5936abfbd Mon Sep 17 00:00:00 2001 From: tmichela Date: Mon, 10 Feb 2025 15:45:14 +0100 Subject: [PATCH 01/13] deprecate: remove support for standalone comments in the main table --- damnit/gui/main_window.py | 37 ---------------------- damnit/gui/table.py | 65 ++++----------------------------------- tests/test_gui.py | 10 ------ 3 files changed, 6 insertions(+), 106 deletions(-) diff --git a/damnit/gui/main_window.py b/damnit/gui/main_window.py index 9ac98e98..647a8dc6 100644 --- a/damnit/gui/main_window.py +++ b/damnit/gui/main_window.py @@ -595,18 +595,6 @@ def _updates_thread_launcher(self) -> None: self.update_agent.message.connect(self.handle_update) QtCore.QTimer.singleShot(0, self._updates_thread.start) - def _set_comment_date(self): - self.comment_time.setText( - time.strftime("%H:%M %d/%m/%Y", time.localtime(time.time())) - ) - - def _comment_button_clicked(self): - ts = datetime.strptime(self.comment_time.text(), "%H:%M %d/%m/%Y").timestamp() - text = self.comment.text() - comment_id = self.db.add_standalone_comment(ts, text) - self.table.insert_comment_row(comment_id, text, ts) - self.comment.clear() - def get_run_file(self, proposal, run, log=True): file_name = self.extracted_data_template.format(proposal, run) @@ -743,7 +731,6 @@ def show_run_logs(self, proposal, run): def _create_table_model(self, db, col_settings): table = DamnitTableModel(db, col_settings, self) table.value_changed.connect(self.save_value) - table.time_comment_changed.connect(self.save_time_comment) table.run_visibility_changed.connect(lambda row, state: self.plot.update()) table.rowsInserted.connect(self.on_rows_inserted) return table @@ -773,30 +760,6 @@ def _create_view(self) -> None: collapsible = CollapsibleWidget() vertical_layout.addWidget(collapsible) - comment_horizontal_layout = QtWidgets.QHBoxLayout() - self.comment = QtWidgets.QLineEdit(self) - self.comment.setText("Time can be edited in the field on the right.") - - self.comment_time = QtWidgets.QLineEdit(self) - self.comment_time.setStyleSheet("width: 25px;") - - comment_button = QtWidgets.QPushButton("Additional comment") - comment_button.setEnabled(True) - comment_button.clicked.connect(self._comment_button_clicked) - - comment_horizontal_layout.addWidget(comment_button) - comment_horizontal_layout.addWidget(self.comment, stretch=3) - comment_horizontal_layout.addWidget(QtWidgets.QLabel("at")) - comment_horizontal_layout.addWidget(self.comment_time, stretch=1) - - collapsible.add_layout(comment_horizontal_layout) - - comment_timer = QtCore.QTimer() - self._set_comment_date() - comment_timer.setInterval(30000) - comment_timer.timeout.connect(self._set_comment_date) - comment_timer.start() - # plotting control self.plot = PlottingControls(self) plotting_group = QtWidgets.QGroupBox("Plotting controls") diff --git a/damnit/gui/table.py b/damnit/gui/table.py index 3dadfdc9..4c657b9f 100644 --- a/damnit/gui/table.py +++ b/damnit/gui/table.py @@ -18,7 +18,7 @@ ROW_HEIGHT = 30 THUMBNAIL_SIZE = 35 -COMMENT_ID_ROLE = Qt.ItemDataRole.UserRole + 1 + class TableView(QtWidgets.QTableView): settings_changed = QtCore.pyqtSignal() @@ -86,10 +86,8 @@ def setModel(self, model: 'DamnitTableModel'): # any reordering from the view level, which maps logical indices to # different visual indices, to show the columns as in the model. self.setHorizontalHeader(QtWidgets.QHeaderView(Qt.Horizontal, self)) - self.horizontalHeader().sortIndicatorChanged.connect(self.style_comment_rows) self.horizontalHeader().setSectionsClickable(True) if model is not None: - self.model().rowsInserted.connect(self.style_comment_rows) self.model().rowsInserted.connect(self.resize_new_rows) self.model().columnsInserted.connect(self.on_columns_inserted) self.model().columnsRemoved.connect(self.on_columns_removed) @@ -244,18 +242,6 @@ def add_column_states(widget): return column_states - def style_comment_rows(self, *_): - self.clearSpans() - model : DamnitTableModel = self.damnit_model - comment_col = model.find_column("Comment", by_title=True) - timestamp_col = model.find_column("Timestamp", by_title=True) - - proxy_mdl = self.model() - for row_ix in model.standalone_comment_rows(): - ix = proxy_mdl.mapFromSource(self.damnit_model.createIndex(row_ix, 0)) - self.setSpan(ix.row(), 0, 1, timestamp_col) - self.setSpan(ix.row(), comment_col, 1, 1000) - def resize_new_rows(self, parent, first, last): for row in range(first, last + 1): self.resizeRowToContents(row) @@ -364,16 +350,14 @@ def get_toolbar_widgets(self): class DamnitTableModel(QtGui.QStandardItemModel): value_changed = QtCore.pyqtSignal(int, int, str, object) - time_comment_changed = QtCore.pyqtSignal(int, str) run_visibility_changed = QtCore.pyqtSignal(int, bool) def __init__(self, db: DamnitDB, column_settings: dict, parent): self.column_ids, self.column_titles = self._load_columns(db, column_settings) n_run_rows = db.conn.execute("SELECT count(*) FROM run_info").fetchone()[0] - n_cmnt_rows = db.conn.execute("SELECT count(*) FROM time_comments").fetchone()[0] - log.info(f"Table will have {n_run_rows} runs & {n_cmnt_rows} standalone comments") + log.info(f"Table will have {n_run_rows} runs") - super().__init__(n_run_rows + n_cmnt_rows, len(self.column_ids), parent) + super().__init__(n_run_rows, len(self.column_ids), parent) self.setHorizontalHeaderLabels(self.column_titles) self._main_window = parent self.is_sorted_by = "" @@ -381,7 +365,6 @@ def __init__(self, db: DamnitDB, column_settings: dict, parent): self.db = db self.column_index = {c: i for (i, c) in enumerate(self.column_ids)} self.run_index = {} # {(proposal, run): row} - self.standalone_comment_index = {} self.processing_jobs = QtExtractionJobTracker(self) self.processing_jobs.run_jobs_changed.connect(self.update_processing_status) @@ -475,12 +458,9 @@ def image_item(self, png_data: bytes): ) return item - def comment_item(self, text, comment_id=None): + def comment_item(self, text): item = QtGui.QStandardItem(text) # Editable by default item.setToolTip(text) - if comment_id is not None: - # For standalone comments, integer ID - item.setData(comment_id, COMMENT_ID_ROLE) return item def new_item(self, value, column_id, max_diff, attrs): @@ -531,18 +511,6 @@ def _load_from_db(self): attrs = json.loads(attr_json) if attr_json else {} self.setItem(row_ix, col_ix, self.new_item(value, name, max_diff, attrs)) - comments_start = row_ix + 1 - comment_rows = self.db.conn.execute(""" - SELECT rowid, timestamp, comment FROM time_comments - """).fetchall() - for row_ix, (cid, ts, comment) in enumerate(comment_rows, start=comments_start): - self.setItem(row_ix, 3, self.text_item(ts, timestamp2str(ts))) - self.setItem( - row_ix, self.column_index["comment"], self.comment_item(comment, cid) - ) - row_headers.append('') - self.standalone_comment_index[cid] = row_ix - self.setVerticalHeaderLabels(row_headers) t1 = time.perf_counter() log.info(f"Filled rows in {t1 - t0:.3f} s") @@ -589,14 +557,6 @@ def row_to_proposal_run(self, row_ix): return None, None return prop_it.data(Qt.UserRole), run_it.data(Qt.UserRole) - def row_to_comment_id(self, row): - comment_col = 4 - item = self.item(row, comment_col) - return item and item.data(COMMENT_ID_ROLE) - - def standalone_comment_rows(self): - return sorted(self.standalone_comment_index.values()) - def precreate_runs(self, n_runs: int): proposal = self.db.metameta["proposal"] start_run = max([r for (p, r) in self.run_index if p == proposal]) + 1 @@ -655,14 +615,6 @@ def insert_run_row(self, proposal, run, contents: dict, max_diffs: dict, attrs: self.setVerticalHeaderItem(row_ix, QtGui.QStandardItem(str(run))) return row_ix - def insert_comment_row(self, comment_id: int, comment: str, timestamp: float): - blank = self.itemPrototype().clone() - ts_item = self.text_item(timestamp, display=timestamp2str(timestamp)) - row = [blank, blank, blank, ts_item, self.comment_item(comment, comment_id)] - self.standalone_comment_index[comment_id] = row_ix = self.rowCount() - self.appendRow(row) - self.setVerticalHeaderItem(row_ix, QtGui.QStandardItem('')) - def handle_run_values_changed(self, proposal, run, values: dict): known_col_ids = set(self.column_ids) new_col_ids = [c for c in values if c not in known_col_ids] @@ -838,13 +790,8 @@ def setData(self, index, value, role=None) -> bool: if not super().setData(index, display, role): return False - # Send appropriate signals if we edited a standalone comment or an - # editable column. - if comment_id := self.row_to_comment_id(index.row()): - self.time_comment_changed.emit(comment_id, value) - else: - prop, run = self.row_to_proposal_run(index.row()) - self.value_changed.emit(int(prop), int(run), changed_column, parsed) + prop, run = self.row_to_proposal_run(index.row()) + self.value_changed.emit(int(prop), int(run), changed_column, parsed) return True diff --git a/tests/test_gui.py b/tests/test_gui.py index 15d0060d..01d71472 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -769,16 +769,6 @@ def get_index(title, row=0): comment_index = get_index("Comment") win.table.setData(comment_index, "Foo", Qt.EditRole) - # Add a standalone comment - row_count = win.table.rowCount() - win.comment.setText("Bar") - win._comment_button_clicked() - assert win.table.rowCount() == row_count + 1 - - # Edit a standalone comment - comment_index = get_index("Comment", row=1) - win.table.setData(comment_index, "Foo", Qt.EditRole) - # Check that 2D arrays are treated as images image_index = get_index("Image") assert isinstance(win.table.data(image_index, role=Qt.DecorationRole), QPixmap) From 433c3b26ed7e1954942a02bb71b0180e9e461078 Mon Sep 17 00:00:00 2001 From: tmichela Date: Mon, 10 Feb 2025 15:49:52 +0100 Subject: [PATCH 02/13] feat: add a new tableView for standalone comments --- damnit/gui/standalone_comments.py | 132 ++++++++++++++++++++++++++++++ tests/test_gui.py | 48 +++++++++++ 2 files changed, 180 insertions(+) create mode 100644 damnit/gui/standalone_comments.py diff --git a/damnit/gui/standalone_comments.py b/damnit/gui/standalone_comments.py new file mode 100644 index 00000000..d3a6a11c --- /dev/null +++ b/damnit/gui/standalone_comments.py @@ -0,0 +1,132 @@ +import logging + +from PyQt5.QtCore import (QAbstractTableModel, QDateTime, QModelIndex, Qt, + QVariant) +from PyQt5.QtWidgets import (QDateTimeEdit, QDialog, QHBoxLayout, QLineEdit, + QPushButton, QTableView, QVBoxLayout, QHeaderView) + +log = logging.getLogger(__name__) + + +class CommentModel(QAbstractTableModel): + def __init__(self, db, parent=None): + super().__init__(parent) + self.db = db + self._data = [] + self._headers = ['#', 'Timestamp', 'Comment'] + self._sort_column = 1 # Default sort by timestamp + self._sort_order = Qt.DescendingOrder + self.load_comments() + + def load_comments(self): + """Load comments from the database, sorted by timestamp in descending order""" + self._data = self.db.conn.execute(""" + SELECT rowid, timestamp, comment FROM time_comments + ORDER BY timestamp DESC + """).fetchall() + + self.layoutChanged.emit() + + def data(self, index, role=Qt.DisplayRole): + if role == Qt.DisplayRole: + row = index.row() + col = index.column() + + if col == 1: + return QDateTime.fromSecsSinceEpoch( + int(self._data[row][col]) + ).toString("yyyy-MM-dd HH:mm:ss") + else: + return self._data[row][col] + return QVariant() + + def headerData(self, section, orientation, role): + if role == Qt.DisplayRole and orientation == Qt.Horizontal: + return self._headers[section] + return QVariant() + + def rowCount(self, parent=QModelIndex()): + return len(self._data) + + def columnCount(self, parent=QModelIndex()): + return len(self._headers) + + def addComment(self, timestamp, comment): + """Add a comment to the database""" + if self.db is None: + log.warning("No SQLite database in use, comment not saved") + return + + cid = self.db.add_standalone_comment(timestamp, comment) + log.debug("Saving time-based id %d", cid) + # Reload comments to reflect the latest state + self.load_comments() + + def sort(self, column, order): + """Sort table by given column number.""" + self._sort_column = column + self._sort_order = order + + self.layoutAboutToBeChanged.emit() + self._data = sorted(self._data, + key=lambda x: x[column], + reverse=(order == Qt.DescendingOrder)) + self.layoutChanged.emit() + + +class TimeComment(QDialog): + + def __init__(self, parent=None): + super().__init__(parent) + + layout = QVBoxLayout() + + # Table View + self.tableView = QTableView() + self.tableView.setSortingEnabled(True) + self.model = CommentModel(self.parent().db, self) + self.tableView.setModel(self.model) + + # Configure column widths + header = self.tableView.horizontalHeader() + for ix in range(self.model.columnCount() - 1): + header.setSectionResizeMode(ix, header.ResizeToContents) + header.setStretchLastSection(True) + # Set word wrap for the comment column + self.tableView.setWordWrap(True) + # Ensure rows resize properly + self.tableView.verticalHeader().setSectionResizeMode(QHeaderView.ResizeToContents) + + # Dialog layout + layout.addWidget(self.tableView) + + inputLayout = QHBoxLayout() + + self.timestampInput = QDateTimeEdit() + self.timestampInput.setDisplayFormat("yyyy-MM-dd HH:mm:ss") + self.timestampInput.setDateTime(QDateTime.currentDateTime()) + + self.commentInput = QLineEdit() + self.commentInput.setPlaceholderText('Comment:') + + publishButton = QPushButton("Publish") + publishButton.clicked.connect(self.publishComment) + + inputLayout.addWidget(self.timestampInput) + inputLayout.addWidget(self.commentInput) + inputLayout.addWidget(publishButton) + + layout.addLayout(inputLayout) + + self.setLayout(layout) + self.setWindowTitle('Standalone comments') + self.resize(600, 400) + + def publishComment(self): + timestamp = self.timestampInput.dateTime().toSecsSinceEpoch() + comment = self.commentInput.text() + if comment: + self.model.addComment(timestamp, comment) + self.commentInput.clear() + self.timestampInput.setDateTime(QDateTime.currentDateTime()) + self.tableView.resizeRowsToContents() diff --git a/tests/test_gui.py b/tests/test_gui.py index 01d71472..2074737d 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -24,6 +24,7 @@ from damnit.gui.main_window import AddUserVariableDialog, MainWindow from damnit.gui.open_dialog import OpenDBDialog from damnit.gui.plot import HistogramPlotWindow, ScatterPlotWindow +from damnit.gui.standalone_comments import TimeComment from damnit.gui.theme import Theme from damnit.gui.zulip_messenger import ZulipConfig @@ -1138,3 +1139,50 @@ def test_theme(mock_db, qtbot, tmp_path): win2._toggle_theme(False) assert win2.current_theme == Theme.LIGHT assert win2.palette() != dark_palette # Light theme should have different colors + + +def test_standalone_comments(mock_db, qtbot): + db_dir, db = mock_db + + win = MainWindow(db_dir, False) + win.show() + qtbot.waitExposed(win) + qtbot.addWidget(win) + + # Create and show the TimeComment dialog + dialog = TimeComment(win) + qtbot.addWidget(dialog) + dialog.show() + qtbot.waitExposed(dialog) + + model = dialog.model + + # Test adding a comment + test_timestamp = 1640995200 # 2022-01-01 00:00:00 + test_comment = "Test comment 1" + model.addComment(test_timestamp, test_comment) + + # Verify comment was added + assert model.rowCount() > 0 + index = model.index(0, 2) # Comment column + assert model.data(index, Qt.DisplayRole) == test_comment + + # Add another comment + test_timestamp2 = 1641081600 # 2022-01-02 00:00:00 + test_comment2 = "Test comment 2" + model.addComment(test_timestamp2, test_comment2) + + # Test sorting + # Sort by timestamp ascending + model.sort(1, Qt.AscendingOrder) + index = model.index(0, 2) + assert model.data(index, Qt.DisplayRole) == test_comment + + # Sort by timestamp descending + model.sort(1, Qt.DescendingOrder) + index = model.index(0, 2) + assert model.data(index, Qt.DisplayRole) == test_comment2 + + # Test comment persistence + model.load_comments() + assert model.rowCount() == 2 From ab1cb7caad6ab841602c075f979ba7bab3b6f751 Mon Sep 17 00:00:00 2001 From: tmichela Date: Mon, 10 Feb 2025 16:00:46 +0100 Subject: [PATCH 03/13] feat: add filter proxy - apply row filtering based on column value selection. - implements cathegorical filtering --- damnit/gui/table_filter.py | 242 +++++++++++++++++++++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 damnit/gui/table_filter.py diff --git a/damnit/gui/table_filter.py b/damnit/gui/table_filter.py new file mode 100644 index 00000000..a6968606 --- /dev/null +++ b/damnit/gui/table_filter.py @@ -0,0 +1,242 @@ +from enum import Enum +from math import inf, isnan +from typing import Any, Dict, Optional, Set + +from fonticon_fa6 import FA6S +from natsort import natsorted +from PyQt5 import QtCore +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QDoubleValidator, QPixmap +from PyQt5.QtWidgets import ( + QAction, + QCheckBox, + QHBoxLayout, + QLineEdit, + QListWidgetItem, + QMenu, + QPushButton, + QVBoxLayout, + QWidget, + QWidgetAction, + QGroupBox, +) +from superqt import QSearchableListWidget +from superqt.fonticon import icon +from superqt.utils import qthrottled + + +class FilterType(Enum): + CATEGORICAL = "categorical" + + +class Filter: + """Base class for all filters.""" + + def __init__(self, column: int, filter_type: FilterType): + self.column = column + self.type = filter_type + self.enabled = True + + def accepts(self, value: Any) -> bool: + """Return True if the value passes the filter.""" + raise NotImplementedError + + +class CategoricalFilter(Filter): + """Filter for categorical values based on a set of selected values.""" + + def __init__(self, column: int, selected_values: Optional[Set[Any]] = None, include_nan: bool = True): + super().__init__(column, FilterType.CATEGORICAL) + self.selected_values = set(selected_values) if selected_values else set() + self.include_nan = include_nan + + def accepts(self, value: Any) -> bool: + # Handle nan/empty values + if value is None or (isinstance(value, float) and isnan(value)): + return self.include_nan + # If no values are selected, reject all values + if not self.selected_values: + return False + return value in self.selected_values + + +class FilterProxy(QtCore.QSortFilterProxyModel): + """Proxy model that applies filters to rows.""" + + filterChanged = QtCore.pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) + self.filters: Dict[int, Filter] = {} + + def set_filter(self, column: int, filter: Optional[Filter] = None): + """Set or remove a filter for a column.""" + if filter is not None: + self.filters[column] = filter + elif column in self.filters: + del self.filters[column] + self.invalidateFilter() + self.filterChanged.emit() + + def clear_filters(self): + """Remove all filters.""" + self.filters.clear() + self.invalidateFilter() + if hasattr(self.parent(), "damnit_model"): + cid = self.parent().damnit_model.find_column("Timestamp", by_title=True) + self.parent().sortByColumn(cid, Qt.AscendingOrder) + self.filterChanged.emit() + + def filterAcceptsRow( + self, source_row: int, source_parent: QtCore.QModelIndex + ) -> bool: + """Return True if the row passes all active filters.""" + for col, filter in self.filters.items(): + if not filter.enabled: + continue + + item = self.sourceModel().index(source_row, col, source_parent) + data = item.data(Qt.UserRole) + if not filter.accepts(data): + return False + return True + + +class FilterMenu(QMenu): + """Menu for configuring filters on table columns.""" + + def __init__(self, column: int, model: FilterProxy, parent=None): + super().__init__(parent) + self.column = column + self.model = model + + self.filter_widget = self._create_filter_widget(column, model) + + # Connect filter widget to model + self.filter_widget.filterChanged.connect(self._on_filter_changed) + + # Add widget to menu + action = QWidgetAction(self) + action.setDefaultWidget(self.filter_widget) + self.addAction(action) + + # Set initial state if there's an existing filter + existing_filter = model.filters.get(column) + if existing_filter is not None: + self.filter_widget.set_filter(existing_filter) + + def _create_filter_widget(self, column: int, model: FilterProxy): + values = [] + for row in range(model.sourceModel().rowCount()): + item = model.sourceModel().index(row, column) + value = item.data(Qt.UserRole) + values.add(value) + + return CategoricalFilterWidget(column, values) + + @qthrottled(timeout=20, leading=False) + def _on_filter_changed(self, filter: Filter): + """Apply the new filter to the model.""" + self.model.set_filter(self.column, filter) + + +class CategoricalFilterWidget(QWidget): + """Widget for configuring categorical filters with a searchable list of values.""" + + filterChanged = QtCore.pyqtSignal(CategoricalFilter) + + def __init__(self, column: int, values: Set[Any], parent=None): + super().__init__(parent) + self.column = column + + layout = QVBoxLayout() + layout.setContentsMargins(5, 5, 5, 5) + + # Searchable list of values + self.list_widget = QSearchableListWidget() + self.list_widget.filter_widget.setPlaceholderText("Search values...") + self.list_widget.layout().setContentsMargins(0, 0, 0, 0) + + # All/None buttons + button_layout = QHBoxLayout() + self.all_button = QPushButton("Select All") + self.none_button = QPushButton("Select None") + button_layout.addWidget(self.all_button) + button_layout.addWidget(self.none_button) + + # NaN handling + self.include_nan = QCheckBox("Include NaN/empty values") + self.include_nan.setChecked(True) + + # Connect signals + self.list_widget.itemChanged.connect(self._on_selection_changed) + self.all_button.clicked.connect(lambda: self._set_all_checked(True)) + self.none_button.clicked.connect(lambda: self._set_all_checked(False)) + self.include_nan.toggled.connect(self._on_selection_changed) + + # Layout + layout.addLayout(button_layout) + layout.addWidget(self.list_widget) + layout.addWidget(self.include_nan) + self.setLayout(layout) + + # Add values to list (excluding nan/empty values) + for value in natsorted(values): + if value is not None and not (isinstance(value, float) and isnan(value)): + item = QListWidgetItem() + item.setData(Qt.UserRole, value) + item.setData(Qt.DisplayRole, str(value)) + item.setCheckState(Qt.Checked) + self.list_widget.addItem(item) + + def _set_all_checked(self, checked: bool): + """Set all items to checked or unchecked state.""" + for i in range(self.list_widget.count()): + self.list_widget.item(i).setCheckState( + Qt.Checked if checked else Qt.Unchecked + ) + # Emit signal after setting all items + selected = set() + if checked: + for i in range(self.list_widget.count()): + selected.add(self.list_widget.item(i).data(Qt.UserRole)) + + self.filterChanged.emit( + CategoricalFilter( + self.column, + selected_values=selected, + include_nan=self.include_nan.isChecked() + ) + ) + + def _on_selection_changed(self, item: QListWidgetItem = None): + selected = set() + for i in range(self.list_widget.count()): + item = self.list_widget.item(i) + if item.checkState() == Qt.Checked: + selected.add(item.data(Qt.UserRole)) + + self.filterChanged.emit( + CategoricalFilter( + self.column, + selected_values=selected, + include_nan=self.include_nan.isChecked() + ) + ) + + def set_filter(self, filter: Optional[CategoricalFilter]): + if filter is None: + self._set_all_checked(True) + self.include_nan.setChecked(True) + return + + # Set nan checkbox state + self.include_nan.setChecked(filter.include_nan) + + # Set item check states + for i in range(self.list_widget.count()): + item = self.list_widget.item(i) + value = item.data(Qt.UserRole) + item.setCheckState( + Qt.Checked if value in filter.selected_values else Qt.Unchecked + ) From 12787287cf88006ec93b6d363ecfb1c9d498913c Mon Sep 17 00:00:00 2001 From: tmichela Date: Mon, 10 Feb 2025 16:11:34 +0100 Subject: [PATCH 04/13] feat: add Numerical filter --- damnit/gui/table_filter.py | 213 ++++++++++++++++++++++++++++++++++++- 1 file changed, 211 insertions(+), 2 deletions(-) diff --git a/damnit/gui/table_filter.py b/damnit/gui/table_filter.py index a6968606..24c72912 100644 --- a/damnit/gui/table_filter.py +++ b/damnit/gui/table_filter.py @@ -26,6 +26,7 @@ class FilterType(Enum): + NUMERIC = "numeric" CATEGORICAL = "categorical" @@ -42,6 +43,35 @@ def accepts(self, value: Any) -> bool: raise NotImplementedError +class NumericFilter(Filter): + """Filter for numeric values with optional NaN handling.""" + + def __init__( + self, + column: int, + min_val: float = -inf, + max_val: float = inf, + include_nan: bool = True, + selected_values: Optional[Set[Any]] = None, + ): + super().__init__(column, FilterType.NUMERIC) + self.min_val = min_val + self.max_val = max_val + self.include_nan = include_nan + self.selected_values = selected_values if selected_values else set() + + def accepts(self, value: Any) -> bool: + if value is None or (isinstance(value, float) and isnan(value)): + return self.include_nan + try: + value = float(value) + if not (self.min_val <= value <= self.max_val): + return False + return value in self.selected_values + except (TypeError, ValueError): + return False + + class CategoricalFilter(Filter): """Filter for categorical values based on a set of selected values.""" @@ -126,13 +156,24 @@ def __init__(self, column: int, model: FilterProxy, parent=None): self.filter_widget.set_filter(existing_filter) def _create_filter_widget(self, column: int, model: FilterProxy): - values = [] + # Determine if column is numeric + is_numeric = True + values = set() for row in range(model.sourceModel().rowCount()): item = model.sourceModel().index(row, column) value = item.data(Qt.UserRole) values.add(value) + # Only check type for non-None, non-NaN values + if value is not None and not (isinstance(value, float) and isnan(value)): + if not isinstance(value, (int, float)): + is_numeric = False - return CategoricalFilterWidget(column, values) + # Create appropriate filter widget + if is_numeric: + filter_widget = NumericFilterWidget(column, values) + else: + filter_widget = CategoricalFilterWidget(column, values) + return filter_widget @qthrottled(timeout=20, leading=False) def _on_filter_changed(self, filter: Filter): @@ -140,6 +181,174 @@ def _on_filter_changed(self, filter: Filter): self.model.set_filter(self.column, filter) +class NumericFilterWidget(QWidget): + """Widget for configuring numeric filters with both range and value selection.""" + + filterChanged = QtCore.pyqtSignal(NumericFilter) + + def __init__(self, column: int, values: Set[Any], parent=None): + super().__init__(parent) + self.column = column + self.all_values = values + + layout = QVBoxLayout() + layout.setContentsMargins(5, 5, 5, 5) + + # Range inputs + range_group = QGroupBox("Value Range") + range_layout = QVBoxLayout() + + self.min_input = QLineEdit() + self.max_input = QLineEdit() + self.min_input.setPlaceholderText("Min") + self.max_input.setPlaceholderText("Max") + + # Create and set validator for numerical input + validator = QDoubleValidator() + validator.setNotation(QDoubleValidator.StandardNotation) + self.min_input.setValidator(validator) + self.max_input.setValidator(validator) + + range_layout.addWidget(self.min_input) + range_layout.addWidget(self.max_input) + range_group.setLayout(range_layout) + + # Value selection list + list_group = QGroupBox("Select Values") + list_layout = QVBoxLayout() + + # Searchable list of values + self.list_widget = QSearchableListWidget() + self.list_widget.filter_widget.setPlaceholderText("Search values...") + self.list_widget.layout().setContentsMargins(0, 0, 0, 0) + + # All/None buttons + button_layout = QHBoxLayout() + self.all_button = QPushButton("Select All") + self.none_button = QPushButton("Select None") + button_layout.addWidget(self.all_button) + button_layout.addWidget(self.none_button) + + # NaN handling + self.include_nan = QCheckBox("Include NaN/empty values") + self.include_nan.setChecked(True) + + list_layout.addLayout(button_layout) + list_layout.addWidget(self.list_widget) + list_group.setLayout(list_layout) + + # Main layout + layout.addWidget(range_group) + layout.addWidget(list_group) + layout.addWidget(self.include_nan) + self.setLayout(layout) + + # Populate the list initially + self._populate_list() + + # Connect signals + self.min_input.editingFinished.connect(self._on_range_changed) + self.max_input.editingFinished.connect(self._on_range_changed) + self.list_widget.itemChanged.connect(self._on_selection_changed) + self.all_button.clicked.connect(lambda: self._set_all_checked(True)) + self.none_button.clicked.connect(lambda: self._set_all_checked(False)) + self.include_nan.toggled.connect(self._emit_filter) + + def _populate_list(self): + """Populate the list widget with values that match the current range.""" + self.list_widget.clear() + + min_val = float(self.min_input.text()) if self.min_input.text() else -inf + max_val = float(self.max_input.text()) if self.max_input.text() else inf + + # Add all values to list, but only check those in range + for value in natsorted(self.all_values): + if value is not None and not (isinstance(value, float) and isnan(value)): + item = QListWidgetItem() + item.setData(Qt.UserRole, value) + item.setData(Qt.DisplayRole, str(value)) + # Check if value is in range + try: + float_val = float(value) + item.setCheckState(Qt.Checked if min_val <= float_val <= max_val else Qt.Unchecked) + except (TypeError, ValueError): + item.setCheckState(Qt.Unchecked) + self.list_widget.addItem(item) + + def _on_range_changed(self): + """Handle changes in the range inputs.""" + self._populate_list() # Update list to match range + self._emit_filter() + + def _set_all_checked(self, checked: bool): + """Set all items to checked or unchecked state.""" + self.min_input.clear() + self.max_input.clear() + for idx in range(self.list_widget.count()): + self.list_widget.item(idx).setCheckState( + Qt.Checked if checked else Qt.Unchecked + ) + self._emit_filter() + + def _on_selection_changed(self, item: QListWidgetItem = None): + """Handle changes in value selection.""" + self._emit_filter() + + def _emit_filter(self): + """Create and emit a new NumericFilter based on current widget state.""" + min_val = self.min_input.text() + max_val = self.max_input.text() + include_nan = self.include_nan.isChecked() + + # Get range values + min_val = float(min_val) if min_val else -inf + max_val = float(max_val) if max_val else inf + + # Get selected values + selected_values = { + self.list_widget.item(idx).data(Qt.UserRole) + for idx in range(self.list_widget.count()) + if self.list_widget.item(idx).checkState() == Qt.Checked + } + + self.filterChanged.emit( + NumericFilter( + self.column, + min_val=min_val, + max_val=max_val, + selected_values=selected_values, + include_nan=include_nan + ) + ) + + def set_filter(self, filter: Optional[NumericFilter]): + """Update widget state from an existing filter.""" + if filter is None: + self.min_input.clear() + self.max_input.clear() + self.include_nan.setChecked(True) + self._populate_list() + return + + if filter.min_val != -inf: + self.min_input.setText(str(filter.min_val)) + if filter.max_val != inf: + self.max_input.setText(str(filter.max_val)) + + self.include_nan.setChecked(filter.include_nan) + + # Update list and selection + self._populate_list() + if hasattr(filter, 'selected_values'): + for idx in range(self.list_widget.count()): + item = self.list_widget.item(idx) + item.setCheckState( + Qt.Checked + if item.data(Qt.UserRole) in filter.selected_values + else Qt.Unchecked + ) + + class CategoricalFilterWidget(QWidget): """Widget for configuring categorical filters with a searchable list of values.""" From a7c896dc44b54cfbb4361e9a71a202de8f3ca487 Mon Sep 17 00:00:00 2001 From: tmichela Date: Mon, 10 Feb 2025 16:13:49 +0100 Subject: [PATCH 05/13] feat: adds thumbnail filter --- damnit/gui/table_filter.py | 70 +++++++++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/damnit/gui/table_filter.py b/damnit/gui/table_filter.py index 24c72912..bb6a5821 100644 --- a/damnit/gui/table_filter.py +++ b/damnit/gui/table_filter.py @@ -28,6 +28,7 @@ class FilterType(Enum): NUMERIC = "numeric" CATEGORICAL = "categorical" + THUMBNAIL = "thumbnail" class Filter: @@ -90,6 +91,19 @@ def accepts(self, value: Any) -> bool: return value in self.selected_values +class ThumbnailFilter(Filter): + """Filter for columns containing thumbnails, filtering based on presence/absence of thumbnails.""" + + def __init__(self, column: int, show_with_thumbnail: bool = True, show_without_thumbnail: bool = True): + super().__init__(column, FilterType.THUMBNAIL) + self.show_with_thumbnail = show_with_thumbnail + self.show_without_thumbnail = show_without_thumbnail + + def accepts(self, value: Any) -> bool: + has_thumbnail = value is QPixmap + return (has_thumbnail and self.show_with_thumbnail) or (not has_thumbnail and self.show_without_thumbnail) + + class FilterProxy(QtCore.QSortFilterProxyModel): """Proxy model that applies filters to rows.""" @@ -159,6 +173,8 @@ def _create_filter_widget(self, column: int, model: FilterProxy): # Determine if column is numeric is_numeric = True values = set() + decos = set() + for row in range(model.sourceModel().rowCount()): item = model.sourceModel().index(row, column) value = item.data(Qt.UserRole) @@ -167,9 +183,15 @@ def _create_filter_widget(self, column: int, model: FilterProxy): if value is not None and not (isinstance(value, float) and isnan(value)): if not isinstance(value, (int, float)): is_numeric = False + # break + + if thumb := item.data(Qt.DecorationRole): + decos.add(type(thumb)) # Create appropriate filter widget - if is_numeric: + if values == {None} and len(decos) == 1 and decos.pop() is QPixmap: + filter_widget = ThumbnailFilterWidget(column) + elif is_numeric: filter_widget = NumericFilterWidget(column, values) else: filter_widget = CategoricalFilterWidget(column, values) @@ -349,6 +371,52 @@ def set_filter(self, filter: Optional[NumericFilter]): ) +class ThumbnailFilterWidget(QWidget): + """Widget for configuring thumbnail filters.""" + + filterChanged = QtCore.pyqtSignal(ThumbnailFilter) + + def __init__(self, column: int, parent=None): + super().__init__(parent) + self.column = column + + layout = QVBoxLayout() + layout.setContentsMargins(5, 5, 5, 5) + + # Checkboxes for filtering + self.with_thumbnail = QCheckBox("Show thumbnails") + self.without_thumbnail = QCheckBox("Show empty") + + # Set initial state + self.with_thumbnail.setChecked(True) + self.without_thumbnail.setChecked(True) + + # Connect signals + self.with_thumbnail.toggled.connect(self._emit_filter) + self.without_thumbnail.toggled.connect(self._emit_filter) + + # Layout + layout.addWidget(self.with_thumbnail) + layout.addWidget(self.without_thumbnail) + self.setLayout(layout) + + def _emit_filter(self): + filter = ThumbnailFilter( + self.column, + show_with_thumbnail=self.with_thumbnail.isChecked(), + show_without_thumbnail=self.without_thumbnail.isChecked(), + ) + self.filterChanged.emit(filter) + + def set_filter(self, filter: Optional[ThumbnailFilter]): + if filter is None: + self.with_thumbnail.setChecked(True) + self.without_thumbnail.setChecked(True) + else: + self.with_thumbnail.setChecked(filter.show_with_thumbnail) + self.without_thumbnail.setChecked(filter.show_without_thumbnail) + + class CategoricalFilterWidget(QWidget): """Widget for configuring categorical filters with a searchable list of values.""" From 7c69704bb6a9c003a68cc3ae9ed96e3484a1eb28 Mon Sep 17 00:00:00 2001 From: tmichela Date: Mon, 10 Feb 2025 16:18:37 +0100 Subject: [PATCH 06/13] feat: add filter status button --- damnit/gui/table_filter.py | 60 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/damnit/gui/table_filter.py b/damnit/gui/table_filter.py index bb6a5821..7cbf0ef4 100644 --- a/damnit/gui/table_filter.py +++ b/damnit/gui/table_filter.py @@ -104,6 +104,66 @@ def accepts(self, value: Any) -> bool: return (has_thumbnail and self.show_with_thumbnail) or (not has_thumbnail and self.show_without_thumbnail) +class FilterStatus(QPushButton): + def __init__(self, table_view, parent=None): + super().__init__(parent) + self._actions = [] + self.table_view = table_view + self._update_model() + self.menu = QMenu(self) + self.setMenu(self.menu) + self._update_text() + + if self.model is not None: + self.model.filterChanged.connect(self._update_text) + self.menu.aboutToShow.connect(self._populate_menu) + self.table_view.model_updated.connect(self._update_model) + + def _update_model(self): + self.model = None + model = self.table_view.model() + if isinstance(model, FilterProxy): + self.model = model + self.model.filterChanged.connect(self._update_text) + + self._update_text() + + def _populate_menu(self): + self.menu.clear() + self._actions.clear() + + if self.model is None or len(self.model.filters) == 0: + action = QAction("No active filter") + self._actions.append(action) + self.menu.addAction(action) + return + + clear_all = QAction("Clear All Filters", self) + clear_all.triggered.connect(lambda x: self.model.clear_filters()) + self._actions.append(clear_all) + self.menu.addAction(clear_all) + self.menu.addSeparator() + + for column in self.model.filters: + title = self.model.sourceModel().column_title(column) + action = QAction(icon(FA6S.trash), f"Clear filter on {title}") + action.triggered.connect( + lambda x, column=column: self.model.set_filter(column, None) + ) + self._actions.append(action) + self.menu.addAction(action) + + def _clear_menu(self): + self.menu.clear() + self._actions.clear() + + def _update_text(self): + if self.model is None: + self.setText("Filters (0)") + else: + self.setText(f"Filters ({len(self.model.filters)})") + + class FilterProxy(QtCore.QSortFilterProxyModel): """Proxy model that applies filters to rows.""" From 49878612fe315d60dfaacca4675da5dbb2671f19 Mon Sep 17 00:00:00 2001 From: tmichela Date: Mon, 10 Feb 2025 16:30:34 +0100 Subject: [PATCH 07/13] feat: integrate filter menu to tableView columns --- damnit/gui/table.py | 56 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 44 insertions(+), 12 deletions(-) diff --git a/damnit/gui/table.py b/damnit/gui/table.py index 4c657b9f..08defad6 100644 --- a/damnit/gui/table.py +++ b/damnit/gui/table.py @@ -4,15 +4,19 @@ from base64 import b64encode from itertools import groupby +from fonticon_fa6 import FA6S from PyQt5 import QtCore, QtGui, QtWidgets -from PyQt5.QtCore import Qt, QProcess -from PyQt5.QtWidgets import QMessageBox +from PyQt5.QtCore import QProcess, Qt +from PyQt5.QtGui import QCursor +from PyQt5.QtWidgets import QAction, QMenu, QMessageBox +from superqt.fonticon import icon from superqt.utils import qthrottled from ..backend.db import BlobTypes, DamnitDB, ReducedData, blob2complex from ..backend.extraction_control import ExtractionJobTracker from ..backend.user_variables import value_types_by_name from ..util import StatusbarStylesheet, delete_variable, timestamp2str +from .table_filter import FilterMenu, FilterProxy, FilterStatus log = logging.getLogger(__name__) @@ -23,14 +27,12 @@ class TableView(QtWidgets.QTableView): settings_changed = QtCore.pyqtSignal() log_view_requested = QtCore.pyqtSignal(int, int) # proposal, run + model_updated = QtCore.pyqtSignal() - def __init__(self) -> None: - super().__init__() + def __init__(self, parent=None) -> None: + super().__init__(parent) self.setAlternatingRowColors(False) - self.setSortingEnabled(True) - self.sortByColumn(0, Qt.SortOrder.AscendingOrder) - self.setSelectionBehavior( QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows ) @@ -65,11 +67,12 @@ def __init__(self) -> None: self._current_tag_filter = set() # Change to set for multiple tags self._tag_filter_button = QtWidgets.QPushButton("Variables by Tag") self._tag_filter_button.clicked.connect(self._show_tag_filter_menu) + # add column values filter support + self._filter_status = FilterStatus(self, parent) def setModel(self, model: 'DamnitTableModel'): """ - Overload of setModel() to make sure that we restyle the comment rows - when the model is updated. + Overload """ if (old_sel_model := self.selectionModel()) is not None: old_sel_model.deleteLater() @@ -77,22 +80,30 @@ def setModel(self, model: 'DamnitTableModel'): old_model.deleteLater() self.damnit_model = model - sfpm = QtCore.QSortFilterProxyModel(self) + + sfpm = FilterProxy(self) sfpm.setSourceModel(model) sfpm.setSortRole(Qt.ItemDataRole.UserRole) # Numeric sort where relevant super().setModel(sfpm) + # When loading a new model, the saved column order is applied at the # model level (changing column logical indices). So we need to reset # any reordering from the view level, which maps logical indices to # different visual indices, to show the columns as in the model. self.setHorizontalHeader(QtWidgets.QHeaderView(Qt.Horizontal, self)) - self.horizontalHeader().setSectionsClickable(True) + header = self.horizontalHeader() + header.setContextMenuPolicy(Qt.CustomContextMenu) + header.customContextMenuRequested.connect(self.show_horizontal_header_menu) + # header.setSectionsMovable(True) # TODO need to update variable order in the table / emit settings_changed + if model is not None: self.model().rowsInserted.connect(self.resize_new_rows) self.model().columnsInserted.connect(self.on_columns_inserted) self.model().columnsRemoved.connect(self.on_columns_removed) self.resizeRowsToContents() + self.model_updated.emit() + def selected_rows(self): """Get indices of selected rows in the DamnitTableModel""" proxy_rows = self.selectionModel().selectedRows() @@ -345,11 +356,31 @@ def apply_tag_filter(self, tag_names: set): def get_toolbar_widgets(self): """Return widgets to be added to the toolbar.""" - return [self._tag_filter_button] + return [self._tag_filter_button, self._filter_status] + + def show_horizontal_header_menu(self, position): + pos = QCursor.pos() + index = self.horizontalHeader().logicalIndexAt(position) + menu = QMenu(self) + sort_asc_action = QAction(icon(FA6S.arrow_up_short_wide), "Sort Ascending", self) + sort_desc_action = QAction(icon(FA6S.arrow_down_wide_short), "Sort Descending", self) + filter_action = QAction(icon(FA6S.filter), "Filter", self) + + menu.addAction(sort_asc_action) + menu.addAction(sort_desc_action) + menu.addSeparator() + menu.addAction(filter_action) + + sort_asc_action.triggered.connect(lambda: self.sortByColumn(index, Qt.AscendingOrder)) + sort_desc_action.triggered.connect(lambda: self.sortByColumn(index, Qt.DescendingOrder)) + filter_action.triggered.connect(lambda: FilterMenu(index, self.model(), self).popup(pos)) + + menu.exec_(pos) class DamnitTableModel(QtGui.QStandardItemModel): value_changed = QtCore.pyqtSignal(int, int, str, object) + time_comment_changed = QtCore.pyqtSignal(int, str) run_visibility_changed = QtCore.pyqtSignal(int, bool) def __init__(self, db: DamnitDB, column_settings: dict, parent): @@ -365,6 +396,7 @@ def __init__(self, db: DamnitDB, column_settings: dict, parent): self.db = db self.column_index = {c: i for (i, c) in enumerate(self.column_ids)} self.run_index = {} # {(proposal, run): row} + self.processing_jobs = QtExtractionJobTracker(self) self.processing_jobs.run_jobs_changed.connect(self.update_processing_status) From f93467bcc1be3993b6ae0687adb75e44f50eb3c6 Mon Sep 17 00:00:00 2001 From: tmichela Date: Mon, 10 Feb 2025 16:34:43 +0100 Subject: [PATCH 08/13] feat: refactor toolbar widget (plots and comments), integrate filters status to main window --- damnit/gui/main_window.py | 64 +++++++++------------------------------ damnit/gui/plot.py | 58 ++++++++++++++++++++++++++--------- 2 files changed, 57 insertions(+), 65 deletions(-) diff --git a/damnit/gui/main_window.py b/damnit/gui/main_window.py index 647a8dc6..318dd0cf 100644 --- a/damnit/gui/main_window.py +++ b/damnit/gui/main_window.py @@ -35,11 +35,11 @@ from .plot import (ImagePlotWindow, PlottingControls, ScatterPlotWindow, Xarray1DPlotWindow) from .process import ProcessingDialog +from .standalone_comments import TimeComment from .table import DamnitTableModel, TableView, prettify_notation from .theme import Theme, ThemeManager, set_lexer_theme from .user_variables import AddUserVariableDialog from .web_viewer import PlotlyPlot, UrlSchemeHandler -from .widgets import CollapsibleWidget from .zulip_messenger import ZulipMessenger log = logging.getLogger(__name__) @@ -743,58 +743,29 @@ def _create_view(self) -> None: vertical_layout.addWidget(toolbar) # the table - self.table_view = TableView() + self.table_view = TableView(self) self.table_view.doubleClicked.connect(self._inspect_data_proxy_idx) self.table_view.settings_changed.connect(self.save_settings) self.table_view.zulip_action.triggered.connect(self.export_selection_to_zulip) self.table_view.process_action.triggered.connect(self.process_runs) self.table_view.log_view_requested.connect(self.show_run_logs) - # Add table view's toolbar widgets - for widget in self.table_view.get_toolbar_widgets(): - toolbar.addWidget(widget) - - vertical_layout.addWidget(self.table_view) - - # add all other widgets on a collapsible layout - collapsible = CollapsibleWidget() - vertical_layout.addWidget(collapsible) - - # plotting control + # Initialize plot controls self.plot = PlottingControls(self) - plotting_group = QtWidgets.QGroupBox("Plotting controls") - plot_vertical_layout = QtWidgets.QVBoxLayout() - plot_horizontal_layout = QtWidgets.QHBoxLayout() - plot_parameters_horizontal_layout = QtWidgets.QHBoxLayout() - - plot_horizontal_layout.addWidget(self.plot._button_plot) - self.plot._button_plot_runs.setMinimumWidth(200) - plot_horizontal_layout.addStretch() - - plot_horizontal_layout.addWidget(QtWidgets.QLabel("Y:")) - plot_horizontal_layout.addWidget(self.plot._combo_box_y_axis) - plot_horizontal_layout.addWidget(self.plot.vs_button) - plot_horizontal_layout.addWidget(QtWidgets.QLabel("X:")) - plot_horizontal_layout.addWidget(self.plot._combo_box_x_axis) - - plot_vertical_layout.addLayout(plot_horizontal_layout) - - plot_parameters_horizontal_layout.addWidget(self.plot._button_plot_runs) - self.plot._button_plot.setMinimumWidth(200) - plot_parameters_horizontal_layout.addStretch() - plot_parameters_horizontal_layout.addWidget( - self.plot._toggle_probability_density - ) - - plot_vertical_layout.addLayout(plot_parameters_horizontal_layout) + self.plot_dialog_button = QtWidgets.QPushButton("Plot") + self.plot_dialog_button.clicked.connect(self.plot.show_dialog) + self.comment_button = QtWidgets.QPushButton("Time comment") + self.comment_button.clicked.connect(lambda: TimeComment(self).show()) - plotting_group.setLayout(plot_vertical_layout) + toolbar.addWidget(self.plot_dialog_button) + toolbar.addWidget(self.comment_button) + for widget in self.table_view.get_toolbar_widgets(): + toolbar.addWidget(widget) - collapsible.add_widget(plotting_group) + vertical_layout.addWidget(self.table_view) + vertical_layout.setContentsMargins(0, 7, 0, 0) - vertical_layout.setSpacing(0) - vertical_layout.setContentsMargins(0, 0, 0, 0) self._view_widget.setLayout(vertical_layout) def configure_editor(self): @@ -946,14 +917,6 @@ def save_value(self, prop, run, name, value): if self._connect_to_kafka: self.update_agent.run_values_updated(prop, run, name, value) - def save_time_comment(self, comment_id, value): - if self.db is None: - log.warning("No SQLite database in use, comment not saved") - return - - log.debug("Saving time-based comment ID %d", comment_id) - self.db.change_standalone_comment(comment_id, value) - def check_zulip_messenger(self): if not isinstance(self.zulip_messenger, ZulipMessenger): self.zulip_messenger = ZulipMessenger(self) @@ -1091,6 +1054,7 @@ def styleHint(self, hint, option=None, widget=None, returnData=None): else: return super().styleHint(hint, option, widget, returnData) + class TabBarStyle(QtWidgets.QProxyStyle): """ Subclass that enables bold tab text for tab 1 (the editor tab). diff --git a/damnit/gui/plot.py b/damnit/gui/plot.py index 5c7c5fb9..49928d58 100644 --- a/damnit/gui/plot.py +++ b/damnit/gui/plot.py @@ -6,6 +6,7 @@ import numpy as np import pandas as pd import xarray as xr + from matplotlib.backends.backend_qtagg import FigureCanvas from matplotlib.backends.backend_qtagg import \ NavigationToolbar2QT as NavigationToolbar @@ -13,7 +14,7 @@ from matplotlib.figure import Figure from mpl_pan_zoom import MouseButton, PanManager, zoom_factory from PyQt5 import QtCore, QtGui, QtWidgets -from PyQt5.QtCore import Qt +from PyQt5.QtCore import Qt, QObject from PyQt5.QtGui import QColor, QIcon, QPainter from PyQt5.QtWidgets import QMessageBox @@ -592,37 +593,64 @@ class PlottingControls: def __init__(self, main_window) -> None: self._main_window = main_window - self._button_plot = QtWidgets.QPushButton(main_window) + self.dialog = QtWidgets.QDialog(main_window) + self.dialog.setWindowTitle("Plot Controls") + + plot_vertical_layout = QtWidgets.QVBoxLayout() + plot_horizontal_layout = QtWidgets.QHBoxLayout() + plot_parameters_horizontal_layout = QtWidgets.QHBoxLayout() + + self._button_plot = QtWidgets.QPushButton(self.dialog) self._button_plot.setEnabled(True) self._button_plot.setText("Plot summary for all runs") self._button_plot.clicked.connect(self._plot_summaries_clicked) - self._button_plot_runs = QtWidgets.QPushButton( - "Plot for selected runs", main_window - ) + self._button_plot_runs = QtWidgets.QPushButton("Plot for selected runs", self.dialog) self._button_plot_runs.clicked.connect(self._plot_run_data_clicked) - self._combo_box_x_axis = SearchableComboBox(self._main_window) - self._combo_box_y_axis = SearchableComboBox(self._main_window) + plot_horizontal_layout.addWidget(self._button_plot) + self._button_plot_runs.setMinimumWidth(200) + plot_horizontal_layout.addStretch() - self._toggle_probability_density = QtWidgets.QPushButton( - "Histogram", main_window - ) - self._toggle_probability_density.setCheckable(True) - self._toggle_probability_density.setChecked(False) - self._toggle_probability_density.toggled.connect( - self._combo_box_y_axis.setDisabled - ) + self._combo_box_x_axis = SearchableComboBox(self.dialog) + self._combo_box_y_axis = SearchableComboBox(self.dialog) self.vs_button = QtWidgets.QToolButton() self.vs_button.setText("vs.") self.vs_button.setToolTip("Click to swap axes") self.vs_button.clicked.connect(self.swap_plot_axes) + plot_horizontal_layout.addWidget(QtWidgets.QLabel("Y:")) + plot_horizontal_layout.addWidget(self._combo_box_y_axis) + plot_horizontal_layout.addWidget(self.vs_button) + plot_horizontal_layout.addWidget(QtWidgets.QLabel("X:")) + plot_horizontal_layout.addWidget(self._combo_box_x_axis) + self._combo_box_x_axis.setCurrentText("Run") + plot_vertical_layout.addLayout(plot_horizontal_layout) + + plot_parameters_horizontal_layout.addWidget(self._button_plot_runs) + self._button_plot.setMinimumWidth(200) + plot_parameters_horizontal_layout.addStretch() + + self._toggle_probability_density = QtWidgets.QPushButton("Histogram", self.dialog) + self._toggle_probability_density.setCheckable(True) + self._toggle_probability_density.setChecked(False) + self._toggle_probability_density.toggled.connect(self._combo_box_y_axis.setDisabled) + + plot_parameters_horizontal_layout.addWidget(self._toggle_probability_density) + + plot_vertical_layout.addLayout(plot_parameters_horizontal_layout) + + self.dialog.setLayout(plot_vertical_layout) + self.dialog.setVisible(False) + self._plot_windows = [] + def show_dialog(self): + self.dialog.setVisible(True) + def update_columns(self): keys = self.table.column_titles From 134e753436b1b5bad487bf54a88f4a7796ca579d Mon Sep 17 00:00:00 2001 From: tmichela Date: Mon, 10 Feb 2025 16:42:30 +0100 Subject: [PATCH 09/13] test: add tests for filter proxy and associated widgets --- pyproject.toml | 2 + tests/conftest.py | 44 ++++++- tests/test_backend.py | 2 +- tests/test_gui.py | 276 ++++++++++++++++++++++++++++++++++-------- 4 files changed, 271 insertions(+), 53 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6e8f6520..268bc8e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,8 +38,10 @@ backend = [ ] gui = [ "adeqt", + "fonticon-fontawesome6", "mplcursors", "mpl-pan-zoom", + "natsort", "openpyxl", # for spreadsheet export "PyQt5", "PyQtWebEngine", diff --git a/tests/conftest.py b/tests/conftest.py index 0a56bbff..737138c1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,12 +20,16 @@ def mock_ctx(): from damnit.context import Variable @Variable(title="Scalar1", tags=['scalar', 'integer']) - def scalar1(run): + def scalar1(run, run_nr: 'meta#run_number'): + if run_nr == 2: + return None + elif run_nr == 3: + return np.nan return 42 @Variable(title="Scalar2", tags=['scalar', 'float']) def scalar2(run, foo: "var#scalar1"): - return 3.14 + return 3.14 if foo is not None else None @Variable(title="Empty text", tags='text') def empty_string(run): @@ -36,7 +40,9 @@ def empty_string(run): # numpy scalars (np.int32, np.float32, etc). @Variable(title="Array", summary="size") def array(run, foo: "var#scalar1", bar: "var#scalar2"): - return np.array([foo, bar]) + if foo is not None and bar is not None: + return np.array([foo, bar]) + return None # Can't have a title of 'Timestamp' or it'll conflict with the GUI's # 'Timestamp' colummn. @@ -56,10 +62,24 @@ def string(run, proposal_path: "meta#proposal_path"): @Variable(data="raw") def plotly_mc_plotface(run): return px.bar(x=["a", "b", "c"], y=[1, 3, 2]) + + @Variable(title="Results") + def results(run, run_nr: "meta#run_number"): + # Return different statuses for different runs + if run_nr == 1: + return "OK" + else: + return "Failed" + + @Variable(title='Image') + def image(run, run_nr: 'meta#run_number'): + if run_nr in (1, 1000): + return np.random.rand(10, 10) """ return mkcontext(code) + @pytest.fixture def mock_user_vars(): @@ -76,6 +96,7 @@ def mock_user_vars(): return user_variables + @pytest.fixture def mock_ctx_user(mock_user_vars): code = """ @@ -108,6 +129,7 @@ def dep_string(run, user_string="foo"): return mkcontext(code) + @pytest.fixture def mock_run(): run = MagicMock() @@ -129,6 +151,7 @@ def train_timestamps(): return run + @pytest.fixture def mock_db(tmp_path, mock_ctx, monkeypatch): db = DamnitDB.from_dir(tmp_path) @@ -143,6 +166,7 @@ def mock_db(tmp_path, mock_ctx, monkeypatch): db.close() + @pytest.fixture def mock_db_with_data(mock_ctx, mock_db, monkeypatch): db_dir, db = mock_db @@ -154,6 +178,20 @@ def mock_db_with_data(mock_ctx, mock_db, monkeypatch): yield mock_db + +@pytest.fixture +def mock_db_with_data_2(mock_ctx, mock_db, monkeypatch): + db_dir, db = mock_db + + with monkeypatch.context() as m: + m.chdir(db_dir) + amore_proto(["proposal", "1234"]) + for run_num in range(1, 6): + extract_mock_run(run_num) + + yield mock_db + + @pytest.fixture def bound_port(): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) diff --git a/tests/test_backend.py b/tests/test_backend.py index 6bfc201a..beff767a 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -93,7 +93,7 @@ def bar(run): return 43 assert { "array", "timestamp" } == var_deps("meta_array") # Check that the ordering is correct for execution - assert mock_ctx.ordered_vars() == ("scalar1", "empty_string", "timestamp", "string", "plotly_mc_plotface", "scalar2", "array", "meta_array") + assert mock_ctx.ordered_vars() == ("scalar1", "empty_string", "timestamp", "string", "plotly_mc_plotface", "results", "image", "scalar2", "array", "meta_array") # Check that we can retrieve direct and indirect dependencies assert set() == all_var_deps("scalar1") diff --git a/tests/test_gui.py b/tests/test_gui.py index 2074737d..8384eb00 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -9,6 +9,7 @@ from uuid import uuid4 import h5py +import numpy as np import pandas as pd import pytest from PyQt5.QtCore import Qt @@ -25,6 +26,9 @@ from damnit.gui.open_dialog import OpenDBDialog from damnit.gui.plot import HistogramPlotWindow, ScatterPlotWindow from damnit.gui.standalone_comments import TimeComment +from damnit.gui.table_filter import (CategoricalFilter, + CategoricalFilterWidget, ThumbnailFilterWidget, FilterMenu, + NumericFilter, NumericFilterWidget, ThumbnailFilter) from damnit.gui.theme import Theme from damnit.gui.zulip_messenger import ZulipConfig @@ -1006,7 +1010,7 @@ def count_visible_static(): if not table_view.isColumnHidden(col): count += 1 return count - + # Hepler function to apply tag filter def apply_tag_filter(tags): table_view.apply_tag_filter(tags) @@ -1073,6 +1077,227 @@ def apply_tag_filter(tags): assert count_visible_vars() == initial_var_count - 1 +def test_filter_proxy(mock_db_with_data_2, qtbot): + db_dir, db = mock_db_with_data_2 + + # Create main window + win = MainWindow(db_dir, False) + qtbot.addWidget(win) + + table_view = win.table_view + + proxy_model = table_view.model() + source_model = win.table + initial_rows = proxy_model.rowCount() + + # Test numeric filtering + scalar1_col = source_model.find_column("Scalar1", by_title=True) + + # Test with range and selected values + num_filter = NumericFilter(scalar1_col, min_val=40, max_val=45, selected_values={42}) + proxy_model.set_filter(scalar1_col, num_filter) + assert proxy_model.rowCount() == 5 + + # Test with range but no matching selected values + num_filter = NumericFilter(scalar1_col, min_val=40, max_val=45, include_nan=False) + proxy_model.clear_filters() + proxy_model.set_filter(scalar1_col, num_filter) + assert proxy_model.rowCount() == 0 + + # Test categorical filtering + status_col = source_model.find_column("Results", by_title=True) + + # Filter to show only rows with the first status value + cat_filter = CategoricalFilter(status_col, {"Failed"}) + proxy_model.clear_filters() + proxy_model.set_filter(status_col, cat_filter) + assert proxy_model.rowCount() == 4 + + # Test multiple filters + num_filter = NumericFilter(scalar1_col, min_val=40, max_val=45, selected_values={42}, include_nan=False) + proxy_model.set_filter(scalar1_col, num_filter) + assert proxy_model.rowCount() == 2 + + # Clear filters + proxy_model.clear_filters() + assert proxy_model.rowCount() == initial_rows + + # Test thumbnail filter + thumb_col = source_model.find_column("Image", by_title=True) + thumb_filter = ThumbnailFilter(thumb_col, show_with_thumbnail=True, show_without_thumbnail=False) + proxy_model.set_filter(thumb_col, thumb_filter) + assert proxy_model.rowCount() == 1 + + # Test clear all filters + proxy_model.clear_filters() + assert proxy_model.rowCount() == initial_rows + + +def test_filters(): + # Test numeric filter with selected values + num_filter = NumericFilter(column=0, min_val=10, max_val=20, selected_values={15}) + assert num_filter.accepts(15) + assert not num_filter.accepts(18) + assert not num_filter.accepts(5) + assert not num_filter.accepts(25) + assert num_filter.accepts(None) + assert num_filter.accepts(np.nan) + assert not num_filter.accepts("not a number") + + # Test numeric filter with nan handling + nan_filter = NumericFilter(column=0, min_val=10, max_val=20, selected_values={15}, include_nan=False) + assert nan_filter.accepts(15) + assert not nan_filter.accepts(None) + assert not nan_filter.accepts(np.nan) + assert not nan_filter.accepts(5) + assert not nan_filter.accepts(25) + assert not nan_filter.accepts(18) + + # Test categorical filter with selected values + cat_filter = CategoricalFilter(column=1, selected_values={"A", "B"}) + assert cat_filter.accepts("A") + assert cat_filter.accepts("B") + assert not cat_filter.accepts("C") + assert cat_filter.accepts(None) + assert cat_filter.accepts(np.nan) + + # Test categorical filter with nan handling + nan_cat_filter = CategoricalFilter(column=1, selected_values={"A"}, include_nan=False) + assert nan_cat_filter.accepts("A") + assert not nan_cat_filter.accepts(None) + assert not nan_cat_filter.accepts(np.nan) + assert not nan_cat_filter.accepts("B") + + # Test empty filters + empty_filter = CategoricalFilter(column=1) + assert not empty_filter.accepts("nothing") + assert empty_filter.accepts(None) + assert not empty_filter.accepts(42) + + # Test thumbnail filter with both options enabled + thumb_filter = ThumbnailFilter(column=2) + assert thumb_filter.accepts(QPixmap) + assert thumb_filter.accepts(None) + assert thumb_filter.accepts("not a thumbnail") + + # Test thumbnail filter showing only thumbnails + thumb_only_filter = ThumbnailFilter(column=2, show_with_thumbnail=True, show_without_thumbnail=False) + assert thumb_only_filter.accepts(QPixmap) + assert not thumb_only_filter.accepts(None) + assert not thumb_only_filter.accepts("not a thumbnail") + + # Test thumbnail filter showing only non-thumbnails + no_thumb_filter = ThumbnailFilter(column=2, show_with_thumbnail=False, show_without_thumbnail=True) + assert not no_thumb_filter.accepts(QPixmap) + assert no_thumb_filter.accepts(None) + assert no_thumb_filter.accepts("not a thumbnail") + + # Test thumbnail filter with both options disabled + hidden_thumb_filter = ThumbnailFilter(column=2, show_with_thumbnail=False, show_without_thumbnail=False) + assert not hidden_thumb_filter.accepts(QPixmap) + assert not hidden_thumb_filter.accepts(None) + assert not hidden_thumb_filter.accepts("not a thumbnail") + + +def test_standalone_comments(mock_db, qtbot): + db_dir, db = mock_db + + win = MainWindow(db_dir, False) + win.show() + qtbot.waitExposed(win) + qtbot.addWidget(win) + + # Create and show the TimeComment dialog + dialog = TimeComment(win) + qtbot.addWidget(dialog) + dialog.show() + qtbot.waitExposed(dialog) + + model = dialog.model + + # Test adding a comment + test_timestamp = 1640995200 # 2022-01-01 00:00:00 + test_comment = "Test comment 1" + model.addComment(test_timestamp, test_comment) + + # Verify comment was added + assert model.rowCount() > 0 + index = model.index(0, 2) # Comment column + assert model.data(index, Qt.DisplayRole) == test_comment + + # Add another comment + test_timestamp2 = 1641081600 # 2022-01-02 00:00:00 + test_comment2 = "Test comment 2" + model.addComment(test_timestamp2, test_comment2) + + # Test sorting + # Sort by timestamp ascending + model.sort(1, Qt.AscendingOrder) + index = model.index(0, 2) + assert model.data(index, Qt.DisplayRole) == test_comment + + # Sort by timestamp descending + model.sort(1, Qt.DescendingOrder) + index = model.index(0, 2) + assert model.data(index, Qt.DisplayRole) == test_comment2 + + # Test comment persistence + model.load_comments() + assert model.rowCount() == 2 + + +def test_filter_menu(mock_db_with_data, qtbot): + """Test FilterMenu initialization and functionality.""" + win = MainWindow(mock_db_with_data[0], False) + win.show() + qtbot.addWidget(win) + qtbot.waitExposed(win) + model = win.table_view.model() + + # Test numeric column + scalar1_col = win.table.find_column("Scalar1", by_title=True) + numeric_menu = FilterMenu(scalar1_col, model) + qtbot.addWidget(numeric_menu) + assert isinstance(numeric_menu.filter_widget, NumericFilterWidget) + + # Test categorical column + results_col = win.table.find_column("Results", by_title=True) + categorical_menu = FilterMenu(results_col, model) + qtbot.addWidget(categorical_menu) + assert isinstance(categorical_menu.filter_widget, CategoricalFilterWidget) + + # Test thumbnail column + thumb_col = win.table.find_column("Image", by_title=True) + thumbnail_menu = FilterMenu(thumb_col, model) + qtbot.addWidget(thumbnail_menu) + assert isinstance(thumbnail_menu.filter_widget, ThumbnailFilterWidget) + + # Test filter application + with qtbot.waitSignal(numeric_menu.filter_widget.filterChanged): + numeric_menu.filter_widget._on_selection_changed() + + # Test menu with existing filter + existing_filter = CategoricalFilter(results_col, selected_values={"OK"}) + model.set_filter(results_col, existing_filter) + menu_with_filter = FilterMenu(results_col, model) + qtbot.addWidget(menu_with_filter) + assert menu_with_filter.model.filters[results_col] == existing_filter + + # Test menu with existing thumbnail filter + existing_thumb_filter = ThumbnailFilter(thumb_col, show_with_thumbnail=True, show_without_thumbnail=False) + model.set_filter(thumb_col, existing_thumb_filter) + menu_with_thumb_filter = FilterMenu(thumb_col, model) + qtbot.addWidget(menu_with_thumb_filter) + assert menu_with_thumb_filter.model.filters[thumb_col] == existing_thumb_filter + + # Test thumbnail filter + thumb_filter = ThumbnailFilter(thumb_col, show_with_thumbnail=True, show_without_thumbnail=False) + model.set_filter(thumb_col, thumb_filter) + assert len(model.filters) == 2 + assert thumb_col in model.filters + assert model.filters[thumb_col] == thumb_filter + + def test_processing_status(mock_db_with_data, qtbot): db_dir, db = mock_db_with_data win = MainWindow(db_dir, connect_to_kafka=False) @@ -1120,7 +1345,7 @@ def test_theme(mock_db, qtbot, tmp_path): win._toggle_theme(True) assert win.current_theme == Theme.DARK win.close() - + # Create new window to test theme persistence win2 = MainWindow(db_dir, False) qtbot.addWidget(win2) @@ -1139,50 +1364,3 @@ def test_theme(mock_db, qtbot, tmp_path): win2._toggle_theme(False) assert win2.current_theme == Theme.LIGHT assert win2.palette() != dark_palette # Light theme should have different colors - - -def test_standalone_comments(mock_db, qtbot): - db_dir, db = mock_db - - win = MainWindow(db_dir, False) - win.show() - qtbot.waitExposed(win) - qtbot.addWidget(win) - - # Create and show the TimeComment dialog - dialog = TimeComment(win) - qtbot.addWidget(dialog) - dialog.show() - qtbot.waitExposed(dialog) - - model = dialog.model - - # Test adding a comment - test_timestamp = 1640995200 # 2022-01-01 00:00:00 - test_comment = "Test comment 1" - model.addComment(test_timestamp, test_comment) - - # Verify comment was added - assert model.rowCount() > 0 - index = model.index(0, 2) # Comment column - assert model.data(index, Qt.DisplayRole) == test_comment - - # Add another comment - test_timestamp2 = 1641081600 # 2022-01-02 00:00:00 - test_comment2 = "Test comment 2" - model.addComment(test_timestamp2, test_comment2) - - # Test sorting - # Sort by timestamp ascending - model.sort(1, Qt.AscendingOrder) - index = model.index(0, 2) - assert model.data(index, Qt.DisplayRole) == test_comment - - # Sort by timestamp descending - model.sort(1, Qt.DescendingOrder) - index = model.index(0, 2) - assert model.data(index, Qt.DisplayRole) == test_comment2 - - # Test comment persistence - model.load_comments() - assert model.rowCount() == 2 From 1e73b0fea88189b6520648ef9b5ad4c57dbeaf13 Mon Sep 17 00:00:00 2001 From: tmichela Date: Mon, 10 Feb 2025 16:46:44 +0100 Subject: [PATCH 10/13] doc: update changelog --- docs/changelog.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 7ee262b6..791c5713 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -5,12 +5,13 @@ Added: - GUI: Watch context file for changes saved outside the editor (!304). -- GUI: show when runs are being processed (!322) +- GUI: show when runs are being processed (!322). - Reads techniques annotation from MyMDC (!338). - Add a `tags` attribute allowing cathegorizing `Variable`s (!354). -- Add support for `complex` numbers (!374) -- GUI: Add a Dark theme (!376) -- add a`transient` attribute for variables we don't want to save data (!xxx) +- GUI: Add row filtering (!362). +- Add support for `complex` numbers (!374). +- GUI: Add a Dark theme (!376). +- add a`transient` attribute for variables we don't want to save data (!379). Changed: @@ -24,12 +25,15 @@ Fixed: - Fixed loading data with WebViewer (!310). - Added back grid lines for plots of `DataArray`'s (!334). -- Failed to setup new database from the GUI (!337) +- Failed to setup new database from the GUI (!337). - Fixed adding new variable without explicit title as column in GUI (!347). - Fixed thumbnails of 2D `DataArray`'s to match what is displayed when the variable is plotted (!355). - Fixed crashes when the context file environment is missing dependencies (!356). -- handle failure generating summary while writing results to file (!370) +- handle failure generating summary while writing results to file (!370). + +Deprecated: +- GUI: Standalone comments are no longer supported in the run table(!362). ## [0.1.4] From 21ba35a98e14fefde35a6edd9bf0eeafc478a44f Mon Sep 17 00:00:00 2001 From: tmichela Date: Mon, 10 Feb 2025 16:47:12 +0100 Subject: [PATCH 11/13] fix: filter proxy for thumbnail filter --- damnit/gui/table_filter.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/damnit/gui/table_filter.py b/damnit/gui/table_filter.py index 7cbf0ef4..af8a603f 100644 --- a/damnit/gui/table_filter.py +++ b/damnit/gui/table_filter.py @@ -200,7 +200,10 @@ def filterAcceptsRow( continue item = self.sourceModel().index(source_row, col, source_parent) - data = item.data(Qt.UserRole) + if isinstance(filter, ThumbnailFilter): + data = type(item.data(Qt.DecorationRole)) + else: + data = item.data(Qt.UserRole) if not filter.accepts(data): return False return True From 89b3425dc84af1968ac04faf6459f573d2aa91e5 Mon Sep 17 00:00:00 2001 From: tmichela Date: Mon, 10 Feb 2025 17:11:53 +0100 Subject: [PATCH 12/13] fix: prevent crash on non-orderable types when building cathegorical filter widget treeview --- damnit/gui/table_filter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/damnit/gui/table_filter.py b/damnit/gui/table_filter.py index af8a603f..729f594e 100644 --- a/damnit/gui/table_filter.py +++ b/damnit/gui/table_filter.py @@ -521,7 +521,7 @@ def __init__(self, column: int, values: Set[Any], parent=None): self.setLayout(layout) # Add values to list (excluding nan/empty values) - for value in natsorted(values): + for value in natsorted(values, key=str): if value is not None and not (isinstance(value, float) and isnan(value)): item = QListWidgetItem() item.setData(Qt.UserRole, value) From 6f1c1e566ad5d6c4e12486970bf6d5b0c1f715b8 Mon Sep 17 00:00:00 2001 From: tmichela Date: Tue, 11 Feb 2025 12:05:58 +0100 Subject: [PATCH 13/13] fix: filtering based on the "comment" column --- damnit/gui/table.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/damnit/gui/table.py b/damnit/gui/table.py index 08defad6..ec4f5338 100644 --- a/damnit/gui/table.py +++ b/damnit/gui/table.py @@ -491,8 +491,9 @@ def image_item(self, png_data: bytes): return item def comment_item(self, text): - item = QtGui.QStandardItem(text) # Editable by default + item = self.text_item(text) item.setToolTip(text) + item.setEditable(True) return item def new_item(self, value, column_id, max_diff, attrs):