How to create foldable panels in custom Export window

I am looking for a way to create “foldable” panels in the custom export window, similar to what is available in for example a default export/import file windows. For my custom window I’m using “Operator File Export” Template available in Blender, so I’m not pasting any code here.

You can achieve that by assigning a bl_parent_id to your panels.

If you want to see exactly how the FBX exporter does it, you can enable ‘Developer Extras’ in Edit > Preferences > Interface, then open the exporter window you show in your post, right click on one of the properties and select ‘Edit Source’.

This will make the file appear in the Text Editor, and you can see the entire layout.

Generally, you are making a parent class to hold all the other panels. The parent class won’t use it’s draw method, so it will show up as just a header, and for the other panels, you can set options like 'DEFAULT_CLOSED' and provide the bl_parent_id.

There is also the option of bl_order if they’re not arranged in the order you want, but they will appear in the order that they are registered, so there are a couple of ways to handle that.

Here’s the code for the ‘Include’ panel pictured above.

class FBX_PT_export_include(bpy.types.Panel):
    bl_space_type = 'FILE_BROWSER'
    bl_region_type = 'TOOL_PROPS'
    bl_label = "Include"
    bl_parent_id = "FILE_PT_operator"

    @classmethod
    def poll(cls, context):
        sfile = context.space_data
        operator = sfile.active_operator

        return operator.bl_idname == "EXPORT_SCENE_OT_fbx"

    def draw(self, context):
        layout = self.layout
        layout.use_property_split = True
        layout.use_property_decorate = False  # No animation.

        sfile = context.space_data
        operator = sfile.active_operator

        sublayout = layout.column(heading="Limit to")
        sublayout.enabled = (operator.batch_mode == 'OFF')
        sublayout.prop(operator, "use_selection")
        sublayout.prop(operator, "use_visible")
        sublayout.prop(operator, "use_active_collection")

        layout.column().prop(operator, "object_types")
        layout.prop(operator, "use_custom_props")

Overall, it looks like I’m on the right track, as this is more or less exactly what I was trying to do before I asked here. Yes, I know about this ‘Edit Source’ function, I’m using it a lot actually. I even checked the source of these settings in the export window and indeed there are a lot of classes in the code related to each panel in the import/export windows. They all have “bl_parent_id” mentioned, so I deduced that this must be the parent class they refer to. So I searched the entire code, but unfortunately I couldn’t find a class with this name. Its definition must then be in some other script, or this parent_id is some much more general id related not to a specific class, but to windows in general - at least that’s how I understand it.
The problem is that I still don’t understand how it actually works and how to use it. I’m testing it in “Operator File Export” template. There is “ExportSomeData” class, so I tried refer another class to it.

import bpy


def write_some_data(context, filepath, use_some_setting):
    print("running write_some_data...")
    f = open(filepath, 'w', encoding='utf-8')
    f.write("Hello World %s" % use_some_setting)
    f.close()

    return {'FINISHED'}


# ExportHelper is a helper class, defines filename and
# invoke() function which calls the file selector.
from bpy_extras.io_utils import ExportHelper
from bpy.props import StringProperty, BoolProperty, EnumProperty
from bpy.types import Operator


class ExportSomeData(Operator, ExportHelper):
    """This appears in the tooltip of the operator and in the generated docs"""
    bl_idname = "export_test.some_data"  # important since its how bpy.ops.import_test.some_data is constructed
    bl_label = "Export Some Data"

    # ExportHelper mixin class uses this
    filename_ext = ".txt"

    filter_glob: StringProperty(
        default="*.txt",
        options={'HIDDEN'},
        maxlen=255,  # Max internal buffer length, longer would be clamped.
    )

    # List of operator properties, the attributes will be assigned
    # to the class instance from the operator settings before calling.
    use_setting: BoolProperty(
        name="Example Boolean",
        description="Example Tooltip",
        default=True,
    )

    type: EnumProperty(
        name="Example Enum",
        description="Choose between two items",
        items=(
            ('OPT_A', "First Option", "Description one"),
            ('OPT_B', "Second Option", "Description two"),
        ),
        default='OPT_A',
    )

    def execute(self, context):
        return write_some_data(context, self.filepath, self.use_setting)


