from collections.abc import Callable
from pathlib import Path
import numpy as np
import pyqtgraph as pg
from qtpy import QtCore, QtWidgets
from ibl_alignment_gui.utils.qt import custom_widgets
from iblutil.util import Bunch
pg.setConfigOption('background', 'w')
pg.setConfigOption('foreground', 'k')
[docs]
class AlignmentGUIView(QtWidgets.QMainWindow):
"""
The main GUI window for the alignment application.
Parameters
----------
offline: bool
Whether to run in offline mode (local files) or online mode (ONE/Alyx)
config: bool
Whether multiple configs are to be used
"""
def __init__(self, offline: bool = False, config: bool = False):
super().__init__()
self.config = config
self.resize(1600, 800)
self.setWindowTitle('IBL alignment GUI')
self.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding)
# Create custom widgets that will be added to the main window
self.button_widgets = custom_widgets.ButtonWidget(parent=self)
self.selection_widgets = custom_widgets.SelectionWidget(
offline=offline, config=self.config, parent=self
)
self.menu_widgets = custom_widgets.MenuWidget(self)
self.setMenuBar(self.menu_widgets)
self.menu_widgets.setCornerWidget(self.selection_widgets, corner=QtCore.Qt.TopRightCorner)
self.tab_widgets = Bunch()
self.tab_widgets['shank'] = custom_widgets.GridTabSwitcher()
self.tab_widgets['slice'] = custom_widgets.GridTabSwitcher()
self.lut_widget = custom_widgets.LutWidget()
self.fit_widget = custom_widgets.FitWidget()
# Layout the widgets
# Group together the slice tabs and the lut widget
slice_area = QtWidgets.QWidget()
layout = QtWidgets.QHBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
layout.addWidget(self.tab_widgets['slice'], stretch=4)
layout.addWidget(self.lut_widget, stretch=1)
slice_area.setLayout(layout)
# Add the slice area and fit plot into a splitter
splitter = QtWidgets.QSplitter(QtCore.Qt.Vertical)
splitter.addWidget(slice_area)
splitter.addWidget(self.fit_widget)
# Add this splitter to a layout with the button widgets below
widget = QtWidgets.QWidget()
layout = QtWidgets.QVBoxLayout()
layout.addWidget(splitter, stretch=6)
layout.addWidget(self.button_widgets, stretch=1)
layout.setContentsMargins(0, 0, 0, 0)
widget.setLayout(layout)
# Add these onto a main splitter that contains all the individual components
self.main_splitter = QtWidgets.QSplitter(QtCore.Qt.Horizontal)
self.main_splitter.addWidget(self.tab_widgets['shank'])
self.main_splitter.addWidget(widget)
self.main_splitter.setStretchFactor(0, 2)
self.main_splitter.setStretchFactor(1, 1)
[docs]
def init_view(self) -> None:
"""Initialize the main view by setting the central widget and the splitter sizes."""
self.setCentralWidget(self.main_splitter)
total_width = self.main_splitter.width()
self.main_splitter.setSizes([int(total_width * 0.7), int(total_width * 0.3)])
[docs]
def reset_view(self) -> None:
"""Reset the main view by clearing the fit plot and all the tab widgets."""
self.fit_widget.fig_fit.clear()
self.clear_tabs()
[docs]
def focus(self) -> None:
"""Ensure the main widget is set as the main focus."""
if 'subject' in self.selection_widgets.dropdowns:
self.selection_widgets.dropdowns['subject']['line'].clearFocus()
self.selection_widgets.dropdowns['subject']['combobox'].clearFocus()
self.setFocus()
self.raise_()
self.activateWindow()
# --------------------------------------------------------------------------------------------
# GridTab widget
# --------------------------------------------------------------------------------------------
[docs]
def clear_tabs(self) -> None:
"""Clear the shank and slice tab widgets of all their contents."""
self.tab_widgets['shank'].tab_widget.blockSignals(True)
self.tab_widgets['slice'].tab_widget.blockSignals(True)
self.tab_widgets['shank'].delete_widgets()
self.tab_widgets['slice'].delete_widgets()
self.tab_widgets['shank'].tab_widget.blockSignals(False)
self.tab_widgets['slice'].tab_widget.blockSignals(False)
[docs]
def init_tabs(
self,
shank_items: dict | Bunch,
selected_config: str,
default_config: str,
non_default_config: str,
feature_view: bool = False,
) -> list[QtWidgets.QWidget]:
"""
Initialize the shank and slice tab widgets with the given shank items.
Parameters
----------
shank_items: dict | Bunch
A dictionary of ShankController instances for each shank
selected_config:
The configuration to display. Can be 'default', 'non-default' or 'both'
default_config:
The default configuration.
non_default_config
The non-default configuration.
feature_view: bool
Whether to display the ephys plot as a feature view or not.
Returns
-------
list[QtWidgets.QWidget]:
A list of the shank panels created
"""
shank_panels = []
slice_panels = []
headers = []
config = default_config if selected_config == 'both' else selected_config
for i, shank in enumerate(shank_items):
# Create the shank panel depending on the configuration selected
if selected_config == 'both':
if feature_view:
fig_area = custom_widgets.DualConfigFeatureWidget(
shank_items[shank][default_config].view,
shank_items[shank][non_default_config].view,
)
else:
fig_area = custom_widgets.DualConfigWidget(
shank_items[shank][default_config].view,
shank_items[shank][non_default_config].view,
)
elif feature_view:
fig_area = custom_widgets.SingleConfigFeatureWidget(
shank_items[shank][selected_config].view
)
else:
fig_area = custom_widgets.SingleConfigWidget(
shank_items[shank][selected_config].view
)
# Add the fit items from each shank to the fit plot
self.fit_widget.fig_fit.addItem(shank_items[shank][config].view.fit_plot)
self.fit_widget.fig_fit.addItem(shank_items[shank][config].view.fit_scatter)
self.fit_widget.fig_fit.addItem(shank_items[shank][config].view.fit_plot_lin)
# Link the histology views so they pan and zoom together
if i == 0:
slice_link = shank_items[shank][config].view.fig_slice
if i > 0:
shank_items[shank][config].view.fig_slice.setYLink(slice_link)
shank_items[shank][config].view.fig_slice.setXLink(slice_link)
headers.append(shank_items[shank][config].view.header)
slice_panels.append(shank_items[shank][config].view.fig_slice_area)
shank_panels.append(fig_area)
# Add the panels to the tab widgets
self.tab_widgets['shank'].initialise(shank_panels, shank_items.keys(), headers)
self.tab_widgets['slice'].initialise(slice_panels, shank_items.keys())
return shank_panels
[docs]
def toggle_tabs(self, idx: int) -> None:
"""
Toggle the shank and slice tabs between grid and tab view.
After toggling ensure the correct index is selected.
Parameters
----------
idx: int
The index of the selected panel
"""
self.tab_widgets['slice'].toggle_layout()
self.tab_widgets['slice'].tab_widget.setCurrentIndex(idx)
# Change the display of the shank displays
self.tab_widgets['shank'].toggle_layout()
self.tab_widgets['shank'].tab_widget.setCurrentIndex(idx)
@property
def is_grid(self) -> bool:
"""
Check if the shank tab view is in grid layout.
Returns
-------
bool:
True if the shank tab view is in grid layout, False if in tab layout
"""
return self.tab_widgets['shank'].grid_layout
[docs]
def set_tabs(self, idx: int) -> None:
"""
Set the index of the selected tab in the shank and slice tab views.
Parameters
----------
idx:
The index to set the tabs to
"""
if not self.tab_widgets['shank'].grid_layout:
self.tab_widgets['shank'].tab_widget.blockSignals(True)
self.set_slice_tab(idx)
self.set_shank_tab(idx)
self.tab_widgets['slice'].tab_widget.setCurrentIndex(idx)
self.tab_widgets['shank'].tab_widget.blockSignals(False)
[docs]
def set_slice_tab(self, idx: int):
"""
Set the tab of the slice tabs to the given index.
Parameters
----------
idx:
The index to set the tab to
"""
self.tab_widgets['slice'].tab_widget.setCurrentIndex(idx)
[docs]
def set_shank_tab(self, idx: int) -> None:
"""
Set the tab of the shank tabs to the given index.
Parameters
----------
idx:
The index to set the tab to
"""
self.tab_widgets['shank'].tab_widget.setCurrentIndex(idx)
[docs]
def connect_tabs(
self, name: str, callback: Callable, layout_callback: Callable | None = None
) -> None:
"""
Connect the tab change signal to a callback.
Parameters
----------
name: str
The name of the tab widget
callback: Callable
The tab change callback to connect to
layout_callback: Callable, optional
The layout chance callback to connect to
"""
self.tab_widgets[name].tab_widget.currentChanged.connect(callback)
if layout_callback:
self.tab_widgets[name].custom_signal.connect(layout_callback)
# --------------------------------------------------------------------------------------------
# Menu widget
# --------------------------------------------------------------------------------------------
# --------------------------------------------------------------------------------------------
# Button widget
# --------------------------------------------------------------------------------------------
[docs]
def set_labels(self, current_idx: int, total_idx: int) -> None:
"""
Set the strings to indicate the number of fits applied by the user.
Parameters
----------
current_idx: int
The current index of the fit in the alignment buffer
total_idx: int
The total number of fits stores in the alignment buffer
"""
self.button_widgets.labels['current'].setText(f'Current Index = {current_idx}')
self.button_widgets.labels['total'].setText(f'Total Index = {total_idx}')
# --------------------------------------------------------------------------------------------
# Selection widget
# --------------------------------------------------------------------------------------------
[docs]
def set_selection_dropdown(self, name: str, idx: int) -> None:
"""
Set the dropdown to a given index.
Parameters
----------
name: str
The name of the dropdown
idx
The index to set
"""
self.selection_widgets.dropdowns[name]['combobox'].setCurrentIndex(idx)
[docs]
def connect_selection_dropdown(self, name: str, callback: Callable) -> None:
"""
Connect a dropdown to a callback.
Parameters
----------
name: str
The name of the dropdown
callback: Callable
The callback function to connect to
"""
self.selection_widgets.dropdowns[name]['combobox'].activated.connect(callback)
[docs]
def populate_selection_dropdown(self, name: str, values: list | dict | np.ndarray) -> None:
"""
Populate a dropdown with values.
Parameters
----------
name: str
The name of the dropdown to populate
values: list or dict or np.ndarray
The values to add to the list
"""
self.selection_widgets.populate_combobox(
values,
self.selection_widgets.dropdowns[name]['list'],
self.selection_widgets.dropdowns[name]['combobox'],
)
[docs]
def clear_selection_dropdown(self, name: str | list) -> None:
"""
Clear values from a list or a set of lists.
Parameters
----------
name: str or list
The name of the dropdown or dropdowns to clear
"""
if isinstance(name, str):
self.selection_widgets.dropdowns[name]['list'].clear()
elif isinstance(name, list | tuple):
for n in name:
self.selection_widgets.dropdowns[n]['list'].clear()
[docs]
def get_selected_path(self) -> Path:
"""
Get the user selected path and set the text line edit to show the selected folder path.
Returns
-------
selected_path: Path
The user selected path that contains data to load
"""
selected_path = Path(QtWidgets.QFileDialog.getExistingDirectory(self, 'Select Folder'))
self.selection_widgets.buttons['folder']['line'].setText(str(selected_path))
return selected_path
[docs]
def set_selected_path(self, selected_path: Path | str) -> None:
"""
Set the text line edit to show the selected folder path.
Parameters
----------
selected_path: Path or str
The user selected path that contains data to load
"""
self.selection_widgets.buttons['folder']['line'].setText(str(selected_path))
# --------------------------------------------------------------------------------------------
# LUT widget
# --------------------------------------------------------------------------------------------
[docs]
def set_levels(self, levels) -> None:
"""See :meth:`LutWidget.set_lut_levels` for details."""
self.lut_widget.set_lut_levels(levels)
[docs]
def set_lut(self, images: list, cbar: custom_widgets.ColorBar) -> None:
"""
Add the LUT item if not already added and set the LUT for the given images and colorbar.
Parameters
----------
images : list
A list of pyqtgraph ImageItem instances to be linked to the LUT.
cbar : ColorBar
A ColorBar object
"""
self.lut_widget.add_lut()
self.lut_widget.set_lut(images, cbar)
[docs]
def remove_lut(self) -> None:
"""See :meth:`LutWidget.remove_lut` for details."""
self.lut_widget.remove_lut()
# --------------------------------------------------------------------------------------------
# Fit widget
# --------------------------------------------------------------------------------------------
[docs]
def connect_lin_fit(self, callback: Callable) -> None:
"""
Connect the linear fit checkbox to a callback.
Parameters
----------
callback: Callable
The callback function to connect to
"""
self.fit_widget.lin_fit_option.stateChanged.connect(callback)
[docs]
def add_points_to_display(self, points: list[pg.PlotDataItem]) -> None:
"""
Add a list of points to the fit plot.
Parameters
----------
points: list[pg.PlotDataItem]
A list of points to add to the fit plot
"""
for point in points:
self.add_point(point)
[docs]
def remove_points_from_display(self, points: list[pg.PlotDataItem]) -> None:
"""
Remove a list of points to the fit plot.
Parameters
----------
points: list[pg.PlotDataItem]
A list of points to remove to the fit plot
"""
for point in points:
self.remove_point(point)
[docs]
def remove_point(self, point: pg.PlotDataItem) -> None:
"""
Add a point to the fit plot.
Parameters
----------
point: pg.PlotDataItem
A point to add to the fit plot
"""
self.fit_widget.fig_fit.removeItem(point)
[docs]
def add_point(self, point: pg.PlotDataItem) -> None:
"""
Remove a point to the fit plot.
Parameters
----------
point: pg.PlotDataItem
A point to remove to the fit plot
"""
self.fit_widget.fig_fit.addItem(point)
# --------------------------------------------------------------------------------------------
# Upload dialog boxes
# --------------------------------------------------------------------------------------------
[docs]
def upload_prompt(self) -> bool:
"""
Show a message box to ask the user if they want to upload the channels and alignments.
Returns
-------
bool:
True if the user wants to upload the channels and alignments, False otherwise
"""
upload = QtWidgets.QMessageBox.question(
self, '', 'Upload alignment?', QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No
)
return upload == QtWidgets.QMessageBox.Yes
[docs]
def upload_info(self, uploaded: bool, info: str | None = None) -> None:
"""
Show a message box to inform the user of the upload status.
Parameters
----------
uploaded: bool
Whether the channels and alignments were saved.
info: str or None
The message to display to the user
"""
if uploaded:
QtWidgets.QMessageBox.information(self, 'Status', info)
else:
QtWidgets.QMessageBox.information(self, 'Status', 'Channels not saved')