Source code for PartSeg.common_gui.main_window

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