Source code for spinetoolbox.ui_main
######################################################################################################################
# Copyright (C) 2017-2020 Spine project consortium
# This file is part of Spine Toolbox.
# Spine Toolbox is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General
# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option)
# any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General
# Public License for more details. You should have received a copy of the GNU Lesser General Public License along with
# this program. If not, see <http://www.gnu.org/licenses/>.
######################################################################################################################
"""
Contains ToolboxUI class.
:author: P. Savolainen (VTT)
:date: 14.12.2017
"""
import os
import locale
import logging
import json
import pathlib
import numpy as np
from PySide2.QtCore import QByteArray, QItemSelection, QMimeData, Qt, Signal, Slot, QSettings, QUrl, SIGNAL
from PySide2.QtGui import QDesktopServices, QGuiApplication, QKeySequence, QIcon, QCursor
from PySide2.QtWidgets import (
QMainWindow,
QApplication,
QErrorMessage,
QFileDialog,
QMessageBox,
QCheckBox,
QDockWidget,
QAction,
QUndoStack,
)
from .category import CATEGORIES, CATEGORY_DESCRIPTIONS
from .graphics_items import ProjectItemIcon
from .load_project_items import load_item_specification_factories, load_project_items
from .mvcmodels.project_item_model import ProjectItemModel
from .mvcmodels.project_item_factory_models import (
ProjectItemFactoryModel,
ProjectItemSpecFactoryModel,
FilteredSpecFactoryModel,
)
from .widgets.about_widget import AboutWidget
from .widgets.custom_menus import (
ProjectItemModelContextMenu,
LinkContextMenu,
AddSpecificationPopupMenu,
RecentProjectsPopupMenu,
)
from .widgets.settings_widget import SettingsWidget
from .widgets.custom_qwidgets import ZoomWidgetAction
from .widgets.spine_console_widget import SpineConsoleWidget
from .widgets import toolbars
from .widgets.open_project_widget import OpenProjectDialog
from .project import SpineToolboxProject
from .config import (
STATUSBAR_SS,
TEXTBROWSER_SS,
MAINWINDOW_SS,
DOCUMENTATION_PATH,
_program_root,
LATEST_PROJECT_VERSION,
DEFAULT_WORK_DIR,
PROJECT_FILENAME
)
from .helpers import (
ensure_window_is_on_screen,
get_datetime,
busy_effect,
set_taskbar_icon,
supported_img_formats,
create_dir,
recursive_overwrite,
serialize_path,
deserialize_path,
open_url,
ChildCyclingKeyPressFilter
)
from .project_upgrader import ProjectUpgrader
from .project_tree_item import LeafProjectTreeItem, CategoryProjectTreeItem, RootProjectTreeItem
from .project_commands import AddSpecificationCommand, RemoveSpecificationCommand, UpdateSpecificationCommand
from .configuration_assistants import spine_opt
[docs]class ToolboxUI(QMainWindow):
"""Class for application main GUI functions."""
# Signals to comply with the spinetoolbox.logger_interface.LoggerInterface interface.
# The rest of the msg_* signals should be moved to LoggerInterface in the long run.
def __init__(self):
"""Initializes application and main window."""
from .ui.mainwindow import Ui_MainWindow # pylint: disable=import-outside-toplevel
super().__init__(flags=Qt.Window)
self._qsettings = QSettings("SpineProject", "Spine Toolbox")
# Set number formatting to use user's default settings
locale.setlocale(locale.LC_NUMERIC, 'C')
# Setup the user interface from Qt Designer files
self.ui = Ui_MainWindow()
self.ui.setupUi(self)
self.setWindowIcon(QIcon(":/symbols/app.ico"))
set_taskbar_icon() # in helpers.py
self.ui.graphicsView.set_ui(self)
self.key_press_filter = ChildCyclingKeyPressFilter()
self.ui.tabWidget_item_properties.installEventFilter(self.key_press_filter)
self._project_item_actions = list()
self._item_edit_actions()
# Set style sheets
self.ui.statusbar.setStyleSheet(STATUSBAR_SS) # Initialize QStatusBar
self.ui.statusbar.setFixedHeight(20)
self.ui.textBrowser_eventlog.setStyleSheet(TEXTBROWSER_SS)
self.ui.textBrowser_process_output.setStyleSheet(TEXTBROWSER_SS)
self.setStyleSheet(MAINWINDOW_SS)
# Class variables
self.undo_stack = QUndoStack(self)
self._item_categories = dict()
self.item_factories = dict() # maps item types to `ProjectItemFactory` objects
self._item_specification_factories = dict() # maps item types to `ProjectItemSpecificationFactory` objects
self._project = None
self.project_item_factory_model = None
self.project_item_model = None
self.specification_model = None
self.filtered_spec_factory_models = {}
self.show_datetime = self.update_datetime()
self.active_project_item = None
self.work_dir = None
# Widget and form references
self.settings_form = None
self.specification_context_menu = None
self.project_item_context_menu = None
self.link_context_menu = None
self.process_output_context_menu = None
self.add_project_item_form = None
self.specification_form = None
self.placing_item = ""
self.add_specification_popup_menu = None
self.zoom_widget_action = None
self.recent_projects_menu = RecentProjectsPopupMenu(self)
# Make and initialize toolbars
self.main_toolbar = toolbars.MainToolBar(self)
self.addToolBar(Qt.TopToolBarArea, self.main_toolbar)
# Make julia REPL
self.julia_console = SpineConsoleWidget(self, "Julia Console")
self.ui.dockWidgetContents_julia_repl.layout().addWidget(self.julia_console)
# Make Python REPL
self.python_console = SpineConsoleWidget(self, "Python Console")
self.ui.dockWidgetContents_python_console.layout().addWidget(self.python_console)
# Setup main window menu
self.setup_zoom_widget_action()
self.add_menu_actions()
# Hidden QActions for debugging or testing
self.show_properties_tabbar = QAction(self)
self.show_supported_img_formats = QAction(self)
self.set_debug_qactions()
self.ui.tabWidget_item_properties.tabBar().hide() # Hide tab bar in properties dock widget
# Finalize init
self._proposed_item_name_counts = dict()
self.ui.actionSave.setDisabled(True)
self.ui.actionSave_As.setDisabled(True)
self.connect_signals()
self.restore_ui()
self.parse_project_item_modules()
self.parse_assistant_modules()
self.main_toolbar.setup()
self.set_work_directory()
[docs] def connect_signals(self):
"""Connect signals."""
# Event and process log signals
self.msg.connect(self.add_message)
self.msg_success.connect(self.add_success_message)
self.msg_error.connect(self.add_error_message)
self.msg_warning.connect(self.add_warning_message)
self.msg_proc.connect(self.add_process_message)
self.msg_proc_error.connect(self.add_process_error_message)
self.ui.textBrowser_eventlog.anchorClicked.connect(self.open_anchor)
# Message box signals
self.information_box.connect(self._show_message_box)
self.error_box.connect(self._show_error_box)
# Menu commands
self.ui.actionNew.triggered.connect(self.new_project)
self.ui.actionOpen.triggered.connect(self.open_project)
self.ui.actionOpen_recent.setMenu(self.recent_projects_menu)
self.ui.actionOpen_recent.hovered.connect(self.show_recent_projects_menu)
self.ui.actionSave.triggered.connect(self.save_project)
self.ui.actionSave_As.triggered.connect(self.save_project_as)
self.ui.actionUpgrade_project.triggered.connect(self.upgrade_project)
self.ui.actionExport_project_to_GraphML.triggered.connect(self.export_as_graphml)
self.ui.actionSettings.triggered.connect(self.show_settings)
self.ui.actionQuit.triggered.connect(self.close)
self.ui.actionRemove_all.triggered.connect(self.remove_all_items)
self.ui.actionUser_Guide.triggered.connect(self.show_user_guide)
self.ui.actionGetting_started.triggered.connect(self.show_getting_started_guide)
self.ui.actionAbout.triggered.connect(self.show_about)
# noinspection PyArgumentList
self.ui.actionAbout_Qt.triggered.connect(lambda: QApplication.aboutQt()) # pylint: disable=unnecessary-lambda
self.ui.actionRestore_Dock_Widgets.triggered.connect(self.restore_dock_widgets)
self.ui.actionCopy.triggered.connect(self.project_item_to_clipboard)
self.ui.actionPaste.triggered.connect(self.project_item_from_clipboard)
self.ui.actionDuplicate.triggered.connect(self.duplicate_project_item)
# Debug actions
self.show_properties_tabbar.triggered.connect(self.toggle_properties_tabbar_visibility)
self.show_supported_img_formats.triggered.connect(supported_img_formats) # in helpers.py
# Context-menus
self.ui.treeView_project.customContextMenuRequested.connect(self.show_item_context_menu)
# Zoom actions
self.zoom_widget_action.minus_pressed.connect(self._handle_zoom_minus_pressed)
self.zoom_widget_action.plus_pressed.connect(self._handle_zoom_plus_pressed)
self.zoom_widget_action.reset_pressed.connect(self._handle_zoom_reset_pressed)
# Undo stack
self.undo_stack.cleanChanged.connect(self.update_window_modified)
@Slot(bool)
[docs] def update_window_modified(self, clean):
"""Updates window modified status and save actions depending on the state of the undo stack."""
try:
self.setWindowModified(not clean)
except RuntimeError as e:
raise e
self.ui.actionSave.setDisabled(clean)
[docs] def parse_project_item_modules(self):
"""Collects attributes from project item modules into a dict.
This dict is then used to perform all project item related tasks.
"""
self._item_categories, self.item_factories = load_project_items(self)
self._item_specification_factories = load_item_specification_factories()
self.init_project_item_factory_model()
self.add_specification_popup_menu = AddSpecificationPopupMenu(self)
[docs] def init_project_item_factory_model(self):
self.project_item_factory_model = ProjectItemFactoryModel(self)
for item_type, factory in self.item_factories.items():
self.project_item_factory_model.add_item(item_type, factory)
[docs] def parse_assistant_modules(self):
"""Makes actions to run assistants from assistant modules."""
menu = self.ui.menuTool_configuration_assistants
for module in (spine_opt,): # NOTE: add others as needed
action = menu.addAction(module.assistant_name)
action.triggered.connect(
lambda checked=False, module=module, action=action: self.show_assistant(module, action)
)
[docs] def show_assistant(self, module, action):
"""Creates and shows the assistant for the given module.
Disables the given action while the assistant is shown,
enables the action back when the assistant is destroyed.
This is to make sure we don't open the same assistant twice.
"""
assistant = module.make_assistant(self)
action.setEnabled(False)
assistant.destroyed.connect(lambda obj=None: action.setEnabled(True))
assistant.show()
[docs] def set_work_directory(self, new_work_dir=None):
"""Creates a work directory if it does not exist or changes the current work directory to given.
Args:
new_work_dir (str, optional): If given, changes the work directory to given
and creates the directory if it does not exist.
"""
if not new_work_dir:
self.work_dir = self._qsettings.value("appSettings/workDir", defaultValue=DEFAULT_WORK_DIR)
if not self.work_dir:
# It is possible we still don't have a directory set
self.work_dir = DEFAULT_WORK_DIR
else:
self.work_dir = new_work_dir
self.msg.emit("Work directory is now <b>{0}</b>".format(self.work_dir))
try:
create_dir(self.work_dir)
except OSError:
self.msg_error.emit(
"[OSError] Creating work directory {0} failed. Check permissions.".format(self.work_dir)
)
self.work_dir = None
[docs] def project(self):
"""Returns current project or None if no project open."""
return self._project
[docs] def update_window_title(self):
self.setWindowTitle("{0} [{1}][*] - Spine Toolbox".format(self._project.name, self._project.project_dir))
@Slot()
[docs] def init_project(self, project_dir):
"""Initializes project at application start-up. Opens the last project that was open
when app was closed (if enabled in Settings) or starts the app without a project.
"""
p = os.path.join(DOCUMENTATION_PATH, "getting_started.html")
if os.path.exists(p): # First try to open local gettings started guide, if it does not exist, open it online
getting_started_anchor = (
"<a style='color:#99CCFF;' title='" + p + "' href='file:///" + p + "'>Getting Started</a>"
)
else:
getting_started_anchor = (
"<a style='color:#99CCFF;' title='" + p +
"' href='https://spine-toolbox.readthedocs.io/en/release-0.5/getting_started.html'>Getting Started</a>"
)
welcome_msg = "Welcome to Spine Toolbox! If you need help, please read the {0} guide.".format(
getting_started_anchor
)
if not project_dir:
open_previous_project = int(self._qsettings.value("appSettings/openPreviousProject", defaultValue="0"))
if open_previous_project != Qt.Checked: # 2: Qt.Checked, ie. open_previous_project==True
self.msg.emit(welcome_msg)
return
# Get previous project (directory)
project_dir = self._qsettings.value("appSettings/previousProject", defaultValue="")
if not project_dir:
return
if os.path.isfile(project_dir) and project_dir.endswith(".proj"):
# Previous project was a .proj file -> Show welcome message instead
self.msg.emit(welcome_msg)
return
if not os.path.isdir(project_dir):
self.ui.statusbar.showMessage("Opening previous project failed", 10000)
self.msg_error.emit(
"Cannot open previous project. Directory <b>{0}</b> may have been moved.".format(project_dir)
)
return
self.open_project(project_dir, clear_logs=False)
@Slot()
[docs] def new_project(self):
"""Opens a file dialog where user can select a directory where a project is created.
Pops up a question box if selected directory is not empty or if it already contains
a Spine Toolbox project. Initial project name is the directory name.
"""
recents = self.qsettings().value("appSettings/recentProjectStorages", defaultValue=None)
if not recents:
initial_path = _program_root
else:
recents_lst = str(recents).split("\n")
if not os.path.isdir(recents_lst[0]):
# Remove obsolete entry from recentProjectStorages
OpenProjectDialog.remove_directory_from_recents(recents_lst[0], self.qsettings())
initial_path = _program_root
else:
initial_path = recents_lst[0]
# noinspection PyCallByClass
project_dir = QFileDialog.getExistingDirectory(self, "Select project directory (New project...)", initial_path)
if not project_dir:
return
if not os.path.isdir(project_dir): # Just to be sure, probably not needed
self.msg_error.emit("Selection is not a directory, please try again")
return
# Check if directory is empty and/or a project directory
if not self.overwrite_check(project_dir):
return
_, project_name = os.path.split(project_dir)
self.create_project(project_name, "", project_dir)
[docs] def create_project(self, name, description, location):
"""Creates new project and sets it active.
Args:
name (str): Project name
description (str): Project description
location (str): Path to project directory
"""
self.undo_critical_commands()
self.clear_ui()
self.init_project_item_model()
self.ui.treeView_project.selectionModel().selectionChanged.connect(self.item_selection_changed)
self._project = SpineToolboxProject(
self,
name,
description,
location,
self.project_item_model,
settings=self._qsettings,
embedded_julia_console=self.julia_console,
embedded_python_console=self.python_console,
logger=self,
)
self._project.connect_signals()
self._connect_project_signals()
self.init_specification_model(list()) # Start project with no specifications
self.update_window_title()
self.ui.actionSave_As.setEnabled(True)
self.ui.graphicsView.reset_zoom()
# Update recentProjects
self.update_recent_projects()
# Update recentProjectStorages
OpenProjectDialog.update_recents(os.path.abspath(os.path.join(location, os.path.pardir)), self.qsettings())
self.save_project()
# Clear text browsers
self.ui.textBrowser_eventlog.clear()
self.ui.textBrowser_process_output.clear()
self.msg.emit("New project <b>{0}</b> is now open".format(self._project.name))
@Slot()
[docs] def open_project(self, load_dir=None, clear_logs=True):
"""Opens project from a selected or given directory.
Args:
load_dir (str): Path to project base directory. If default value is used,
a file explorer dialog is opened where the user can select the
project to open.
clear_logs (bool): True clears Event and Process Log, False does not
Returns:
bool: True when opening the project succeeded, False otherwise
"""
if not load_dir:
dialog = OpenProjectDialog(self)
if not dialog.exec():
return False
load_dir = dialog.selection()
load_path = os.path.abspath(os.path.join(load_dir, ".spinetoolbox", PROJECT_FILENAME))
try:
with open(load_path, "r") as fh:
try:
proj_info = json.load(fh)
except json.decoder.JSONDecodeError:
self.msg_error.emit("Error in project file <b>{0}</b>. Invalid JSON.".format(load_path))
return False
except OSError:
# Remove path from recent projects
self.remove_path_from_recent_projects(load_dir)
self.msg_error.emit("Project file <b>{0}</b> missing".format(load_path))
return False
return self.restore_project(proj_info, load_dir, clear_logs)
[docs] def restore_project(self, project_info, project_dir, clear_logs):
"""Initializes UI, Creates project, models, connections, etc., when opening a project.
Args:
project_info (dict): Project information dictionary
project_dir (str): Project directory
clear_logs (bool): True clears Event and Process Log, False does not
Returns:
bool: True when restoring project succeeded, False otherwise
"""
self.undo_critical_commands()
# Make room for a new project
self.clear_ui()
# Clear text browsers
if clear_logs:
self.ui.textBrowser_eventlog.clear()
self.ui.textBrowser_process_output.clear()
# Check if project dictionary needs to be upgraded
project_info = ProjectUpgrader(self).upgrade(project_info, project_dir)
if not project_info:
return False
if not ProjectUpgrader(self).is_valid(LATEST_PROJECT_VERSION, project_info): # Check project info validity
self.msg_error.emit(f"Opening project in directory {project_dir} failed")
return False
# Parse project info
name = project_info["project"]["name"] # Project name
desc = project_info["project"]["description"] # Project description
tool_specs = project_info["project"]["specifications"]["Tool"]
connections = project_info["project"]["connections"]
project_items = project_info["items"]
# Init project item model
self.init_project_item_model()
self.ui.treeView_project.selectionModel().selectionChanged.connect(self.item_selection_changed)
# Create project
self._project = SpineToolboxProject(
self,
name,
desc,
project_dir,
self.project_item_model,
settings=self._qsettings,
embedded_julia_console=self.julia_console,
embedded_python_console=self.python_console,
logger=self,
)
self._connect_project_signals()
self.update_window_title()
self.ui.actionSave.setDisabled(True)
self.ui.actionSave_As.setEnabled(True)
# Init tool spec model
deserialized_paths = [deserialize_path(spec, self._project.project_dir) for spec in tool_specs]
self.init_specification_model(deserialized_paths)
# Populate project model with project items
if not self._project.load(project_items):
self.msg_error.emit("Loading project items failed")
return False
self.ui.treeView_project.expandAll()
# Restore connections
self.msg.emit("Restoring connections...")
self.ui.graphicsView.restore_links(connections)
# Simulate project execution after restoring links
self._project.notify_changes_in_all_dags()
self._project.connect_signals()
# Reset zoom on Design View
self.ui.graphicsView.reset_zoom()
self.update_recent_projects()
self.msg.emit("Project <b>{0}</b> is now open".format(self._project.name))
return True
@Slot()
@Slot()
[docs] def save_project(self):
"""Save project."""
if not self._project:
self.msg.emit("Please open or create a project first")
return
# Save specs
for spec in self.specification_model.specifications():
if not spec.save():
self.msg_error.emit("Project saving failed")
return
# Put project's specification definition files into a list
tool_spec_paths = [
self.specification_model.specification(i).definition_file_path
for i in range(self.specification_model.rowCount())
]
# Serialize tool spec paths
serialized_tool_spec_paths = [serialize_path(spec, self._project.project_dir) for spec in tool_spec_paths]
if not self._project.save(serialized_tool_spec_paths):
self.msg_error.emit("Project saving failed")
return
self.msg.emit("Project <b>{0}</b> saved".format(self._project.name))
self.undo_stack.setClean()
@Slot()
[docs] def save_project_as(self):
"""Ask user for a new project name and save. Creates a duplicate of the open project."""
if not self._project:
self.msg.emit("Please open or create a project first")
return
# Ask for a new directory
# noinspection PyCallByClass, PyArgumentList
answer = QFileDialog.getExistingDirectory(
self,
"Select new project directory (Save as...)",
os.path.abspath(os.path.join(self._project.project_dir, os.path.pardir)),
)
if not answer: # Canceled
return
# Just do regular save if selected directory is the same as the current project directory
if pathlib.Path(answer) == pathlib.Path(self._project.project_dir):
self.msg_warning.emit("Project directory unchanged")
self.save_project()
return
# Check and ask what to do if selected directory is not empty
if not self.overwrite_check(answer):
return
self.msg.emit("Saving project to directory {0}".format(answer))
recursive_overwrite(self, self._project.project_dir, answer, silent=False)
# Get the project info from the new directory and restore project
config_file_path = os.path.join(answer, ".spinetoolbox", "project.json")
try:
with open(config_file_path, "r") as fh:
try:
proj_info = json.load(fh)
except json.decoder.JSONDecodeError:
self.msg_error.emit("Error in project file <b>{0}</b>. Invalid JSON. {0}".format(config_file_path))
return
except OSError:
self.msg_error.emit("[OSError] Opening project file <b>{0}</b> failed".format(config_file_path))
return
if not self.restore_project(proj_info, answer, clear_logs=False):
return
# noinspection PyCallByClass, PyArgumentList
QMessageBox.information(self, f"Project {self._project.name} saved", f"Project directory is now\n\n{answer}")
self.undo_stack.setClean()
@Slot(bool)
[docs] def upgrade_project(self, checked=False):
"""Upgrades an old style project (.proj file) to a new directory based Spine Toolbox project.
Note that this method can be removed when we no longer want to support upgrading .proj projects.
Project upgrading should happen later automatically when opening a project.
"""
msg = (
"This option upgrades your legacy Spine Toolbox projects from .proj files to "
"<br/>Spine Toolbox project <b>directories</b>."
"<br/><br/>Next, you will be presented two file dialogs:"
"<br/><b>1.</b>From the first dialog, select a project you<br/> want "
"to upgrade by selecting a <i>.proj</i> <b>file</b>"
"<br/><b>2.</b>From the second dialog, select a <b>directory</b> "
"for the upgraded project."
"<br/><br/>Project item data will be copied to the new project directory."
"<br/><br/><b>P.S.</b> You only need to do this once per project."
)
QMessageBox.information(self, "Project upgrade wizard", msg)
# noinspection PyCallByClass
answer = QFileDialog.getOpenFileName(
self, "Select an old project (.proj file) to upgrade", _program_root, "Project file (*.proj)"
)
if not answer[0]:
return
fp = answer[0]
upgrader = ProjectUpgrader(self)
proj_dir = upgrader.get_project_directory() # New project directory
if not proj_dir:
self.msg.emit("Project upgrade canceled")
return
proj_info = upgrader.open_proj_json(fp)
if not proj_info:
return
old_project_dir = os.path.normpath(os.path.join(os.path.dirname(fp), fp[:-5]))
if not os.path.isdir(old_project_dir):
self.msg_error.emit("Project upgrade failed")
self.msg_warning.emit("Project directory <b>{0}</b> does not exist".format(old_project_dir))
return
# Upgrade project info dict to latest version
project_dict_v1 = upgrader.upgrade_to_v1(proj_info, old_project_dir)
# Make version 1 project.json file to new project directory.
# Needs to be done so that upgrade() is able to make a backup and force save project.json
# version 2 file.
spinetoolbox_dir = os.path.abspath(os.path.join(proj_dir, ".spinetoolbox"))
try:
create_dir(spinetoolbox_dir)
except OSError:
self.msg_error.emit("Creating directory {0} failed".format(spinetoolbox_dir))
return
project_json_path = os.path.join(spinetoolbox_dir, PROJECT_FILENAME)
with open(project_json_path, "w") as project_json_fp:
json.dump(project_dict_v1, project_json_fp, indent=4)
# Upgrade to latest version
upgraded_project_dict = upgrader.upgrade(project_dict_v1, proj_dir)
# Copy project item data from old project to new project directory
if not upgrader.copy_data(fp, proj_dir):
self.msg_warning.emit(
"Copying data to project <b>{0}</b> failed. "
"Please copy project item directories to directory <b>{1}</b> manually.".format(
proj_dir, os.path.join(proj_dir, ".spinetoolbox", "items")
)
)
# Open the upgraded project
if not self.restore_project(upgraded_project_dict, proj_dir, clear_logs=False):
return
# Save project to finish the upgrade process
self.save_project()
[docs] def init_project_item_model(self):
"""Initializes project item model. Create root and category items and
add them to the model."""
root_item = RootProjectTreeItem()
self.project_item_model = ProjectItemModel(self, root=root_item)
for category in CATEGORIES:
category_item = CategoryProjectTreeItem(str(category), CATEGORY_DESCRIPTIONS[category])
self.project_item_model.insert_item(category_item)
self.ui.treeView_project.setModel(self.project_item_model)
self.ui.treeView_project.header().hide()
self.ui.graphicsView.set_project_item_model(self.project_item_model)
[docs] def init_specification_model(self, specification_paths):
"""Initializes Tool specification model.
Args:
specification_paths (list): List of tool definition file paths used in this project
"""
factory_icons = {name: QIcon(factory.icon()) for name, factory in self.item_factories.items()}
self.specification_model = ProjectItemSpecFactoryModel(factory_icons)
self.filtered_spec_factory_models = {name: FilteredSpecFactoryModel(name) for name in self.item_factories}
for model in self.filtered_spec_factory_models.values():
model.setSourceModel(self.specification_model)
n_specs = 0
self.msg.emit("Loading specifications...")
for path in specification_paths:
if not path:
continue
# Add tool specification into project
spec = self.load_specification_from_file(path)
if not spec:
continue
n_specs += 1
# Add tool definition file path to tool instance variable
spec.definition_file_path = path
# Insert tool into model
self.specification_model.insertRow(spec)
# Set model to the tool specification list view
self.main_toolbar.project_item_spec_list_view.setModel(self.specification_model)
# Set model to Tool project item combo box
self.specification_model_changed.emit()
# Note: If ProjectItemSpecFactoryModel signals are in use, they should be reconnected here.
# Reconnect ProjectItemSpecFactoryModel and QListView signals. Make sure that signals are connected only once.
n_recv_sig1 = self.main_toolbar.project_item_spec_list_view.receivers(
SIGNAL("doubleClicked(QModelIndex)")
) # nr of receivers
if n_recv_sig1 == 0:
# logging.debug("Connecting doubleClicked signal for QListView")
self.main_toolbar.project_item_spec_list_view.doubleClicked.connect(self.edit_specification)
elif n_recv_sig1 > 1: # Check that this never gets over 1
logging.error("Number of receivers for QListView doubleClicked signal is now: %d", n_recv_sig1)
else:
pass # signal already connected
n_recv_sig2 = self.main_toolbar.project_item_spec_list_view.receivers(
SIGNAL("customContextMenuRequested(QPoint)")
)
if n_recv_sig2 == 0:
# logging.debug("Connecting customContextMenuRequested signal for QListView")
self.main_toolbar.project_item_spec_list_view.customContextMenuRequested.connect(
self.show_specification_context_menu
)
elif n_recv_sig2 > 1: # Check that this never gets over 1
logging.error("Number of receivers for QListView customContextMenuRequested signal is now: %d", n_recv_sig2)
else:
pass # signal already connected
if n_specs == 0:
self.msg_warning.emit("Project has no specifications")
[docs] def load_specification_from_file(self, def_path):
"""Returns an Item specification from a definition file.
Args:
def_path (str): Path of the specification definition file
Returns:
ProjectItemSpecification: item specification or None if reading the file failed
"""
try:
with open(def_path, "r") as fp:
try:
definition = json.load(fp)
except ValueError:
self.msg_error.emit("Item specification file not valid")
logging.exception("Loading JSON data failed")
return None
except FileNotFoundError:
self.msg_error.emit("Specification file <b>{0}</b> does not exist".format(def_path))
return None
return self.load_specification(definition, def_path)
[docs] def load_specification(self, definition, def_path):
"""Returns a Tool specification from a definition dictionary.
Args:
definition (dict): Dictionary with the tool definition
def_path (str): Path of the specification definition file
Returns:
ToolSpecification, NoneType
"""
# NOTE: Default to Tools so tool-specs work out of the box
item_type = definition.get("item_type", "Tool")
factory = self._item_specification_factories.get(item_type)
if factory is None:
return None
return factory.make_specification(
definition, def_path, self._qsettings, self, self.julia_console, self.python_console
)
[docs] def restore_ui(self):
"""Restore UI state from previous session."""
window_size = self._qsettings.value("mainWindow/windowSize", defaultValue="false")
window_pos = self._qsettings.value("mainWindow/windowPosition", defaultValue="false")
window_state = self._qsettings.value("mainWindow/windowState", defaultValue="false")
window_maximized = self._qsettings.value("mainWindow/windowMaximized", defaultValue="false") # returns str
n_screens = self._qsettings.value("mainWindow/n_screens", defaultValue=1) # number of screens on last exit
# noinspection PyArgumentList
n_screens_now = len(QGuiApplication.screens()) # Number of screens now
original_size = self.size()
# Note: cannot use booleans since Windows saves them as strings to registry
if window_size != "false":
self.resize(window_size) # Expects QSize
if window_pos != "false":
self.move(window_pos) # Expects QPoint
if window_state != "false":
self.restoreState(window_state, version=1) # Toolbar and dockWidget positions. Expects QByteArray
if n_screens_now < int(n_screens):
# There are less screens available now than on previous application startup
# Move main window to position 0,0 to make sure that it is not lost on another screen that does not exist
self.move(0, 0)
ensure_window_is_on_screen(self, original_size)
if window_maximized == "true":
self.setWindowState(Qt.WindowMaximized)
[docs] def clear_ui(self):
"""Clean UI to make room for a new or opened project."""
if not self.project():
return
item_names = self.project_item_model.item_names()
for name in item_names:
self.project().do_remove_item(name)
self.activate_no_selection_tab() # Clear properties widget
if self._project:
self._project.deleteLater()
self._project = None
self.specification_model = None
self.ui.graphicsView.scene().clear() # Clear all items from scene
[docs] def undo_critical_commands(self):
"""Undoes critical commands in the undo stack.
"""
if self.undo_stack.isClean():
return
for ind in reversed(range(self.undo_stack.index())):
cmd = self.undo_stack.command(ind)
if cmd.is_critical():
cmd.undo()
[docs] def overwrite_check(self, project_dir):
"""Checks if given directory is a project directory and/or empty
And asks the user what to do in that case.
Args:
project_dir (str): Abs. path to a directory
Returns:
bool: True if user wants to overwrite an existing project or
if the directory is not empty and the user wants to make it
into a Spine Toolbox project directory anyway. False if user
cancels the action.
"""
# Check if directory is empty and/or a project directory
is_project_dir = os.path.isdir(os.path.join(project_dir, ".spinetoolbox"))
empty = not bool(os.listdir(project_dir))
if not empty:
if is_project_dir:
msg1 = (
"Directory <b>{0}</b> already contains a Spine Toolbox project.<br/><br/>"
"Would you like to overwrite the existing project?".format(project_dir)
)
box1 = QMessageBox(
QMessageBox.Question, "Overwrite?", msg1, buttons=QMessageBox.Ok | QMessageBox.Cancel, parent=self
)
box1.button(QMessageBox.Ok).setText("Overwrite")
answer1 = box1.exec_()
if answer1 != QMessageBox.Ok:
return False
else:
msg2 = (
"Directory <b>{0}</b> is not empty.<br/><br/>"
"Would you like to make this directory into a Spine Toolbox project?".format(project_dir)
)
box2 = QMessageBox(
QMessageBox.Question, "Not empty", msg2, buttons=QMessageBox.Ok | QMessageBox.Cancel, parent=self
)
box2.button(QMessageBox.Ok).setText("Go ahead")
answer2 = box2.exec_()
if answer2 != QMessageBox.Ok:
return False
return True
@Slot(QItemSelection, QItemSelection)
[docs] def item_selection_changed(self, selected, deselected):
"""Synchronize selection with scene. Check if only one item is selected and make it the
active item: disconnect signals of previous active item, connect signals of current active item
and show correct properties tab for the latter.
"""
inds = self.ui.treeView_project.selectedIndexes()
proj_items = [self.project_item_model.item(i).project_item for i in inds]
# NOTE: Category items are not selectable anymore
# Sync selection with the scene
if proj_items:
scene = self.ui.graphicsView.scene()
scene.sync_selection = False # This tells the scene not to sync back
scene.clearSelection()
for item in proj_items:
item.get_icon().setSelected(True)
scene.sync_selection = True
# Refresh active item if needed
if len(proj_items) == 1:
new_active_project_item = proj_items[0]
else:
new_active_project_item = None
if self.active_project_item and self.active_project_item != new_active_project_item:
# Deactivate old active project item
ret = self.active_project_item.deactivate()
if not ret:
self.msg_error.emit(
"Something went wrong in disconnecting {0} signals".format(self.active_project_item.name)
)
self.active_project_item = new_active_project_item
if self.active_project_item:
# Activate new active project item
self.active_project_item.activate()
self.activate_item_tab(self.active_project_item)
else:
self.activate_no_selection_tab()
[docs] def activate_no_selection_tab(self):
"""Shows 'No Selection' tab."""
for i in range(self.ui.tabWidget_item_properties.count()):
if self.ui.tabWidget_item_properties.tabText(i) == "No Selection":
self.ui.tabWidget_item_properties.setCurrentIndex(i)
break
self.ui.dockWidget_item.setWindowTitle("Properties")
[docs] def activate_item_tab(self, item):
"""Shows project item properties tab according to item type.
Note: Does not work if a category item is given as argument.
Args:
item (ProjectItem): Instance of a project item
"""
# Find tab index according to item type
for i in range(self.ui.tabWidget_item_properties.count()):
if self.ui.tabWidget_item_properties.tabText(i) == item.item_type():
self.ui.tabWidget_item_properties.setCurrentIndex(i)
break
# Set QDockWidget title to selected item's type
self.ui.dockWidget_item.setWindowTitle(item.item_type() + " Properties")
@Slot()
[docs] def import_specification(self):
"""Opens a file dialog where the user can select an existing specification
definition file (.json). If file is valid, calls add_specification().
"""
if not self._project:
self.msg.emit("Please create a new project or open an existing one first")
return
# noinspection PyCallByClass, PyTypeChecker, PyArgumentList
answer = QFileDialog.getOpenFileName(
self, "Select Specification file", self._project.project_dir, "JSON (*.json)"
)
if answer[0] == "": # Cancel button clicked
return
def_file = os.path.abspath(answer[0])
# Load tool definition
specification = self.load_specification_from_file(def_file)
if not specification:
return
if self.specification_model.find_specification(specification.name):
# Tool specification already added to project
self.msg_warning.emit("Specification <b>{0}</b> already in project".format(specification.name))
return
# Add definition file path into tool specification
specification.definition_file_path = def_file
self.add_specification(specification)
[docs] def add_specification(self, specification):
"""Pushes a new AddSpecificationCommand to the undo stack."""
self.undo_stack.push(AddSpecificationCommand(self, specification))
[docs] def do_add_specification(self, specification, row=None):
"""Adds a ProjectItemSpecification instance to project.
Args:
specification (ProjectItemSpecification): specification that is added to project
"""
self.specification_model.insertRow(specification, row)
self.msg_success.emit("Specification <b>{0}</b> added to project".format(specification.name))
[docs] def update_specification(self, row, specification):
"""Pushes a new UpdateSpecificationCommand to the undo stack."""
self.undo_stack.push(UpdateSpecificationCommand(self, row, specification))
[docs] def do_update_specification(self, row, specification):
"""Updates a specification and refreshes all items that use it.
Args:
row (int): Row of tool specification in ProjectItemSpecFactoryModel
specification (ProjectItemSpecification): An updated specification
"""
if not self.specification_model.update_specification(row, specification):
self.msg_error.emit("Unable to update specification <b>{0}</b>".format(specification.name))
return
self.msg_success.emit("Specification <b>{0}</b> successfully updated".format(specification.name))
for project_item in self._get_specific_items(specification):
project_item.do_set_specification(specification)
self.msg.emit(
"Specification <b>{0}</b> successfully updated in Item <b>{1}</b>".format(
specification.name, project_item.name
)
)
[docs] def undo_update_specification(self, row):
"""Reverts a specification update and refreshes all items that use it.
Args:
row (int): Row of tool specification in ProjectItemSpecFactoryModel
"""
if not self.specification_model.undo_update_specification(row):
self.msg_error.emit("Unable to update specification at row <b>{0}</b>".format(row))
return
specification = self.specification_model.specification(row)
self.msg_success.emit("Specification <b>{0}</b> successfully updated".format(specification.name))
for project_item in self._get_specific_items(specification):
project_item.undo_set_specification()
self.msg.emit(
"Specification <b>{0}</b> successfully updated in Item <b>{1}</b>".format(
specification.name, project_item.name
)
)
[docs] def _get_specific_items(self, specification):
"""Yields project items with given specification.
Args:
specification (ProjectItemSpecification)
"""
for item in self.project_item_model.items(specification.item_category):
project_item = item.project_item
if project_item.specification() == specification:
yield project_item
@Slot(bool)
[docs] def remove_selected_specification(self, checked=False):
"""Removes specification selected in QListView."""
if not self._project:
self.msg.emit("Please create a new project or open an existing one first")
return
selected = self.main_toolbar.project_item_spec_list_view.selectedIndexes()
if not selected:
self.msg.emit("Select a Specific item to remove")
return
index = selected[0]
if not index.isValid():
return
self.remove_specification(index.row())
[docs] def remove_specification(self, row, ask_verification=True):
self.undo_stack.push(RemoveSpecificationCommand(self, row, ask_verification=ask_verification))
[docs] def do_remove_specification(self, row, ask_verification=True):
"""Removes specification from ProjectItemSpecFactoryModel.
Removes also specifications from all items that use this specification.
Args:
row (int): Row in ProjectItemSpecFactoryModel
ask_verification (bool): If True, displays a dialog box asking user to verify the removal
"""
specification = self.specification_model.specification(row)
if ask_verification:
message = "Remove Specification <b>{0}</b> from Project?".format(specification.name)
message_box = QMessageBox(
QMessageBox.Question,
"Remove Specification",
message,
buttons=QMessageBox.Ok | QMessageBox.Cancel,
parent=self,
)
message_box.button(QMessageBox.Ok).setText("Remove Specification")
answer = message_box.exec_()
if answer != QMessageBox.Ok:
return
items_with_removed_spec = list(self._get_specific_items(specification))
if not self.specification_model.removeRow(row):
self.msg_error.emit("Error in removing specification <b>{0}</b>".format(specification.name))
return
self.msg_success.emit("Specification removed")
for project_item in items_with_removed_spec:
project_item.do_set_specification(None)
self.msg.emit(
"Specification <b>{0}</b> successfully removed from Item <b>{1}</b>".format(
specification.name, project_item.name
)
)
@Slot()
[docs] def remove_all_items(self):
"""Removes all items from project. Slot for Remove All button."""
if not self._project:
self.msg.emit("No project items to remove")
return
self._project.remove_all_items()
@Slot("QUrl")
[docs] def open_anchor(self, qurl):
"""Open file explorer in the directory given in qurl.
Args:
qurl (QUrl): Directory path or a file to open
"""
if qurl.url() == "#": # This is a Tip so do not try to open the URL
return
path = qurl.toLocalFile() # Path to result folder
# noinspection PyTypeChecker, PyCallByClass, PyArgumentList
res = QDesktopServices.openUrl(qurl)
if not res:
self.msg_error.emit("Opening path {} failed".format(path))
@Slot("QPoint")
@Slot("QModelIndex")
[docs] def edit_specification(self, index):
"""Open the tool specification widget for editing an existing tool specification.
Args:
index (QModelIndex): Index of the item (from double-click or contex menu signal)
"""
if not index.isValid():
return
specification = self.specification_model.specification(index.row())
# Open spec in Tool specification edit widget
self.show_specification_form(specification.item_type, specification)
@busy_effect
@Slot("QModelIndex")
[docs] def open_specification_file(self, index):
"""Open the specification definition file in the default (.json) text-editor.
Args:
index (QModelIndex): Index of the item
"""
if not index.isValid():
return
specification = self.specification_model.specification(index.row())
file_path = specification.definition_file_path
# Check if file exists first. openUrl may return True if file doesn't exist
# TODO: this could still fail if the file is deleted or renamed right after the check
if not os.path.isfile(file_path):
logging.error("Failed to open editor for %s", file_path)
self.msg_error.emit("Specification file <b>{0}</b> not found.".format(file_path))
return
tool_specification_url = "file:///" + file_path
# Open Tool specification file in editor
# noinspection PyTypeChecker, PyCallByClass, PyArgumentList
res = open_url(tool_specification_url)
if not res:
logging.error("Failed to open editor for %s", tool_specification_url)
self.msg_error.emit(
"Unable to open specification file {0}. Make sure that <b>.json</b> "
"files are associated with a text editor. For example on Windows "
"10, go to Control Panel -> Default Programs to do this.".format(file_path)
)
return
@Slot()
[docs] def export_as_graphml(self):
"""Exports all DAGs in project to separate GraphML files."""
if not self.project():
self.msg.emit("Please open or create a project first")
return
self.project().export_graphs()
@Slot()
[docs] def _handle_zoom_minus_pressed(self):
"""Slot for handling case when '-' button in menu is pressed."""
self.ui.graphicsView.zoom_out()
@Slot()
[docs] def _handle_zoom_plus_pressed(self):
"""Slot for handling case when '+' button in menu is pressed."""
self.ui.graphicsView.zoom_in()
@Slot()
[docs] def _handle_zoom_reset_pressed(self):
"""Slot for handling case when 'reset zoom' button in menu is pressed."""
self.ui.graphicsView.reset_zoom()
[docs] def setup_zoom_widget_action(self):
"""Setups zoom widget action in view menu."""
self.zoom_widget_action = ZoomWidgetAction(self.ui.menuView)
self.ui.menuView.addSeparator()
self.ui.menuView.addAction(self.zoom_widget_action)
@Slot()
[docs] def restore_dock_widgets(self):
"""Dock all floating and or hidden QDockWidgets back to the main window."""
for dock in self.findChildren(QDockWidget):
if not dock.isVisible():
dock.setVisible(True)
if dock.isFloating():
dock.setFloating(False)
[docs] def set_debug_qactions(self):
"""Set shortcuts for QActions that may be needed in debugging."""
self.show_properties_tabbar.setShortcut(QKeySequence(Qt.CTRL + Qt.Key_0))
self.show_supported_img_formats.setShortcut(QKeySequence(Qt.CTRL + Qt.Key_8))
self.addAction(self.show_properties_tabbar)
self.addAction(self.show_supported_img_formats)
[docs] def toggle_properties_tabbar_visibility(self):
"""Shows or hides the tab bar in properties dock widget. For debugging purposes."""
if self.ui.tabWidget_item_properties.tabBar().isVisible():
self.ui.tabWidget_item_properties.tabBar().hide()
else:
self.ui.tabWidget_item_properties.tabBar().show()
[docs] def update_datetime(self):
"""Returns a boolean, which determines whether
date and time is prepended to every Event Log message."""
d = int(self._qsettings.value("appSettings/dateTime", defaultValue="2"))
return d != 0
@Slot(str)
[docs] def add_message(self, msg):
"""Append regular message to Event Log.
Args:
msg (str): String written to QTextBrowser
"""
open_tag = "<span style='color:white;white-space: pre-wrap;'>"
date_str = get_datetime(show=self.show_datetime)
message = open_tag + date_str + msg + "</span>"
self.ui.textBrowser_eventlog.append(message)
# noinspection PyArgumentList
QApplication.processEvents()
@Slot(str)
[docs] def add_success_message(self, msg):
"""Append message with green text color to Event Log.
Args:
msg (str): String written to QTextBrowser
"""
open_tag = "<span style='color:#00ff00;white-space: pre-wrap;'>"
date_str = get_datetime(show=self.show_datetime)
message = open_tag + date_str + msg + "</span>"
self.ui.textBrowser_eventlog.append(message)
# noinspection PyArgumentList
QApplication.processEvents()
@Slot(str)
[docs] def add_error_message(self, msg):
"""Append message with red color to Event Log.
Args:
msg (str): String written to QTextBrowser
"""
open_tag = "<span style='color:#ff3333;white-space: pre-wrap;'>"
date_str = get_datetime(show=self.show_datetime)
message = open_tag + date_str + msg + "</span>"
self.ui.textBrowser_eventlog.append(message)
# noinspection PyArgumentList
QApplication.processEvents()
@Slot(str)
[docs] def add_warning_message(self, msg):
"""Append message with yellow (golden) color to Event Log.
Args:
msg (str): String written to QTextBrowser
"""
open_tag = "<span style='color:yellow;white-space: pre-wrap;'>"
date_str = get_datetime(show=self.show_datetime)
message = open_tag + date_str + msg + "</span>"
self.ui.textBrowser_eventlog.append(message)
# noinspection PyArgumentList
QApplication.processEvents()
@Slot(str)
[docs] def add_process_message(self, msg):
"""Writes message from stdout to process output QTextBrowser.
Args:
msg (str): String written to QTextBrowser
"""
open_tag = "<span style='color:white;white-space: pre;'>"
message = open_tag + msg + "</span>"
self.ui.textBrowser_process_output.append(message)
# noinspection PyArgumentList
QApplication.processEvents()
@Slot(str)
[docs] def add_process_error_message(self, msg):
"""Writes message from stderr to process output QTextBrowser.
Args:
msg (str): String written to QTextBrowser
"""
open_tag = "<span style='color:#ff3333;white-space: pre;'>"
message = open_tag + msg + "</span>"
self.ui.textBrowser_process_output.append(message)
# noinspection PyArgumentList
QApplication.processEvents()
[docs] def show_add_project_item_form(self, item_type, x=0, y=0, spec=""):
"""Show add project item widget."""
if not self._project:
self.msg.emit("Please open or create a project first")
return
factory = self.item_factories.get(item_type)
if factory is None:
self.msg_error.emit(f"{item_type} not found in factories")
return
self.add_project_item_form = factory.add_form_maker(self, x, y, spec)
self.add_project_item_form.show()
@Slot()
[docs] def show_specification_form(self, item_type, specification=None):
"""Show specification widget."""
if not self._project:
self.msg.emit("Please open or create a project first")
return
factory = self.item_factories[item_type]
if not factory.supports_specifications():
return
form = factory.specification_form_maker(self, specification)
form.show()
@Slot()
[docs] def show_settings(self):
"""Show Settings widget."""
self.settings_form = SettingsWidget(self)
self.settings_form.show()
@Slot()
[docs] def show_about(self):
"""Show About Spine Toolbox form."""
form = AboutWidget(self)
form.show()
@Slot()
[docs] def show_user_guide(self):
"""Open Spine Toolbox User Guide index page. First tries
to open the local docs but if they are missing, opens
the docs from readthedocs.org."""
doc_index_path = os.path.join(DOCUMENTATION_PATH, "index.html")
local_docs = "file:///" + doc_index_path
online_docs = "https://spine-toolbox.readthedocs.io/en/release-0.5/"
# noinspection PyTypeChecker, PyCallByClass, PyArgumentList
if not open_url(local_docs):
self.msg_warning.emit("Unable to find local User Guide. Opening online docs...")
if not open_url(online_docs):
self.msg_warning.emit("Unable to open readthedocs.org. Please check Internet connection.")
@Slot()
[docs] def show_getting_started_guide(self):
"""Open Spine Toolbox Getting Started HTML page in browser."""
getting_started_path = os.path.join(DOCUMENTATION_PATH, "getting_started.html")
local_docs = "file:///" + getting_started_path
online_docs = "https://spine-toolbox.readthedocs.io/en/release-0.5/getting_started.html"
# noinspection PyTypeChecker, PyCallByClass, PyArgumentList
if not open_url(local_docs):
self.msg_warning.emit("Unable to find local Getting Started guide. Opening online docs...")
if not open_url(online_docs):
self.msg_warning.emit("Unable to open readthedocs.org. Please check Internet connection.")
@Slot("QPoint")
@Slot("QPoint", str)
[docs] def tear_down_items(self):
"""Calls the tear_down method on all project items, so they can clean up their mess if needed."""
if not self._project:
return
for item in self.project_item_model.items():
if isinstance(item, LeafProjectTreeItem):
item.project_item.tear_down()
[docs] def _tasks_before_exit(self):
"""
Returns a list of tasks to perform before exiting the application.
Possible tasks are:
- `"prompt exit"`: prompt user if quitting is really desired
- `"prompt save"`: prompt user if project should be saved before quitting
- `"save"`: save project before quitting
Returns:
a list containing zero or more tasks
"""
show_confirm_exit = int(self._qsettings.value("appSettings/showExitPrompt", defaultValue="2"))
save_at_exit = (
int(self._qsettings.value("appSettings/saveAtExit", defaultValue="1")) if self._project is not None else 0
)
if show_confirm_exit != 2:
# Don't prompt for exit
if save_at_exit == 0:
return []
if save_at_exit == 1:
# We still need to prompt for saving
return ["prompt save"]
return ["save"]
if save_at_exit == 0:
return ["prompt exit"]
if save_at_exit == 1:
return ["prompt save"]
return ["prompt exit", "save"]
[docs] def _perform_pre_exit_tasks(self):
"""
Prompts user to confirm quitting and saves the project if necessary.
Returns:
True if exit should proceed, False if the process was cancelled
"""
tasks = self._tasks_before_exit()
for task in tasks:
if task == "prompt exit":
if not self._confirm_exit():
return False
elif task == "prompt save":
if not self._confirm_save_and_exit():
return False
elif task == "save":
self.save_project()
return True
[docs] def _confirm_exit(self):
"""
Confirms exiting from user.
Returns:
True if exit should proceed, False if user cancelled
"""
msg = QMessageBox(parent=self)
msg.setIcon(QMessageBox.Question)
msg.setWindowTitle("Confirm exit")
msg.setText("Are you sure you want to exit Spine Toolbox?")
msg.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel)
msg.button(QMessageBox.Ok).setText("Exit")
chkbox = QCheckBox()
chkbox.setText("Do not ask me again")
msg.setCheckBox(chkbox)
answer = msg.exec_() # Show message box
if answer == QMessageBox.Ok:
# Update conf file according to checkbox status
if not chkbox.checkState():
show_prompt = "2" # 2 as in True
else:
show_prompt = "0" # 0 as in False
self._qsettings.setValue("appSettings/showExitPrompt", show_prompt)
return True
return False
[docs] def _confirm_save_and_exit(self):
"""
Confirms exit from user and saves the project if requested.
Returns:
True if exiting should proceed, False if user cancelled
"""
msg = QMessageBox(parent=self)
msg.setIcon(QMessageBox.Question)
msg.setWindowTitle("Save project before exiting")
msg.setText("Exiting Spine Toolbox. Save changes to project?")
msg.setStandardButtons(QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel)
msg.button(QMessageBox.Save).setText("Save And Exit")
msg.button(QMessageBox.Discard).setText("Exit without Saving")
chkbox = QCheckBox()
chkbox.setText("Do not ask me again")
msg.setCheckBox(chkbox)
answer = msg.exec_()
if answer == QMessageBox.Cancel:
return False
if answer == QMessageBox.Save:
self.save_project()
chk = chkbox.checkState()
if chk == 2:
if answer == QMessageBox.Save:
self._qsettings.setValue("appSettings/saveAtExit", "2")
elif answer == QMessageBox.Discard:
self._qsettings.setValue("appSettings/saveAtExit", "0")
return True
[docs] def remove_path_from_recent_projects(self, p):
"""Removes entry that contains given path from the recent project files list in QSettings.
Args:
p (str): Full path to a project directory
"""
recents = self._qsettings.value("appSettings/recentProjects", defaultValue=None)
if not recents:
return
recents = str(recents)
recents_list = recents.split("\n")
for entry in recents_list:
_, path = entry.split("<>")
if os.path.normcase(path) == os.path.normcase(p):
recents_list.pop(recents_list.index(entry))
break
updated_recents = "\n".join(recents_list)
# Save updated recent paths
self._qsettings.setValue("appSettings/recentProjects", updated_recents)
self._qsettings.sync() # Commit change immediately
[docs] def update_recent_projects(self):
"""Adds a new entry to QSettings variable that remembers the five most recent project paths."""
recents = self._qsettings.value("appSettings/recentProjects", defaultValue=None)
entry = self.project().name + "<>" + self.project().project_dir
if not recents:
updated_recents = entry
else:
recents = str(recents)
recents_list = recents.split("\n")
normalized_recents = list(map(os.path.normcase, recents_list))
try:
index = normalized_recents.index(os.path.normcase(entry))
except ValueError:
# Add path only if it's not in the list already
recents_list.insert(0, entry)
if len(recents_list) > 5:
recents_list.pop()
else:
# If entry was on the list, move it as the first item
recents_list.insert(0, recents_list.pop(index))
updated_recents = "\n".join(recents_list)
# Save updated recent paths
self._qsettings.setValue("appSettings/recentProjects", updated_recents)
self._qsettings.sync() # Commit change immediately
[docs] def closeEvent(self, event):
"""Method for handling application exit.
Args:
event (QCloseEvent): PySide2 event
"""
# Show confirm exit message box
exit_confirmed = self._perform_pre_exit_tasks()
if not exit_confirmed:
event.ignore()
return
# Save settings
if self._project is None:
self._qsettings.setValue("appSettings/previousProject", "")
else:
self._qsettings.setValue("appSettings/previousProject", self._project.project_dir)
self.update_recent_projects()
self._qsettings.setValue("mainWindow/windowSize", self.size())
self._qsettings.setValue("mainWindow/windowPosition", self.pos())
self._qsettings.setValue("mainWindow/windowState", self.saveState(version=1))
self._qsettings.setValue("mainWindow/windowMaximized", self.windowState() == Qt.WindowMaximized)
# Save number of screens
# noinspection PyArgumentList
self._qsettings.setValue("mainWindow/n_screens", len(QGuiApplication.screens()))
self.julia_console.shutdown_kernel()
self.python_console.shutdown_kernel()
self.tear_down_items()
event.accept()
[docs] def _serialize_selected_items(self):
"""
Serializes selected project items into a dictionary.
The serialization protocol tries to imitate the format in which projects are saved.
The format of the dictionary is following:
`{"item_category_1": [{"name": "item_1_name", ...}, ...], ...}`
Returns:
a dict containing serialized version of selected project items
"""
selected_project_items = self.ui.graphicsView.scene().selectedItems()
serialized_items = dict()
for item_icon in selected_project_items:
if not isinstance(item_icon, ProjectItemIcon):
continue
name = item_icon.name()
index = self.project_item_model.find_item(name)
project_item = self.project_item_model.item(index).project_item
item_type = project_item.item_type()
items = serialized_items.setdefault(item_type, list())
item_dict = project_item.item_dict()
item_dict["name"] = project_item.name
items.append(item_dict)
return serialized_items
[docs] def _deserialized_item_position_shifts(self, serialized_items):
"""
Calculates horizontal and vertical shifts for project items being deserialized.
If the mouse cursor is on the Design view we try to place the items unders the cursor.
Otherwise the items will get a small shift so they don't overlap a possible item below.
In case the items don't fit the scene rect we clamp their coordinates within it.
Args:
serialized_items (dict): a dictionary of serialized items being deserialized
Returns:
a tuple of (horizontal shift, vertical shift) in scene's coordinates
"""
mouse_position = self.ui.graphicsView.mapFromGlobal(QCursor.pos())
if self.ui.graphicsView.rect().contains(mouse_position):
mouse_over_design_view = self.ui.graphicsView.mapToScene(mouse_position)
else:
mouse_over_design_view = None
if mouse_over_design_view is not None:
first_item = next(iter(serialized_items.values()))[0]
x = first_item["x"]
y = first_item["y"]
shift_x = x - mouse_over_design_view.x()
shift_y = y - mouse_over_design_view.y()
else:
shift_x = -15.0
shift_y = -15.0
return shift_x, shift_y
@staticmethod
[docs] def _set_deserialized_item_position(item_dict, shift_x, shift_y, scene_rect):
"""Moves item's position by shift_x and shift_y while keeping it within the limits of scene_rect."""
new_x = np.clip(item_dict["x"] - shift_x, scene_rect.left(), scene_rect.right())
new_y = np.clip(item_dict["y"] - shift_y, scene_rect.top(), scene_rect.bottom())
item_dict["x"] = new_x
item_dict["y"] = new_y
[docs] def _deserialize_items(self, serialized_items):
"""
Deserializes project items from a dictionary and adds them to the current project.
Args:
serialized_items (dict): serialized project items
"""
if self._project is None:
return
scene = self.ui.graphicsView.scene()
scene.clearSelection()
shift_x, shift_y = self._deserialized_item_position_shifts(serialized_items)
scene_rect = scene.sceneRect()
for item_type, item_dicts in serialized_items.items():
for item in item_dicts:
name = item["name"]
if self.project_item_model.find_item(name) is not None:
new_name = self.propose_item_name(name)
item["name"] = new_name
self._set_deserialized_item_position(item, shift_x, shift_y, scene_rect)
item.pop("type")
self._project.add_project_items(item_type, *item_dicts, set_selected=True, verbosity=False)
@Slot()
[docs] def project_item_to_clipboard(self):
"""Copies the selected project items to system's clipboard."""
serialized_items = self._serialize_selected_items()
if not serialized_items:
return
item_dump = json.dumps(serialized_items)
clipboard = QApplication.clipboard()
data = QMimeData()
data.setData("application/vnd.spinetoolbox.ProjectItem", QByteArray(item_dump.encode("utf-8")))
clipboard.setMimeData(data)
@Slot()
[docs] def project_item_from_clipboard(self):
"""Adds project items in system's clipboard to the current project."""
clipboard = QApplication.clipboard()
mime_data = clipboard.mimeData()
byte_data = mime_data.data("application/vnd.spinetoolbox.ProjectItem")
if byte_data.isNull():
return
item_dump = str(byte_data.data(), "utf-8")
serialized_items = json.loads(item_dump)
self._deserialize_items(serialized_items)
@Slot()
[docs] def duplicate_project_item(self):
"""Duplicates the selected project items."""
serialized_items = self._serialize_selected_items()
self._deserialize_items(serialized_items)
[docs] def propose_item_name(self, prefix):
"""
Proposes a name for a project item.
The format is `prefix_xx` where `xx` is a counter value [01..99].
Args:
prefix (str): a prefix for the name
Returns:
a name string
"""
name_count = self._proposed_item_name_counts.setdefault(prefix, 0)
name = prefix + " {}".format(name_count + 1)
if self.project_item_model.find_item(name) is not None:
if name_count == 98:
# Avoiding too deep recursions.
raise RuntimeError("Ran out of numbers: cannot find suitable name for project item.")
# Increment index recursively if name is already in project.
self._proposed_item_name_counts[prefix] += 1
name = self.propose_item_name(prefix)
return name
[docs] def _item_edit_actions(self):
"""Creates project item edit actions (copy, paste, duplicate) and adds them to proper places."""
def prepend_to_edit_menu(text, shortcut, slot):
action = QAction(text, self.ui.graphicsView)
action.setShortcuts(shortcut)
action.setShortcutContext(Qt.WidgetShortcut)
action.triggered.connect(slot)
self._project_item_actions.append(action)
self.ui.graphicsView.addAction(action)
self.ui.menuEdit.insertAction(self.ui.menuEdit.actions()[0], action)
return action
self.ui.menuEdit.insertSeparator(self.ui.menuEdit.actions()[0])
duplicate_action = prepend_to_edit_menu(
"Duplicate", [QKeySequence(Qt.CTRL + Qt.Key_D)], lambda checked: self.duplicate_project_item()
)
paste_action = prepend_to_edit_menu(
"Paste", QKeySequence.Paste, lambda checked: self.project_item_from_clipboard()
)
copy_action = prepend_to_edit_menu("Copy", QKeySequence.Copy, lambda checked: self.project_item_to_clipboard())
def mirror_action_to_project_tree_view(action_to_duplicate):
action = QAction(action_to_duplicate.text(), self.ui.treeView_project)
action.setShortcuts([action_to_duplicate.shortcut()])
action.setShortcutContext(Qt.WidgetShortcut)
action.triggered.connect(action_to_duplicate.trigger)
self._project_item_actions.append(action)
self.ui.treeView_project.addAction(action)
mirror_action_to_project_tree_view(copy_action)
mirror_action_to_project_tree_view(paste_action)
mirror_action_to_project_tree_view(duplicate_action)
@Slot()
[docs] def _scroll_event_log_to_end(self):
self.ui.textBrowser_eventlog.verticalScrollBar().setValue(
self.ui.textBrowser_eventlog.verticalScrollBar().maximum()
)
@Slot(str, str)
[docs] def _show_message_box(self, title, message):
"""Shows an information message box."""
QMessageBox.information(self, title, message)
@Slot(str, str)
[docs] def _show_error_box(self, title, message):
box = QErrorMessage(self)
box.setWindowTitle(title)
box.setWindowModality(Qt.ApplicationModal)
box.showMessage(message)
[docs] def _connect_project_signals(self):
"""Connects signals emitted by project."""
self._project.project_execution_about_to_start.connect(self._scroll_event_log_to_end)