Do all buttons on a menu need to be an operator?

Hi,

Do all buttons on a menu need to be an operator?
I’m trying to create Add Constraint Pop Up Menu (Ctrl+Shift+C).

  1. If I have 25 buttons, I’ll have 25 operators, that would be too much to manage.
  2. Also, if they need to be an operator, I can’t use it as a script.

Basically, I want to do this kind of code:

script_list = os.listdir()
for script in script_list:
    layout.add_button(script) 

And then when I execute a button, the external script will be executed.

Is this possible?

In short, yes. Each button, unless it’s a bool property with toggle=True flag, is an operator.

Some operators, however, are so-called enum operators, where an EnumProperty controls the type of action the operator does. Blender has a special layout for these called layout.operator_enum(op_name, enum_name).

When this is called in a menu layout, each item of the enum is placed as its own button. When one of these buttons are clicked, the operator is specifically called with the enum identifier as argument.

As an example, try adding to your menu:

layout.operator_enum("object.light_add", "type")

To build your own enum operator, the syntax is essentially:

class OBJECT_OT_some_operator(bpy.types.Operator):
    bl_idname = "object.some_operator"
    bl_label = "Some Operator"

    enum_items = (
        ("item1", "Item 1", "Description 1"),
        ("item2", "Item 2", "Description 2"),
        ("item3", "Item 3", "Description 3"),
    )
    action: bpy.props.EnumProperty(items=enum_items)

    def execute(self, context):
        print(self.action)
        return {'FINISHED'}

To place a single operator with just one of the items:

layout.operator("object.some_operator").action = 'item1'

To place all items:

layout.operator("object.some_operator", "action")

It’s also possible to have an operator dynamically generate enums. This generates an enum using a simple range(), though you could use anything, like dynamically getting a piece of script. Note that the function must take self and context. self points to the operator instance.

def generate_enum(self, context):
    enum = []

    for index in range(10):
        id_ = "item" + str(index)  # id_ used by operator.
        name = id_  # Name is shown in ui
        desc = "Description " + str(index)  # Description.
        icon = "FILE"  # Icon name. Can also be an integer.
        enum.append((id_, name, desc, icon, index))
    return enum

This requires the items argument of an enum property to point to the function.

action: bpy.props.EnumProperty(items=generate_enum)

image

The whole thing:

import bpy

class SimpleCustomMenu(bpy.types.Menu):
    bl_label = "Simple Custom Menu"
    bl_idname = "OBJECT_MT_simple_custom_menu"

    def draw(self, context):
        layout = self.layout
        layout.operator_enum("object.some_operator", "action")

def generate_enum(self, context):
    enum = []

    for index in range(10):
        id_ = "item" + str(index)  # id_ used by operator.
        name = id_  # Name is shown in ui
        desc = "Description " + str(index)  # Description.
        icon = "FILE"  # Icon name. Can also be an integer.
        enum.append((id_, name, desc, icon, index))
    return enum

class OBJECT_OT_some_operator(bpy.types.Operator):
    bl_idname = "object.some_operator"
    bl_label = "Some Operator"

    action: bpy.props.EnumProperty(items=generate_enum)

    def execute(self, context):
        print(self.action)
        return {'FINISHED'}

if __name__ == "__main__":
    bpy.utils.register_class(OBJECT_OT_some_operator)
    bpy.utils.register_class(SimpleCustomMenu)
    bpy.ops.wm.call_menu(name=SimpleCustomMenu.bl_idname)
4 Likes

@iceythe

Thanks for the response.
Ignore what I just said. Lol.
The print function is on the console (a separate window).

Will try it on a separate script.

Hi @iceythe

Just tried it with an external script when I press the button it prints
scripts.print_hello_universe.main()

What I would expect is
Hello Universe which is the output of the scripts.print_hello_universe.main().

Here is the working code:

import bpy
import sys
import os
import importlib

script_dir = r"D:\blender_utils"
if not script_dir in sys.path:
    sys.path.append(script_dir)
# The script_dir contains one folder: scripts
# The script folder contains three files: __init__.py, print_hello_universe.py, print_hello_world.py

import scripts
importlib.reload(scripts)

all_files = (os.listdir("D:\\blender_utils\\scripts"))
script_files = [f for f in all_files if f.endswith(".py") and f != "__init__.py"]

for f in script_files:

    package = f.split(".py")[0]
    module = importlib.import_module('scripts.' + package)
    importlib.reload(scripts)
    

class SimpleCustomMenu(bpy.types.Menu):
    bl_label = "Simple Custom Menu"
    bl_idname = "OBJECT_MT_simple_custom_menu"

    def draw(self, context):
        layout = self.layout
        layout.operator_enum("object.some_operator", "action")


class OBJECT_OT_some_operator(bpy.types.Operator):
    bl_idname = "object.some_operator"
    bl_label = "Some Operator"

    enum_items = (
        ("scripts.print_hello_universe.main()", "Hello Universe", "Description 1"),
        ("scripts.print_hello_world.main()", "Hello World", "Description 2"),
    )
    action: bpy.props.EnumProperty(items=enum_items)

    def execute(self, context):
        print(self.action)
        return {'FINISHED'}


if __name__ == "__main__":
    bpy.utils.register_class(OBJECT_OT_some_operator)
    bpy.utils.register_class(SimpleCustomMenu)
    bpy.ops.wm.call_menu(name=SimpleCustomMenu.bl_idname)

You’re assuming the operator argument action is a statement that’s being executed - it’s not. It’s only an identifier for the enum. Calling the functions needs to happen in the operator’s execute().

You’ve already established a common way of calling the scripts by having a main() in each. Good, because you can leverage this in the operator. Now the enum just needs to identify the scripts themselves.

