From 25954dd35d3c6f3c8e19537bf656e33bed27a7f8 Mon Sep 17 00:00:00 2001 From: Ricardo Subtil Date: Mon, 22 Jun 2026 14:56:57 +0100 Subject: [PATCH] Implement balloon focus strategy algorithm --- core/config/project_settings.cpp | 1 + core/math/rect2.cpp | 36 +++++++ core/math/rect2.h | 2 + doc/classes/ProjectSettings.xml | 5 + editor/editor_node.cpp | 2 + scene/gui/control.cpp | 176 +++++++++++++++++++++++-------- scene/gui/control.h | 12 ++- 7 files changed, 190 insertions(+), 44 deletions(-) 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();