-
Notifications
You must be signed in to change notification settings - Fork 40
/
CuraSettingsGuide.py
512 lines (451 loc) · 23.7 KB
/
CuraSettingsGuide.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
#Copyright (C) 2018 Aleksei Sasin
#Copyright (C) 2022 Ghostkeeper
#This plug-in is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
#This plug-in 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 Affero General Public License for details.
#You should have received a copy of the GNU Affero General Public License along with this plug-in. If not, see <https://gnu.org/licenses/>.
import os # To find the article files and other resources.
import urllib.parse # For unquote_plus to create preference keys for forms.
try:
from PyQt6.QtCore import pyqtSlot, pyqtProperty, pyqtSignal, QObject, QUrl # To expose data to the GUI and adjust the size of setting tooltips.
except ImportError: # Older version of Cura.
from PyQt5.QtCore import pyqtSlot, pyqtProperty, pyqtSignal, QObject, QUrl # In Cura 4.x, use Qt5 instead of Qt6.
import re # To get images from the descriptions.
import shutil # To copy the theme.
import threading # Screenshot refresh is done on a separate thread.
from typing import Dict, List
from cura.CuraApplication import CuraApplication # To get the setting version to load the correct definition file, and to create QML components.
from UM.Extension import Extension # We're implementing a Cura extension.
from UM.Logger import Logger
from UM.Job import Job # To load articles as a background task.
from UM.JobQueue import JobQueue # To load articles as a background task.
from UM.PluginRegistry import PluginRegistry # To find the path of the resources.
from UM.Qt.Bindings.PointingRectangle import PointingRectangle # To adjust the width of setting tooltips.
from UM.Resources import Resources # To find the themes in order to adjust them.
from UM.Settings.ContainerRegistry import ContainerRegistry # To register the non-setting entries.
from UM.Settings.DefinitionContainer import DefinitionContainer # To register the non-setting entries.
from UM.Version import Version # To parse Cura's version number.
from . import MenuItemHandler # To register the context menu item in the settings list.
from . import QtMarkdownRenderer # To match Mistune's output to Qt's supported HTML subset.
try:
from . import ScreenshotTool # To refresh screenshots using the Cura client.
has_screenshot_tool = True
except ImportError:
has_screenshot_tool = False
from .Mistune import mistune # To parse the Markdown files.
class CuraSettingsGuide(Extension, QObject):
"""
The main manager and entry point for the Cura Settings Guide.
This adds a menu item to the extensions menu and to the context menu of
every article. It creates a dialogue window to display the guide in, and
makes the menu items open said dialogue window.
This class is also exposed to the QML code, which can use it to request the
articles and metadata about the article.
"""
def __init__(self, parent=None) -> None:
"""
Executed during registration of the plug-in.
This adds a menu item to the extensions menu and the context menu of the
settings.
:param parent: The parent QObject this is located in. Unused by this
particular object.
"""
QObject.__init__(self, parent)
Extension.__init__(self)
self.setMenuName("Ѕettings Guide") # Using Cyrillic Ѕ instead of normal S to prevent MacOS detecting the word "setting" and pulling this menu item out of context.
self.addMenuItem("Ѕettings Guide", self.startWelcomeGuide)
self.addMenuItem("Рreferences", lambda: self.startWelcomeGuideAndSelectArticle("preferences"))
self._dialog = None # Cached instance of the dialogue window.
self.definition_container = None # Setting definitions that provide not only the normal settings but also the extra articles added by this guide.
self._markdown_per_folder = {} # For each directory containing Markdown files, create one renderer that correctly dereferences images relatively.
self.articles = {} # type: Dict[str, Dict[str, List[List[str]]]] # All of the articles by key and language. Key: article ID, value: Dict with language as key and lists of items in each article as value.
self.articles_rich_text = {} # type: Dict[str, Dict[str, str]] # For each article and language, the complete Rich Text that should get shown in the tooltip.
self.articles_source = {} # type: Dict[str, Dict[str, str]] # For each article and language, the original Markdown source the article is derived from.
self.load_definitions()
self.article_locations = self.find_articles()
self._selected_article_id = "" # Which article is currently shown for the user. Empty string indicates it's the welcome screen.
self.completed_loading = False # Only after loading is completed can we set the tooltips.
# Add context menu item to the settings list to open the guide for that setting.
application = CuraApplication.getInstance()
application.getCuraAPI().interface.settings.addContextMenuItem({
"name": "Settings Guide",
"icon_name": "help-contents",
"actions": ["sidebarMenuItemOnClickHandler"],
"menu_item": MenuItemHandler.MenuItemHandler(self)
})
preferences = application.getPreferences()
preferences.addPreference("settings_guide/language", "cura_default")
preferences.addPreference("settings_guide/show+articles+in+setting+tooltips+%28requires+restart%29", True)
preferences.addPreference("settings_guide/window+always+in+front", False)
preferences.addPreference("settings_guide/screenshot_tool", False)
self.adjust_theme()
application.initializationFinished.connect(self.load_all_in_background)
application.initializationFinished.connect(self.widen_tooltips)
application.globalContainerStackChanged.connect(self.set_tooltips)
def adjust_theme(self):
"""
Makes the tooltips wider, if displaying articles in the tooltips.
"""
# Previous versions of the Settings Guide may create a theme, which may become outdated in newer versions.
# We need to remove it and widen the tooltips in a different way.
application = CuraApplication.getInstance()
preferences = application.getPreferences()
preferences.addPreference("general/theme", application.default_theme) # May not exist yet at this point.
current_theme = preferences.getValue("general/theme")
if current_theme and current_theme.endswith("_settingsguideadjust"):
preferences.setValue("general/theme", current_theme[:-len("_settingsguideadjust")])
# Look for Settings Guide adjusted themes in the resources folder.
theme_path = os.path.dirname(Resources.getStoragePath(Resources.Themes, ""))
try:
for theme_folder in os.listdir(theme_path):
if theme_folder.endswith("_settingsguideadjust"):
shutil.rmtree(os.path.join(theme_path, theme_folder))
except EnvironmentError as e:
# Perhaps no rights? Well, just leave the extra theme in then. Nothing to be done about it.
Logger.error("Unable to remove Settings Guide deprecated theme: {err}".format(err=str(e)))
def widen_tooltips(self):
"""
Make tooltips wider, so that they are easier to read.
This really only works in Qt 5.14 and higher, so only Cura 4.9 and up can enjoy the wider tooltips.
"""
application = CuraApplication.getInstance()
if application.getPreferences().getValue("settings_guide/show+articles+in+setting+tooltips+%28requires+restart%29"):
try:
main_window = application._qml_engine.rootObjects()[0]
if main_window is not None:
tooltips = main_window.findChildren(PointingRectangle) # There are multiple instances of this. We can distinguish them by the meta class name.
for tooltip in tooltips:
if not tooltip.metaObject().className().startswith("PrintSetupTooltip"):
continue
tooltip.setWidth(tooltip.width() * 2.5)
except IndexError: # rootObjects() returned an empty list, meaning the main window failed to load.
pass # Let's skip widening the tooltips then.
def load_all_in_background(self):
"""
Runs the load_all() function as a background task.
"""
class ArticleLoadJob(Job):
"""
A background task that loads all articles of the guide.
"""
def __init__(self, guide):
"""
Creates the background task.
:param guide: The CuraSettingsGuide object which has the
function to call.
"""
super().__init__()
self.guide = guide
def run(self):
"""
Runs the background task.
Cura will call this function from a different thread.
"""
self.guide.load_all()
JobQueue.getInstance().add(ArticleLoadJob(self))
def load_all(self):
"""
Pre-cache all articles in all languages.
This is meant to run as a background task. This makes sure all setting
descriptions are loaded so that they can load faster next time the
article is requested. It also makes sure that the setting description
is correctly displayed (after a while).
"""
for article_id in self.article_locations:
for language in self.article_locations[article_id]:
self._getArticle(article_id, language) # Load articles one by one.
self.completed_loading = True
self.set_tooltips()
Logger.log("i", "Finished loading Settings Guide articles.")
def load_definitions(self):
"""
Load all the setting definitions into a custom definition container.
This definition container also contains extra entries for the articles
that are not settings.
The result is stored in `self.definition_container`.
"""
if self.definition_container:
return # Already done earlier. Don't re-load.
with open(os.path.join(os.path.dirname(__file__), "resources", "settings_guide_definitions.def.json")) as f:
definitions_serialised = f.read()
self.definition_container = DefinitionContainer("settings_guide_definitions")
self.definition_container.deserialize(definitions_serialised)
ContainerRegistry.getInstance().addContainer(self.definition_container)
def set_tooltips(self):
"""
Set the tooltips to the contents of the articles in the current
language.
"""
if not self.completed_loading:
return # This got triggered via the global stack changed signal before all articles completed loading. It will trigger again when loading is complete.
if not CuraApplication.getInstance().getPreferences().getValue("settings_guide/show+articles+in+setting+tooltips+%28requires+restart%29"):
return # User doesn't want tooltips changed.
language = CuraApplication.getInstance().getPreferences().getValue("settings_guide/language")
global_stack = CuraApplication.getInstance().getGlobalContainerStack()
if not global_stack:
return # Fail.
for article_id in self.articles_rich_text:
if article_id in global_stack.getAllKeys():
definition = global_stack.definition.findDefinitions(key=article_id)[0]
if language in self.articles_rich_text[article_id]:
definition._SettingDefinition__property_values["description"] = self.preprocess_tooltips(self.articles_rich_text[article_id][language])
elif "en_US" in self.articles_rich_text[article_id]: # Fall back to English. Or don't change it if the article doesn't exist.
definition._SettingDefinition__property_values["description"] = self.preprocess_tooltips(self.articles_rich_text[article_id]["en_US"])
def preprocess_tooltips(self, original_text):
"""
Preprocess the articles for display in tooltips.
The tooltips need slightly different formatting. This function transforms text for display in the tooltips.
:param original_text: Rich text that is being displayed in the main articles.
:return: Rich text that can be displayed in the tooltips.
"""
# Remove all hyperlinks from this text.
# Hyperlinks don't work when clicked on here (Cura's tooltip display code won't activate weblinks, and it can't open up the Settings Guide to display different articles).
# However they do break through having a different colour in system-styled tooltips in the setting visibility list.
remove_links_regex = re.compile(r"(?:<a href=\".*\">|</a>)")
return re.sub(remove_links_regex, "", original_text)
def find_articles(self):
"""
For each article and language, find where the Markdown file is
located.
:return: A nested dictionary mapping article ID to language to file
path.
"""
result = {}
# Find the English translations first.
for root, _, files in os.walk(os.path.join(os.path.dirname(__file__), "resources", "articles")):
for filename in files:
base_name, extension = os.path.splitext(filename)
if extension != ".md":
continue # Only interested in article files.
result[base_name] = {"en_US": os.path.join(root, filename)}
# Find the translated articles in the translations folder.
for language in os.listdir(os.path.join(os.path.dirname(__file__), "resources", "translations")):
language_path = os.path.join(os.path.dirname(__file__), "resources", "translations", language)
if not os.path.isdir(language_path):
continue # Not a translation folder.
for root, _, files in os.walk(language_path):
for filename in files:
base_name, extension = os.path.splitext(filename)
if extension != ".md":
continue # Only interested in article files.
if base_name not in result:
continue # Translation for an article that doesn't exist in English?
result[base_name][language] = os.path.join(root, filename)
return result
def load_window(self):
"""
Create the GUI window for the guide.
The window's QML element is stored in a field. There can only be one
instance at a time.
"""
self.load_definitions()
if self._dialog: # Dialogue already open.
self._dialog.requestActivate() # Bring focus to dialogue.
return
Logger.log("d", "Creating Settings Guide window.")
plugin_path = PluginRegistry.getInstance().getPluginPath(self.getPluginId())
if plugin_path is None:
plugin_path = os.path.dirname(__file__)
application = CuraApplication.getInstance()
version = Version(application.getVersion())
if application.getVersion() != "source" and version.getMajor() <= 4:
path = os.path.join(plugin_path, "resources", "qml_cura4", "SettingsGuide.qml")
else: # In Cura 5+ the QML interface changed significantly, and the settings layout too. We use a different set of QML files to use the new interface and to mimic the new look.
path = os.path.join(plugin_path, "resources", "qml", "SettingsGuide.qml")
self._dialog = application.createQmlComponent(path, {"manager": self})
if not self._dialog:
Logger.log("e", "Unable to create settings guide dialogue.")
def startWelcomeGuide(self) -> None:
"""
Opens the guide in the welcome page.
"""
self.load_window()
if not self._dialog:
return
self.setSelectedArticleId("") # Display welcome page.
self._dialog.show()
def startWelcomeGuideAndSelectArticle(self, article_key: str) -> None:
"""
Opens the guide and immediately selects a certain article.
:param article_key: The key of the article to show the guide of.
"""
self.load_window()
if not self._dialog:
return
self.currentArticleReset.emit()
self.setSelectedArticleId(article_key)
self._dialog.show()
currentArticleReset = pyqtSignal() #Signal to indicate that the article list should reset its current index.
def _getArticle(self, article_id, language="en_US") -> List[List[str]]:
"""
Gets the rich text of a specified article.
This function lazily loads an article from a file. If it's never been
loaded before the article gets parsed and stored. Otherwise it'll get
taken from the cache.
:param article_id: The ID of the article to get.
:param language: The language to get the article in.
:return: A list of article "parts". Each article part is a list, where
the first element indicates the type of part and the rest contains the
content. Possible types of parts are "rich_text", "images" or
"checkbox".
"""
if article_id in self.articles and language in self.articles[article_id]:
return self.articles[article_id][language]
images_path = os.path.join(os.path.dirname(__file__), "resources", "articles", "images")
try:
if language not in self.article_locations[article_id]:
language = "en_US" # Fall back to English if the preferred language is not available.
markdown_file = self.article_locations[article_id][language]
with open(markdown_file, encoding="utf-8") as f:
markdown_str = f.read()
images_path = os.path.dirname(markdown_file)
except (OSError, KeyError): # File doesn't exist or is otherwise not readable.
if self.definition_container and article_id in self.definition_container.getAllKeys():
markdown_str = self.definition_container.getProperty(article_id, "label") + "\n====\n"
markdown_str += "*" + self.definition_container.getProperty(article_id, "description") + "*" # Use the setting description as fallback.
else:
markdown_str = "There is no article on this topic."
# Store this unparsed for later.
if language not in self.articles_source:
self.articles_source[language] = {}
self.articles_source[language][article_id] = markdown_str
if images_path not in self._markdown_per_folder:
renderer = QtMarkdownRenderer.QtMarkdownRenderer(images_path)
self._markdown_per_folder[images_path] = mistune.Markdown(renderer=renderer) # Renders the Markdown articles into the subset of HTML supported by Qt.
# Pre-process so that comments and conditionals don't influence the business.
markdown_str = QtMarkdownRenderer.QtMarkdownRenderer.preprocess_conditionals(markdown_str)
markdown_str = QtMarkdownRenderer.QtMarkdownRenderer.preprocess_comments(markdown_str)
find_images = re.compile(r"!\[(.*)\]\(([^\)]+)\)")
find_checkboxes = re.compile(r"\[ \]\s*([^\n]+)")
image_description = None
parts = [] # type: List[List[str]] # List of items in the article. Each item starts with a type ID, and then a variable number of data items.
for index, part_between_images in enumerate(find_images.split(markdown_str)):
# The parts of the regex split alternate between text, image description and image URL.
if index % 3 == 0:
part_between_images = part_between_images.strip()
if part_between_images or index == 0:
parts_between_checkboxes = find_checkboxes.split(part_between_images)
for index2, part_between_checkboxes in enumerate(parts_between_checkboxes):
part_between_checkboxes = part_between_checkboxes.strip()
# The parts of the regex split alternate between text and checkbox description.
if index2 % 2 == 0:
if part_between_checkboxes:
rich_text = self._markdown_per_folder[images_path](part_between_checkboxes)
parts.append(["rich_text", rich_text])
else: # if index2 == 1:
preference_key = "settings_guide/" + urllib.parse.quote_plus(part_between_checkboxes).lower()
parts.append(["checkbox", preference_key, part_between_checkboxes])
elif index % 3 == 1:
image_description = mistune.markdown(part_between_images)
else: # if index % 3 == 2:
if image_description is not None:
if parts[-1][0] != "images": # List of images.
parts.append(["images"])
image_url = os.path.join(images_path, part_between_images)
parts[-1].append(QUrl.fromLocalFile(image_url).toString() + "|" + image_description)
image_description = None
if article_id not in self.articles:
self.articles[article_id] = {}
self.articles[article_id][language] = parts
if article_id not in self.articles_rich_text:
self.articles_rich_text[article_id] = {}
self.articles_rich_text[article_id][language] = self._markdown_per_folder[images_path](markdown_str)
return self.articles[article_id][language]
@pyqtSlot(str, result=bool)
def isArticleFile(self, filename: str) -> bool:
"""
Tests whether a file name is the file name of an existing article.
This test is used to determine if a link should refer to a different
article or to the internet.
:param filename: The file name to test for.
:return: True if the file name is the file name of an existing article,
or False if it isn't.
"""
return os.path.exists(filename)
@pyqtProperty(str, constant=True)
def pluginVersion(self) -> str:
"""
Get the version number of this plug-in.
This plug-in version is a semantic version number in three parts
("1.2.3").
:return: The version number of this plug-in.
"""
return self.getVersion()
selectedArticleChanged = pyqtSignal()
@pyqtSlot(str)
def setSelectedArticleId(self, article_key: str) -> None:
"""
Show a certain article in the dialogue.
:param article_key: The key of the article to display.
"""
if self._selected_article_id != article_key:
self._selected_article_id = article_key
self.selectedArticleChanged.emit()
@pyqtProperty(str, fset=setSelectedArticleId, notify=selectedArticleChanged)
def selectedArticleId(self) -> str:
"""
Returns the key of the article that is currently selected.
If no article has been selected (the logo is being shown), an empty
string is returned.
:return: The key of the article that is currently selected.
"""
return self._selected_article_id
@pyqtProperty("QVariantList", notify=selectedArticleChanged)
def selectedArticle(self) -> List[List[str]]:
"""
Returns the currently selected article.
This article data is a rich text document, properly formatted from the
Markdown files in the article directory. Each article is a list of
items, some of which are text and some of which are image lists.
:return: The the currently selected article.
"""
preferences = CuraApplication.getInstance().getPreferences()
language = preferences.getValue("settings_guide/language")
if language == "cura_default":
language = preferences.getValue("general/language")
return self._getArticle(self._selected_article_id, language)
@pyqtSlot(str, result="QVariantList")
def language_list(self, article_key: str) -> List[str]:
"""
Gets a list of available languages for a certain article.
:param article_key: The article ID to get the languages for.
:return: A list of language codes (e.g. [nl_NL, en_US]).
"""
if article_key not in self.article_locations:
return [] # We have no articles about this setting at all.
return list(self.article_locations[article_key].keys())
@pyqtSlot(str)
def set_language(self, language_code: str) -> None:
"""
Changes the viewing language.
:param language_code: The new language code.
"""
preferences = CuraApplication.getInstance().getPreferences()
preferences.setValue("settings_guide/language", language_code)
if preferences.getValue("settings_guide/show+articles+in+setting+tooltips+%28requires+restart%29"):
self.set_tooltips()
self.selectedArticleChanged.emit()
@pyqtSlot()
def refresh_screenshots(self) -> None:
"""
Refresh the screenshots on the current article.
This starts the process outlined in the ScreenshotTool class.
"""
if not has_screenshot_tool:
Logger.error("The screenshot tool is not installed in this version of the Settings Guide. Please use the version from the source repository.")
return
if self.selectedArticleId: # Refresh a particular article.
preferences = CuraApplication.getInstance().getPreferences()
language = preferences.getValue("settings_guide/language")
if language == "cura_default":
language = preferences.getValue("general/language")
refresh_job = threading.Thread(target=ScreenshotTool.refresh_screenshots, kwargs={"article_text": self.articles_source[language][self.selectedArticleId], "refreshed_set": set()})
refresh_job.start()
else: # Refresh everything.
refreshed_set = set() # Don't refresh the same image multiple times. Share the same set among all calls.
def refresh_everything():
Logger.info("Refreshing ALL screenshots.")
for article_per_language in self.articles_source.values():
for article in article_per_language.values():
ScreenshotTool.refresh_screenshots(article_text=article, refreshed_set=refreshed_set)
refresh_job = threading.Thread(target=refresh_everything)
refresh_job.start()