diff --git a/release/datafiles/userdef/userdef_default.c b/release/datafiles/userdef/userdef_default.c index d62f95d83f6..b3a8ec5889d 100644 --- a/release/datafiles/userdef/userdef_default.c +++ b/release/datafiles/userdef/userdef_default.c @@ -34,7 +34,7 @@ const UserDef U_default = { .renderdir = "//", .render_cachedir = "", .textudir = "//", - .pythondir = "", + .script_directories = {NULL, NULL}, .sounddir = "//", .i18ndir = "", .image_editor = "", diff --git a/scripts/modules/bpy/utils/__init__.py b/scripts/modules/bpy/utils/__init__.py index 8865abab186..406016d82a0 100644 --- a/scripts/modules/bpy/utils/__init__.py +++ b/scripts/modules/bpy/utils/__init__.py @@ -30,7 +30,6 @@ __all__ = ( "previews", "resource_path", "script_path_user", - "script_path_pref", "script_paths", "smpte_from_frame", "smpte_from_seconds", @@ -340,10 +339,14 @@ def script_path_user(): return _os.path.normpath(path) if path else None -def script_path_pref(): - """returns the user preference or None""" - path = _preferences.filepaths.script_directory - return _os.path.normpath(path) if path else None +def script_paths_pref(): + """Returns a list of user preference script directories.""" + paths = [] + for script_directory in _preferences.filepaths.script_directories: + directory = script_directory.directory + if directory: + paths.append(_os.path.normpath(directory)) + return paths def script_paths(*, subdir=None, user_pref=True, check_all=False, use_user=True): @@ -384,9 +387,6 @@ def script_paths(*, subdir=None, user_pref=True, check_all=False, use_user=True) if use_user: base_paths.append(path_user) - if user_pref: - base_paths.append(script_path_pref()) - scripts = [] for path in base_paths: if not path: diff --git a/scripts/modules/sys_info.py b/scripts/modules/sys_info.py index 5bd38acb19c..368531d4ce2 100644 --- a/scripts/modules/sys_info.py +++ b/scripts/modules/sys_info.py @@ -88,7 +88,9 @@ def write_sysinfo(filepath): for p in bpy.utils.script_paths(): output.write("\t%r\n" % p) output.write("user scripts: %r\n" % (bpy.utils.script_path_user())) - output.write("pref scripts: %r\n" % (bpy.utils.script_path_pref())) + output.write("pref scripts:\n") + for p in bpy.utils.script_paths_pref(): + output.write("\t%r\n" % p) output.write("datafiles: %r\n" % (bpy.utils.user_resource('DATAFILES'))) output.write("config: %r\n" % (bpy.utils.user_resource('CONFIG'))) output.write("scripts : %r\n" % (bpy.utils.user_resource('SCRIPTS'))) diff --git a/scripts/startup/bl_operators/userpref.py b/scripts/startup/bl_operators/userpref.py index d0134bd076f..a6efa7f149a 100644 --- a/scripts/startup/bl_operators/userpref.py +++ b/scripts/startup/bl_operators/userpref.py @@ -587,12 +587,18 @@ class PREFERENCES_OT_addon_install(Operator): description="Remove existing add-ons with the same ID", default=True, ) + + def _target_path_items(_self, context): + paths = context.preferences.filepaths + return ( + ('DEFAULT', "Default", ""), + None, + *[(item.name, item.name, "") for index, item in enumerate(paths.script_directories) if item.directory], + ) + target: EnumProperty( name="Target Path", - items=( - ('DEFAULT', "Default", ""), - ('PREFS', "Preferences", ""), - ), + items=_target_path_items, ) filepath: StringProperty( @@ -626,9 +632,11 @@ class PREFERENCES_OT_addon_install(Operator): # Don't use `bpy.utils.script_paths(path="addons")` because we may not be able to write to it. path_addons = bpy.utils.user_resource('SCRIPTS', path="addons", create=True) else: - path_addons = context.preferences.filepaths.script_directory - if path_addons: - path_addons = os.path.join(path_addons, "addons") + paths = context.preferences.filepaths + for script_directory in paths.script_directories: + if script_directory.name == self.target: + path_addons = os.path.join(script_directory.directory, "addons") + break if not path_addons: self.report({'ERROR'}, "Failed to get add-ons path") @@ -1139,6 +1147,60 @@ class PREFERENCES_OT_studiolight_show(Operator): return {'FINISHED'} +class PREFERENCES_OT_script_directory_new(Operator): + bl_idname = "preferences.script_directory_add" + bl_label = "Add Python Script Directory" + + directory: StringProperty( + subtype='DIR_PATH', + ) + filter_folder: BoolProperty( + name="Filter Folders", + default=True, + options={'HIDDEN'}, + ) + + def execute(self, context): + import os + + script_directories = context.preferences.filepaths.script_directories + + new_dir = script_directories.new() + # Assign path selected via file browser. + new_dir.directory = self.directory + new_dir.name = os.path.basename(self.directory.rstrip(os.sep)) + + assert context.preferences.is_dirty == True + + return {'FINISHED'} + + def invoke(self, context, _event): + wm = context.window_manager + + wm.fileselect_add(self) + return {'RUNNING_MODAL'} + + +class PREFERENCES_OT_script_directory_remove(Operator): + bl_idname = "preferences.script_directory_remove" + bl_label = "Remove Python Script Directory" + + index: IntProperty( + name="Index", + description="Index of the script directory to remove", + ) + + def execute(self, context): + script_directories = context.preferences.filepaths.script_directories + for search_index, script_directory in enumerate(script_directories): + if search_index == self.index: + script_directories.remove(script_directory) + break + + assert context.preferences.is_dirty == True + + return {'FINISHED'} + classes = ( PREFERENCES_OT_addon_disable, PREFERENCES_OT_addon_enable, @@ -1164,4 +1226,6 @@ classes = ( PREFERENCES_OT_studiolight_uninstall, PREFERENCES_OT_studiolight_copy_settings, PREFERENCES_OT_studiolight_show, + PREFERENCES_OT_script_directory_new, + PREFERENCES_OT_script_directory_remove, ) diff --git a/scripts/startup/bl_ui/space_userpref.py b/scripts/startup/bl_ui/space_userpref.py index 8d1fa237754..fc4cc0e15b8 100644 --- a/scripts/startup/bl_ui/space_userpref.py +++ b/scripts/startup/bl_ui/space_userpref.py @@ -1333,11 +1333,52 @@ class USERPREF_PT_file_paths_data(FilePathsPanel, Panel): col = self.layout.column() col.prop(paths, "font_directory", text="Fonts") col.prop(paths, "texture_directory", text="Textures") - col.prop(paths, "script_directory", text="Scripts") col.prop(paths, "sound_directory", text="Sounds") col.prop(paths, "temporary_directory", text="Temporary Files") +class USERPREF_PT_file_paths_script_directories(FilePathsPanel, Panel): + bl_label = "Script Directories" + + def draw(self, context): + layout = self.layout + + paths = context.preferences.filepaths + + if len(paths.script_directories) == 0: + layout.operator("preferences.script_directory_add", text="Add", icon='ADD') + return + + layout.use_property_split = False + layout.use_property_decorate = False + + box = layout.box() + split = box.split(factor=0.35) + name_col = split.column() + path_col = split.column() + + row = name_col.row(align=True) # Padding + row.separator() + row.label(text="Name") + + row = path_col.row(align=True) # Padding + row.separator() + row.label(text="Path") + + row.operator("preferences.script_directory_add", text="", icon='ADD', emboss=False) + + for i, script_directory in enumerate(paths.script_directories): + row = name_col.row() + row.alert = not script_directory.name + row.prop(script_directory, "name", text="") + + row = path_col.row() + subrow = row.row() + subrow.alert = not script_directory.directory + subrow.prop(script_directory, "directory", text="") + row.operator("preferences.script_directory_remove", text="", icon='X', emboss=False).index = i + + class USERPREF_PT_file_paths_render(FilePathsPanel, Panel): bl_label = "Render" @@ -1878,7 +1919,7 @@ class USERPREF_PT_addons(AddOnPanel, Panel): if not user_addon_paths: for path in ( bpy.utils.script_path_user(), - bpy.utils.script_path_pref(), + *bpy.utils.script_paths_pref(), ): if path is not None: user_addon_paths.append(os.path.join(path, "addons")) @@ -1910,7 +1951,7 @@ class USERPREF_PT_addons(AddOnPanel, Panel): addon_user_dirs = tuple( p for p in ( - os.path.join(prefs.filepaths.script_directory, "addons"), + *[os.path.join(pref_p, "addons") for pref_p in bpy.utils.script_path_user()], bpy.utils.user_resource('SCRIPTS', path="addons"), ) if p @@ -2457,6 +2498,7 @@ classes = ( USERPREF_PT_theme_strip_colors, USERPREF_PT_file_paths_data, + USERPREF_PT_file_paths_script_directories, USERPREF_PT_file_paths_render, USERPREF_PT_file_paths_applications, USERPREF_PT_file_paths_development, diff --git a/source/blender/blenkernel/BKE_blender_version.h b/source/blender/blenkernel/BKE_blender_version.h index 6c85f234d57..cfd38d6167b 100644 --- a/source/blender/blenkernel/BKE_blender_version.h +++ b/source/blender/blenkernel/BKE_blender_version.h @@ -25,7 +25,7 @@ extern "C" { /* Blender file format version. */ #define BLENDER_FILE_VERSION BLENDER_VERSION -#define BLENDER_FILE_SUBVERSION 4 +#define BLENDER_FILE_SUBVERSION 5 /* Minimum Blender version that supports reading file written with the current * version. Older Blender versions will test this and show a warning if the file diff --git a/source/blender/blenkernel/intern/blender.c b/source/blender/blenkernel/intern/blender.c index 3598201d906..305ac5f62cc 100644 --- a/source/blender/blenkernel/intern/blender.c +++ b/source/blender/blenkernel/intern/blender.c @@ -296,6 +296,7 @@ void BKE_blender_userdef_data_free(UserDef *userdef, bool clear_fonts) } BLI_freelistN(&userdef->autoexec_paths); + BLI_freelistN(&userdef->script_directories); BLI_freelistN(&userdef->asset_libraries); BLI_freelistN(&userdef->uistyles); diff --git a/source/blender/blenloader/intern/readfile.cc b/source/blender/blenloader/intern/readfile.cc index a9353e8ba13..30e875f6f2d 100644 --- a/source/blender/blenloader/intern/readfile.cc +++ b/source/blender/blenloader/intern/readfile.cc @@ -3700,6 +3700,7 @@ static BHead *read_userdef(BlendFileData *bfd, FileData *fd, BHead *bhead) BLO_read_list(reader, &user->user_menus); BLO_read_list(reader, &user->addons); BLO_read_list(reader, &user->autoexec_paths); + BLO_read_list(reader, &user->script_directories); BLO_read_list(reader, &user->asset_libraries); LISTBASE_FOREACH (wmKeyMap *, keymap, &user->user_keymaps) { diff --git a/source/blender/blenloader/intern/versioning_userdef.c b/source/blender/blenloader/intern/versioning_userdef.c index a6e55f7b12d..9b7468636cf 100644 --- a/source/blender/blenloader/intern/versioning_userdef.c +++ b/source/blender/blenloader/intern/versioning_userdef.c @@ -31,8 +31,12 @@ #include "BLO_readfile.h" +#include "BLT_translation.h" + #include "GPU_platform.h" +#include "MEM_guardedalloc.h" + #include "readfile.h" /* Own include. */ #include "WM_types.h" @@ -798,6 +802,17 @@ void blo_do_versions_userdef(UserDef *userdef) } } + if (!USER_VERSION_ATLEAST(306, 5)) { + if (userdef->pythondir_legacy[0]) { + bUserScriptDirectory *script_dir = MEM_callocN(sizeof(*script_dir), + "Versioning user script path"); + + STRNCPY(script_dir->dir_path, userdef->pythondir_legacy); + STRNCPY(script_dir->name, DATA_("Untitled")); + BLI_addhead(&userdef->script_directories, script_dir); + } + } + /** * Versioning code until next subversion bump goes here. * diff --git a/source/blender/blenloader/intern/writefile.cc b/source/blender/blenloader/intern/writefile.cc index ca8c9f75b44..0b5eb9aac51 100644 --- a/source/blender/blenloader/intern/writefile.cc +++ b/source/blender/blenloader/intern/writefile.cc @@ -922,6 +922,10 @@ static void write_userdef(BlendWriter *writer, const UserDef *userdef) BLO_write_struct(writer, bPathCompare, path_cmp); } + LISTBASE_FOREACH (const bUserScriptDirectory *, script_dir, &userdef->script_directories) { + BLO_write_struct(writer, bUserScriptDirectory, script_dir); + } + LISTBASE_FOREACH (const bUserAssetLibrary *, asset_library_ref, &userdef->asset_libraries) { BLO_write_struct(writer, bUserAssetLibrary, asset_library_ref); } diff --git a/source/blender/editors/space_file/fsmenu.c b/source/blender/editors/space_file/fsmenu.c index 0e46a2eafe3..41b78ff3562 100644 --- a/source/blender/editors/space_file/fsmenu.c +++ b/source/blender/editors/space_file/fsmenu.c @@ -1033,7 +1033,14 @@ void fsmenu_read_system(struct FSMenu *fsmenu, int read_bookmarks) FS_UDIR_PATH(U.fontdir, ICON_FILE_FONT) FS_UDIR_PATH(U.textudir, ICON_FILE_IMAGE) - FS_UDIR_PATH(U.pythondir, ICON_FILE_SCRIPT) + LISTBASE_FOREACH (bUserScriptDirectory *, script_dir, &U.script_directories) { + fsmenu_insert_entry(fsmenu, + FS_CATEGORY_OTHER, + script_dir->dir_path, + script_dir->name, + ICON_FILE_SCRIPT, + FS_INSERT_LAST); + } FS_UDIR_PATH(U.sounddir, ICON_FILE_SOUND) FS_UDIR_PATH(U.tempdir, ICON_TEMP) diff --git a/source/blender/makesdna/DNA_userdef_types.h b/source/blender/makesdna/DNA_userdef_types.h index 1d8cb6f7baa..70aed0eab5b 100644 --- a/source/blender/makesdna/DNA_userdef_types.h +++ b/source/blender/makesdna/DNA_userdef_types.h @@ -679,6 +679,17 @@ typedef struct UserDef_Experimental { #define USER_EXPERIMENTAL_TEST(userdef, member) \ (((userdef)->flag & USER_DEVELOPER_UI) && ((userdef)->experimental).member) +/** + * Container to store multiple directory paths and a name for each as a #ListBase. + */ +typedef struct bUserScriptDirectory { + struct bUserScriptDirectory *next, *prev; + + /** Name must be unique. */ + char name[64]; /* MAX_NAME */ + char dir_path[768]; /* FILE_MAXDIR */ +} bUserScriptDirectory; + typedef struct UserDef { DNA_DEFINE_CXX_METHODS(UserDef) @@ -703,22 +714,8 @@ typedef struct UserDef { /** 768 = FILE_MAXDIR. */ char render_cachedir[768]; char textudir[768]; - /** - * Optional user location for scripts. - * - * This supports the same layout as Blender's scripts directory `scripts`. - * - * \note Unlike most paths, changing this is not fully supported at run-time, - * requiring a restart to properly take effect. Supporting this would cause complications as - * the script path can contain `startup`, `addons` & `modules` etc. properly unwinding the - * Python environment to the state it _would_ have been in gets complicated. - * - * Although this is partially supported as the `sys.path` is refreshed when loading preferences. - * This is done to support #PREFERENCES_OT_copy_prev which is available to the user when they - * launch with a new version of Blender. In this case setting the script path on top of - * factory settings will work without problems. - */ - char pythondir[768]; + /* Deprecated, use #UserDef.script_directories instead. */ + char pythondir_legacy[768] DNA_DEPRECATED; char sounddir[768]; char i18ndir[768]; /** 1024 = FILE_MAX. */ @@ -790,6 +787,22 @@ typedef struct UserDef { struct ListBase user_keyconfig_prefs; struct ListBase addons; struct ListBase autoexec_paths; + /** + * Optional user locations for Python scripts. + * + * This supports the same layout as Blender's scripts directory `scripts`. + * + * \note Unlike most paths, changing this is not fully supported at run-time, + * requiring a restart to properly take effect. Supporting this would cause complications as + * the script path can contain `startup`, `addons` & `modules` etc. properly unwinding the + * Python environment to the state it _would_ have been in gets complicated. + * + * Although this is partially supported as the `sys.path` is refreshed when loading preferences. + * This is done to support #PREFERENCES_OT_copy_prev which is available to the user when they + * launch with a new version of Blender. In this case setting the script path on top of + * factory settings will work without problems. + */ + ListBase script_directories; /* #bUserScriptDirectory */ /** #bUserMenu. */ struct ListBase user_menus; /** #bUserAssetLibrary */ diff --git a/source/blender/makesdna/intern/dna_rename_defs.h b/source/blender/makesdna/intern/dna_rename_defs.h index 3318603ffd3..d1fcd09231d 100644 --- a/source/blender/makesdna/intern/dna_rename_defs.h +++ b/source/blender/makesdna/intern/dna_rename_defs.h @@ -148,6 +148,7 @@ DNA_STRUCT_RENAME_ELEM(ThemeSpace, scrubbing_background, time_scrub_background) DNA_STRUCT_RENAME_ELEM(ThemeSpace, show_back_grad, background_type) DNA_STRUCT_RENAME_ELEM(UVProjectModifierData, num_projectors, projectors_num) DNA_STRUCT_RENAME_ELEM(UserDef, gp_manhattendist, gp_manhattandist) +DNA_STRUCT_RENAME_ELEM(UserDef, pythondir, pythondir_legacy) DNA_STRUCT_RENAME_ELEM(VFont, name, filepath) DNA_STRUCT_RENAME_ELEM(View3D, far, clip_end) DNA_STRUCT_RENAME_ELEM(View3D, near, clip_start) diff --git a/source/blender/makesrna/intern/rna_userdef.c b/source/blender/makesrna/intern/rna_userdef.c index e24c975bfa3..24b8b368551 100644 --- a/source/blender/makesrna/intern/rna_userdef.c +++ b/source/blender/makesrna/intern/rna_userdef.c @@ -150,6 +150,7 @@ static const EnumPropertyItem rna_enum_preference_gpu_backend_items[] = { #ifdef RNA_RUNTIME # include "BLI_math_vector.h" +# include "BLI_string_utils.h" # include "DNA_object_types.h" # include "DNA_screen_types.h" @@ -344,6 +345,52 @@ static void rna_userdef_script_autoexec_update(Main *UNUSED(bmain), USERDEF_TAG_DIRTY; } +static void rna_userdef_script_directory_name_set(PointerRNA *ptr, const char *value) +{ + bUserScriptDirectory *script_dir = ptr->data; + bool value_invalid = false; + + if (!value[0]) { + value_invalid = true; + } + if (STREQ(value, "DEFAULT")) { + value_invalid = true; + } + + if (value_invalid) { + value = DATA_("Untitled"); + } + + BLI_strncpy_utf8(script_dir->name, value, sizeof(script_dir->name)); + BLI_uniquename(&U.script_directories, + script_dir, + value, + '.', + offsetof(bUserScriptDirectory, name), + sizeof(script_dir->name)); +} + +static bUserScriptDirectory *rna_userdef_script_directory_new(void) +{ + bUserScriptDirectory *script_dir = MEM_callocN(sizeof(*script_dir), __func__); + BLI_addtail(&U.script_directories, script_dir); + USERDEF_TAG_DIRTY; + return script_dir; +} + +static void rna_userdef_script_directory_remove(ReportList *reports, PointerRNA *ptr) +{ + bUserScriptDirectory *script_dir = ptr->data; + if (BLI_findindex(&U.script_directories, script_dir) == -1) { + BKE_report(reports, RPT_ERROR, "Script directory not found"); + return; + } + + BLI_freelinkN(&U.script_directories, script_dir); + RNA_POINTER_INVALIDATE(ptr); + USERDEF_TAG_DIRTY; +} + static void rna_userdef_load_ui_update(Main *UNUSED(bmain), Scene *UNUSED(scene), PointerRNA *ptr) { UserDef *userdef = (UserDef *)ptr->data; @@ -6212,6 +6259,57 @@ static void rna_def_userdef_filepaths_asset_library(BlenderRNA *brna) RNA_def_property_update(prop, 0, "rna_userdef_update"); } +static void rna_def_userdef_script_directory(BlenderRNA *brna) +{ + StructRNA *srna = RNA_def_struct(brna, "ScriptDirectory", NULL); + RNA_def_struct_sdna(srna, "bUserScriptDirectory"); + RNA_def_struct_clear_flag(srna, STRUCT_UNDO); + RNA_def_struct_ui_text(srna, "Python Scripts Directory", ""); + + PropertyRNA *prop; + + prop = RNA_def_property(srna, "name", PROP_STRING, PROP_NONE); + RNA_def_property_ui_text(prop, "Name", "Identifier for the Python scripts directory"); + RNA_def_property_string_funcs(prop, NULL, NULL, "rna_userdef_script_directory_name_set"); + RNA_def_struct_name_property(srna, prop); + RNA_def_property_update(prop, 0, "rna_userdef_update"); + + prop = RNA_def_property(srna, "directory", PROP_STRING, PROP_DIRPATH); + RNA_def_property_string_sdna(prop, NULL, "dir_path"); + RNA_def_property_ui_text( + prop, + "Python Scripts Directory", + "Alternate script path, matching the default layout with sub-directories: startup, add-ons, " + "modules, and presets (requires restart)"); + /* TODO: editing should reset sys.path! */ +} + +static void rna_def_userdef_script_directory_collection(BlenderRNA *brna, PropertyRNA *cprop) +{ + StructRNA *srna; + FunctionRNA *func; + PropertyRNA *parm; + + RNA_def_property_srna(cprop, "ScriptDirectoryCollection"); + srna = RNA_def_struct(brna, "ScriptDirectoryCollection", NULL); + RNA_def_struct_clear_flag(srna, STRUCT_UNDO); + RNA_def_struct_ui_text(srna, "Python Scripts Directories", ""); + + func = RNA_def_function(srna, "new", "rna_userdef_script_directory_new"); + RNA_def_function_flag(func, FUNC_NO_SELF); + RNA_def_function_ui_description(func, "Add a new python script directory"); + /* return type */ + parm = RNA_def_pointer(func, "script_directory", "ScriptDirectory", "", ""); + RNA_def_function_return(func, parm); + + func = RNA_def_function(srna, "remove", "rna_userdef_script_directory_remove"); + RNA_def_function_flag(func, FUNC_NO_SELF | FUNC_USE_REPORTS); + RNA_def_function_ui_description(func, "Remove a python script directory"); + parm = RNA_def_pointer(func, "script_directory", "ScriptDirectory", "", ""); + RNA_def_parameter_flags(parm, PROP_NEVER_NULL, PARM_REQUIRED | PARM_RNAPTR); + RNA_def_parameter_clear_flags(parm, PROP_THICK_WRAP, 0); +} + static void rna_def_userdef_filepaths(BlenderRNA *brna) { PropertyRNA *prop; @@ -6311,14 +6409,12 @@ static void rna_def_userdef_filepaths(BlenderRNA *brna) "Render Output Directory", "The default directory for rendering output, for new scenes"); - prop = RNA_def_property(srna, "script_directory", PROP_STRING, PROP_DIRPATH); - RNA_def_property_string_sdna(prop, NULL, "pythondir"); - RNA_def_property_ui_text( - prop, - "Python Scripts Directory", - "Alternate script path, matching the default layout with subdirectories: " - "`startup`, `addons`, `modules`, and `presets` (requires restart)"); - /* TODO: editing should reset sys.path! */ + rna_def_userdef_script_directory(brna); + + prop = RNA_def_property(srna, "script_directories", PROP_COLLECTION, PROP_NONE); + RNA_def_property_struct_type(prop, "ScriptDirectory"); + RNA_def_property_ui_text(prop, "Python Scripts Directory", ""); + rna_def_userdef_script_directory_collection(brna, prop); prop = RNA_def_property(srna, "i18n_branches_directory", PROP_STRING, PROP_DIRPATH); RNA_def_property_string_sdna(prop, NULL, "i18ndir");