diff --git a/core/config/project_settings.cpp b/core/config/project_settings.cpp
index 7f23b5c78d38..14f39023c6b4 100644
--- a/core/config/project_settings.cpp
+++ b/core/config/project_settings.cpp
@@ -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);
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);
diff --git a/core/math/rect2.cpp b/core/math/rect2.cpp
index 102a9e4b5410..f39b7a37a7e2 100644
--- a/core/math/rect2.cpp
+++ b/core/math/rect2.cpp
@@ -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;
+ }
+
+ 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)) {
diff --git a/core/math/rect2.h b/core/math/rect2.h
index 2ba59fde3529..89dbcd2d0d2c 100644
--- a/core/math/rect2.h
+++ b/core/math/rect2.h
@@ -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)) {
diff --git a/doc/classes/ProjectSettings.xml b/doc/classes/ProjectSettings.xml
index 162a84cacac5..b3eac918c784 100644
--- a/doc/classes/ProjectSettings.xml
+++ b/doc/classes/ProjectSettings.xml
@@ -1233,6 +1233,11 @@
Override for [member filesystem/import/fbx2gltf/enabled] on the Web where FBX2glTF can't easily be accessed from Godot.
+
+ Determines what strategy to use when inferring the next [Control] to focus if no neighbor is defined.
+ - [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.
+
Default value for [member ScrollContainer.scroll_deadzone], which will be used for all [ScrollContainer]s unless overridden.
diff --git a/editor/editor_node.cpp b/editor/editor_node.cpp
index 1497e881030d..e8b558f52b58 100644
--- a/editor/editor_node.cpp
+++ b/editor/editor_node.cpp
@@ -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"
@@ -8357,6 +8358,7 @@ HashMap EditorNode::get_initial_settings() {
HashMap 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;
diff --git a/scene/gui/control.cpp b/scene/gui/control.cpp
index 26fc944743a9..b5d7083d29b7 100644
--- a/scene/gui/control.cpp
+++ b/scene/gui/control.cpp
@@ -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"
@@ -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) {
ERR_FAIL_INDEX_V((int)p_side, 4, nullptr);
@@ -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] = {
@@ -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) {
@@ -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;
}
@@ -3450,7 +3452,7 @@ Control *Control::find_valid_focus_neighbor(Side p_side) const {
return const_cast(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(p_at)) {
return; // Bye.
}
@@ -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
@@ -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;
}
}
@@ -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) {
+ // Algorithm proposed and designed by Rune Skovbo Johansen & Adriaan de Jongh
+
+ // 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;
+ }
+
+ // 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.
diff --git a/scene/gui/control.h b/scene/gui/control.h
index 44709c6508c5..4768b3c1f42f 100644
--- a/scene/gui/control.h
+++ b/scene/gui/control.h
@@ -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 {
@@ -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();