diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dc9ba29..9d4d1240 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,13 @@ ## [UNRELEASED] · YYYY-MM-DD ### ✨ Added -- The dataset pane now contains badges that help you easily recognize the data type of each entry ([#576](https://github.com/cbrnr/mnelab/pull/576) by [Richard Höchenberger](https://github.com/hoechenberger)) +- The dataset pane now contains badges that make it easy to recognize the data type of each entry ([#576](https://github.com/cbrnr/mnelab/pull/576) by [Richard Höchenberger](https://github.com/hoechenberger)) - Add mask argument to Find Events dialog, which is useful for parsing events in BDF files ([#584](https://github.com/cbrnr/mnelab/pull/584) by [Clemens Brunner](https://github.com/cbrnr)) - The info pane now displays a copy button when hovering over the file name ([#578](https://github.com/cbrnr/mnelab/pull/578) and [#581](https://github.com/cbrnr/mnelab/pull/581) by [Richard Höchenberger](https://github.com/hoechenberger)) ### 🔧 Fixed - Fix toolbar button hover effects on macOS ([#565](https://github.com/cbrnr/mnelab/pull/565) by [Clemens Brunner](https://github.com/cbrnr)) +- Add option to navigate between datasets in the sidebar with the up and down arrow keys ([#573](https://github.com/cbrnr/mnelab/pull/573) by [Richard Höchenberger](https://github.com/hoechenberger)) +- Improve sidebar styling ([#573](https://github.com/cbrnr/mnelab/pull/573) by [Richard Höchenberger](https://github.com/hoechenberger)) - Improve dark mode theme for the Artifact Detection preview dialog ([#585](https://github.com/cbrnr/mnelab/pull/585) by [Fabian Schellander](https://github.com/SchellanderF)) ### 🌀 Changed diff --git a/src/mnelab/mainwindow.py b/src/mnelab/mainwindow.py index 40378805..09e4f314 100644 --- a/src/mnelab/mainwindow.py +++ b/src/mnelab/mainwindow.py @@ -16,7 +16,16 @@ import numpy as np from mne import channel_type from pybvrf import read_bvrf_header -from PySide6.QtCore import QEvent, QMetaObject, QModelIndex, Qt, QUrl, Slot +from PySide6.QtCore import ( + QByteArray, + QEvent, + QMetaObject, + QModelIndex, + Qt, + QTimer, + QUrl, + Slot, +) from PySide6.QtGui import QAction, QDesktopServices, QIcon, QKeySequence from PySide6.QtWidgets import ( QApplication, @@ -426,10 +435,13 @@ def __init__(self, model: Model): self.sidebar.hide() self.sidebar.rowsMoved.connect(self._sidebar_move_event) self.sidebar.itemDelegate().commitData.connect(self._sidebar_edit_event) - self.sidebar.cellClicked.connect(self._update_data) + self.sidebar.currentCellChanged.connect( + lambda row, col, *_: self._update_data(row, col) + ) - splitter = QSplitter() - splitter.addWidget(self.sidebar) + self.splitter = QSplitter() + self.splitter.setObjectName("main_splitter") + self.splitter.addWidget(self.sidebar) self.infowidget = QStackedWidget() self.infowidget.addWidget(InfoWidget()) @@ -437,10 +449,21 @@ def __init__(self, model: Model): itemgetter("open_file", "history", "settings")(self.actions) ) self.infowidget.addWidget(emptywidget) - splitter.addWidget(self.infowidget) - width = splitter.size().width() - splitter.setSizes((int(width * 0.3), int(width * 0.7))) - self.setCentralWidget(splitter) + self.splitter.addWidget(self.infowidget) + saved_splitter = settings["splitter"] + if saved_splitter: + self.splitter.restoreState(saved_splitter) + else: + QTimer.singleShot( + 0, + lambda: self.splitter.setSizes( + [ + int(self.splitter.size().width() * 0.4), + int(self.splitter.size().width() * 0.6), + ] + ), + ) + self.setCentralWidget(self.splitter) self.status_label = QLabel() self.statusBar().addPermanentWidget(self.status_label) @@ -512,12 +535,15 @@ def data_changed(self): # update sidebar if len(self.model.data) > 0: self.sidebar.show() + # block signals during rebuild: setRowCount(0) would otherwise + # emit currentCellChanged with row=-1, corrupting model.index + self.sidebar.blockSignals(True) self.sidebar.setRowCount(0) self.sidebar.setRowCount(len(self.model.names)) self.sidebar.setColumnCount(4) for row_index, name in enumerate(self.model.names): - item_index = QTableWidgetItem(str(row_index)) + item_index = QTableWidgetItem(str(row_index + 1)) self.sidebar.setItem(row_index, 0, item_index) item_name = QTableWidgetItem(name) @@ -529,6 +555,8 @@ def data_changed(self): self.sidebar.style_rows() self.sidebar.selectRow(self.model.index) + self.sidebar.blockSignals(False) + self.sidebar.setFocus() else: self.sidebar.hide() @@ -1575,7 +1603,6 @@ def _update_data(self, row, column): if row != self.model.index: self.model.index = row self.data_changed() - self.sidebar.showCloseButton(row) self.model.history.append(f"data = datasets[{self.model.index}]") @Slot() @@ -1612,7 +1639,11 @@ def _plot_closed(self, event=None): def event(self, event): if event.type() == QEvent.Close: - write_settings(size=self.size(), pos=self.pos()) + write_settings( + size=self.size(), + pos=self.pos(), + splitter=QByteArray(self.splitter.saveState()), + ) if self.model.history: print("\n# Command History\n") print(format_code("\n".join(self.model.history))) diff --git a/src/mnelab/settings.py b/src/mnelab/settings.py index a624d88a..60914f07 100644 --- a/src/mnelab/settings.py +++ b/src/mnelab/settings.py @@ -5,7 +5,15 @@ from pathlib import Path from mne import get_config_path -from PySide6.QtCore import QPoint, QSettings, QSize, QStandardPaths, QUrl, Slot +from PySide6.QtCore import ( + QByteArray, + QPoint, + QSettings, + QSize, + QStandardPaths, + QUrl, + Slot, +) from PySide6.QtGui import QDesktopServices, Qt from PySide6.QtWidgets import ( QComboBox, @@ -36,6 +44,7 @@ "size": QSize(700, 500), "pos": QPoint(100, 100), "plot_backend": "Matplotlib", + "splitter": QByteArray(), } diff --git a/src/mnelab/widgets/sidebar.py b/src/mnelab/widgets/sidebar.py index a9ac1f51..e8ea4c09 100644 --- a/src/mnelab/widgets/sidebar.py +++ b/src/mnelab/widgets/sidebar.py @@ -2,20 +2,23 @@ # # License: BSD (3-clause) -from PySide6.QtCore import QEvent, QRectF, Qt, Signal -from PySide6.QtGui import QColor, QIcon, QPainter +import sys + +from PySide6.QtCore import QEvent, QRect, QRectF, Qt, Signal +from PySide6.QtGui import QColor, QCursor, QIcon, QPainter from PySide6.QtWidgets import ( QAbstractItemView, QFrame, QHeaderView, - QPushButton, QStyledItemDelegate, QTableWidget, QTableWidgetItem, + QToolButton, ) ROW_HEIGHT = 30 +# settings for the data type badges in the sidebar DTYPE_COLORS = { "raw": ("#2563EB", "#FFFFFF"), # blue-600 bg, white text "epochs": ("#92400E", "#FFFFFF"), # amber-800 bg, white text @@ -44,6 +47,11 @@ def paint(self, painter, option, index): painter.setBrush(QColor(bg_hex)) painter.drawRoundedRect(badge_rect, badge_h / 2, badge_h / 2) + # add a subtle border + painter.setPen(QColor(0, 0, 0, 40)) + painter.setBrush(Qt.NoBrush) + painter.drawRoundedRect(badge_rect, badge_h / 2, badge_h / 2) + font = painter.font() font.setPointSizeF(max(6.0, font.pointSizeF() - 1)) painter.setFont(font) @@ -58,6 +66,26 @@ def sizeHint(self, option, index): return hint +class SpanningHeaderView(QHeaderView): + """Horizontal header where a contiguous range of sections is painted as one.""" + + def __init__(self, orientation, span_start=0, span_count=1, parent=None): + super().__init__(orientation, parent) + self._span_start = span_start + self._span_count = span_count + + def paintSection(self, painter, rect, logical_index): + span_cols = range(self._span_start, self._span_start + self._span_count) + if logical_index in span_cols and logical_index != self._span_start: + return + if logical_index == self._span_start: + total_width = sum( + self.sectionSize(self._span_start + i) for i in range(self._span_count) + ) + rect = QRect(rect.x(), rect.y(), total_width, rect.height()) + super().paintSection(painter, rect, logical_index) + + class SidebarTableWidget(QTableWidget): rowsMoved = Signal(int, int) # custom signal emitted when drag-and-dropping rows @@ -73,25 +101,42 @@ def __init__(self, parent): self.setDropIndicatorShown(False) self.setDragDropOverwriteMode(False) self.setFrameStyle(QFrame.NoFrame) - self.setFocusPolicy(Qt.NoFocus) self.setEditTriggers(QAbstractItemView.DoubleClicked) self.setColumnCount(4) self.setShowGrid(False) + if sys.platform != "darwin": + # disable cell style changes upon focusing (clicking); not needed on macOS + self.setStyleSheet(""" + QTableWidget { outline: 0; } + QTableWidget::item:focus { + background: palette(highlight); + color: palette(highlighted-text); + } + """) self.drop_row = -1 - self.horizontalHeader().hide() + header = SpanningHeaderView( + Qt.Horizontal, span_start=1, span_count=3, parent=self + ) + self.setHorizontalHeader(header) + self.setHorizontalHeaderLabels(["#", "Dataset", "", ""]) + self.horizontalHeaderItem(0).setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.horizontalHeaderItem(1).setTextAlignment(Qt.AlignLeft | Qt.AlignVCenter) self.verticalHeader().hide() self.horizontalHeader().setStretchLastSection(False) + self.horizontalHeader().setMinimumSectionSize(0) self.horizontalHeader().setSectionResizeMode(0, QHeaderView.Fixed) self.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) self.horizontalHeader().setSectionResizeMode(2, QHeaderView.Fixed) self.horizontalHeader().setSectionResizeMode(3, QHeaderView.Fixed) self.setColumnWidth(2, 56) - self.setColumnWidth(3, 20) + self.setColumnWidth(3, 28) self.resizeColumnToContents(0) self.setItemDelegateForColumn(2, TypeBadgeDelegate(self)) - + self.horizontalHeader().hide() + self.setAccessibleName("Opened datasets") self.setMouseTracking(True) + self.setTabKeyNavigation(False) self.viewport().installEventFilter(self) def mousePressEvent(self, event): @@ -180,6 +225,11 @@ def style_rows(self): self.item(i, 1).setFlags(self.item(i, 1).flags() | Qt.ItemIsEditable) if self.item(i, 2) is not None: self.item(i, 2).setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) + # after a rebuild (e.g. row removed), the cursor may already be hovering + # over a row without a MouseMove firing — update the close button immediately + pos = self.viewport().mapFromGlobal(QCursor.pos()) + index = self.indexAt(pos) + self.showCloseButton(index.row() if index.isValid() else -1) def update_vertical_header(self): row_count = self.rowCount() @@ -200,10 +250,14 @@ def eventFilter(self, source, event): def showCloseButton(self, row_index): for i in range(self.rowCount()): if i == row_index: - delete_button = QPushButton(self) + delete_button = QToolButton(self) + delete_button.setFocusPolicy(Qt.NoFocus) + delete_button.setFixedSize(24, ROW_HEIGHT) delete_button.setIcon(QIcon.fromTheme("close-data")) + delete_button.setToolTip("Close dataset") + delete_button.setAutoRaise(True) delete_button.setStyleSheet( - "background: transparent; border: none; margin: auto;" + "QToolButton { background: transparent; border: none; }" ) delete_button.clicked.connect( lambda _, index=row_index: self.parent.model.remove_data(index)