Supports in Godot 4.5+ with mono (.Net) module.
GD Panel Framework is a Godot 4 UI Management System designed to provide a flexible, panel-based, single-focus point, Gamepad + Keyboard + Keyboard&Mouse friendly UI programming experience.
This framework groups sets of user interactions into a UIPanel, which includes a combination of the following:
Controls, such asbutton,label, andcontainer.Inputs, which is a set of developer-defined input actions binds with this panel.
These user interactions are panel-scoped, which means they only stay active when the panel is active; this simplifies the workflow for maintaining large amounts of discrete Controls and Global Input Actions and allows developers to focus on programming game logic (not collecting and toggling Controls or adding more ifs into a global _Input method).
As the Godot doesn't work well with external assemblies, the GD Panel Framework is now distributed by zip/addon instead of being a Nuget Package.
- Ensure your project C# language version is at least
<LangVersion>12</LangVersion>. - Download the
GDPanelFramework.zipfrom the latest release. - Decompress the file and place the
addonsdirectory into the project root (res://). - Call the
PanelManager.Initialize()method before use any APIs.
This addon only contains source file for runtime use; so you do not need to enable it from the Plugin window.
- Simple API Usage
- Framework Documentation
- Framework Concept
- The
UIPanel- Instantiate a Panel
- Open a panel
- Close a panel
- Input Binding / Routing
- Input Registration
- Basic Usage
- Default Input Phase
- Variation:
RegisterAnyKeyInput - Variation:
RegisterInputToggle - Variation:
RegisterEchoedInput/RemoveEchoedInput - Variation:
RegisterInputCancel/RemoveInputCancel/ToggleInputCancel - Variation:
EnableCloseWithCancelKeyandDisableCloseWithCancelKey - Variation:
RegisterInputAxis/RemoveInputAxis/ToggleInputAxis - Variation:
RegisterInputVector/RemoveInputVector/ToggleInputVector
- the BuiltinInputNames Class
- Global Input Listeners
- Input Registration
- Panel Stack
- Framework-level Caching
- Panel Event Methods Overview
- Configuring the Previous Panel Visual Behavior
- The
UIPanelArg1andUIPanelArg2 - Panel Container Management
- The
Panel Tweener
- Runtime Builder DSL
You can run RunMe_Example00.tscn in Godot Editor.
using Godot;
using GodotTask;
namespace GDPanelFramework.Examples;
/// <summary>
/// The bootstrap script that creates and opens the panel.
/// </summary>
public partial class Example00_Main : Node
{
/// <summary>
/// The packed panel.
/// </summary>
[Export] private PackedScene _panelPrefab;
/// <summary>
/// Executes the main logic after one frame since the game starts.
/// This is required by the GDPanelFramework for adding its panel root into the scene tree.
/// </summary>
public override void _Ready() =>
GDTask.NextFrame().ContinueWith(OnReady);
private void OnReady()
{
_panelPrefab
.CreatePanel<Example00_MyPanel>() // This extension method tells the framework to create or reuse an instance of this panel.
.OpenPanel( // This method tells the framework to opens the panel.
onPanelCloseCallback: // This delegate gets called when this panel gets closed when the panel itself calls ClosePanel().
() => GetTree().Quit() // Terminate the application when this panel gets closed.
);
}
}using GDPanelFramework.Panels;
using Godot;
namespace GDPanelFramework.Examples;
/// <summary>
/// Attach this script to a Control to make it a "UIPanel".
/// </summary>
public partial class Example00_MyPanel : UIPanel
{
// These three fields are assigned in Godot Editor, through inspector.
[Export] private Label _text;
[Export] private Button _updateButton;
[Export] private Button _closeButton;
// Stores the click count.
private int _clickCount = 0;
/// <summary>
/// Called by the framework when this instance of panel is created,
/// an instance can only gets created once.
/// </summary>
protected override void _OnPanelInitialize()
{
_updateButton.Pressed += OnClick; // Calls OnClick then the _updateButton gets pressed.
_closeButton.Pressed += ClosePanel; // Close this panel when the _closeButton gets pressed.
}
/// <summary>
/// Registered to the <see cref="_updateButton"/>.
/// </summary>
private void OnClick()
{
_clickCount++;
_text.Text = $"Clicked {_clickCount} time(s).";
}
/// <summary>
/// Called by the framework when this instance of panel is opened.
/// The framework supports automatic panel caching
/// so you may reopen a panel after it's closed and cached.
/// </summary>
protected override void _OnPanelOpen()
{
_text.Text = "Hello World";
_updateButton.GrabFocus();
}
}You can run RunMe_Example01.tscn in Godot Editor.
using Godot;
using GodotTask;
namespace GDPanelFramework.Examples;
/// <summary>
/// The bootstrap script that creates and opens the panel.
/// </summary>
public partial class Example01_Main : Node
{
/// <summary>
/// The packed panel.
/// </summary>
[Export] private PackedScene _panelPrefab;
/// <summary>
/// Executes the main logic after one frame since the game starts.
/// This is required by the GDPanelFramework for adding its panel root into the scene tree.
/// </summary>
public override void _Ready() =>
GDTask.NextFrame().ContinueWith(OnReady);
private void OnReady()
{
_panelPrefab
.CreatePanel<Example01_MyPanel>() // This extension method tells the framework to create or reuse an instance of this panel.
.OpenPanel( // This method tells the framework to opens the panel.
"Hello World!", // Passes the argument to the panel.
onPanelCloseCallback: // This delegate gets called when this panel gets closed when the panel itself calls ClosePanel().
result => // Prints the result and terminate the application when this panel gets closed.
{
GD.Print($"Clicked {result.Unwrap()} time(s) before closed.");
GetTree().Quit();
}
);
}
}using GDPanelFramework.Panels;
using Godot;
namespace GDPanelFramework.Examples;
/// <summary>
/// Attach this script to a Control to make it a "UIPanel".
/// </summary>
public partial class Example01_MyPanel : UIPanelArg2<string, string>
{
// These three fields are assigned in Godot Editor, through inspector.
[Export] private Label _text;
[Export] private Button _updateButton;
[Export] private Button _closeButton;
// Stores the click count.
private int _clickCount = 0;
/// <summary>
/// Called by the framework when this instance of panel is created,
/// an instance can only gets created once.
/// </summary>
protected override void _OnPanelInitialize()
{
_updateButton.Pressed += OnClick; // Calls OnClick then the _updateButton gets pressed.
_closeButton.Pressed += () => ClosePanel(_clickCount.ToString()); // Close this panel when the _closeButton gets pressed.
}
/// <summary>
/// Registered to the <see cref="_updateButton"/>.
/// </summary>
private void OnClick()
{
_clickCount++;
_text.Text = $"Clicked {_clickCount} time(s).";
}
/// <summary>
/// Called by the framework when this instance of panel is opened.
/// The framework supports automatic panel caching
/// so you may reopen a panel after it's closed and cached.
/// </summary>
protected override void _OnPanelOpen(string openArg)
{
_text.Text = openArg;
_updateButton.GrabFocus();
}
}In a typical GUI application such as Games, a panel/page-based control flow is a common practice.
When opening a panel from the main logic, developer may want the panel executes its own panel logic and self closes when finish, then continue the main logic (such as file dialog or warning).
This design transfers the control flow from the main logic to the panel, and the panel returns the control flow back to the main logic when finish simplifies the workflow for programming panels, it handles the requirement for managing ui focuses, and is crucial when designing game pad compatible games.
This framework implementing this practice by the Panel Stack based Control Management, Async/Callback Styled API, and Panel Input Binding design.
UIPanel is the fundamental component of the framework, it provides Panel Level Input Binding, Child Control Access Management features for simplfying programming workflow, it also supports configurable Panel Tweener for animated opening/closing requirements.
-
The
Panel Level Input Bindingfeature allows developers to register/deregister a set of input bindings for this panel, the registered inputs are sandboxed at the panel level so they don't get in the way when panel is inactive. -
The
Child Control Access Managementfeature automatically disables/restores theFocusModeandMouseFilterproperty for every child control when the panel activates/deactivates, this prevents unwanted UI Navigation and Mouse Interaction toleaked behindthe current activated panel.
Call CreatePanel<TPanel> to instatiate a panel from the supplied PackedScene, instead of the built-in PackedScene.Instantiate, this API also handles necessary initialization and caching.
// In caller class.
[Export] private PackedScene _panelPrefab;
// In caller method.
var panelInstance =
_panelPrefab
.CreatePanel<TypeOfScriptAttachedToThePanel>();There are three OpenPanel Methods for a UIPanel each of which is designed for a certain programming style.
In an async method, an async/await-styled opening method returns a PanelAwaitable / PanelAwaitable<TCloseArg> that allows the developer to await for a panel close. These awaitables are single-use, similar to ValueTask, and OpenPanelAsync also accepts an optional CancellationToken for externally closing the panel.
// When opening a panel, in async method.
await panelInstance.OpenPanelAsync();
GD.Print("The panel has closed!");
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await panelInstance.OpenPanelAsync(cancellationToken: cts.Token);A callback-styled opening method allows the developer to supply a delegate to get notified when the panel has closed. For UIPanelArg2, the callback receives a PanelResult<TCloseArg> so you can distinguish a normal close from an external cancellation.
// When opening a panel.
panelInstance
.OpenPanel(
onPanelCloseCallback: // This lambda gets called when the panel is closed.
() => GD.Print("The panel has closed!")
);
argPanelInstance
.OpenPanel(
10,
onPanelCloseCallback: result =>
{
if (result.TryGetValue(out var value))
GD.Print($"Returned: {value}");
else
GD.Print("The panel was closed by cancellation.");
}
);A forget-styled opening method only opens the panel, it is useful when the time of a panel closing is not a concern.
// When opening a panel.
panelInstance.OpenPanel();Calling ClosePanel() in a panel script will close the opened panel. This method is protected by default, developer may expose this method by wrapping it around by a public one.
If a panel is closed by the cancellationToken supplied to OpenPanel / OpenPanelAsync, the framework invokes _OnPanelExternalClose() instead of _OnPanelClose(...).
Please note that a panel must be opened before you can close it, and closing a panel that's not on top of the panel stack is considered an error and will crash the framework.
// Inside a panel script
protected override void _OnPanelOpen()
{
// Close a panel one frame after it opens.
GDTask.NextFrame().ContinueWith(ClosePanel);
}All Godot Input Events are intercepted by the root/RootPanelViewport and dispatched directly to the active panel. A set of inputs bound to the panel are automatically switched off or on when the panel deactivates/activates.
Calling RegisterInput in a panel can bind a delegate to a specific input event, the registered delegates are freed automatically when the panel gets freed.
// In panel
RegisterInput( // Register a callback to the associated inputName
BuiltinInputNames.UIAccept, // The input name to associate with, this name should Correspond to the name in InputManager.
inputEvent => GD.Print(inputEvent.AsText()), // The delegate to associate to.
InputActionPhase.Pressed // The input state to focus on.
);The actionPhase argument on RegisterInput, RemoveInput, ToggleInput, and related helpers is now optional. When omitted, the framework uses PanelManager.DefaultInputRegistrationBehavior, whose default value is InputRegistrationBehavior.Press.
PanelManager.DefaultInputRegistrationBehavior = PanelManager.InputRegistrationBehavior.Release;
RegisterInput(
BuiltinInputNames.UIAccept,
inputEvent => GD.Print($"Released: {inputEvent.AsText()}")
); // Uses the global default phase.In certain cases where unbinding a delegate is required, call RemoveInput with the corresponding registration.
Note that when working with input deregistration, to correctly deregisters a
lambda expression, it is mandatory toassign the lambda expression to a variableandpass that variable to the APIs.
// Assign this lambda expression to a variable.
Action<InputEvent> myDelegate = inputEvent => GD.Print(inputEvent.AsText());
// Register this callback to the associated inputName.
RegisterInput(BuiltinInputNames.UIAccept, myDelegate);
// Remove this registration.
RemoveInput(BuiltinInputNames.UIAccept, myDelegate);Alternatively, you may use the ToggleInput API.
ToggleInput( // This API supports change input registration based on the first bool.
true, // set to false to deregister.
BuiltinInputNames.UIAccept,
inputEvent => GD.Print(inputEvent.AsText()) // This lambda expression is cached by the compiler.
);For achieving certain purposes there are several other variations of input registration APIs.
Associate a delegate to any key/button style input received by the active panel.
RegisterAnyKeyInput(inputEvent => GD.Print($"Any key: {inputEvent.AsText()}"));Associate a delegate that receives true on press and false on release. You may bind one input name or a span of input names and observe the combined pressed state.
RegisterInputToggle(BuiltinInputNames.UIAccept, pressed => GD.Print($"Accept: {pressed}"));
RegisterInputToggle(
[BuiltinInputNames.UILeft, BuiltinInputNames.UIRight],
pressed => GD.Print($"Any horizontal input pressed: {pressed}")
);Associate a delegate to repeated held input, similar to keyboard key-repeat. The first call fires immediately, then repeats after InputEchoing.InitialDelay, and continues at InputEchoing.RepeatInterval.
InputEchoing.InitialDelay = 250;
InputEchoing.RepeatInterval = 100;
Action moveSelection = () => GD.Print("Move selection");
RegisterEchoedInput(BuiltinInputNames.UIDown, moveSelection);
RemoveEchoedInput(BuiltinInputNames.UIDown, moveSelection);Associate a delegate directly to the ui_cancel input event, developer may configure the value in PanelManager.UICancelActionName.
RegisterInputCancel(() => GD.Print("Canceled!"));
Action myDelegate = () => GD.Print("Canceled!");
RegisterInputCancel(myDelegate);
RemoveInputCancel(myDelegate);
ToggleInputCancel(true, () => GD.Print("Canceled!"));UIPanel comes with two extra input binding APIs: EnableCloseWithCancelKey and DisableCloseWithCancelKey, Calling EnableCloseWithCancelKey allows the player to close the current panel with ui_cancel (PanelManager.UICancelActionName), and DisableCloseWithCancelKey revert this behavior.
Associate a delegate to the composites of two inputs, similar to what Input.GetAxis does.
RegisterInputAxis(
BuiltinInputNames.UILeft,
BuiltinInputNames.UIRight,
value => GD.Print(value),
CompositeInputActionState.Update // Start, End
);
Action<float> myDelegate = value => GD.Print(value);
RegisterInputAxis(
BuiltinInputNames.UILeft,
BuiltinInputNames.UIRight,
myDelegate,
CompositeInputActionState.Update
);
RemoveInputAxis(
BuiltinInputNames.UILeft,
BuiltinInputNames.UIRight,
myDelegate,
CompositeInputActionState.Update
);
ToggleInputAxis(
true,
BuiltinInputNames.UILeft,
BuiltinInputNames.UIRight,
value => GD.Print(value),
CompositeInputActionState.Update
);Associate a delegate to the composites of four input, similar to what Input.GetVector does.
RegisterInputVector(
BuiltinInputNames.UIUp,
BuiltinInputNames.UIDown,
BuiltinInputNames.UILeft,
BuiltinInputNames.UIRight,
value => GD.Print(value),
CompositeInputActionState.Update // Start, End
);
Action<Vector2> myDelegate = value => GD.Print(value);
RegisterInputVector(
BuiltinInputNames.UIUp,
BuiltinInputNames.UIDown,
BuiltinInputNames.UILeft,
BuiltinInputNames.UIRight,
myDelegate,
CompositeInputActionState.Update
);
RemoveInputVector(
BuiltinInputNames.UIUp,
BuiltinInputNames.UIDown,
BuiltinInputNames.UILeft,
BuiltinInputNames.UIRight,
myDelegate,
CompositeInputActionState.Update
);
ToggleInputVector(
true,
BuiltinInputNames.UIUp,
BuiltinInputNames.UIDown,
BuiltinInputNames.UILeft,
BuiltinInputNames.UIRight,
value => GD.Print(value),
CompositeInputActionState.Update
);Godot provides a list of builtin UI input event names, developer may access these input names from the BuiltinInputNames class.
The active panel still receives routed panel input, but the framework can now also broadcast every processed input event globally through IGlobalInputListener.
public partial class MyGlobalInputLogger : Node, IGlobalInputListener
{
public void OnGlobalInput(InputEvent inputEvent)
{
GD.Print($"Global input: {inputEvent.AsText()}");
}
}
PanelManager.AddGlobalInputListener(this);
PanelManager.RemoveGlobalInputListener(this);The Panel Stack is designed to maintain the order of the opened panels, when opening a panel, the framework peeks at the panel stack for the top panel, disables every control under it (their opening statuses are cached), and pushes this new instance to the stack. When closing the top panel, the framework pops it from the panel stack and reactivates all the control for the panel underneath it, it also sets the focus to the last selected item before this panel becomes inactive.
The example below shows the panel stack of the following sequence of operations:
timeline
No Panel
Open MainPanel : MainPanel (Active)
Open SettingPanel : SettingPanel (Active) : MainPanel (Deactivated)
Open SettingConfirmPanel : SettingConfirmPanel (Active) : SettingPanel (Deactivated): MainPanel (Deactivated)
Close SettingConfirmPanel : SettingPanel (Reactivated) : MainPanel (Deactivated)
Close SettingPanel : MainPanel (Reactivated)
Close MainPanel
In certain cases where a panel requires frequent opening and closing by design (think about the inventory panel in some games), instantiating a panel and deleting it on close every time can be expensive. To resolve this performance issue, the framework does automatically panel caching that you can configure on a per opening/closing basis.
When creating a panel, by specifying the createPolicy, you may choose to force the framework instantiate a new instance of the panel (CreatePolicy.ForceCreate) or let the framework reuse a cached instance (default) if possible (CreatePolicy.TryReuse), of course, if there is no existing cache, a new instance is created anyway.
// When creating a panel.
var panelInstance =
_panelPrefab
.CreatePanel<TPanel>(
createPolicy: CreatePolicy.ForceCreate // CreatePolicy.TryReuse
);When opening a panel, by specifying the closePolicy, you may choose to instruct the framework to delete this instance (ClosePolicy.Delete) after the transition completes or let the framework to cache this instance (default) (ClosePolicy.Cache), which you can reuse when the calling CreatePanel on the same PackedScene next time.
// When opening a panel.
panelInstance
.OpenPanel(
closePolicy: ClosePolicy.Delete // ClosePolicy.Cache
);
#### Scoped Panel Buffering
For temporary gameplay states or modal flows, you may create a scoped panel buffer. Panels cached while the scope is active are stored separately from the global cache and are all freed when the scope ends.
```csharp
var scopeToken = PanelManager.BeginScopedPanelManagement("Gameplay");
try
{
var panel = _panelPrefab.CreatePanel<MyPanel>();
panel.OpenPanel(closePolicy: ClosePolicy.Cache);
}
finally
{
PanelManager.EndScopedPanelManagement(scopeToken);
}While working with UIPanel, certain methods get called at a certain lifetime of a panel, a brief diagram of the panel can be summarised as follows.
---
title: The Summary of Event Methods throughout the lifetime of UIPanel
---
flowchart TD
id1["_OnPanelInitialize()"]
id2["_OnPanelOpen()"]
id3(["ClosePanel()"])
id4["_OnPanelClose()"]
id5["_OnPanelPredelete()"]
id6["_OnPanelNotification()"]
id0[["Framework Calls"]] -.-> id1
id1 -.->|Framework Calls|id2
subgraph Called Multiple Times before the Panel gets Freed
id2 --> id3
id3 -.->|Framework Calls|id4
id4 -.->|Framework Calls|id2
end
id6 -.->|Framework Calls|id5
id7[["Godot Calls"]] -.-> id6
- When calling
CreatePanel<TPanel>(PackedScene)and causing a new instance of the creation, after the framework has done basic initializing, the_OnPanelInitializemethod of that instance gets invoked. This method gets called only once throughout the panel lifetime; that means, if theCreatePanelhas reused an instance of the panel, this method is not invoked again. - When calling any of the
OpenPanelon a non-opened panel instance, after the framework has done preparations for opening this panel, the_OnPanelOpenmethod gets invoked. For a closed panel that gets cached,_OnPanelOpenwill get re-invoked when the panel gets reopened. - When calling the
ClosePanel, after the framework has done preparations for closing this panel, the_OnPanelClosemethod gets invoked. For a panel that gets cached,_OnPanelClosewill get re-invoked when the panel gets reopened and closed. - A
UIPaneldelegates the_Notificationengine call to_OnPanelNotification, and calls_OnPanelPredeletewhen necessary.
When opening a new panel, the currently active panel becomes unavailable (such as buttons will no longer be clickable or focusable), you may also control whether the current panel should stay visible or hidden.
Setting the previousPanelVisual to PreviousPanelVisual.Hidden in OpenPanel, will instruct the framework to hide the previous panel using its PanelTweener, otherwise the panel will stays visible (default) (PreviousPanelVisual.Visible).
// When opening a panel.
panelInstance
.OpenPanel( // Any panel opening method.
previousPanelVisual: PreviousPanelVisual.Hidden // PreviousPanelVisual.Visible
);Passing arguments to a panel and receiving a result from it are separated into two base types:
UIPanelArg1<TOpenArg>for panels that need an opening argument but do not return a value.UIPanelArg2<TOpenArg, TCloseArg>for panels that need both an opening argument and a closing result.
If you need neither, inherit UIPanel. If you only need one side of the pair while still using UIPanelArg2, use Empty as the placeholder type.
// MyArgumentPanel.cs
// Defines a panel that accepts an int as the opening argument, and string as the returning value.
public partial class MyArgumentPanel : UIPanelArg2<int, string>
{
protected override void _OnPanelOpen(int openArg) // The opening argument passed from the caller.
{
GD.Print($"Opened with argument: {openArg}");
ClosePanel(openArg.ToString()); // The ClosePanel method requires a return value.
}
}Different from the regular UIPanel type, the OpenPanel methods of UIPanelArg1 and UIPanelArg2 accept an extra argument and pass it to _OnPanelOpen(TOpenArg). UIPanelArg2 additionally exposes async/callback overloads for receiving the closing result.
// In caller class.
[Export] private PackedScene _panelPrefab;
// In caller method.
var argPanelInstance = _panelPrefab.CreatePanel<MyArgumentPanel>();
// Async/Await-styled open method.
string returnValue = await argPanelInstance.OpenPanelAsync(10); // return value is "10".
// Callback/Delegate-styled open method.
argPanelInstance.OpenPanel(
10,
onPanelCloseCallback: result => GD.Print(result.Unwrap() == "10")
); // prints true when the panel closes normally.UIPanelArg1 is the compact option when you only need an opening argument:
public partial class MyArgumentPanel : UIPanelArg1<int>
{
protected override void _OnPanelOpen(int openArg)
{
GD.Print($"Opened with argument: {openArg}");
ClosePanel();
}
}UIPanelArg2 supports both passing an argument and returning a value. If one of the features is not needed, you may use the Empty struct to serve as a placeholder.
// The definition for a panel that doesn't require an opening argument.
public partial class MyArgumentPanel : UIPanelArg2<Empty, string>
{
protected override void _OnPanelOpen(Empty _)
{
ClosePanel("Hello World!");
}
}
// In caller method
argPanelInstance.OpenPanelAsync(Empty.Default);// The definition for a panel that doesn't requires returning value.
public partial class MyArgumentPanel : UIPanelArg2<int, Empty>
{
protected override void _OnPanelOpen(int openArg)
{
GD.Print($"Opened with argument: {openArg}");
ClosePanel(Empty.Default);
}
}All panels in are instantiated under root/RootPanelViewport/PanelRoot by default, developers may configure the container for the opening panel through a series of APIs.
Similar to the Panel Stack, Panel Container Stack is designed for managing the panel containers, the developer may push a control to the panel container stack using PanelManager.PushPanelContainer, and pop the topmost container by PanelManager.PopPanelContainer. Similar to the restrictions of opening and closing panels, developers are only allowed to pop the topmost container before they are allowed to pop the other containers.
To prevent unexpected poping of containers, each PushPanelContainer operation is authorized by a Node, that is, you need to provide a key when pushing a new container, and popping the container with the same key.
// In class
[Export] private Control _myContainer;
// In method
// Every opened panel after this line will get instantiating/reparenting under _myContainer.
PanelManager.PushPanelContainer(this, _myContainer);
// Every opened panel after this line will get instantiating/reparenting under the default panel container.
PanelManager.PopPanelContainer(this);Please note that, when working with customized panel containers, be careful when
spawning panels under a panel/custom containerthat'sgetting deleted in the future, while the framework is trying its best to handle deleted panels, it is possible todelete custom panel containers that have active panels live under, such behavior will possibly crash the framework, developers are recommended toensure every panel under a custom container has closedbeforepopping/deleting that container.
OpenPanelAsync now returns PanelAwaitable / PanelAwaitable<T>, a lightweight pooled awaitable dedicated to panel lifetime tracking.
These awaitables are single-use. Awaiting a panel opened with a canceled token throws OperationCanceledException, while callback-based APIs receive PanelResult.None.
Panels also expose PanelCancellationToken for responding to external close requests from inside panel logic, and _OnPanelExternalClose() for custom cleanup when the supplied CancellationToken closes the panel.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try
{
var value = await argPanelInstance.OpenPanelAsync(10, cancellationToken: cts.Token);
GD.Print($"Panel returned: {value}");
}
catch (OperationCanceledException)
{
GD.Print("The panel was canceled externally.");
}While there are precautions taken, there are still cases where certain uses of APIs could inevitably crash the framework.
The following panel event methods are executed in under try ... catch block, throwing exceptions in the overrides of these methods will not crash the framework.
_OnPanelInitialize_OnPanelOpen_OnPanelClose_OnPanelExternalClose_OnPanelPredelete_OnPanelNotification- Registered input events
The following usage WILL crash the framework:
- Creating a panel by specifying a type that's not equal to the type of the
Script. - Opening a panel that's not initialized, which probably means the instance of this panel is not obtained through the
CreatePanelAPI. - Opening a panel that's already opened.
- Closing a panel that's not the last opened panel.
- Providing an invalid
CompositeInputActionStateenum. - Authorising a
panel container poppingwith anodewhich is pushed by a differentnode. - Reuse the
awaitkeyword on aPanelAwaitablethat has already awaited, or access its awaiter after completion. - Calling
GetResult()on aPanelAwaitablethat has not been completed yet.
Developers may customize a panel's visual transition behavior when opening/closing by accessing its PanelTweener property, or modifying the PanelManager.DefaultPanelTweener to set the default tweener for all panels globally.
There are two preconfigured Tweenrs provided with the framework.
- NonePanelTweener: This tweener simply hides and shows the panel instantly on open and close, it is also the default value of
PanelManager.DefaultPanelTweener, you may access the global instance of this tweener fromNonPanelTweener.Instance. - FadePanelTweener: This tweener performs fade transition for the panel opening and closing, after instantiating the tweener, you may configure the transition time by accessing its
FadeTimeproperty.
By inheriting the IPanelTweenr interface, the developer may customize their transition effects.
/// <summary>
/// Defines the behavior for panel transitions.
/// </summary>
public interface IPanelTweener
{
/// <summary>
/// This sets the default visual appearance for a panel.
/// </summary>
/// <param name="panel">The target panel.</param>
void Init(Control panel);
/// <summary>
/// This async method manages the behavior when the panel is showing up.
/// </summary>
/// <param name="panel">The target panel.</param>
/// <param name="onFinish">Called by the method when the behavior is considered finished, or not be called at all if the behavior is interrupted</param>
void Show(Control panel, Action? onFinish);
/// <summary>
/// This async method manages the behavior when the panel is hiding out.
/// </summary>
/// <param name="panel">The target panel.</param>
/// <param name="onFinish">Called by the method when the behavior is considered finished, or not be called at all if the behavior is interrupted</param>
void Hide(Control panel, Action? onFinish);
}This section focuses on the dedicated runtime panel builder DSL introduced by PanelBuilder, rather than the PackedScene-based panel flow shown above.
PanelBuilder is a runtime factory for UIPanel, UIPanelArg1, and UIPanelArg2. Instead of preparing a PackedScene in the editor, you describe the control tree directly in C# and let the framework wrap that tree inside a fully initialized panel instance.
The important point is that this is not a separate UI system. The result is still a regular framework panel with the same panel stack, focus handling, async open APIs, callback open APIs, input routing, and close semantics. The DSL only changes how the panel's Control tree is produced.
This makes it a good fit when:
- the UI is highly data-driven or generated from runtime state
- the panel is primarily a tool, inspector, picker, or debug surface
- you want to compose small temporary dialogs without creating many scene assets
- you need a panel flow that is easier to keep close to gameplay or application logic
It is usually a worse fit when the panel depends heavily on hand-authored scene layout, animation tracks, artist iteration in the editor, or large reusable visual prefabs. In those cases, the PackedScene workflow is still the better default.
You may also build a simple panel completely in C# without a PackedScene. Runtime-built panels use the same open and await APIs, but currently support ClosePolicy.Delete only.
using GDPanelFramework;
using Godot;
var panel = PanelBuilder.CreatePanel(builder =>
{
var titleLabel = builder.Label("Runtime Panel", label => label.HorizontalAlignment = HorizontalAlignment.Center);
var closeButton = builder.Button("Close");
builder.OnPanelInitialized += runtimePanel =>
{
closeButton.Pressed += runtimePanel.Close;
runtimePanel.RegisterInputCancel(runtimePanel.Close);
};
return builder.MarginContainer(
builder.VBox(
box => box.AddThemeConstantOverride("separation", 12),
titleLabel,
closeButton
)
);
});
await panel.OpenPanelAsync(closePolicy: ClosePolicy.Delete);
var argPanel = PanelBuilder.CreatePanelArg2<int, string>(builder =>
{
var valueLabel = builder.LateInit<Label>();
builder.OnPanelOpen += runtimePanel =>
{
valueLabel.Text = runtimePanel.CurrentOpenArg.ToString();
runtimePanel.Close($"closed:{runtimePanel.CurrentOpenArg}");
};
return builder.MarginContainer(valueLabel = builder.Label());
});
var result = await argPanel.OpenPanelAsync(10, closePolicy: ClosePolicy.Delete);The three entry points map directly to the three panel shapes used elsewhere in the framework:
CreatePanel(...)creates aUIPanelCreatePanelArg1<TOpenArg>(...)creates a panel that accepts one open argumentCreatePanelArg2<TOpenArg, TCloseArg>(...)creates a panel that accepts an open argument and returns a typed close value
The runtime DSL also covers tool-oriented controls such as TextureRect, floating-point and integer SpinBox helpers, and OptionButton overloads that accept icon entries.
For a richer runtime-built sample, run Example/03/RunMe_Example03.tscn to see RichTextLabel, TextureButton, ColorPickerButton, HSlider, and ProgressBar created entirely through PanelBuilder.
The builder callback should be read as “construct the panel's control tree once, then register panel lifecycle hooks around it”. In practice that means two kinds of work happen in the same place:
- You create the
Controlhierarchy that becomes the panel content. - You attach panel lifecycle handlers such as
OnPanelInitializedandOnPanelOpen.
That split matters because control creation and panel opening are not the same moment. The callback runs when the runtime panel is created, while OnPanelOpen runs each time the panel is opened. If a panel may be reopened, initialization code and open-time reset code should stay separate.
For stateful runtime panels, the usual pattern is:
- Declare references with
LateInit<T>()for controls that must be updated from callbacks or lifecycle events. - Keep your mutable panel state in normal C# variables or helper objects captured by the builder callback.
- Use
OnPanelInitializedfor one-time wiring such as button events and panel-scoped input registration. - Use
OnPanelOpento readCurrentOpenArg, reset state for this opening, and push that state back into the controls. - Close through the supplied runtime handle when the panel flow is complete.
LateInit<T>() is particularly important because complex trees are often built inside nested container expressions. It gives you a typed placeholder so a child control can be assigned while still being referenced later by callbacks or panel events.
OnPanelInitialized is the place for one-time setup. Treat it like _OnPanelInitialize() for a runtime-built panel. This is where button delegates, cancel behavior, token registrations, and input bindings usually belong.
OnPanelOpen is the place for per-open refresh. Treat it like _OnPanelOpen(...). If the panel has open arguments, this is where CurrentOpenArg should be read and translated into current UI state.
For CreatePanelArg2, the runtime handle also becomes your typed close channel. That is the main reason the DSL works well for picker dialogs, wizards, and temporary editors: the whole interaction can stay in one C# flow while still returning a strongly typed result.
The helper set is meant to cover the repetitive Control creation that otherwise turns runtime-built UI into a long sequence of new, property assignment, and AddChild calls.
At a high level, the DSL currently includes:
- layout containers such as
VBox,HBox,Grid,Scroll,Panel,Center,HSplit, andVSplit - text and display controls such as
Label,RichTextLabel, andTextureRect - interactive controls such as
Button,TextureButton,CheckButton,LineEdit, andTextEdit - value editors such as
HSlider,VSlider, floating-point and integerSpinBox,ColorPickerButton, andProgressBar - list and selection widgets such as
OptionButton,ItemList, andTree - tree item helpers such as
TreeRoot(...)andTreeItem(...)
The goal is not to hide Godot. The init callbacks still expose the actual Godot control instance, so you can keep using native properties and theme overrides whenever the helper defaults are not enough.
The DSL also exposes panel-specific hooks that plain Control factories do not normally give you:
OnPanelInitialized,OnPanelOpen,OnPanelClose, and related events mirror the runtime panel lifecycle- runtime handles expose
Close(), typed close values, cancellation tokens, and open/close tween tokens - runtime handles can register panel-scoped input bindings such as
RegisterInputCancel,RegisterInputAxis, andRegisterInputVector
That combination is why the runtime builder is more than just a shorthand for creating controls. It is a shorthand for creating controls and attaching them to the framework's panel model in one place.
Runtime-built panels currently support ClosePolicy.Delete only. In practice, this means you should treat them as runtime-generated objects whose control tree is rebuilt when you recreate the panel, rather than as cached editor-authored assets.
Example/03/RunMe_Example03.tscn is a single-panel showcase of the helper controls, while Example/04/RunMe_Example04.tscn demonstrates a more complete workflow with live state, a nested confirmation dialog, and a typed close result.