From 04831147df44a188a44bb978add83d3cd7190c6c Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Mon, 20 Jul 2026 11:17:14 +0300 Subject: [PATCH] Bonsai: real multiline entry for text annotations Text annotations could only be edited through single line string fields, so the only ways to get more than one line were the Newline At wrap length or typing a literal \n escape. Both were called out as awkward in the Drawings and Documentation proposal. Add an "Edit Multiline Text" button next to each text literal. It opens the literal in Blender's own text editor (the same pattern the search module already uses for filter queries), where Enter and Tab behave normally, and an "Apply Text" button in the text editor header saves the result back to the IfcTextLiteral. Legacy \n escapes are converted to real line breaks when a literal is opened, and the Newline At wrap length still applies on top of any embedded line breaks. Multiline literals are shown one line per row in the Text panel instead of being truncated into a single line field, and the viewport decorator now resolves the \n escape the same way the SVG writer already did. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/drawing/__init__.py | 4 + .../bonsai/bim/module/drawing/decoration.py | 2 +- .../bonsai/bim/module/drawing/operator.py | 87 +++++++++++++++++++ src/bonsai/bonsai/bim/module/drawing/ui.py | 14 ++- src/bonsai/bonsai/core/drawing.py | 4 + src/bonsai/bonsai/core/tool.py | 3 + src/bonsai/bonsai/tool/drawing.py | 20 +++++ src/bonsai/test/core/test_drawing.py | 7 ++ src/bonsai/test/tool/test_drawing.py | 27 ++++++ 9 files changed, 165 insertions(+), 3 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/__init__.py b/src/bonsai/bonsai/bim/module/drawing/__init__.py index 12b63d9372..47dd5b3cb6 100644 --- a/src/bonsai/bonsai/bim/module/drawing/__init__.py +++ b/src/bonsai/bonsai/bim/module/drawing/__init__.py @@ -42,6 +42,7 @@ classes = ( operator.AddScheduleToSheet, operator.AddSheet, operator.AddTextLiteral, + operator.ApplyMultilineTextLiteral, operator.AssignSelectedObjectAsProduct, operator.BuildSchedule, operator.CleanWireframes, @@ -64,6 +65,7 @@ classes = ( operator.EditElementFilter, operator.EditSheet, operator.EditText, + operator.EditTextLiteralMultiline, operator.EditTextPopup, operator.EnableAddAnnotationType, operator.EnableEditingAssignedProduct, @@ -197,6 +199,7 @@ def register(): bpy.app.handlers.depsgraph_update_pre.append(handler.depsgraph_update_pre_handler) bpy.types.VIEW3D_MT_image_add.append(ui.add_object_button) bpy.types.VIEW3D_MT_object_context_menu.append(menu_func) + bpy.types.TEXT_HT_header.append(operator.draw_text_editor_header) def unregister(): @@ -212,3 +215,4 @@ def unregister(): bpy.app.handlers.depsgraph_update_pre.remove(handler.depsgraph_update_pre_handler) bpy.types.VIEW3D_MT_image_add.remove(ui.add_object_button) bpy.types.VIEW3D_MT_object_context_menu.remove(menu_func) + bpy.types.TEXT_HT_header.remove(operator.draw_text_editor_header) diff --git a/src/bonsai/bonsai/bim/module/drawing/decoration.py b/src/bonsai/bonsai/bim/module/drawing/decoration.py index eedc79e995..0165b5d446 100644 --- a/src/bonsai/bonsai/bim/module/drawing/decoration.py +++ b/src/bonsai/bonsai/bim/module/drawing/decoration.py @@ -618,7 +618,7 @@ class BaseDecorator: if newline_at != 0: text = helper.add_newline_between_words(text, newline_at) - multiple_lines = text.split("\n") + multiple_lines = text.replace("\\n", "\n").split("\n") for line in multiple_lines: self.draw_label( diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 35d93db7d2..3bf310beb3 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -3382,6 +3382,93 @@ class EditText(bpy.types.Operator, tool.Ifc.Operator): tool.Blender.update_viewport() +MULTILINE_TEXT_DATABLOCK = "BonsaiAnnotationText" + + +def draw_text_editor_header(self: bpy.types.TEXT_HT_header, context: bpy.types.Context) -> None: + space = context.space_data + if not isinstance(space, bpy.types.SpaceTextEditor): + return + if space.text and space.text.name == MULTILINE_TEXT_DATABLOCK: + layout = self.layout + layout.separator() + layout.operator("bim.apply_multiline_text_literal", text="Apply Text", icon="CHECKMARK") + + +class EditTextLiteralMultiline(bpy.types.Operator): + bl_idname = "bim.edit_text_literal_multiline" + bl_label = "Edit Multiline Text" + bl_description = ( + "Edit this text literal in Blender's text editor,\n" + "where Enter and Tab insert real line breaks and tabs.\n" + "Click 'Apply Text' in the text editor header when done" + ) + bl_options = {"REGISTER", "UNDO"} + + literal_prop_id: bpy.props.IntProperty() + + def execute(self, context): + obj = context.active_object + if not obj: + return {"CANCELLED"} + props = tool.Drawing.get_text_props(obj) + if not props.is_editing: + bpy.ops.bim.enable_editing_text() + if self.literal_prop_id >= len(props.literals): + self.report({"ERROR"}, "Text literal not found.") + return {"CANCELLED"} + + text = bpy.data.texts.get(MULTILINE_TEXT_DATABLOCK) or bpy.data.texts.new(MULTILINE_TEXT_DATABLOCK) + text.clear() + literal_value = props.literals[self.literal_prop_id].attributes["Literal"].string_value + text.write(tool.Drawing.unescape_literal_newlines(literal_value)) + text["bonsai_text_object"] = obj.name + text["bonsai_text_literal"] = self.literal_prop_id + + if bpy.app.background: + return {"FINISHED"} + + bpy.ops.wm.window_new() + new_area = context.window_manager.windows[-1].screen.areas[0] + new_area.type = "TEXT_EDITOR" + for space in new_area.spaces: + if space.type == "TEXT_EDITOR": + space.text = text + break + self.report({"INFO"}, "Text editor opened, click 'Apply Text' in the header when done.") + return {"FINISHED"} + + +class ApplyMultilineTextLiteral(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.apply_multiline_text_literal" + bl_label = "Apply Text" + bl_description = "Save the edited text back to the text annotation" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + space = context.space_data + if not isinstance(space, bpy.types.SpaceTextEditor): + return False + return bool(space.text and space.text.name == MULTILINE_TEXT_DATABLOCK) + + def _execute(self, context): + text = context.space_data.text + obj = bpy.data.objects.get(text.get("bonsai_text_object", "")) + if not obj: + self.report({"ERROR"}, "The edited text annotation is no longer available.") + return {"CANCELLED"} + core.edit_text_literal_value( + tool.Drawing, + obj=obj, + literal_index=text.get("bonsai_text_literal", 0), + value=text.as_string(), + ) + tool.Blender.update_viewport() + if len(context.window_manager.windows) > 1: + bpy.ops.wm.window_close() + + class CopyTextToSelection(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.copy_text_to_selection" bl_label = "Copy Text To Selection" diff --git a/src/bonsai/bonsai/bim/module/drawing/ui.py b/src/bonsai/bonsai/bim/module/drawing/ui.py index 5c690e7282..37992ef7d1 100644 --- a/src/bonsai/bonsai/bim/module/drawing/ui.py +++ b/src/bonsai/bonsai/bim/module/drawing/ui.py @@ -696,7 +696,15 @@ class BIM_PT_text(Panel): box = self.layout.box() if len(literal_props.attributes): row = box.row(align=True) - bonsai.bim.helper.draw_attribute(literal_props.attributes[0], row, enable_search=True) + literal_value = literal_props.attributes[0].string_value + if "\n" in literal_value: + column = row.column(align=True) + for line in literal_value.split("\n"): + column.label(text=line or " ") + else: + bonsai.bim.helper.draw_attribute(literal_props.attributes[0], row, enable_search=True) + op = row.operator("bim.edit_text_literal_multiline", icon="GREASEPENCIL", text="") + op.literal_prop_id = i if i > 0: row.operator("bim.order_text_literal_up", icon="TRIA_UP", text="").literal_prop_id = i if i < len(props.literals) - 1: @@ -715,7 +723,9 @@ class BIM_PT_text(Panel): ) row = box.row(align=True) row.label(text="CurrentValue:") - row.label(text=str(resolved_value)) + column = row.column(align=True) + for line in str(resolved_value).split("\n"): + column.label(text=line or " ") # Show the element values panel if expanded if getattr(literal_props, "show_element_values", False): diff --git a/src/bonsai/bonsai/core/drawing.py b/src/bonsai/bonsai/core/drawing.py index 2a1f760570..5b6c998eab 100644 --- a/src/bonsai/bonsai/core/drawing.py +++ b/src/bonsai/bonsai/core/drawing.py @@ -47,6 +47,10 @@ def edit_text(drawing: type[tool.Drawing], obj: bpy.types.Object) -> None: drawing.disable_editing_text(obj) +def edit_text_literal_value(drawing: type[tool.Drawing], obj: bpy.types.Object, literal_index: int, value: str) -> None: + drawing.edit_text_literal_value(obj, literal_index, drawing.sanitize_multiline_literal(value)) + + def copy_text_to_selection( drawing: type[tool.Drawing], attribute: Literal["FONT_SIZE", "ALIGNMENT", "WRAP_LENGTH", "SYMBOL", "LITERALS"], diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index 51120ad088..22e22785f5 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -344,6 +344,7 @@ class Drawing: def does_file_exist(cls, uri): pass def edit_text_alignment(cls, obj, alignment): pass def edit_text_font_size(cls, obj, size): pass + def edit_text_literal_value(cls, obj, literal_index, value): pass def edit_text_literals(cls, obj, literals): pass def edit_text_symbol(cls, obj, symbol): pass def edit_text_wrap_length(cls, obj, wrap_length): pass @@ -417,6 +418,7 @@ class Drawing: def run_drawing_activate_model(cls): pass def run_root_assign_class(cls, obj=None, ifc_class=None, predefined_type=None, should_add_representation=True, context=None, ifc_representation_class=None): pass def run_type_assign_type(cls, element=None, relating_type=None): pass + def sanitize_multiline_literal(cls, value): pass def select_assigned_product(cls, drawing): pass def set_camera_name(cls, drawing, name): pass def set_drawing_collection_name(cls, drawing, collection): pass @@ -425,6 +427,7 @@ class Drawing: def setup_shading_styles_path(cls, resource_path): pass def show_decorations(cls): pass def sync_object_placement(cls, obj): pass + def unescape_literal_newlines(cls, value): pass def update_embedded_svg_location(cls, uri, old_location, new_location): pass diff --git a/src/bonsai/bonsai/tool/drawing.py b/src/bonsai/bonsai/tool/drawing.py index aedc1219d9..d134731edd 100644 --- a/src/bonsai/bonsai/tool/drawing.py +++ b/src/bonsai/bonsai/tool/drawing.py @@ -929,6 +929,26 @@ class Drawing(bonsai.core.tool.Drawing): props = tool.Drawing.get_document_props() return props.is_editing_sheets + @classmethod + def unescape_literal_newlines(cls, value: str) -> str: + """Turn the legacy ``\\n`` escape sequence into a real line break.""" + return value.replace("\\n", "\n") + + @classmethod + def sanitize_multiline_literal(cls, value: str) -> str: + # Blender's Text.as_string() always terminates with a line break. + return value.replace("\r\n", "\n").rstrip("\n") + + @classmethod + def edit_text_literal_value(cls, obj: bpy.types.Object, literal_index: int, value: str) -> None: + props = cls.get_text_props(obj) + if props.is_editing and literal_index < len(props.literals): + props.literals[literal_index].attributes["Literal"].string_value = value + ifc_literals = cls.get_text_literal(obj, return_list=True) + assert isinstance(ifc_literals, list) + if literal_index < len(ifc_literals): + ifc_literals[literal_index].Literal = value + @classmethod def edit_text_literals(cls, obj: bpy.types.Object, literal_attributes: dict) -> None: if not literal_attributes: diff --git a/src/bonsai/test/core/test_drawing.py b/src/bonsai/test/core/test_drawing.py index 914ddbbb54..d3ffd55d02 100644 --- a/src/bonsai/test/core/test_drawing.py +++ b/src/bonsai/test/core/test_drawing.py @@ -47,6 +47,13 @@ class TestEditText: subject.edit_text(drawing, obj="obj") +class TestEditTextLiteralValue: + def test_run(self, drawing): + drawing.sanitize_multiline_literal("value\n").should_be_called().will_return("value") + drawing.edit_text_literal_value("obj", 0, "value").should_be_called() + subject.edit_text_literal_value(drawing, obj="obj", literal_index=0, value="value\n") + + class TestEnableEditingAssignedProduct: def test_run(self, drawing): drawing.enable_editing_assigned_product("obj").should_be_called() diff --git a/src/bonsai/test/tool/test_drawing.py b/src/bonsai/test/tool/test_drawing.py index 580b326822..d8ded8d5fc 100644 --- a/src/bonsai/test/tool/test_drawing.py +++ b/src/bonsai/test/tool/test_drawing.py @@ -687,6 +687,33 @@ class TestGetTextLiteral(NewFile): assert subject.get_text_literal(obj) == item +class TestEditTextLiteralValue(NewFile): + def test_run(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + obj = bpy.data.objects.new("Object", None) + element = ifc.createIfcAnnotation() + element.Representation = ifc.createIfcProductDefinitionShape() + context = ifc.createIfcGeometricRepresentationSubContext(ContextType="Plan", ContextIdentifier="Annotation") + item = ifc.createIfcTextLiteralWithExtent(Literal="Literal", Path="RIGHT", BoxAlignment="bottom-left") + representation = ifc.createIfcShapeRepresentation(ContextOfItems=context, Items=[item]) + element.Representation.Representations = [representation] + element.ObjectType = "TEXT" + tool.Ifc.link(element, obj) + subject.edit_text_literal_value(obj, 0, "Line one\nLine two") + assert item.Literal == "Line one\nLine two" + + +class TestSanitizeMultilineLiteral(NewFile): + def test_run(self): + assert subject.sanitize_multiline_literal("Line one\r\nLine two\n") == "Line one\nLine two" + + +class TestUnescapeLiteralNewlines(NewFile): + def test_run(self): + assert subject.unescape_literal_newlines("Line one\\nLine two") == "Line one\nLine two" + + class TestGetAssignedProduct(NewFile): def test_run(self): ifc = ifcopenshell.file()