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