Reputation: 67437
Consider a control that behaves as a decorator to user provided controls, like a window frame. I want my control to have all the common logic of the window (title bar, draggable window borders, buttons to hide the window, etc) and any time it's instanced in the main scene I want it to "eat" any of its node children and place them into a container of my choice.
This is the control I made, and the LinesContainer
container is where I want any of its children to reside:
And just to be absolutely clear what I mean, when it's instantiated into a scene as below, I want its children (the label, in this case) to behave as if they were children of the LinesContainer
node instead:
If you are familiar with .Net XAML at all, this is what the ContentPresenter
tag does in a control, it "eats" the Content
property of the entire control (ie, the children of the control instance, as above) and displays it inside that tag, allowing me to create anything I need around it (or behind it, or over it, etc).
Is there anything built-in like ContentPresenter
? Or if not, how would I go about making something of my own? If possible, that also works correctly in the editor, allowing me to add and remove items as I need and have them layout correctly.
Upvotes: 6
Views: 884
Reputation: 40295
Below is my best attempt to do what you ask for, as written. I have done some testing in Godot 4.1 and 4.2.dev 2, and it works with some caveats I'll get into.
But before I get into that, I want to mention that using base Container
is not recommendable as it is a Container
that does nothing. Thus you might instead use a regular Control
. And yes, the solution I present can be modified to work with a regular Control
, I'll get to that at the end.
Addendum: I guess the Godot standard approach would have been to use "Editable Children" (which you find in the context menu of the scene instance in the Scene dock), I want to mention it, just in case you are not aware of that. Also, the feature you want could make for a good proposal: https://github.com/godotengine/godot-proposals/issues (at least consider opening a discussion: https://github.com/godotengine/godot-proposals/discussions)
Design considerations and caveats:
ContentPresenter
from XAML, I'm calling the new class ContentPresenter
.Control
s nor reparent them, as this might result in problems (e.g. a NodePath
no longer being correct, or the script on the control not handling being removed from the scene tree).Control
s. This is all in the new ContentPresenter
class.Container
is, however I'm handling the case where the Container
might change, under the idea that it would be easier for you to remove that part of my code than to add it. And perhaps you learn something in the process.@tool
script.@tool
script to have a variable reference to another node... I will not expose a Container
property, but a NodePath
property (I got a crash trying to use the Container
property while developing this in Godot 4.2.dev5 - whatever it was I hope will be fixed for the stable release - but I'm falling back to the old way for this).Container
you will use is not clear, I want to have something actually as child of the Container
.dual
s of the children on the Container
, and copy their position properties back and forth.dual
s in the scene tree. This is because they do not have an owner
, and consequently won't be stored with the scene either. Instead they will be regenerated when the scene loads (either in the editor or in runtime).dual
s to the children Control
s using metadata, which I temporarily compile into a Dictionary
. Before I was storing the Dictionary
but ran into issues of it getting out sync. I also did consider using name
s, but that might have caused a conflict if you wanted to have children of the Container
already there (e.g. Cursor
).Container
already there, the code will place the dual
s after them (if you want them before, replace the computation of children_to_skip
with 0
).child_entered_tree
, child_order_changed
and child_order_changed
to make sure the correct duals exists in the correct order. And the signals minimum_size_changed
and item_rect_changed
to trigger the copy of the position properties. Here item_rect_changed
seems to make resized
redundant, and I didn't find another way to handle the change of position (other than checking each frame, which would be much less efficient)._process
) however, I'm not including that in this answer (The code will keep _process
disable most of the time to reduce performance impact). As a result changes in size flags won't be reflected right away, you can take advantage of the dual
s being regenerated to fix it in the editor, calling _invalidate_childdren
should fix it too.Control
s, placing it onto of top of the Container
s won't do.This is the code:
@tool
class_name ContentPresenter
extends Control
# Reference to the container that children will behave as if they were in
var target_container:Container
# NodePath to the container that children will behave as if they were in
@export var target_container_path:NodePath:
set(mod_value):
# Update the NodePath
target_container_path = mod_value
# Update the reference et.al. only if this node is ready
# If this node is not ready, the reponsability falls to _ready
if is_node_ready():
_update_target_container()
var invalidated_children:bool:
set(mod_value):
if invalidated_children == mod_value:
return
invalidated_children = mod_value
var invalidated_container:bool:
set(mod_value):
if invalidated_container == mod_value:
return
invalidated_container = mod_value
func _invalidate_children() -> void:
invalidated_children = true
set_process(true)
func _invalidate_container() -> void:
invalidated_container = true
set_process(true)
# Runs when this node is ready
func _ready() -> void:
# Make sure child_entered_tree is connected
if not child_entered_tree.is_connected(_child_entered_tree):
child_entered_tree.connect(_child_entered_tree)
# Make sure child_exiting_tree is connected
if not child_exiting_tree.is_connected(_child_exiting_tree):
child_exiting_tree.connect(_child_exiting_tree)
# Make sure child_order_changed is connected
if not child_order_changed.is_connected(_child_order_changed):
child_order_changed.connect(_child_order_changed)
# Update the container reference if necessary
_update_target_container()
_invalidate_children()
# Runs when this node leaves the scene tree
func _exit_tree() -> void:
# Request to run _ready next time it enters the scene tree
# This is so it can update the reference to the container
request_ready()
# Called by the Godot edito to get warning
func _get_configuration_warnings() -> PackedStringArray:
# If we don't have a valid reference to the container put up a warning
if not is_instance_valid(target_container):
return ["Target Container Not Found"]
return []
func _process(_delta: float) -> void:
if is_instance_valid(target_container):
var control_by_dual := {}
var dual_by_control := {}
var duals_to_remove:Array[Control] = []
var children_to_skip := 0
for dual_candidate in target_container.get_children():
if dual_candidate.has_meta("__dual_of"):
var control := _validate_control(dual_candidate.get_meta("__dual_of", null))
if is_instance_valid(control):
control_by_dual[dual_candidate] = control
dual_by_control[control] = dual_candidate
else:
duals_to_remove.append(dual_candidate)
else:
children_to_skip += 1
if invalidated_container:
for dual in control_by_dual.keys():
var control:Control = control_by_dual[dual]
_copy_positioning(dual, control, false)
if invalidated_children:
# Make sure all the children Controls have a dual, and what should their order be
var order:Array[Control] = []
for control_candidate in get_children():
var control := _validate_control(control_candidate)
if not is_instance_valid(control):
continue
var dual:Control = dual_by_control.get(control, null)
if not is_instance_valid(dual):
dual = Control.new()
# When the child control changes its minimum size, update the dual
if not control.minimum_size_changed.is_connected(_invalidate_children):
control.minimum_size_changed.connect(_invalidate_children)
# When the child control moves or resizes, update the dual
if not control.item_rect_changed.is_connected(_invalidate_children):
control.item_rect_changed.connect(_invalidate_children)
# When the dual moves or resizes, update the child control
if not dual.item_rect_changed.is_connected(_invalidate_container):
dual.item_rect_changed.connect(_invalidate_container)
dual.set_meta("__dual_of", control)
control_by_dual[dual] = control
dual_by_control[control] = dual
target_container.add_child(dual)
#dual.owner = owner if owner != null else self
order.append(dual)
# Remove any duals whose child Control is no longer valid
for dual in duals_to_remove:
target_container.remove_child(dual)
dual.queue_free()
# Clear the list to remove so we don't remove them again
duals_to_remove = []
# Make sure the dual is in the correct order in the container children
for index in order.size():
target_container.move_child(order[index], children_to_skip + index)
# Update the duals position
for dual in control_by_dual.keys():
var control = control_by_dual[dual]
_copy_positioning(control, dual, true)
# Remove any duals whose child Control is no longer valid (if they weren't removed before)
for dual in duals_to_remove:
target_container.remove_child(dual)
dual.queue_free()
set_process(false)
# Called by _ready or target_container_path's setter
func _update_target_container():
# Figure out the new reference to the container
var new_target_container:Container = null
if not target_container_path.is_empty():
new_target_container = get_node_or_null(target_container_path)
# If it is the same reference do nothing
if new_target_container == target_container:
update_configuration_warnings()
return
# Since we are going to change container, remove duals from the old one
if is_instance_valid(target_container):
var children := target_container.get_children()
for child in children:
if child.has_meta("__dual_of"):
target_container.remove_child(child)
if target_container.item_rect_changed.is_connected(_invalidate_container):
target_container.item_rect_changed.disconnect(_invalidate_container)
# Update the container reference
target_container = new_target_container
if is_instance_valid(target_container):
if not target_container.item_rect_changed.is_connected(_invalidate_container):
target_container.item_rect_changed.connect(_invalidate_container)
_invalidate_container()
_invalidate_children()
# Tell Godot to update warning
update_configuration_warnings()
# Handler for child_entered_tree
func _child_entered_tree(node:Node) -> void:
var control := _validate_control(node)
if control == null:
return
_invalidate_children()
# Handler for child_exiting_tree
func _child_exiting_tree(node:Node) -> void:
var control := _validate_control(node)
if control == null:
return
_invalidate_children()
# Handler for child_order_changed
func _child_order_changed() -> void:
_invalidate_children()
# Called from _child_entered_tree and _child_exiting_tree
func _validate_control(node:Node) -> Control:
if node.owner == self:
# We got a node that is part of the scene
return null
var control = node as Control
if not is_instance_valid(control):
# We got a node that is not a Control
return null
if control.get_parent() != self:
return null
if (
is_instance_valid(target_container)
and (
control == target_container
or control.is_ancestor_of(target_container)
)
):
# We got a Control that contains the container
return null
# return the Control
return control
# Copies data between the children Controls and their duals
func _copy_positioning(from:Control, to:Control, is_push:bool) -> void:
# global transform of from
var from_global_transform := from.get_global_transform()
# global transform of the parent of to
var to_parent_global_transform := Transform2D.IDENTITY
var to_parent := to.get_parent_control()
if to_parent != null:
to_parent_global_transform = to_parent.get_global_transform()
# transform of from relative to the parent of to
var from_to_local_transform := to_parent_global_transform.affine_inverse() * from_global_transform
if is_push:
to.visible = from.visible
to.size_flags_horizontal = from.size_flags_horizontal
to.size_flags_vertical = from.size_flags_vertical
to.size_flags_stretch_ratio = from.size_flags_stretch_ratio
to.custom_minimum_size = from.get_combined_minimum_size()
to.size = from.size
to.global_position = from_global_transform.origin
to.rotation = from_to_local_transform.get_rotation()
to.scale = from_to_local_transform.get_scale()
As you can see I have added some comments, which I hope help understand what is going on.
However, I want to elaborate further on a few things:
is_node_ready
before updating the reference to the Container
that is because I want to make sure that this node is in the scene tree before trying to access it (to query the NodePath
). If the node is not ready, then _ready
will call the method to update the reference. If the node is removed from the scene tree (perhaps the NodePath
modified while it is not in the scene tree) and added again, I'd need to update the reference again, for that I use request_ready
to make sure _ready
runs again (otherwise _ready
would only run the first time)._validate_control
checks if the ContentPresenter
is the owner, which would be the case for Node
s added in the editor in a scene where the ContentPresenter
is the root. So this makes it easy to skip those Node
s. It also checks if the Control
is actually a child of the ContentPresenter
, allowing to detect if a dual
is pointing to a removed Control
that is otherwise still a valid instance._copy_positioning
is really the heart of this (and what took more time to figure out). It might be useful to you if you even do not need all the extra setup. I'll get to that._process
method will disable itself with set_process(false)
and calling _invalidate_children
or _invalidate_container
enables it again._get_configuration_warnings
will be called by Godot to
get, well, warnings. They show up as that yellow triangle next to the Node
in the scene tree.Now, if you do not need the target to ge a Container
, you only need to change it in a a couple places:
var target_container:Container
var new_target_container:Container = null
Plus it is also mentioned in the _get_configuration_warnings
, and names of variables and methods, but these are not functional.
You could also remove the line where dual.item_rect_changed
is connected if the target is not a Container
, under the assumption that only Container
s would be moving or resizing their children.
I also want to note that handling this as a transformation would have been problematic. It would be an infinite feedback loop:
Control
.Control
.Control
changed, repeat.Thus I would have needed to keep track of the original values, and in a way that would still let you edit them. Thus, I believe the dual
s is a good solution.
I had initially posted this answer without using _process
, but I ran into order of execution problems from the signals. Which are important: Say both the Container
and a child Control
moved, which one updates from the other first matters.
Now using _process
I'm giving preference to the Container
moving the child Control
, which minimizes the situations where they behave incorrectly.
Perhaps you actually do not need to handle multiple children. Instead you might want to just copy the positioning of one Control
(e.g. the Container
) to another Control
(e.g. what you are placing "inside" the Container
). In which case the method _copy_positioning
might still be useful to you... But you can get rid of all the dual
s et.al.
Thus, I submit to your consideration specifying what you will put inside the Container
using a NodePath
.
No, copying the Container
position to the ContentPresenter
won't work, if you want the ContentPresenter
to be a parent of the Container
. But there might be an alternative approach hiding in there if you are OK not having the Container
as a child of the ContentPresenter
. For example, it could be similar to RemoteTransform
(2D
/3D
) but for Control
s. But anyway, that is not the question as written.
Upvotes: 1
Reputation: 1
I don't know of a simple and automatic way of "eating" the children of a scene in Godot.
However, you could go with 3 different ways to meet your need, which I'm understanding as "showing the same window decorations in every scenes".
LinesContainer
You could encapsulate all the children of your HexEditor node in a new Control node named "Decorator" (like this), then, in script, move all nodes that are not named "Decorator" in the right container when it enters the tree:
func _enter_tree() -> void:
var container = $Decorator/VBoxContainer/LinesContainer
for child in get_children():
if child.name != "Decorator":
child.reparent(container)
(Note that you could also use a node group instead of encapsulating in a new node)
This solution feels a bit hacky to me, and does not display well in the editor (do not try to add @tool
to run the script in the editor, as this would delete all of the children of the instantiated scene).
LineContainer
I feel like going the other way around would be better: instantiating your content scene inside your LineContainer
.
In the content scenes, do not instantiate the HexEditor
scene. Put only the node that you want to see inside the LinesContainer
.
Add a function change_scene
to your HexEditor
script, that replaces the child of LineContainer
:
@onready
var container = $VBoxContainer/LinesContainer
func change_scene(scene: Node):
for child in container.get_children():
child.queue_free()
container.add_child(scene)
And whenever you need to change the content inside your LinesContainer
, call this function. For example, in a content1
scene:
var scene = preload("res://content2.tscn")
func on_click():
get_tree().root.get_node("HexEditor").change_scene(scene.instantiate())
You will need to make HexEditor
the main scene, and, in the _ready()
function of HexEditor
, call the change_scene()
function with your first content scene.
However, if you only need the header bar, the simplest way would be to add a scene HeaderBar
(containing the label "Hex Editor", the close button, and the separator line) as a child of all your content scenes.
In addition, if you also want the same background color for your content scenes, you can use a PanelContainer
as the root of your content scenes and customize their StyleBox
by creating a theme and adding it as your project theme.
Upvotes: -1