Skip to content

Godot UI

Created: 2020-07-12 21:43:58 -0700 Modified: 2020-07-12 21:47:11 -0700

  • If you’re seeing a Control node in world space, then it’s likely because you didn’t use a canvas layer (reference). Don’t make your UI a child of a Camera (reference).
  • Containers are intended to be used to size/position children.
    • Control nodes themselves sort of break this. I don’t think that adding Control nodes is generally a good solution. It’s probably better to position using more containers when possible. E.g. one time, I wanted a “Close” button at the upper right of a dialog, so I put the first item in the dialog into an HBoxContainer, that way the “Close” button could live in that container.
    • If you want a container to shrink to the size of its children, then you probably just need to wrap your container inside one that does shrink to its children, e.g. a VBoxContainer. E.g. if you’re making a Label or RichTextLabel that you want to shrink for a tooltip, your tree should probably be VBoxContainerPanelContainerMarginContainerLabel.
    • As an example, at one point, I wanted to make a GridContainer with highlightable rows, so I thought I would use a ColorRect in conjunction with the grid. But due to how container positioning works, it was actually much easier to override Draw (reference).
  • You have to mark input as handled (reference), e.g. call AcceptEvent() from _GuiInput and call GetViewport().SetInputAsHandled() from _UnhandledInput. Without this, I think you may see strange behavior where an event seems to be consumed twice.
  • TextureProgressBar has an “over” texture; it’s for some texture that should cover the entire lifebar. If you want to use it for a border, I think you would need to do more than just provide a solid, filled rectangle (e.g. you would need a proper border graphic or use a 9-patch rect).
  • Primer video on anchors

In general, if you find yourself trying to write code to detect if the mouse is within a rectangle yourself, then you’re likely doing things wrong.

E.g. I originally tried to use global_position and global_scale to figure out a Rect2 so that I could call has_point on it with event.position. However, this didn’t take the Camera2D’s bounds into account, so it would mess up whenever I moved the camera, and probably would have been a pain to maintain anyway.

Instead, the “right” way to do things is to add an Area2D and CollisionShape2D to your object, then use the Area2D’s input_event signal:

func _on_input_event(_viewport: Node, event: InputEvent, _shape_idx: int) -> void:
if event is InputEventMouseButton and event.pressed:
# do something
get_tree().set_input_as_handled()

Then, the issue I ran into was that my CanvasLayer was consuming clicks (reference). I had a Control under the CanvasLayer whose mouse_filter I needed to set to IGNORE. If you want to figure out which component is getting clicks, run your game from Godot, then click Debugger → Misc to see the last-clicked control: 500

If you still can’t figure out why a node isn’t receiving input, add this code to the node:

public override void _UnhandledInput(InputEvent @event)
{
GD.Print(@event.AsText());
}

…if it doesn’t print a bunch of Mouse motion at position messages while hovering over the component, then something must be consuming the events before it.

  • Use Debugger → Misc to figure out which component is being clicked.
  • Turn off visibility of nodes directly in the editor while the game is running. Eventually, your component should get the events, meaning you’ve found the offending node.
    • If you haven’t, it’s possible that a parent Control has a mouse filter set to Stop, in which case try changing ancestors’ mouse filters to Pass
  • Consult this page of the documentation for more information about how input handling works.

For example, I had this scene tree at one point:

  • VBoxContainer Game
    • SubViewportContainer Container - with Disable Input off and Object Picking on
      • SubViewport
        • Sprite2D

…the sprite was getting UnhandledInput for mouse events as expected, but Game wasn’t. It’s because SubViewportContainer was propagating the right-click event to the SubViewport, which I needed to stop with this code:

public override bool _PropagateInputEvent(InputEvent @event)
{
if (
@event is InputEventMouseButton inputEventMouseButton
&& inputEventMouseButton.Pressed
&& inputEventMouseButton.ButtonIndex == MouseButton.Right
)
{
return false;
}
return true;
}

However, understanding this particular scenario for mouse clicks is quite difficult. At least in Godot 4.3, if I have this setup:

  • Override _UnhandledInput in all four nodes to print the event
  • Set mouse filter on Container to Pass
  • Set Disable Input on SubViewport to false

…I get this output when I right-click:

[UNHANDLED] Right-click in Sprite2D
[UNHANDLED] Right-click in SubViewport

Note that it matters where you right-click. E.g. you may have black bars around your game due to the aspect ratio and window size. In that case, the output you get when you click can change based on where the mouse was.

