Files
blender-addons-contrib/bl_pkg/bl_extension_ui.py
Pablo Vazquez c97c1c847c UI: Move Extensions repositories popover to header
Improve discoverability of the repositories settings by moving them from
the dropdown menu into the header.

Inspired by !27

Pull Request: https://projects.blender.org/blender/blender-addons-contrib/pulls/30
2024-04-16 17:23:29 +02:00

764 lines
28 KiB
Python

# SPDX-FileCopyrightText: 2023 Blender Foundation
#
# SPDX-License-Identifier: GPL-2.0-or-later
"""
GUI (WARNING) this is a hack!
Written to allow a UI without modifying Blender.
"""
__all__ = (
"display_errors",
"register",
"unregister",
)
import bpy
from bpy.types import (
Menu,
Panel,
)
from bl_ui.space_userpref import (
USERPREF_PT_addons,
)
from . import repo_status_text
# -----------------------------------------------------------------------------
# Generic Utilities
def size_as_fmt_string(num: float, *, precision: int = 1) -> str:
for unit in ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB"):
if abs(num) < 1024.0:
return "{:3.{:d}f}{:s}".format(num, precision, unit)
num /= 1024.0
unit = "yb"
return "{:.{:d}f}{:s}".format(num, precision, unit)
def sizes_as_percentage_string(size_partial: int, size_final: int) -> str:
if size_final == 0:
percent = 0.0
else:
size_partial = min(size_partial, size_final)
percent = size_partial / size_final
return "{:-6.2f}%".format(percent * 100)
def license_info_to_text(license_list):
# See: https://spdx.org/licenses/
# - Note that we could include all, for now only common, GPL compatible licenses.
# - Note that many of the human descriptions are not especially more humanly readable
# than the short versions, so it's questionable if we should attempt to add all of these.
_spdx_id_to_text = {
"GPL-2.0-only": "GNU General Public License v2.0 only",
"GPL-2.0-or-later": "GNU General Public License v2.0 or later",
"GPL-3.0-only": "GNU General Public License v3.0 only",
"GPL-3.0-or-later": "GNU General Public License v3.0 or later",
}
result = []
for item in license_list:
if item.startswith("SPDX:"):
item = item[5:]
item = _spdx_id_to_text.get(item, item)
result.append(item)
return ", ".join(result)
def pkg_repo_and_id_from_theme_path(repos_all, filepath):
import os
if not filepath:
return None
# Strip the `theme.xml` filename.
dirpath = os.path.dirname(filepath)
repo_directory, pkg_id = os.path.split(dirpath)
for repo_index, repo in enumerate(repos_all):
if not os.path.samefile(repo_directory, repo.directory):
continue
return repo_index, pkg_id
return None
# -----------------------------------------------------------------------------
# Extensions UI (Legacy)
def extensions_panel_draw_legacy_addons(
layout,
context,
*,
search_lower,
enabled_only,
installed_only,
used_addon_module_name_map,
):
# NOTE: this duplicates logic from `USERPREF_PT_addons` eventually this logic should be used instead.
# Don't de-duplicate the logic as this is a temporary state - as long as extensions remains experimental.
import addon_utils
from bpy.app.translations import (
pgettext_iface as iface_,
)
from .bl_extension_ops import (
pkg_info_check_exclude_filter_ex,
)
addons = [
(mod, addon_utils.module_bl_info(mod))
for mod in addon_utils.modules(refresh=False)
]
# Initialized on demand.
user_addon_paths = []
for mod, bl_info in addons:
module_name = mod.__name__
is_extension = addon_utils.check_extension(module_name)
if is_extension:
continue
if search_lower and (
not pkg_info_check_exclude_filter_ex(
bl_info["name"],
bl_info["description"],
search_lower,
)
):
continue
is_enabled = module_name in used_addon_module_name_map
if enabled_only and (not is_enabled):
continue
col_box = layout.column()
box = col_box.box()
colsub = box.column()
row = colsub.row(align=True)
row.operator(
"preferences.addon_expand",
icon='DISCLOSURE_TRI_DOWN' if bl_info["show_expanded"] else 'DISCLOSURE_TRI_RIGHT',
emboss=False,
).module = module_name
row.operator(
"preferences.addon_disable" if is_enabled else "preferences.addon_enable",
icon='CHECKBOX_HLT' if is_enabled else 'CHECKBOX_DEHLT', text="",
emboss=False,
).module = module_name
sub = row.row()
sub.active = is_enabled
sub.label(text="Legacy: " + bl_info["name"])
if bl_info["warning"]:
sub.label(icon='ERROR')
row_right = row.row()
row_right.alignment = 'RIGHT'
row_right.label(text="Installed ")
row_right.active = False
if bl_info["show_expanded"]:
split = box.split(factor=0.15)
col_a = split.column()
col_b = split.column()
if value := bl_info["description"]:
col_a.label(text="Description:")
col_b.label(text=iface_(value))
col_a.label(text="File:")
col_b.label(text=mod.__file__, translate=False)
if value := bl_info["author"]:
col_a.label(text="Author:")
col_b.label(text=value.split("<", 1)[0].rstrip(), translate=False)
if value := bl_info["version"]:
col_a.label(text="Version:")
col_b.label(text=".".join(str(x) for x in value), translate=False)
if value := bl_info["warning"]:
col_a.label(text="Warning:")
col_b.label(text=" " + iface_(value), icon='ERROR')
del value
# Include for consistency.
col_a.label(text="Type:")
col_b.label(text="add-on")
user_addon = USERPREF_PT_addons.is_user_addon(mod, user_addon_paths)
if bl_info["doc_url"] or bl_info.get("tracker_url"):
split = box.row().split(factor=0.15)
split.label(text="Internet:")
sub = split.row()
if bl_info["doc_url"]:
sub.operator(
"wm.url_open", text="Documentation", icon='HELP',
).url = bl_info["doc_url"]
# Only add "Report a Bug" button if tracker_url is set
# or the add-on is bundled (use official tracker then).
if bl_info.get("tracker_url"):
sub.operator(
"wm.url_open", text="Report a Bug", icon='URL',
).url = bl_info["tracker_url"]
elif not user_addon:
addon_info = (
"Name: %s %s\n"
"Author: %s\n"
) % (bl_info["name"], str(bl_info["version"]), bl_info["author"])
props = sub.operator(
"wm.url_open_preset", text="Report a Bug", icon='URL',
)
props.type = 'BUG_ADDON'
props.id = addon_info
if user_addon:
rowsub = col_b.row()
rowsub.alignment = 'RIGHT'
rowsub.operator(
"preferences.addon_remove", text="Uninstall", icon='CANCEL',
).module = module_name
if is_enabled:
if (addon_preferences := used_addon_module_name_map[module_name].preferences) is not None:
USERPREF_PT_addons.draw_addon_preferences(layout, context, addon_preferences)
# -----------------------------------------------------------------------------
# Extensions UI
class display_errors:
"""
This singleton class is used to store errors which are generated while drawing,
note that these errors are reasonably obscure, examples are:
- Failure to parse the repository JSON file.
- Failure to access the file-system for reading where the repository is stored.
The current and previous state are compared, when they match no drawing is done,
this allows the current display errors to be dismissed.
"""
errors_prev = []
errors_curr = []
@staticmethod
def clear():
display_errors.errors_prev = display_errors.errors_curr
@staticmethod
def draw(layout):
if display_errors.errors_curr == display_errors.errors_prev:
return
box_header = layout.box()
# Don't clip longer names.
row = box_header.split(factor=0.9)
row.label(text="Repository Access Errors:", icon='ERROR')
rowsub = row.row(align=True)
rowsub.alignment = 'RIGHT'
rowsub.operator("bl_pkg.pkg_display_errors_clear", text="", icon='X', emboss=False)
box_contents = box_header.box()
for err in display_errors.errors_curr:
box_contents.label(text=err)
def extensions_panel_draw_impl(
self,
context,
search_lower,
filter_by_type,
enabled_only,
installed_only,
show_legacy_addons,
show_development,
):
"""
Show all the items... we may want to paginate at some point.
"""
import os
from .bl_extension_ops import (
blender_extension_mark,
blender_extension_show,
extension_repos_read,
pkg_info_check_exclude_filter,
repo_cache_store_refresh_from_prefs,
)
from . import repo_cache_store
# This isn't elegant, but the preferences aren't available on registration.
if not repo_cache_store.is_init():
repo_cache_store_refresh_from_prefs()
layout = self.layout
# Define a top-most column to place warnings (if-any).
# Needed so the warnings aren't mixed in with other content.
layout_topmost = layout.column()
repos_all = extension_repos_read()
# To access enabled add-ons.
show_addons = filter_by_type in {"", "add-on"}
show_themes = filter_by_type in {"", "theme"}
if show_addons:
used_addon_module_name_map = {addon.module: addon for addon in context.preferences.addons}
if show_themes:
active_theme_info = pkg_repo_and_id_from_theme_path(repos_all, context.preferences.themes[0].filepath)
# Collect exceptions accessing repositories, and optionally show them.
errors_on_draw = []
remote_ex = None
local_ex = None
def error_fn_remote(ex):
nonlocal remote_ex
remote_ex = ex
def error_fn_local(ex):
nonlocal remote_ex
remote_ex = ex
for repo_index, (
pkg_manifest_remote,
pkg_manifest_local,
) in enumerate(zip(
repo_cache_store.pkg_manifest_from_remote_ensure(error_fn=error_fn_remote),
repo_cache_store.pkg_manifest_from_local_ensure(error_fn=error_fn_local),
)):
# Show any exceptions created while accessing the JSON,
# if the JSON has an IO error while being read or if the directory doesn't exist.
# In general users should _not_ see these kinds of errors however we cannot prevent
# IO errors in general and it is better to show a warning than to ignore the error entirely
# or cause a trace-back which breaks the UI.
if (remote_ex is not None) or (local_ex is not None):
repo = repos_all[repo_index]
# NOTE: `FileNotFoundError` occurs when a repository has been added but has not update with its remote.
# We may want a way for users to know a repository is missing from the view and they need to run update
# to access its extensions.
if remote_ex is not None:
if isinstance(remote_ex, FileNotFoundError) and (remote_ex.filename == repo.directory):
pass
else:
errors_on_draw.append("Remote of \"{:s}\": {:s}".format(repo.name, str(remote_ex)))
remote_ex = None
if local_ex is not None:
if isinstance(local_ex, FileNotFoundError) and (local_ex.filename == repo.directory):
pass
else:
errors_on_draw.append("Local of \"{:s}\": {:s}".format(repo.name, str(local_ex)))
local_ex = None
continue
if pkg_manifest_remote is None:
repo = repos_all[repo_index]
has_remote = (repo.repo_url != "")
if has_remote:
# NOTE: it would be nice to detect when the repository ran sync and it failed.
# This isn't such an important distinction though, the main thing users should be aware of
# is that a "sync" is required.
errors_on_draw.append("Repository: \"{:s}\" must sync with the remote repository.".format(repo.name))
del repo
continue
else:
repo = repos_all[repo_index]
has_remote = (repo.repo_url != "")
del repo
for pkg_id, item_remote in pkg_manifest_remote.items():
if filter_by_type and (filter_by_type != item_remote["type"]):
continue
if search_lower and (not pkg_info_check_exclude_filter(item_remote, search_lower)):
continue
item_local = pkg_manifest_local.get(pkg_id)
is_installed = item_local is not None
if installed_only and (is_installed == 0):
continue
is_addon = False
is_theme = False
match item_remote["type"]:
case "add-on":
is_addon = True
case "theme":
is_theme = True
if is_addon:
if is_installed:
# Currently we only need to know the module name once installed.
addon_module_name = "bl_ext.{:s}.{:s}".format(repos_all[repo_index].module, pkg_id)
is_enabled = addon_module_name in used_addon_module_name_map
else:
is_enabled = False
addon_module_name = None
elif is_theme:
is_enabled = (repo_index, pkg_id) == active_theme_info
addon_module_name = None
else:
# TODO: ability to disable.
is_enabled = is_installed
addon_module_name = None
if enabled_only and (not is_enabled):
continue
item_version = item_remote["version"]
if item_local is None:
item_local_version = None
is_outdated = False
else:
item_local_version = item_local["version"]
is_outdated = item_local_version != item_version
key = (pkg_id, repo_index)
if show_development:
mark = key in blender_extension_mark
show = key in blender_extension_show
del key
box = layout.box()
# Left align so the operator text isn't centered.
colsub = box.column()
row = colsub.row(align=True)
# row.label
if show:
props = row.operator("bl_pkg.pkg_show_clear", text="", icon='DISCLOSURE_TRI_DOWN', emboss=False)
else:
props = row.operator("bl_pkg.pkg_show_set", text="", icon='DISCLOSURE_TRI_RIGHT', emboss=False)
props.pkg_id = pkg_id
props.repo_index = repo_index
del props
if is_installed:
if is_addon:
row.operator(
"preferences.addon_disable" if is_enabled else "preferences.addon_enable",
icon='CHECKBOX_HLT' if is_enabled else 'CHECKBOX_DEHLT',
text="",
emboss=False,
).module = addon_module_name
elif is_theme:
props = row.operator(
"bl_pkg.extension_theme_disable" if is_enabled else "bl_pkg.extension_theme_enable",
icon='CHECKBOX_HLT' if is_enabled else 'CHECKBOX_DEHLT',
text="",
emboss=False,
)
props.repo_index = repo_index
props.pkg_id = pkg_id
del props
else:
# Use a place-holder checkbox icon to avoid odd text alignment when mixing with installed add-ons.
# Non add-ons have no concept of "enabled" right now, use installed.
row.operator(
"bl_pkg.extension_disable",
text="",
icon='CHECKBOX_HLT',
emboss=False,
)
else:
# Not installed, always placeholder.
row.operator("bl_pkg.extensions_enable_not_installed", text="", icon='CHECKBOX_DEHLT', emboss=False)
if show_development:
if mark:
props = row.operator("bl_pkg.pkg_mark_clear", text="", icon='RADIOBUT_ON', emboss=False)
else:
props = row.operator("bl_pkg.pkg_mark_set", text="", icon='RADIOBUT_OFF', emboss=False)
props.pkg_id = pkg_id
props.repo_index = repo_index
del props
sub = row.row()
sub.active = is_enabled
sub.label(text=item_remote["name"])
del sub
row_right = row.row()
row_right.alignment = 'RIGHT'
if has_remote:
if is_installed:
# Include uninstall below.
if is_outdated:
props = row_right.operator("bl_pkg.pkg_install", text="Update")
props.repo_index = repo_index
props.pkg_id = pkg_id
del props
else:
# Right space for alignment with the button.
row_right.label(text="Installed ")
row_right.active = False
else:
props = row_right.operator("bl_pkg.pkg_install", text="Install")
props.repo_index = repo_index
props.pkg_id = pkg_id
del props
else:
# Right space for alignment with the button.
row_right.label(text="Installed ")
row_right.active = False
if show:
split = box.split(factor=0.15)
col_a = split.column()
col_b = split.column()
col_a.label(text="Description:")
# The full description may be multiple lines (not yet supported by Blender's UI).
col_b.label(text=item_remote["tagline"])
if is_installed:
col_a.label(text="Path:")
col_b.label(text=os.path.join(repos_all[repo_index].directory, pkg_id), translate=False)
# Remove the maintainers email while it's not private, showing prominently
# could cause maintainers to get direct emails instead of issue tracking systems.
col_a.label(text="Maintainer:")
col_b.label(text=item_remote["maintainer"].split("<", 1)[0].rstrip(), translate=False)
col_a.label(text="License:")
col_b.label(text=license_info_to_text(item_remote["license"]))
col_a.label(text="Version:")
if is_outdated:
col_b.label(text="{:s} ({:s} available)".format(item_local_version, item_version))
else:
col_b.label(text=item_version)
if has_remote:
col_a.label(text="Size:")
col_b.label(text=size_as_fmt_string(item_remote["archive_size"]))
if not filter_by_type:
col_a.label(text="Type:")
col_b.label(text=item_remote["type"])
if len(repos_all) > 1:
col_a.label(text="Repository:")
col_b.label(text=repos_all[repo_index].name)
if value := item_remote.get("website"):
col_a.label(text="Internet:")
# Use half size button, for legacy add-ons there are two, here there is one
# however one large button looks silly, so use a half size still.
col_b.split(factor=0.5).operator("wm.url_open", text="Website", icon='HELP').url = value
del value
# Note that we could allow removing extensions from non-remote extension repos
# although this is destructive, so don't enable this right now.
if is_installed:
rowsub = col_b.row()
rowsub.alignment = 'RIGHT'
props = rowsub.operator("bl_pkg.pkg_uninstall", text="Uninstall")
props.repo_index = repo_index
props.pkg_id = pkg_id
del props, rowsub
# Show addon user preferences.
if is_enabled and is_addon:
if (addon_preferences := used_addon_module_name_map[addon_module_name].preferences) is not None:
USERPREF_PT_addons.draw_addon_preferences(layout, context, addon_preferences)
if show_addons and show_legacy_addons:
extensions_panel_draw_legacy_addons(
layout,
context,
search_lower=search_lower,
enabled_only=enabled_only,
installed_only=installed_only,
used_addon_module_name_map=used_addon_module_name_map,
)
# Finally show any errors in a single panel which can be dismissed.
display_errors.errors_curr = errors_on_draw
if errors_on_draw:
display_errors.draw(layout_topmost)
class USERPREF_PT_extensions_bl_pkg_filter(Panel):
bl_label = "Extensions Filter"
bl_space_type = 'TOPBAR' # dummy.
bl_region_type = 'HEADER'
bl_ui_units_x = 13
def draw(self, context):
layout = self.layout
wm = context.window_manager
col = layout.column(heading="Show")
col.use_property_split = True
col.prop(wm, "extension_show_legacy_addons", text="Legacy Add-ons")
col = layout.column(heading="Only")
col.use_property_split = True
col.prop(wm, "extension_enabled_only", text="Enabled Extensions")
sub = col.column()
sub.active = not wm.extension_enabled_only
sub.prop(wm, "extension_installed_only", text="Installed Extensions")
class USERPREF_MT_extensions_bl_pkg_settings(Menu):
bl_label = "Extension Settings"
def draw(self, context):
layout = self.layout
addon_prefs = context.preferences.addons[__package__].preferences
layout.operator("bl_pkg.repo_sync_all", text="Check for Updates", icon='FILE_REFRESH')
layout.separator()
layout.operator("bl_pkg.pkg_upgrade_all", text="Install Available Updates", icon='IMPORT')
layout.operator("bl_pkg.pkg_install_files", text="Install from Disk")
layout.operator("preferences.addon_install", text="Install Legacy Add-on")
if context.preferences.experimental.use_extension_utils:
layout.separator()
layout.prop(addon_prefs, "show_development_reports")
layout.separator()
# We might want to expose this for all users, the purpose of this
# is to refresh after changes have been made to the repos outside of Blender
# it's disputable if this is a common case.
layout.operator("preferences.addon_refresh", text="Refresh (file-system)", icon='FILE_REFRESH')
layout.separator()
layout.operator("bl_pkg.pkg_install_marked", text="Install Marked", icon='IMPORT')
layout.operator("bl_pkg.pkg_uninstall_marked", text="Uninstall Marked", icon='X')
layout.operator("bl_pkg.obsolete_marked")
layout.separator()
layout.operator("bl_pkg.repo_lock")
layout.operator("bl_pkg.repo_unlock")
def extensions_panel_draw(panel, context):
prefs = context.preferences
if not prefs.experimental.use_extension_repos:
# Unexpected, the extension is disabled but this add-on is.
# In this case don't show the UI as it is confusing.
return
from .bl_extension_ops import (
blender_filter_by_type_map,
)
addon_prefs = prefs.addons[__package__].preferences
show_development = context.preferences.experimental.use_extension_utils
show_development_reports = show_development and addon_prefs.show_development_reports
wm = context.window_manager
layout = panel.layout
row = layout.split(factor=0.5)
row_a = row.row()
row_a.prop(wm, "extension_search", text="", icon='VIEWZOOM')
row_b = row.row(align=True)
row_b.prop(wm, "extension_type", text="")
row_b.popover("USERPREF_PT_extensions_bl_pkg_filter", text="", icon='FILTER')
row_b.separator()
row_b.popover("USERPREF_PT_extensions_repos", text="Repositories")
row_b.separator()
row_b.menu("USERPREF_MT_extensions_bl_pkg_settings", text="", icon='DOWNARROW_HLT')
del row, row_a, row_b
if show_development_reports:
show_status = bool(repo_status_text.log)
else:
# Only show if running and there is progress to display.
show_status = bool(repo_status_text.log) and repo_status_text.running
if show_status:
show_status = False
for ty, msg in repo_status_text.log:
if ty == 'PROGRESS':
show_status = True
if show_status:
box = layout.box()
# Don't clip longer names.
row = box.split(factor=0.9, align=True)
if repo_status_text.running:
row.label(text=repo_status_text.title + "...", icon='INFO')
else:
row.label(text=repo_status_text.title, icon='INFO')
if show_development_reports:
rowsub = row.row(align=True)
rowsub.alignment = 'RIGHT'
rowsub.operator("bl_pkg.pkg_status_clear", text="", icon='X', emboss=False)
boxsub = box.box()
for ty, msg in repo_status_text.log:
if ty == 'STATUS':
boxsub.label(text=msg)
elif ty == 'PROGRESS':
msg_str, progress_unit, progress, progress_range = msg
if progress <= progress_range:
boxsub.progress(
factor=progress / progress_range,
text="{:s}, {:s}".format(
sizes_as_percentage_string(progress, progress_range),
msg_str,
),
)
elif progress_unit == 'BYTE':
boxsub.progress(factor=0.0, text="{:s}, {:s}".format(msg_str, size_as_fmt_string(progress)))
else:
# We might want to support other types.
boxsub.progress(factor=0.0, text="{:s}, {:d}".format(msg_str, progress))
else:
boxsub.label(text="{:s}: {:s}".format(ty, msg))
# Hide when running.
if repo_status_text.running:
return
extensions_panel_draw_impl(
panel,
context,
wm.extension_search.lower(),
blender_filter_by_type_map[wm.extension_type],
wm.extension_enabled_only,
wm.extension_installed_only,
wm.extension_show_legacy_addons,
show_development,
)
classes = (
# Pop-overs.
USERPREF_PT_extensions_bl_pkg_filter,
USERPREF_MT_extensions_bl_pkg_settings,
)
def register():
USERPREF_PT_addons.append(extensions_panel_draw)
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
USERPREF_PT_addons.remove(extensions_panel_draw)
for cls in reversed(classes):
bpy.utils.unregister_class(cls)