Detect when text wraps to add custom behaviour?

Godot Version

Godot 4.2


Hi, I’m trying to implement a dialogue system UI similar to that of Disco Elysium’s. The behaviour I want to emulate can be seen in that image: Every time the text gets wrapped onto a new line, it gets indented, probably once. I’m having a lot of difficulty emulating this behaviour in Godot, however.

There are options to indent every new line of text in godot’s String using the “Indent” function, but this obviously doesn’t notice the new lines that are born out of autowrapping text using Label or RichTextLabel nodes. I haven’t really found anything that detects when those nodes autowrap the text.

Is there a workaround or a way to detect this? It’s a small functionality, but one that adds quite a bit to the customization abilities of text. Essentially, all that is needed is to add “\t” to the string every time it autowraps to a new line.

I was able to do something like this using the bbcode [indent]:


This is the code I used:

extends Control

@onready var rich_text_label:RichTextLabel = $MarginContainer/RichTextLabel

var dialogue:Array = [
	"KIM KITSURAGI - \"Hello, I\'m Kim Kitsuragi.\" He [indent]looks unfazed. \"Lieutnant, Precinct 57. You[/indent] [indent]must be from the 41st...[/indent]",
	"[indent]You realize he is waiting for your name.[/indent]"

func _ready():
	for d in dialogue:
		rich_text_label.text += str("[p]",d,"[/p]")

That looks like what I want, yeah, but it seems like you would have to manually type out every single indentation, and also know the sizing of the text box while writing the dialogue, which is unfortunately not very realistic

I’m basically describing a scenario where all that work, the [indent] tags you’ve inserted is automatically done through code. All it would require is being able to grab code from the RichTextLabel whenever the text gets autowrapped to the next line, which is currently not possible through Godot itself…

I was checking how the source code does the line breaks in autowrap mode and it seems… complicated LOL.

It looks like you would need to make a similar loop to detect line breaks since there is no signal or method to get a line break position in the string.

Yeah, that goes a bit beyond my ability at the moment haha.

I’ve gone ahead and opened up a suggestion to add this to the engine in a future update, but there’s of course no telling when the devs might get around to implementing this feature.

I’ll welcome any workarounds anybody can think about, but I’ll have to just make do with not using this functionality for now.

It’s kinda possible as long as the “name” doesn’t spill to the second line and the message does not have tabs as we are going to “hijack” the tab stops from the paragraph bbcode tag to move the starting line of the “message” after the “name”. You’ll need two RichTextLabel one for the name and another for the message.

  • Set the root as a Control and with its Layout Mode to Anchors and the Anchors Preset to Full Rect.
  • Enable Fit Content in both RichTextLabels.
  • Set the name RichTextLabel Layout Mode to Anchors and the Anchors Preset to Full Rect.
  • Set the message RichTextLabel Layout Mode to Anchors and the Anchors Preset to Custom. Then set the Anchors Points to 0, 0, 1, 1 and Anchors Offsets to <your desired space>,0,0,0. Reset the Grow Direction Horizontal to Right and the Vertical to Bottom

Scene tree

I did it in code too but I’m not sure if it gets correctly applied or not:

extends Control

@onready var name_rich_text_label: RichTextLabel = $NameRichTextLabel
@onready var message_rich_text_label: RichTextLabel = $MessageRichTextLabel

@export var indent_size = 16.0

func _ready() -> void:
	name_rich_text_label.fit_content = true
	message_rich_text_label.fit_content = true
	message_rich_text_label.set_anchor_and_offset(SIDE_TOP, 0, 0)
	message_rich_text_label.set_anchor_and_offset(SIDE_LEFT, 0, indent_size)
	message_rich_text_label.set_anchor_and_offset(SIDE_BOTTOM, 1, 0)
	message_rich_text_label.set_anchor_and_offset(SIDE_RIGHT, 1, 0)
	message_rich_text_label.grow_horizontal = Control.GROW_DIRECTION_END
	message_rich_text_label.grow_vertical = Control.GROW_DIRECTION_END

func set_dialog(dialog) -> void:
	# wait for the node to be ready if needed
	if not is_node_ready():
		await ready

	# clear contents

	# add the name line
	name_rich_text_label.append_text(' - ')
	# calculate the position of the first line
	var first_line_position = name_rich_text_label.get_content_width() - message_rich_text_label.offset_left
	message_rich_text_label.append_text('[p tab_stops={first_line_position}]\t{message}[/p]'.format({'first_line_position': float(first_line_position), 'message': dialog.get('message')}))
	# set this control minimum size to the message content height
	custom_minimum_size.y = message_rich_text_label.get_content_height()

Then you’ll need to instantiate that scene into the container you want like:

extends Node

const DIALOG_ENTRY_SCENE = preload('res://dialog_entry.tscn')

const DIALOGS = [
	{"name": "Kim Kitsuragi", "message": "Cras dui sem, pretium nec tempor vel, egestas posuere mi. Ut quis ipsum ac diam congue mattis nec ut tellus. Quisque congue fermentum porta. Vestibulum feugiat convallis consequat. Vivamus ac lectus accumsan dolor facilisis malesuada. Nunc quis libero ut libero tincidunt placerat at ut nisl. Aenean a sapien iaculis, mollis lectus sed, auctor justo. Etiam non ligula ultrices, vestibulum massa eget, vulputate nunc. Interdum et malesuada fames ac ante ipsum primis in faucibus."},
	{"name": "You", "message": "Nam tempor sollicitudin lectus."},
	{"name": "Kim Kitsuragi", "message": "Mauris accumsan interdum leo vitae volutpat. Proin et elementum elit, nec pellentesque justo. Aliquam vitae dolor rutrum, imperdiet erat eu, finibus nulla. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Phasellus hendrerit eu purus ac iaculis."},

@onready var dialog_container: VBoxContainer = $ScrollContainer/HBoxContainer

func _ready() -> void:
	for dialog in DIALOGS:
		var entry = DIALOG_ENTRY_SCENE.instantiate()
		entry.set_dialog(dialog.get("name", "Unknown"), dialog.get("message", "No message?"))