Files
2026-03-17 14:58:51 -06:00

610 lines
21 KiB
Python

from typing import List, Dict, Tuple, Optional
import bpy
from bpy.types import KeyConfig, KeyMap, KeyMapItem, Operator
def addon_hotkey_register(
keymap_name='Window',
op_idname='',
key_id='A',
event_type='PRESS',
any=False,
ctrl=False,
alt=False,
shift=False,
oskey=False,
key_modifier='NONE',
direction='ANY',
repeat=False,
op_kwargs={},
add_on_conflict=True,
warn_on_conflict=True,
error_on_conflict=False,
):
"""Top-level function for registering a hotkey as conveniently as possible.
If you want to better manage the registered hotkey (for example, to be able
to un-register it), it's advised to instantiate PyKeyMapItems yourself instead.
:param str keymap_name: Name of the KeyMap that this hotkey will be created in. Used to define what contexts the hotkey is available in
:param str op_idname: bl_idname of the operator this hotkey should execute
:param str key_id: Name of the key that must be interacted with to trigger this hotkey
:param str event_type: Type of interaction to trigger this hotkey
:param bool any: If True, all modifier keys will be valid to trigger this hotkey
:param bool ctrl: Whether the Ctrl key needs to be pressed in addition to the primary key
:param bool alt: Whether the Alt key needs to be pressed in addition to the primary key
:param bool shift: Whether the Shift key needs to be pressed in addition to the primary key
:param bool oskey: Whether the OS key needs to be pressed in addition to the primary key
:param str key_modifier: Another non-modifier key that should be used as a modifier key
:param str direction: For interaction methods with a direction, this defines the direction
:param bool repeat: Whether the hotkey should repeat its action as long as the keys remain held
:param op_kwargs: A dictionary of parameters that should be passed as operator parameters
:return: The PyKeyMapItem that manages this hotkey
"""
py_kmi = PyKeyMapItem(
op_idname=op_idname,
key_id=key_id,
event_type=event_type,
any=any,
ctrl=ctrl,
alt=alt,
shift=shift,
oskey=oskey,
key_modifier=key_modifier,
direction=direction,
repeat=repeat,
op_kwargs=op_kwargs,
)
keymap, kmi = py_kmi.register(
keymap_name=keymap_name,
add_on_conflict=add_on_conflict,
warn_on_conflict=warn_on_conflict,
error_on_conflict=error_on_conflict,
)
return keymap, kmi
class PyKeyMapItem:
"""Class to help conveniently manage a single KeyMapItem, independently of
any particular KeyMap or any other container or built-in bpy_type."""
def __init__(
self,
op_idname='',
key_id='A',
event_type='PRESS',
any=False,
ctrl=False,
alt=False,
shift=False,
oskey=False,
key_modifier='NONE',
direction='ANY',
repeat=False,
op_kwargs={},
):
self.op_idname = op_idname
self.key_id = self.type = key_id
self.check_key_id()
self.event_type = self.value = event_type
self.check_event_type()
self.any = any
self.ctrl = ctrl
self.alt = alt
self.shift = shift
self.oskey = oskey
self.key_modifier = key_modifier
self.direction = direction
self.repeat = repeat
self.op_kwargs = op_kwargs
@staticmethod
def new_from_keymap_item(kmi: KeyMapItem, context=None) -> "PyKeyMapItem":
op_kwargs = {}
if kmi.properties:
op_kwargs = {key: value for key, value in kmi.properties.items()}
return PyKeyMapItem(
op_idname=kmi.idname,
key_id=kmi.type,
event_type=kmi.value,
any=kmi.any,
ctrl=kmi.ctrl,
alt=kmi.alt,
shift=kmi.shift,
oskey=kmi.oskey,
key_modifier=kmi.key_modifier,
direction=kmi.direction,
repeat=kmi.repeat,
op_kwargs=op_kwargs,
)
def check_key_id(self):
"""Raise a KeyMapException if the keymap_name isn't a valid KeyMap name that
actually exists in Blender's keymap system.
"""
return check_key_id(self.key_id)
def check_event_type(self):
"""Raise a KeyMapException if the event_type isn't one that actually exists
in Blender's keymap system."""
return check_event_type(self.event_type)
@property
def key_string(self) -> str:
return get_kmi_key_string(self)
def register(
self,
context=None,
keymap_name='Window',
*,
add_on_conflict=True,
warn_on_conflict=True,
error_on_conflict=False,
) -> Optional[Tuple[KeyMap, KeyMapItem]]:
"""Higher-level function for addon dev convenience.
The caller doesn't have to worry about the KeyConfig or the KeyMap.
The `addon` KeyConfig will be used.
"""
if not context:
context = bpy.context
wm = context.window_manager
kconf_addon = wm.keyconfigs.addon
if not kconf_addon:
# This happens when running Blender in background mode.
return
check_keymap_name(keymap_name)
# Find conflicts.
user_km = get_keymap_of_config(wm.keyconfigs.user, keymap_name)
if not user_km:
conflicts = []
else:
conflicts = self.find_in_keymap_conflicts(user_km)
kmi = None
keymap = None
if not conflicts or add_on_conflict:
# Add the keymap if there is no conflict, or if we are allowed
# to add it in spite of a conflict.
# If this KeyMap already exists, new() will return the existing one,
# which is confusing, but ideal.
space_type, region_type = get_ui_types_of_keymap(keymap_name)
keymap = kconf_addon.keymaps.new(
name=keymap_name, space_type=space_type, region_type=region_type
)
kmi = self.register_in_keymap(keymap)
# Warn or raise error about conflicts.
if conflicts and (warn_on_conflict or error_on_conflict):
conflict_info = "\n".join(["Conflict: " + kmi_to_str(kmi) for kmi in conflicts])
if error_on_conflict:
raise KeyMapException("Failed to register KeyMapItem due to conflicting items:" + conflict_info)
if warn_on_conflict:
print(
"Warning: Conflicting KeyMapItems: " + str(self) + "\n" + conflict_info
)
return keymap, kmi
def register_in_keymap(self, keymap: KeyMap) -> Optional[KeyMapItem]:
"""Lower-level function, for registering in a specific KeyMap."""
kmi = keymap.keymap_items.new(
self.op_idname,
type=self.key_id,
value=self.event_type,
any=self.any,
ctrl=self.ctrl,
alt=self.alt,
shift=self.shift,
oskey=self.oskey,
key_modifier=self.key_modifier,
direction=self.direction,
repeat=self.repeat,
)
for key in self.op_kwargs:
value = self.op_kwargs[key]
setattr(kmi.properties, key, value)
return kmi
def unregister(self, context=None) -> bool:
"""Higher-level function for addon dev convenience.
The caller doesn't have to worry about the KeyConfig or the KeyMap.
The hotkey will be removed from all KeyMaps of both `addon` and 'user' KeyConfigs.
"""
if not context:
context = bpy.context
wm = context.window_manager
kconfs = wm.keyconfigs
success = False
for kconf in (kconfs.user, kconfs.addon):
if not kconf:
# This happens when running Blender in background mode.
continue
for km in self.find_containing_keymaps(kconf):
self.unregister_from_keymap(km)
success = True
return success
def unregister_from_keymap(self, keymap: KeyMap):
"""Lower-level function, for unregistering from a specific KeyMap."""
kmi = self.find_in_keymap_exact(keymap)
if not kmi:
return False
keymap.keymap_items.remove(kmi)
return True
def find_containing_keymaps(self, key_config: KeyConfig) -> List[KeyMap]:
"""Return list of KeyMaps in a KeyConfig that contain a matching KeyMapItem."""
matches: List[KeyMap] = []
for km in key_config.keymaps:
match = self.find_in_keymap_exact(km)
if match:
matches.append(km)
return matches
def find_in_keymap_exact(self, keymap: KeyMap) -> Optional[KeyMapItem]:
"""Find zero or one KeyMapItem in the given KeyMap that is an exact match
with this in its operator, parameters, and key binding.
More than one will result in an error.
"""
matches = self.find_in_keymap_exact_multi(keymap)
if len(matches) > 1:
# This should happen only if an addon dev or a user creates two keymaps
# that are identical in everything except their ``repeat`` flag.
raise KeyMapException(
"More than one KeyMapItems match this PyKeyMapItem: \n"
+ str(self)
+ "\n".join([str(match) for match in matches])
)
if matches:
return matches[0]
def find_in_keymap_exact_multi(self, keymap: KeyMap) -> List[KeyMapItem]:
"""Return KeyMapItems in the given KeyMap that are an exact match with
this PyKeyMapItem in its operator, parameters, and key binding.
"""
return [kmi for kmi in keymap.keymap_items if self.compare_to_kmi_exact(kmi)]
def compare_to_kmi_exact(self, kmi: KeyMapItem) -> bool:
"""Return whether we have the same operator, params, and trigger
as the passed KeyMapItem.
"""
return self.compare_to_kmi_by_operator(
kmi, match_kwargs=True
) and self.compare_to_kmi_by_trigger(kmi)
def find_in_keymap_by_operator(
self, keymap: KeyMap, *, match_kwargs=True
) -> List[KeyMapItem]:
"""Return all KeyMapItems in the given KeyMap, which triggers the given
operator with the given parameters.
"""
return [
kmi
for kmi in keymap.keymap_items
if self.compare_to_kmi_by_operator(kmi, match_kwargs=match_kwargs)
]
def compare_to_kmi_by_operator(self, kmi: KeyMapItem, *, match_kwargs=True) -> bool:
"""Return whether we have the same operator
(and optionally operator params) as the passed KMI.
"""
if kmi.idname != self.op_idname:
return False
if not match_kwargs:
return True
# Check for mismatching default-ness of operator parameters.
if set(kmi.properties.keys()) != set(self.op_kwargs.keys()):
# This happens when the parameter overrides specified in the KMI
# aren't the same as what we're searching for.
return False
# Check for mismatching values of operator parameters.
for prop_name in kmi.properties.keys():
# It's important to use getattr() instead of dictionary syntax here,
# otherwise enum values will be integers instead of identifier strings.
value = getattr(kmi.properties, prop_name)
if value != self.op_kwargs[prop_name]:
return False
return True
def find_in_keymap_conflicts(self, keymap: KeyMap) -> List[KeyMapItem]:
"""Return any KeyMapItems in the given KeyMap which are bound to the
same key combination.
"""
return [
kmi for kmi in keymap.keymap_items if self.compare_to_kmi_by_trigger(kmi)
]
def compare_to_kmi_by_trigger(self, kmi: KeyMapItem) -> bool:
"""Return whether we have the same trigger settings as the passed KMI."""
return (
kmi.type == self.key_id
and kmi.value == self.event_type
and kmi.any == self.any
and kmi.ctrl == self.ctrl
and kmi.alt == self.alt
and kmi.shift == self.shift
and kmi.oskey == self.oskey
and kmi.key_modifier == self.key_modifier
and kmi.direction == self.direction
)
def get_user_kmis(self, context=None) -> List[KeyMapItem]:
"""Return all matching KeyMapItems in the user keyconfig."""
if not context:
context = bpy.context
user_kconf = context.window_manager.keyconfigs.user
matches = []
for km in user_kconf.keymaps:
for kmi in km.keymap_items:
if self.compare_to_kmi_exact(kmi):
matches.append(kmi)
return matches
def update(self, **kwargs):
"""Update all KeyMapItems with the passed keyword arguments."""
for key, value in kwargs.items():
for kmi in self.get_user_kmis():
setattr(kmi, key, value)
setattr(self, key, value)
def __str__(self) -> str:
"""Return an informative but compact string representation."""
ret = f"PyKeyMapItem: < {self.key_string}"
if self.op_idname:
op = find_operator_class_by_bl_idname(self.op_idname)
if not op:
ret += " | " + self.op_idname + " (Unregistered)"
else:
op_ui_name = op.name if hasattr(op, 'name') else op.bl_label
op_class_name = op.bl_rna.identifier
ret += " | " + op_ui_name + f" | {self.op_idname} | {op_class_name}"
if self.op_kwargs:
ret += " | " + str(self.op_kwargs)
else:
ret += " | (No operator assigned.)"
return ret + " >"
def __repr__(self):
"""Return a string representation that evaluates back to this object."""
pretty_kwargs = str(self.op_kwargs).replace(", ", ",\n")
return (
"PyKeyMapItem(\n"
f" op_idname='{self.op_idname}',\n"
f" key_id='{self.key_id}',\n"
f" event_type='{self.event_type}',\n"
"\n"
f" any={self.any},\n"
f" ctrl={self.ctrl},\n"
f" alt={self.alt},\n"
f" shift={self.shift},\n"
f" oskey={self.oskey},\n"
f" key_modifier='{self.key_modifier}',\n"
f" direction='{self.direction}',\n"
f" repeat='{self.repeat}',\n"
"\n"
f" op_kwargs={pretty_kwargs}\n"
")"
)
def kmi_to_str(kmi: KeyMapItem) -> str:
"""Similar to PyKeyMapItem.__str__: Return a compact string representation of this KeyMapItem."""
ret = f"KeyMapItem: < {get_kmi_key_string(kmi)}"
if kmi.idname:
op = find_operator_class_by_bl_idname(kmi.idname)
if not op:
ret += " | " + kmi.idname + " (Unregistered)"
else:
op_ui_name = op.name if hasattr(op, 'name') else op.bl_label
op_class_name = op.bl_rna.identifier
ret += " | " + op_ui_name + f" | {kmi.idname} | {op_class_name}"
# if kmi.properties: # TODO: This currently causes a crash: https://projects.blender.org/blender/blender/issues/111702
# ret += " | " + str({key:value for key, value in kmi.properties.items()})
else:
ret += " | (No operator assigned.)"
return ret + " >"
def get_kmi_key_string(kmi) -> str:
"""A user-friendly description string of the keys needed to activate this hotkey.
Should be identical to what's displayed in Blender's Keymap preferences.
"""
key_data = get_enum_values(bpy.types.KeyMapItem, 'type')
keys = []
if kmi.shift:
keys.append("Shift")
if kmi.ctrl:
keys.append("Ctrl")
if kmi.alt:
keys.append("Alt")
if kmi.oskey:
keys.append("OS")
if kmi.key_modifier != 'NONE':
keys.append(key_data[kmi.key_modifier][0])
keys.append(key_data[kmi.type][0])
final_string = " ".join(keys)
if not final_string:
return "Unassigned"
return final_string
def get_keymap_of_config(keyconfig: KeyConfig, keymap_name: str) -> Optional[KeyMap]:
space_type, region_type = get_ui_types_of_keymap(keymap_name)
keymap = keyconfig.keymaps.find(
keymap_name, space_type=space_type, region_type=region_type
)
return keymap
def ensure_keymap_in_config(keyconfig, keymap_name: str) -> KeyMap:
space_type, region_type = get_ui_types_of_keymap(keymap_name)
keymap = keyconfig.keymaps.new(
keymap_name, space_type=space_type, region_type=region_type
)
return keymap
def get_enum_values(bpy_type, enum_prop_name: str) -> Dict[str, Tuple[str, str]]:
"""Given a registered EnumProperty's owner and name, return the enum's
possible states as a dictionary, mapping the enum identifiers to a tuple
of its name and description.
:param bpy_type: The RNA type that owns the Enum property
:param str enum_prop_name: The name of the Enum property
:return: A dictionary mapping the enum's identifiers to its name and description
:rtype: dict{str: (str, str)}
"""
# If it's a Python Operator.
if isinstance(bpy_type, Operator):
try:
enum_items = bpy_type.__annotations__[enum_prop_name].keywords['items']
return {e[0]: (e[1], e[2]) for e in enum_items}
except:
return
# If it's a built-in operator.
enum_items = bpy_type.bl_rna.properties[enum_prop_name].enum_items
return {e.identifier: (e.name, e.description) for e in enum_items}
def get_all_keymap_names() -> List[str]:
"""Returns a list of all keymap names in Blender.
:return: A list of all valid keymap names
:rtype: list[str]
"""
return bpy.context.window_manager.keyconfigs.default.keymaps.keys()
def get_ui_types_of_keymap(keymap_name: str) -> Tuple[str, str]:
# The default KeyConfig contains all the possible valid KeyMap names,
# with the correct space_type and region_type already assigned.
kc_default = bpy.context.window_manager.keyconfigs.default
# This is useful to acquire the correct parameters for new KeyMapItems,
# since having the wrong params causes the KeyMapItem to fail silently.
check_keymap_name(keymap_name)
km = kc_default.keymaps.get(keymap_name)
assert km, f"Error: KeyMap not found: '{keymap_name}'"
return km.space_type, km.region_type
def find_operator_class_by_bl_idname(bl_idname: str):
"""
Returns the class of the operator registered with the given bl_idname.
:param str bl_idname: Identifier of the operator to find
:return: Class of the operator registered with the given bl_idname
:rtype: bpy.types.Operator (for Python ops) or bpy_struct (for built-ins)
"""
# Try Python operators first.
for cl in Operator.__subclasses__():
if not hasattr(cl, 'bl_idname'):
# This can happen with mix-in classes.
continue
if cl.bl_idname == bl_idname:
return cl
# Then built-ins.
module_name, op_name = bl_idname.split(".")
module = getattr(bpy.ops, module_name)
if not module:
return
op = getattr(module, op_name)
if not op:
return
return op.get_rna_type()
class KeyMapException(Exception):
"""Raised when a KeyMapItem cannot (un)register."""
pass
def check_keymap_name(keymap_name: str):
"""Raise a KeyMapException if the keymap_name isn't a valid KeyMap name that
actually exists in Blender's keymap system.
"""
all_km_names = get_all_keymap_names()
is_valid = keymap_name in all_km_names
if not is_valid:
print("All valid keymap names:")
print("\n".join(all_km_names))
raise KeyMapException(
f'"{keymap_name}" is not a valid keymap name. Must be one of the above.'
)
def check_key_id(key_id: str):
"""Raise a KeyMapException if the key_id isn't one that actually exists
in Blender's keymap system.
"""
all_valid_key_identifiers = get_enum_values(KeyMapItem, 'type')
is_valid = key_id in all_valid_key_identifiers
if not is_valid:
print("All valid key identifiers and names:")
print("\n".join(list(all_valid_key_identifiers.items())))
raise KeyMapException(
f'"{key_id}" is not a valid key identifier. Must be one of the above.'
)
def check_event_type(event_type: str):
"""Raise a KeyMapException if the event_type isn't one that actually exists
in Blender's keymap system.
"""
all_valid_event_types = get_enum_values(KeyMapItem, 'value')
is_valid = event_type in all_valid_event_types
if not is_valid:
print("All valid event names:")
print("\n".join(list(all_valid_event_types.keys())))
raise KeyMapException(
f'"{event_type}" is not a valid event type. Must be one of the above.'
)
return is_valid
def find_broken_items_of_keymap(keymap: bpy.types.KeyMap):
"""I encountered one case where kmi.properties.keys() resulted in an error.
If that happens again, use this func to troubleshoot.
"""
broken = []
for kmi in keymap.keymap_items:
try:
kmi.properties.keys()
except:
broken.append(kmi)
return broken