WelcomePagesModel.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343
  1. # Copyright (c) 2021 Ultimaker B.V.
  2. # Cura is released under the terms of the LGPLv3 or higher.
  3. import os
  4. from collections import deque
  5. from typing import TYPE_CHECKING, Optional, List, Dict, Any
  6. from PyQt5.QtCore import QUrl, Qt, pyqtSlot, pyqtProperty, pyqtSignal
  7. import cura.CuraApplication
  8. from UM.i18n import i18nCatalog
  9. from UM.Logger import Logger
  10. from UM.Qt.ListModel import ListModel
  11. from UM.Resources import Resources
  12. if TYPE_CHECKING:
  13. from PyQt5.QtCore import QObject
  14. from cura.CuraApplication import CuraApplication
  15. class WelcomePagesModel(ListModel):
  16. """
  17. This is the Qt ListModel that contains all welcome pages data. Each page is a page that can be shown as a step in
  18. the welcome wizard dialog. Each item in this ListModel represents a page, which contains the following fields:
  19. - id : A unique page_id which can be used in function goToPage(page_id)
  20. - page_url : The QUrl to the QML file that contains the content of this page
  21. - next_page_id : (OPTIONAL) The next page ID to go to when this page finished. This is optional. If this is
  22. not provided, it will go to the page with the current index + 1
  23. - next_page_button_text : (OPTIONAL) The text to show for the "next" button, by default it's the translated text of
  24. "Next". Note that each step QML can decide whether to use this text or not, so it's not
  25. mandatory.
  26. - should_show_function : (OPTIONAL) An optional function that returns True/False indicating if this page should be
  27. shown. By default all pages should be shown. If a function returns False, that page will
  28. be skipped and its next page will be shown.
  29. Note that in any case, a page that has its "should_show_function" == False will ALWAYS be skipped.
  30. """
  31. IdRole = Qt.UserRole + 1 # Page ID
  32. PageUrlRole = Qt.UserRole + 2 # URL to the page's QML file
  33. NextPageIdRole = Qt.UserRole + 3 # The next page ID it should go to
  34. NextPageButtonTextRole = Qt.UserRole + 4 # The text for the next page button
  35. PreviousPageButtonTextRole = Qt.UserRole + 5 # The text for the previous page button
  36. def __init__(self, application: Optional["CuraApplication"], parent: Optional["QObject"] = None) -> None:
  37. super().__init__(parent)
  38. self.addRoleName(self.IdRole, "id")
  39. self.addRoleName(self.PageUrlRole, "page_url")
  40. self.addRoleName(self.NextPageIdRole, "next_page_id")
  41. self.addRoleName(self.NextPageButtonTextRole, "next_page_button_text")
  42. self.addRoleName(self.PreviousPageButtonTextRole, "previous_page_button_text")
  43. if application is None:
  44. application = cura.CuraApplication.CuraApplication.getInstance()
  45. self._application = application
  46. self._catalog = i18nCatalog("cura")
  47. self._default_next_button_text = self._catalog.i18nc("@action:button", "Next")
  48. self._pages: List[Dict[str, Any]] = []
  49. self._current_page_index = 0
  50. # Store all the previous page indices so it can go back.
  51. self._previous_page_indices_stack: deque = deque()
  52. # If the welcome flow should be shown. It can show the complete flow or just the changelog depending on the
  53. # specific case. See initialize() for how this variable is set.
  54. self._should_show_welcome_flow = False
  55. if application.isQmlEngineInitialized():
  56. self.initialize()
  57. else:
  58. application.engineCreatedSignal.connect(self.initialize)
  59. allFinished = pyqtSignal() # emitted when all steps have been finished
  60. currentPageIndexChanged = pyqtSignal()
  61. @pyqtProperty(int, notify = currentPageIndexChanged)
  62. def currentPageIndex(self) -> int:
  63. return self._current_page_index
  64. @pyqtProperty(float, notify = currentPageIndexChanged)
  65. def currentProgress(self) -> float:
  66. """
  67. Returns a float number in [0, 1] which indicates the current progress.
  68. """
  69. if len(self._items) == 0:
  70. return 0
  71. else:
  72. return self._current_page_index / len(self._items)
  73. @pyqtProperty(bool, notify = currentPageIndexChanged)
  74. def isCurrentPageLast(self) -> bool:
  75. """
  76. Indicates if the current page is the last page.
  77. """
  78. return self._current_page_index == len(self._items) - 1
  79. def _setCurrentPageIndex(self, page_index: int) -> None:
  80. if page_index != self._current_page_index:
  81. self._previous_page_indices_stack.append(self._current_page_index)
  82. self._current_page_index = page_index
  83. self.currentPageIndexChanged.emit()
  84. @pyqtSlot()
  85. def atEnd(self) -> None:
  86. """
  87. Ends the Welcome-Pages. Put as a separate function for cases like the 'decline' in the User-Agreement.
  88. """
  89. self.allFinished.emit()
  90. self.resetState()
  91. @pyqtSlot()
  92. def goToNextPage(self, from_index: Optional[int] = None) -> None:
  93. """
  94. Goes to the next page.
  95. If "from_index" is given, it will look for the next page to show starting from the "from_index" page instead of
  96. the "self._current_page_index".
  97. """
  98. # Look for the next page that should be shown
  99. current_index = self._current_page_index if from_index is None else from_index
  100. while True:
  101. page_item = self._items[current_index]
  102. # Check if there's a "next_page_id" assigned. If so, go to that page. Otherwise, go to the page with the
  103. # current index + 1.
  104. next_page_id = page_item.get("next_page_id")
  105. next_page_index = current_index + 1
  106. if next_page_id:
  107. idx = self.getPageIndexById(next_page_id)
  108. if idx is None:
  109. # FIXME: If we cannot find the next page, we cannot do anything here.
  110. Logger.log("e", "Cannot find page with ID [%s]", next_page_id)
  111. return
  112. next_page_index = idx
  113. is_final_page = page_item.get("is_final_page")
  114. # If we have reached the last page, emit allFinished signal and reset.
  115. if next_page_index == len(self._items) or is_final_page:
  116. self.atEnd()
  117. return
  118. # Check if the this page should be shown (default yes), if not, keep looking for the next one.
  119. next_page_item = self.getItem(next_page_index)
  120. if self._shouldPageBeShown(next_page_index):
  121. break
  122. Logger.log("d", "Page [%s] should not be displayed, look for the next page.", next_page_item["id"])
  123. current_index = next_page_index
  124. # Move to the next page
  125. self._setCurrentPageIndex(next_page_index)
  126. @pyqtSlot()
  127. def goToPreviousPage(self) -> None:
  128. """
  129. Goes to the previous page. If there's no previous page, do nothing.
  130. """
  131. if len(self._previous_page_indices_stack) == 0:
  132. Logger.log("i", "No previous page, do nothing")
  133. return
  134. previous_page_index = self._previous_page_indices_stack.pop()
  135. self._current_page_index = previous_page_index
  136. self.currentPageIndexChanged.emit()
  137. @pyqtSlot(str)
  138. def goToPage(self, page_id: str) -> None:
  139. """Sets the current page to the given page ID. If the page ID is not found, do nothing."""
  140. page_index = self.getPageIndexById(page_id)
  141. if page_index is None:
  142. # FIXME: If we cannot find the next page, we cannot do anything here.
  143. Logger.log("e", "Cannot find page with ID [%s], go to the next page by default", page_index)
  144. self.goToNextPage()
  145. return
  146. if self._shouldPageBeShown(page_index):
  147. # Move to that page if it should be shown
  148. self._setCurrentPageIndex(page_index)
  149. else:
  150. # Find the next page to show starting from the "page_index"
  151. self.goToNextPage(from_index = page_index)
  152. def _shouldPageBeShown(self, page_index: int) -> bool:
  153. """
  154. Checks if the page with the given index should be shown by calling the "should_show_function" associated with
  155. it. If the function is not present, returns True (show page by default).
  156. """
  157. next_page_item = self.getItem(page_index)
  158. should_show_function = next_page_item.get("should_show_function", lambda: True)
  159. return should_show_function()
  160. @pyqtSlot()
  161. def resetState(self) -> None:
  162. """
  163. Resets the state of the WelcomePagesModel. This functions does the following:
  164. - Resets current_page_index to 0
  165. - Clears the previous page indices stack
  166. """
  167. self._current_page_index = 0
  168. self._previous_page_indices_stack.clear()
  169. self.currentPageIndexChanged.emit()
  170. shouldShowWelcomeFlowChanged = pyqtSignal()
  171. @pyqtProperty(bool, notify = shouldShowWelcomeFlowChanged)
  172. def shouldShowWelcomeFlow(self) -> bool:
  173. return self._should_show_welcome_flow
  174. def getPageIndexById(self, page_id: str) -> Optional[int]:
  175. """Gets the page index with the given page ID. If the page ID doesn't exist, returns None."""
  176. page_idx = None
  177. for idx, page_item in enumerate(self._items):
  178. if page_item["id"] == page_id:
  179. page_idx = idx
  180. break
  181. return page_idx
  182. @staticmethod
  183. def _getBuiltinWelcomePagePath(page_filename: str) -> QUrl:
  184. """Convenience function to get QUrl path to pages that's located in "resources/qml/WelcomePages"."""
  185. from cura.CuraApplication import CuraApplication
  186. return QUrl.fromLocalFile(Resources.getPath(CuraApplication.ResourceTypes.QmlFiles,
  187. os.path.join("WelcomePages", page_filename)))
  188. # FIXME: HACKs for optimization that we don't update the model every time the active machine gets changed.
  189. def _onActiveMachineChanged(self) -> None:
  190. self._application.getMachineManager().globalContainerChanged.disconnect(self._onActiveMachineChanged)
  191. self._initialize(update_should_show_flag = False)
  192. def initialize(self) -> None:
  193. self._application.getMachineManager().globalContainerChanged.connect(self._onActiveMachineChanged)
  194. self._initialize()
  195. def _initialize(self, update_should_show_flag: bool = True) -> None:
  196. show_whats_new_only = False
  197. if update_should_show_flag:
  198. has_active_machine = self._application.getMachineManager().activeMachine is not None
  199. has_app_just_upgraded = self._application.hasJustUpdatedFromOldVersion()
  200. # Only show the what's new dialog if there's no machine and we have just upgraded
  201. show_complete_flow = not has_active_machine
  202. show_whats_new_only = has_active_machine and has_app_just_upgraded
  203. # FIXME: This is a hack. Because of the circular dependency between MachineManager, ExtruderManager, and
  204. # possibly some others, setting the initial active machine is not done when the MachineManager gets
  205. # initialized. So at this point, we don't know if there will be an active machine or not. It could be that
  206. # the active machine files are corrupted so we cannot rely on Preferences either. This makes sure that once
  207. # the active machine gets changed, this model updates the flags, so it can decide whether to show the
  208. # welcome flow or not.
  209. should_show_welcome_flow = show_complete_flow or show_whats_new_only
  210. if should_show_welcome_flow != self._should_show_welcome_flow:
  211. self._should_show_welcome_flow = should_show_welcome_flow
  212. self.shouldShowWelcomeFlowChanged.emit()
  213. # All pages
  214. all_pages_list = [{"id": "welcome",
  215. "page_url": self._getBuiltinWelcomePagePath("WelcomeContent.qml"),
  216. },
  217. {"id": "user_agreement",
  218. "page_url": self._getBuiltinWelcomePagePath("UserAgreementContent.qml"),
  219. },
  220. {"id": "data_collections",
  221. "page_url": self._getBuiltinWelcomePagePath("DataCollectionsContent.qml"),
  222. },
  223. {"id": "cloud",
  224. "page_url": self._getBuiltinWelcomePagePath("CloudContent.qml"),
  225. "should_show_function": self.shouldShowCloudPage,
  226. },
  227. {"id": "add_network_or_local_printer",
  228. "page_url": self._getBuiltinWelcomePagePath("AddNetworkOrLocalPrinterContent.qml"),
  229. "next_page_id": "machine_actions",
  230. },
  231. {"id": "add_printer_by_ip",
  232. "page_url": self._getBuiltinWelcomePagePath("AddPrinterByIpContent.qml"),
  233. "next_page_id": "machine_actions",
  234. },
  235. {"id": "add_cloud_printers",
  236. "page_url": self._getBuiltinWelcomePagePath("AddCloudPrintersView.qml"),
  237. "next_page_button_text": self._catalog.i18nc("@action:button", "Next"),
  238. "next_page_id": "whats_new",
  239. },
  240. {"id": "machine_actions",
  241. "page_url": self._getBuiltinWelcomePagePath("FirstStartMachineActionsContent.qml"),
  242. "should_show_function": self.shouldShowMachineActions,
  243. },
  244. {"id": "whats_new",
  245. "page_url": self._getBuiltinWelcomePagePath("WhatsNewContent.qml"),
  246. "next_page_button_text": self._catalog.i18nc("@action:button", "Skip"),
  247. },
  248. {"id": "changelog",
  249. "page_url": self._getBuiltinWelcomePagePath("ChangelogContent.qml"),
  250. "next_page_button_text": self._catalog.i18nc("@action:button", "Finish"),
  251. },
  252. ]
  253. pages_to_show = all_pages_list
  254. if show_whats_new_only:
  255. pages_to_show = list(filter(lambda x: x["id"] == "whats_new", all_pages_list))
  256. self._pages = pages_to_show
  257. self.setItems(self._pages)
  258. def setItems(self, items: List[Dict[str, Any]]) -> None:
  259. # For convenience, inject the default "next" button text to each item if it's not present.
  260. for item in items:
  261. if "next_page_button_text" not in item:
  262. item["next_page_button_text"] = self._default_next_button_text
  263. super().setItems(items)
  264. def shouldShowMachineActions(self) -> bool:
  265. """
  266. Indicates if the machine action panel should be shown by checking if there's any first start machine actions
  267. available.
  268. """
  269. global_stack = self._application.getMachineManager().activeMachine
  270. if global_stack is None:
  271. return False
  272. definition_id = global_stack.definition.getId()
  273. first_start_actions = self._application.getMachineActionManager().getFirstStartActions(definition_id)
  274. return len([action for action in first_start_actions if action.needsUserInteraction()]) > 0
  275. def shouldShowCloudPage(self) -> bool:
  276. """
  277. The cloud page should be shown only if the user is not logged in
  278. :return: True if the user is not logged in, False if he/she is
  279. """
  280. # Import CuraApplication locally or else it fails
  281. from cura.CuraApplication import CuraApplication
  282. api = CuraApplication.getInstance().getCuraAPI()
  283. return not api.account.isLoggedIn
  284. def addPage(self) -> None:
  285. pass