Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/Ultimaker/Cura.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/cura/UI
diff options
context:
space:
mode:
authorDiego Prado Gesto <d.pradogesto@ultimaker.com>2019-05-07 12:57:31 +0300
committerDiego Prado Gesto <d.pradogesto@ultimaker.com>2019-05-07 12:57:31 +0300
commit9e5e57e6c54e5e54a32799115d8b61119182d99f (patch)
tree198587223d07174490942a529405d46fee45fb1d /cura/UI
parent730564345b0797b541888899493f17e7d4c3ba44 (diff)
parent6708d9da642230bfd71c57d103916a1c5c5b369c (diff)
Merge branch 'master' into feature_model_list
Diffstat (limited to 'cura/UI')
-rw-r--r--cura/UI/AddPrinterPagesModel.py31
-rw-r--r--cura/UI/CuraSplashScreen.py106
-rw-r--r--cura/UI/MachineActionManager.py161
-rw-r--r--cura/UI/MachineSettingsManager.py82
-rw-r--r--cura/UI/ObjectsModel.py112
-rw-r--r--cura/UI/PrintInformation.py435
-rw-r--r--cura/UI/TextManager.py69
-rw-r--r--cura/UI/WelcomePagesModel.py294
-rw-r--r--cura/UI/WhatsNewPagesModel.py22
-rw-r--r--cura/UI/__init__.py0
10 files changed, 1312 insertions, 0 deletions
diff --git a/cura/UI/AddPrinterPagesModel.py b/cura/UI/AddPrinterPagesModel.py
new file mode 100644
index 0000000000..d40da59b2a
--- /dev/null
+++ b/cura/UI/AddPrinterPagesModel.py
@@ -0,0 +1,31 @@
+# Copyright (c) 2019 Ultimaker B.V.
+# Cura is released under the terms of the LGPLv3 or higher.
+
+from .WelcomePagesModel import WelcomePagesModel
+
+
+#
+# This Qt ListModel is more or less the same the WelcomePagesModel, except that this model is only for adding a printer,
+# so only the steps for adding a printer is included.
+#
+class AddPrinterPagesModel(WelcomePagesModel):
+
+ def initialize(self) -> None:
+ self._pages.append({"id": "add_network_or_local_printer",
+ "page_url": self._getBuiltinWelcomePagePath("AddNetworkOrLocalPrinterContent.qml"),
+ "next_page_id": "machine_actions",
+ "next_page_button_text": self._catalog.i18nc("@action:button", "Add"),
+ "previous_page_button_text": self._catalog.i18nc("@action:button", "Cancel"),
+ })
+ self._pages.append({"id": "add_printer_by_ip",
+ "page_url": self._getBuiltinWelcomePagePath("AddPrinterByIpContent.qml"),
+ "next_page_id": "machine_actions",
+ })
+ self._pages.append({"id": "machine_actions",
+ "page_url": self._getBuiltinWelcomePagePath("FirstStartMachineActionsContent.qml"),
+ "should_show_function": self.shouldShowMachineActions,
+ })
+ self.setItems(self._pages)
+
+
+__all__ = ["AddPrinterPagesModel"]
diff --git a/cura/UI/CuraSplashScreen.py b/cura/UI/CuraSplashScreen.py
new file mode 100644
index 0000000000..77c9ad1427
--- /dev/null
+++ b/cura/UI/CuraSplashScreen.py
@@ -0,0 +1,106 @@
+# Copyright (c) 2017 Ultimaker B.V.
+# Uranium is released under the terms of the LGPLv3 or higher.
+
+from PyQt5.QtCore import Qt, QCoreApplication, QTimer
+from PyQt5.QtGui import QPixmap, QColor, QFont, QPen, QPainter
+from PyQt5.QtWidgets import QSplashScreen
+
+from UM.Resources import Resources
+from UM.Application import Application
+
+
+class CuraSplashScreen(QSplashScreen):
+ def __init__(self):
+ super().__init__()
+ self._scale = 0.7
+
+ splash_image = QPixmap(Resources.getPath(Resources.Images, "cura.png"))
+ self.setPixmap(splash_image)
+
+ self._current_message = ""
+
+ self._loading_image_rotation_angle = 0
+
+ self._to_stop = False
+ self._change_timer = QTimer()
+ self._change_timer.setInterval(50)
+ self._change_timer.setSingleShot(False)
+ self._change_timer.timeout.connect(self.updateLoadingImage)
+
+ def show(self):
+ super().show()
+ self._change_timer.start()
+
+ def updateLoadingImage(self):
+ if self._to_stop:
+ return
+
+ self._loading_image_rotation_angle -= 10
+ self.repaint()
+
+ # Override the mousePressEvent so the splashscreen doesn't disappear when clicked
+ def mousePressEvent(self, mouse_event):
+ pass
+
+ def drawContents(self, painter):
+ if self._to_stop:
+ return
+
+ painter.save()
+ painter.setPen(QColor(255, 255, 255, 255))
+ painter.setRenderHint(QPainter.Antialiasing)
+ painter.setRenderHint(QPainter.Antialiasing, True)
+
+ version = Application.getInstance().getVersion().split("-")
+ buildtype = Application.getInstance().getBuildType()
+ if buildtype:
+ version[0] += " (%s)" % buildtype
+
+ # draw version text
+ font = QFont() # Using system-default font here
+ font.setPixelSize(37)
+ painter.setFont(font)
+ painter.drawText(215, 66, 330 * self._scale, 230 * self._scale, Qt.AlignLeft | Qt.AlignTop, version[0])
+ if len(version) > 1:
+ font.setPixelSize(16)
+ painter.setFont(font)
+ painter.setPen(QColor(200, 200, 200, 255))
+ painter.drawText(247, 105, 330 * self._scale, 255 * self._scale, Qt.AlignLeft | Qt.AlignTop, version[1])
+ painter.setPen(QColor(255, 255, 255, 255))
+
+ # draw the loading image
+ pen = QPen()
+ pen.setWidth(6 * self._scale)
+ pen.setColor(QColor(32, 166, 219, 255))
+ painter.setPen(pen)
+ painter.drawArc(60, 150, 32 * self._scale, 32 * self._scale, self._loading_image_rotation_angle * 16, 300 * 16)
+
+ # draw message text
+ if self._current_message:
+ font = QFont() # Using system-default font here
+ font.setPixelSize(13)
+ pen = QPen()
+ pen.setColor(QColor(255, 255, 255, 255))
+ painter.setPen(pen)
+ painter.setFont(font)
+ painter.drawText(100, 128, 170, 64,
+ Qt.AlignLeft | Qt.AlignVCenter | Qt.TextWordWrap,
+ self._current_message)
+
+ painter.restore()
+ super().drawContents(painter)
+
+ def showMessage(self, message, *args, **kwargs):
+ if self._to_stop:
+ return
+
+ self._current_message = message
+ self.messageChanged.emit(message)
+ QCoreApplication.flush()
+ self.repaint()
+
+ def close(self):
+ # set stop flags
+ self._to_stop = True
+ self._change_timer.stop()
+ super().close()
diff --git a/cura/UI/MachineActionManager.py b/cura/UI/MachineActionManager.py
new file mode 100644
index 0000000000..aa90e909e2
--- /dev/null
+++ b/cura/UI/MachineActionManager.py
@@ -0,0 +1,161 @@
+# Copyright (c) 2018 Ultimaker B.V.
+# Cura is released under the terms of the LGPLv3 or higher.
+
+from typing import TYPE_CHECKING, Optional, List, Set, Dict
+
+from PyQt5.QtCore import QObject
+
+from UM.FlameProfiler import pyqtSlot
+from UM.Logger import Logger
+from UM.PluginRegistry import PluginRegistry # So MachineAction can be added as plugin type
+
+if TYPE_CHECKING:
+ from cura.CuraApplication import CuraApplication
+ from cura.Settings.GlobalStack import GlobalStack
+ from cura.MachineAction import MachineAction
+
+
+## Raised when trying to add an unknown machine action as a required action
+class UnknownMachineActionError(Exception):
+ pass
+
+
+## Raised when trying to add a machine action that does not have an unique key.
+class NotUniqueMachineActionError(Exception):
+ pass
+
+
+class MachineActionManager(QObject):
+ def __init__(self, application: "CuraApplication", parent: Optional["QObject"] = None) -> None:
+ super().__init__(parent = parent)
+ self._application = application
+ self._container_registry = self._application.getContainerRegistry()
+
+ # Keeps track of which machines have already been processed so we don't do that again.
+ self._definition_ids_with_default_actions_added = set() # type: Set[str]
+
+ # Dict of all known machine actions
+ self._machine_actions = {} # type: Dict[str, MachineAction]
+ # Dict of all required actions by definition ID
+ self._required_actions = {} # type: Dict[str, List[MachineAction]]
+ # Dict of all supported actions by definition ID
+ self._supported_actions = {} # type: Dict[str, List[MachineAction]]
+ # Dict of all actions that need to be done when first added by definition ID
+ self._first_start_actions = {} # type: Dict[str, List[MachineAction]]
+
+ def initialize(self):
+ # Add machine_action as plugin type
+ PluginRegistry.addType("machine_action", self.addMachineAction)
+
+ # Adds all default machine actions that are defined in the machine definition for the given machine.
+ def addDefaultMachineActions(self, global_stack: "GlobalStack") -> None:
+ definition_id = global_stack.definition.getId()
+
+ if definition_id in self._definition_ids_with_default_actions_added:
+ Logger.log("i", "Default machine actions have been added for machine definition [%s], do nothing.",
+ definition_id)
+ return
+
+ supported_actions = global_stack.getMetaDataEntry("supported_actions", [])
+ for action_key in supported_actions:
+ self.addSupportedAction(definition_id, action_key)
+
+ required_actions = global_stack.getMetaDataEntry("required_actions", [])
+ for action_key in required_actions:
+ self.addRequiredAction(definition_id, action_key)
+
+ first_start_actions = global_stack.getMetaDataEntry("first_start_actions", [])
+ for action_key in first_start_actions:
+ self.addFirstStartAction(definition_id, action_key)
+
+ self._definition_ids_with_default_actions_added.add(definition_id)
+ Logger.log("i", "Default machine actions added for machine definition [%s]", definition_id)
+
+ ## Add a required action to a machine
+ # Raises an exception when the action is not recognised.
+ def addRequiredAction(self, definition_id: str, action_key: str) -> None:
+ if action_key in self._machine_actions:
+ if definition_id in self._required_actions:
+ if self._machine_actions[action_key] not in self._required_actions[definition_id]:
+ self._required_actions[definition_id].append(self._machine_actions[action_key])
+ else:
+ self._required_actions[definition_id] = [self._machine_actions[action_key]]
+ else:
+ raise UnknownMachineActionError("Action %s, which is required for %s is not known." % (action_key, definition_id))
+
+ ## Add a supported action to a machine.
+ def addSupportedAction(self, definition_id: str, action_key: str) -> None:
+ if action_key in self._machine_actions:
+ if definition_id in self._supported_actions:
+ if self._machine_actions[action_key] not in self._supported_actions[definition_id]:
+ self._supported_actions[definition_id].append(self._machine_actions[action_key])
+ else:
+ self._supported_actions[definition_id] = [self._machine_actions[action_key]]
+ else:
+ Logger.log("w", "Unable to add %s to %s, as the action is not recognised", action_key, definition_id)
+
+ ## Add an action to the first start list of a machine.
+ def addFirstStartAction(self, definition_id: str, action_key: str) -> None:
+ if action_key in self._machine_actions:
+ if definition_id in self._first_start_actions:
+ self._first_start_actions[definition_id].append(self._machine_actions[action_key])
+ else:
+ self._first_start_actions[definition_id] = [self._machine_actions[action_key]]
+ else:
+ Logger.log("w", "Unable to add %s to %s, as the action is not recognised", action_key, definition_id)
+
+ ## Add a (unique) MachineAction
+ # if the Key of the action is not unique, an exception is raised.
+ def addMachineAction(self, action: "MachineAction") -> None:
+ if action.getKey() not in self._machine_actions:
+ self._machine_actions[action.getKey()] = action
+ else:
+ raise NotUniqueMachineActionError("MachineAction with key %s was already added. Actions must have unique keys.", action.getKey())
+
+ ## Get all actions supported by given machine
+ # \param definition_id The ID of the definition you want the supported actions of
+ # \returns set of supported actions.
+ @pyqtSlot(str, result = "QVariantList")
+ def getSupportedActions(self, definition_id: str) -> List["MachineAction"]:
+ if definition_id in self._supported_actions:
+ return list(self._supported_actions[definition_id])
+ else:
+ return list()
+
+ ## Get all actions required by given machine
+ # \param definition_id The ID of the definition you want the required actions of
+ # \returns set of required actions.
+ def getRequiredActions(self, definition_id: str) -> List["MachineAction"]:
+ if definition_id in self._required_actions:
+ return self._required_actions[definition_id]
+ else:
+ return list()
+
+ ## Get all actions that need to be performed upon first start of a given machine.
+ # Note that contrary to required / supported actions a list is returned (as it could be required to run the same
+ # action multiple times).
+ # \param definition_id The ID of the definition that you want to get the "on added" actions for.
+ # \returns List of actions.
+ @pyqtSlot(str, result = "QVariantList")
+ def getFirstStartActions(self, definition_id: str) -> List["MachineAction"]:
+ if definition_id in self._first_start_actions:
+ return self._first_start_actions[definition_id]
+ else:
+ return []
+
+ ## Remove Machine action from manager
+ # \param action to remove
+ def removeMachineAction(self, action: "MachineAction") -> None:
+ try:
+ del self._machine_actions[action.getKey()]
+ except KeyError:
+ Logger.log("w", "Trying to remove MachineAction (%s) that was already removed", action.getKey())
+
+ ## Get MachineAction by key
+ # \param key String of key to select
+ # \return Machine action if found, None otherwise
+ def getMachineAction(self, key: str) -> Optional["MachineAction"]:
+ if key in self._machine_actions:
+ return self._machine_actions[key]
+ else:
+ return None
diff --git a/cura/UI/MachineSettingsManager.py b/cura/UI/MachineSettingsManager.py
new file mode 100644
index 0000000000..7ecd9ed65f
--- /dev/null
+++ b/cura/UI/MachineSettingsManager.py
@@ -0,0 +1,82 @@
+# Copyright (c) 2019 Ultimaker B.V.
+# Cura is released under the terms of the LGPLv3 or higher.
+
+from typing import Optional, TYPE_CHECKING
+
+from PyQt5.QtCore import QObject, pyqtSlot
+
+from UM.i18n import i18nCatalog
+
+if TYPE_CHECKING:
+ from cura.CuraApplication import CuraApplication
+
+
+#
+# This manager provides (convenience) functions to the Machine Settings Dialog QML to update certain machine settings.
+#
+class MachineSettingsManager(QObject):
+
+ def __init__(self, application: "CuraApplication", parent: Optional["QObject"] = None) -> None:
+ super().__init__(parent)
+ self._i18n_catalog = i18nCatalog("cura")
+
+ self._application = application
+
+ # Force rebuilding the build volume by reloading the global container stack. This is a bit of a hack, but it seems
+ # quite enough.
+ @pyqtSlot()
+ def forceUpdate(self) -> None:
+ self._application.getMachineManager().globalContainerChanged.emit()
+
+ # Function for the Machine Settings panel (QML) to update the compatible material diameter after a user has changed
+ # an extruder's compatible material diameter. This ensures that after the modification, changes can be notified
+ # and updated right away.
+ @pyqtSlot(int)
+ def updateMaterialForDiameter(self, extruder_position: int) -> None:
+ # Updates the material container to a material that matches the material diameter set for the printer
+ self._application.getMachineManager().updateMaterialWithVariant(str(extruder_position))
+
+ @pyqtSlot(int)
+ def setMachineExtruderCount(self, extruder_count: int) -> None:
+ # Note: this method was in this class before, but since it's quite generic and other plugins also need it
+ # it was moved to the machine manager instead. Now this method just calls the machine manager.
+ self._application.getMachineManager().setActiveMachineExtruderCount(extruder_count)
+
+ # Function for the Machine Settings panel (QML) to update after the usre changes "Number of Extruders".
+ #
+ # fieldOfView: The Ultimaker 2 family (not 2+) does not have materials in Cura by default, because the material is
+ # to be set on the printer. But when switching to Marlin flavor, the printer firmware can not change/insert material
+ # settings on the fly so they need to be configured in Cura. So when switching between gcode flavors, materials may
+ # need to be enabled/disabled.
+ @pyqtSlot()
+ def updateHasMaterialsMetadata(self):
+ machine_manager = self._application.getMachineManager()
+ material_manager = self._application.getMaterialManager()
+
+ global_stack = machine_manager.activeMachine
+
+ definition = global_stack.definition
+ if definition.getProperty("machine_gcode_flavor", "value") != "UltiGCode" or definition.getMetaDataEntry(
+ "has_materials", False):
+ # In other words: only continue for the UM2 (extended), but not for the UM2+
+ return
+
+ extruder_positions = list(global_stack.extruders.keys())
+ has_materials = global_stack.getProperty("machine_gcode_flavor", "value") != "UltiGCode"
+
+ material_node = None
+ if has_materials:
+ global_stack.setMetaDataEntry("has_materials", True)
+ else:
+ # The metadata entry is stored in an ini, and ini files are parsed as strings only.
+ # Because any non-empty string evaluates to a boolean True, we have to remove the entry to make it False.
+ if "has_materials" in global_stack.getMetaData():
+ global_stack.removeMetaDataEntry("has_materials")
+
+ # set materials
+ for position in extruder_positions:
+ if has_materials:
+ material_node = material_manager.getDefaultMaterial(global_stack, position, None)
+ machine_manager.setMaterial(position, material_node)
+
+ self.forceUpdate()
diff --git a/cura/UI/ObjectsModel.py b/cura/UI/ObjectsModel.py
new file mode 100644
index 0000000000..d1ca3353f5
--- /dev/null
+++ b/cura/UI/ObjectsModel.py
@@ -0,0 +1,112 @@
+# Copyright (c) 2018 Ultimaker B.V.
+# Cura is released under the terms of the LGPLv3 or higher.
+
+from collections import defaultdict
+from typing import Dict
+
+from PyQt5.QtCore import QTimer, Qt
+
+from UM.Application import Application
+from UM.Qt.ListModel import ListModel
+from UM.Scene.Camera import Camera
+from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
+from UM.Scene.SceneNode import SceneNode
+from UM.Scene.Selection import Selection
+from UM.i18n import i18nCatalog
+
+catalog = i18nCatalog("cura")
+
+
+## Keep track of all objects in the project
+class ObjectsModel(ListModel):
+ NameRole = Qt.UserRole + 1
+ SelectedRole = Qt.UserRole + 2
+ OutsideAreaRole = Qt.UserRole + 3
+ BuilplateNumberRole = Qt.UserRole + 4
+ NodeRole = Qt.UserRole + 5
+
+ def __init__(self, parent = None) -> None:
+ super().__init__(parent)
+
+ self.addRoleName(self.NameRole, "name")
+ self.addRoleName(self.SelectedRole, "selected")
+ self.addRoleName(self.OutsideAreaRole, "outside_build_area")
+ self.addRoleName(self.BuilplateNumberRole, "buildplate_number")
+ self.addRoleName(self.NodeRole, "node")
+
+ Application.getInstance().getController().getScene().sceneChanged.connect(self._updateSceneDelayed)
+ Application.getInstance().getPreferences().preferenceChanged.connect(self._updateDelayed)
+
+ self._update_timer = QTimer()
+ self._update_timer.setInterval(200)
+ self._update_timer.setSingleShot(True)
+ self._update_timer.timeout.connect(self._update)
+
+ self._build_plate_number = -1
+
+ def setActiveBuildPlate(self, nr: int) -> None:
+ if self._build_plate_number != nr:
+ self._build_plate_number = nr
+ self._update()
+
+ def _updateSceneDelayed(self, source) -> None:
+ if not isinstance(source, Camera):
+ self._update_timer.start()
+
+ def _updateDelayed(self, *args) -> None:
+ self._update_timer.start()
+
+ def _update(self, *args) -> None:
+ nodes = []
+ filter_current_build_plate = Application.getInstance().getPreferences().getValue("view/filter_current_build_plate")
+ active_build_plate_number = self._build_plate_number
+ group_nr = 1
+ name_count_dict = defaultdict(int) # type: Dict[str, int]
+
+ for node in DepthFirstIterator(Application.getInstance().getController().getScene().getRoot()): # type: ignore
+ if not isinstance(node, SceneNode):
+ continue
+ if (not node.getMeshData() and not node.callDecoration("getLayerData")) and not node.callDecoration("isGroup"):
+ continue
+
+ parent = node.getParent()
+ if parent and parent.callDecoration("isGroup"):
+ continue # Grouped nodes don't need resetting as their parent (the group) is resetted)
+ if not node.callDecoration("isSliceable") and not node.callDecoration("isGroup"):
+ continue
+ node_build_plate_number = node.callDecoration("getBuildPlateNumber")
+ if filter_current_build_plate and node_build_plate_number != active_build_plate_number:
+ continue
+
+ if not node.callDecoration("isGroup"):
+ name = node.getName()
+
+ else:
+ name = catalog.i18nc("@label", "Group #{group_nr}").format(group_nr = str(group_nr))
+ group_nr += 1
+
+ if hasattr(node, "isOutsideBuildArea"):
+ is_outside_build_area = node.isOutsideBuildArea() # type: ignore
+ else:
+ is_outside_build_area = False
+
+ # Check if we already have an instance of the object based on name
+ name_count_dict[name] += 1
+ name_count = name_count_dict[name]
+
+ if name_count > 1:
+ name = "{0}({1})".format(name, name_count-1)
+ node.setName(name)
+
+ nodes.append({
+ "name": name,
+ "selected": Selection.isSelected(node),
+ "outside_build_area": is_outside_build_area,
+ "buildplate_number": node_build_plate_number,
+ "node": node
+ })
+
+ nodes = sorted(nodes, key=lambda n: n["name"])
+ self.setItems(nodes)
+
+ self.itemsChanged.emit()
diff --git a/cura/UI/PrintInformation.py b/cura/UI/PrintInformation.py
new file mode 100644
index 0000000000..3fafaaba12
--- /dev/null
+++ b/cura/UI/PrintInformation.py
@@ -0,0 +1,435 @@
+# Copyright (c) 2018 Ultimaker B.V.
+# Cura is released under the terms of the LGPLv3 or higher.
+
+import json
+import math
+import os
+import unicodedata
+from typing import Dict, List, Optional, TYPE_CHECKING
+
+from PyQt5.QtCore import QObject, pyqtSignal, pyqtProperty, pyqtSlot
+
+from UM.Logger import Logger
+from UM.Qt.Duration import Duration
+from UM.Scene.SceneNode import SceneNode
+from UM.i18n import i18nCatalog
+from UM.MimeTypeDatabase import MimeTypeDatabase, MimeTypeNotFoundError
+
+if TYPE_CHECKING:
+ from cura.CuraApplication import CuraApplication
+
+catalog = i18nCatalog("cura")
+
+
+## A class for processing and the print times per build plate as well as managing the job name
+#
+# This class also mangles the current machine name and the filename of the first loaded mesh into a job name.
+# This job name is requested by the JobSpecs qml file.
+class PrintInformation(QObject):
+
+ UNTITLED_JOB_NAME = "Untitled"
+
+ def __init__(self, application: "CuraApplication", parent = None) -> None:
+ super().__init__(parent)
+ self._application = application
+
+ self.initializeCuraMessagePrintTimeProperties()
+
+ # Indexed by build plate number
+ self._material_lengths = {} # type: Dict[int, List[float]]
+ self._material_weights = {} # type: Dict[int, List[float]]
+ self._material_costs = {} # type: Dict[int, List[float]]
+ self._material_names = {} # type: Dict[int, List[str]]
+
+ self._pre_sliced = False
+
+ self._backend = self._application.getBackend()
+ if self._backend:
+ self._backend.printDurationMessage.connect(self._onPrintDurationMessage)
+
+ self._application.getController().getScene().sceneChanged.connect(self._onSceneChanged)
+
+ self._is_user_specified_job_name = False
+ self._base_name = ""
+ self._abbr_machine = ""
+ self._job_name = ""
+ self._active_build_plate = 0
+ self._initVariablesByBuildPlate(self._active_build_plate)
+
+ self._multi_build_plate_model = self._application.getMultiBuildPlateModel()
+
+ self._application.globalContainerStackChanged.connect(self._updateJobName)
+ self._application.globalContainerStackChanged.connect(self.setToZeroPrintInformation)
+ self._application.fileLoaded.connect(self.setBaseName)
+ self._application.workspaceLoaded.connect(self.setProjectName)
+ self._application.getMachineManager().rootMaterialChanged.connect(self._onActiveMaterialsChanged)
+ self._application.getInstance().getPreferences().preferenceChanged.connect(self._onPreferencesChanged)
+
+ self._multi_build_plate_model.activeBuildPlateChanged.connect(self._onActiveBuildPlateChanged)
+ self._material_amounts = [] # type: List[float]
+ self._onActiveMaterialsChanged()
+
+ def initializeCuraMessagePrintTimeProperties(self) -> None:
+ self._current_print_time = {} # type: Dict[int, Duration]
+
+ self._print_time_message_translations = {
+ "inset_0": catalog.i18nc("@tooltip", "Outer Wall"),
+ "inset_x": catalog.i18nc("@tooltip", "Inner Walls"),
+ "skin": catalog.i18nc("@tooltip", "Skin"),
+ "infill": catalog.i18nc("@tooltip", "Infill"),
+ "support_infill": catalog.i18nc("@tooltip", "Support Infill"),
+ "support_interface": catalog.i18nc("@tooltip", "Support Interface"),
+ "support": catalog.i18nc("@tooltip", "Support"),
+ "skirt": catalog.i18nc("@tooltip", "Skirt"),
+ "prime_tower": catalog.i18nc("@tooltip", "Prime Tower"),
+ "travel": catalog.i18nc("@tooltip", "Travel"),
+ "retract": catalog.i18nc("@tooltip", "Retractions"),
+ "none": catalog.i18nc("@tooltip", "Other")
+ }
+
+ self._print_times_per_feature = {} # type: Dict[int, Dict[str, Duration]]
+
+ def _initPrintTimesPerFeature(self, build_plate_number: int) -> None:
+ # Full fill message values using keys from _print_time_message_translations
+ self._print_times_per_feature[build_plate_number] = {}
+ for key in self._print_time_message_translations.keys():
+ self._print_times_per_feature[build_plate_number][key] = Duration(None, self)
+
+ def _initVariablesByBuildPlate(self, build_plate_number: int) -> None:
+ if build_plate_number not in self._print_times_per_feature:
+ self._initPrintTimesPerFeature(build_plate_number)
+ if self._active_build_plate not in self._material_lengths:
+ self._material_lengths[self._active_build_plate] = []
+ if self._active_build_plate not in self._material_weights:
+ self._material_weights[self._active_build_plate] = []
+ if self._active_build_plate not in self._material_costs:
+ self._material_costs[self._active_build_plate] = []
+ if self._active_build_plate not in self._material_names:
+ self._material_names[self._active_build_plate] = []
+ if self._active_build_plate not in self._current_print_time:
+ self._current_print_time[self._active_build_plate] = Duration(parent = self)
+
+ currentPrintTimeChanged = pyqtSignal()
+
+ preSlicedChanged = pyqtSignal()
+
+ @pyqtProperty(bool, notify=preSlicedChanged)
+ def preSliced(self) -> bool:
+ return self._pre_sliced
+
+ def setPreSliced(self, pre_sliced: bool) -> None:
+ if self._pre_sliced != pre_sliced:
+ self._pre_sliced = pre_sliced
+ self._updateJobName()
+ self.preSlicedChanged.emit()
+
+ @pyqtProperty(Duration, notify = currentPrintTimeChanged)
+ def currentPrintTime(self) -> Duration:
+ return self._current_print_time[self._active_build_plate]
+
+ materialLengthsChanged = pyqtSignal()
+
+ @pyqtProperty("QVariantList", notify = materialLengthsChanged)
+ def materialLengths(self):
+ return self._material_lengths[self._active_build_plate]
+
+ materialWeightsChanged = pyqtSignal()
+
+ @pyqtProperty("QVariantList", notify = materialWeightsChanged)
+ def materialWeights(self):
+ return self._material_weights[self._active_build_plate]
+
+ materialCostsChanged = pyqtSignal()
+
+ @pyqtProperty("QVariantList", notify = materialCostsChanged)
+ def materialCosts(self):
+ return self._material_costs[self._active_build_plate]
+
+ materialNamesChanged = pyqtSignal()
+
+ @pyqtProperty("QVariantList", notify = materialNamesChanged)
+ def materialNames(self):
+ return self._material_names[self._active_build_plate]
+
+ # Get all print times (by feature) of the active buildplate.
+ def printTimes(self) -> Dict[str, Duration]:
+ return self._print_times_per_feature[self._active_build_plate]
+
+ def _onPrintDurationMessage(self, build_plate_number: int, print_times_per_feature: Dict[str, int], material_amounts: List[float]) -> None:
+ self._updateTotalPrintTimePerFeature(build_plate_number, print_times_per_feature)
+ self.currentPrintTimeChanged.emit()
+
+ self._material_amounts = material_amounts
+ self._calculateInformation(build_plate_number)
+
+ def _updateTotalPrintTimePerFeature(self, build_plate_number: int, print_times_per_feature: Dict[str, int]) -> None:
+ total_estimated_time = 0
+
+ if build_plate_number not in self._print_times_per_feature:
+ self._initPrintTimesPerFeature(build_plate_number)
+
+ for feature, time in print_times_per_feature.items():
+ if feature not in self._print_times_per_feature[build_plate_number]:
+ self._print_times_per_feature[build_plate_number][feature] = Duration(parent=self)
+ duration = self._print_times_per_feature[build_plate_number][feature]
+
+ if time != time: # Check for NaN. Engine can sometimes give us weird values.
+ duration.setDuration(0)
+ Logger.log("w", "Received NaN for print duration message")
+ continue
+
+ total_estimated_time += time
+ duration.setDuration(time)
+
+ if build_plate_number not in self._current_print_time:
+ self._current_print_time[build_plate_number] = Duration(None, self)
+ self._current_print_time[build_plate_number].setDuration(total_estimated_time)
+
+ def _calculateInformation(self, build_plate_number: int) -> None:
+ global_stack = self._application.getGlobalContainerStack()
+ if global_stack is None:
+ return
+
+ self._material_lengths[build_plate_number] = []
+ self._material_weights[build_plate_number] = []
+ self._material_costs[build_plate_number] = []
+ self._material_names[build_plate_number] = []
+
+ material_preference_values = json.loads(self._application.getInstance().getPreferences().getValue("cura/material_settings"))
+
+ extruder_stacks = global_stack.extruders
+
+ for position in extruder_stacks:
+ extruder_stack = extruder_stacks[position]
+ index = int(position)
+ if index >= len(self._material_amounts):
+ continue
+ amount = self._material_amounts[index]
+ # Find the right extruder stack. As the list isn't sorted because it's a annoying generator, we do some
+ # list comprehension filtering to solve this for us.
+ density = extruder_stack.getMetaDataEntry("properties", {}).get("density", 0)
+ material = extruder_stack.material
+ radius = extruder_stack.getProperty("material_diameter", "value") / 2
+
+ weight = float(amount) * float(density) / 1000
+ cost = 0.
+
+ material_guid = material.getMetaDataEntry("GUID")
+ material_name = material.getName()
+
+ if material_guid in material_preference_values:
+ material_values = material_preference_values[material_guid]
+
+ if material_values and "spool_weight" in material_values:
+ weight_per_spool = float(material_values["spool_weight"])
+ else:
+ weight_per_spool = float(extruder_stack.getMetaDataEntry("properties", {}).get("weight", 0))
+
+ cost_per_spool = float(material_values["spool_cost"] if material_values and "spool_cost" in material_values else 0)
+
+ if weight_per_spool != 0:
+ cost = cost_per_spool * weight / weight_per_spool
+ else:
+ cost = 0
+
+ # Material amount is sent as an amount of mm^3, so calculate length from that
+ if radius != 0:
+ length = round((amount / (math.pi * radius ** 2)) / 1000, 2)
+ else:
+ length = 0
+
+ self._material_weights[build_plate_number].append(weight)
+ self._material_lengths[build_plate_number].append(length)
+ self._material_costs[build_plate_number].append(cost)
+ self._material_names[build_plate_number].append(material_name)
+
+ self.materialLengthsChanged.emit()
+ self.materialWeightsChanged.emit()
+ self.materialCostsChanged.emit()
+ self.materialNamesChanged.emit()
+
+ def _onPreferencesChanged(self, preference: str) -> None:
+ if preference != "cura/material_settings":
+ return
+
+ for build_plate_number in range(self._multi_build_plate_model.maxBuildPlate + 1):
+ self._calculateInformation(build_plate_number)
+
+ def _onActiveBuildPlateChanged(self) -> None:
+ new_active_build_plate = self._multi_build_plate_model.activeBuildPlate
+ if new_active_build_plate != self._active_build_plate:
+ self._active_build_plate = new_active_build_plate
+ self._updateJobName()
+
+ self._initVariablesByBuildPlate(self._active_build_plate)
+
+ self.materialLengthsChanged.emit()
+ self.materialWeightsChanged.emit()
+ self.materialCostsChanged.emit()
+ self.materialNamesChanged.emit()
+ self.currentPrintTimeChanged.emit()
+
+ def _onActiveMaterialsChanged(self, *args, **kwargs) -> None:
+ for build_plate_number in range(self._multi_build_plate_model.maxBuildPlate + 1):
+ self._calculateInformation(build_plate_number)
+
+ # Manual override of job name should also set the base name so that when the printer prefix is updated, it the
+ # prefix can be added to the manually added name, not the old base name
+ @pyqtSlot(str, bool)
+ def setJobName(self, name: str, is_user_specified_job_name = False) -> None:
+ self._is_user_specified_job_name = is_user_specified_job_name
+ self._job_name = name
+ self._base_name = name.replace(self._abbr_machine + "_", "")
+ if name == "":
+ self._is_user_specified_job_name = False
+ self.jobNameChanged.emit()
+
+ jobNameChanged = pyqtSignal()
+
+ @pyqtProperty(str, notify = jobNameChanged)
+ def jobName(self):
+ return self._job_name
+
+ def _updateJobName(self) -> None:
+ if self._base_name == "":
+ self._job_name = self.UNTITLED_JOB_NAME
+ self._is_user_specified_job_name = False
+ self.jobNameChanged.emit()
+ return
+
+ base_name = self._stripAccents(self._base_name)
+ self._defineAbbreviatedMachineName()
+
+ # Only update the job name when it's not user-specified.
+ if not self._is_user_specified_job_name:
+ if self._pre_sliced:
+ self._job_name = catalog.i18nc("@label", "Pre-sliced file {0}", base_name)
+ elif self._application.getInstance().getPreferences().getValue("cura/jobname_prefix"):
+ # Don't add abbreviation if it already has the exact same abbreviation.
+ if base_name.startswith(self._abbr_machine + "_"):
+ self._job_name = base_name
+ else:
+ self._job_name = self._abbr_machine + "_" + base_name
+ else:
+ self._job_name = base_name
+
+ # In case there are several buildplates, a suffix is attached
+ if self._multi_build_plate_model.maxBuildPlate > 0:
+ connector = "_#"
+ suffix = connector + str(self._active_build_plate + 1)
+ if connector in self._job_name:
+ self._job_name = self._job_name.split(connector)[0] # get the real name
+ if self._active_build_plate != 0:
+ self._job_name += suffix
+
+ self.jobNameChanged.emit()
+
+ @pyqtSlot(str)
+ def setProjectName(self, name: str) -> None:
+ self.setBaseName(name, is_project_file = True)
+
+ baseNameChanged = pyqtSignal()
+
+ def setBaseName(self, base_name: str, is_project_file: bool = False) -> None:
+ self._is_user_specified_job_name = False
+
+ # Ensure that we don't use entire path but only filename
+ name = os.path.basename(base_name)
+
+ # when a file is opened using the terminal; the filename comes from _onFileLoaded and still contains its
+ # extension. This cuts the extension off if necessary.
+ check_name = os.path.splitext(name)[0]
+ filename_parts = os.path.basename(base_name).split(".")
+
+ # If it's a gcode, also always update the job name
+ is_gcode = False
+ if len(filename_parts) > 1:
+ # Only check the extension(s)
+ is_gcode = "gcode" in filename_parts[1:]
+
+ # if this is a profile file, always update the job name
+ # name is "" when I first had some meshes and afterwards I deleted them so the naming should start again
+ is_empty = check_name == ""
+ if is_gcode or is_project_file or (is_empty or (self._base_name == "" and self._base_name != check_name)):
+ # Only take the file name part, Note : file name might have 'dot' in name as well
+
+ data = ""
+ try:
+ mime_type = MimeTypeDatabase.getMimeTypeForFile(name)
+ data = mime_type.stripExtension(name)
+ except MimeTypeNotFoundError:
+ Logger.log("w", "Unsupported Mime Type Database file extension %s", name)
+
+ if data is not None and check_name is not None:
+ self._base_name = data
+ else:
+ self._base_name = ""
+
+ # Strip the old "curaproject" extension from the name
+ OLD_CURA_PROJECT_EXT = ".curaproject"
+ if self._base_name.lower().endswith(OLD_CURA_PROJECT_EXT):
+ self._base_name = self._base_name[:len(self._base_name) - len(OLD_CURA_PROJECT_EXT)]
+
+ # CURA-5896 Try to strip extra extensions with an infinite amount of ".curaproject.3mf".
+ OLD_CURA_PROJECT_3MF_EXT = ".curaproject.3mf"
+ while self._base_name.lower().endswith(OLD_CURA_PROJECT_3MF_EXT):
+ self._base_name = self._base_name[:len(self._base_name) - len(OLD_CURA_PROJECT_3MF_EXT)]
+
+ self._updateJobName()
+
+ @pyqtProperty(str, fset = setBaseName, notify = baseNameChanged)
+ def baseName(self):
+ return self._base_name
+
+ ## Created an acronym-like abbreviated machine name from the currently
+ # active machine name.
+ # Called each time the global stack is switched.
+ def _defineAbbreviatedMachineName(self) -> None:
+ global_container_stack = self._application.getGlobalContainerStack()
+ if not global_container_stack:
+ self._abbr_machine = ""
+ return
+ active_machine_type_name = global_container_stack.definition.getName()
+
+ self._abbr_machine = self._application.getMachineManager().getAbbreviatedMachineName(active_machine_type_name)
+
+ ## Utility method that strips accents from characters (eg: รข -> a)
+ def _stripAccents(self, to_strip: str) -> str:
+ return ''.join(char for char in unicodedata.normalize('NFD', to_strip) if unicodedata.category(char) != 'Mn')
+
+ @pyqtSlot(result = "QVariantMap")
+ def getFeaturePrintTimes(self) -> Dict[str, Duration]:
+ result = {}
+ if self._active_build_plate not in self._print_times_per_feature:
+ self._initPrintTimesPerFeature(self._active_build_plate)
+ for feature, time in self._print_times_per_feature[self._active_build_plate].items():
+ if feature in self._print_time_message_translations:
+ result[self._print_time_message_translations[feature]] = time
+ else:
+ result[feature] = time
+ return result
+
+ # Simulate message with zero time duration
+ def setToZeroPrintInformation(self, build_plate: Optional[int] = None) -> None:
+ if build_plate is None:
+ build_plate = self._active_build_plate
+
+ # Construct the 0-time message
+ temp_message = {}
+ if build_plate not in self._print_times_per_feature:
+ self._print_times_per_feature[build_plate] = {}
+ for key in self._print_times_per_feature[build_plate].keys():
+ temp_message[key] = 0
+ temp_material_amounts = [0.]
+
+ self._onPrintDurationMessage(build_plate, temp_message, temp_material_amounts)
+
+ ## Listen to scene changes to check if we need to reset the print information
+ def _onSceneChanged(self, scene_node: SceneNode) -> None:
+ # Ignore any changes that are not related to sliceable objects
+ if not isinstance(scene_node, SceneNode)\
+ or not scene_node.callDecoration("isSliceable")\
+ or not scene_node.callDecoration("getBuildPlateNumber") == self._active_build_plate:
+ return
+
+ self.setToZeroPrintInformation(self._active_build_plate)
diff --git a/cura/UI/TextManager.py b/cura/UI/TextManager.py
new file mode 100644
index 0000000000..86838a0b48
--- /dev/null
+++ b/cura/UI/TextManager.py
@@ -0,0 +1,69 @@
+# Copyright (c) 2019 Ultimaker B.V.
+# Cura is released under the terms of the LGPLv3 or higher.
+
+import collections
+from typing import Optional, Dict, List, cast
+
+from PyQt5.QtCore import QObject, pyqtSlot
+
+from UM.Resources import Resources
+from UM.Version import Version
+
+
+#
+# This manager provides means to load texts to QML.
+#
+class TextManager(QObject):
+
+ def __init__(self, parent: Optional["QObject"] = None) -> None:
+ super().__init__(parent)
+
+ self._change_log_text = ""
+
+ @pyqtSlot(result = str)
+ def getChangeLogText(self) -> str:
+ if not self._change_log_text:
+ self._change_log_text = self._loadChangeLogText()
+ return self._change_log_text
+
+ def _loadChangeLogText(self) -> str:
+ # Load change log texts and organize them with a dict
+ file_path = Resources.getPath(Resources.Texts, "change_log.txt")
+ change_logs_dict = {} # type: Dict[Version, Dict[str, List[str]]]
+ with open(file_path, "r", encoding = "utf-8") as f:
+ open_version = None # type: Optional[Version]
+ open_header = "" # Initialise to an empty header in case there is no "*" in the first line of the changelog
+ for line in f:
+ line = line.replace("\n", "")
+ if "[" in line and "]" in line:
+ line = line.replace("[", "")
+ line = line.replace("]", "")
+ open_version = Version(line)
+ if open_version > Version([14, 99, 99]): # Bit of a hack: We released the 15.x.x versions before 2.x
+ open_version = Version([0, open_version.getMinor(), open_version.getRevision(), open_version.getPostfixVersion()])
+ open_header = ""
+ change_logs_dict[open_version] = collections.OrderedDict()
+ elif line.startswith("*"):
+ open_header = line.replace("*", "")
+ change_logs_dict[cast(Version, open_version)][open_header] = []
+ elif line != "":
+ if open_header not in change_logs_dict[cast(Version, open_version)]:
+ change_logs_dict[cast(Version, open_version)][open_header] = []
+ change_logs_dict[cast(Version, open_version)][open_header].append(line)
+
+ # Format changelog text
+ content = ""
+ for version in sorted(change_logs_dict.keys(), reverse = True):
+ text_version = version
+ if version < Version([1, 0, 0]): # Bit of a hack: We released the 15.x.x versions before 2.x
+ text_version = Version([15, version.getMinor(), version.getRevision(), version.getPostfixVersion()])
+ content += "<h1>" + str(text_version) + "</h1><br>"
+ content += ""
+ for change in change_logs_dict[version]:
+ if str(change) != "":
+ content += "<b>" + str(change) + "</b><br>"
+ for line in change_logs_dict[version][change]:
+ content += str(line) + "<br>"
+ content += "<br>"
+
+ return content
diff --git a/cura/UI/WelcomePagesModel.py b/cura/UI/WelcomePagesModel.py
new file mode 100644
index 0000000000..c16ec3763e
--- /dev/null
+++ b/cura/UI/WelcomePagesModel.py
@@ -0,0 +1,294 @@
+# Copyright (c) 2019 Ultimaker B.V.
+# Cura is released under the terms of the LGPLv3 or higher.
+from collections import deque
+import os
+from typing import TYPE_CHECKING, Optional, List, Dict, Any
+
+from PyQt5.QtCore import QUrl, Qt, pyqtSlot, pyqtProperty, pyqtSignal
+
+from UM.i18n import i18nCatalog
+from UM.Logger import Logger
+from UM.Qt.ListModel import ListModel
+from UM.Resources import Resources
+
+if TYPE_CHECKING:
+ from PyQt5.QtCore import QObject
+ from cura.CuraApplication import CuraApplication
+
+
+#
+# This is the Qt ListModel that contains all welcome pages data. Each page is a page that can be shown as a step in the
+# welcome wizard dialog. Each item in this ListModel represents a page, which contains the following fields:
+#
+# - id : A unique page_id which can be used in function goToPage(page_id)
+# - page_url : The QUrl to the QML file that contains the content of this page
+# - next_page_id : (OPTIONAL) The next page ID to go to when this page finished. This is optional. If this is not
+# provided, it will go to the page with the current index + 1
+# - next_page_button_text: (OPTIONAL) The text to show for the "next" button, by default it's the translated text of
+# "Next". Note that each step QML can decide whether to use this text or not, so it's not
+# mandatory.
+# - should_show_function : (OPTIONAL) An optional function that returns True/False indicating if this page should be
+# shown. By default all pages should be shown. If a function returns False, that page will
+# be skipped and its next page will be shown.
+#
+# Note that in any case, a page that has its "should_show_function" == False will ALWAYS be skipped.
+#
+class WelcomePagesModel(ListModel):
+
+ IdRole = Qt.UserRole + 1 # Page ID
+ PageUrlRole = Qt.UserRole + 2 # URL to the page's QML file
+ NextPageIdRole = Qt.UserRole + 3 # The next page ID it should go to
+ NextPageButtonTextRole = Qt.UserRole + 4 # The text for the next page button
+ PreviousPageButtonTextRole = Qt.UserRole + 5 # The text for the previous page button
+
+ def __init__(self, application: "CuraApplication", parent: Optional["QObject"] = None) -> None:
+ super().__init__(parent)
+
+ self.addRoleName(self.IdRole, "id")
+ self.addRoleName(self.PageUrlRole, "page_url")
+ self.addRoleName(self.NextPageIdRole, "next_page_id")
+ self.addRoleName(self.NextPageButtonTextRole, "next_page_button_text")
+ self.addRoleName(self.PreviousPageButtonTextRole, "previous_page_button_text")
+
+ self._application = application
+ self._catalog = i18nCatalog("cura")
+
+ self._default_next_button_text = self._catalog.i18nc("@action:button", "Next")
+
+ self._pages = [] # type: List[Dict[str, Any]]
+
+ self._current_page_index = 0
+ # Store all the previous page indices so it can go back.
+ self._previous_page_indices_stack = deque() # type: deque
+
+ # If the welcome flow should be shown. It can show the complete flow or just the changelog depending on the
+ # specific case. See initialize() for how this variable is set.
+ self._should_show_welcome_flow = False
+
+ allFinished = pyqtSignal() # emitted when all steps have been finished
+ currentPageIndexChanged = pyqtSignal()
+
+ @pyqtProperty(int, notify = currentPageIndexChanged)
+ def currentPageIndex(self) -> int:
+ return self._current_page_index
+
+ # Returns a float number in [0, 1] which indicates the current progress.
+ @pyqtProperty(float, notify = currentPageIndexChanged)
+ def currentProgress(self) -> float:
+ if len(self._items) == 0:
+ return 0
+ else:
+ return self._current_page_index / len(self._items)
+
+ # Indicates if the current page is the last page.
+ @pyqtProperty(bool, notify = currentPageIndexChanged)
+ def isCurrentPageLast(self) -> bool:
+ return self._current_page_index == len(self._items) - 1
+
+ def _setCurrentPageIndex(self, page_index: int) -> None:
+ if page_index != self._current_page_index:
+ self._previous_page_indices_stack.append(self._current_page_index)
+ self._current_page_index = page_index
+ self.currentPageIndexChanged.emit()
+
+ # Ends the Welcome-Pages. Put as a separate function for cases like the 'decline' in the User-Agreement.
+ @pyqtSlot()
+ def atEnd(self) -> None:
+ self.allFinished.emit()
+ self.resetState()
+
+ # Goes to the next page.
+ # If "from_index" is given, it will look for the next page to show starting from the "from_index" page instead of
+ # the "self._current_page_index".
+ @pyqtSlot()
+ def goToNextPage(self, from_index: Optional[int] = None) -> None:
+ # Look for the next page that should be shown
+ current_index = self._current_page_index if from_index is None else from_index
+ while True:
+ page_item = self._items[current_index]
+
+ # Check if there's a "next_page_id" assigned. If so, go to that page. Otherwise, go to the page with the
+ # current index + 1.
+ next_page_id = page_item.get("next_page_id")
+ next_page_index = current_index + 1
+ if next_page_id:
+ idx = self.getPageIndexById(next_page_id)
+ if idx is None:
+ # FIXME: If we cannot find the next page, we cannot do anything here.
+ Logger.log("e", "Cannot find page with ID [%s]", next_page_id)
+ return
+ next_page_index = idx
+
+ # If we have reached the last page, emit allFinished signal and reset.
+ if next_page_index == len(self._items):
+ self.atEnd()
+ return
+
+ # Check if the this page should be shown (default yes), if not, keep looking for the next one.
+ next_page_item = self.getItem(next_page_index)
+ if self._shouldPageBeShown(next_page_index):
+ break
+
+ Logger.log("d", "Page [%s] should not be displayed, look for the next page.", next_page_item["id"])
+ current_index = next_page_index
+
+ # Move to the next page
+ self._setCurrentPageIndex(next_page_index)
+
+ # Goes to the previous page. If there's no previous page, do nothing.
+ @pyqtSlot()
+ def goToPreviousPage(self) -> None:
+ if len(self._previous_page_indices_stack) == 0:
+ Logger.log("i", "No previous page, do nothing")
+ return
+
+ previous_page_index = self._previous_page_indices_stack.pop()
+ self._current_page_index = previous_page_index
+ self.currentPageIndexChanged.emit()
+
+ # Sets the current page to the given page ID. If the page ID is not found, do nothing.
+ @pyqtSlot(str)
+ def goToPage(self, page_id: str) -> None:
+ page_index = self.getPageIndexById(page_id)
+ if page_index is None:
+ # FIXME: If we cannot find the next page, we cannot do anything here.
+ Logger.log("e", "Cannot find page with ID [%s], go to the next page by default", page_index)
+ self.goToNextPage()
+ return
+
+ if self._shouldPageBeShown(page_index):
+ # Move to that page if it should be shown
+ self._setCurrentPageIndex(page_index)
+ else:
+ # Find the next page to show starting from the "page_index"
+ self.goToNextPage(from_index = page_index)
+
+ # Checks if the page with the given index should be shown by calling the "should_show_function" associated with it.
+ # If the function is not present, returns True (show page by default).
+ def _shouldPageBeShown(self, page_index: int) -> bool:
+ next_page_item = self.getItem(page_index)
+ should_show_function = next_page_item.get("should_show_function", lambda: True)
+ return should_show_function()
+
+ # Resets the state of the WelcomePagesModel. This functions does the following:
+ # - Resets current_page_index to 0
+ # - Clears the previous page indices stack
+ @pyqtSlot()
+ def resetState(self) -> None:
+ self._current_page_index = 0
+ self._previous_page_indices_stack.clear()
+
+ self.currentPageIndexChanged.emit()
+
+ shouldShowWelcomeFlowChanged = pyqtSignal()
+
+ @pyqtProperty(bool, notify = shouldShowWelcomeFlowChanged)
+ def shouldShowWelcomeFlow(self) -> bool:
+ return self._should_show_welcome_flow
+
+ # Gets the page index with the given page ID. If the page ID doesn't exist, returns None.
+ def getPageIndexById(self, page_id: str) -> Optional[int]:
+ page_idx = None
+ for idx, page_item in enumerate(self._items):
+ if page_item["id"] == page_id:
+ page_idx = idx
+ break
+ return page_idx
+
+ # Convenience function to get QUrl path to pages that's located in "resources/qml/WelcomePages".
+ def _getBuiltinWelcomePagePath(self, page_filename: str) -> "QUrl":
+ from cura.CuraApplication import CuraApplication
+ return QUrl.fromLocalFile(Resources.getPath(CuraApplication.ResourceTypes.QmlFiles,
+ os.path.join("WelcomePages", page_filename)))
+
+ # FIXME: HACKs for optimization that we don't update the model every time the active machine gets changed.
+ def _onActiveMachineChanged(self) -> None:
+ self._application.getMachineManager().globalContainerChanged.disconnect(self._onActiveMachineChanged)
+ self._initialize(update_should_show_flag = False)
+
+ def initialize(self) -> None:
+ self._application.getMachineManager().globalContainerChanged.connect(self._onActiveMachineChanged)
+ self._initialize()
+
+ def _initialize(self, update_should_show_flag: bool = True) -> None:
+ show_whatsnew_only = False
+ if update_should_show_flag:
+ has_active_machine = self._application.getMachineManager().activeMachine is not None
+ has_app_just_upgraded = self._application.hasJustUpdatedFromOldVersion()
+
+ # Only show the what's new dialog if there's no machine and we have just upgraded
+ show_complete_flow = not has_active_machine
+ show_whatsnew_only = has_active_machine and has_app_just_upgraded
+
+ # FIXME: This is a hack. Because of the circular dependency between MachineManager, ExtruderManager, and
+ # possibly some others, setting the initial active machine is not done when the MachineManager gets initialized.
+ # So at this point, we don't know if there will be an active machine or not. It could be that the active machine
+ # files are corrupted so we cannot rely on Preferences either. This makes sure that once the active machine
+ # gets changed, this model updates the flags, so it can decide whether to show the welcome flow or not.
+ should_show_welcome_flow = show_complete_flow or show_whatsnew_only
+ if should_show_welcome_flow != self._should_show_welcome_flow:
+ self._should_show_welcome_flow = should_show_welcome_flow
+ self.shouldShowWelcomeFlowChanged.emit()
+
+ # All pages
+ all_pages_list = [{"id": "welcome",
+ "page_url": self._getBuiltinWelcomePagePath("WelcomeContent.qml"),
+ },
+ {"id": "user_agreement",
+ "page_url": self._getBuiltinWelcomePagePath("UserAgreementContent.qml"),
+ },
+ {"id": "whats_new",
+ "page_url": self._getBuiltinWelcomePagePath("WhatsNewContent.qml"),
+ },
+ {"id": "data_collections",
+ "page_url": self._getBuiltinWelcomePagePath("DataCollectionsContent.qml"),
+ },
+ {"id": "add_network_or_local_printer",
+ "page_url": self._getBuiltinWelcomePagePath("AddNetworkOrLocalPrinterContent.qml"),
+ "next_page_id": "machine_actions",
+ },
+ {"id": "add_printer_by_ip",
+ "page_url": self._getBuiltinWelcomePagePath("AddPrinterByIpContent.qml"),
+ "next_page_id": "machine_actions",
+ },
+ {"id": "machine_actions",
+ "page_url": self._getBuiltinWelcomePagePath("FirstStartMachineActionsContent.qml"),
+ "next_page_id": "cloud",
+ "should_show_function": self.shouldShowMachineActions,
+ },
+ {"id": "cloud",
+ "page_url": self._getBuiltinWelcomePagePath("CloudContent.qml"),
+ },
+ ]
+
+ pages_to_show = all_pages_list
+ if show_whatsnew_only:
+ pages_to_show = list(filter(lambda x: x["id"] == "whats_new", all_pages_list))
+
+ self._pages = pages_to_show
+ self.setItems(self._pages)
+
+ # For convenience, inject the default "next" button text to each item if it's not present.
+ def setItems(self, items: List[Dict[str, Any]]) -> None:
+ for item in items:
+ if "next_page_button_text" not in item:
+ item["next_page_button_text"] = self._default_next_button_text
+
+ super().setItems(items)
+
+ # Indicates if the machine action panel should be shown by checking if there's any first start machine actions
+ # available.
+ def shouldShowMachineActions(self) -> bool:
+ global_stack = self._application.getMachineManager().activeMachine
+ if global_stack is None:
+ return False
+
+ definition_id = global_stack.definition.getId()
+ first_start_actions = self._application.getMachineActionManager().getFirstStartActions(definition_id)
+ return len([action for action in first_start_actions if action.needsUserInteraction()]) > 0
+
+ def addPage(self) -> None:
+ pass
+
+
+__all__ = ["WelcomePagesModel"]
diff --git a/cura/UI/WhatsNewPagesModel.py b/cura/UI/WhatsNewPagesModel.py
new file mode 100644
index 0000000000..5b968ae574
--- /dev/null
+++ b/cura/UI/WhatsNewPagesModel.py
@@ -0,0 +1,22 @@
+# Copyright (c) 2019 Ultimaker B.V.
+# Cura is released under the terms of the LGPLv3 or higher.
+
+from .WelcomePagesModel import WelcomePagesModel
+
+
+#
+# This Qt ListModel is more or less the same the WelcomePagesModel, except that this model is only for showing the
+# "what's new" page. This is also used in the "Help" menu to show the changes log.
+#
+class WhatsNewPagesModel(WelcomePagesModel):
+
+ def initialize(self) -> None:
+ self._pages = []
+ self._pages.append({"id": "whats_new",
+ "page_url": self._getBuiltinWelcomePagePath("WhatsNewContent.qml"),
+ "next_page_button_text": self._catalog.i18nc("@action:button", "Close"),
+ })
+ self.setItems(self._pages)
+
+
+__all__ = ["WhatsNewPagesModel"]
diff --git a/cura/UI/__init__.py b/cura/UI/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/cura/UI/__init__.py