From ec2bb1943380012f49a6676ce40a04d04084b489 Mon Sep 17 00:00:00 2001 From: Michael Ragazzon Date: Sun, 15 Oct 2023 11:35:09 +0200 Subject: [PATCH 1/8] Minor: Use property id when possible --- Source/Core/Elements/ElementFormControl.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/Core/Elements/ElementFormControl.cpp b/Source/Core/Elements/ElementFormControl.cpp index 6bd35ee5b..4efccbf3b 100644 --- a/Source/Core/Elements/ElementFormControl.cpp +++ b/Source/Core/Elements/ElementFormControl.cpp @@ -83,7 +83,7 @@ void ElementFormControl::OnAttributeChange(const ElementAttributes& changed_attr Blur(); } else - RemoveProperty("focus"); + RemoveProperty(PropertyId::Focus); } } From 0695cf025e8ccf558ced980cd1c626d8e4acd3a9 Mon Sep 17 00:00:00 2001 From: Michael Ragazzon Date: Tue, 10 Oct 2023 23:27:00 +0200 Subject: [PATCH 2/8] Focusable element is now clicked when pressing space bar --- Source/Core/ElementDocument.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/Core/ElementDocument.cpp b/Source/Core/ElementDocument.cpp index c207f7817..323ef278e 100644 --- a/Source/Core/ElementDocument.cpp +++ b/Source/Core/ElementDocument.cpp @@ -509,7 +509,7 @@ void ElementDocument::ProcessDefaultAction(Event& event) } } // Process ENTER being pressed on a focusable object (emulate click) - else if (key_identifier == Input::KI_RETURN || key_identifier == Input::KI_NUMPADENTER) + else if (key_identifier == Input::KI_RETURN || key_identifier == Input::KI_NUMPADENTER || key_identifier == Input::KI_SPACE) { Element* focus_node = GetFocusLeafNode(); From 6d552f71db77b61a162a5e710fa4f16dfd838579 Mon Sep 17 00:00:00 2001 From: Michael Ragazzon Date: Sun, 15 Oct 2023 11:33:34 +0200 Subject: [PATCH 3/8] After tab-navigation, use 'nearest' scroll-into-view algorithm --- Source/Core/ElementDocument.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/Core/ElementDocument.cpp b/Source/Core/ElementDocument.cpp index 323ef278e..1ca74adee 100644 --- a/Source/Core/ElementDocument.cpp +++ b/Source/Core/ElementDocument.cpp @@ -503,7 +503,7 @@ void ElementDocument::ProcessDefaultAction(Event& event) { if (element->Focus()) { - element->ScrollIntoView(false); + element->ScrollIntoView(ScrollAlignment::Nearest); event.StopPropagation(); } } From 309476282527128822c739afbb37bf0650753c59 Mon Sep 17 00:00:00 2001 From: Michael Ragazzon Date: Sun, 15 Oct 2023 23:35:12 +0200 Subject: [PATCH 4/8] Invaders samples: Press escape key to go back to previous window --- Samples/invaders/data/game.rml | 2 +- Samples/invaders/data/help.rml | 2 +- Samples/invaders/data/high_score.rml | 2 +- Samples/invaders/data/options.rml | 2 +- Samples/invaders/data/pause.rml | 2 +- Samples/invaders/data/start_game.rml | 2 +- Samples/invaders/src/ElementGame.cpp | 5 ----- Samples/invaders/src/EventManager.cpp | 11 ++++++++--- Samples/luainvaders/data/game.rml | 11 +---------- Samples/luainvaders/data/help.rml | 2 +- Samples/luainvaders/data/high_score.rml | 2 +- Samples/luainvaders/data/main_menu.rml | 4 ++-- Samples/luainvaders/data/options.rml | 2 +- Samples/luainvaders/data/pause.rml | 2 +- Samples/luainvaders/data/start_game.rml | 2 +- Samples/luainvaders/data/window.rml | 11 +++++++++++ 16 files changed, 33 insertions(+), 31 deletions(-) diff --git a/Samples/invaders/data/game.rml b/Samples/invaders/data/game.rml index 7776973d5..b49a5b6ea 100644 --- a/Samples/invaders/data/game.rml +++ b/Samples/invaders/data/game.rml @@ -89,7 +89,7 @@ } - +
diff --git a/Samples/invaders/data/help.rml b/Samples/invaders/data/help.rml index 79bd5f86d..adeeca24a 100644 --- a/Samples/invaders/data/help.rml +++ b/Samples/invaders/data/help.rml @@ -21,7 +21,7 @@ } - +

Story

One day, without warning, they came for us; endless waves of the invaders, numbers too vast to count, fresh from the Martian foundries. Earth's orbital defences took a heavy toll, but were inevitably overrun, buying enough time only for a single rmlui ship to launch. The prototype X-42 'Defender'-class, not yet tested but piloted by the finest astronaut the Space Corps had to offer. diff --git a/Samples/invaders/data/high_score.rml b/Samples/invaders/data/high_score.rml index 21b95f578..e523e2dcb 100644 --- a/Samples/invaders/data/high_score.rml +++ b/Samples/invaders/data/high_score.rml @@ -44,7 +44,7 @@ } - + diff --git a/Samples/invaders/data/options.rml b/Samples/invaders/data/options.rml index d4f2843f3..86b23b387 100644 --- a/Samples/invaders/data/options.rml +++ b/Samples/invaders/data/options.rml @@ -24,7 +24,7 @@ } - +

diff --git a/Samples/invaders/data/pause.rml b/Samples/invaders/data/pause.rml index b898a9286..cd57e3612 100644 --- a/Samples/invaders/data/pause.rml +++ b/Samples/invaders/data/pause.rml @@ -17,7 +17,7 @@ } - +

Are you sure you want to end this game?

diff --git a/Samples/invaders/data/start_game.rml b/Samples/invaders/data/start_game.rml index 6d4011991..0a2308eed 100644 --- a/Samples/invaders/data/start_game.rml +++ b/Samples/invaders/data/start_game.rml @@ -31,7 +31,7 @@ } - +

