UI: I/O: Match format labels with Import menu

Part of https://projects.blender.org/blender/blender/issues/162410
Add extensions dynamically to format labels rather than having the
extension list hardcoded and duplicated.

Extensions are added to the label dynamically based off the FileHandler
bl_file_extensions field which is the source of truth now. This allows
the extension list to be included for the Import and Export menu, and
relevant add menus, but not the UI List for collection export.

Details:
- Several extensions: replace ; by / (.glb;.gltf -> .glb/.gltf).
- Two or more extensions starting with the same first three letters:
concatenate with as many common letters as possible, and add *
(.usd;.usda;.usdc;.usdz -> .usd*).

Pull Request: https://projects.blender.org/blender/blender/pulls/163438
This commit is contained in:
Eitan Traurig 2026-09-14 21:01:31 +02:00 • committed by Jesse Yurkovich
parent 3423e8fed9
commit ebbd2ab38d
9 changed files with 170 additions and 19 deletions

View file

@ -84,7 +84,9 @@ class IO_FH_svg_as_curves(bpy.types.FileHandler):
def menu_func_import(self, context):
self.layout.operator(ImportSVG.bl_idname, text="SVG as Curves (.svg)")
self.layout.operator(
ImportSVG.bl_idname,
text=bpy.types.FileHandler.label_with_extensions("IO_FH_svg_as_curves"))
classes = [

View file

@ -29,6 +29,7 @@ if "bpy" in locals():
import bpy
from bpy.app.translations import pgettext_iface as iface_
from bpy.props import (
StringProperty,
BoolProperty,
@ -713,11 +714,14 @@ def export_panel_animation(layout, operator):
def menu_func_import(self, context):
self.layout.operator(ImportFBX.bl_idname, text="FBX (.fbx) (Legacy)")
self.layout.operator(
ImportFBX.bl_idname,
text=bpy.types.FileHandler.label_with_extensions("IO_FH_fbx") + " " + iface_("(Legacy)"))
def menu_func_export(self, context):
self.layout.operator(ExportFBX.bl_idname, text="FBX (.fbx)")
self.layout.operator(
ExportFBX.bl_idname, text=bpy.types.FileHandler.label_with_extensions("IO_FH_fbx"))
classes = (

View file

@ -1908,7 +1908,8 @@ class ExportGLTF2(bpy.types.Operator, ExportGLTF2_Base, ExportHelper):
def menu_func_export(self, context):
self.layout.operator(ExportGLTF2.bl_idname, text='glTF 2.0 (.glb/.gltf)')
self.layout.operator(
ExportGLTF2.bl_idname, text=bpy.types.FileHandler.label_with_extensions('IO_FH_gltf2'))
class ImportGLTF2(Operator, ConvertGLTF2_Base, ImportHelper):
@ -2277,7 +2278,7 @@ class IO_FH_gltf2(bpy.types.FileHandler):
def menu_func_import(self, context):
self.layout.operator(ImportGLTF2.bl_idname, text='glTF 2.0 (.glb/.gltf)')
self.layout.operator(ImportGLTF2.bl_idname, text=bpy.types.FileHandler.label_with_extensions("IO_FH_gltf2"))
classes = (

View file

@ -384,24 +384,32 @@ class TOPBAR_MT_file_import(Menu):
bl_owner_use_filter = False
def draw(self, _context):
FileHandler = bpy.types.FileHandler
if bpy.app.build_options.alembic:
self.layout.operator("wm.alembic_import", text="Alembic (.abc)")
self.layout.operator(
"wm.alembic_import", text=FileHandler.label_with_extensions("IO_FH_alembic"))
if bpy.app.build_options.usd:
self.layout.operator(
"wm.usd_import", text="Universal Scene Description (.usd*)")
"wm.usd_import", text=FileHandler.label_with_extensions("IO_FH_usd"))
if bpy.app.build_options.io_gpencil:
self.layout.operator("wm.grease_pencil_import_svg", text="SVG as Grease Pencil")
self.layout.operator(
"wm.grease_pencil_import_svg",
text=FileHandler.label_with_extensions("IO_FH_grease_pencil_svg"))
if bpy.app.build_options.io_wavefront_obj:
self.layout.operator("wm.obj_import", text="Wavefront (.obj)")
self.layout.operator(
"wm.obj_import", text=FileHandler.label_with_extensions("IO_FH_obj"))
if bpy.app.build_options.io_ply:
self.layout.operator("wm.ply_import", text="Stanford PLY (.ply)")
self.layout.operator(
"wm.ply_import", text=FileHandler.label_with_extensions("IO_FH_ply"))
if bpy.app.build_options.io_stl:
self.layout.operator("wm.stl_import", text="STL (.stl)")
self.layout.operator(
"wm.stl_import", text=FileHandler.label_with_extensions("IO_FH_stl"))
if bpy.app.build_options.io_fbx:
self.layout.operator("wm.fbx_import", text="FBX (.fbx)")
self.layout.operator(
"wm.fbx_import", text=FileHandler.label_with_extensions("IO_FH_fbx"))
class TOPBAR_MT_file_export(Menu):
@ -410,6 +418,8 @@ class TOPBAR_MT_file_export(Menu):
bl_owner_use_filter = False
def draw(self, context):
FileHandler = bpy.types.FileHandler
row = self.layout.row()
row.operator("wm.collection_export_all")
row.enabled = context.view_layer.has_export_collections
@ -417,10 +427,11 @@ class TOPBAR_MT_file_export(Menu):
self.layout.separator()
if bpy.app.build_options.alembic:
self.layout.operator("wm.alembic_export", text="Alembic (.abc)")
self.layout.operator(
"wm.alembic_export", text=FileHandler.label_with_extensions("IO_FH_alembic"))
if bpy.app.build_options.usd:
self.layout.operator(
"wm.usd_export", text="Universal Scene Description (.usd*)")
"wm.usd_export", text=FileHandler.label_with_extensions("IO_FH_usd"))
if bpy.app.build_options.io_gpencil:
# PUGIXML library dependency.
@ -431,11 +442,14 @@ class TOPBAR_MT_file_export(Menu):
self.layout.operator("wm.grease_pencil_export_pdf", text="Grease Pencil as PDF")
if bpy.app.build_options.io_wavefront_obj:
self.layout.operator("wm.obj_export", text="Wavefront (.obj)")
self.layout.operator(
"wm.obj_export", text=FileHandler.label_with_extensions("IO_FH_obj"))
if bpy.app.build_options.io_ply:
self.layout.operator("wm.ply_export", text="Stanford PLY (.ply)")
self.layout.operator(
"wm.ply_export", text=FileHandler.label_with_extensions("IO_FH_ply"))
if bpy.app.build_options.io_stl:
self.layout.operator("wm.stl_export", text="STL (.stl)")
self.layout.operator(
"wm.stl_export", text=FileHandler.label_with_extensions("IO_FH_stl"))
class TOPBAR_MT_file_external_data(Menu):

View file

@ -55,6 +55,11 @@ struct FileHandlerType {
* Generate a default file name for use with this file handler.
*/
std::string get_default_filename(StringRefNull name);
/**
* Return `label` with the supported file extensions appended, e.g. "Alembic (.abc)".
*/
std::string label_with_extensions() const;
};
/**

View file

@ -144,4 +144,61 @@ std::string FileHandlerType::get_default_filename(const StringRefNull name)
return filename;
}
static std::string extensions_common_prefix(const Span<std::string> extensions)
{
std::string prefix = extensions.first();
for (const std::string &extension : extensions.drop_front(1)) {
const int64_t max_len = std::min<int64_t>(prefix.size(), extension.size());
int64_t i = 0;
while (i < max_len && prefix[i] == extension[i]) {
i++;
}
prefix.resize(i);
}
return prefix;
}
static bool extensions_group_is_collapsible(const Span<std::string> extensions)
{
const std::string prefix = extensions_common_prefix(extensions);
const int64_t prefix_letters = prefix.size() - (!prefix.empty() && prefix[0] == '.' ? 1 : 0);
return prefix_letters >= 3 && std::ranges::all_of(extensions, [&](const auto &extension) {
return extension.size() <= prefix.size() + 1;
});
}
std::string FileHandlerType::label_with_extensions() const
{
if (file_extensions.is_empty()) {
return label;
}
std::string extensions;
const Span<std::string> all_extensions = file_extensions.as_span();
int64_t group_start = 0;
while (group_start < all_extensions.size()) {
int64_t group_size = 1;
while (group_start + group_size < all_extensions.size() &&
extensions_group_is_collapsible(all_extensions.slice(group_start, group_size + 1)))
{
group_size++;
}
const Span<std::string> group = all_extensions.slice(group_start, group_size);
if (!extensions.empty()) {
extensions += "/";
}
if (group.size() > 1) {
extensions += extensions_common_prefix(group) + "*";
}
else {
extensions += group.first();
}
group_start += group_size;
}
return std::string(label) + " (" + extensions + ")";
}
} // namespace blender::bke

View file

@ -116,4 +116,51 @@ TEST_F(FileHandlerTest, remove)
EXPECT_EQ(file_handlers()[4].get(), test_file_handlers[5]);
EXPECT_EQ(file_handlers()[5].get(), test_file_handlers[6]);
}
static void expect_label_with_extensions(const char *label,
Vector<std::string> file_extensions,
const std::string &expected)
{
FileHandlerType file_handler{};
STRNCPY(file_handler.label, label);
file_handler.file_extensions = std::move(file_extensions);
EXPECT_EQ(file_handler.label_with_extensions(), expected);
}
TEST_F(FileHandlerTest, label_with_extensions)
{
/* No extensions: label is returned unchanged. */
expect_label_with_extensions("Wavefront OBJ", {}, "Wavefront OBJ");
/* Single extension. */
expect_label_with_extensions("Wavefront OBJ", {".obj"}, "Wavefront OBJ (.obj)");
/* No common prefix: joined with '/'. */
expect_label_with_extensions("Collada", {".dae", ".zae"}, "Collada (.dae/.zae)");
/* Common prefix ".ab" has only 2 letters excluding the leading dot: below the 3-letter
* threshold required to collapse. */
expect_label_with_extensions("Prefix Too Short", {".ab", ".abc"}, "Prefix Too Short (.ab/.abc)");
/* Common prefix ".ble" has enough letters to collapse, but ".blend" is longer than
* `prefix.size() + 1`, so the extensions are not collapsible. */
expect_label_with_extensions(
"Extension Too Long", {".ble", ".blend"}, "Extension Too Long (.ble/.blend)");
/* Common prefix has enough letters and no extension exceeds the length limit:
* collapses to `<prefix>*`. */
expect_label_with_extensions("Universal Scene Description",
{".usd", ".usda", ".usdc"},
"Universal Scene Description (.usd*)");
/* Common prefix ".abc" has exactly 3 letters excluding the leading dot: the minimum
* required to collapse. */
expect_label_with_extensions(
"Prefix Length Boundary", {".abc", ".abcd"}, "Prefix Length Boundary (.abc*)");
/* ".foo" and ".fooz" form a collapsible group, ".bar" does not share a prefix with
* either and forms its own group. */
expect_label_with_extensions(
"Multiple Groups", {".foo", ".fooz", ".bar"}, "Multiple Groups (.foo*/.bar)");
}
} // namespace blender::bke::tests