Change the enum to:

    enum_items = (
        ("print_hello_universe", "Hello Universe", "Description 1"),
        ("print_hello_world", "Hello World", "Description 2"),
    )

And in the execute method of the operator:

    def execute(self, context):
        script = getattr(scripts, self.action, None)
        if script:
            script.main()
        return {'FINISHED'}
1 Like

@iceythe

Ah gotcha. Thanks for the clarification.
Just one little thing.

I changed something in the external python file.
And Blender doesn’t seem to respect this.

  1. Even though I already have importlib.reload(scripts) enabled for every run.
  2. Already executed built-in Reload Script command.

I have to restart Blender before my revised external python file to take effect.

Is there a way around this?

Think of importlib.reload() as simply meaning “run module”. Its return value is the newly run module. It’s still your responsibility to update the old references. In your case, just update scripts on module level.

import scripts
scripts = importlib.reload(scripts)
1 Like

Thanks again. Will close the thread for now :slight_smile:

Cool! How did you know this syntax? Again there is nothing about it in blender docs (operator)
I suppose it’s Python-specific and I need to dig deeper in Python to better understand all this stuff?

Previously (I’m a noob, sorry) I created class for each operator :laughing:
, for example

class PRNT_disconnect(bpy.types.Operator):
    bl_idname = "class.parent_disconnect"
    bl_label = "Class Parent Disconnect"

    def execute(self, context):
        bpy.ops.armature.parent_clear(type='DISCONNECT')
        return {'FINISHED'}

and then called it

layout.operator("class.parent_disconnect", text="Disconnect")

I thought there is much simpler way, but found it only today ( a week later)

Sorry for offtopic, but in my case for some reason it works with

pie.operator("armature.parent_clear", text="Clear").type = 'CLEAR'

and

pie.operator("armature.parent_clear", text="Disconnect").type = 'DISCONNECT'

but with

pie.operator("armature.parent_set").type = 'CONNECTED'

and

pie.operator("armature.parent_set").type = "OFFSET"

result same as if there is no .type selected (even label is “Make Parent”)
I tested it in 2.83.6 and in 2.90…result the same

pie.operator("armature.parent_set")

2020-09-20 00_44_15-Blender
2020-09-20 01_01_35-Blender

Looks like a visual bug. The arguments are updated, but the button title stays the same. In this case you could simply change the text to match the enum:

pie.operator("armature.parent_set", text="Connected").type = 'CONNECTED'
pie.operator("armature.parent_set", text="Keep Offset").type = 'OFFSET'

I generally loathe api docs and rather learn by doing. Not sure where I came about that in particular, but when I was still learning the api the PME thread was a massive goldmine. I don’t think there’s a single person who has given more advice about ui layout on this forum than roaoao.

1 Like

Unfortunately, it’s not just visual bug. In case of armature.parent_clear title stays the same too but it calls operator with specified type
In case of armature.parent_set it calls operator as if type not specified at all. And after clicking on title it opens menu (second screenshot in my previous post).

I’m not able to reproduce. Can you post the code?

Of course. I tried it in my custom addon but in text editor situation the same.
Just edited ui menu template:

import bpy
from bpy.types import Menu

class VIEW3D_MT_PIE_template(Menu):
    # label is displayed at the center of the pie menu.
    bl_label = "Select Mode"

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

        pie = layout.menu_pie()
       
        pie.operator("armature.parent_clear").type = 'CLEAR'


        pie.operator("armature.parent_clear").type = 'DISCONNECT'


        pie.operator("armature.parent_set").type = 'CONNECTED'


        pie.operator("armature.parent_set").type = "OFFSET"
   


def register():
    bpy.utils.register_class(VIEW3D_MT_PIE_template)


def unregister():
    bpy.utils.unregister_class(VIEW3D_MT_PIE_template)


if __name__ == "__main__":
    register()

    bpy.ops.wm.call_menu_pie(name="VIEW3D_MT_PIE_template")

Still works fine to me, except for the opening of the sub menu. In this case the operator by default runs its invoke which calls the menu, instead of execute. You can override this using layout.operator_context = 'EXEC_DEFAULT'.
I’ve renamed the buttons a bit.

        pie.operator_context = 'EXEC_DEFAULT'
        pie.operator("armature.parent_clear",
                     text="Clear Parent").type = 'CLEAR'

        pie.operator("armature.parent_clear",
                     text="Disconnect").type = 'DISCONNECT'

        pie.operator("armature.parent_set",
                     text="Connect").type = 'CONNECTED'

        pie.operator("armature.parent_set",
                     text="Connect (Offset)").type = 'OFFSET'
1 Like

Yes it worked for me too but sub menu is driving me insane :grinning:
layout.operator_context = 'EXEC_DEFAULT' is what I need.

Thank you so much!

1 Like

Aaand…how did you know it? How to “get inside” these operators? There is only menu with operators in space_view3d.py.

class VIEW3D_MT_edit_armature_parent(Menu):
    bl_label = "Parent"

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

        layout.operator("armature.parent_set", text="Make")
        layout.operator("armature.parent_clear", text="Clear")

It’s one of those things you’ll pick up after writing between 50 and 100 operators.

Blender comes with 2900 python files. At one point you’ll come across an api feature which can be applied in different ways. layout.operator_context is just one of them. Just wait until you get to layout.context_pointer_set(), where you can directly modify the members of context passed to an operator :slight_smile:

1 Like

:grinning:
I suppose these operators are hardcoded/compiled, right?

They’re hardcoded in the sense that you can’t change the operator code, but often you can manipulate the parameters it works with. A recurring topic related to such manipulation is context overriding.

Blender’s python api is fairly extensive, though, and what you can’t make an internal operator do, you most likely can with a custom operator.

1 Like