Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions core/config/project_settings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1820,6 +1820,7 @@ ProjectSettings::ProjectSettings() {
GLOBAL_DEF_BASIC("gui/common/snap_controls_to_pixels", true);
GLOBAL_DEF(PropertyInfo(Variant::INT, "gui/common/show_focus_state_on_pointer_event", PROPERTY_HINT_ENUM, "Never,Text Input Controls,Always"), 1);
GLOBAL_DEF_BASIC("gui/fonts/dynamic_fonts/use_oversampling", true);
GLOBAL_DEF(PropertyInfo(Variant::INT, "gui/common/auto_focus_strategy", PROPERTY_HINT_ENUM, "Legacy,Balloon"), 0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd make the new algorithm the default.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the current state, any newly created project will use the new algorithm by default, while current projects maintain the legacy one. This follows the same "maintaing old behavior" mechanism as some other settings (e.g. Jolt Physics on 3D, D3D12 renderer on Windows).

So far this algorithm behaves better in all scenarios I've tested (still need to check ScrollContainer's though), and I agree in making this the default option going forward, but since there is concern about behavior changes in the issue/proposal discussion, I've left the legacy behavior for current projects.


GLOBAL_DEF_RST(PropertyInfo(Variant::INT, "rendering/rendering_device/vsync/frame_queue_size", PROPERTY_HINT_RANGE, "2,3,1"), 2);
GLOBAL_DEF_RST(PropertyInfo(Variant::INT, "rendering/rendering_device/vsync/swapchain_image_count", PROPERTY_HINT_RANGE, "2,4,1"), 3);
Expand Down
36 changes: 36 additions & 0 deletions core/math/rect2.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,42 @@ bool Rect2::intersects_segment(const Point2 &p_from, const Point2 &p_to, Point2
return true;
}

bool Rect2::intersects_ray(const Point2 &p_from, const Vector2 &p_dir, Point2 *r_pos) const {
#ifdef MATH_CHECKS
if (unlikely(size.x < 0 || size.y < 0)) {
ERR_PRINT("Rect2 size is negative, this is not supported. Use Rect2.abs() to get a Rect2 with a positive size.");
}
#endif
// Slab method for ray-AABB intersection. See https://tavianator.com/fast-branchless-raybounding-box-intersections/

Vector2 dir = p_dir.normalized();
// Avoid division by zero by ensuring an epsilon value.
if (Math::is_zero_approx(dir.x)) {
dir.x = 1e-10;
}
if (Math::is_zero_approx(dir.y)) {
dir.y = 1e-10;
}
Comment on lines +124 to +129

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we can rely on IEEE 754 behavior, we can avoid this check and allow division by zero. This results in INF / -INF values which are correctly handled by this algorithm. See https://tavianator.com/fast-branchless-raybounding-box-intersections/

Not sure if we can do that though, as e.g. I've found explicit warning silence #pragma for compiler warnings in regards to division by zero, so need to confirm.


real_t t_x1 = (position.x - p_from.x) / dir.x;
real_t t_x2 = (position.x + size.x - p_from.x) / dir.x;
real_t t_y1 = (position.y - p_from.y) / dir.y;
real_t t_y2 = (position.y + size.y - p_from.y) / dir.y;

real_t t_min = MAX(MIN(t_x1, t_x2), MIN(t_y1, t_y2));
real_t t_max = MIN(MAX(t_x1, t_x2), MAX(t_y1, t_y2));

if (t_max < 0 || t_min > t_max) {
return false;
}

if (r_pos) {
*r_pos = p_from + dir * (t_min >= 0 ? t_min : t_max);
}

return true;
}

bool Rect2::intersects_transformed(const Transform2D &p_xform, const Rect2 &p_rect) const {
#ifdef MATH_CHECKS
if (unlikely(size.x < 0 || size.y < 0 || p_rect.size.x < 0 || p_rect.size.y < 0)) {
Expand Down
2 changes: 2 additions & 0 deletions core/math/rect2.h
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ struct [[nodiscard]] Rect2 {

bool intersects_segment(const Point2 &p_from, const Point2 &p_to, Point2 *r_pos = nullptr, Point2 *r_normal = nullptr) const;

bool intersects_ray(const Point2 &p_from, const Vector2 &p_dir, Point2 *r_pos = nullptr) const;

inline bool encloses(const Rect2 &p_rect) const {
#ifdef MATH_CHECKS
if (unlikely(size.x < 0 || size.y < 0 || p_rect.size.x < 0 || p_rect.size.y < 0)) {
Expand Down
5 changes: 5 additions & 0 deletions doc/classes/ProjectSettings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1233,6 +1233,11 @@
<member name="filesystem/import/fbx2gltf/enabled.web" type="bool" setter="" getter="" default="false">
Override for [member filesystem/import/fbx2gltf/enabled] on the Web where FBX2glTF can't easily be accessed from Godot.
</member>
<member name="gui/common/auto_focus_strategy" type="int" setter="" getter="" default="0">
Determines what strategy to use when inferring the next [Control] to focus if no neighbor is defined.

@Calinou Calinou Jun 29, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Determines what strategy to use when inferring the next [Control] to focus if no neighbor is defined.
Determines what strategy to use when inferring the previous/next [Control] to focus if no neighbor is defined. This does not affect the determination of top/left/right/bottom neighbors when using the arrow keys or gamepad to navigate menus.

- [b]Legacy[/b] is the old focus strategy up until Godot 4.8. It shouldn't be used for new projects, as the new default (Balloon) yields more intuitive results.
- [b]Balloon[/b] searches for both the closest and most aligned [Control] node in the input direction, akin to a expanding balloon which stops at the first node it finds.
</member>
<member name="gui/common/default_scroll_deadzone" type="int" setter="" getter="" default="0">
Default value for [member ScrollContainer.scroll_deadzone], which will be used for all [ScrollContainer]s unless overridden.
</member>
Expand Down
2 changes: 2 additions & 0 deletions editor/editor_node.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@
#include "scene/3d/bone_attachment_3d.h"
#include "scene/animation/animation_tree.h"
#include "scene/gui/color_picker.h"
#include "scene/gui/control.h"
#include "scene/gui/dialogs.h"
#include "scene/gui/file_dialog.h"
#include "scene/gui/menu_bar.h"
Expand Down Expand Up @@ -8357,6 +8358,7 @@ HashMap<String, Variant> EditorNode::get_initial_settings() {
HashMap<String, Variant> settings;
settings["display/window/stretch/aspect"] = "expand";
settings["display/window/stretch/mode"] = "canvas_items";
settings["gui/common/auto_focus_strategy"] = Control::AutoFocusStrategy::STRATEGY_BALLOON;
settings["physics/3d/physics_engine"] = "Jolt Physics";
settings["rendering/rendering_device/driver.windows"] = "d3d12";
return settings;
Expand Down
176 changes: 133 additions & 43 deletions scene/gui/control.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ STATIC_ASSERT_INCOMPLETE_TYPE(class, RenderingServer);
#include "core/config/engine.h"
#include "core/config/project_settings.h"
#include "core/input/input_map.h"
#include "core/math/geometry_2d.h"
#include "core/math/transform_2d.h"
#include "core/object/callable_mp.h"
#include "core/object/class_db.h"
Expand Down Expand Up @@ -3325,6 +3326,7 @@ NodePath Control::get_focus_previous() const {
}

#define MAX_NEIGHBOR_SEARCH_COUNT 512
#define NO_SCORE 1e10

Control *Control::_get_focus_neighbor(Side p_side, int p_count) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function still has some logic which I believe is tied to the now legacy algorithm, especially when concerning nodes within a ScrollContainer. I haven't yet confirmed how the new algorithm behaves in these cases, and still need to research what issues prompted special handling for ScrollContainer to fully understand.
cc @Rindbee

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Snippets of what I mean, which have been left mostly untouched:

godot/scene/gui/control.cpp

Lines 3358 to 3366 in fd98f84

Vector2 vdir = dir[p_side];
Rect2 r = get_global_rect();
real_t begin_d = vdir.dot(r.get_position());
real_t end_d = vdir.dot(r.get_end());
real_t maxd = MAX(begin_d, end_d);
Rect2 clamp = Rect2(-1e7, -1e7, 2e7, 2e7);
Rect2 result_rect;

godot/scene/gui/control.cpp

Lines 3382 to 3395 in fd98f84

real_t sc_begin_d = vdir.dot(sc_r.get_position());
real_t sc_end_d = vdir.dot(sc_r.get_end());
real_t sc_maxd = sc_begin_d;
real_t sc_mind = sc_end_d;
if (sc_begin_d < sc_end_d) {
sc_maxd = sc_end_d;
sc_mind = sc_begin_d;
}
if (!follow_focus && maxd < sc_mind) {
// Reposition to find visible control.
maxd = sc_mind;
r.set_position(r.get_position() + (sc_mind - maxd) * vdir);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This refers to a situation where focus moves from a sibling control of the ScrollContainer to a child control within the ScrollContainer (or vice versa). This can be viewed as two layers. This is cross-layer movement. Control overlap may occur in this case.

ERR_FAIL_INDEX_V((int)p_side, 4, nullptr);
Expand All @@ -3345,7 +3347,7 @@ Control *Control::_get_focus_neighbor(Side p_side, int p_count) {
return c;
}

real_t square_of_dist = 1e14;
real_t score = NO_SCORE;
Control *result = nullptr;

const Vector2 dir[4] = {
Expand Down Expand Up @@ -3395,7 +3397,7 @@ Control *Control::_get_focus_neighbor(Side p_side, int p_count) {
}

if (follow_focus || sc_maxd > maxd) {
_window_find_focus_neighbor(vdir, base, r, clamp, maxd, square_of_dist, &result);
_window_find_focus_neighbor(vdir, base, r, clamp, maxd, score, &result);
}

if (result == nullptr) {
Expand Down Expand Up @@ -3441,7 +3443,7 @@ Control *Control::_get_focus_neighbor(Side p_side, int p_count) {
return nullptr;
}

_window_find_focus_neighbor(vdir, base, r, clamp, maxd, square_of_dist, &result);
_window_find_focus_neighbor(vdir, base, r, clamp, maxd, score, &result);

return result;
}
Expand All @@ -3450,7 +3452,7 @@ Control *Control::find_valid_focus_neighbor(Side p_side) const {
return const_cast<Control *>(this)->_get_focus_neighbor(p_side);
}

void Control::_window_find_focus_neighbor(const Vector2 &p_dir, Node *p_at, const Rect2 &p_rect, const Rect2 &p_clamp, real_t p_min, real_t &r_closest_dist_squared, Control **r_closest) {
void Control::_window_find_focus_neighbor(const Vector2 &p_dir, Node *p_at, const Rect2 &p_rect, const Rect2 &p_clamp, real_t p_min, real_t &r_score, Control **r_closest) {
if (Object::cast_to<Viewport>(p_at)) {
return; // Bye.
}
Expand All @@ -3462,45 +3464,22 @@ void Control::_window_find_focus_neighbor(const Vector2 &p_dir, Node *p_at, cons
bool in_container = container ? container->is_ancestor_of(this) : false;

if (c && c != this && ((c->get_focus_mode_with_override() == FOCUS_ALL) || (ac_enabled && c->get_focus_mode_with_override() == FOCUS_ACCESSIBILITY)) && !in_container && p_clamp.intersects(c->get_global_rect())) {
Rect2 r_c = c->get_global_rect();
r_c = r_c.intersection(p_clamp);
real_t begin_d = p_dir.dot(r_c.get_position());
real_t end_d = p_dir.dot(r_c.get_end());
real_t max = MAX(begin_d, end_d);

// Use max to allow navigation to overlapping controls (for ScrollContainer case).
if (max > (p_min + CMP_EPSILON)) {
// Calculate the shortest distance. (No shear transform)
// Flip along axis(es) so that C falls in the first quadrant of c (as origin) for easy calculation.
// The same transformation would put the direction vector in the positive direction (+x or +y).
// | -------------
// | | | |
// | |-----C-----|
// ----|---a | | |
// | | | b------------
// -|---c---|----------------------->
// | | |
// ----|----
// cC = ca + ab + bC
// The shortest distance is the vector ab's length or its positive projection length.

Vector2 cC_origin = r_c.get_center() - p_rect.get_center();
Vector2 cC = cC_origin.abs(); // Converted to fall in the first quadrant of c.

Vector2 ab = cC - 0.5 * r_c.get_size() - 0.5 * p_rect.get_size();

real_t min_d_squared = 0.0;
if (ab.x > 0.0) {
min_d_squared += ab.x * ab.x;
}
if (ab.y > 0.0) {
min_d_squared += ab.y * ab.y;
}
real_t score = NO_SCORE;
switch (GLOBAL_GET_CACHED(int, "gui/common/auto_focus_strategy")) {
case AutoFocusStrategy::STRATEGY_LEGACY:
score = _focus_strategy_legacy(p_dir, *c, p_rect, p_clamp, p_min);
break;
case AutoFocusStrategy::STRATEGY_BALLOON:
default:
score = _focus_strategy_balloon(p_dir, *c, p_rect, p_clamp, p_min);
break;
}

if (min_d_squared < r_closest_dist_squared || *r_closest == nullptr) {
r_closest_dist_squared = min_d_squared;
if (score != NO_SCORE) {
if (score < r_score || *r_closest == nullptr) {
r_score = score;
*r_closest = c;
} else if (min_d_squared == r_closest_dist_squared) {
} else if (score == r_score) {
// Tie-breaking aims to address situations where a potential focus neighbor's bounding rect
// is right next to the currently focused control (e.g. in BoxContainer with
// separation overridden to 0). This needs specific handling so that the correct
Expand All @@ -3511,7 +3490,7 @@ void Control::_window_find_focus_neighbor(const Vector2 &p_dir, Node *p_at, cons
Point2 closest_center = closest->get_global_rect().get_center();

// Tie-break in favor of the control most aligned with p_dir.
if (Math::abs(p_dir.cross(cC_origin)) < Math::abs(p_dir.cross(closest_center - p_center))) {
if (Math::abs(p_dir.cross(c->get_rect().get_center() - p_center)) < Math::abs(p_dir.cross(closest_center - p_center))) {
*r_closest = c;
}
}
Expand Down Expand Up @@ -3543,8 +3522,119 @@ void Control::_window_find_focus_neighbor(const Vector2 &p_dir, Node *p_at, cons
continue; // Already searched in it, skip it.
}
}
_window_find_focus_neighbor(p_dir, child, p_rect, intersection, p_min, r_closest_dist_squared, r_closest);
_window_find_focus_neighbor(p_dir, child, p_rect, intersection, p_min, r_score, r_closest);
}
}

real_t Control::_focus_strategy_legacy(const Vector2 &p_dir, const Control &p_c, const Rect2 &p_rect, const Rect2 &p_clamp, real_t p_min) {
Rect2 r_c = p_c.get_global_rect();
r_c = r_c.intersection(p_clamp);
real_t begin_d = p_dir.dot(r_c.get_position());
real_t end_d = p_dir.dot(r_c.get_end());
real_t max = MAX(begin_d, end_d);

// Use max to allow navigation to overlapping controls (for ScrollContainer case).
if (max > (p_min + CMP_EPSILON)) {
// Calculate the shortest distance. (No shear transform)
// Flip along axis(es) so that C falls in the first quadrant of c (as origin) for easy calculation.
// The same transformation would put the direction vector in the positive direction (+x or +y).
// | -------------
// | | | |
// | |-----C-----|
// ----|---a | | |
// | | | b------------
// -|---c---|----------------------->
// | | |
// ----|----
// cC = ca + ab + bC
// The shortest distance is the vector ab's length or its positive projection length.

Vector2 cC_origin = r_c.get_center() - p_rect.get_center();
Vector2 cC = cC_origin.abs(); // Converted to fall in the first quadrant of c.

Vector2 ab = cC - 0.5 * r_c.get_size() - 0.5 * p_rect.get_size();

real_t min_d_squared = 0.0;
if (ab.x > 0.0) {
min_d_squared += ab.x * ab.x;
}
if (ab.y > 0.0) {
min_d_squared += ab.y * ab.y;
}

return min_d_squared;
}

return NO_SCORE;
}

Vector2 Control::_focus_strategy_balloon_line_segment(const Vector2 &p_start, const Vector2 &p_dir, const Vector2 &p1, const Vector2 &p2) {
// Slightly tweaked line intersection algorithm, which clamps out-of-bounds intersections
Vector2 line_dir = p2 - p1;
Vector2 normal = Vector2(-line_dir.y, line_dir.x).normalized();
Vector2 intersect_dir = p_dir + normal;

Vector2 touch(Math::INF, Math::INF);
Geometry2D::line_intersects_line(p_start, intersect_dir, p1, line_dir, touch);

// Clamp resulting intersection to P1-P2 segment
if ((touch - p2).dot(-line_dir) < 0) {
touch = p2;
}
if ((touch - p1).dot(line_dir) < 0) {
touch = p1;
}

return touch;
}

real_t Control::_focus_strategy_balloon(const Vector2 &p_dir, const Control &p_candidate, const Rect2 &p_rect, const Rect2 &p_clamp, real_t p_min) {

@rsubtil rsubtil Jun 25, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p_rect and p_min are currently not used by the algorithm at all. Need to understand how behavior changes with ScrollContainer nodes (in practice, waiting for clarification on #120631 (comment))

// Algorithm proposed and designed by Rune Skovbo Johansen & Adriaan de Jongh

@rsubtil rsubtil Jun 25, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@runevision @AdriaandeJongh I advocate for proper attribution when possible, and I want to make it clear the origins of this algorithm. Let me know what do you think of this attribution and any changes if you want 🙂

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, I appreciate the shoutout! But... not sure how common this is in the Godot codebase 😅 @AThousandShips wdyt?


// Compute the starting point by intersecting the input direction to a normalized Rect2.
Vector2 starting_point;
const Rect2 normalized_rect = Rect2(0, 0, 1, 1);
if (!normalized_rect.intersects_ray(normalized_rect.get_center(), p_dir, &starting_point)) {
// TODO: Should be impossible to not have an intersection for rays starting within the Rect2 (even for dir == Vector2.ZER0, as the algorithm applies an epsilon)
// Do we assert? [[ unlikely ]]? ERR_FAIL_COND?
return NO_SCORE;
Comment on lines +3598 to +3600

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need clarification on how Godot handles theoretically-impossible scenarios.

}

// Convert the normalized intersection by first restoring local size, then multiplying with global transform.
starting_point *= get_size();
starting_point = get_global_transform_const().xform(starting_point);

real_t score = -1;
Vector2 candidate_point;

// Fetch the corners of the Control's rect in global coordinates.
const Rect2 candidate_rect = p_candidate.get_global_rect().intersection(p_clamp);
const Transform2D candidate_transform = p_candidate.get_global_transform_const();
Vector2 point_a = candidate_transform.xform(Vector2());
Vector2 point_b = candidate_transform.xform(Vector2(candidate_rect.size.x, 0));
Vector2 point_c = candidate_transform.xform(candidate_rect.size);
Vector2 point_d = candidate_transform.xform(Vector2(0, candidate_rect.size.y));

const auto testCandidate = [&](const Vector2 &point_1, const Vector2 &point_2) {
Vector2 new_touch = _focus_strategy_balloon_line_segment(starting_point, p_dir, point_1, point_2);
Vector2 hit = new_touch - starting_point;
real_t new_score = p_dir.dot(hit) / hit.length_squared();

if (new_score > score) {
score = new_score;
candidate_point = new_touch;
}
};

// Test all four edges of a Control's Rect
testCandidate(point_a, point_b);
testCandidate(point_b, point_c);
testCandidate(point_c, point_d);
testCandidate(point_d, point_a);

// Valid scores are in the range of ]0, 1]. For this algorithm, higher scores are better; however,
// Godot is looking for minimal score, so we invert the range.
return score > 0 ? (1 - score) : NO_SCORE;
}

// Rendering.
Expand Down
12 changes: 11 additions & 1 deletion scene/gui/control.h
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,11 @@ class Control : public CanvasItem {
TEXT_DIRECTION_INHERITED = TextServer::DIRECTION_INHERITED,
};

enum AutoFocusStrategy {
STRATEGY_LEGACY,
STRATEGY_BALLOON
};

private:
struct CComparator {
bool operator()(const Control *p_a, const Control *p_b) const {
Expand Down Expand Up @@ -400,12 +405,17 @@ class Control : public CanvasItem {

// Focus.

void _window_find_focus_neighbor(const Vector2 &p_dir, Node *p_at, const Rect2 &p_rect, const Rect2 &p_clamp, real_t p_min, real_t &r_closest_dist_squared, Control **r_closest);
void _window_find_focus_neighbor(const Vector2 &p_dir, Node *p_at, const Rect2 &p_rect, const Rect2 &p_clamp, real_t p_min, real_t &r_score, Control **r_closest);
Control *_get_focus_neighbor(Side p_side, int p_count = 0);
bool _is_focus_mode_enabled() const;
void _update_focus_behavior_recursive();
void _propagate_focus_behavior_recursive_recursively(bool p_enabled, bool p_skip_non_inherited);

// Focus Strategies.
real_t _focus_strategy_legacy(const Vector2 &p_dir, const Control &p_candidate, const Rect2 &p_rect, const Rect2 &p_clamp, real_t p_min);
Vector2 _focus_strategy_balloon_line_segment(const Vector2 &p_start, const Vector2 &p_dir, const Vector2 &p1, const Vector2 &p2);
real_t _focus_strategy_balloon(const Vector2 &p_dir, const Control &p_candidate, const Rect2 &p_rect, const Rect2 &p_clamp, real_t p_min);

// Theming.

void _theme_changed();
Expand Down