How to make user-controls in Godot automatically show their children inside an intenal container of my choice?
Asked Answered
D

2

6

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:

Control Tree Control Visual

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:

enter image description here

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.

Denudation answered 11/9, 2023 at 14:50 Comment(1)
Note that work-arounds are far less interesting to me, as I can achieve what I want by copy-pasting the frame around the window and its code in all of my windows myself. What I want is an automatic way to "decorate" nodes with my own user-defined controls.Denudation
P
1

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:

  • Since you say this is like a ContentPresenter from XAML, I'm calling the new class ContentPresenter.
  • I do not want to remove the children Controls 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).
  • I do not require to add code to the children Controls. This is all in the new ContentPresenter class.
  • Perhaps you intent to hardcode where the 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.
  • Since this must work in the editor, we will have a @tool script.
  • Given that I want a @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).
  • Since Containers are designed to operate on their children (setting their position and size), and which kind of Container you will use is not clear, I want to have something actually as child of the Container.
  • Thus, I must create - what I'll call - duals of the children on the Container, and copy their position properties back and forth.
  • You won't see the duals 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).
  • I'm mapping duals to the children Controls 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 names, but that might have caused a conflict if you wanted to have children of the Container already there (e.g. Cursor).
  • Speaking of children of the Container already there, the code will place the duals after them (if you want them before, replace the computation of children_to_skip with 0).
  • I'm using the signals 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).
  • Speaking of checking each frame, I didn't find a way to get a signal when the resize flags changed, so to detect if they changed I would have to check every frame (in _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 duals being regenerated to fix it in the editor, calling _invalidate_childdren should fix it too.
  • This does not solve Z-ordering. If you want something to be on top of the child Controls, placing it onto of top of the Containers won't do.
  • I had to figure out how to get the position properties in global coordinates to copy them around correctly.

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:

  • You will see that I check 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).
  • The method _validate_control checks if the ContentPresenter is the owner, which would be the case for Nodes added in the editor in a scene where the ContentPresenter is the root. So this makes it easy to skip those Nodes. 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.
  • The method _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.
  • The _process method will disable itself with set_process(false) and calling _invalidate_children or _invalidate_container enables it again.
  • The method _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.
  • I'll reiterate that this does not fix Z-Order.

Now, if you do not need the target to ge a Container, you only need to change it in a a couple places:

  1. var target_container:Container
  2. 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 Containers 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:

  • Get the position of the Control.
  • Transform it.
  • Update the position of the Control.
  • The position of the 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 duals 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 duals 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 Controls. But anyway, that is not the question as written.

Prefab answered 25/9, 2023 at 5:23 Comment(4)
Somehow it didn't occur to me to make this class a Container (after all it moves and resizes it's children), which might had allowed me to take advantage of of features of Containers - I do not know if I would had run into different issues. Anyway, this works, and I need my time.Prefab
Man, you put a lot of work into this. I guess my problem with this approach is the need to create these duals, thus doubling the weight of individual controls. Does everyone do this when they create popups in games? It seems insane to me that the best other solution is "just copy & paste the window frame everywhere"...Denudation
@Denudation If you enable "Editable Children" on the scene instance with your frame, you can then insert the Controls on the Container where they belong. That has been the approach in Godot so far... There have been people who has faced similar problems, in fact there was a similar proposal: github.com/godotengine/godot-proposals/issues/5475 but nothing has come out of that. I'm thinking a dedicated Control for this might be better.Prefab
Ah, I see, you can use that to see the internal controls and insert your own in the right place. Jeez, that's somewhere between absolutely horrible (what if you change the frame? or how do you even know what to insert them in if you didn't also write the frame?) and kind of workable.Denudation
H
-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".


Closest to your original question but complicated: moving the content nodes to 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).


Instantiating your content scene inside your 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.


Simplest solution: Header bar scene + theme

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.

H answered 23/9, 2023 at 21:31 Comment(3)
The comment I wrote above is for exactly this kind of response, no offense. #1 duplicates the insertion code in every child, read up on DRY if you need to know why that is undesirable. #2 prevents me from using different editors with the same frame (I want a hex editor, an asm editor and a C editor, for example, each with their own sub-controls). #3 ignores all other window functions, like minimizing, moving, resizing, etc.Denudation
I may have misunderstood your question, but #1 does not duplicate the insertion code in every child, you only need to add it to the HexEditor scriptH
_enter_tree triggers when a node enter the scene tree, and the event you want here is when a child enters the tree, so the code has to be in the child.Denudation

© 2022 - 2024 — McMap. All rights reserved.