View file

@ -1159,7 +1159,8 @@ static void collection_importer_menu_draw(const bContext * /*C*/, Menu *menu)
bool at_least_one = false;
for (const auto &fh : bke::file_handlers()) {
if (STREQ(fh->idname, "IO_FH_usd") && WM_operatortype_find(fh->import_operator, true)) {
PointerRNA op_ptr = layout.op("COLLECTION_OT_importer_add", fh->label, ICON_NONE);
PointerRNA op_ptr = layout.op(
"COLLECTION_OT_importer_add", fh->label_with_extensions(), ICON_NONE);
RNA_string_set(&op_ptr, "name", fh->idname);
at_least_one = true;
}
@ -1178,7 +1179,8 @@ static void collection_exporter_menu_draw(const bContext * /*C*/, Menu *menu)
bool at_least_one = false;
for (const auto &fh : bke::file_handlers()) {
if (WM_operatortype_find(fh->export_operator, true)) {
PointerRNA op_ptr = layout.op("COLLECTION_OT_exporter_add", fh->label, ICON_NONE);
PointerRNA op_ptr = layout.op(
"COLLECTION_OT_exporter_add", fh->label_with_extensions(), ICON_NONE);
RNA_string_set(&op_ptr, "name", fh->idname);
at_least_one = true;
}

View file

@ -1679,6 +1679,14 @@ static StructRNA *rna_FileHandler_refine(PointerRNA *file_handler_ptr)
RNA_FileHandler;
}
static void rna_FileHandler_label_with_extensions(const char *idname, char *result)
{
const bke::FileHandlerType *file_handler = bke::file_handler_find(idname);
BLI_strncpy(result,
file_handler ? file_handler->label_with_extensions().c_str() : "",
FH_MAX_FILE_EXTENSIONS_STR + OP_MAX_TYPENAME + 3);
}
} // namespace blender
#else /* RNA_RUNTIME */
@ -2612,6 +2620,17 @@ static void rna_def_file_handler(BlenderRNA *brna)
RNA_def_function_return(func, RNA_def_boolean(func, "is_usable", false, "", ""));
parm = RNA_def_pointer(func, "context", "Context", "", "The context");
RNA_def_parameter_flags(parm, PropertyFlag(0), PARM_REQUIRED);
func = RNA_def_function(srna, "label_with_extensions", "rna_FileHandler_label_with_extensions");
RNA_def_function_ui_description(
func, "Return the label of the file handler with the given ID, with its file extensions");
RNA_def_function_flag(func, FUNC_NO_SELF);
parm = RNA_def_string(func, "idname", nullptr, sizeof(bke::FileHandlerType::idname), "", "");
RNA_def_parameter_flags(parm, PropertyFlag(0), PARM_REQUIRED);
parm = RNA_def_string(
func, "result", nullptr, FH_MAX_FILE_EXTENSIONS_STR + OP_MAX_TYPENAME + 3, "result", "");
RNA_def_parameter_flags(parm, PROP_THICK_WRAP, ParameterFlag(0));
RNA_def_function_output(func, parm);
}
static void rna_def_layout_panel_state(BlenderRNA *brna)