Merge branch 'blender-v2.93-release'
[blender.git] / release / scripts / startup / bl_operators / userpref.py
1 # ##### BEGIN GPL LICENSE BLOCK #####
2 #
3 #  This program is free software; you can redistribute it and/or
4 #  modify it under the terms of the GNU General Public License
5 #  as published by the Free Software Foundation; either version 2
6 #  of the License, or (at your option) any later version.
7 #
8 #  This program is distributed in the hope that it will be useful,
9 #  but WITHOUT ANY WARRANTY; without even the implied warranty of
10 #  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11 #  GNU General Public License for more details.
12 #
13 #  You should have received a copy of the GNU General Public License
14 #  along with this program; if not, write to the Free Software Foundation,
15 #  Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 #
17 # ##### END GPL LICENSE BLOCK #####
18
19 # <pep8 compliant>
20
21 import bpy
22 from bpy.types import (
23     Operator,
24     OperatorFileListElement
25 )
26 from bpy.props import (
27     BoolProperty,
28     EnumProperty,
29     IntProperty,
30     StringProperty,
31     CollectionProperty,
32 )
33
34 from bpy.app.translations import pgettext_tip as tip_
35
36
37 def _zipfile_root_namelist(file_to_extract):
38     # Return a list of root paths from zipfile.ZipFile.namelist.
39     import os
40     root_paths = []
41     for f in file_to_extract.namelist():
42         # Python's `zipfile` API always adds a separate at the end of directories.
43         # use `os.path.normpath` instead of `f.removesuffix(os.sep)`
44         # since paths could be stored as `./paths/./`.
45         #
46         # Note that `..` prefixed paths can exist in ZIP files but they don't write to parent directory when extracting.
47         # Nor do they pass the `os.sep not in f` test, this is important,
48         # otherwise `shutil.rmtree` below could made to remove directories outside the installation directory.
49         f = os.path.normpath(f)
50         if os.sep not in f:
51             root_paths.append(f)
52     return root_paths
53
54
55 def _module_filesystem_remove(path_base, module_name):
56     # Remove all Python modules with `module_name` in `base_path`.
57     # The `module_name` is expected to be a result from `_zipfile_root_namelist`.
58     import os
59     import shutil
60     module_name = os.path.splitext(module_name)[0]
61     for f in os.listdir(path_base):
62         f_base = os.path.splitext(f)[0]
63         if f_base == module_name:
64             f_full = os.path.join(path_base, f)
65             if os.path.isdir(f_full):
66                 shutil.rmtree(f_full)
67             else:
68                 os.remove(f_full)
69
70
71 class PREFERENCES_OT_keyconfig_activate(Operator):
72     bl_idname = "preferences.keyconfig_activate"
73     bl_label = "Activate Keyconfig"
74
75     filepath: StringProperty(
76         subtype='FILE_PATH',
77     )
78
79     def execute(self, _context):
80         if bpy.utils.keyconfig_set(self.filepath, report=self.report):
81             return {'FINISHED'}
82         else:
83             return {'CANCELLED'}
84
85
86 class PREFERENCES_OT_copy_prev(Operator):
87     """Copy settings from previous version"""
88     bl_idname = "preferences.copy_prev"
89     bl_label = "Copy Previous Settings"
90
91     @classmethod
92     def _old_version_path(cls, version):
93         return bpy.utils.resource_path('USER', version[0], version[1])
94
95     @classmethod
96     def previous_version(cls):
97         # Find config folder from previous version.
98         import os
99         version = bpy.app.version
100         version_new = ((version[0] * 100) + version[1])
101         version_old = ((version[0] * 100) + version[1]) - 1
102
103         # Special case, remove when the version is > 3.0.
104         if version_new == 300:
105             version_new = 294
106             version_old = 293
107         else:
108             print("TODO: remove exception!")
109         # End special case.
110
111         # Ensure we only try to copy files from a point release.
112         # The check below ensures the second numbers match.
113         while (version_new % 100) // 10 == (version_old % 100) // 10:
114             version_split = version_old // 100, version_old % 100
115             if os.path.isdir(cls._old_version_path(version_split)):
116                 return version_split
117             version_old = version_old - 1
118         return None
119
120     @classmethod
121     def _old_path(cls):
122         version_old = cls.previous_version()
123         return cls._old_version_path(version_old) if version_old else None
124
125     @classmethod
126     def _new_path(cls):
127         return bpy.utils.resource_path('USER')
128
129     @classmethod
130     def poll(cls, _context):
131         import os
132
133         old = cls._old_path()
134         new = cls._new_path()
135         if not old:
136             return False
137
138         # Disable operator in case config path is overridden with environment
139         # variable. That case has no automatic per-version configuration.
140         userconfig_path = os.path.normpath(bpy.utils.user_resource('CONFIG'))
141         new_userconfig_path = os.path.normpath(os.path.join(new, "config"))
142         if userconfig_path != new_userconfig_path:
143             return False
144
145         # Enable operator if new config path does not exist yet.
146         if os.path.isdir(old) and not os.path.isdir(new):
147             return True
148
149         # Enable operator also if there are no new user preference yet.
150         old_userpref = os.path.join(old, "config", "userpref.blend")
151         new_userpref = os.path.join(new, "config", "userpref.blend")
152         return os.path.isfile(old_userpref) and not os.path.isfile(new_userpref)
153
154     def execute(self, _context):
155         import shutil
156         shutil.copytree(self._old_path(), self._new_path(), dirs_exist_ok=True, symlinks=True)
157
158         # reload preferences and recent-files.txt
159         bpy.ops.wm.read_userpref()
160         bpy.ops.wm.read_history()
161
162         # don't loose users work if they open the splash later.
163         if bpy.data.is_saved is bpy.data.is_dirty is False:
164             bpy.ops.wm.read_homefile()
165         else:
166             self.report({'INFO'}, "Reload Start-Up file to restore settings")
167
168         return {'FINISHED'}
169
170
171 class PREFERENCES_OT_keyconfig_test(Operator):
172     """Test key configuration for conflicts"""
173     bl_idname = "preferences.keyconfig_test"
174     bl_label = "Test Key Configuration for Conflicts"
175
176     def execute(self, context):
177         from bpy_extras import keyconfig_utils
178
179         wm = context.window_manager
180         kc = wm.keyconfigs.default
181
182         if keyconfig_utils.keyconfig_test(kc):
183             print("CONFLICT")
184
185         return {'FINISHED'}
186
187
188 class PREFERENCES_OT_keyconfig_import(Operator):
189     """Import key configuration from a python script"""
190     bl_idname = "preferences.keyconfig_import"
191     bl_label = "Import Key Configuration..."
192
193     filepath: StringProperty(
194         subtype='FILE_PATH',
195         default="keymap.py",
196     )
197     filter_folder: BoolProperty(
198         name="Filter folders",
199         default=True,
200         options={'HIDDEN'},
201     )
202     filter_text: BoolProperty(
203         name="Filter text",
204         default=True,
205         options={'HIDDEN'},
206     )
207     filter_python: BoolProperty(
208         name="Filter python",
209         default=True,
210         options={'HIDDEN'},
211     )
212     keep_original: BoolProperty(
213         name="Keep Original",
214         description="Keep original file after copying to configuration folder",
215         default=True,
216     )
217
218     def execute(self, _context):
219         import os
220         from os.path import basename
221         import shutil
222
223         if not self.filepath:
224             self.report({'ERROR'}, "Filepath not set")
225             return {'CANCELLED'}
226
227         config_name = basename(self.filepath)
228
229         path = bpy.utils.user_resource('SCRIPTS', os.path.join("presets", "keyconfig"), create=True)
230         path = os.path.join(path, config_name)
231
232         try:
233             if self.keep_original:
234                 shutil.copy(self.filepath, path)
235             else:
236                 shutil.move(self.filepath, path)
237         except Exception as ex:
238             self.report({'ERROR'}, "Installing keymap failed: %s" % ex)
239             return {'CANCELLED'}
240
241         # sneaky way to check we're actually running the code.
242         if bpy.utils.keyconfig_set(path, report=self.report):
243             return {'FINISHED'}
244         else:
245             return {'CANCELLED'}
246
247     def invoke(self, context, _event):
248         wm = context.window_manager
249         wm.fileselect_add(self)
250         return {'RUNNING_MODAL'}
251
252 # This operator is also used by interaction presets saving - AddPresetBase
253
254
255 class PREFERENCES_OT_keyconfig_export(Operator):
256     """Export key configuration to a python script"""
257     bl_idname = "preferences.keyconfig_export"
258     bl_label = "Export Key Configuration..."
259
260     all: BoolProperty(
261         name="All Keymaps",
262         default=False,
263         description="Write all keymaps (not just user modified)",
264     )
265     filepath: StringProperty(
266         subtype='FILE_PATH',
267         default="keymap.py",
268     )
269     filter_folder: BoolProperty(
270         name="Filter folders",
271         default=True,
272         options={'HIDDEN'},
273     )
274     filter_text: BoolProperty(
275         name="Filter text",
276         default=True,
277         options={'HIDDEN'},
278     )
279     filter_python: BoolProperty(
280         name="Filter python",
281         default=True,
282         options={'HIDDEN'},
283     )
284
285     def execute(self, context):
286         from bl_keymap_utils.io import keyconfig_export_as_data
287
288         if not self.filepath:
289             raise Exception("Filepath not set")
290
291         if not self.filepath.endswith(".py"):
292             self.filepath += ".py"
293
294         wm = context.window_manager
295
296         keyconfig_export_as_data(
297             wm,
298             wm.keyconfigs.active,
299             self.filepath,
300             all_keymaps=self.all,
301         )
302
303         return {'FINISHED'}
304
305     def invoke(self, context, _event):
306         wm = context.window_manager
307         wm.fileselect_add(self)
308         return {'RUNNING_MODAL'}
309
310
311 class PREFERENCES_OT_keymap_restore(Operator):
312     """Restore key map(s)"""
313     bl_idname = "preferences.keymap_restore"
314     bl_label = "Restore Key Map(s)"
315
316     all: BoolProperty(
317         name="All Keymaps",
318         description="Restore all keymaps to default",
319     )
320
321     def execute(self, context):
322         wm = context.window_manager
323
324         if self.all:
325             for km in wm.keyconfigs.user.keymaps:
326                 km.restore_to_default()
327         else:
328             km = context.keymap
329             km.restore_to_default()
330
331         context.preferences.is_dirty = True
332         return {'FINISHED'}
333
334
335 class PREFERENCES_OT_keyitem_restore(Operator):
336     """Restore key map item"""
337     bl_idname = "preferences.keyitem_restore"
338     bl_label = "Restore Key Map Item"
339
340     item_id: IntProperty(
341         name="Item Identifier",
342         description="Identifier of the item to restore",
343     )
344
345     @classmethod
346     def poll(cls, context):
347         keymap = getattr(context, "keymap", None)
348         return keymap
349
350     def execute(self, context):
351         km = context.keymap
352         kmi = km.keymap_items.from_id(self.item_id)
353
354         if (not kmi.is_user_defined) and kmi.is_user_modified:
355             km.restore_item_to_default(kmi)
356
357         return {'FINISHED'}
358
359
360 class PREFERENCES_OT_keyitem_add(Operator):
361     """Add key map item"""
362     bl_idname = "preferences.keyitem_add"
363     bl_label = "Add Key Map Item"
364
365     def execute(self, context):
366         km = context.keymap
367
368         if km.is_modal:
369             km.keymap_items.new_modal("", 'A', 'PRESS')
370         else:
371             km.keymap_items.new("none", 'A', 'PRESS')
372
373         # clear filter and expand keymap so we can see the newly added item
374         if context.space_data.filter_text != "":
375             context.space_data.filter_text = ""
376             km.show_expanded_items = True
377             km.show_expanded_children = True
378
379         context.preferences.is_dirty = True
380         return {'FINISHED'}
381
382
383 class PREFERENCES_OT_keyitem_remove(Operator):
384     """Remove key map item"""
385     bl_idname = "preferences.keyitem_remove"
386     bl_label = "Remove Key Map Item"
387
388     item_id: IntProperty(
389         name="Item Identifier",
390         description="Identifier of the item to remove",
391     )
392
393     @classmethod
394     def poll(cls, context):
395         return hasattr(context, "keymap")
396
397     def execute(self, context):
398         km = context.keymap
399         kmi = km.keymap_items.from_id(self.item_id)
400         km.keymap_items.remove(kmi)
401
402         context.preferences.is_dirty = True
403         return {'FINISHED'}
404
405
406 class PREFERENCES_OT_keyconfig_remove(Operator):
407     """Remove key config"""
408     bl_idname = "preferences.keyconfig_remove"
409     bl_label = "Remove Key Config"
410
411     @classmethod
412     def poll(cls, context):
413         wm = context.window_manager
414         keyconf = wm.keyconfigs.active
415         return keyconf and keyconf.is_user_defined
416
417     def execute(self, context):
418         wm = context.window_manager
419         keyconfig = wm.keyconfigs.active
420         wm.keyconfigs.remove(keyconfig)
421         return {'FINISHED'}
422
423
424 # -----------------------------------------------------------------------------
425 # Add-on Operators
426
427 class PREFERENCES_OT_addon_enable(Operator):
428     """Enable an add-on"""
429     bl_idname = "preferences.addon_enable"
430     bl_label = "Enable Add-on"
431
432     module: StringProperty(
433         name="Module",
434         description="Module name of the add-on to enable",
435     )
436
437     def execute(self, _context):
438         import addon_utils
439
440         err_str = ""
441
442         def err_cb(ex):
443             import traceback
444             nonlocal err_str
445             err_str = traceback.format_exc()
446             print(err_str)
447
448         mod = addon_utils.enable(self.module, default_set=True, handle_error=err_cb)
449
450         if mod:
451             info = addon_utils.module_bl_info(mod)
452
453             info_ver = info.get("blender", (0, 0, 0))
454
455             if info_ver > bpy.app.version:
456                 self.report(
457                     {'WARNING'},
458                     "This script was written Blender "
459                     "version %d.%d.%d and might not "
460                     "function (correctly), "
461                     "though it is enabled" %
462                     info_ver
463                 )
464             return {'FINISHED'}
465         else:
466
467             if err_str:
468                 self.report({'ERROR'}, err_str)
469
470             return {'CANCELLED'}
471
472
473 class PREFERENCES_OT_addon_disable(Operator):
474     """Disable an add-on"""
475     bl_idname = "preferences.addon_disable"
476     bl_label = "Disable Add-on"
477
478     module: StringProperty(
479         name="Module",
480         description="Module name of the add-on to disable",
481     )
482
483     def execute(self, _context):
484         import addon_utils
485
486         err_str = ""
487
488         def err_cb(ex):
489             import traceback
490             nonlocal err_str
491             err_str = traceback.format_exc()
492             print(err_str)
493
494         addon_utils.disable(self.module, default_set=True, handle_error=err_cb)
495
496         if err_str:
497             self.report({'ERROR'}, err_str)
498
499         return {'FINISHED'}
500
501
502 class PREFERENCES_OT_theme_install(Operator):
503     """Load and apply a Blender XML theme file"""
504     bl_idname = "preferences.theme_install"
505     bl_label = "Install Theme..."
506
507     overwrite: BoolProperty(
508         name="Overwrite",
509         description="Remove existing theme file if exists",
510         default=True,
511     )
512     filepath: StringProperty(
513         subtype='FILE_PATH',
514     )
515     filter_folder: BoolProperty(
516         name="Filter folders",
517         default=True,
518         options={'HIDDEN'},
519     )
520     filter_glob: StringProperty(
521         default="*.xml",
522         options={'HIDDEN'},
523     )
524
525     def execute(self, _context):
526         import os
527         import shutil
528         import traceback
529
530         xmlfile = self.filepath
531
532         path_themes = bpy.utils.user_resource('SCRIPTS', "presets/interface_theme", create=True)
533
534         if not path_themes:
535             self.report({'ERROR'}, "Failed to get themes path")
536             return {'CANCELLED'}
537
538         path_dest = os.path.join(path_themes, os.path.basename(xmlfile))
539
540         if not self.overwrite:
541             if os.path.exists(path_dest):
542                 self.report({'WARNING'}, "File already installed to %r\n" % path_dest)
543                 return {'CANCELLED'}
544
545         try:
546             shutil.copyfile(xmlfile, path_dest)
547             bpy.ops.script.execute_preset(
548                 filepath=path_dest,
549                 menu_idname="USERPREF_MT_interface_theme_presets",
550             )
551
552         except:
553             traceback.print_exc()
554             return {'CANCELLED'}
555
556         return {'FINISHED'}
557
558     def invoke(self, context, _event):
559         wm = context.window_manager
560         wm.fileselect_add(self)
561         return {'RUNNING_MODAL'}
562
563
564 class PREFERENCES_OT_addon_refresh(Operator):
565     """Scan add-on directories for new modules"""
566     bl_idname = "preferences.addon_refresh"
567     bl_label = "Refresh"
568
569     def execute(self, _context):
570         import addon_utils
571
572         addon_utils.modules_refresh()
573
574         return {'FINISHED'}
575
576
577 # Note: shares some logic with PREFERENCES_OT_app_template_install
578 # but not enough to de-duplicate. Fixed here may apply to both.
579 class PREFERENCES_OT_addon_install(Operator):
580     """Install an add-on"""
581     bl_idname = "preferences.addon_install"
582     bl_label = "Install Add-on"
583
584     overwrite: BoolProperty(
585         name="Overwrite",
586         description="Remove existing add-ons with the same ID",
587         default=True,
588     )
589     target: EnumProperty(
590         name="Target Path",
591         items=(
592             ('DEFAULT', "Default", ""),
593             ('PREFS', "Preferences", ""),
594         ),
595     )
596
597     filepath: StringProperty(
598         subtype='FILE_PATH',
599     )
600     filter_folder: BoolProperty(
601         name="Filter folders",
602         default=True,
603         options={'HIDDEN'},
604     )
605     filter_python: BoolProperty(
606         name="Filter python",
607         default=True,
608         options={'HIDDEN'},
609     )
610     filter_glob: StringProperty(
611         default="*.py;*.zip",
612         options={'HIDDEN'},
613     )
614
615     def execute(self, context):
616         import addon_utils
617         import traceback
618         import zipfile
619         import shutil
620         import os
621
622         pyfile = self.filepath
623
624         if self.target == 'DEFAULT':
625             # don't use bpy.utils.script_paths("addons") because we may not be able to write to it.
626             path_addons = bpy.utils.user_resource('SCRIPTS', "addons", create=True)
627         else:
628             path_addons = context.preferences.filepaths.script_directory
629             if path_addons:
630                 path_addons = os.path.join(path_addons, "addons")
631
632         if not path_addons:
633             self.report({'ERROR'}, "Failed to get add-ons path")
634             return {'CANCELLED'}
635
636         if not os.path.isdir(path_addons):
637             try:
638                 os.makedirs(path_addons, exist_ok=True)
639             except:
640                 traceback.print_exc()
641
642         # Check if we are installing from a target path,
643         # doing so causes 2+ addons of same name or when the same from/to
644         # location is used, removal of the file!
645         addon_path = ""
646         pyfile_dir = os.path.dirname(pyfile)
647         for addon_path in addon_utils.paths():
648             if os.path.samefile(pyfile_dir, addon_path):
649                 self.report({'ERROR'}, "Source file is in the add-on search path: %r" % addon_path)
650                 return {'CANCELLED'}
651         del addon_path
652         del pyfile_dir
653         # done checking for exceptional case
654
655         addons_old = {mod.__name__ for mod in addon_utils.modules()}
656
657         # check to see if the file is in compressed format (.zip)
658         if zipfile.is_zipfile(pyfile):
659             try:
660                 file_to_extract = zipfile.ZipFile(pyfile, 'r')
661             except:
662                 traceback.print_exc()
663                 return {'CANCELLED'}
664
665             file_to_extract_root = _zipfile_root_namelist(file_to_extract)
666             if self.overwrite:
667                 for f in file_to_extract_root:
668                     _module_filesystem_remove(path_addons, f)
669             else:
670                 for f in file_to_extract_root:
671                     path_dest = os.path.join(path_addons, os.path.basename(f))
672                     if os.path.exists(path_dest):
673                         self.report({'WARNING'}, "File already installed to %r\n" % path_dest)
674                         return {'CANCELLED'}
675
676             try:  # extract the file to "addons"
677                 file_to_extract.extractall(path_addons)
678             except:
679                 traceback.print_exc()
680                 return {'CANCELLED'}
681
682         else:
683             path_dest = os.path.join(path_addons, os.path.basename(pyfile))
684
685             if self.overwrite:
686                 _module_filesystem_remove(path_addons, os.path.basename(pyfile))
687             elif os.path.exists(path_dest):
688                 self.report({'WARNING'}, "File already installed to %r\n" % path_dest)
689                 return {'CANCELLED'}
690
691             # if not compressed file just copy into the addon path
692             try:
693                 shutil.copyfile(pyfile, path_dest)
694             except:
695                 traceback.print_exc()
696                 return {'CANCELLED'}
697
698         addons_new = {mod.__name__ for mod in addon_utils.modules()} - addons_old
699         addons_new.discard("modules")
700
701         # disable any addons we may have enabled previously and removed.
702         # this is unlikely but do just in case. bug T23978.
703         for new_addon in addons_new:
704             addon_utils.disable(new_addon, default_set=True)
705
706         # possible the zip contains multiple addons, we could disallow this
707         # but for now just use the first
708         for mod in addon_utils.modules(refresh=False):
709             if mod.__name__ in addons_new:
710                 info = addon_utils.module_bl_info(mod)
711
712                 # show the newly installed addon.
713                 context.preferences.view.show_addons_enabled_only = False
714                 context.window_manager.addon_filter = 'All'
715                 context.window_manager.addon_search = info["name"]
716                 break
717
718         # in case a new module path was created to install this addon.
719         bpy.utils.refresh_script_paths()
720
721         # print message
722         msg = (
723             tip_("Modules Installed (%s) from %r into %r") %
724             (", ".join(sorted(addons_new)), pyfile, path_addons)
725         )
726         print(msg)
727         self.report({'INFO'}, msg)
728
729         return {'FINISHED'}
730
731     def invoke(self, context, _event):
732         wm = context.window_manager
733         wm.fileselect_add(self)
734         return {'RUNNING_MODAL'}
735
736
737 class PREFERENCES_OT_addon_remove(Operator):
738     """Delete the add-on from the file system"""
739     bl_idname = "preferences.addon_remove"
740     bl_label = "Remove Add-on"
741
742     module: StringProperty(
743         name="Module",
744         description="Module name of the add-on to remove",
745     )
746
747     @staticmethod
748     def path_from_addon(module):
749         import os
750         import addon_utils
751
752         for mod in addon_utils.modules():
753             if mod.__name__ == module:
754                 filepath = mod.__file__
755                 if os.path.exists(filepath):
756                     if os.path.splitext(os.path.basename(filepath))[0] == "__init__":
757                         return os.path.dirname(filepath), True
758                     else:
759                         return filepath, False
760         return None, False
761
762     def execute(self, context):
763         import addon_utils
764         import os
765
766         path, isdir = PREFERENCES_OT_addon_remove.path_from_addon(self.module)
767         if path is None:
768             self.report({'WARNING'}, "Add-on path %r could not be found" % path)
769             return {'CANCELLED'}
770
771         # in case its enabled
772         addon_utils.disable(self.module, default_set=True)
773
774         import shutil
775         if isdir and (not os.path.islink(path)):
776             shutil.rmtree(path)
777         else:
778             os.remove(path)
779
780         addon_utils.modules_refresh()
781
782         context.area.tag_redraw()
783         return {'FINISHED'}
784
785     # lame confirmation check
786     def draw(self, _context):
787         self.layout.label(text="Remove Add-on: %r?" % self.module)
788         path, _isdir = PREFERENCES_OT_addon_remove.path_from_addon(self.module)
789         self.layout.label(text="Path: %r" % path)
790
791     def invoke(self, context, _event):
792         wm = context.window_manager
793         return wm.invoke_props_dialog(self, width=600)
794
795
796 class PREFERENCES_OT_addon_expand(Operator):
797     """Display information and preferences for this add-on"""
798     bl_idname = "preferences.addon_expand"
799     bl_label = ""
800     bl_options = {'INTERNAL'}
801
802     module: StringProperty(
803         name="Module",
804         description="Module name of the add-on to expand",
805     )
806
807     def execute(self, _context):
808         import addon_utils
809
810         module_name = self.module
811
812         mod = addon_utils.addons_fake_modules.get(module_name)
813         if mod is not None:
814             info = addon_utils.module_bl_info(mod)
815             info["show_expanded"] = not info["show_expanded"]
816
817         return {'FINISHED'}
818
819
820 class PREFERENCES_OT_addon_show(Operator):
821     """Show add-on preferences"""
822     bl_idname = "preferences.addon_show"
823     bl_label = ""
824     bl_options = {'INTERNAL'}
825
826     module: StringProperty(
827         name="Module",
828         description="Module name of the add-on to expand",
829     )
830
831     def execute(self, context):
832         import addon_utils
833
834         module_name = self.module
835
836         _modules = addon_utils.modules(refresh=False)
837         mod = addon_utils.addons_fake_modules.get(module_name)
838         if mod is not None:
839             info = addon_utils.module_bl_info(mod)
840             info["show_expanded"] = True
841
842             context.preferences.active_section = 'ADDONS'
843             context.preferences.view.show_addons_enabled_only = False
844             context.window_manager.addon_filter = 'All'
845             context.window_manager.addon_search = info["name"]
846             bpy.ops.screen.userpref_show('INVOKE_DEFAULT')
847
848         return {'FINISHED'}
849
850
851 # Note: shares some logic with PREFERENCES_OT_addon_install
852 # but not enough to de-duplicate. Fixes here may apply to both.
853 class PREFERENCES_OT_app_template_install(Operator):
854     """Install an application template"""
855     bl_idname = "preferences.app_template_install"
856     bl_label = "Install Template from File..."
857
858     overwrite: BoolProperty(
859         name="Overwrite",
860         description="Remove existing template with the same ID",
861         default=True,
862     )
863
864     filepath: StringProperty(
865         subtype='FILE_PATH',
866     )
867     filter_folder: BoolProperty(
868         name="Filter folders",
869         default=True,
870         options={'HIDDEN'},
871     )
872     filter_glob: StringProperty(
873         default="*.zip",
874         options={'HIDDEN'},
875     )
876
877     def execute(self, _context):
878         import traceback
879         import zipfile
880         import os
881
882         filepath = self.filepath
883
884         path_app_templates = bpy.utils.user_resource(
885             'SCRIPTS', os.path.join("startup", "bl_app_templates_user"),
886             create=True,
887         )
888
889         if not path_app_templates:
890             self.report({'ERROR'}, "Failed to get add-ons path")
891             return {'CANCELLED'}
892
893         if not os.path.isdir(path_app_templates):
894             try:
895                 os.makedirs(path_app_templates, exist_ok=True)
896             except:
897                 traceback.print_exc()
898
899         app_templates_old = set(os.listdir(path_app_templates))
900
901         # check to see if the file is in compressed format (.zip)
902         if zipfile.is_zipfile(filepath):
903             try:
904                 file_to_extract = zipfile.ZipFile(filepath, 'r')
905             except:
906                 traceback.print_exc()
907                 return {'CANCELLED'}
908
909             file_to_extract_root = _zipfile_root_namelist(file_to_extract)
910             if self.overwrite:
911                 for f in file_to_extract_root:
912                     _module_filesystem_remove(path_app_templates, f)
913             else:
914                 for f in file_to_extract_root:
915                     path_dest = os.path.join(path_app_templates, os.path.basename(f))
916                     if os.path.exists(path_dest):
917                         self.report({'WARNING'}, "File already installed to %r\n" % path_dest)
918                         return {'CANCELLED'}
919
920             try:  # extract the file to "bl_app_templates_user"
921                 file_to_extract.extractall(path_app_templates)
922             except:
923                 traceback.print_exc()
924                 return {'CANCELLED'}
925
926         else:
927             # Only support installing zipfiles
928             self.report({'WARNING'}, "Expected a zip-file %r\n" % filepath)
929             return {'CANCELLED'}
930
931         app_templates_new = set(os.listdir(path_app_templates)) - app_templates_old
932
933         # in case a new module path was created to install this addon.
934         bpy.utils.refresh_script_paths()
935
936         # print message
937         msg = (
938             tip_("Template Installed (%s) from %r into %r") %
939             (", ".join(sorted(app_templates_new)), filepath, path_app_templates)
940         )
941         print(msg)
942         self.report({'INFO'}, msg)
943
944         return {'FINISHED'}
945
946     def invoke(self, context, _event):
947         wm = context.window_manager
948         wm.fileselect_add(self)
949         return {'RUNNING_MODAL'}
950
951
952 # -----------------------------------------------------------------------------
953 # Studio Light Operations
954
955 class PREFERENCES_OT_studiolight_install(Operator):
956     """Install a user defined light"""
957     bl_idname = "preferences.studiolight_install"
958     bl_label = "Install Light"
959
960     files: CollectionProperty(
961         name="File Path",
962         type=OperatorFileListElement,
963     )
964     directory: StringProperty(
965         subtype='DIR_PATH',
966     )
967     filter_folder: BoolProperty(
968         name="Filter Folders",
969         default=True,
970         options={'HIDDEN'},
971     )
972     filter_glob: StringProperty(
973         default="*.png;*.jpg;*.hdr;*.exr",
974         options={'HIDDEN'},
975     )
976     type: EnumProperty(
977         name="Type",
978         items=(
979             ('MATCAP', "MatCap", "Install custom MatCaps"),
980             ('WORLD', "World", "Install custom HDRIs"),
981             ('STUDIO', "Studio", "Install custom Studio Lights"),
982         )
983     )
984
985     def execute(self, context):
986         import os
987         import shutil
988         prefs = context.preferences
989
990         path_studiolights = os.path.join("studiolights", self.type.lower())
991         path_studiolights = bpy.utils.user_resource('DATAFILES', path_studiolights, create=True)
992         if not path_studiolights:
993             self.report({'ERROR'}, "Failed to create Studio Light path")
994             return {'CANCELLED'}
995
996         for e in self.files:
997             shutil.copy(os.path.join(self.directory, e.name), path_studiolights)
998             prefs.studio_lights.load(os.path.join(path_studiolights, e.name), self.type)
999
1000         # print message
1001         msg = (
1002             tip_("StudioLight Installed %r into %r") %
1003             (", ".join(e.name for e in self.files), path_studiolights)
1004         )
1005         print(msg)
1006         self.report({'INFO'}, msg)
1007         return {'FINISHED'}
1008
1009     def invoke(self, context, _event):
1010         wm = context.window_manager
1011
1012         if self.type == 'STUDIO':
1013             self.filter_glob = "*.sl"
1014
1015         wm.fileselect_add(self)
1016         return {'RUNNING_MODAL'}
1017
1018
1019 class PREFERENCES_OT_studiolight_new(Operator):
1020     """Save custom studio light from the studio light editor settings"""
1021     bl_idname = "preferences.studiolight_new"
1022     bl_label = "Save Custom Studio Light"
1023
1024     filename: StringProperty(
1025         name="Name",
1026         default="StudioLight",
1027     )
1028
1029     ask_override = False
1030
1031     def execute(self, context):
1032         import os
1033         prefs = context.preferences
1034         wm = context.window_manager
1035         filename = bpy.path.ensure_ext(self.filename, ".sl")
1036
1037         path_studiolights = bpy.utils.user_resource('DATAFILES', os.path.join("studiolights", "studio"), create=True)
1038         if not path_studiolights:
1039             self.report({'ERROR'}, "Failed to get Studio Light path")
1040             return {'CANCELLED'}
1041
1042         filepath_final = os.path.join(path_studiolights, filename)
1043         if os.path.isfile(filepath_final):
1044             if not self.ask_override:
1045                 self.ask_override = True
1046                 return wm.invoke_props_dialog(self, width=320)
1047             else:
1048                 for studio_light in prefs.studio_lights:
1049                     if studio_light.name == filename:
1050                         bpy.ops.preferences.studiolight_uninstall(index=studio_light.index)
1051
1052         prefs.studio_lights.new(path=filepath_final)
1053
1054         # print message
1055         msg = (
1056             tip_("StudioLight Installed %r into %r") %
1057             (self.filename, str(path_studiolights))
1058         )
1059         print(msg)
1060         self.report({'INFO'}, msg)
1061         return {'FINISHED'}
1062
1063     def draw(self, _context):
1064         layout = self.layout
1065         if self.ask_override:
1066             layout.label(text="Warning, file already exists. Overwrite existing file?")
1067         else:
1068             layout.prop(self, "filename")
1069
1070     def invoke(self, context, _event):
1071         wm = context.window_manager
1072         return wm.invoke_props_dialog(self, width=320)
1073
1074
1075 class PREFERENCES_OT_studiolight_uninstall(Operator):
1076     """Delete Studio Light"""
1077     bl_idname = "preferences.studiolight_uninstall"
1078     bl_label = "Uninstall Studio Light"
1079     index: IntProperty()
1080
1081     def execute(self, context):
1082         import os
1083         prefs = context.preferences
1084         for studio_light in prefs.studio_lights:
1085             if studio_light.index == self.index:
1086                 for filepath in (
1087                         studio_light.path,
1088                         studio_light.path_irr_cache,
1089                         studio_light.path_sh_cache,
1090                 ):
1091                     if filepath and os.path.exists(filepath):
1092                         os.unlink(filepath)
1093                 prefs.studio_lights.remove(studio_light)
1094                 return {'FINISHED'}
1095         return {'CANCELLED'}
1096
1097
1098 class PREFERENCES_OT_studiolight_copy_settings(Operator):
1099     """Copy Studio Light settings to the Studio Light editor"""
1100     bl_idname = "preferences.studiolight_copy_settings"
1101     bl_label = "Copy Studio Light Settings"
1102     index: IntProperty()
1103
1104     def execute(self, context):
1105         prefs = context.preferences
1106         system = prefs.system
1107         for studio_light in prefs.studio_lights:
1108             if studio_light.index == self.index:
1109                 system.light_ambient = studio_light.light_ambient
1110                 for sys_light, light in zip(system.solid_lights, studio_light.solid_lights):
1111                     sys_light.use = light.use
1112                     sys_light.diffuse_color = light.diffuse_color
1113                     sys_light.specular_color = light.specular_color
1114                     sys_light.smooth = light.smooth
1115                     sys_light.direction = light.direction
1116                 return {'FINISHED'}
1117         return {'CANCELLED'}
1118
1119
1120 class PREFERENCES_OT_studiolight_show(Operator):
1121     """Show light preferences"""
1122     bl_idname = "preferences.studiolight_show"
1123     bl_label = ""
1124     bl_options = {'INTERNAL'}
1125
1126     def execute(self, context):
1127         context.preferences.active_section = 'LIGHTS'
1128         bpy.ops.screen.userpref_show('INVOKE_DEFAULT')
1129         return {'FINISHED'}
1130
1131
1132 classes = (
1133     PREFERENCES_OT_addon_disable,
1134     PREFERENCES_OT_addon_enable,
1135     PREFERENCES_OT_addon_expand,
1136     PREFERENCES_OT_addon_install,
1137     PREFERENCES_OT_addon_refresh,
1138     PREFERENCES_OT_addon_remove,
1139     PREFERENCES_OT_addon_show,
1140     PREFERENCES_OT_app_template_install,
1141     PREFERENCES_OT_copy_prev,
1142     PREFERENCES_OT_keyconfig_activate,
1143     PREFERENCES_OT_keyconfig_export,
1144     PREFERENCES_OT_keyconfig_import,
1145     PREFERENCES_OT_keyconfig_remove,
1146     PREFERENCES_OT_keyconfig_test,
1147     PREFERENCES_OT_keyitem_add,
1148     PREFERENCES_OT_keyitem_remove,
1149     PREFERENCES_OT_keyitem_restore,
1150     PREFERENCES_OT_keymap_restore,
1151     PREFERENCES_OT_theme_install,
1152     PREFERENCES_OT_studiolight_install,
1153     PREFERENCES_OT_studiolight_new,
1154     PREFERENCES_OT_studiolight_uninstall,
1155     PREFERENCES_OT_studiolight_copy_settings,
1156     PREFERENCES_OT_studiolight_show,
1157 )