import dataclasses
import os
import sys
from pathlib import Path
from typing import List, Optional, Type, Union
from napari import __version__
from packaging.version import parse as parse_version
from qtpy.QtCore import Qt, Signal
from qtpy.QtGui import QCloseEvent, QDragEnterEvent, QDropEvent, QShowEvent
from qtpy.QtWidgets import QAction, QApplication, QDockWidget, QMainWindow, QMenu, QMessageBox, QWidget
from vispy.color import colormap
from PartSeg.common_backend.base_settings import (
FILE_HISTORY,
BaseSettings,
SwapTimeStackException,
TimeAndStackException,
)
from PartSeg.common_backend.except_hook import show_warning
from PartSeg.common_backend.load_backup import import_config
from PartSeg.common_gui.about_dialog import AboutDialog
from PartSeg.common_gui.custom_save_dialog import PSaveDialog
from PartSeg.common_gui.error_report import DataImportErrorDialog
from PartSeg.common_gui.exception_hooks import load_data_exception_hook
from PartSeg.common_gui.image_adjustment import ImageAdjustmentDialog
from PartSeg.common_gui.napari_image_view import ImageView
from PartSeg.common_gui.napari_viewer_wrap import Viewer
from PartSeg.common_gui.qt_console import QtConsole
from PartSeg.common_gui.show_directory_dialog import DirectoryDialog
from PartSeg.common_gui.waiting_dialog import ExecuteFunctionDialog
from PartSegCore.algorithm_describe_base import Register
from PartSegCore.io_utils import LoadBase, SaveScreenshot
from PartSegCore.project_info import ProjectInfoBase
from PartSegImage import Image
OPEN_FILE = "io.open_file"
OPEN_DIRECTORY = "io.open_directory"
OPEN_FILE_FILTER = "io.open_filter"
_EXIT = object()
NAPARI_LE_4_16 = parse_version(__version__) <= parse_version("0.4.16")
[docs]class BaseMainMenu(QWidget):
def __init__(self, settings: BaseSettings, main_window):
super().__init__()
self.settings = settings
self.main_window = main_window
def _set_data_list(self, data_list):
if len(data_list) == 0:
QMessageBox.warning(self, "Empty list", "List of files to load is empty")
return _EXIT
if hasattr(self.main_window, "multiple_files"):
self.main_window.multiple_files.add_states(data_list)
self.main_window.multiple_files.setVisible(True)
self.settings.set("multiple_files", True)
return data_list[0]
def _set_project_info_base(self, project_info_base: ProjectInfoBase):
if project_info_base.errors: # pragma: no cover
resp = QMessageBox.question(
self,
"Load problem",
f"During load data "
f"some problems occur: {project_info_base.errors}."
"Do you would like to try load it anyway?",
QMessageBox.Yes | QMessageBox.No,
)
if resp == QMessageBox.No:
return _EXIT
try:
image = self.settings.verify_image(project_info_base.image, False)
except SwapTimeStackException:
res = QMessageBox.question(
self,
"Not supported",
"Time data are currently not supported. Maybe You would like to treat time as z-stack",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if res == QMessageBox.Yes:
image = project_info_base.image.swap_time_and_stack()
else:
return _EXIT
except TimeAndStackException:
QMessageBox.warning(self, "image error", "Do not support time and stack image")
return _EXIT
if not image:
return _EXIT
if isinstance(image, Image):
# noinspection PyProtectedMember
project_info_base = dataclasses.replace(project_info_base, image=image)
return project_info_base
def set_data(self, data):
if isinstance(data, list):
data = self._set_data_list(data)
if isinstance(data, ProjectInfoBase):
data = self._set_project_info_base(data)
if data is _EXIT:
return
if data is None:
QMessageBox().warning(self, "Data loading failure", "Error during data loading", QMessageBox.Ok)
return
self.settings.set_project_info(data)
if data.file_path:
self.settings.add_path_history(os.path.dirname(data.file_path))
[docs]class BaseMainWindow(QMainWindow):
"""
Base for main windows of subprograms
:ivar settings: store state of application. initial value is obtained from :py:attr:`.settings_class`
:ivar files_num: maximal number of files accepted by drag and rop event
:param config_folder: path to directory in which application save state. If `settings` parameter is note
then settings object is created with passing this path to :py:attr:`.settings_class`.
If this parameter and `settings`
are None then constructor fail with :py:exc:`ValueError`.
:param title: Window default title
:param settings: object to store application state
:param signal_fun: function which need to be called when window shown.
"""
show_signal = Signal()
"""Signal emitted when window has shown. Used to hide Launcher."""
[docs] @classmethod
def get_setting_class(cls) -> Type[BaseSettings]:
"""Get constructor for :py:attr:`settings`"""
return BaseSettings
def __init__(
self,
config_folder: Union[str, Path, None] = None,
title="PartSeg",
settings: Optional[BaseSettings] = None,
load_dict: Optional[Register] = None,
signal_fun=None,
):
if settings is None:
if config_folder is None:
raise ValueError("wrong config folder")
if not os.path.exists(config_folder):
import_config()
settings: BaseSettings = self.get_setting_class()(config_folder)
if errors := settings.load(): # pragma: no cover
DataImportErrorDialog(
errors,
text="During load saved state some of data could not be load properly\n"
"The files has prepared backup copies in "
" state directory (Help > State directory)",
).exec_()
super().__init__()
if signal_fun is not None:
self.show_signal.connect(signal_fun)
self.settings = settings
self._load_dict = load_dict
self.viewer_list: List[Viewer] = []
self.files_num = 1
self.setAcceptDrops(True)
self.setWindowTitle(title)
self.title_base = title
app = QApplication.instance()
if app is not None and "PYTEST_CURRENT_TEST" not in os.environ: # pragma: no cover
# FIXME the PYTEST_CURRENT_TEST is used to prevent pytest session fail on CI.
app.setStyleSheet(settings.get_style_sheet())
self.settings.theme_changed.connect(self.change_theme)
self.channel_info = ""
self.multiple_files = None
self.settings.request_load_files.connect(self.read_drop)
self.recent_file_menu = QMenu("Open recent")
self._refresh_recent()
self.settings.connect_(FILE_HISTORY, self._refresh_recent)
self.settings.napari_settings.appearance.events.theme.connect(self.change_theme)
self.settings.set_parent(self)
self.console = None
self.console_dock = QDockWidget("console", self)
self.console_dock.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.BottomDockWidgetArea)
self.console_dock.hide()
self.addDockWidget(Qt.BottomDockWidgetArea, self.console_dock)
self._scale_bar_warning = True # remove after drop napari 0.4.16
def _toggle_console(self):
if self.console is None:
self.console = QtConsole(self)
self.console_dock.setWidget(self.console)
self.console_dock.setVisible(not self.console_dock.isVisible())
def _refresh_recent(self):
self.recent_file_menu.clear()
for name_list, method in self.settings.get_last_files():
action = self.recent_file_menu.addAction(f"{name_list[0]}, {method}")
action.setData((name_list, method))
action.triggered.connect(self._load_recent)
def _load_recent(self):
sender: QAction = self.sender()
data = sender.data()
try:
method: LoadBase = self._load_dict[data[1]]
dial = ExecuteFunctionDialog(method.load, [data[0]], exception_hook=load_data_exception_hook)
if dial.exec_():
result = dial.get_result()
self.main_menu.set_data(result)
self.settings.add_last_files(data[0], method.get_name())
self.settings.set(OPEN_DIRECTORY, os.path.dirname(data[0][0]))
self.settings.set(OPEN_FILE, data[0][0])
self.settings.set(OPEN_FILE_FILTER, data[1])
except KeyError: # pragma: no cover
self.read_drop(data[0])
def toggle_multiple_files(self):
self.settings.set("multiple_files_widget", not self.settings.get("multiple_files_widget", False))
def get_colormaps(self) -> List[Optional[colormap.Colormap]]:
channel_num = self.settings.image.channels
if not self.channel_info:
return [None for _ in range(channel_num)]
colormaps_name = [self.settings.get_channel_colormap_name(self.channel_info, i) for i in range(channel_num)]
return [self.settings.colormap_dict[name][0] for name in colormaps_name]
def napari_viewer_show(self):
viewer = Viewer(title="Additional output", settings=self.settings, partseg_viewer_name=self.channel_info)
viewer.theme = self.settings.theme_name
viewer.create_initial_layers(image=True, roi=True, additional_layers=False, points=True)
self.viewer_list.append(viewer)
viewer.window.qt_viewer.destroyed.connect(lambda _x: self.close_viewer(viewer))
def additional_layers_show(self, with_channels=False):
if not self.settings.additional_layers:
QMessageBox().information(self, "No data", "Last executed algoritm does not provide additional data")
return
viewer = Viewer(title="Additional output", settings=self.settings, partseg_viewer_name=self.channel_info)
viewer.theme = self.settings.theme_name
viewer.create_initial_layers(image=with_channels, roi=False, additional_layers=True, points=False)
self.viewer_list.append(viewer)
viewer.window.qt_viewer.destroyed.connect(lambda _x: self.close_viewer(viewer))
def close_viewer(self, obj):
for i, el in enumerate(self.viewer_list):
if el == obj:
self.viewer_list.pop(i)
break
# @ensure_main_thread
def change_theme(self, event):
style_sheet = self.settings.style_sheet
app = QApplication.instance()
if app is not None and "PYTEST_CURRENT_TEST" not in os.environ: # pragma: no cover
app.setStyleSheet(style_sheet)
self.setStyleSheet(style_sheet)
[docs] def showEvent(self, a0: QShowEvent):
self.show_signal.emit()
[docs] def dragEnterEvent(self, event: QDragEnterEvent): # pylint: disable=no-self-use
if event.mimeData().hasUrls():
event.acceptProposedAction()
[docs] def read_drop(self, paths: List[str]):
"""Function to process loading files by drag and drop."""
self._read_drop(paths, self._load_dict)
def _read_drop(self, paths, load_dict):
ext_set = {os.path.splitext(x)[1].lower() for x in paths}
def exception_hook(exception): # pragma: no cover
additional_info = "files: " + ", ".join(paths)
if isinstance(exception, OSError):
# if happens on macos then add information about requirements to check permissions to file
if sys.platform == "darwin":
additional_info += (
"In latest macos release you may need to check if you gave PartSeg (or terminal)"
"Permission to access files. You can do it in System Preferences -> Security & Privacy"
)
show_warning(
"IO Error",
"Disc operation error: " + ", ".join(str(x) for x in exception.args) + additional_info,
exception=exception,
)
else:
raise exception
for load_class in load_dict.values():
if load_class.partial() or load_class.number_of_files() != len(paths):
continue
if ext_set.issubset(load_class.get_extensions()):
dial = ExecuteFunctionDialog(load_class.load, [paths], exception_hook=exception_hook)
if dial.exec_():
result = dial.get_result()
self.main_menu.set_data(result)
self.settings.add_last_files(paths, load_class.get_name())
return
QMessageBox.information(self, "No method", "No methods for load files: " + ",".join(paths))
[docs] def dropEvent(self, event: QDropEvent):
"""
Support for load files by drag and drop.
At beginning it check number of files and if it greater than :py:attr:`.files_num` it refuse loading. Otherwise
it call :py:meth:`.read_drop` method and this method should be overwritten in sub classes
"""
if not all(x.isLocalFile() for x in event.mimeData().urls()):
QMessageBox().warning(self, "Load error", "Not all files are locally. Cannot load data.", QMessageBox.Ok)
paths = [x.toLocalFile() for x in event.mimeData().urls()]
if self.files_num != -1 and len(paths) > self.files_num:
QMessageBox.information(self, "To many files", "currently support only drag and drop one file")
return
self.read_drop(paths)
def show_settings_directory(self):
DirectoryDialog(
self.settings.json_folder_path, "Path to place where PartSeg store the data between runs"
).exec_()
[docs] @staticmethod
def show_about_dialog():
"""Show about dialog."""
AboutDialog().exec_()
@staticmethod
def get_project_info(file_path, image, roi_info=None):
raise NotImplementedError
def image_adjust_exec(self):
dial = ImageAdjustmentDialog(self.settings.image)
if dial.exec_():
algorithm = dial.result_val.algorithm
dial2 = ExecuteFunctionDialog(
algorithm.transform,
[],
{"image": self.settings.image, "arguments": dial.result_val.values, "roi_info": self.settings.roi_info},
)
if dial2.exec_():
image, roi_info = dial2.get_result()
self.settings.set_project_info(self.get_project_info(image.file_path, image, roi_info))
[docs] def closeEvent(self, event: QCloseEvent):
for el in self.viewer_list:
el.close()
del el
self.settings.napari_settings.appearance.events.theme.disconnect(self.change_theme)
self.settings.dump()
super().closeEvent(event)
def screenshot(self, viewer: ImageView):
def _screenshot():
data = viewer.viewer_widget.screenshot()
dial = PSaveDialog(
SaveScreenshot,
settings=self.settings,
system_widget=False,
path="io.save_screenshot",
file_mode=PSaveDialog.AnyFile,
)
if not dial.exec_():
return
res = dial.get_result()
res.save_class.save(res.save_destination, data, res.parameters)
return _screenshot
def image_read(self):
if self.settings.image_path is None:
self.setWindowTitle(f"{self.title_base}")
return
folder_name, file_name = os.path.split(self.settings.image_path)
self.setWindowTitle(f"{self.title_base}: {os.path.join(os.path.basename(folder_name), file_name)}")
self.statusBar().showMessage(self.settings.image_path)
[docs] def deleteLater(self) -> None:
self.settings.napari_settings.appearance.events.theme.disconnect(self.change_theme)
super().deleteLater()
def _toggle_scale_bar(self):
"""Remove after drop napari 0.4.16"""
if NAPARI_LE_4_16 and self._scale_bar_warning and self.settings.theme_name == "light": # pragma: no cover
QMessageBox.warning(
self, "Not supported", "Scale bar is not supported for light theme and napari bellow 0.4.17"
)
self._scale_bar_warning = False