# Only needed if you want to add into a dynamic menu
def menu_func_export(self, context):
    self.layout.operator(ExportSomeData.bl_idname, text="Text Export Operator")


# Register and add to the "file selector" menu (required to use F3 search "Text Export Operator" for quick access).
def register():
    bpy.utils.register_class(ExportSomeData)
    bpy.types.TOPBAR_MT_file_export.append(menu_func_export)


def unregister():
    bpy.utils.unregister_class(ExportSomeData)
    bpy.types.TOPBAR_MT_file_export.remove(menu_func_export)


if __name__ == "__main__":
    register()

    # test call
    bpy.ops.export_test.some_data('INVOKE_DEFAULT')

From what I understand, “bl_parent_id” is the id of the class I want to refer to. So in my panel class I used this:

bl_parent_id = "export_test.some_data"

as in the main class I can see that the id is:

bl_idname = "export_test.some_data"

but it did’t work at all. I also tried using the class name in the “bl_parent_id” but it didn’t work as well. For some reason the only thing that worked was setting “bl_parent_id” to “FILE_PT_operator”. And with this setting my panel actually appears in my window. The problem is that it also appears in other windows, that are completely unrelated to my code.
obraz

I will only add that the panel began to appear in my window only after removing this piece of code:

@classmethod
    def poll(cls, context):
        sfile = context.space_data
        operator = sfile.active_operator

        return operator.bl_idname == "EXPORT_SCENE_OT_fbx"

so I guess it has some purpose and meaning that I don’t understand.

So I can explain what’s going on, but there are a couple of tricky hidden things.

bl_parent_id takes the name of the class where you want it to exist. Most of the time that just involves copy/pasting one of the other classes in your code and you’re good.

In this case, though, you’re right, you have to pass FILE_PT_operator for it to show up in the Import/Export window. This is defined elsewhere, and is one of the tricky - ‘just do it this way’ kind of things.

The same is true for the poll function, and this one stumped me for a bit as well.

In general, poll determines when a panel can appear or an operator can run. If the conditions in the poll aren’t met, then the code won’t run. (Apologies if you already knew all that)

The tricky bit here is that it’s looking for a name that you don’t have access to. Even though your export operator is called ExportSomeData, you will somehow end up with a class called EXPORT_TEST_OT_some_data, and that is how it wants you to refer to it.

I only found that class name by looking up bpy.types.EXPORT_SCENE_OT_fbx in the console and autofilling after typing bpy.types.EXPORT, I’m not sure how you’re expected to know that.

Below is some code with two example panels added to the export template:

import bpy

class CUSTOM_PT_export_include(bpy.types.Panel):
    bl_space_type = 'FILE_BROWSER'
    bl_region_type = 'TOOL_PROPS'
    bl_label = "Panel Name"
    bl_parent_id = "FILE_PT_operator"

    @classmethod
    def poll(cls, context):
        sfile = context.space_data
        operator = sfile.active_operator

        return operator.bl_idname == "EXPORT_TEST_OT_some_data"

    def draw(self, context):
        layout = self.layout
        layout.use_property_split = True
        layout.use_property_decorate = False  # No animation.

        sfile = context.space_data
        operator = sfile.active_operator
        layout.prop(operator, "type")
        layout.prop(operator, 'use_setting')
        layout.label(text='Whatever you wnat!')

class CUSTOM_PT_export_fun(bpy.types.Panel):
    bl_space_type = 'FILE_BROWSER'
    bl_region_type = 'TOOL_PROPS'
    bl_label = "Panel Name 2"
    bl_parent_id = "FILE_PT_operator"

    @classmethod
    def poll(cls, context):
        sfile = context.space_data
        operator = sfile.active_operator

        return operator.bl_idname == "EXPORT_TEST_OT_some_data"

    def draw(self, context):
        layout = self.layout
