1 # SPDX-FileCopyrightText: 2023 Blender Foundation
3 # SPDX-License-Identifier: GPL-2.0-or-later
6 GUI (WARNING) this is a hack!
7 Written to allow a UI without modifying Blender.
18 from bpy
.types
import (
23 from bl_ui
.space_userpref
import (
27 from . import repo_status_text
30 # -----------------------------------------------------------------------------
34 def size_as_fmt_string(num
: float, *, precision
: int = 1) -> str:
35 for unit
in ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB"):
37 return "{:3.{:d}f}{:s}".format(num
, precision
, unit
)
40 return "{:.{:d}f}{:s}".format(num
, precision
, unit
)
43 def sizes_as_percentage_string(size_partial
: int, size_final
: int) -> str:
47 size_partial
= min(size_partial
, size_final
)
48 percent
= size_partial
/ size_final
50 return "{:-6.2f}%".format(percent
* 100)
53 def license_info_to_text(license_list
):
54 # See: https://spdx.org/licenses/
55 # - Note that we could include all, for now only common, GPL compatible licenses.
56 # - Note that many of the human descriptions are not especially more humanly readable
57 # than the short versions, so it's questionable if we should attempt to add all of these.
59 "GPL-2.0-only": "GNU General Public License v2.0 only",
60 "GPL-2.0-or-later": "GNU General Public License v2.0 or later",
61 "GPL-3.0-only": "GNU General Public License v3.0 only",
62 "GPL-3.0-or-later": "GNU General Public License v3.0 or later",
65 for item
in license_list
:
66 if item
.startswith("SPDX:"):
68 item
= _spdx_id_to_text
.get(item
, item
)
70 return ", ".join(result
)
73 def pkg_repo_and_id_from_theme_path(repos_all
, filepath
):
78 # Strip the `theme.xml` filename.
79 dirpath
= os
.path
.dirname(filepath
)
80 repo_directory
, pkg_id
= os
.path
.split(dirpath
)
81 for repo_index
, repo
in enumerate(repos_all
):
82 if not os
.path
.samefile(repo_directory
, repo
.directory
):
84 return repo_index
, pkg_id
88 # -----------------------------------------------------------------------------
89 # Extensions UI (Legacy)
91 def extensions_panel_draw_legacy_addons(
98 used_addon_module_name_map
,
100 # NOTE: this duplicates logic from `USERPREF_PT_addons` eventually this logic should be used instead.
101 # Don't de-duplicate the logic as this is a temporary state - as long as extensions remains experimental.
103 from bpy
.app
.translations
import (
104 pgettext_iface
as iface_
,
106 from .bl_extension_ops
import (
107 pkg_info_check_exclude_filter_ex
,
111 (mod
, addon_utils
.module_bl_info(mod
))
112 for mod
in addon_utils
.modules(refresh
=False)
115 # Initialized on demand.
116 user_addon_paths
= []
118 for mod
, bl_info
in addons
:
119 module_name
= mod
.__name
__
120 is_extension
= addon_utils
.check_extension(module_name
)
124 if search_lower
and (
125 not pkg_info_check_exclude_filter_ex(
127 bl_info
["description"],
133 is_enabled
= module_name
in used_addon_module_name_map
134 if enabled_only
and (not is_enabled
):
137 col_box
= layout
.column()
139 colsub
= box
.column()
140 row
= colsub
.row(align
=True)
143 "preferences.addon_expand",
144 icon
='DISCLOSURE_TRI_DOWN' if bl_info
["show_expanded"] else 'DISCLOSURE_TRI_RIGHT',
146 ).module
= module_name
149 "preferences.addon_disable" if is_enabled
else "preferences.addon_enable",
150 icon
='CHECKBOX_HLT' if is_enabled
else 'CHECKBOX_DEHLT', text
="",
152 ).module
= module_name
155 sub
.active
= is_enabled
156 sub
.label(text
="Legacy: " + bl_info
["name"])
158 if bl_info
["warning"]:
159 sub
.label(icon
='ERROR')
161 row_right
= row
.row()
162 row_right
.alignment
= 'RIGHT'
164 row_right
.label(text
="Installed ")
165 row_right
.active
= False
167 if bl_info
["show_expanded"]:
168 split
= box
.split(factor
=0.15)
169 col_a
= split
.column()
170 col_b
= split
.column()
171 if value
:= bl_info
["description"]:
172 col_a
.label(text
="Description:")
173 col_b
.label(text
=iface_(value
))
175 col_a
.label(text
="File:")
176 col_b
.label(text
=mod
.__file
__, translate
=False)
178 if value
:= bl_info
["author"]:
179 col_a
.label(text
="Author:")
180 col_b
.label(text
=value
.split("<", 1)[0].rstrip(), translate
=False)
181 if value
:= bl_info
["version"]:
182 col_a
.label(text
="Version:")
183 col_b
.label(text
=".".join(str(x
) for x
in value
), translate
=False)
184 if value
:= bl_info
["warning"]:
185 col_a
.label(text
="Warning:")
186 col_b
.label(text
=" " + iface_(value
), icon
='ERROR')
189 # Include for consistency.
190 col_a
.label(text
="Type:")
191 col_b
.label(text
="add-on")
193 user_addon
= USERPREF_PT_addons
.is_user_addon(mod
, user_addon_paths
)
195 if bl_info
["doc_url"] or bl_info
.get("tracker_url"):
196 split
= box
.row().split(factor
=0.15)
197 split
.label(text
="Internet:")
199 if bl_info
["doc_url"]:
201 "wm.url_open", text
="Documentation", icon
='HELP',
202 ).url
= bl_info
["doc_url"]
203 # Only add "Report a Bug" button if tracker_url is set
204 # or the add-on is bundled (use official tracker then).
205 if bl_info
.get("tracker_url"):
207 "wm.url_open", text
="Report a Bug", icon
='URL',
208 ).url
= bl_info
["tracker_url"]
213 ) % (bl_info
["name"], str(bl_info
["version"]), bl_info
["author"])
214 props
= sub
.operator(
215 "wm.url_open_preset", text
="Report a Bug", icon
='URL',
217 props
.type = 'BUG_ADDON'
218 props
.id = addon_info
222 rowsub
.alignment
= 'RIGHT'
224 "preferences.addon_remove", text
="Uninstall", icon
='CANCEL',
225 ).module
= module_name
228 if (addon_preferences
:= used_addon_module_name_map
[module_name
].preferences
) is not None:
229 USERPREF_PT_addons
.draw_addon_preferences(layout
, context
, addon_preferences
)
232 # -----------------------------------------------------------------------------
235 class display_errors
:
237 This singleton class is used to store errors which are generated while drawing,
238 note that these errors are reasonably obscure, examples are:
239 - Failure to parse the repository JSON file.
240 - Failure to access the file-system for reading where the repository is stored.
242 The current and previous state are compared, when they match no drawing is done,
243 this allows the current display errors to be dismissed.
250 display_errors
.errors_prev
= display_errors
.errors_curr
254 if display_errors
.errors_curr
== display_errors
.errors_prev
:
256 box_header
= layout
.box()
257 # Don't clip longer names.
258 row
= box_header
.split(factor
=0.9)
259 row
.label(text
="Repository Access Errors:", icon
='ERROR')
260 rowsub
= row
.row(align
=True)
261 rowsub
.alignment
= 'RIGHT'
262 rowsub
.operator("bl_pkg.pkg_display_errors_clear", text
="", icon
='X', emboss
=False)
264 box_contents
= box_header
.box()
265 for err
in display_errors
.errors_curr
:
266 box_contents
.label(text
=err
)
269 def extensions_panel_draw_impl(
280 Show all the items... we may want to paginate at some point.
283 from .bl_extension_ops
import (
284 blender_extension_mark
,
285 blender_extension_show
,
286 extension_repos_read
,
287 pkg_info_check_exclude_filter
,
288 repo_cache_store_refresh_from_prefs
,
291 from . import repo_cache_store
293 # This isn't elegant, but the preferences aren't available on registration.
294 if not repo_cache_store
.is_init():
295 repo_cache_store_refresh_from_prefs()
299 # Define a top-most column to place warnings (if-any).
300 # Needed so the warnings aren't mixed in with other content.
301 layout_topmost
= layout
.column()
303 repos_all
= extension_repos_read()
305 # To access enabled add-ons.
306 show_addons
= filter_by_type
in {"", "add-on"}
307 show_themes
= filter_by_type
in {"", "theme"}
309 used_addon_module_name_map
= {addon
.module
: addon
for addon
in context
.preferences
.addons
}
311 active_theme_info
= pkg_repo_and_id_from_theme_path(repos_all
, context
.preferences
.themes
[0].filepath
)
313 # Collect exceptions accessing repositories, and optionally show them.
319 def error_fn_remote(ex
):
323 def error_fn_local(ex
):
331 repo_cache_store
.pkg_manifest_from_remote_ensure(error_fn
=error_fn_remote
),
332 repo_cache_store
.pkg_manifest_from_local_ensure(error_fn
=error_fn_local
),
334 # Show any exceptions created while accessing the JSON,
335 # if the JSON has an IO error while being read or if the directory doesn't exist.
336 # In general users should _not_ see these kinds of errors however we cannot prevent
337 # IO errors in general and it is better to show a warning than to ignore the error entirely
338 # or cause a trace-back which breaks the UI.
339 if (remote_ex
is not None) or (local_ex
is not None):
340 repo
= repos_all
[repo_index
]
341 # NOTE: `FileNotFoundError` occurs when a repository has been added but has not update with its remote.
342 # We may want a way for users to know a repository is missing from the view and they need to run update
343 # to access its extensions.
344 if remote_ex
is not None:
345 if isinstance(remote_ex
, FileNotFoundError
) and (remote_ex
.filename
== repo
.directory
):
348 errors_on_draw
.append("Remote of \"{:s}\": {:s}".format(repo
.name
, str(remote_ex
)))
351 if local_ex
is not None:
352 if isinstance(local_ex
, FileNotFoundError
) and (local_ex
.filename
== repo
.directory
):
355 errors_on_draw
.append("Local of \"{:s}\": {:s}".format(repo
.name
, str(local_ex
)))
359 if pkg_manifest_remote
is None:
360 repo
= repos_all
[repo_index
]
361 has_remote
= (repo
.repo_url
!= "")
363 # NOTE: it would be nice to detect when the repository ran sync and it failed.
364 # This isn't such an important distinction though, the main thing users should be aware of
365 # is that a "sync" is required.
366 errors_on_draw
.append("Repository: \"{:s}\" must sync with the remote repository.".format(repo
.name
))
370 repo
= repos_all
[repo_index
]
371 has_remote
= (repo
.repo_url
!= "")
374 for pkg_id
, item_remote
in pkg_manifest_remote
.items():
375 if filter_by_type
and (filter_by_type
!= item_remote
["type"]):
377 if search_lower
and (not pkg_info_check_exclude_filter(item_remote
, search_lower
)):
380 item_local
= pkg_manifest_local
.get(pkg_id
)
381 is_installed
= item_local
is not None
383 if installed_only
and (is_installed
== 0):
388 match item_remote
["type"]:
396 # Currently we only need to know the module name once installed.
397 addon_module_name
= "bl_ext.{:s}.{:s}".format(repos_all
[repo_index
].module
, pkg_id
)
398 is_enabled
= addon_module_name
in used_addon_module_name_map
402 addon_module_name
= None
404 is_enabled
= (repo_index
, pkg_id
) == active_theme_info
405 addon_module_name
= None
407 # TODO: ability to disable.
408 is_enabled
= is_installed
409 addon_module_name
= None
411 if enabled_only
and (not is_enabled
):
414 item_version
= item_remote
["version"]
415 if item_local
is None:
416 item_local_version
= None
419 item_local_version
= item_local
["version"]
420 is_outdated
= item_local_version
!= item_version
422 key
= (pkg_id
, repo_index
)
424 mark
= key
in blender_extension_mark
425 show
= key
in blender_extension_show
430 # Left align so the operator text isn't centered.
431 colsub
= box
.column()
432 row
= colsub
.row(align
=True)
435 props
= row
.operator("bl_pkg.pkg_show_clear", text
="", icon
='DISCLOSURE_TRI_DOWN', emboss
=False)
437 props
= row
.operator("bl_pkg.pkg_show_set", text
="", icon
='DISCLOSURE_TRI_RIGHT', emboss
=False)
438 props
.pkg_id
= pkg_id
439 props
.repo_index
= repo_index
445 "preferences.addon_disable" if is_enabled
else "preferences.addon_enable",
446 icon
='CHECKBOX_HLT' if is_enabled
else 'CHECKBOX_DEHLT',
449 ).module
= addon_module_name
451 props
= row
.operator(
452 "bl_pkg.extension_theme_disable" if is_enabled
else "bl_pkg.extension_theme_enable",
453 icon
='CHECKBOX_HLT' if is_enabled
else 'CHECKBOX_DEHLT',
457 props
.repo_index
= repo_index
458 props
.pkg_id
= pkg_id
461 # Use a place-holder checkbox icon to avoid odd text alignment when mixing with installed add-ons.
462 # Non add-ons have no concept of "enabled" right now, use installed.
464 "bl_pkg.extension_disable",
470 # Not installed, always placeholder.
471 row
.operator("bl_pkg.extensions_enable_not_installed", text
="", icon
='CHECKBOX_DEHLT', emboss
=False)
475 props
= row
.operator("bl_pkg.pkg_mark_clear", text
="", icon
='RADIOBUT_ON', emboss
=False)
477 props
= row
.operator("bl_pkg.pkg_mark_set", text
="", icon
='RADIOBUT_OFF', emboss
=False)
478 props
.pkg_id
= pkg_id
479 props
.repo_index
= repo_index
483 sub
.active
= is_enabled
484 sub
.label(text
=item_remote
["name"])
487 row_right
= row
.row()
488 row_right
.alignment
= 'RIGHT'
492 # Include uninstall below.
494 props
= row_right
.operator("bl_pkg.pkg_install", text
="Update")
495 props
.repo_index
= repo_index
496 props
.pkg_id
= pkg_id
499 # Right space for alignment with the button.
500 row_right
.label(text
="Installed ")
501 row_right
.active
= False
503 props
= row_right
.operator("bl_pkg.pkg_install", text
="Install")
504 props
.repo_index
= repo_index
505 props
.pkg_id
= pkg_id
508 # Right space for alignment with the button.
509 row_right
.label(text
="Installed ")
510 row_right
.active
= False
513 split
= box
.split(factor
=0.15)
514 col_a
= split
.column()
515 col_b
= split
.column()
517 col_a
.label(text
="Description:")
518 # The full description may be multiple lines (not yet supported by Blender's UI).
519 col_b
.label(text
=item_remote
["tagline"])
522 col_a
.label(text
="Path:")
523 col_b
.label(text
=os
.path
.join(repos_all
[repo_index
].directory
, pkg_id
), translate
=False)
525 # Remove the maintainers email while it's not private, showing prominently
526 # could cause maintainers to get direct emails instead of issue tracking systems.
527 col_a
.label(text
="Maintainer:")
528 col_b
.label(text
=item_remote
["maintainer"].split("<", 1)[0].rstrip(), translate
=False)
530 col_a
.label(text
="License:")
531 col_b
.label(text
=license_info_to_text(item_remote
["license"]))
533 col_a
.label(text
="Version:")
535 col_b
.label(text
="{:s} ({:s} available)".format(item_local_version
, item_version
))
537 col_b
.label(text
=item_version
)
540 col_a
.label(text
="Size:")
541 col_b
.label(text
=size_as_fmt_string(item_remote
["archive_size"]))
543 if not filter_by_type
:
544 col_a
.label(text
="Type:")
545 col_b
.label(text
=item_remote
["type"])
547 if len(repos_all
) > 1:
548 col_a
.label(text
="Repository:")
549 col_b
.label(text
=repos_all
[repo_index
].name
)
551 if value
:= item_remote
.get("website"):
552 col_a
.label(text
="Internet:")
553 # Use half size button, for legacy add-ons there are two, here there is one
554 # however one large button looks silly, so use a half size still.
555 col_b
.split(factor
=0.5).operator("wm.url_open", text
="Website", icon
='HELP').url
= value
558 # Note that we could allow removing extensions from non-remote extension repos
559 # although this is destructive, so don't enable this right now.
562 rowsub
.alignment
= 'RIGHT'
563 props
= rowsub
.operator("bl_pkg.pkg_uninstall", text
="Uninstall")
564 props
.repo_index
= repo_index
565 props
.pkg_id
= pkg_id
568 # Show addon user preferences.
569 if is_enabled
and is_addon
:
570 if (addon_preferences
:= used_addon_module_name_map
[addon_module_name
].preferences
) is not None:
571 USERPREF_PT_addons
.draw_addon_preferences(layout
, context
, addon_preferences
)
573 if show_addons
and show_legacy_addons
:
574 extensions_panel_draw_legacy_addons(
577 search_lower
=search_lower
,
578 enabled_only
=enabled_only
,
579 installed_only
=installed_only
,
580 used_addon_module_name_map
=used_addon_module_name_map
,
583 # Finally show any errors in a single panel which can be dismissed.
584 display_errors
.errors_curr
= errors_on_draw
586 display_errors
.draw(layout_topmost
)
589 class USERPREF_PT_extensions_bl_pkg_filter(Panel
):
590 bl_label
= "Extensions Filter"
592 bl_space_type
= 'TOPBAR' # dummy.
593 bl_region_type
= 'HEADER'
596 def draw(self
, context
):
599 wm
= context
.window_manager
600 col
= layout
.column(heading
="Show")
601 col
.use_property_split
= True
602 col
.prop(wm
, "extension_show_legacy_addons", text
="Legacy Add-ons")
604 col
= layout
.column(heading
="Only")
605 col
.use_property_split
= True
606 col
.prop(wm
, "extension_enabled_only", text
="Enabled Extensions")
608 sub
.active
= not wm
.extension_enabled_only
609 sub
.prop(wm
, "extension_installed_only", text
="Installed Extensions")
612 class USERPREF_MT_extensions_bl_pkg_settings(Menu
):
613 bl_label
= "Extension Settings"
615 def draw(self
, context
):
618 addon_prefs
= context
.preferences
.addons
[__package__
].preferences
620 layout
.operator("bl_pkg.repo_sync_all", text
="Check for Updates", icon
='FILE_REFRESH')
624 layout
.operator("bl_pkg.pkg_upgrade_all", text
="Install Available Updates", icon
='IMPORT')
625 layout
.operator("bl_pkg.pkg_install_files", text
="Install from Disk")
626 layout
.operator("preferences.addon_install", text
="Install Legacy Add-on")
628 if context
.preferences
.experimental
.use_extension_utils
:
631 layout
.prop(addon_prefs
, "show_development_reports")
635 # We might want to expose this for all users, the purpose of this
636 # is to refresh after changes have been made to the repos outside of Blender
637 # it's disputable if this is a common case.
638 layout
.operator("preferences.addon_refresh", text
="Refresh (file-system)", icon
='FILE_REFRESH')
641 layout
.operator("bl_pkg.pkg_install_marked", text
="Install Marked", icon
='IMPORT')
642 layout
.operator("bl_pkg.pkg_uninstall_marked", text
="Uninstall Marked", icon
='X')
643 layout
.operator("bl_pkg.obsolete_marked")
647 layout
.operator("bl_pkg.repo_lock")
648 layout
.operator("bl_pkg.repo_unlock")
651 def extensions_panel_draw(panel
, context
):
652 prefs
= context
.preferences
653 if not prefs
.experimental
.use_extension_repos
:
654 # Unexpected, the extension is disabled but this add-on is.
655 # In this case don't show the UI as it is confusing.
658 from .bl_extension_ops
import (
659 blender_filter_by_type_map
,
662 addon_prefs
= prefs
.addons
[__package__
].preferences
664 show_development
= context
.preferences
.experimental
.use_extension_utils
665 show_development_reports
= show_development
and addon_prefs
.show_development_reports
667 wm
= context
.window_manager
668 layout
= panel
.layout
670 row
= layout
.split(factor
=0.5)
672 row_a
.prop(wm
, "extension_search", text
="", icon
='VIEWZOOM')
673 row_b
= row
.row(align
=True)
674 row_b
.prop(wm
, "extension_type", text
="")
675 row_b
.popover("USERPREF_PT_extensions_bl_pkg_filter", text
="", icon
='FILTER')
678 row_b
.popover("USERPREF_PT_extensions_repos", text
="Repositories")
681 row_b
.menu("USERPREF_MT_extensions_bl_pkg_settings", text
="", icon
='DOWNARROW_HLT')
682 del row
, row_a
, row_b
684 if show_development_reports
:
685 show_status
= bool(repo_status_text
.log
)
687 # Only show if running and there is progress to display.
688 show_status
= bool(repo_status_text
.log
) and repo_status_text
.running
691 for ty
, msg
in repo_status_text
.log
:
697 # Don't clip longer names.
698 row
= box
.split(factor
=0.9, align
=True)
699 if repo_status_text
.running
:
700 row
.label(text
=repo_status_text
.title
+ "...", icon
='INFO')
702 row
.label(text
=repo_status_text
.title
, icon
='INFO')
703 if show_development_reports
:
704 rowsub
= row
.row(align
=True)
705 rowsub
.alignment
= 'RIGHT'
706 rowsub
.operator("bl_pkg.pkg_status_clear", text
="", icon
='X', emboss
=False)
708 for ty
, msg
in repo_status_text
.log
:
710 boxsub
.label(text
=msg
)
711 elif ty
== 'PROGRESS':
712 msg_str
, progress_unit
, progress
, progress_range
= msg
713 if progress
<= progress_range
:
715 factor
=progress
/ progress_range
,
716 text
="{:s}, {:s}".format(
717 sizes_as_percentage_string(progress
, progress_range
),
721 elif progress_unit
== 'BYTE':
722 boxsub
.progress(factor
=0.0, text
="{:s}, {:s}".format(msg_str
, size_as_fmt_string(progress
)))
724 # We might want to support other types.
725 boxsub
.progress(factor
=0.0, text
="{:s}, {:d}".format(msg_str
, progress
))
727 boxsub
.label(text
="{:s}: {:s}".format(ty
, msg
))
730 if repo_status_text
.running
:
733 extensions_panel_draw_impl(
736 wm
.extension_search
.lower(),
737 blender_filter_by_type_map
[wm
.extension_type
],
738 wm
.extension_enabled_only
,
739 wm
.extension_installed_only
,
740 wm
.extension_show_legacy_addons
,
747 USERPREF_PT_extensions_bl_pkg_filter
,
748 USERPREF_MT_extensions_bl_pkg_settings
,
753 USERPREF_PT_addons
.append(extensions_panel_draw
)
756 bpy
.utils
.register_class(cls
)
760 USERPREF_PT_addons
.remove(extensions_panel_draw
)
762 for cls
in reversed(classes
):
763 bpy
.utils
.unregister_class(cls
)