Anyway, it’s strange to me that Game doesn’t get to process the click as unhandled input. My thought process is this: the click isn’t handled in a child node, so I would expect it to propagate up the scene tree. It’s also strange that SubViewportContainer doesn’t print anything.

If I take that same setup and override SubViewportContainer’s _PropagateInputEvent so that right-click isn’t propagated, I get this output:

[UNHANDLED] Right-click in SubViewportContainer
[UNHANDLED] Right-click in Game

In other words, now the SubViewport doesn’t get the input at all (since it’s not propagated), and the Game gets a chance to handle the input. This seems to be the same behavior you can achieve by setting Container’s mouse filter is Ignore (although that will affect all mouse events, not just right-click).

I can’t explain this though. 😢 When right-click events are propagated to the SubViewport, why doesn’t SubViewportContainer or Game get the clicks as unhandled input?

I think it may just be how SubViewports handle inputs. If you put this code in the SubViewportContainer:

public override void _UnhandledInput(InputEvent @event)
{
if (
@event is InputEventMouseButton inputEventMouseButton
&& inputEventMouseButton.Pressed
&& inputEventMouseButton.ButtonIndex == MouseButton.Right
)
{
GD.Print("[UNHANDLED] Right-click in SubViewportContainer");
}
((Viewport)GetChild(0)).PushInput(@event);
}

…then after the SubViewportContainer gets the input, it will pass it to the SubViewport and you’ll see this:

[UNHANDLED] Right-click in SubViewportContainer
[UNHANDLED] Right-click in Sprite2D
[UNHANDLED] Right-click in SubViewport

The important part is that the Game still doesn’t trigger its unhandled input at all, meaning the SubViewport must be consuming the event. I don’t see code for this in the Godot engine itself.

Either way, here are my conclusions:

  • I don’t fully understand why mouse clicks don’t propagate up through unhandled input. Something must be marking it as handled even in my minimal repros which have no such calls to mark it handled. I filed a bug on this.
  • None of this unusual behavior would happen if you turn Object Picking off. However, then you wouldn’t be able to click anything with a collider (e.g. an item or NPC in your game).
  • If you use a SubViewportContainer and a SubViewport and can’t get the parent of those nodes to process input, then consider overriding _PropagateInputEvent (or maybe calling PushInput on the Viewport depending on what you need)

Another consideration for clickability

Section titled Another consideration for clickability

At one point, I ran into this situation with my game:

  • I wanted a horizontal ScrollContainer to contain up to three elements.
  • The elements would be centered on the bottom of the screen.
  • If the game window wasn’t wide enough room, you would scroll the container either via the scroll wheel or by clicking and dragging one of the elements.

The problem I had with this was that the ScrollContainer was set to always take up the entirety of the screen, so when there were no elements, there would just be an invisible Control consuming mouse inputs.

I tried a bunch of stupid things like redirecting input events before realizing two key things:

  • The ScrollContainer simply shouldn’t be taking up the full screen if there aren’t visible elements within it.
  • The ScrollContainer should be in charge of the clicking and dragging. E.g. one of the problems I ran into when trying to have the elements handle it was that there was padding between the elements that the elements couldn’t know about and thus couldn’t have an event handler for.

This page of documentation has the order that input handling works. It’s important to note that clicks happening from colliders are considered part of the final step, physics picking.

If you want to change the order that something happens in, then you need to use a different event entirely. For example, suppose you have an object that requires picking, but you can’t wait until the physics-picking step. In that case, you can “promote” it to an input event:

  • Keep track of a bool _isMouseOver
  • Connect to MouseEntered and MouseExited to set that boolean properly
  • In _Input, make sure _isMouseOver == true when you click something
  • Add your TTF font to res://assets/fonts (not required, but a good convention)
  • Choose “New FontFile” and drag your file onto it
  • Alternatively, you can set it everywhere via Project Settings → GUI → Theme → Custom Font.

Tween code can get ugly pretty quickly. It may be better to use an AnimationPlayer, especially because then you can play the animations directly in Godot rather than having to build/run/test your tweens repeatedly.

Callable tweenFontSize = Callable.From<int>(TweenFontSize);
sizeTween.SetLoops(0);
sizeTween.TweenMethod(tweenFontSize, 19, 25, 1);
sizeTween.TweenMethod(tweenFontSize, 25, 19, 1);
private void TweenFontSize(int value)
{
_label.AddThemeFontSizeOverride("font_size", value);
}

