`Tree` node "Index p_column = `x` is out of bounds" error

Godot Version

4.4.1

Question

I am building a custom plugin for my game, in which I am rendering a nested resource structure in a Tree node. All the functionality and rendering is working fine, but every time the plugin is rendered (on selection of a specific type of node) the console is spammed with these “out of bounds” errors:

They seem to be causing slowdown, so I’m guessing they may be a symptom of something else… The error appears to be coming from the batch_row.set[...] lines, which I guess makes sense, but I don’t understand why…

Here is the code in question:

@tool

extends BoxContainer

@onready var editor: PanelContainer = $Editor
@onready var instructions: PanelContainer = $Instructions
@onready var tree: Tree = $Editor/Tree

@export var debug = false:
	set(value):
		debug = value
		update_ui()

@onready var undo_redo = EditorPlugin.new().get_undo_redo()
var editor_selection := EditorInterface.get_selection()
var spawner_config: SpawnerConfig
var spawner_waves: Array[WaveConfig]
var selected_wave: WaveConfig
var selected_wave_batches: Array[WaveBatch]
var tree_root

func _ready() -> void:
	undo_redo.version_changed.connect(_on_undo)
	tree.columns = 5
	tree.column_titles_visible = true
	tree.set_column_title(0, "")
	tree.set_column_title(1, "Count")
	tree.set_column_title(2, "Interval")
	tree.set_column_title(3, "Delay")
	tree.set_column_title(4, "Asynchronous")
	tree.hide_root = true
	tree.item_edited.connect(_on_edit)
	editor_selection.selection_changed.connect(_on_selection_changed)
	update_ui()

func update_ui():
	if not editor:
		return
	var nodes = editor_selection.get_selected_nodes()
	if not debug and nodes.size() != 1:
		editor.visible = false
		instructions.visible = true
		return
	if not debug and not nodes[0] is SpawnerConfig:
		editor.visible = false
		instructions.visible = true
		return
	
	editor.visible = true
	instructions.visible = false
	spawner_config = nodes[0]
	spawner_waves = spawner_config.waves

	if tree:
		tree.clear()
		tree_root = tree.create_item()

	var i = 0
	for wave in spawner_waves:
		var wave_root = tree.create_item(tree_root)
		wave_root.set_text(0, "Wave " + str(i))
		var j = 0
		for batch in wave.batches:
			var batch_row = tree.create_item(wave_root)
			batch_row.set_meta("wave_index", i)
			batch_row.set_meta("batch_index", j)
			var scene = batch.scene.instantiate() as Enemy
			# Column 1
			batch_row.set_icon(0, scene.sprite.sprite_frames.get_frame_texture("down", 0))
			batch_row.set_text(0, scene.name)
			batch_row.set_tooltip_text(0, scene.scene_file_path)
			# Column 2
			batch_row.set_metadata(1, { "key": "count" })
			batch_row.set_cell_mode(1, TreeItem.CELL_MODE_RANGE)
			batch_row.set_range_config(1, 0, 9999, 1)
			batch_row.set_range(1, batch.count)
			batch_row.set_editable(1, true)
			# Column 3
			batch_row.set_metadata(2, { "key": "interval" })
			batch_row.set_cell_mode(2, TreeItem.CELL_MODE_RANGE)
			batch_row.set_range_config(2, 0.0, 100.0, 0.1)
			batch_row.set_range(2, batch.interval)
			batch_row.set_editable(2, true)
			# Column 4
			batch_row.set_metadata(3, { "key": "delay" })
			batch_row.set_cell_mode(3, TreeItem.CELL_MODE_RANGE)
			batch_row.set_range_config(3, 0.0, 100.0, 0.1)
			batch_row.set_range(3, batch.delay)
			batch_row.set_editable(3, true)
			# Column 5
			batch_row.set_metadata(4, { "key": "async" })
			batch_row.set_cell_mode(4, TreeItem.CELL_MODE_CHECK)
			batch_row.set_checked(4, batch.async)
			if j == 0:
				batch_row.set_tooltip_text(4, "First batch can't run async")
			else:
				batch_row.set_editable(4, true)
			j += 1
		i += 1

func _on_edit():
	var edited_column_index = tree.get_edited_column()
	var edited_row = tree.get_edited()
	var wave_index = edited_row.get_meta("wave_index")
	var batch_index = edited_row.get_meta("batch_index")
	var column_meta = edited_row.get_metadata(edited_column_index)
	var column_key = column_meta.key
	var value
	match column_key:
		"count":
			value = edited_row.get_range(edited_column_index)
		"interval":
			value = edited_row.get_range(edited_column_index)
		"delay":
			value = edited_row.get_range(edited_column_index)
		"async":
			value = edited_row.is_checked(edited_column_index)
		_: value = edited_row.get_text(edited_column_index)
	if spawner_config.waves[wave_index]:
		if spawner_config.waves[wave_index].batches[batch_index]:
			var object = spawner_config.waves[wave_index].batches[batch_index]
			undo_redo.create_action("Change batch value")
			undo_redo.add_do_property(object, column_key, value)
			undo_redo.add_undo_property(object, column_key, object[column_key])
			undo_redo.commit_action()

func _on_undo():
	update_ui()

func _on_selection_changed():
	if tree:
		update_ui()

Any help is greatly appreciated! For now I can just ignore them, annoying as they are they are causing no real actual issues.

I wonder if you need to tree.columns = 5 again after tree.clear()?

Try guarding the update_ui() call of this:

to wait for the node to be ready. Like:

@export var debug = false:
	set(value):
		debug = value
		if not is_node_ready():
            await ready
		update_ui()

Also, move the undo_redo.version_changed.connect(_on_undo) line to after setting up the columns.

Something is firing before the tree columns are correctly setup (probably the setter but it won’t hurt moving the signal connection too).