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.
This commit is contained in:
Petru Conduraru
2026-07-20 11:17:14 +03:00
parent 55a2430d71
commit 04831147df
9 changed files with 165 additions and 3 deletions
@@ -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)
@@ -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(
@@ -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"
+12 -2
View File
@@ -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):
+4
View File
@@ -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"],
+3
View File
@@ -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
+20
View File
@@ -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:
+7
View File
@@ -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()
+27
View File
@@ -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()