Notes:

  • I tried tweening a Label’s scale, but it sort of jittered the position.
  • The font size is an integer, so tweening it may still look sort of jittery.
    • I got around that by making the font huge (~300px) and then setting the scale to ~0.06 (for an effective font size of 18px). However, this caused several problems (all fixable):
      • When I updated the pivot via code, the label would move down and right. I fixed this by making sure I had the correct pivot set in the editor.
      • When I increased the label size, the text would move depending on its anchor. I fixed this by making the size of the label large enough to accommodate the biggest font size that would be applied.
      • The outline and shadow needed to be scaled up, too. However, whenever you update the outline, you may also need to change the MSDF Pixel Range of the font (in the Import settings) since it’s always supposed to be at least 2x the largest outline used (reference).

I ran into a situation where I wanted to set a maximum size of 0px so that I could animate a container growing. However, the container itself had a minimum size of 27 px because of a button with a label in it. I didn’t want to have to hide every child with a minimum size, so I concocted this solution:

  • Make a SubViewportContainer with a Viewport underneath it
  • Add your container to the Viewport and set its anchor to “Full Rect”
  • Set the minimum size of the SubViewportContainer to the maximum size that you wanted originally (you can do this through code)
  • If the SubViewportContainer has a sibling, then make sure its container sizing has “Expand” turned off
  • A CanvasItem may have its Visible property set to true but still not actually be visible in the tree (reference). You have to use IsVisibleInTree() for that.
  • To find out when a CanvasItem is shown or hidden even due to a parent being shown or hidden, use the VisibilityChanged signal.

Spritesheet sprite in a TextureRect

Section titled Spritesheet sprite in a TextureRect
  • To use a single sprite from a spritesheet as a TextureRect, use an AtlasTexture.
    • Make a new TextureRect
    • In the Inspector, make a new AtlasTexture
    • Expand the AtlasTexture so that you can see the “Atlas” property
    • Drag a spritesheet onto the “Atlas”
    • Set the region below it

  • If you end up using the AtlasTexture a lot, you can save it as a file and share it between controls.
  • Note: I had found a much more complicated way of doing this with a ViewportTexture in the TextureRect, then a sibling Viewport in the scene with a Sprite child of that Viewport, but then the Sprite size had to be doubled for some reason and I couldn’t figure out why.

Centering an AtlasTexture sprite in a layout

Section titled Centering an AtlasTexture sprite in a layout

To achieve something like this:

…my scene tree looks like this:

  • Panel
    • GridContainer
      • TextureRect
      • CenterContainer
        • Label

The TextureRect has “Keep Aspect Centered”:

Center elements in a GridContainer

Section titled Center elements in a GridContainer

This is sort of like “justify-content: space-between” in CSS:

How I accomplished this was with this scene tree:

  • Container
    • CenterContainer
      • Label (“Row 1”)
    • GridContainer (or HBoxContainer if you want)
      • Label (“Row”)
      • Label (“Two”)
      • Label (“Here”)

The GridContainer has 3 columns and has the “fill” and “expand” size flags for “Horizontal” (the scripting constant for this is SIZE_EXPAND_FILL). Each child label has the same horizontal size flags and also “Align: Center”.

To get closer to “justify-content: space-between”, I think you’d just set the middle label to SIZE_EXPAND_FILL, not the left/right labels.

Overlapping problems with containers

Section titled Overlapping problems with containers

I kept running into issues like this:

This is a scene tree that looks like this:

  • VBoxContainer
    • CenterContainer
      • HBoxContainer
        • Item
    • CenterContainer
      • HBoxContainer
        • Item
        • Item
    • CenterContainer
      • HBoxContainer
        • Item
        • Item

…where “Item” was itself another scene whose root node was a Panel:

My issue ended up being that I didn’t set a minimum size on the Panel in Item. Without that, a CenterContainer can’t know how big the Panel should be, so it sets the size to (0, 0).

Alternatively, you could be running into this issue where dynamically laying out a container’s children isn’t straightforward.

A MarginContainer is like the “padding” style in HTML; it puts extra space inside the container. This is not to be confused with the “Margin” properties on all Control nodes! MarginContainer’s properties are here:

Here’s a MarginContainer with a Panel as a child:

As you can see, the panel is “pulled away” from each edge by 25 pixels.

Positioning labels or other UI through code

