Best practice for addon key bindings - own preferences or Blender's?

tl;dr: How can I let the user define presets that tie together a user-defined property (eg colour, location, size, duration) with a user-defined key binding?


Context

I am writing an addon to speed up working with text sequences in the VSE (see my last question for more info)). I can do all the individual bits if I hard-code them which is fine for my own use, but I’d like to let users define their own presets. So I have a query or two about addon preferences key bindings:

(1) Should an addon, whose primary or sole interface are hotkeys, let the user define their key bindings via the addon’s preferences panel; or via Blender’s own keymap editor?

I can see arguments for and argainst either option, or even for doing both†! The Human Interface Guidelines don’t make a suggestion either way.

Addon preferences Blender Keymap Both
:white_check_mark: Keeps hotkeys with actions/presets :white_check_mark: Reuses existing interface :white_check_mark: Upsides of both worlds
:white_check_mark: Less switching back-and-forth :white_check_mark: Less chance of bugs
:x: Reimplementing existing functionality :x: Tedious setup :x: Downsides of both worlds

(†: I am aware that custom hotkeys added to keymap_items will show up in the keymap editor, so even managing hotkeys ‘only’ with the addon results in ‘both’)

Addon preferences approach

I looked at doing it via addon preferences, hoping to end up with something like this:

Please forgive the non-descriptive names etc, this is an example of the layout left over from an earlier version where hotkeys were hard-coded in the script!

I’ve got code working for storing a colour name + colour:

I used a CollectionProperty (which to my understanding works like a list) in the preferences and a custom class PropertyGroup to store the colour_name and colour; with an operator to add a new item to the list:

class NewQTEPreset(bpy.types.Operator):
    """Create a new QTE preset"""
    bl_idname = "qte.newpreset"
    bl_label = "Add a new preset"

    def execute(self, context):
        addonprefs = context.preferences.addons[__name__].preferences
        newpreset = addonprefs.presets.add()

        return {'FINISHED'}


class ColourPresets(bpy.types.PropertyGroup):
    colour_name: bpy.props.StringProperty(
        name="Name",
        description="Colour preset",
        default="Red"
    )

    colour: bpy.props.FloatVectorProperty(
        name="Text colour",
        subtype='COLOR',
        description="Colour for text",
        size=4,
        min=0.0,
        max=1.0,
        default=(1.0, 0.0, 0.0, 1),  # red in RGBA
        )


class QTEPreferences(bpy.types.AddonPreferences):
    bl_idname = __name__

    presets: bpy.props.CollectionProperty(type=ColourPresets)

    def draw(self, context):
        layout = self.layout

        for preset in self.presets:
            row = layout.row()
            row.prop(preset, "colour_name")
            row.prop(preset, "colour")

        layout.operator("qte.newpreset", icon='ADD')

(As an aside, I’m not sure how kosher the context.preferences.addons[__name__].preferences line is for adding to the addons’ preferences property is; I think this could be done another way.)

The Problem

Where I’m tripping up is tying in to the key bindings!

I thought I could add another property to the ColourPresets PropertyGroup; there’s no property for key bindings (?), so I figured I could use a PointerProperty to point to a (new, empty) KeyMapItem created via an appropriate call to km.keymap_items.new(). However, the following property definition (part of the ColourPresets ProeprtyGroup) fails:

keymapitem: bpy.props.PointerProperty(
        name="Key",
        type=bpy.types.KeyMapItem,
    )

fails on registration with:

TypeError: PointerProperty(...) expected an RNA type derived from ID or ID Property Group
(...)
ValueError: bpy_struct "ColourPresets" registration error: 'keymapitem' PointerProperty could not register (see previous error)

(2) How can I let the user define presets that tie together a user-defined property (eg colour, location, size, duration) with a user-defined key binding?

Despite having dozens of tabs open across the API reference, here and BSE, I’m still pretty unfamiliar with the API, so if there’s something obvious I’ve overlooked I’d be grateful if that could be pointed out :smile:

:duck:for (2): Using an IntProperty to store the KeyMapItem.id and then using keymap_items.from_id() looks like a potentially promising approach.

This post over on devtalk pointed me in the direction of doing a lookup, rather than storing a reference / pointer to the object directly:

(I think I may have seen something similar on BSE)

However, even if this approach works for creating a keymap and referencing it from the preferences panel, I’m not sure how to set the reference from the keymap operator back to the preset (the colour in this case)- the keymapinstance is created and the operator property is set using kmi.properties.colour (as in the example in the other thread linked), but this seems to be assigned by value, rather than by reference:

ie the kmi.properties.colour is set to [1,0,0,1] (the default value for the preset property .colour), and does not update when the preferences change.

Even if I point the preferences panel at kmi.properties.colour directly, it doesn’t work as expected- the colour associated with the key binding only updates when I add a new binding!

Sample code

For the addon preferences class:

 def draw(self, context):
        layout = self.layout

        for preset in self.presets:
            kmi = context.window_manager.\
                keyconfigs.addon.keymaps['Sequencer'].keymap_items.\
                from_id(preset.keymapitemid)
            row = layout.row()
            row.prop(preset, "colour_name")
            row.prop(kmi.properties, "colour")
            row.prop(kmi, "type", text="", full_event=True)
            row.operator(
                "preferences.keyitem_remove",
                text="",
                icon='X'
            ).item_id = preset.keymapitemid

        layout.operator("qte.newpreset", icon='ADD')

demo:

Description: I add a preset, change the colour (blue), hit the hotkey- the operator is called with the default value. I then change the key binding so not to conflict with the next preset and add another preset. Now pressing the hotkey changes to the colour I set (blue)! I set the colour to another (green), but pressing the key changes/keeps the text blue. Adding a third preset ‘updates’ the colour again and pressing the hotkey changes the colour to the updated one (green)

:confounded:

How should I get the operator to use the reference to the preferences (QTEPreferences.presets[item].colour), as opposed to a one off value? Or, is there a way to update the colour property set by the hotkey (kmi.properties.colour) when the colour changes in addon preferences? Or should I use a different approach altogether? :upside_down_face:

For clarity:

Lastly, as I am clearly having issues, I am still happy to hear thoughts on Q (1) if the approach is fundamentally misguided!

Q (1) What’s the best practice with addon key bindings?

I couldn’t find clear consensus on this. It seems that addon authors do as they see best.

However, there is an approach laid out by Bookyakuno which attmepts to make this easier by creating a layout for items in a list of keymap items (addon_keymaps). This code might be helpful for others as-is, or as a starting point:

I would welcome further input on this :slight_smile:

Q (2) How can I associate a keymap item with a property (like colour) in addon preferences?

I felt like I was going in circles on this one!

Demo/example:

I’m not sure why this happens, but seems that if, say, the .active property is set to True on the keybinding, this causes the binding to update. This is the case even if kmi.active == True already! :upside_down_face:

This feels like a bug, but I don’t have the experience to say for sure.

Obviously guddling with the console to update a key binding-associated property is not convenient!

Thankfully there is a workaround- a “save key bindings” operator can set the required property:

Code
class SAMPLE_OT_DirtyKeymap(bpy.types.Operator) :
    bl_idname = "addon.sample_dirty_keymap"
    bl_label = "Save Keymap"

    def execute(self, context):
        km = context.window_manager.keyconfigs.user.keymaps["MyAddon"] :
        km.show_expanded_items = km.show_expanded_items
        for kmi in km.keymap_items :
            kmi.active = kmi.active
        context.preferences.is_dirty = True
        return {'FINISHED'}

This seems to work:

But it is very much a workaround!