#        layout.use_property_split = True
#        layout.use_property_decorate = False  # No animation.

        sfile = context.space_data
        operator = sfile.active_operator
        layout.prop(operator, "type", icon='WORLD')
        layout.prop(operator, 'use_setting', text='New label!')
        layout.label(text='Some fun name!')
        

def write_some_data(context, filepath, use_some_setting):
    print("running write_some_data...")
    f = open(filepath, 'w', encoding='utf-8')
    f.write("Hello World %s" % use_some_setting)
    f.close()

    return {'FINISHED'}


# ExportHelper is a helper class, defines filename and
# invoke() function which calls the file selector.
from bpy_extras.io_utils import ExportHelper
from bpy.props import StringProperty, BoolProperty, EnumProperty
from bpy.types import Operator


class ExportSomeData(Operator, ExportHelper):
    """This appears in the tooltip of the operator and in the generated docs"""
    bl_idname = "export_test.some_data"  # important since its how bpy.ops.import_test.some_data is constructed
    bl_label = "Export Some Data"

    # ExportHelper mixin class uses this
    filename_ext = ".txt"

    filter_glob: StringProperty(
        default="*.txt",
        options={'HIDDEN'},
        maxlen=255,  # Max internal buffer length, longer would be clamped.
    )

    # List of operator properties, the attributes will be assigned
    # to the class instance from the operator settings before calling.
    use_setting: BoolProperty(
        name="Example Boolean",
        description="Example Tooltip",
        default=True,
    )

    type: EnumProperty(
        name="Example Enum",
        description="Choose between two items",
        items=(
            ('OPT_A', "First Option", "Description one"),
            ('OPT_B', "Second Option", "Description two"),
        ),
        default='OPT_A',
    )

    def draw(self, context):
        pass

    def execute(self, context):
        return write_some_data(context, self.filepath, self.use_setting)


# Only needed if you want to add into a dynamic menu
def menu_func_export(self, context):
    self.layout.operator(ExportSomeData.bl_idname, text="Text Export Operator")

classes = [
    ExportSomeData,
    CUSTOM_PT_export_include,
    CUSTOM_PT_export_fun,
]

# Register and add to the "file selector" menu (required to use F3 search "Text Export Operator" for quick access).
def register():
    for cls in classes:
        bpy.utils.register_class(cls)

    bpy.types.TOPBAR_MT_file_export.append(menu_func_export)


def unregister():
    for cls in classes:
        bpy.utils.unregister_class(cls)

    bpy.types.TOPBAR_MT_file_export.remove(menu_func_export)


if __name__ == "__main__":
    register()

    # test call
    bpy.ops.export_test.some_data('INVOKE_DEFAULT')

All right, your code works flawlessly. This is exactly what I was looking for. Thank you so much! I had a few more questions, but I was able to deduce everything from your sample code. Thank you for explaining everything.

In general, poll determines when a panel can appear or an operator can run. If the conditions in the poll aren’t met, then the code won’t run. (Apologies if you already knew all that)

No, I didn’t know that, so thanks for explaining. I wasn’t sure what the poll is used for, so your explanation helped me a lot.

Anyway, an example code that you uploaded above solved my problem. Thank you so much once again!

1 Like

Ok, one more question. What is this used for:

layout.use_property_decorate = False

I tried setting this value to True, but I don’t see any difference.

use_property_decorate is a pretty terrible name for what it does.

It should be called use_property_animate or use_property_keyframe or something like that.

It allows you to keyframe the property, and indicates that by drawing a small white dot next to the property in the panel.

I’m not sure if any of the properties in the export window can be animated, so you might not be able to see it there, but if you load up one of the panel template examples in the Text Editor and add that line you’ll be able to see the small white dot.

1 Like

Oh, I see. Thank you so much again!

1 Like