2026-01-01
This commit is contained in:
@@ -6,6 +6,8 @@ import bpy
|
||||
from bpy.utils import register_class, unregister_class
|
||||
import importlib
|
||||
|
||||
_VPM_AGENT_IMMEDIATE_REGISTER_DONE = locals().get("_VPM_AGENT_IMMEDIATE_REGISTER_DONE", False)
|
||||
|
||||
module_names = (
|
||||
"op_pie_wrappers",
|
||||
"op_copy_to_selected",
|
||||
@@ -57,10 +59,8 @@ def register_unregister_modules(modules: list, register: bool):
|
||||
for c in m.registry:
|
||||
try:
|
||||
register_func(c)
|
||||
except Exception as e:
|
||||
print(
|
||||
f"Warning: Pie Menus failed to {un}register class: {c.__name__}"
|
||||
)
|
||||
except (AttributeError, RuntimeError, TypeError, ValueError) as e:
|
||||
print(f"Warning: Pie Menus failed to {un}register class: {c.__name__}")
|
||||
print(e)
|
||||
|
||||
if hasattr(m, 'modules'):
|
||||
@@ -78,8 +78,27 @@ def delayed_register(_scene=None):
|
||||
register_unregister_modules(modules, True)
|
||||
|
||||
def register():
|
||||
# NOTE: persistent=True must be set, otherwise this doesn't work when opening a .blend file directly from a file browser.
|
||||
bpy.app.timers.register(delayed_register, first_interval=0.5, persistent=True)
|
||||
"""
|
||||
We prefer an *immediate* register during startup, because other add-ons may touch
|
||||
keyconfig initialization very early, and Blender's keymap diff application appears
|
||||
sensitive to timing.
|
||||
|
||||
If immediate registration fails (e.g. missing WM in edge cases), fall back to the
|
||||
legacy timer-based delayed registration.
|
||||
"""
|
||||
global _VPM_AGENT_IMMEDIATE_REGISTER_DONE
|
||||
if not _VPM_AGENT_IMMEDIATE_REGISTER_DONE:
|
||||
try:
|
||||
register_unregister_modules(modules, True)
|
||||
_VPM_AGENT_IMMEDIATE_REGISTER_DONE = True
|
||||
return
|
||||
except Exception as e:
|
||||
# Keep behavior unchanged (fallback to timer), but avoid raising during registration.
|
||||
pass
|
||||
|
||||
# NOTE: persistent=True must be set, otherwise this doesn't work when opening
|
||||
# a .blend file directly from a file browser.
|
||||
bpy.app.timers.register(delayed_register, first_interval=0.0, persistent=True)
|
||||
|
||||
def unregister():
|
||||
register_unregister_modules(reversed(modules), False)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
schema_version = "1.0.0"
|
||||
id = "viewport_pie_menus"
|
||||
name = "3D Viewport Pie Menus"
|
||||
version = "1.7.1"
|
||||
version = "1.7.3"
|
||||
tagline = "Various pie menus to speed up your workflow"
|
||||
maintainer = "Community"
|
||||
type = "add-on"
|
||||
|
||||
@@ -4,11 +4,17 @@
|
||||
|
||||
# This file requires (and is made possible by) Blender 5.0 due to the find_match() API call.
|
||||
|
||||
import hashlib
|
||||
from typing import Callable, Any
|
||||
|
||||
import bpy
|
||||
import json
|
||||
from bpy.types import KeyMap, KeyMapItem, UILayout
|
||||
|
||||
if "ADDON_KEYMAPS" not in locals():
|
||||
ADDON_KEYMAPS = []
|
||||
# Preserve across Reload Scripts (module reload) when possible, but also keep
|
||||
# names defined for static analyzers.
|
||||
ADDON_KEYMAPS = locals().get("ADDON_KEYMAPS", [])
|
||||
KMI_HASHES = locals().get("KMI_HASHES", {})
|
||||
|
||||
KEYMAP_ICONS = {
|
||||
'Object Mode': 'OBJECT_DATAMODE',
|
||||
@@ -29,20 +35,30 @@ KEYMAP_UI_NAMES = {
|
||||
}
|
||||
|
||||
KMI_DEFAULTS = {
|
||||
prop: KeyMapItem.bl_rna.properties[prop].default
|
||||
for prop in KeyMapItem.bl_rna.properties.keys()
|
||||
prop.identifier: prop.default
|
||||
for prop in KeyMapItem.bl_rna.properties
|
||||
if hasattr(prop, 'default')
|
||||
}
|
||||
|
||||
def register_hotkey(
|
||||
bl_idname,
|
||||
*,
|
||||
op_kwargs={},
|
||||
hotkey_kwargs={'type': "SPACE", 'value': "PRESS"},
|
||||
bl_idname,
|
||||
*,
|
||||
op_kwargs=None,
|
||||
hotkey_kwargs=None,
|
||||
keymap_name='Window'
|
||||
):
|
||||
global ADDON_KEYMAPS
|
||||
wm = bpy.context.window_manager
|
||||
if op_kwargs is None:
|
||||
op_kwargs = {}
|
||||
if hotkey_kwargs is None:
|
||||
hotkey_kwargs = {'type': "SPACE", 'value': "PRESS"}
|
||||
|
||||
context = bpy.context
|
||||
wm = context.window_manager
|
||||
|
||||
kmi_hash = any_to_hash(op_kwargs, hotkey_kwargs, keymap_name)
|
||||
if kmi_hash in KMI_HASHES:
|
||||
# Avoid re-registering on Reload Scripts.
|
||||
return
|
||||
|
||||
space_type = wm.keyconfigs.default.keymaps[keymap_name].space_type
|
||||
|
||||
@@ -63,26 +79,27 @@ def register_hotkey(
|
||||
# it is SUPPOSED TO stick around for ever.
|
||||
# This allows Blender to store the associated user keymap, meaning the user's modifications
|
||||
# will be stored and restored as expected, whenever the add-on is enabled again.
|
||||
# if (addon_km, existing_kmi) not in ADDON_KEYMAPS:
|
||||
# ADDON_KEYMAPS.append((addon_km, existing_kmi))
|
||||
if (addon_km, existing_kmi) not in ADDON_KEYMAPS:
|
||||
ADDON_KEYMAPS.append((addon_km, existing_kmi))
|
||||
return
|
||||
addon_kmi = addon_km.keymap_items.new(bl_idname, **hotkey_kwargs)
|
||||
for key in op_kwargs:
|
||||
value = op_kwargs[key]
|
||||
setattr(addon_kmi.properties, key, value)
|
||||
|
||||
KMI_HASHES[kmi_hash] = (addon_km, addon_kmi)
|
||||
ADDON_KEYMAPS.append((addon_km, addon_kmi))
|
||||
|
||||
def draw_hotkey_list(
|
||||
context,
|
||||
layout,
|
||||
*,
|
||||
compact=False,
|
||||
debug=False,
|
||||
sort_mode='BY_KEYMAP',
|
||||
ignore_missing=False,
|
||||
button_draw_func: callable=None,
|
||||
):
|
||||
context,
|
||||
layout,
|
||||
*,
|
||||
compact=False,
|
||||
debug=False,
|
||||
sort_mode='BY_KEYMAP',
|
||||
ignore_missing=False,
|
||||
button_draw_func: Callable = None,
|
||||
):
|
||||
"""Draw the list of hotkeys registered by this add-on.
|
||||
Will find the corresponding User KeyMapItems, which are safe to modify.
|
||||
Supports two sorting modes:
|
||||
@@ -99,7 +116,7 @@ def draw_hotkey_list(
|
||||
if sort_mode == 'BY_OPERATOR':
|
||||
layout = layout.column(align=True)
|
||||
|
||||
if compact == None:
|
||||
if compact is None:
|
||||
sidebar = get_sidebar(context)
|
||||
if sidebar:
|
||||
compact = sidebar.width < 600
|
||||
@@ -130,41 +147,57 @@ def draw_hotkey_list(
|
||||
if prev_kmi_name != kmi_name:
|
||||
layout.separator()
|
||||
|
||||
draw_kmi(user_km, user_kmi, layout, compact=compact, button_draw_func=button_draw_func, debug=debug)
|
||||
draw_kmi(
|
||||
user_km,
|
||||
user_kmi,
|
||||
layout,
|
||||
compact=compact,
|
||||
button_draw_func=button_draw_func,
|
||||
debug=debug
|
||||
)
|
||||
|
||||
def get_user_kmis_of_addon(context) -> list[tuple[KeyMap, KeyMapItem]]:
|
||||
|
||||
def get_user_kmis_of_addon(context, *, do_update=True) -> list[tuple[KeyMap, KeyMapItem]]:
|
||||
"""Return a list of (KeyMap, KeyMapItem) tuples of user-shortcuts (the ones that can be modified by user)."""
|
||||
ret = []
|
||||
|
||||
assert bpy.app.version >= (5, 0, 0), "This function requires Blender 5.0 or later."
|
||||
|
||||
context.window_manager.keyconfigs.update()
|
||||
if do_update:
|
||||
context.window_manager.keyconfigs.update()
|
||||
for addon_km, addon_kmi in ADDON_KEYMAPS:
|
||||
user_km = context.window_manager.keyconfigs.user.keymaps.get(addon_km.name)
|
||||
if not user_km:
|
||||
# This should never happen.
|
||||
print("Failed to find User KeyMap: ", addon_km.name)
|
||||
continue
|
||||
user_kmi = user_km.keymap_items.find_match(addon_km, addon_kmi)
|
||||
if not user_kmi:
|
||||
# This shouldn't really happen, but maybe it can, eg. if user changes idname.
|
||||
print("Failed to find User KeyMapItem: ", addon_km.name, addon_kmi.idname)
|
||||
continue
|
||||
ret.append((user_km, user_kmi))
|
||||
user_km, user_kmi = get_user_kmi_of_addon(context, addon_km, addon_kmi)
|
||||
if user_kmi:
|
||||
ret.append((user_km, user_kmi))
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
def get_user_kmi_of_addon(context, addon_km, addon_kmi) -> tuple[KeyMap | None, KeyMapItem | None]:
|
||||
user_km = context.window_manager.keyconfigs.user.keymaps.get(addon_km.name)
|
||||
if not user_km:
|
||||
# This should never happen.
|
||||
print("Failed to find User KeyMap: ", addon_km.name)
|
||||
return None, None
|
||||
user_kmi = user_km.keymap_items.find_match(addon_km, addon_kmi)
|
||||
if not user_kmi:
|
||||
# This shouldn't really happen, but maybe it can, eg. if user changes idname.
|
||||
print("Failed to find User KeyMapItem: ", addon_km.name, addon_kmi.idname)
|
||||
return None, None
|
||||
return user_km, user_kmi
|
||||
|
||||
|
||||
def get_kmi_ui_info(km, kmi) -> tuple[str, str, str]:
|
||||
km_name = km.name
|
||||
km_name: str = km.name
|
||||
km_icon = KEYMAP_ICONS.get(km_name, 'BLANK1')
|
||||
km_name = KEYMAP_UI_NAMES.get(km_name, km_name)
|
||||
if kmi.properties and 'name' in kmi.properties:
|
||||
name = kmi.properties.name
|
||||
if name:
|
||||
try:
|
||||
if hasattr(bpy.types, kmi.properties.name):
|
||||
bpy_type = getattr(bpy.types, kmi.properties.name)
|
||||
kmi_name = bpy_type.bl_label
|
||||
except:
|
||||
else:
|
||||
kmi_name = "Missing (code 1). Try restarting."
|
||||
else:
|
||||
kmi_name = "Missing (code 2). Try restarting."
|
||||
@@ -174,7 +207,7 @@ def get_kmi_ui_info(km, kmi) -> tuple[str, str, str]:
|
||||
bpy_type = getattr(bpy.ops, parts[0])
|
||||
bpy_type = getattr(bpy_type, parts[1])
|
||||
kmi_name = bpy_type.get_rna_type().name
|
||||
except:
|
||||
except (AttributeError, IndexError, TypeError):
|
||||
kmi_name = "Missing (code 3). Try restarting."
|
||||
|
||||
return km_icon, km_name, kmi_name
|
||||
@@ -194,30 +227,49 @@ def find_kmi_in_km_by_data(km: KeyMap, hotkey_kwargs: dict, op_idname: str, op_k
|
||||
if value != getattr(kmi, key):
|
||||
return False
|
||||
|
||||
want_to_crash = False
|
||||
if want_to_crash:
|
||||
# These checks cause https://projects.blender.org/Mets/CloudRig/issues/201
|
||||
# They don't seem necessary.
|
||||
if kmi.properties == None:
|
||||
# IMPORTANT:
|
||||
# `wm.keyconfigs.addon` is shared by *all* add-ons. If we only match on idname+hotkey,
|
||||
# we may incorrectly treat another add-on's KeyMapItem as ours and skip registering,
|
||||
# which prevents Blender from applying the user's stored overrides on next startup
|
||||
# (manifesting as "prefs/hotkeys reset", eg. when used together with Pie Menu Editor).
|
||||
#
|
||||
# We therefore include operator properties in the match, but do it defensively:
|
||||
# - only compare simple scalar types (str/int/float/bool/None)
|
||||
# - if anything unexpected is encountered, treat it as a mismatch rather than risking
|
||||
# false positives or Blender RNA edge-case crashes.
|
||||
if op_kwargs:
|
||||
try:
|
||||
if kmi.properties is None:
|
||||
return False
|
||||
for key, expected in op_kwargs.items():
|
||||
if key not in kmi.properties:
|
||||
return False
|
||||
actual = getattr(kmi.properties, key, None)
|
||||
|
||||
# Compare only stable scalar values.
|
||||
scalar_types = (str, int, float, bool, type(None))
|
||||
if isinstance(expected, scalar_types) and isinstance(actual, scalar_types):
|
||||
if actual != expected:
|
||||
return False
|
||||
else:
|
||||
# Unknown/complex value: do not assume a match.
|
||||
return False
|
||||
except (AttributeError, KeyError, TypeError, RuntimeError):
|
||||
# Be conservative: if Blender throws, don't match.
|
||||
return False
|
||||
for key, value in op_kwargs.items():
|
||||
if key not in kmi.properties:
|
||||
return False
|
||||
if value != kmi.properties[key]:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
return next((kmi for kmi in km.keymap_items if is_kmi_matching(kmi, hotkey_kwargs, op_idname, op_kwargs)), None)
|
||||
|
||||
def draw_kmi(
|
||||
km: KeyMap,
|
||||
kmi: KeyMapItem,
|
||||
layout: UILayout,
|
||||
compact=False,
|
||||
button_draw_func: callable=None,
|
||||
debug=False,
|
||||
):
|
||||
km: KeyMap,
|
||||
kmi: KeyMapItem,
|
||||
layout: UILayout,
|
||||
compact=False,
|
||||
button_draw_func: Callable = None,
|
||||
debug=False,
|
||||
):
|
||||
"""Draw a KeyMapItem in the provided UI.
|
||||
This function is designed specifically to be used in an add-on's preferences:
|
||||
- It does not allow removing the KeyMapItem, since add-on KMIs should never be removed.
|
||||
@@ -249,14 +301,14 @@ def draw_kmi(
|
||||
sub.prop(kmi, "type", text="", full_event=True)
|
||||
|
||||
if kmi.is_user_modified:
|
||||
row2.context_pointer_set("keymap", km) # NOTE: Yes, this actually matters.
|
||||
row2.operator(
|
||||
"preferences.keyitem_restore", text="", icon='BACK'
|
||||
).item_id = kmi.id
|
||||
# Make `context.keymap` available in the drawing code (in this case blender's native code)
|
||||
row2.context_pointer_set("keymap", km)
|
||||
row2.operator("preferences.keyitem_restore", text="", icon='BACK').item_id = kmi.id
|
||||
|
||||
if debug and kmi.show_expanded:
|
||||
layout.template_keymap_item_properties(kmi)
|
||||
|
||||
|
||||
def get_sidebar(context):
|
||||
if not context.area.type == 'VIEW_3D':
|
||||
return None
|
||||
@@ -264,16 +316,17 @@ def get_sidebar(context):
|
||||
if region.type == 'UI':
|
||||
return region
|
||||
|
||||
def find_matching_km_and_kmi(context, target_kc, src_km, src_kmi) -> tuple[KeyMap or None, KeyMapItem or None]:
|
||||
|
||||
def find_matching_km_and_kmi(context, target_kc, src_km, src_kmi) -> tuple[KeyMap | None, KeyMapItem | None]:
|
||||
target_km = find_matching_keymap(context, target_kc, src_km)
|
||||
if not target_km:
|
||||
raise Exception(f"Failed to find KeyMap '{src_km.name}' in KeyConfig '{target_kc.name}'")
|
||||
raise RuntimeError(f"Failed to find KeyMap '{src_km.name}' in KeyConfig '{target_kc.name}'")
|
||||
kc_user = context.window_manager.keyconfigs.user
|
||||
# If we want to find a matching User KeyMapItem, that's easy, because that's what the API was meant for.
|
||||
if target_kc == kc_user:
|
||||
return target_km, target_km.keymap_items.find_match(src_km, src_kmi)
|
||||
|
||||
user_km, user_kmi = src_km, src_kmi
|
||||
user_km = src_km
|
||||
# If we want to find any other type of KeyMapItem, we have to do it indirectly, since we can only directly check for matches in the User KeyConfig.
|
||||
# So eg. if we want to find an Addon KeyMapItem based on a User KeyMapItem, we have to loop over all Addon KeyMapItems, and find which one matches with the given User KeyMapItem.
|
||||
for target_kmi in target_km.keymap_items:
|
||||
@@ -283,14 +336,14 @@ def find_matching_km_and_kmi(context, target_kc, src_km, src_kmi) -> tuple[KeyMa
|
||||
return target_km, target_kmi
|
||||
except RuntimeError:
|
||||
print("Failed to find matching KeyMapItem for: ", target_km.name, target_kmi.to_string())
|
||||
|
||||
|
||||
# raise Exception(f"Failed to find KeyMapItem '{src_kmi.idname}' ({src_kmi.to_string()}) in KeyConfig '{target_kc.name}', KeyMap '{target_km.name}'")
|
||||
# We will return here eg. when looking for an add-on keymap in the default keyconfig.
|
||||
return None, None
|
||||
|
||||
def find_matching_keymap(context, target_kc, src_km):
|
||||
"""Find the equivalent keymap in another keyconfig."""
|
||||
|
||||
|
||||
kc_user = context.window_manager.keyconfigs.user
|
||||
|
||||
# If we want to find a matching User KeyMap, that's easy, because that's what the API was meant for.
|
||||
@@ -306,6 +359,7 @@ def find_matching_keymap(context, target_kc, src_km):
|
||||
if match == src_km:
|
||||
return km
|
||||
|
||||
|
||||
class WINDOW_OT_restore_deleted_hotkeys(bpy.types.Operator):
|
||||
bl_idname = "window.restore_deleted_hotkeys"
|
||||
bl_description = "Restore any missing built-in or add-on hotkeys.\n(These should be disabled instead of being deleted.)\nThis operation cannot be undone!"
|
||||
@@ -318,6 +372,7 @@ class WINDOW_OT_restore_deleted_hotkeys(bpy.types.Operator):
|
||||
self.report({'INFO'}, f"Restored {num_restored} deleted keymaps.")
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
def restore_deleted_keymap_items_global(context) -> int:
|
||||
"""Deleting built-in or add-on KeyMapItems should never be done by users, as there's no way to recover them.
|
||||
Changing the operator name also shouldn't be done, since that makes it impossible to track modifications.
|
||||
@@ -339,6 +394,7 @@ def restore_deleted_keymap_items_global(context) -> int:
|
||||
total_restored += num_restored
|
||||
return total_restored
|
||||
|
||||
|
||||
def restore_deleted_keymap_items(context, user_km_name) -> int:
|
||||
keyconfigs = context.window_manager.keyconfigs
|
||||
user_kc = keyconfigs.user
|
||||
@@ -348,7 +404,7 @@ def restore_deleted_keymap_items(context, user_km_name) -> int:
|
||||
user_km = user_kc.keymaps[user_km_name]
|
||||
|
||||
# Step 1: Store modified and added KeyMapItems in a temp keymap.
|
||||
temp_km_name = "temp_"+user_km_name
|
||||
temp_km_name = "temp_" + user_km_name
|
||||
temp_km = user_kc.keymaps.new(temp_km_name)
|
||||
kmis_user_modified = []
|
||||
kmis_user_defined = []
|
||||
@@ -359,8 +415,8 @@ def restore_deleted_keymap_items(context, user_km_name) -> int:
|
||||
continue
|
||||
if user_kmi.is_user_modified:
|
||||
temp_kmi = temp_km.keymap_items.new_from_item(user_kmi)
|
||||
# Find the original keymap in either the Blender default or Addon KeyConfigs.
|
||||
# Not sure if this works with presets like Industry Compatible keymap,
|
||||
# Find the original keymap in either the Blender default or Addon KeyConfigs.
|
||||
# Not sure if this works with presets like Industry Compatible keymap,
|
||||
# but I assume they change the contents of the "default" keyconfig, so it would work.
|
||||
default_km, default_kmi = find_matching_km_and_kmi(context, default_kc, user_km, user_kmi)
|
||||
if not default_kmi:
|
||||
@@ -370,7 +426,7 @@ def restore_deleted_keymap_items(context, user_km_name) -> int:
|
||||
# Step 2: Restore User KeyMap to default.
|
||||
num_kmis = len(user_km.keymap_items)
|
||||
user_km.restore_to_default()
|
||||
# XXX: restore_to_default() will shuffle the memory addresses, so we need to re-reference user_km.
|
||||
# NOTE: restore_to_default() will shuffle the memory addresses, so we need to re-reference user_km.
|
||||
# I don't think this was the case pre-Blender 5.0!!
|
||||
user_km = user_kc.keymaps[user_km_name]
|
||||
temp_km = user_kc.keymaps[temp_km_name]
|
||||
@@ -381,7 +437,8 @@ def restore_deleted_keymap_items(context, user_km_name) -> int:
|
||||
|
||||
for (default_km, default_kmi), (temp_km, temp_kmi) in kmis_user_modified:
|
||||
user_km, user_kmi = find_matching_km_and_kmi(context, user_kc, default_km, default_kmi)
|
||||
for key in ('active', 'alt', 'any', 'ctrl', 'hyper', 'key_modifier', 'map_type', 'oskey', 'shift', 'repeat', 'type', 'value'):
|
||||
for key in ('active', 'alt', 'any', 'ctrl', 'hyper', 'key_modifier',
|
||||
'map_type', 'oskey', 'shift', 'repeat', 'type', 'value'):
|
||||
setattr(user_kmi, key, getattr(temp_kmi, key))
|
||||
if temp_kmi.properties:
|
||||
for key in temp_kmi.properties.keys():
|
||||
@@ -401,4 +458,23 @@ def restore_deleted_keymap_items(context, user_km_name) -> int:
|
||||
|
||||
return len(user_km.keymap_items) - num_kmis
|
||||
|
||||
def any_to_hash(*args) -> str:
|
||||
"""Hash whatever."""
|
||||
def stable(obj: Any):
|
||||
# Make hashing deterministic across runs and independent of dict insertion order.
|
||||
# Keep it conservative to avoid surprises with Blender RNA objects.
|
||||
if isinstance(obj, dict):
|
||||
return {str(k): stable(v) for k, v in sorted(obj.items(), key=lambda kv: str(kv[0]))}
|
||||
if isinstance(obj, (list, tuple)):
|
||||
return [stable(v) for v in obj]
|
||||
return obj
|
||||
|
||||
try:
|
||||
stringified = json.dumps([stable(a) for a in args], sort_keys=True, separators=(",", ":"), default=str)
|
||||
except (TypeError, ValueError):
|
||||
# Fallback: last resort stringification
|
||||
stringified = ";".join([str(arg) for arg in args])
|
||||
return hashlib.sha256(stringified.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
registry = [WINDOW_OT_restore_deleted_hotkeys]
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import bpy, json, os
|
||||
from bpy.types import PropertyGroup
|
||||
import bpy
|
||||
from bpy.types import AddonPreferences, PropertyGroup
|
||||
from rna_prop_ui import IDPropertyGroup
|
||||
from bpy.types import AddonPreferences
|
||||
|
||||
from .. import __package__ as base_package
|
||||
|
||||
assert base_package
|
||||
|
||||
|
||||
class PrefsFileSaveLoadMixin:
|
||||
"""Mix-in class that can be used by any add-on to store their preferences in a file,
|
||||
so that they don't get lost when the add-on is disabled.
|
||||
@@ -48,6 +52,7 @@ class PrefsFileSaveLoadMixin:
|
||||
return
|
||||
if prefs:
|
||||
prefs.load_and_apply_prefs_from_file()
|
||||
|
||||
bpy.app.timers.register(timer_func, first_interval=delay)
|
||||
|
||||
def apply_prefs_from_dict_recursive(self, propgroup: PropertyGroup, data: dict):
|
||||
@@ -55,14 +60,14 @@ class PrefsFileSaveLoadMixin:
|
||||
if not hasattr(propgroup, key):
|
||||
# Property got removed or renamed in the implementation.
|
||||
continue
|
||||
if type(value) == list:
|
||||
if type(value) is list:
|
||||
for elem in value:
|
||||
collprop = getattr(propgroup, key)
|
||||
entry = collprop.get(elem['name'])
|
||||
entry = collprop.get(elem["name"])
|
||||
if not entry:
|
||||
entry = collprop.add()
|
||||
self.apply_prefs_from_dict_recursive(entry, elem)
|
||||
elif type(value) == dict:
|
||||
elif type(value) is dict:
|
||||
self.apply_prefs_from_dict_recursive(getattr(propgroup, key), value)
|
||||
else:
|
||||
setattr(propgroup, key, value)
|
||||
@@ -125,15 +130,17 @@ def props_to_dict_recursive(propgroup: IDPropertyGroup, skip=[]) -> dict:
|
||||
ret = {}
|
||||
|
||||
for key in propgroup.bl_rna.properties.keys():
|
||||
if key in skip or key in ['rna_type', 'bl_idname']:
|
||||
if key in skip or key in ["rna_type", "bl_idname"]:
|
||||
continue
|
||||
value = getattr(propgroup, key)
|
||||
if isinstance(value, bpy.types.bpy_prop_collection):
|
||||
ret[key] = [props_to_dict_recursive(elem) for elem in value]
|
||||
elif type(value) == IDPropertyGroup or isinstance(value, bpy.types.PropertyGroup):
|
||||
elif isinstance(value, IDPropertyGroup) or isinstance(
|
||||
value, bpy.types.PropertyGroup
|
||||
):
|
||||
ret[key] = props_to_dict_recursive(value)
|
||||
else:
|
||||
if hasattr(propgroup.bl_rna.properties[key], 'enum_items'):
|
||||
if hasattr(propgroup.bl_rna.properties[key], "enum_items"):
|
||||
# Save enum values as string, not int.
|
||||
ret[key] = propgroup.bl_rna.properties[key].enum_items[value].identifier
|
||||
else:
|
||||
@@ -146,7 +153,7 @@ def get_addon_prefs(context=None) -> AddonPreferences | None:
|
||||
context = bpy.context
|
||||
|
||||
addons = context.preferences.addons
|
||||
if base_package.startswith('bl_ext'):
|
||||
if base_package.startswith("bl_ext"):
|
||||
# 4.2 and later
|
||||
addon_key = base_package
|
||||
else:
|
||||
@@ -154,7 +161,7 @@ def get_addon_prefs(context=None) -> AddonPreferences | None:
|
||||
addon_key = base_package.split(".")[0]
|
||||
|
||||
addon = addons.get(addon_key)
|
||||
if addon == None:
|
||||
if addon is None:
|
||||
# print("This happens when packaging the extension, due to the registration delay.")
|
||||
return
|
||||
|
||||
|
||||
@@ -2,54 +2,69 @@
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from typing import Any
|
||||
|
||||
import bpy
|
||||
from bpy.types import PropertyGroup, bpy_prop_collection, Object
|
||||
from rna_prop_ui import IDPropertyGroup
|
||||
from bpy.types import Object, PropertyGroup, bpy_prop_collection
|
||||
from bpy.utils import flip_name
|
||||
from mathutils import Matrix, Vector
|
||||
from rna_prop_ui import IDPropertyGroup
|
||||
|
||||
# Functions to manage runtime properties, which include custom properties and add-on properties.
|
||||
# These functions aim to abstract away that distinction, and also abstract away whether something is a single value,
|
||||
# a PropertyGroup, or a CollectionProperty.
|
||||
# Compatible with the API changes in 5.0, but also older versions.
|
||||
|
||||
|
||||
def copy_all_runtime_properties(src_id, tgt_id, x_mirror=False):
|
||||
"""Copy add-on and custom properties from source to target.
|
||||
"""Copy add-on and custom properties from source to target.
|
||||
Both should be the same type.
|
||||
Should support anything that supports custom properties or property registration.
|
||||
"""
|
||||
for prop_name in get_all_runtime_prop_names(src_id):
|
||||
copy_runtime_property(src_id, tgt_id, prop_name, x_mirror)
|
||||
|
||||
|
||||
def copy_all_custom_properties(src_id, tgt_id, x_mirror=False):
|
||||
for prop_name in get_custom_prop_names(src_id):
|
||||
copy_custom_property(src_id, tgt_id, prop_name=prop_name, x_mirror=x_mirror)
|
||||
|
||||
|
||||
def get_all_runtime_prop_names(owner):
|
||||
custom_props = list(owner.keys())
|
||||
addon_props = get_addon_prop_names(owner)
|
||||
props = custom_props + addon_props
|
||||
return props
|
||||
|
||||
|
||||
def get_custom_prop_names(owner):
|
||||
for prop_name in get_all_runtime_prop_names(owner):
|
||||
if is_custom_prop(owner, prop_name):
|
||||
yield prop_name
|
||||
|
||||
|
||||
def get_addon_prop_names(owner):
|
||||
if bpy.app.version >= (5, 0, 0):
|
||||
sys_props = owner.bl_system_properties_get()
|
||||
if sys_props == None:
|
||||
if sys_props is None:
|
||||
# If there aren't any add-on properties.
|
||||
return []
|
||||
return list(sys_props.keys())
|
||||
else:
|
||||
return [prop_name for prop_name in owner.keys() if is_addon_prop(owner, prop_name)]
|
||||
return [
|
||||
prop_name for prop_name in owner.keys()
|
||||
if is_addon_prop(owner, prop_name)
|
||||
]
|
||||
|
||||
|
||||
def rename_custom_prop(owner, from_name, to_name):
|
||||
assert is_custom_prop(owner, from_name), f"Property {from_name} of {owner} is not a Custom Property."
|
||||
assert is_custom_prop(owner, from_name), (
|
||||
f"Property {from_name} of {owner} is not a Custom Property."
|
||||
)
|
||||
copy_custom_property(owner, owner, from_name, new_name=to_name, x_mirror=False)
|
||||
remove_property(owner, from_name)
|
||||
|
||||
|
||||
def copy_runtime_property(src_id, tgt_id, prop_name, x_mirror=False):
|
||||
"""Copy add-on properties or custom properties."""
|
||||
if is_addon_prop(src_id, prop_name):
|
||||
@@ -64,7 +79,7 @@ def copy_runtime_property(src_id, tgt_id, prop_name, x_mirror=False):
|
||||
copy_single_addon_prop(src_id, tgt_id, prop_name, x_mirror)
|
||||
else:
|
||||
if bpy.app.version >= (5, 0, 0):
|
||||
# HACK: If we need to copy add-on properties, but the add-on is not present,
|
||||
# HACK: If we need to copy add-on properties, but the add-on is not present,
|
||||
# we have to write to the system properties, which is API abuse that could
|
||||
# lose support any moment, but there is no other way to do this atm.
|
||||
tgt_props = tgt_id.bl_system_properties_get()
|
||||
@@ -81,6 +96,7 @@ def copy_runtime_property(src_id, tgt_id, prop_name, x_mirror=False):
|
||||
else:
|
||||
copy_custom_property(src_id, tgt_id, prop_name)
|
||||
|
||||
|
||||
def copy_property_group(src_pg: PropertyGroup, tgt_pg: PropertyGroup, x_mirror=False):
|
||||
"""
|
||||
Copy the values from one PropertyGroup into another of the same type.
|
||||
@@ -90,7 +106,7 @@ def copy_property_group(src_pg: PropertyGroup, tgt_pg: PropertyGroup, x_mirror=F
|
||||
assert tgt_pg.__class__ == src_pg.__class__
|
||||
|
||||
for prop_name in src_pg.bl_rna.properties.keys():
|
||||
if prop_name in ('rna_type', 'bl_rna'):
|
||||
if prop_name in ("rna_type", "bl_rna"):
|
||||
continue
|
||||
if not src_pg.is_property_set(prop_name):
|
||||
tgt_pg.property_unset(prop_name)
|
||||
@@ -108,6 +124,7 @@ def copy_property_group(src_pg: PropertyGroup, tgt_pg: PropertyGroup, x_mirror=F
|
||||
# PropertyGroups also support custom properties.
|
||||
copy_custom_property(src_pg, tgt_pg, prop_name, x_mirror)
|
||||
|
||||
|
||||
def copy_coll_prop(src_cp, tgt_cp, x_mirror=False):
|
||||
tgt_cp.clear()
|
||||
for src_pg in src_cp:
|
||||
@@ -115,6 +132,7 @@ def copy_coll_prop(src_cp, tgt_cp, x_mirror=False):
|
||||
tgt_pg = tgt_cp.add()
|
||||
copy_property_group(src_pg, tgt_pg, x_mirror)
|
||||
|
||||
|
||||
def copy_custom_property(src_owner, tgt_owner, prop_name, new_name="", x_mirror=False):
|
||||
"""Copy a custom property (one that was created via the UI or via Python dictionary syntax)."""
|
||||
if not new_name:
|
||||
@@ -133,10 +151,13 @@ def copy_custom_property(src_owner, tgt_owner, prop_name, new_name="", x_mirror=
|
||||
tgt_owner[new_name] = value
|
||||
new_prop = tgt_owner.id_properties_ui(new_name)
|
||||
new_prop.update_from(src_prop)
|
||||
tgt_owner.property_overridable_library_set(f'["{new_name}"]', src_owner.is_property_overridable_library(f'["{prop_name}"]'))
|
||||
tgt_owner.property_overridable_library_set(
|
||||
f'["{new_name}"]', src_owner.is_property_overridable_library(f'["{prop_name}"]')
|
||||
)
|
||||
return tgt_owner.id_properties_ui(new_name)
|
||||
|
||||
def copy_single_addon_prop(src, tgt, prop_name, x_mirror=False) -> True:
|
||||
|
||||
def copy_single_addon_prop(src, tgt, prop_name, x_mirror=False) -> bool:
|
||||
if src.is_property_readonly(prop_name):
|
||||
# This "early" exit has to come after CollectionProperty & PropertyGroup
|
||||
# checks, since they are technically read-only.
|
||||
@@ -145,10 +166,11 @@ def copy_single_addon_prop(src, tgt, prop_name, x_mirror=False) -> True:
|
||||
value = getattr(src, prop_name)
|
||||
if x_mirror:
|
||||
value = x_mirror_value(value)
|
||||
|
||||
|
||||
setattr(tgt, prop_name, value)
|
||||
return True
|
||||
|
||||
|
||||
def x_mirror_value(value):
|
||||
if isinstance(value, str):
|
||||
return flip_name(value)
|
||||
@@ -157,29 +179,38 @@ def x_mirror_value(value):
|
||||
else:
|
||||
return value
|
||||
|
||||
|
||||
def get_opposite_obj(obj: Object) -> Object:
|
||||
"""Return the X-mirrored version of a Blender object by name (and library if linked)."""
|
||||
flipped_name = flip_name(obj.name)
|
||||
lib = obj.library
|
||||
return (
|
||||
bpy.data.objects.get((lib, flipped_name)) if lib else
|
||||
bpy.data.objects.get(flipped_name)
|
||||
bpy.data.objects.get((lib, flipped_name))
|
||||
if lib
|
||||
else bpy.data.objects.get(flipped_name)
|
||||
) or obj
|
||||
|
||||
|
||||
def is_addon_prop(owner, prop_name):
|
||||
if bpy.app.version >= (5, 0, 0):
|
||||
return prop_name in get_addon_prop_names(owner)
|
||||
else:
|
||||
# NOTE: I don't think it's possible to detect pre-5.0 non-PropertyGroup/CollectionProperty non-registered add-on properties.
|
||||
# They just behave completely as custom properties.
|
||||
return prop_name in owner and (isinstance(owner[prop_name], IDPropertyGroup) or isinstance(owner[prop_name], list))
|
||||
return prop_name in owner and (
|
||||
isinstance(owner[prop_name], IDPropertyGroup)
|
||||
or isinstance(owner[prop_name], list)
|
||||
)
|
||||
|
||||
|
||||
def is_registered_addon_prop(owner, prop_name):
|
||||
return is_addon_prop(owner, prop_name) and prop_name in owner.bl_rna.properties
|
||||
|
||||
|
||||
def is_custom_prop(owner, prop_name):
|
||||
return prop_name in owner.keys() and not is_addon_prop(owner, prop_name)
|
||||
|
||||
|
||||
def remove_property(obj, prop_name):
|
||||
if is_custom_prop(obj, prop_name):
|
||||
del obj[prop_name]
|
||||
@@ -190,3 +221,33 @@ def remove_property(obj, prop_name):
|
||||
del disabled_addon_props[prop_name]
|
||||
else:
|
||||
raise KeyError(f"{prop_name} not found in {obj.name}")
|
||||
|
||||
|
||||
def get_property_defaults(bpy_type: type, exclude: list[str] = []) -> dict[str, Any]:
|
||||
def get_default(prop):
|
||||
if not hasattr(prop, "default"):
|
||||
return None
|
||||
|
||||
if hasattr(prop, "default"):
|
||||
if hasattr(prop, "default_array"):
|
||||
default_array = list(prop.default_array)
|
||||
if default_array:
|
||||
if len(default_array) == 9:
|
||||
return Matrix.Identity(3)
|
||||
elif len(default_array) == 16:
|
||||
return Matrix.Identity(4)
|
||||
elif len(default_array) == 3 and prop.type == 'FLOAT':
|
||||
default = default_array[0]
|
||||
return Vector((default, default, default))
|
||||
else:
|
||||
return default_array
|
||||
|
||||
return prop.default
|
||||
|
||||
assert False, f"Couldn't find default for {prop}"
|
||||
|
||||
return {
|
||||
prop.identifier: get_default(prop)
|
||||
for prop in bpy_type.bl_rna.properties
|
||||
if not prop.is_readonly and prop.identifier not in exclude
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
from bpy.types import UILayout
|
||||
|
||||
def aligned_label(layout: UILayout, *, alert=False, alignment='LEFT', **kwargs):
|
||||
|
||||
def aligned_label(layout: UILayout, *, alert=False, alignment="LEFT", **kwargs):
|
||||
"""Draw some text in the single-column-layout style, ie. offset by 60%."""
|
||||
row = layout.split(factor=0.4)
|
||||
row.separator()
|
||||
@@ -12,11 +13,12 @@ def aligned_label(layout: UILayout, *, alert=False, alignment='LEFT', **kwargs):
|
||||
row.alignment = alignment
|
||||
row.label(**kwargs)
|
||||
|
||||
|
||||
def label_split(layout: UILayout, *, alert=False, **kwargs) -> UILayout:
|
||||
"""Return an empty UILayout with a text label to its left in the single-column-layout style."""
|
||||
split = layout.split(factor=0.4, align=True)
|
||||
split.alert = alert
|
||||
row = split.row(align=True)
|
||||
row.alignment = 'RIGHT'
|
||||
row.alignment = "RIGHT"
|
||||
row.label(**kwargs)
|
||||
return split
|
||||
return split
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import bpy
|
||||
import os
|
||||
from bpy.types import Operator
|
||||
from bpy.props import StringProperty, BoolProperty
|
||||
import json
|
||||
@@ -68,7 +69,14 @@ class WM_OT_call_menu_pie_drag_only(Operator):
|
||||
|
||||
if op_cls.poll():
|
||||
try:
|
||||
return op_cls('INVOKE_DEFAULT', **fallback_op_kwargs)
|
||||
# 1. Execute the original operator and capture the result
|
||||
result = op_cls('INVOKE_DEFAULT', **fallback_op_kwargs)
|
||||
|
||||
# 2. [Added] Check if it's a Save operation and report the message manually
|
||||
if 'FINISHED' in result and self.fallback_operator == 'wm.save_mainfile':
|
||||
# Get current filename, or default to untitled
|
||||
filename = os.path.basename(bpy.data.filepath) if bpy.data.filepath else "untitled.blend"
|
||||
self.report({'INFO'}, f'Saved "{filename}"')
|
||||
except TypeError:
|
||||
# This can apparently happen sometimes, see issue #86.
|
||||
print(f"Pie Menu Fallback Operator failed: {self.fallback_operator}, {self.fallback_op_kwargs}")
|
||||
@@ -96,19 +104,30 @@ class WM_OT_call_menu_pie_drag_only(Operator):
|
||||
*,
|
||||
keymap_name: str,
|
||||
pie_name: str,
|
||||
hotkey_kwargs={'type': "SPACE", 'value': "PRESS"},
|
||||
hotkey_kwargs=None,
|
||||
default_fallback_op="",
|
||||
default_fallback_kwargs={},
|
||||
default_fallback_kwargs=None,
|
||||
on_drag=True,
|
||||
):
|
||||
if hotkey_kwargs is None:
|
||||
hotkey_kwargs = {'type': "SPACE", 'value': "PRESS"}
|
||||
context = bpy.context
|
||||
fallback_operator = default_fallback_op
|
||||
fallback_op_kwargs = default_fallback_kwargs
|
||||
user_kc = context.window_manager.keyconfigs.user
|
||||
km = user_kc.keymaps.get(keymap_name)
|
||||
fallback_op_kwargs = default_fallback_kwargs if default_fallback_kwargs is not None else {}
|
||||
|
||||
# IMPORTANT:
|
||||
# Do NOT derive fallback operator/kwargs from the USER keyconfig.
|
||||
# Other add-ons (eg. Pie Menu Editor) can legitimately alter user keymaps,
|
||||
# and baking that dynamic state into our add-on KeyMapItem properties can
|
||||
# make Blender fail to match/restore user overrides across restarts.
|
||||
#
|
||||
# Instead, derive fallback from the DEFAULT (active preset) keyconfig,
|
||||
# which is stable and represents expected built-in behavior.
|
||||
default_kc = context.window_manager.keyconfigs.default
|
||||
km = default_kc.keymaps.get(keymap_name)
|
||||
if km:
|
||||
for kmi in km.keymap_items:
|
||||
for i, condition in enumerate([
|
||||
for condition in [
|
||||
kmi.idname != 'wm.call_menu_pie_drag_only',
|
||||
kmi.type == hotkey_kwargs.get('type', ""),
|
||||
kmi.value == hotkey_kwargs.get('value', "PRESS"),
|
||||
@@ -119,7 +138,7 @@ class WM_OT_call_menu_pie_drag_only(Operator):
|
||||
kmi.any == hotkey_kwargs.get('any', False),
|
||||
kmi.key_modifier == hotkey_kwargs.get('key_modifier', 'NONE'),
|
||||
kmi.active
|
||||
]):
|
||||
]:
|
||||
if not condition:
|
||||
break
|
||||
else:
|
||||
@@ -133,14 +152,19 @@ class WM_OT_call_menu_pie_drag_only(Operator):
|
||||
op_kwargs={
|
||||
'name': pie_name,
|
||||
'fallback_operator': fallback_operator,
|
||||
'fallback_op_kwargs': json.dumps(fallback_op_kwargs),
|
||||
# Deterministic JSON (sort_keys=True) helps keep KMI identity stable across sessions.
|
||||
'fallback_op_kwargs': json.dumps(fallback_op_kwargs, sort_keys=True),
|
||||
'on_drag': on_drag,
|
||||
},
|
||||
hotkey_kwargs=hotkey_kwargs,
|
||||
keymap_name=keymap_name,
|
||||
)
|
||||
|
||||
def register():
|
||||
bpy.utils.register_class(WM_OT_call_menu_pie_drag_only)
|
||||
|
||||
registry = [
|
||||
WM_OT_call_menu_pie_drag_only,
|
||||
]
|
||||
def unregister():
|
||||
# HACK: As a workaround to https://projects.blender.org/blender/blender/issues/150229, we do not unregister
|
||||
# this operator when the add-on is uninstalled, which is pretty bad.
|
||||
# bpy.utils.unregister_class(WM_OT_call_menu_pie_drag_only)
|
||||
pass
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import bpy
|
||||
from .bs_utils.prefs import get_addon_prefs
|
||||
from .prefs import draw_prefs
|
||||
|
||||
class VIEW3D_PT_extra_pies(bpy.types.Panel):
|
||||
bl_label = "Extra Pies"
|
||||
|
||||
Reference in New Issue
Block a user