Section titled Positioning labels or other UI through code

Making a Label and setting its text doesn’t immediately update its size (reference). Call get_combined_minimum_size() instead of size, and if that still doesn’t work (e.g. the CanvasItem was only just shown in the scene tree), then wait for process_frame, e.g. await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame); (reference).

Notes:

  • The reason why you have to get the combined minimum size is because MinimumSize can report a size a smaller than CustomMinimumSize, so CombinedMinimumSize is the larger of the two, which is the actual minimum size. I don’t understand what MinimumSize actually represents (the documentation says it’s the “internal minimum size”).
  • Invisible nodes may not ever have their proper sizes reported, in which case you would have to make them visible first.
  • There’s still an oddity on Windows sometimes where the size of something is reported as 0 even after waiting for the next frame. I didn’t have time to investigate, but if everything is working fine on macOS but not on Windows, then consider just calculating the size of that thing yourself. I don’t have anything to link to due to the lack of an investigation on this. Private Skeleseller code is here.

Go to Theme Overrides → Styles → Normal → Make a StyleBoxFlat

To do this through GDScript (reference) (WARNING: this may cause Godot to freeze/crash on exit due to this issue (which should be fixed by this PR, but if not, consider keeping references to styleboxes from GDScript)):

var new_stylebox_normal: StyleboxFlat = $MyButton.get_theme_stylebox("normal").duplicate()
new_stylebox_normal.bg_color = Color.DARK_GREEN
$MyButton.add_theme_stylebox_override("normal", new_stylebox_normal)
# Later, you can remove the stylebox override:
$MyButton.remove_theme_stylebox_override("normal")

When working with 2D scenes, the UI isn’t just automatically placed in screen coordinates. I believe this is what you’d use a CanvasLayer for (reference). follow_viewport_enabled should be off in that case.

If you want many components in a UI to use the same theme, make a theme in the inspector, then modify it using the “Theme” button at the bottom of the editor: 500

To use this: 500

  • Start by clicking the ➕ button at the upper right and selecting something like “Button”.
  • From there, you can customize all of the shared properties of a button. This still isn’t the most straightforward though.
    • Click the ➕ button to the left of one of the style boxes to add a new one
    • Select “New StyleBoxFlat”
    • Click the StyleBoxFlat that you just made to change the inspector to that style box

You can apply a theme to a control node and it will be inherited by all children.

Also, you can apply a default theme across your whole project using Project settings → GUI → Theme → Custom (it’s literally just the name “Custom” for right now).

It’s best to make a PanelContainer and override its style to have a StyleBoxFlat with your nine-patch texture: 800

As shown, I’m using only a specific region within UI.png which is 48x48. Because there are 9 tiles inside that 48x48 region, I set the texture margins to 16x16 (since 48*48 == 9*16*16).

If the borders of your graphic are stretching, then it means that you didn’t set the texture margins correctly.

TextureProgressBar has a height of 1px

Section titled TextureProgressBar has a height of 1px

This is probably because you need to check this the “Nine Patch Stretch” box. If not for that, then your container sizing or containers may be wrong (or just the size of the component is wrong if it’s not in a container).

See this; you generally just need to set the stretch mode to canvas_items in the project settings. However, that mode isn’t recommended for pixel art due to how it scales.

If you can’t do that, then turn on theme/default_font_multichannel_signed_distance_field in the project settings and make sure each label/button/whatever’s filter setting is “Linear”, not “Nearest”. This isn’t perfect though. It’s just really, really hard to get font settings to look good in a pixel-art game. Perhaps consider using a bitmap font.

If the blurry text only appears inside a tooltip, then I think that’s just a bug.

A font has “holes” or weird artifacts

Section titled A font has “holes” or weird artifacts

Pasted image 20240902154049.png

This is caused by the font having overlapping glyphs (reference). The official documentation even talks specifically about using MSDF and Google Fonts.

In this picture, see how the word “battle” has its characters misaligned vertically? 600

This is caused by a combination of the hinting property, which snaps glyphs to integral coordinates (reference), and the MSDF size (if MSDF is enabled), which is a feature used to pre-generate textures for different font sizes. It should not be caused by gui/theme/default_font_subpixel_positioning, which only affects horizontal positioning. It’s much clearer if you select a font in Godot, click “Import” at the upper left, then click “Advanced…” and just try changing some settings:

Pasted image 20240725092909.png

Pasted image 20240725092921.png