diff --git a/Samples/invaders/src/ElementGame.cpp b/Samples/invaders/src/ElementGame.cpp index a03345630..d8dda330f 100644 --- a/Samples/invaders/src/ElementGame.cpp +++ b/Samples/invaders/src/ElementGame.cpp @@ -53,11 +53,6 @@ void ElementGame::ProcessEvent(Rml::Event& event) bool key_down = (event == Rml::EventId::Keydown); Rml::Input::KeyIdentifier key_identifier = (Rml::Input::KeyIdentifier)event.GetParameter("key_identifier", 0); - if (key_identifier == Rml::Input::KI_ESCAPE && !key_down) - { - EventManager::LoadWindow("pause"); - } - // Process left and right keys if (key_down) { diff --git a/Samples/invaders/src/EventManager.cpp b/Samples/invaders/src/EventManager.cpp index 0c12576cc..968aaf719 100644 --- a/Samples/invaders/src/EventManager.cpp +++ b/Samples/invaders/src/EventManager.cpp @@ -67,22 +67,27 @@ void EventManager::ProcessEvent(Rml::Event& event, const Rml::String& value) Rml::StringUtilities::ExpandString(commands, value, ';'); for (size_t i = 0; i < commands.size(); ++i) { - // Check for a generic 'load' or 'exit' command. + // Check for custom commands. Rml::StringList values; Rml::StringUtilities::ExpandString(values, commands[i], ' '); if (values.empty()) return; + if (values[0] == "onescape" && values.size() > 1) + { + Rml::Input::KeyIdentifier key_identifier = (Rml::Input::KeyIdentifier)event.GetParameter("key_identifier", 0); + if (key_identifier == Rml::Input::KI_ESCAPE) + values.erase(values.begin()); + } + if (values[0] == "goto" && values.size() > 1) { - // Load the window, and if successful close the old window. if (LoadWindow(values[1])) event.GetTargetElement()->GetOwnerDocument()->Close(); } else if (values[0] == "load" && values.size() > 1) { - // Load the window. LoadWindow(values[1]); } else if (values[0] == "close") diff --git a/Samples/luainvaders/data/game.rml b/Samples/luainvaders/data/game.rml index 40b2d365d..f9c14499b 100644 --- a/Samples/luainvaders/data/game.rml +++ b/Samples/luainvaders/data/game.rml @@ -87,17 +87,8 @@ decorator: image( icon-lives ); } - - +

diff --git a/Samples/luainvaders/data/help.rml b/Samples/luainvaders/data/help.rml index 3114ad332..fa3f871bb 100644 --- a/Samples/luainvaders/data/help.rml +++ b/Samples/luainvaders/data/help.rml @@ -21,7 +21,7 @@ } - +

Story

One day, without warning, they came for us; endless waves of the invaders, numbers too vast to count, fresh from the Martian foundries. Earth's orbital defences took a heavy toll, but were inevitably overrun, buying enough time only for a single rmlui ship to launch. The prototype X-42 'Defender'-class, not yet tested but piloted by the finest astronaut the Space Corps had to offer. diff --git a/Samples/luainvaders/data/high_score.rml b/Samples/luainvaders/data/high_score.rml index 7bb5b43e3..61d48879b 100644 --- a/Samples/luainvaders/data/high_score.rml +++ b/Samples/luainvaders/data/high_score.rml @@ -53,7 +53,7 @@ function HighScore.OnKeyDown(event) end - +

diff --git a/Samples/luainvaders/data/main_menu.rml b/Samples/luainvaders/data/main_menu.rml index 07ac71128..71076f93a 100644 --- a/Samples/luainvaders/data/main_menu.rml +++ b/Samples/luainvaders/data/main_menu.rml @@ -27,8 +27,8 @@ function MainMenu.CloseLogo(document) end - -
+ +



diff --git a/Samples/luainvaders/data/options.rml b/Samples/luainvaders/data/options.rml index 2048235a6..e7a2b5d54 100644 --- a/Samples/luainvaders/data/options.rml +++ b/Samples/luainvaders/data/options.rml @@ -97,7 +97,7 @@ function Options.SaveOptions(event) end - +

diff --git a/Samples/luainvaders/data/pause.rml b/Samples/luainvaders/data/pause.rml index 311e9dfbb..898f62ec8 100644 --- a/Samples/luainvaders/data/pause.rml +++ b/Samples/luainvaders/data/pause.rml @@ -17,7 +17,7 @@ } - +

Are you sure you want to end this game?

diff --git a/Samples/luainvaders/data/start_game.rml b/Samples/luainvaders/data/start_game.rml index 5540b42c5..cee40cb54 100644 --- a/Samples/luainvaders/data/start_game.rml +++ b/Samples/luainvaders/data/start_game.rml @@ -51,7 +51,7 @@ function StartGame.SetupGame(event,document) end - +

