Source code for spinetoolbox.ui_main
######################################################################################################################
# Copyright (C) 2017-2021 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 sys
import locale
import logging
import json
import pathlib
import numpy as np
from PySide2.QtCore import QByteArray, QItemSelection, QMimeData, QModelIndex, QPoint, Qt, Signal, Slot, QSettings, QUrl
from PySide2.QtGui import QDesktopServices, QGuiApplication, QKeySequence, QIcon, QCursor
from PySide2.QtWidgets import (
QMainWindow,
QApplication,
QErrorMessage,
QFileDialog,
QInputDialog,
QMenu,
QMessageBox,
QCheckBox,
QDockWidget,
QAction,
QUndoStack,
QAbstractButton,
QWidget,
)
from spine_engine.load_project_items import load_item_specification_factories
from spine_engine.utils.serialization import serialize_path, deserialize_path
from spine_engine.utils.helpers import shorten
from spine_items.category import CATEGORIES, CATEGORY_DESCRIPTIONS
from .project_item_icon import ProjectItemIcon
from .load_project_items import load_project_items
from .mvcmodels.project_item_model import ProjectItemModel
from .mvcmodels.project_item_specification_models import ProjectItemSpecificationModel, FilteredSpecificationModel
from .mvcmodels.filter_execution_model import FilterExecutionModel
from .widgets.multi_tab_spec_editor import MultiTabSpecEditor
from .widgets.about_widget import AboutWidget
from .widgets.custom_menus import LinkContextMenu, RecentProjectsPopupMenu
from .widgets.settings_widget import SettingsWidget
from .widgets.custom_qwidgets import ToolBarWidgetAction
from .widgets.spine_console_widget import SpineConsoleWidget
from .widgets import toolbars
from .widgets.open_project_widget import OpenProjectDialog
from .widgets.link_properties_widget import LinkPropertiesWidget
from .widgets.console_window import ConsoleWindow
from .project import SpineToolboxProject
from .spine_db_manager import SpineDBManager
from .spine_db_editor.widgets.multi_spine_db_editor import MultiSpineDBEditor
from .spine_engine_manager import make_engine_manager
from .config import (
STATUSBAR_SS,
MAINWINDOW_SS,
_program_root,
LATEST_PROJECT_VERSION,
DEFAULT_WORK_DIR,
PROJECT_FILENAME,
ONLINE_DOCUMENTATION_URL,
)
from .helpers import (
create_dir,
ensure_window_is_on_screen,
set_taskbar_icon,
supported_img_formats,
recursive_overwrite,
ChildCyclingKeyPressFilter,
open_url,
busy_effect,
format_log_message,
color_from_index,
)
from .project_upgrader import ProjectUpgrader
from .project_tree_item import CategoryProjectTreeItem, RootProjectTreeItem
from .project_commands import (
AddSpecificationCommand,
RemoveSpecificationCommand,
RenameProjectItemCommand,
SpineToolboxCommand,
)
from .plugin_manager import PluginManager
[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.set_error_mode()
self._qsettings = QSettings("SpineProject", "Spine Toolbox", self)
locale.setlocale(locale.LC_NUMERIC, 'C')
# Setup the user interface from Qt Designer files
self.ui = Ui_MainWindow()
self.ui.setupUi(self)
self.takeCentralWidget().deleteLater()
self.setWindowIcon(QIcon(":/symbols/app.ico"))
set_taskbar_icon() # in helpers.py
self.ui.graphicsView.set_ui(self)
self.key_press_filter = ChildCyclingKeyPressFilter(self)
self.ui.tabWidget_item_properties.installEventFilter(self.key_press_filter)
self._share_item_edit_actions()
self.ui.listView_executions.setModel(FilterExecutionModel(self))
# Set style sheets
self.ui.statusbar.setStyleSheet(STATUSBAR_SS) # Initialize QStatusBar
self.ui.statusbar.setFixedHeight(20)
self.setStyleSheet(MAINWINDOW_SS)
# Class variables
self.undo_stack = QUndoStack(self)
self._item_categories = dict()
self._item_properties_uis = 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_model = None
self.specification_model = None
self.filtered_spec_factory_models = {}
self.show_datetime = self.update_datetime()
self.active_project_item = None
self.active_link = None
self.execution_in_progress = False
self.sync_item_selection_with_scene = True
self.link_properties_widget = LinkPropertiesWidget(self)
self._anchor_callbacks = {}
# DB manager
self.db_mngr = SpineDBManager(self._qsettings, self)
# Widget and form references
self.settings_form = None
self.specification_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.recent_projects_menu = RecentProjectsPopupMenu(self)
# Make and initialize toolbars
self.main_toolbar = toolbars.MainToolBar(self)
self.addToolBar(Qt.TopToolBarArea, self.main_toolbar)
self._base_python_console = None # 'base' Python console, independent of project items
self._base_julia_console = None # 'base' Julia console, independent of project items
# Additional consoles for item execution. See ``ToolboxUI.make_console()``
self._extra_consoles = {}
# Setup main window menu
self.add_zoom_action()
self.add_menu_actions()
self.ui.menuFile.setToolTipsVisible(True)
self.ui.menuEdit.setToolTipsVisible(True)
# 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.restore_dock_widgets()
self.restore_ui()
self.ui.dockWidget_executions.hide()
self.parse_project_item_modules()
self.init_project_item_model()
self.init_specification_model()
self.make_item_properties_uis()
self.main_toolbar.setup()
self._plugin_manager = PluginManager(self)
self._plugin_manager.load_plugins()
self.set_work_directory()
self._disable_project_actions()
self.connect_signals()
[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)
self.ui.textBrowser_itemlog.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.actionClose.triggered.connect(lambda _checked=False: self.close_project())
self.ui.actionRename_project.triggered.connect(self.rename_project)
self.ui.actionNew_DB_editor.triggered.connect(self.new_db_editor)
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.actionInstall_plugin.triggered.connect(self._plugin_manager.show_install_plugin_dialog)
self.ui.actionManage_plugins.triggered.connect(self._plugin_manager.show_manage_plugins_dialog)
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)
self.ui.menuEdit.aboutToShow.connect(self.refresh_edit_action_states)
self.ui.menuEdit.aboutToHide.connect(self.enable_edit_actions)
# 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(lambda: self.project_item_from_clipboard(duplicate_files=False))
self.ui.actionDuplicate.triggered.connect(lambda: self.duplicate_project_item(duplicate_files=False))
self.ui.actionPasteAndDuplicateFiles.triggered.connect(
lambda: self.project_item_from_clipboard(duplicate_files=True)
)
self.ui.actionDuplicateAndDuplicateFiles.triggered.connect(
lambda: self.duplicate_project_item(duplicate_files=True)
)
self.ui.actionOpen_project_directory.triggered.connect(self._open_project_directory)
self.ui.actionOpen_item_directory.triggered.connect(self._open_project_item_directory)
self.ui.actionRename_item.triggered.connect(self._rename_project_item)
self.ui.actionRemove.triggered.connect(self._remove_selected_items)
self.ui.actionStartJuliaConsole.triggered.connect(self._start_base_julia_console)
self.ui.actionStartPythonConsole.triggered.connect(self._start_base_python_console)
# 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)
# Undo stack
self.undo_stack.cleanChanged.connect(self.update_window_modified)
# Views
self.ui.listView_executions.selectionModel().currentChanged.connect(self._select_execution)
self.ui.listView_executions.model().layoutChanged.connect(self._refresh_execution_list)
self.ui.treeView_project.selectionModel().selectionChanged.connect(self.item_selection_changed)
# Models
self.project_item_model.rowsInserted.connect(self._update_execute_enabled)
self.project_item_model.rowsRemoved.connect(self._update_execute_enabled)
[docs] def set_error_mode(self):
"""Sets Windows error mode to show all error dialog boxes from subprocesses.
See https://docs.microsoft.com/en-us/windows/win32/api/errhandlingapi/nf-errhandlingapi-seterrormode
for documentation.
"""
if sys.platform == "win32":
import ctypes
ctypes.windll.kernel32.SetErrorMode(0);
[docs] def _update_execute_enabled(self):
first_index = next(self.project_item_model.leaf_indexes(), None)
self.main_toolbar.execute_project_button.setEnabled(first_index is not None and not self.execution_in_progress)
[docs] def _update_execute_selected_enabled(self):
inds = self.ui.treeView_project.selectedIndexes()
self.main_toolbar.execute_selection_button.setEnabled(bool(inds) and not self.execution_in_progress)
@Slot(bool)
[docs] def update_window_modified(self, clean):
"""Updates window modified status and save actions depending on the state of the undo stack."""
self.setWindowModified(not clean)
self.ui.actionSave.setDisabled(clean)
[docs] def parse_project_item_modules(self):
"""Collects data from project item factories."""
self._item_categories, self.item_factories = load_project_items()
self._item_specification_factories = load_item_specification_factories()
[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.
"""
verbose = new_work_dir is not None
if not new_work_dir:
new_work_dir = self._qsettings.value("appSettings/workDir", defaultValue=DEFAULT_WORK_DIR)
if not new_work_dir:
# It is possible "appSettings/workDir" is an empty string???
new_work_dir = DEFAULT_WORK_DIR
try:
create_dir(new_work_dir)
self._qsettings.setValue("appSettings/workDir", new_work_dir)
if verbose:
self.msg.emit(f"Work directory is now <b>{new_work_dir}</b>")
except OSError:
self.msg_error.emit(f"[OSError] Creating work directory {new_work_dir} failed. Check permissions.")
[docs] def project(self):
"""Returns current project or None if no project open."""
return self._project
[docs] def update_window_title(self):
"""Updates main window title."""
if not self._project:
self.setWindowTitle("Spine Toolbox")
return
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.
Args:
project_dir (str): project directory
"""
p = os.path.join(f"{ONLINE_DOCUMENTATION_URL}", "getting_started.html")
getting_started_anchor = (
f"<a style='color:#99CCFF;' title='"
+ p
+ f"' href='{ONLINE_DOCUMENTATION_URL}/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)
)
self.remove_path_from_recent_projects(project_dir)
return
self.open_project(project_dir)
@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
"""
if self._project is not None:
if not self.close_project():
return
self._project = SpineToolboxProject(
self, name, description, location, self.project_item_model, settings=self._qsettings, logger=self
)
self._enable_project_actions()
self.ui.actionSave.setDisabled(True) # Disable in a clean project
self._connect_project_signals()
self.populate_specification_model(list()) # Start project with no specifications
self.update_window_title()
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()
self.msg.emit("New project <b>{0}</b> is now open".format(self._project.name))
@Slot()
[docs] def open_project(self, load_dir=None):
"""Opens project from a selected or given directory.
Args:
load_dir (str, optional): 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.
Returns:
bool: True when opening the project succeeded, False otherwise
"""
if not load_dir:
custom_open_dialog = self.qsettings().value("appSettings/customOpenProjectDialog", defaultValue="true")
if custom_open_dialog == "true":
dialog = OpenProjectDialog(self)
if not dialog.exec():
return False
load_dir = dialog.selection()
else:
recents = self.qsettings().value("appSettings/recentProjectStorages", defaultValue=None)
if not recents:
start_dir = ""
else:
start_dir = str(recents).split("\n")[0]
load_dir = QFileDialog.getExistingDirectory(self, caption="Open Spine Toolbox Project", dir=start_dir)
if not load_dir:
return False # Cancelled
if not os.path.isfile(os.path.join(load_dir, ".spinetoolbox", PROJECT_FILENAME)):
self.msg_warning.emit(f"Opening project failed. <b>{load_dir}</b> is not a Spine Toolbox project.")
return False
# TODO: Save load_dir parent directory to "appSettings/recentProjectStorages"
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)
[docs] def restore_project(self, project_info, project_dir, ask_confirmation=True):
"""Initializes UI, Creates project, models, connections, etc., when opening a project.
Args:
project_info (dict): Project information dictionary
project_dir (str): Project directory
ask_confirmation (bool): True closes the previous project with a confirmation box if user has enabled this
Returns:
bool: True when restoring project succeeded, False otherwise
"""
if not self.close_project(ask_confirmation):
return False
# 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
spec_paths_per_type = project_info["project"]["specifications"]
connections = project_info["project"]["connections"]
project_items = project_info["items"]
# Create project
self._project = SpineToolboxProject(
self, name, desc, project_dir, self.project_item_model, settings=self._qsettings, logger=self
)
self._enable_project_actions()
self.ui.actionSave.setDisabled(True) # Save is disabled in a clean project
self._connect_project_signals()
self.update_window_title()
# Init tool spec model. We don't use the information on the item type in spec_paths_per_type, but we could...
deserialized_paths = [
deserialize_path(path, self._project.project_dir)
for paths in spec_paths_per_type.values()
for path in paths
]
self.populate_specification_model(deserialized_paths)
# Populate project model with project items
self._project.load(project_items, connections)
self.ui.treeView_project.expandAll()
# 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
[docs] def _toolbars(self):
"""Yields all toolbars in the window."""
yield self.main_toolbar
yield from self._plugin_manager.plugin_toolbars.values()
[docs] def _disable_project_actions(self):
"""Disables all project-related actions, except
New project, Open project and Open recent. Called
in the constructor and when closing a project."""
for toolbar in self._toolbars():
for button in toolbar.findChildren(QAbstractButton):
button.setDisabled(True)
self.ui.actionOpen_project_directory.setDisabled(True)
self.ui.actionSave.setDisabled(True)
self.ui.actionSave_As.setDisabled(True)
self.ui.actionClose.setDisabled(True)
self.ui.actionRename_project.setDisabled(True)
[docs] def _enable_project_actions(self):
"""Enables all project-related actions. Called when a
new project is created and when a project is opened."""
for toolbar in self._toolbars():
for action in toolbar.findChildren(QAbstractButton):
action.setEnabled(True)
self.ui.actionOpen_project_directory.setEnabled(True)
self.ui.actionSave.setEnabled(True)
self.ui.actionSave_As.setEnabled(True)
self.ui.actionClose.setEnabled(True)
self.ui.actionRename_project.setEnabled(True)
self._unset_execution_in_progress()
[docs] def refresh_toolbars(self):
"""Set toolbars' color using highest possible contrast."""
all_toolbars = list(self._toolbars())
for k, toolbar in enumerate(all_toolbars):
color = color_from_index(k, len(all_toolbars), base_hue=217.0, saturation=0.6)
toolbar.set_color(color)
for action in toolbar.findChildren(QAbstractButton):
action.setEnabled(self._project is not None)
@Slot()
@Slot()
[docs] def save_project(self):
"""Saves 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 dict by item type
serialized_spec_paths = dict()
for spec in self.specification_model.specifications():
if spec.plugin:
continue
serialized_path = serialize_path(spec.definition_file_path, self._project.project_dir)
serialized_spec_paths.setdefault(spec.item_type, []).append(serialized_path)
if not self._project.save(serialized_spec_paths):
self.msg_error.emit("Project saving failed")
return
self.msg.emit(f"Project <b>{self._project.name}</b> saved")
self.undo_stack.setClean()
@Slot()
[docs] def save_project_as(self):
"""Asks user for a new project directory and duplicates the current project there.
The name of the duplicated project will be the new directory name. The duplicated
project is activated."""
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
if not self.undo_stack.isClean():
self.save_project() # Save before copying the project, so the changes are not discarded
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
# Change name of the duplicated project to the new project directory name
_, new_name = os.path.split(answer)
proj_info["project"]["name"] = new_name
if not self.restore_project(proj_info, answer, ask_confirmation=False):
return
self.save_project() # Save to update project name in project.json, must be done after restore_project()
# noinspection PyCallByClass, PyArgumentList
QMessageBox.information(self, f"Project {self._project.name} saved", f"Project directory is now\n\n{answer}")
[docs] def close_project(self, ask_confirmation=True):
"""Closes the current project.
Returns:
bool: True when no project open or when it's closed successfully, False otherwise.
"""
if not self._project:
return True
if ask_confirmation:
save_at_exit = (
int(self._qsettings.value("appSettings/saveAtExit", defaultValue="1"))
if not self.undo_stack.isClean()
else 0
)
if save_at_exit == 1 and not self._confirm_save_and_exit():
return False
if save_at_exit == 2 and not self.save_project():
return False
if not self.undo_critical_commands():
return False
self._project.tear_down()
self._project = None
self.clear_ui()
self._disable_project_actions()
self.undo_stack.setClean()
self.update_window_title()
self.ui.textBrowser_eventlog.clear()
self.ui.textBrowser_itemlog.clear()
return True
@Slot(bool)
[docs] def rename_project(self, _checked=False):
"""Opens a dialog where the user can enter a new name for the project."""
if not self._project:
return
answer = QInputDialog.getText(
self,
"Rename Project",
"New name:",
text=self._project.name,
flags=Qt.WindowTitleHint | Qt.WindowCloseButtonHint,
)
if not answer[1]:
return
new_name = answer[0].strip()
# Check that new name is valid project name and not empty
if not new_name or new_name == self._project.name:
return
self._project.call_set_name(new_name)
[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(root_item, self)
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()
[docs] def init_specification_model(self):
"""Initializes specification model."""
factory_icons = {item_type: QIcon(factory.icon()) for item_type, factory in self.item_factories.items()}
self.specification_model = ProjectItemSpecificationModel(factory_icons)
for item_type in self.item_factories:
model = self.filtered_spec_factory_models[item_type] = FilteredSpecificationModel(item_type)
model.setSourceModel(self.specification_model)
[docs] def make_item_properties_uis(self):
for item_type, factory in self.item_factories.items():
self._item_properties_uis[item_type] = factory.make_properties_widget(self)
[docs] def populate_specification_model(self, specification_paths):
"""Populates specification model.
Args:
specification_paths (list): List of specification file paths for the current project
"""
self.specification_model.clear()
self.msg.emit("Loading specifications...")
specs = []
for path in specification_paths:
if not path: # TODO: Does this ever happen?
continue
spec = self.load_specification_from_file(path)
if not spec:
continue
specs.append(spec)
specs += list(self._plugin_manager.plugin_specs)
# Add specs to model
for spec in specs:
self.do_add_specification(spec)
if not specs:
self.msg_warning.emit("Project has no specifications")
[docs] def parse_specification_file(self, def_path):
try:
with open(def_path, "r") as fp:
try:
return json.load(fp)
except ValueError:
self.msg_error.emit("Item specification file not valid")
logging.exception("Loading JSON data failed")
return None
except FileNotFoundError:
# TODO: Prompt to find it?
self.msg_error.emit("Specification file <b>{0}</b> does not exist".format(def_path))
return None
[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
"""
spec_dict = self.parse_specification_file(def_path)
if spec_dict is None:
return None
spec_dict["definition_file_path"] = def_path
spec = self.load_specification(spec_dict)
if spec is not None:
spec.definition_file_path = def_path
return spec
[docs] def supports_specifications(self, item_type):
"""Returns True if given project item type supports specifications.
Returns:
bool: True if item supports specifications, False otherwise
"""
return item_type in self._item_specification_factories
[docs] def load_specification(self, definition):
"""Returns Item specification from a definition dictionary.
Args:
definition (dict): Dictionary with the definition
Returns:
ProjectItemSpecification or NoneType: specification or None if factory isn't found.
"""
# NOTE: If the spec doesn't have the "item_type" key, we can assume it's a tool spec
item_type = definition.get("item_type", "Tool")
spec_factory = self._item_specification_factories.get(item_type)
if spec_factory is None:
return None
return spec_factory.make_specification(definition, self._qsettings, self)
[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."""
self.activate_no_selection_tab() # Clear properties widget
self.restore_original_logs_and_consoles()
self.ui.graphicsView.scene().clear() # Clear all items from scene
self._shutdown_engine_kernels()
[docs] def undo_critical_commands(self):
"""Undoes critical commands in the undo stack.
Returns:
Bool: False if any critical commands aren't successfully undone
"""
if self.undo_stack.isClean():
return True
commands = [self.undo_stack.command(ind) for ind in range(self.undo_stack.index())]
def is_critical(cmd):
if isinstance(cmd, SpineToolboxCommand):
return cmd.is_critical()
return any(is_critical(cmd.child(i)) for i in range(cmd.childCount()))
critical_commands = [cmd for cmd in commands if is_critical(cmd)]
if not critical_commands:
return True
for cmd in reversed(critical_commands):
cmd.undo()
if not cmd.successfully_undone:
return False
return True
[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):
"""Synchronizes selection with scene. The scene handles item/link de/activation.
"""
inds = self.ui.treeView_project.selectedIndexes()
self._update_execute_selected_enabled()
if not self.sync_item_selection_with_scene:
return
project_items = [self.project_item_model.item(i).project_item for i in inds]
project_item_names = {i.name for i in project_items}
scene = self.ui.graphicsView.scene()
for icon in scene.project_item_icons():
icon.setSelected(icon.name() in project_item_names)
[docs] def refresh_active_elements(self, active_project_item, active_link):
self._set_active_project_item(active_project_item)
self._set_active_link(active_link)
if self.active_project_item:
self.activate_item_tab()
self.override_logs_and_consoles()
else:
self.restore_original_logs_and_consoles()
if self.active_link:
self.activate_link_tab()
else:
self.activate_no_selection_tab()
[docs] def _set_active_project_item(self, active_project_item):
"""
Args:
active_project_item (ProjectItemBase or NoneType)
"""
if self.active_project_item == active_project_item:
return
if self.active_project_item:
# Deactivate old active project item
if not self.active_project_item.deactivate():
self.msg_error.emit(
"Something went wrong in disconnecting {0} signals".format(self.active_project_item.name)
)
self.active_project_item = active_project_item
if self.active_project_item:
self.active_project_item.activate()
[docs] def _set_active_link(self, active_link):
"""
Args:
active_link (Link or NoneType)
"""
if self.active_link == active_link:
return
if self.active_link:
self.link_properties_widget.unset_link()
self.active_link = active_link
if self.active_link:
self.link_properties_widget.set_link(self.active_link)
[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):
"""Shows active project item properties tab according to item type."""
# 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) == self.active_project_item.item_type():
self.ui.tabWidget_item_properties.setCurrentIndex(i)
break
# Set QDockWidget title to selected item's type
self.ui.dockWidget_item.setWindowTitle(self.active_project_item.item_type() + " Properties")
[docs] def activate_link_tab(self):
"""Shows link properties tab."""
for i in range(self.ui.tabWidget_item_properties.count()):
if self.ui.tabWidget_item_properties.tabText(i) == "Link properties":
self.ui.tabWidget_item_properties.setCurrentIndex(i)
break
self.ui.dockWidget_item.setWindowTitle("Link 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 specification
specification = self.load_specification_from_file(def_file)
if not specification:
return
self.add_specification(specification)
item_factory = self.item_factories.get(specification.item_type)
if item_factory is not None:
item_factory.repair_specification(self, specification)
[docs] def _save_specificiation_file(self, specification):
"""Saves the given spec. If the spec doesn't have the ``definition_file_path`` attribute set,
prompts the user to select a path.
Args:
specification (ProjectItemSpecification)
"""
if specification.definition_file_path:
return specification.save()
# Determine a candidate definition file path *inside* the project folder, for relocatability...
specs_dir = self.project().specs_dir
specs_type_dir = os.path.join(specs_dir, specification.item_type)
try:
create_dir(specs_type_dir)
except OSError:
self._logger.msg_error.emit("Creating directory {0} failed".format(specs_type_dir))
specs_type_dir = specs_dir
candidate_def_file_path = os.path.join(specs_type_dir, shorten(specification.name) + ".json")
if not os.path.exists(candidate_def_file_path):
specification.definition_file_path = candidate_def_file_path
return specification.save()
return self._prompt_to_save_specification_file(specification, candidate_def_file_path)
[docs] def _prompt_to_save_specification_file(self, specification, candidate_def_file_path):
"""Shows a dialog for the user to select a path to save given spec.
Args:
specification (ProjectItemSpecification): The spec
candidate_def_file_path (str): A proposed location.
Returns:
bool: True if the spec is saved successfully, False otherwise
"""
answer = QFileDialog.getSaveFileName(
self, f"Save {specification.item_type} specification", candidate_def_file_path, "JSON (*.json)"
)
if not answer[0]: # Cancel button clicked
return False
definition_file_path = os.path.abspath(answer[0])
return self._do_save_specification(specification, definition_file_path)
[docs] def _do_save_specification(self, specification, new_def_file_path):
curr_def_file_norm_path = os.path.normcase(specification.definition_file_path)
if os.path.normcase(new_def_file_path) == curr_def_file_norm_path:
return True
specification.definition_file_path = new_def_file_path
if not specification.save():
return False
# Update spec path in project file. Moving the specification file doesn't dirty the project,
# so this is the ideal place to do this in fact...
config_file = self._project.config_file
try:
with open(config_file, "r+") as fh:
try:
proj_dict = json.load(fh)
except json.decoder.JSONDecodeError:
self.msg_error.emit("Error in project file <b>{0}</b>. Invalid JSON.".format(config_file))
return False
spec_paths = proj_dict["project"].get("specifications", {}).get(specification.item_type, [])
norm_path_index = {
os.path.normcase(deserialize_path(path, self._project.project_dir)): i
for i, path in enumerate(spec_paths)
}
i = norm_path_index.get(curr_def_file_norm_path)
if i is not None:
# We found the current path in the project. This means that we need to update it.
spec_paths[i] = serialize_path(new_def_file_path)
json.dump(fh, proj_dict)
except OSError:
self.msg_error.emit("Project file <b>{0}</b> missing".format(config_file))
return False
return True
[docs] def _emit_specification_saved(self, specification):
"""Prints a message in the event log, saying that given spec was saved in a certain location,
together with a clickable link to change the location.
Args:
specification (ProjectItemSpecification)
"""
path = specification.definition_file_path
self.msg_success.emit(
f"Specification <b>{specification.name}</b> successfully saved as "
f"<a style='color:#99CCFF;' href='file:///{path}'>{path}</a> "
f"<a style='color:white;' href='change_spec_file.{specification.name}'><b>[change]</b></a>"
)
[docs] def add_specification(self, specification, update_existing=False, widget=None):
"""Adds given specification to the project if there's no one with the same name.
Otherwise it updates the existing one.
Args:
specification (ProjectItemSpecification)
update_existing (bool, optional): If True, updates a spec with the same in the project.
If False (the default), it complains instead.
widget (QWidget, optional): The specification editor widget that calls this method.
Used to parent the QMessageBox
Returns:
bool: True if successful, False if not.
"""
row = self.specification_model.specification_row(specification.name)
if row >= 0:
if not update_existing:
if widget is None:
widget = self
QMessageBox.critical(
widget,
"Duplicate specification name",
f"There's already a specification called <b>{specification.name}</b> in the current project.<br>",
)
return False
current_specification = self.specification_model.specification(row)
if current_specification.is_equivalent(specification):
# Nothing changed
return True
specification.definition_file_path = current_specification.definition_file_path
return self.update_specification(row, specification)
if not self._save_specificiation_file(specification):
return False
self._emit_specification_saved(specification)
self.undo_stack.push(AddSpecificationCommand(self, specification))
return True
[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))
item_factory = self.item_factories.get(specification.item_type)
if item_factory is not None:
item_factory.repair_specification(self, specification)
[docs] def update_specification(self, row, specification):
"""Saves the given spec to disk, then sets it for the given row in the model,
then refreshes the spec in all items that use it.
Args:
row (int): Row of tool specification in ProjectItemSpecificationModel
specification (ProjectItemSpecification): An updated specification
"""
if not self._save_specificiation_file(specification):
return False
self._emit_specification_saved(specification)
if not self.specification_model.update_specification(row, specification):
self.msg_error.emit(f"Unable to update specification <b>{specification.name}</b>")
return False
self.msg_success.emit(f"Specification <b>{specification.name}</b> successfully updated")
for item in self.project_item_model.items():
project_item = item.project_item
project_item_spec = project_item.specification()
if project_item_spec is None or project_item_spec.name != specification.name:
continue
if project_item.do_set_specification(specification):
self.msg_success.emit(
f"Specification <b>{specification.name}</b> successfully updated "
f"in Item <b>{project_item.name}</b>"
)
else:
self.msg_warning.emit(
f"Specification <b>{specification.name}</b> "
f"of type <b>{specification.item_type}</b> "
f"is no longer valid for Item <b>{project_item.name}</b> "
f"of type <b>{project_item.item_type()}</b>"
)
project_item.do_set_specification(None)
return True
@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 specification 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 _get_items_with_spec(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
[docs] def do_remove_specification(self, row, ask_verification=True):
"""Removes specification from ProjectItemSpecificationModel.
Removes also specifications from all items that use this specification.
Args:
row (int): Row in ProjectItemSpecificationModel
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_items_with_spec(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(f"Specification <b>{specification.name}</b> removed")
for project_item in items_with_removed_spec:
project_item.do_set_specification(None)
self.msg.emit(
f"Specification <b>{specification.name}</b> successfully removed from Item <b>{project_item.name}</b>"
)
@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()
[docs] def register_anchor_callback(self, url, callback):
"""Registers a callback for a given anchor in event log, see ``open_anchor()``.
Used by ``ToolFactory.repair_specification()``.
Args:
url (str): The anchor url
callback (function): A function to call when the anchor is clicked on event log.
"""
self._anchor_callbacks[url] = callback
@Slot(QUrl)
[docs] def open_anchor(self, qurl):
"""Open file explorer in the directory given in qurl.
Args:
qurl (QUrl): The url to open
"""
url = qurl.url()
if url == "#": # This is a Tip so do not try to open the URL
return
if url.startswith("change_spec_file."):
_, spec_name = url.split(".")
spec = self.specification_model.find_specification(spec_name)
if not spec:
self.msg_error.emit(f"Unable to find specification '{spec_name}'")
return
if self._prompt_to_save_specification_file(spec, spec.definition_file_path):
self._emit_specification_saved(spec)
return
callback = self._anchor_callbacks.get(url, None)
if callback is not None:
callback()
return
# noinspection PyTypeChecker, PyCallByClass, PyArgumentList
res = QDesktopServices.openUrl(qurl)
if not res:
self.msg_error.emit(f"Unable to open <b>{url}</b>")
@Slot(QModelIndex, QPoint)
@Slot(QModelIndex)
[docs] def edit_specification(self, index, item):
"""Open the tool specification widget for editing an existing tool specification.
Args:
index (QModelIndex): Index of the item (from double-click or context menu signal)
item (ProjectItem, optional)
"""
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, item)
@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:
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)
)
@Slot(bool)
@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 add_zoom_action(self):
"""Setups zoom widget action in view menu."""
zoom_action = ToolBarWidgetAction("Zoom", parent=self.ui.menuView, compact=True)
zoom_action.tool_bar.addAction("-", self._handle_zoom_minus_pressed).setToolTip("Zoom out")
zoom_action.tool_bar.addAction("Reset", self._handle_zoom_reset_pressed).setToolTip("Reset zoom")
zoom_action.tool_bar.addAction("+", self._handle_zoom_plus_pressed).setToolTip("Zoom in")
self.ui.menuView.addSeparator()
self.ui.menuView.addAction(zoom_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)
docks = (self.ui.dockWidget_project, self.ui.dockWidget_design_view, self.ui.dockWidget_item)
self.splitDockWidget(*docks[:-1], Qt.Horizontal)
self.splitDockWidget(*docks[1:], Qt.Horizontal)
width = sum(d.size().width() for d in docks)
self.resizeDocks(docks, [0.2 * width, 0.5 * width, 0.3 * width], Qt.Horizontal)
[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
"""
message = format_log_message("msg", msg, self.show_datetime)
self.ui.textBrowser_eventlog.append(message)
@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
"""
message = format_log_message("msg_success", msg, self.show_datetime)
self.ui.textBrowser_eventlog.append(message)
@Slot(str)
[docs] def add_error_message(self, msg):
"""Append message with red color to Event Log.
Args:
msg (str): String written to QTextBrowser
"""
message = format_log_message("msg_error", msg, self.show_datetime)
self.ui.textBrowser_eventlog.append(message)
@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
"""
message = format_log_message("msg_warning", msg, self.show_datetime)
self.ui.textBrowser_eventlog.append(message)
@Slot(str)
[docs] def add_process_message(self, msg):
"""Writes message from stdout to process output QTextBrowser.
Args:
msg (str): String written to QTextBrowser
"""
message = format_log_message("msg", msg)
self.ui.textBrowser_eventlog.append(message)
@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
"""
message = format_log_message("msg_error", msg)
self.ui.textBrowser_eventlog.append(message)
[docs] def restore_original_logs_and_consoles(self):
self.restore_original_item_log_document()
self.restore_original_python_console()
self.restore_original_julia_console()
self.ui.dockWidget_executions.hide()
[docs] def override_logs_and_consoles(self):
self.override_item_log()
self.override_python_console()
self.override_julia_console()
self.override_execution_list()
[docs] def override_item_log(self):
"""Sets the log document of the active project item in Item Execution Log and updates title."""
if self.active_project_item is None:
return
document = self.active_project_item.log_document
self._do_override_item_log(document)
[docs] def _do_override_item_log(self, document):
if document is None:
self.restore_original_item_log_document()
return
self.ui.textBrowser_itemlog.set_override_document(document)
self.ui.textBrowser_itemlog.show()
self.ui.label_no_itemlog.hide()
self._update_item_log_title()
[docs] def override_python_console(self):
"""Sets the python console of the active project item in Python Console and updates title."""
if self.active_project_item is None:
return
console = self.active_project_item.python_console
self._do_override_python_console(console)
[docs] def _do_override_python_console(self, console):
if console is None:
self.restore_original_python_console()
return
widget = self.ui.dockWidgetContents_python_console
self._set_override_console(widget, console, "Python Console")
[docs] def override_julia_console(self):
"""Sets the julia console of the active project item in Julia Console and updates title."""
if self.active_project_item is None:
return
console = self.active_project_item.julia_console
self._do_override_julia_console(console)
[docs] def _do_override_julia_console(self, console):
if console is None:
self.restore_original_julia_console()
return
widget = self.ui.dockWidgetContents_julia_console
self._set_override_console(widget, console, "Julia Console")
[docs] def override_execution_list(self):
"""Displays executions of the active project item in Executions and updates title."""
if self.active_project_item is None:
return
self.ui.listView_executions.model().reset_model(self.active_project_item)
self.ui.dockWidget_executions.setVisible(
bool(self.active_project_item.filter_log_documents or self.active_project_item.filter_consoles)
)
self.ui.dockWidget_executions.setWindowTitle(f"Executions [{self.active_project_item.name}]")
current = self.ui.listView_executions.currentIndex()
self._select_execution(current, None)
[docs] def restore_original_item_log_document(self):
"""Sets the Item Execution Log document back to the original."""
self.ui.textBrowser_itemlog.restore_original_document()
self.ui.textBrowser_itemlog.hide()
self.ui.label_no_itemlog.show()
self._update_item_log_title()
[docs] def restore_original_python_console(self):
"""Sets the Python Console back to the original."""
widget = self.ui.dockWidgetContents_python_console
self._set_override_console(widget, self.ui.label_no_python_console, "Python Console")
[docs] def restore_original_julia_console(self):
"""Sets the Julia Console back to the original."""
widget = self.ui.dockWidgetContents_julia_console
self._set_override_console(widget, self.ui.label_no_julia_console, "Julia Console")
[docs] def _update_item_log_title(self):
"""Updates Event Log title."""
owner = self.ui.textBrowser_itemlog.document().owner
owner_name = owner.name if owner else "Item"
new_title = f"{owner_name} Execution Log"
self.ui.dockWidget_itemlog.setWindowTitle(new_title)
@staticmethod
[docs] def _set_override_console(widget, console, new_title):
layout = widget.layout()
for i in range(layout.count()):
layout.itemAt(i).widget().hide()
layout.addWidget(console)
console.show()
try:
new_title = f"{console.owner_names} {new_title}"
except AttributeError:
pass
widget.parent().setWindowTitle(new_title)
@Slot()
[docs] def _refresh_execution_list(self):
"""Refreshes Executions as the active project item starts new executions."""
self.ui.dockWidget_executions.show()
if not self.ui.listView_executions.currentIndex().isValid():
index = self.ui.listView_executions.model().index(0, 0)
self.ui.listView_executions.setCurrentIndex(index)
else:
current = self.ui.listView_executions.currentIndex()
self._select_execution(current, None)
@Slot(QModelIndex, QModelIndex)
[docs] def _select_execution(self, current, _previous):
"""Sets the log documents of the selected execution in Event and Process Log,
and any consoles in Python and Julia Console."""
if not current.data():
return
item_log_doc = current.model().get_log_document(current.data())
self._do_override_item_log(item_log_doc)
python_console, julia_console = current.model().get_consoles(current.data())
self._do_override_python_console(python_console)
self._do_override_julia_console(julia_console)
[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.make_add_item_widget(self, x, y, spec)
self.add_project_item_form.show()
[docs] def supports_specification(self, item_type):
"""
Returns True if given item type supports specifications.
Args:
item_type (str): item's type
Returns:
bool: True if item supports specifications, False otherwise
"""
return item_type in self._item_specification_factories
@Slot()
[docs] def show_specification_form(self, item_type, specification=None, item=None, **kwargs):
"""
Shows specification widget.
Args:
item_type (str): item's type
specification (ProjectItemSpecification, optional): specification
item (ProjectItem, optional): project item
**kwargs: parameters passed to the specification widget
"""
if not self._project:
self.msg.emit("Please open or create a project first")
return
if not self.supports_specification(item_type):
return
msg = f"Opening {item_type} specification editor"
if specification:
msg += f" for {specification.name}"
self.msg.emit(msg)
multi_tab_editor = next(self.get_all_multi_tab_spec_editors(item_type), None)
if multi_tab_editor is None:
multi_tab_editor = MultiTabSpecEditor(self, item_type)
multi_tab_editor.add_new_tab(specification, item, **kwargs)
multi_tab_editor.show()
return
existing = self._get_existing_spec_editor(item_type, specification, item)
if existing is None:
multi_tab_editor.add_new_tab(specification, item, **kwargs)
else:
multi_tab_editor, editor = existing
multi_tab_editor.set_current_tab(editor)
if multi_tab_editor.windowState() & Qt.WindowMinimized:
multi_tab_editor.setWindowState(multi_tab_editor.windowState() & ~Qt.WindowMinimized | Qt.WindowActive)
multi_tab_editor.activateWindow()
else:
multi_tab_editor.raise_()
[docs] def get_all_multi_tab_spec_editors(self, item_type):
for window in qApp.topLevelWindows(): # pylint: disable=undefined-variable
widget = QWidget.find(window.winId())
if isinstance(widget, MultiTabSpecEditor) and widget.item_type == item_type:
yield widget
[docs] def _get_existing_spec_editor(self, item_type, specification, item):
for multi_tab_editor in self.get_all_multi_tab_spec_editors(item_type):
for k in range(multi_tab_editor.tab_widget.count()):
editor = multi_tab_editor.tab_widget.widget(k)
if editor.specification is not None and editor.specification == specification and editor.item == item:
return multi_tab_editor, editor
return None
@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 documentation index page in browser."""
index_url = f"{ONLINE_DOCUMENTATION_URL}/index.html"
# noinspection PyTypeChecker, PyCallByClass, PyArgumentList
open_url(index_url)
@Slot()
[docs] def show_getting_started_guide(self):
"""Open Spine Toolbox Getting Started HTML page in browser."""
index_url = f"{ONLINE_DOCUMENTATION_URL}/getting_started.html"
# noinspection PyTypeChecker, PyCallByClass, PyArgumentList
open_url(index_url)
@Slot(QPoint)
@Slot()
[docs] def refresh_edit_action_states(self):
"""Sets the enabled/disabled state for copy, paste, duplicate,
and remove actions in File-Edit menu, project tree view
context menu, and in Design View context menus just before the
menus are shown to user."""
clipboard = QApplication.clipboard()
byte_data = clipboard.mimeData().data("application/vnd.spinetoolbox.ProjectItem")
can_paste = not byte_data.isNull()
can_copy = any(isinstance(x, ProjectItemIcon) for x in self.ui.graphicsView.scene().selectedItems())
has_items = self.project_item_model.n_items() > 0
self.ui.actionCopy.setEnabled(can_copy)
self.ui.actionPaste.setEnabled(can_paste)
self.ui.actionPasteAndDuplicateFiles.setEnabled(can_paste)
self.ui.actionDuplicate.setEnabled(can_copy)
self.ui.actionDuplicateAndDuplicateFiles.setEnabled(can_copy)
self.ui.actionRemove.setEnabled(can_copy)
self.ui.actionRemove_all.setEnabled(has_items)
@Slot()
[docs] def enable_edit_actions(self):
"""Enables project item edit actions after a QMenu has been shown.
This is needed to enable keyboard shortcuts (e.g. Ctrl-C & del)
again."""
self.ui.actionCopy.setEnabled(True)
self.ui.actionPaste.setEnabled(True)
self.ui.actionPasteAndDuplicateFiles.setEnabled(True)
self.ui.actionDuplicate.setEnabled(True)
self.ui.actionDuplicateAndDuplicateFiles.setEnabled(True)
[docs] def tear_down_project(self):
"""Calls the tear_down method on the project."""
if self._project is not None:
self._project.tear_down()
[docs] def tear_down_consoles(self):
"""CLoses the 'base' Python and Juliö Consoles if running."""
if self._base_julia_console is not None:
self._base_julia_console.close()
if self._base_python_console is not None:
self._base_python_console.close()
[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 and not self.undo_stack.isClean()
else 0
)
if save_at_exit == 1:
# Ignore show_confirm_exit...
return ["prompt save"]
tasks = []
if show_confirm_exit == 2:
tasks.append("prompt exit")
if save_at_exit == 2:
tasks.append("save")
return tasks
[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 leaving")
msg.setText("The project has unsaved changes. Do you want to save them before closing?")
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 twenty 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) > 20:
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
if not self.undo_critical_commands():
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("appSettings/toolbarIconOrdering", self.main_toolbar.icon_ordering())
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.tear_down_consoles()
self.tear_down_project()
for item_type in self.item_factories:
for editor in self.get_all_multi_tab_spec_editors(item_type):
editor.close()
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.
Returns:
dict: a dict containing serialized version of selected project items
"""
selected_project_items = self.ui.graphicsView.scene().selectedItems()
items_dict = 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_dict = dict(project_item.item_dict())
item_dict["original_data_dir"] = project_item.data_dir
item_dict["original_db_url"] = item_dict.get("url")
items_dict[name] = item_dict
return items_dict
[docs] def _deserialized_item_position_shifts(self, item_dicts):
"""
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:
item_dicts (dict): a dictionary of serialized items being deserialized
Returns:
tuple: 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(item_dicts.values()))
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, items_dict, duplicate_files=False):
"""
Deserializes project items from a dictionary and adds them to the current project.
Args:
items_dict (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(items_dict)
scene_rect = scene.sceneRect()
final_items_dict = dict()
for name, item_dict in items_dict.items():
item_dict["duplicate_files"] = duplicate_files
if self.project_item_model.find_item(name) is not None:
new_name = self.propose_item_name(name)
final_items_dict[new_name] = item_dict
else:
final_items_dict[name] = item_dict
self._set_deserialized_item_position(item_dict, shift_x, shift_y, scene_rect)
self._project.add_project_items(final_items_dict, 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, duplicate_files=False):
"""Adds project items in system's clipboard to the current project.
Args:
duplicate_files (bool): Duplicate files boolean
"""
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")
item_dicts = json.loads(item_dump)
self._deserialize_items(item_dicts, duplicate_files)
@Slot()
[docs] def duplicate_project_item(self, duplicate_files=False):
"""Duplicates the selected project items."""
item_dicts = self._serialize_selected_items()
if not item_dicts:
return
self._deserialize_items(item_dicts, duplicate_files)
[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:
str: 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
@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._set_execution_in_progress)
self._project.project_execution_finished.connect(self._unset_execution_in_progress)
self._project.item_added.connect(self.set_icon_and_properties_ui)
self._project.item_added.connect(self.ui.graphicsView.add_icon)
self._project.item_about_to_be_removed.connect(self.ui.graphicsView.remove_icon)
self._project.connection_established.connect(self.ui.graphicsView.do_add_link)
self._project.connection_replaced.connect(self.ui.graphicsView.do_replace_link)
self._project.connection_about_to_be_removed.connect(self.ui.graphicsView.do_remove_link)
@Slot()
[docs] def _set_execution_in_progress(self):
self.execution_in_progress = True
self.main_toolbar.execute_project_button.setEnabled(False)
self.main_toolbar.execute_selection_button.setEnabled(False)
self.main_toolbar.stop_execution_button.setEnabled(True)
@Slot()
[docs] def _unset_execution_in_progress(self):
self.execution_in_progress = False
self._update_execute_enabled()
self._update_execute_selected_enabled()
self.main_toolbar.stop_execution_button.setEnabled(False)
@Slot(str)
[docs] def set_icon_and_properties_ui(self, item_name):
"""Adds properties UI to given project item.
Args:
item_name (str): item's name
"""
project_item = self._project.get_item(item_name)
icon = self.project_item_icon(project_item.item_type())
project_item.set_icon(icon)
properties_ui = self.project_item_properties_ui(project_item.item_type())
project_item.set_properties_ui(properties_ui)
[docs] def project_item_properties_ui(self, item_type):
"""Returns the properties tab widget's ui.
Args:
item_type (str): project item's type
Returns:
QWidget: item's properties tab widget
"""
return self._item_properties_uis[item_type].ui
[docs] def project_item_icon(self, item_type):
return self.item_factories[item_type].make_icon(self)
@Slot(bool)
[docs] def _open_project_directory(self, _):
"""Opens project's root directory in system's file browser."""
if self._project is None:
self.msg.emit("Please open or create a project first")
return
open_url("file:///" + self._project.project_dir)
@Slot(bool)
[docs] def _open_project_item_directory(self, _):
"""Opens project item's directory in system's file browser."""
selection_model = self.ui.treeView_project.selectionModel()
current = selection_model.currentIndex()
if not current.isValid():
return
item = self.project_item_model.item(current)
item.project_item.open_directory()
@Slot(bool)
[docs] def _remove_selected_items(self, _):
"""Removes selected project items and links."""
if not self.ui.graphicsView.scene().selectedItems():
return
self.undo_stack.beginMacro("remove items and links")
selection_model = self.ui.treeView_project.selectionModel()
self._project.remove_project_items(*selection_model.selection().indexes())
self.ui.graphicsView.remove_selected_links()
self.undo_stack.endMacro()
@Slot(bool)
[docs] def _rename_project_item(self, _):
"""Renames current project item."""
selection_model = self.ui.treeView_project.selectionModel()
current = selection_model.currentIndex()
if not current.isValid():
return
item = self.project_item_model.item(current)
answer = QInputDialog.getText(
self, "Rename Item", "New name:", text=item.name, flags=Qt.WindowTitleHint | Qt.WindowCloseButtonHint
)
if not answer[1]:
return
new_name = answer[0]
self.undo_stack.push(RenameProjectItemCommand(self._project, item.name, new_name))
@Slot()
[docs] def _start_base_julia_console(self):
"""Shows and starts the 'base' Julia Console if not running or activates the window if running."""
if not self._base_julia_console:
c = SpineConsoleWidget(self, "Julia Console", owner=None)
self._base_julia_console = ConsoleWindow(self, c)
self._base_julia_console.start()
else:
if self._base_julia_console.isMinimized():
self._base_julia_console.showNormal()
self._base_julia_console.activateWindow()
@Slot()
[docs] def _start_base_python_console(self):
"""Shows and starts the 'base' Python Console if not running or activates the window if running."""
if not self._base_python_console:
c = SpineConsoleWidget(self, "Python Console", owner=None)
self._base_python_console = ConsoleWindow(self, c)
self._base_python_console.start()
else:
if self._base_python_console.isMinimized():
self._base_python_console.showNormal()
self._base_python_console.activateWindow()
[docs] def destroy_base_console(self, console_window_title):
"""Destroys the Python or Julia Console window reference.
Args:
console_window_title (str): Used in determining which console ref to destroy
"""
if "python" in console_window_title.lower():
self._base_python_console.deleteLater() # Not strictly necessary because of the Qt.WA_DeleteOnClose attr.
self._base_python_console = None
else:
self._base_julia_console.deleteLater()
self._base_julia_console = None
[docs] def make_console(self, name, item, kernel_name, connection_file):
"""Creates a new SpineConsoleWidget for given connection file if none exists yet, and returns it.
Args:
name (str): Console name
item (ProjectItem): Item that owns the console
kernel_name (str): Name of the kernel
connection_file (str): Path of kernel connection file
Returns:
SpineConsoleWidget
"""
console = self._extra_consoles.get(connection_file)
if console is not None:
console.owners.add(item)
return console
console = self._extra_consoles[connection_file] = SpineConsoleWidget(self, name, owner=item)
console.connect_to_kernel(kernel_name, connection_file)
return console
[docs] def _shutdown_engine_kernels(self):
"""Shuts down all kernels managed by Spine Engine."""
engine_server_address = self.qsettings().value("appSettings/engineServerAddress", defaultValue="")
engine_mngr = make_engine_manager(engine_server_address)
while self._extra_consoles:
connection_file, console = self._extra_consoles.popitem()
engine_mngr.shutdown_kernel(connection_file)
console.deleteLater()