610 lines
21 KiB
Python
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
|