diff --git a/Samples/luainvaders/data/window.rml b/Samples/luainvaders/data/window.rml index d7292e9cd..b56518ddb 100644 --- a/Samples/luainvaders/data/window.rml +++ b/Samples/luainvaders/data/window.rml @@ -14,9 +14,20 @@ function Window.LoadMenu(name,document) doc:Show() document:Close() end + return doc +end +function Window.OpenDocument(name,document) + local doc = document.context:LoadDocument('luainvaders/data/' .. name .. '.rml') + if doc then + doc:Show() + end return doc end + +function Window.EscapePressed(event) + return event.parameters['key_identifier'] == rmlui.key_identifier.ESCAPE +end From 162de42c4885bb055ad258f1cafcd078156dd290 Mon Sep 17 00:00:00 2001 From: Gleb Date: Sat, 30 Sep 2023 22:30:56 +0100 Subject: [PATCH 5/8] Implement spatial navigation and nav-* properties Co-authored-by: Michael Ragazzon --- Include/RmlUi/Core/ElementDocument.h | 3 + Include/RmlUi/Core/ID.h | 6 + Include/RmlUi/Core/StyleTypes.h | 2 + Samples/assets/invader.rcss | 6 +- Samples/basic/demo/data/demo.rml | 16 +- Source/Core/ElementDocument.cpp | 262 +++++++++++++++++++++-- Source/Core/ElementStyle.cpp | 6 + Source/Core/Elements/WidgetDropDown.cpp | 16 ++ Source/Core/Elements/WidgetTextInput.cpp | 36 ++-- Source/Core/Elements/WidgetTextInput.h | 6 +- Source/Core/StyleSheetSpecification.cpp | 6 + 11 files changed, 326 insertions(+), 39 deletions(-) diff --git a/Include/RmlUi/Core/ElementDocument.h b/Include/RmlUi/Core/ElementDocument.h index 95db9e198..b08aea729 100644 --- a/Include/RmlUi/Core/ElementDocument.h +++ b/Include/RmlUi/Core/ElementDocument.h @@ -39,6 +39,7 @@ class DocumentHeader; class ElementText; class StyleSheet; class StyleSheetContainer; +enum class NavigationSearchDirection; /** ModalFlag used for controlling the modal state of the document. @@ -153,6 +154,8 @@ class RMLUICORE_API ElementDocument : public Element { Element* FindNextTabElement(Element* current_element, bool forward); /// Searches forwards or backwards for a focusable element in the given substree Element* SearchFocusSubtree(Element* element, bool forward); + /// Find the next element to navigate to, starting at the current element. + Element* FindNextNavigationElement(Element* current_element, NavigationSearchDirection direction, const Property& property); /// Sets the dirty flag on the layout so the document will format its children before the next render. void DirtyLayout() override; diff --git a/Include/RmlUi/Core/ID.h b/Include/RmlUi/Core/ID.h index 4af9d8aa8..29de36e1a 100644 --- a/Include/RmlUi/Core/ID.h +++ b/Include/RmlUi/Core/ID.h @@ -59,6 +59,7 @@ enum class ShorthandId : uint8_t { TransformOrigin, Flex, FlexFlow, + Nav, NumDefinedIds, FirstCustomId = NumDefinedIds, @@ -169,6 +170,11 @@ enum class PropertyId : uint8_t { FlexWrap, JustifyContent, + NavUp, + NavRight, + NavDown, + NavLeft, + NumDefinedIds, FirstCustomId = NumDefinedIds, diff --git a/Include/RmlUi/Core/StyleTypes.h b/Include/RmlUi/Core/StyleTypes.h index 9b0b31895..dc91f511b 100644 --- a/Include/RmlUi/Core/StyleTypes.h +++ b/Include/RmlUi/Core/StyleTypes.h @@ -158,6 +158,8 @@ namespace Style { enum class FlexWrap : uint8_t { Nowrap, Wrap, WrapReverse }; enum class JustifyContent : uint8_t { FlexStart, FlexEnd, Center, SpaceBetween, SpaceAround }; + enum class Nav : uint8_t { None, Auto, Horizontal, Vertical }; + class ComputedValues; } // namespace Style diff --git a/Samples/assets/invader.rcss b/Samples/assets/invader.rcss index a31b02f9a..e1d825fa4 100644 --- a/Samples/assets/invader.rcss +++ b/Samples/assets/invader.rcss @@ -140,6 +140,7 @@ body font-style: normal; font-size: 15dp; color: white; + nav: auto; } body.window @@ -336,12 +337,13 @@ table input.text background-color: white; font-size: 15dp; - +} +table input.text, table input.text:focus-visible +{ decorator: none; } - select { width: 175dp; diff --git a/Samples/basic/demo/data/demo.rml b/Samples/basic/demo/data/demo.rml index 07268e741..8b0a112dd 100644 --- a/Samples/basic/demo/data/demo.rml +++ b/Samples/basic/demo/data/demo.rml @@ -602,6 +602,14 @@ progress { { height: 12dp; } +form input, form textarea, form select +{ + nav: vertical; +} +form .nav-auto, form input.checkbox, form input.radio +{ + nav: auto; +} @@ -836,21 +844,21 @@ progress {

Email and password

- - + +

Favorite animal

- +

Favorite meals

- +

Rating

diff --git a/Source/Core/ElementDocument.cpp b/Source/Core/ElementDocument.cpp index 1ca74adee..b6a551b0a 100644 --- a/Source/Core/ElementDocument.cpp +++ b/Source/Core/ElementDocument.cpp @@ -43,9 +43,133 @@ #include "Template.h" #include "TemplateCache.h" #include "XMLParseTools.h" +#include namespace Rml { +enum class NavigationSearchDirection { Up, Down, Left, Right }; + +namespace { + constexpr int Infinite = INT_MAX; + + struct BoundingBox { + static const BoundingBox Invalid; + + Vector2f min; + Vector2f max; + + BoundingBox(const Vector2f& min, const Vector2f& max) : min(min), max(max) {} + + BoundingBox Union(const BoundingBox& bounding_box) const + { + return BoundingBox(Math::Min(min, bounding_box.min), Math::Max(max, bounding_box.max)); + } + + bool Intersects(const BoundingBox& box) const { return min.x <= box.max.x && max.x >= box.min.x && min.y <= box.max.y && max.y >= box.min.y; } + + bool IsValid() const { return min.x <= max.x && min.y <= max.y; } + }; + + const BoundingBox BoundingBox::Invalid = {Vector2f(FLT_MAX, FLT_MAX), Vector2f(-FLT_MAX, -FLT_MAX)}; + + enum class CanFocus { Yes, No, NoAndNoChildren }; + + CanFocus CanFocusElement(Element* element) + { + if (!element->IsVisible()) + return CanFocus::NoAndNoChildren; + + const ComputedValues& computed = element->GetComputedValues(); + + if (computed.focus() == Style::Focus::None) + return CanFocus::NoAndNoChildren; + + if (computed.tab_index() == Style::TabIndex::Auto) + return CanFocus::Yes; + + return CanFocus::No; + } + + bool IsScrollContainer(Element* element) + { + const auto& computed = element->GetComputedValues(); + if (computed.overflow_x() != Style::Overflow::Visible || computed.overflow_y() != Style::Overflow::Visible) + return true; + return false; + } + + int GetNavigationHeuristic(const BoundingBox& source, const BoundingBox& target, NavigationSearchDirection direction) + { + enum Axis { Horizontal = 0, Vertical = 1 }; + + auto CalculateHeuristic = [](Axis axis, const BoundingBox& a, const BoundingBox& b) -> int { + // The heuristic is mainly the distance from the source to the target along the specified direction. In + // addition, the following factor determines the penalty for being outside the projected area of the element in + // the given direction, as a multiplier of the cross-axis distance between the target and projected area. + static constexpr int CrossAxisFactor = 10'000; + + const int main_axis = int(a.min[axis] - b.max[axis]); + if (main_axis < 0) + return Infinite; + + const Axis cross = Axis((axis + 1) % 2); + const int cross_axis = Math::Max(0, int(b.min[cross] - a.max[cross])) + Math::Max(0, int(a.min[cross] - b.max[cross])); + + return main_axis + CrossAxisFactor * cross_axis; + }; + + switch (direction) + { + case NavigationSearchDirection::Up: return CalculateHeuristic(Vertical, source, target); + case NavigationSearchDirection::Down: return CalculateHeuristic(Vertical, target, source); + case NavigationSearchDirection::Right: return CalculateHeuristic(Horizontal, target, source); + case NavigationSearchDirection::Left: return CalculateHeuristic(Horizontal, source, target); + } + + RMLUI_ERROR; + return Infinite; + } + + struct SearchNavigationResult { + Element* element = nullptr; + int heuristic = Infinite; + }; + + // Search all descendents to determine which element minimizes the navigation heuristic. + void SearchNavigationTarget(SearchNavigationResult& best_result, Element* element, NavigationSearchDirection direction, + const BoundingBox& bounding_box, Element* exclude_element) + { + const int num_children = element->GetNumChildren(); + for (int child_index = 0; child_index < num_children; child_index++) + { + Element* child = element->GetChild(child_index); + if (child == exclude_element) + continue; + + const CanFocus can_focus = CanFocusElement(child); + if (can_focus == CanFocus::Yes) + { + const Vector2f position = child->GetAbsoluteOffset(BoxArea::Border); + const BoundingBox target_box = {position, position + child->GetBox().GetSize(BoxArea::Border)}; + + const int heuristic = GetNavigationHeuristic(bounding_box, target_box, direction); + if (heuristic < best_result.heuristic) + { + best_result.element = child; + best_result.heuristic = heuristic; + } + } + else if (can_focus == CanFocus::NoAndNoChildren || IsScrollContainer(child)) + { + continue; + } + + SearchNavigationTarget(best_result, child, direction, bounding_box, exclude_element); + } + } + +} // namespace + ElementDocument::ElementDocument(const String& tag) : Element(tag) { context = nullptr; @@ -508,6 +632,54 @@ void ElementDocument::ProcessDefaultAction(Event& event) } } } + // Process direction keys + else if (key_identifier == Input::KI_LEFT || key_identifier == Input::KI_RIGHT || key_identifier == Input::KI_UP || + key_identifier == Input::KI_DOWN) + { + NavigationSearchDirection direction = {}; + PropertyId property_id = PropertyId::NavLeft; + switch (key_identifier) + { + case Input::KI_LEFT: + direction = NavigationSearchDirection::Left; + property_id = PropertyId::NavLeft; + break; + case Input::KI_RIGHT: + direction = NavigationSearchDirection::Right; + property_id = PropertyId::NavRight; + break; + case Input::KI_UP: + direction = NavigationSearchDirection::Up; + property_id = PropertyId::NavUp; + break; + case Input::KI_DOWN: + direction = NavigationSearchDirection::Down; + property_id = PropertyId::NavDown; + break; + } + + auto GetNearestFocusable = [this](Element* focus_node) -> Element* { + while (focus_node) + { + if (CanFocusElement(focus_node) == CanFocus::Yes) + break; + focus_node = focus_node->GetParentNode(); + } + return focus_node ? focus_node : this; + }; + Element* focus_node = GetNearestFocusable(GetFocusLeafNode()); + if (const Property* nav_property = focus_node->GetLocalProperty(property_id)) + { + if (Element* next = FindNextNavigationElement(focus_node, direction, *nav_property)) + { + if (next->Focus()) + { + next->ScrollIntoView(ScrollAlignment::Nearest); + event.StopPropagation(); + } + } + } + } // Process ENTER being pressed on a focusable object (emulate click) else if (key_identifier == Input::KI_RETURN || key_identifier == Input::KI_NUMPADENTER || key_identifier == Input::KI_SPACE) { @@ -527,23 +699,6 @@ void ElementDocument::OnResize() DirtyPosition(); } -enum class CanFocus { Yes, No, NoAndNoChildren }; -static CanFocus CanFocusElement(Element* element) -{ - if (!element->IsVisible()) - return CanFocus::NoAndNoChildren; - - const ComputedValues& computed = element->GetComputedValues(); - - if (computed.focus() == Style::Focus::None) - return CanFocus::NoAndNoChildren; - - if (computed.tab_index() == Style::TabIndex::Auto) - return CanFocus::Yes; - - return CanFocus::No; -} - Element* ElementDocument::FindNextTabElement(Element* current_element, bool forward) { // This algorithm is quite sneaky, I originally thought a depth first search would work, but it appears not. What is @@ -616,7 +771,6 @@ Element* ElementDocument::SearchFocusSubtree(Element* element, bool forward) else if (can_focus == CanFocus::NoAndNoChildren) return nullptr; - // Check all children for (int i = 0; i < element->GetNumChildren(); i++) { int child_index = i; @@ -629,4 +783,76 @@ Element* ElementDocument::SearchFocusSubtree(Element* element, bool forward) return nullptr; } +Element* ElementDocument::FindNextNavigationElement(Element* current_element, NavigationSearchDirection direction, const Property& property) +{ + switch (property.unit) + { + case Unit::STRING: + { + const PropertySource* source = property.source.get(); + const String value = property.Get(); + if (value[0] != '#') + { + Log::Message(Log::LT_WARNING, + "Invalid navigation value '%s': Expected a keyword or a string with an element id prefixed with '#'. Declared at %s:%d", + value.c_str(), source ? source->path.c_str() : "", source ? source->line_number : -1); + return nullptr; + } + + const String id = String(value.begin() + 1, value.end()); + Element* result = GetElementById(id); + if (!result) + { + Log::Message(Log::LT_WARNING, "Trying to navigate to element with id '%s', but could not find element. Declared at %s:%d", id.c_str(), + source ? source->path.c_str() : "", source ? source->line_number : -1); + } + return result; + } + break; + case Unit::KEYWORD: + { + const bool direction_is_horizontal = (direction == NavigationSearchDirection::Left || direction == NavigationSearchDirection::Right); + const bool direction_is_vertical = (direction == NavigationSearchDirection::Up || direction == NavigationSearchDirection::Down); + switch (static_cast(property.value.Get())) + { + case Style::Nav::None: return nullptr; + case Style::Nav::Auto: break; + case Style::Nav::Horizontal: + if (!direction_is_horizontal) + return nullptr; + break; + case Style::Nav::Vertical: + if (!direction_is_vertical) + return nullptr; + break; + } + } + break; + default: return nullptr; + } + + if (current_element == this) + { + const bool direction_is_forward = (direction == NavigationSearchDirection::Down || direction == NavigationSearchDirection::Right); + return FindNextTabElement(this, direction_is_forward); + } + + const Vector2f position = current_element->GetAbsoluteOffset(BoxArea::Border); + const BoundingBox bounding_box = {position, position + current_element->GetBox().GetSize(BoxArea::Border)}; + + auto GetNearestScrollContainer = [this](Element* element) -> Element* { + for (element = element->GetParentNode(); element; element = element->GetParentNode()) + { + if (IsScrollContainer(element)) + return element; + } + return this; + }; + Element* start_element = GetNearestScrollContainer(current_element); + + SearchNavigationResult best_result; + SearchNavigationTarget(best_result, start_element, direction, bounding_box, current_element); + return best_result.element; +} + } // namespace Rml diff --git a/Source/Core/ElementStyle.cpp b/Source/Core/ElementStyle.cpp index b8b2aa9c4..78cd5eeeb 100644 --- a/Source/Core/ElementStyle.cpp +++ b/Source/Core/ElementStyle.cpp @@ -869,6 +869,12 @@ PropertyIdSet ElementStyle::ComputeValues(Style::ComputedValues& values, const S case PropertyId::FlexWrap: case PropertyId::JustifyContent: break; + // Navigation properties. Must be manually retrieved with 'GetProperty()'. + case PropertyId::NavUp: + case PropertyId::NavDown: + case PropertyId::NavLeft: + case PropertyId::NavRight: + break; // Unhandled properties. Must be manually retrieved with 'GetProperty()'. case PropertyId::FillImage: case PropertyId::CaretColor: diff --git a/Source/Core/Elements/WidgetDropDown.cpp b/Source/Core/Elements/WidgetDropDown.cpp index e13673c1d..b4c0fc31e 100644 --- a/Source/Core/Elements/WidgetDropDown.cpp +++ b/Source/Core/Elements/WidgetDropDown.cpp @@ -552,13 +552,29 @@ void WidgetDropDown::ProcessEvent(Event& event) { Input::KeyIdentifier key_identifier = (Input::KeyIdentifier)event.GetParameter("key_identifier", 0); + auto HasVerticalNavigation = [this](PropertyId id) { + if (const Property* p = parent_element->GetProperty(id)) + { + if (p->unit != Unit::KEYWORD) + return true; + const Style::Nav nav = static_cast(p->Get()); + if (nav == Style::Nav::Auto || nav == Style::Nav::Vertical) + return true; + } + return false; + }; + switch (key_identifier) { case Input::KI_UP: + if (!box_visible && HasVerticalNavigation(PropertyId::NavUp)) + break; SeekSelection(false); event.StopPropagation(); break; case Input::KI_DOWN: + if (!box_visible && HasVerticalNavigation(PropertyId::NavDown)) + break; SeekSelection(true); event.StopPropagation(); break; diff --git a/Source/Core/Elements/WidgetTextInput.cpp b/Source/Core/Elements/WidgetTextInput.cpp index ee1d910d1..12dd1dc61 100644 --- a/Source/Core/Elements/WidgetTextInput.cpp +++ b/Source/Core/Elements/WidgetTextInput.cpp @@ -402,33 +402,34 @@ void WidgetTextInput::ProcessEvent(Event& event) bool ctrl = event.GetParameter("ctrl_key", 0) > 0; bool alt = event.GetParameter("alt_key", 0) > 0; bool selection_changed = false; + bool out_of_bounds = false; switch (key_identifier) { - // clang-format off + // clang-format off case Input::KI_NUMPAD4: if (numlock) break; //-fallthrough - case Input::KI_LEFT: selection_changed = MoveCursorHorizontal(ctrl ? CursorMovement::PreviousWord : CursorMovement::Left, shift); break; + case Input::KI_LEFT: selection_changed = MoveCursorHorizontal(ctrl ? CursorMovement::PreviousWord : CursorMovement::Left, shift, out_of_bounds); break; case Input::KI_NUMPAD6: if (numlock) break; //-fallthrough - case Input::KI_RIGHT: selection_changed = MoveCursorHorizontal(ctrl ? CursorMovement::NextWord : CursorMovement::Right, shift); break; + case Input::KI_RIGHT: selection_changed = MoveCursorHorizontal(ctrl ? CursorMovement::NextWord : CursorMovement::Right, shift, out_of_bounds); break; case Input::KI_NUMPAD8: if (numlock) break; //-fallthrough - case Input::KI_UP: selection_changed = MoveCursorVertical(-1, shift); break; + case Input::KI_UP: selection_changed = MoveCursorVertical(-1, shift, out_of_bounds); break; case Input::KI_NUMPAD2: if (numlock) break; //-fallthrough - case Input::KI_DOWN: selection_changed = MoveCursorVertical(1, shift); break; + case Input::KI_DOWN: selection_changed = MoveCursorVertical(1, shift, out_of_bounds); break; case Input::KI_NUMPAD7: if (numlock) break; //-fallthrough - case Input::KI_HOME: selection_changed = MoveCursorHorizontal(ctrl ? CursorMovement::Begin : CursorMovement::BeginLine, shift); break; + case Input::KI_HOME: selection_changed = MoveCursorHorizontal(ctrl ? CursorMovement::Begin : CursorMovement::BeginLine, shift, out_of_bounds); break; case Input::KI_NUMPAD1: if (numlock) break; //-fallthrough - case Input::KI_END: selection_changed = MoveCursorHorizontal(ctrl ? CursorMovement::End : CursorMovement::EndLine, shift); break; + case Input::KI_END: selection_changed = MoveCursorHorizontal(ctrl ? CursorMovement::End : CursorMovement::EndLine, shift, out_of_bounds); break; case Input::KI_NUMPAD9: if (numlock) break; //-fallthrough - case Input::KI_PRIOR: selection_changed = MoveCursorVertical(-int(internal_dimensions.y / parent->GetLineHeight()) + 1, shift); break; + case Input::KI_PRIOR: selection_changed = MoveCursorVertical(-int(internal_dimensions.y / parent->GetLineHeight()) + 1, shift, out_of_bounds); break; case Input::KI_NUMPAD3: if (numlock) break; //-fallthrough - case Input::KI_NEXT: selection_changed = MoveCursorVertical(int(internal_dimensions.y / parent->GetLineHeight()) - 1, shift); break; + case Input::KI_NEXT: selection_changed = MoveCursorVertical(int(internal_dimensions.y / parent->GetLineHeight()) - 1, shift, out_of_bounds); break; case Input::KI_BACK: { @@ -500,7 +501,8 @@ void WidgetTextInput::ProcessEvent(Event& event) default: break; } - event.StopPropagation(); + if (!out_of_bounds || selection_changed) + event.StopPropagation(); if (selection_changed) FormatText(); } @@ -613,10 +615,11 @@ bool WidgetTextInput::AddCharacters(String string) bool WidgetTextInput::DeleteCharacters(CursorMovement direction) { + bool out_of_bounds; // We set a selection of characters according to direction, and then delete it. // If we already have a selection, we delete that first. if (selection_length <= 0) - MoveCursorHorizontal(direction, true); + MoveCursorHorizontal(direction, true, out_of_bounds); if (selection_length > 0) { @@ -636,8 +639,10 @@ void WidgetTextInput::CopySelection() GetSystemInterface()->SetClipboardText(snippet); } -bool WidgetTextInput::MoveCursorHorizontal(CursorMovement movement, bool select) +bool WidgetTextInput::MoveCursorHorizontal(CursorMovement movement, bool select, bool& out_of_bounds) { + out_of_bounds = false; + const String& value = GetValue(); int cursor_line_index = 0, cursor_character_index = 0; @@ -717,7 +722,9 @@ bool WidgetTextInput::MoveCursorHorizontal(CursorMovement movement, bool select) case CursorMovement::End: absolute_cursor_index = INT_MAX; break; } + const int unclamped_absolute_cursor_index = absolute_cursor_index; absolute_cursor_index = Math::Clamp(absolute_cursor_index, 0, (int)GetValue().size()); + out_of_bounds = (unclamped_absolute_cursor_index != absolute_cursor_index); MoveCursorToCharacterBoundaries(seek_forward); UpdateCursorPosition(true); @@ -728,20 +735,23 @@ bool WidgetTextInput::MoveCursorHorizontal(CursorMovement movement, bool select) return selection_changed; } -bool WidgetTextInput::MoveCursorVertical(int distance, bool select) +bool WidgetTextInput::MoveCursorVertical(int distance, bool select, bool& out_of_bounds) { int cursor_line_index = 0, cursor_character_index = 0; + out_of_bounds = false; GetRelativeCursorIndices(cursor_line_index, cursor_character_index); cursor_line_index += distance; if (cursor_line_index < 0) { + out_of_bounds = true; cursor_line_index = 0; cursor_character_index = 0; } else if (cursor_line_index >= (int)lines.size()) { + out_of_bounds = true; cursor_line_index = (int)lines.size() - 1; cursor_character_index = (int)lines[cursor_line_index].editable_length; } diff --git a/Source/Core/Elements/WidgetTextInput.h b/Source/Core/Elements/WidgetTextInput.h index 9a181b4d6..d4bb9664c 100644 --- a/Source/Core/Elements/WidgetTextInput.h +++ b/Source/Core/Elements/WidgetTextInput.h @@ -134,13 +134,15 @@ class WidgetTextInput : public EventListener { /// Moves the cursor along the current line. /// @param[in] movement Cursor movement operation. /// @param[in] select True if the movement will also move the selection cursor, false if not. + /// @param[out] out_of_bounds Set to true if the resulting line position is out of bounds, false if not. /// @return True if selection was changed. - bool MoveCursorHorizontal(CursorMovement movement, bool select); + bool MoveCursorHorizontal(CursorMovement movement, bool select, bool& out_of_bounds); /// Moves the cursor up and down the text field. /// @param[in] x How far to move the cursor. /// @param[in] select True if the movement will also move the selection cursor, false if not. + /// @param[out] out_of_bounds Set to true if the resulting line position is out of bounds, false if not. /// @return True if selection was changed. - bool MoveCursorVertical(int distance, bool select); + bool MoveCursorVertical(int distance, bool select, bool& out_of_bounds); // Move the cursor to utf-8 boundaries, in case it was moved into the middle of a multibyte character. /// @param[in] forward True to seek forward, else back. void MoveCursorToCharacterBoundaries(bool forward); diff --git a/Source/Core/StyleSheetSpecification.cpp b/Source/Core/StyleSheetSpecification.cpp index 53618aea3..ff468c851 100644 --- a/Source/Core/StyleSheetSpecification.cpp +++ b/Source/Core/StyleSheetSpecification.cpp @@ -383,6 +383,12 @@ void StyleSheetSpecification::RegisterDefaultProperties() RegisterProperty(PropertyId::TabIndex, "tab-index", "none", false, false).AddParser("keyword", "none, auto"); RegisterProperty(PropertyId::Focus, "focus", "auto", true, false).AddParser("keyword", "none, auto"); + RegisterProperty(PropertyId::NavUp, "nav-up", "none", false, false).AddParser("keyword", "none, auto, horizontal, vertical").AddParser("string"); + RegisterProperty(PropertyId::NavRight, "nav-right", "none", false, false).AddParser("keyword", "none, auto, horizontal, vertical").AddParser("string"); + RegisterProperty(PropertyId::NavDown, "nav-down", "none", false, false).AddParser("keyword", "none, auto, horizontal, vertical").AddParser("string"); + RegisterProperty(PropertyId::NavLeft, "nav-left", "none", false, false).AddParser("keyword", "none, auto, horizontal, vertical").AddParser("string"); + RegisterShorthand(ShorthandId::Nav, "nav", "nav-up, nav-right, nav-down, nav-left", ShorthandType::Box); + RegisterProperty(PropertyId::ScrollbarMargin, "scrollbar-margin", "0", false, false).AddParser("length"); RegisterProperty(PropertyId::OverscrollBehavior, "overscroll-behavior", "auto", false, false).AddParser("keyword", "auto, contain"); RegisterProperty(PropertyId::PointerEvents, "pointer-events", "auto", true, false).AddParser("keyword", "none, auto"); From 551e6b39aac7781724a1b52db635740104b882d6 Mon Sep 17 00:00:00 2001 From: Michael Ragazzon Date: Sun, 15 Oct 2023 11:32:40 +0200 Subject: [PATCH 6/8] Implement :focus-visible pseudo property Automatically enabled when focus should be visually indicated. --- Include/RmlUi/Core/Context.h | 2 +- Include/RmlUi/Core/Element.h | 3 +- Samples/assets/invader.rcss | 45 ++++++++++++++++++------ Source/Core/Context.cpp | 9 +++-- Source/Core/Element.cpp | 15 +++++--- Source/Core/ElementDocument.cpp | 6 ++-- Source/Core/Elements/WidgetTextInput.cpp | 1 + 7 files changed, 58 insertions(+), 23 deletions(-) diff --git a/Include/RmlUi/Core/Context.h b/Include/RmlUi/Core/Context.h index 9b59969d8..98b3c3709 100644 --- a/Include/RmlUi/Core/Context.h +++ b/Include/RmlUi/Core/Context.h @@ -384,7 +384,7 @@ class RMLUICORE_API Context : public ScriptInterface { // Internal callback for when an element is detached or removed from the hierarchy. void OnElementDetach(Element* element); // Internal callback for when a new element gains focus. - bool OnFocusChange(Element* element); + bool OnFocusChange(Element* element, bool focus_visible); // Generates an event for faking clicks on an element. void GenerateClickEvent(Element* element); diff --git a/Include/RmlUi/Core/Element.h b/Include/RmlUi/Core/Element.h index fb7191027..04b537588 100644 --- a/Include/RmlUi/Core/Element.h +++ b/Include/RmlUi/Core/Element.h @@ -468,8 +468,9 @@ class RMLUICORE_API Element : public ScriptInterface, public EnableObserverPtrReset(); } -bool Context::OnFocusChange(Element* new_focus) +bool Context::OnFocusChange(Element* new_focus, bool focus_visible) { RMLUI_ASSERT(new_focus); @@ -1018,10 +1018,13 @@ bool Context::OnFocusChange(Element* new_focus) element = element->GetParentNode(); } - Dictionary parameters; - // Send out blur/focus events. + Dictionary parameters; SendEvents(old_chain, new_chain, EventId::Blur, parameters); + + if (focus_visible) + parameters["focus_visible"] = true; + SendEvents(new_chain, old_chain, EventId::Focus, parameters); focus = new_focus; diff --git a/Source/Core/Element.cpp b/Source/Core/Element.cpp index 568326525..f86922b2c 100644 --- a/Source/Core/Element.cpp +++ b/Source/Core/Element.cpp @@ -1094,7 +1094,7 @@ void Element::SetInnerRML(const String& rml) Factory::InstanceElementText(this, rml); } -bool Element::Focus() +bool Element::Focus(bool focus_visible) { // Are we allowed focus? Style::Focus focus_property = meta->computed_values.focus(); @@ -1106,7 +1106,7 @@ bool Element::Focus() if (context == nullptr) return false; - if (!context->OnFocusChange(this)) + if (!context->OnFocusChange(this, focus_visible)) return false; // Set this as the end of the focus chain. @@ -1888,8 +1888,15 @@ void Element::ProcessDefaultAction(Event& event) { case EventId::Mouseover: SetPseudoClass("hover", true); break; case EventId::Mouseout: SetPseudoClass("hover", false); break; - case EventId::Focus: SetPseudoClass("focus", true); break; - case EventId::Blur: SetPseudoClass("focus", false); break; + case EventId::Focus: + SetPseudoClass("focus", true); + if (event.GetParameter("focus_visible", false)) + SetPseudoClass("focus-visible", true); + break; + case EventId::Blur: + SetPseudoClass("focus", false); + SetPseudoClass("focus-visible", false); + break; default: break; } } diff --git a/Source/Core/ElementDocument.cpp b/Source/Core/ElementDocument.cpp index b6a551b0a..c9edf3c16 100644 --- a/Source/Core/ElementDocument.cpp +++ b/Source/Core/ElementDocument.cpp @@ -438,7 +438,7 @@ void ElementDocument::Show(ModalFlag modal_flag, FocusFlag focus_flag) } // Focus the window or element - bool focused = focus_element->Focus(); + bool focused = focus_element->Focus(true); if (focused && focus_element != this) focus_element->ScrollIntoView(false); } @@ -625,7 +625,7 @@ void ElementDocument::ProcessDefaultAction(Event& event) { if (Element* element = FindNextTabElement(event.GetTargetElement(), !event.GetParameter("shift_key", false))) { - if (element->Focus()) + if (element->Focus(true)) { element->ScrollIntoView(ScrollAlignment::Nearest); event.StopPropagation(); @@ -672,7 +672,7 @@ void ElementDocument::ProcessDefaultAction(Event& event) { if (Element* next = FindNextNavigationElement(focus_node, direction, *nav_property)) { - if (next->Focus()) + if (next->Focus(true)) { next->ScrollIntoView(ScrollAlignment::Nearest); event.StopPropagation(); diff --git a/Source/Core/Elements/WidgetTextInput.cpp b/Source/Core/Elements/WidgetTextInput.cpp index 12dd1dc61..80b25fa61 100644 --- a/Source/Core/Elements/WidgetTextInput.cpp +++ b/Source/Core/Elements/WidgetTextInput.cpp @@ -525,6 +525,7 @@ void WidgetTextInput::ProcessEvent(Event& event) { if (event.GetTargetElement() == parent) { + parent->SetPseudoClass("focus-visible", true); if (UpdateSelection(false)) FormatElement(); ShowCursor(true, false); From 16179f38588e4e72c2f48cb6d4c6700424f2cc3b Mon Sep 17 00:00:00 2001 From: Michael Ragazzon Date: Sun, 15 Oct 2023 11:48:39 +0200 Subject: [PATCH 7/8] Visual tests: Enable test documents to lock navigation when focused --- Tests/Source/VisualTests/TestNavigator.cpp | 7 ++++++- Tests/Source/VisualTests/TestViewer.cpp | 13 +++++++++++++ Tests/Source/VisualTests/TestViewer.h | 1 + 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/Tests/Source/VisualTests/TestNavigator.cpp b/Tests/Source/VisualTests/TestNavigator.cpp index 185a650ee..b3e450ced 100644 --- a/Tests/Source/VisualTests/TestNavigator.cpp +++ b/Tests/Source/VisualTests/TestNavigator.cpp @@ -33,6 +33,7 @@ #include "TestViewer.h" #include #include +#include #include #include #include @@ -230,6 +231,10 @@ void TestNavigator::ProcessEvent(Rml::Event& event) { element_filter_input->Blur(); } + else if (viewer->IsNavigationLocked()) + { + element_filter_input->GetOwnerDocument()->Focus(); + } else if (goto_index >= 0) { goto_index = -1; @@ -252,7 +257,7 @@ void TestNavigator::ProcessEvent(Rml::Event& event) } // Keydown events in target/bubble phase ignored when focusing on input. - if (event == Rml::EventId::Keydown && event.GetPhase() != Rml::EventPhase::Capture) + if (event == Rml::EventId::Keydown && event.GetPhase() != Rml::EventPhase::Capture && !viewer->IsNavigationLocked()) { const auto key_identifier = (Rml::Input::KeyIdentifier)event.GetParameter("key_identifier", 0); diff --git a/Tests/Source/VisualTests/TestViewer.cpp b/Tests/Source/VisualTests/TestViewer.cpp index 9bd94524e..a76e2f55b 100644 --- a/Tests/Source/VisualTests/TestViewer.cpp +++ b/Tests/Source/VisualTests/TestViewer.cpp @@ -182,6 +182,19 @@ bool TestViewer::IsHelpVisible() const return document_help->IsVisible(); } +bool TestViewer::IsNavigationLocked() const +{ + if (Element* element = context->GetFocusElement()) + { + if (document_test && element->GetOwnerDocument() == document_test) + { + if (document_test->HasAttribute("lock-navigation")) + return true; + } + } + return false; +} + bool TestViewer::LoadTest(const Rml::String& directory, const Rml::String& filename, int test_index, int number_of_tests, int filtered_test_index, int filtered_number_of_tests, int suite_index, int number_of_suites, bool keep_scroll_position) { diff --git a/Tests/Source/VisualTests/TestViewer.h b/Tests/Source/VisualTests/TestViewer.h index 2cef20c26..4aa68bac7 100644 --- a/Tests/Source/VisualTests/TestViewer.h +++ b/Tests/Source/VisualTests/TestViewer.h @@ -47,6 +47,7 @@ class TestViewer { void ShowSource(SourceType type); void ShowHelp(bool show); bool IsHelpVisible() const; + bool IsNavigationLocked() const; bool LoadTest(const Rml::String& directory, const Rml::String& filename, int test_index, int number_of_tests, int filtered_test_index, int filtered_number_of_tests, int suite_index, int number_of_suites, bool keep_scroll_position = false); From 4cacf380dce1fa6ad817197e69859b8f22abc4a7 Mon Sep 17 00:00:00 2001 From: Michael Ragazzon Date: Sun, 15 Oct 2023 11:51:07 +0200 Subject: [PATCH 8/8] Add new visual tests for spatial navigation --- Tests/Data/VisualTests/navigation_01.rml | 163 +++++++++++++++++++++++ Tests/Data/VisualTests/navigation_02.rml | 111 +++++++++++++++ 2 files changed, 274 insertions(+) create mode 100644 Tests/Data/VisualTests/navigation_01.rml create mode 100644 Tests/Data/VisualTests/navigation_02.rml diff --git a/Tests/Data/VisualTests/navigation_01.rml b/Tests/Data/VisualTests/navigation_01.rml new file mode 100644 index 000000000..cb435b1be --- /dev/null +++ b/Tests/Data/VisualTests/navigation_01.rml @@ -0,0 +1,163 @@ + + + Spatial navigation 01 + + + + + + + + + +
+
+
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+

+

+

+

+
+
+
+

+

+

+
+
+
+

+

+

+
+
+
+

+

+

+
+
+
+
+

+

+

+

+
+
+

+

+

+

+
+
+
+
+

+

+

+

+
+ + + diff --git a/Tests/Data/VisualTests/navigation_02.rml b/Tests/Data/VisualTests/navigation_02.rml new file mode 100644 index 000000000..678698176 --- /dev/null +++ b/Tests/Data/VisualTests/navigation_02.rml @@ -0,0 +1,111 @@ + + + Spatial navigation 02 + + + + + + + + + + + +
+
+
+
+
+
+
+
+
+
+
+

+

+

+

+
+

+

+

+
+
+

+

+

+

+
+

+

+

+
+
+
+