| #include "rive/focus_data.hpp" | |
| #include "rive/artboard.hpp" | |
| #include "rive/artboard_component_list.hpp" | |
| #include "rive/artboard_host.hpp" | |
| #include "rive/component.hpp" | |
| #include "rive/constraints/scrolling/scroll_constraint.hpp" | |
| #include "rive/drawable.hpp" | |
| #include "rive/input/focusable.hpp" | |
| #include "rive/input/focus_listener.hpp" | |
| #include "rive/input/focus_manager.hpp" | |
| #include "rive/layout_component.hpp" | |
| #include "rive/math/mat2d.hpp" | |
| #include "rive/nested_artboard.hpp" | |
| #include "rive/node.hpp" | |
| #include "rive/parent_traversal.hpp" | |
| #include "rive/semantic/semantic_data.hpp" | |
| #include "rive/semantic/semantic_provider.hpp" | |
| #include "rive/semantic/semantic_state.hpp" | |
| #include "rive/text/text_input.hpp" | |
| #include "rive/transform_component.hpp" | |
| #include "rive/world_transform_component.hpp" | |
| #include <algorithm> | |
| using namespace rive; | |
| namespace | |
| { | |
| SemanticData* findSiblingSemanticData(Component* self) | |
| { | |
| auto* parentNode = self->parent(); | |
| if (parentNode == nullptr || !parentNode->is<Node>()) | |
| { | |
| return nullptr; | |
| } | |
| return parentNode->as<Node>()->firstChild<SemanticData>(); | |
| } | |
| } // namespace | |
| FocusData::~FocusData() | |
| { | |
| if (m_focusNode != nullptr) | |
| { | |
| // Clear the focusable pointer first to prevent callbacks during removal | |
| m_focusNode->clearFocusable(); | |
| // Take the node out of the focus tree. Its own manager pointer is the | |
| // normal route, but a node can end up without one (a detach that was | |
| // never paired with a re-add, or a manager torn down around it). Left | |
| // parented, such a node survives with a null focusable, which is a | |
| // state nothing else can clean up. | |
| // | |
| // Fall back to the nearest ancestor that still knows its manager, so | |
| // removal keeps clearing focus and invalidating the manager's cached | |
| // focusable-content answer; only when nothing in the chain is | |
| // registered do we settle for a plain detach. | |
| FocusManager* manager = m_focusNode->manager(); | |
| if (manager == nullptr) | |
| { | |
| for (FocusNode* p = m_focusNode->parent(); p != nullptr; | |
| p = p->parent()) | |
| { | |
| if (p->manager() != nullptr) | |
| { | |
| manager = p->manager(); | |
| break; | |
| } | |
| } | |
| } | |
| if (manager != nullptr) | |
| { | |
| manager->removeChild(m_focusNode); | |
| } | |
| else | |
| { | |
| m_focusNode->removeFromParent(); | |
| } | |
| // m_focusNode (rcp) is released automatically when this destructor ends | |
| } | |
| } | |
| rcp<FocusNode> FocusData::focusNode() | |
| { | |
| if (m_focusNode == nullptr) | |
| { | |
| m_focusNode = rcp<FocusNode>(new FocusNode(this)); | |
| m_focusNode->canFocus((focusFlags() & canFocusBitmask) != 0); | |
| m_focusNode->canTouch((focusFlags() & canTouchBitmask) != 0); | |
| m_focusNode->canTraverse((focusFlags() & canTraverseBitmask) != 0); | |
| m_focusNode->edgeBehavior( | |
| static_cast<EdgeBehavior>(m_EdgeBehaviorValue)); | |
| m_focusNode->name(name()); | |
| // Set initial world bounds if available | |
| updateWorldBounds(); | |
| } | |
| return m_focusNode; | |
| } | |
| void FocusData::addFocusListener(FocusListener* listener) | |
| { | |
| m_focusListeners.push_back(listener); | |
| } | |
| void FocusData::removeFocusListener(FocusListener* listener) | |
| { | |
| auto it = | |
| std::find(m_focusListeners.begin(), m_focusListeners.end(), listener); | |
| if (it != m_focusListeners.end()) | |
| { | |
| m_focusListeners.erase(it); | |
| } | |
| } | |
| void FocusData::addKeyboardListener(KeyboardListener* listener) | |
| { | |
| m_keyboardListeners.push_back(listener); | |
| } | |
| void FocusData::removeKeyboardListener(KeyboardListener* listener) | |
| { | |
| auto it = std::find(m_keyboardListeners.begin(), | |
| m_keyboardListeners.end(), | |
| listener); | |
| if (it != m_keyboardListeners.end()) | |
| { | |
| m_keyboardListeners.erase(it); | |
| } | |
| } | |
| void FocusData::addTextInputListener(KeyboardListener* listener) | |
| { | |
| m_textInputListeners.push_back(listener); | |
| } | |
| void FocusData::removeTextInputListener(KeyboardListener* listener) | |
| { | |
| auto it = std::find(m_textInputListeners.begin(), | |
| m_textInputListeners.end(), | |
| listener); | |
| if (it != m_textInputListeners.end()) | |
| { | |
| m_textInputListeners.erase(it); | |
| } | |
| } | |
| void FocusData::addGamepadListener(GamepadListener* listener) | |
| { | |
| m_gamepadListeners.push_back(listener); | |
| } | |
| void FocusData::removeGamepadListener(GamepadListener* listener) | |
| { | |
| auto it = std::find(m_gamepadListeners.begin(), | |
| m_gamepadListeners.end(), | |
| listener); | |
| if (it != m_gamepadListeners.end()) | |
| { | |
| m_gamepadListeners.erase(it); | |
| } | |
| } | |
| void FocusData::focus() | |
| { | |
| // Note: In C++ runtime, focus() needs a FocusManager to set focus. | |
| // The StateMachineInstance will handle this through its own FocusManager. | |
| // This method is provided for API completeness but the actual focus | |
| // setting happens through StateMachineInstance::setFocus(FocusData*). | |
| } | |
| bool FocusData::keyInput(Key value, | |
| KeyModifiers modifiers, | |
| bool isPressed, | |
| bool isRepeat) | |
| { | |
| // Every listener on this node is offered the key, even once one has | |
| // claimed it: two listeners on the same element watching the same key | |
| // both run, the way they always have. A claim reports upward, where | |
| // FocusManager::keyInput stops the bubble -- the same split the DOM draws | |
| // between stopPropagation and stopImmediatePropagation. | |
| bool claimed = false; | |
| for (auto* listener : m_keyboardListeners) | |
| { | |
| if (listener->keyInput(value, modifiers, isPressed, isRepeat)) | |
| { | |
| claimed = true; | |
| } | |
| } | |
| return claimed; | |
| } | |
| bool FocusData::textInput(const std::string& text) | |
| { | |
| for (auto* listener : m_textInputListeners) | |
| { | |
| if (listener->textInput(text)) | |
| { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| bool FocusData::selectedText(std::string& outText) const | |
| { | |
| // Mirror the TextInput special case in KeyboardListenerGroup's | |
| // key/text routing: the focus target for a text input is a FocusData | |
| // child whose parent is the TextInput itself. | |
| Component* target = parent(); | |
| if (target != nullptr && target->is<TextInput>()) | |
| { | |
| return target->as<TextInput>()->selectedText(outText); | |
| } | |
| return false; | |
| } | |
| bool FocusData::acceptsTextInput() const | |
| { | |
| Component* target = parent(); | |
| return target != nullptr && target->is<TextInput>(); | |
| } | |
| bool FocusData::gamepadDispatch( | |
| const ListenerInvocation& invocation, | |
| ScriptedDrawable** outDispatchedScriptedDrawable) | |
| { | |
| for (auto* listener : m_gamepadListeners) | |
| { | |
| if (listener->gamepadDispatch(invocation, | |
| outDispatchedScriptedDrawable)) | |
| { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| void FocusData::scrollIntoView() | |
| { | |
| // Find closest LayoutComponent ancestor | |
| Component* layoutComponent = parent(); | |
| while (layoutComponent != nullptr && | |
| !layoutComponent->is<LayoutComponent>()) | |
| { | |
| layoutComponent = layoutComponent->parent(); | |
| } | |
| if (layoutComponent == nullptr) | |
| { | |
| return; | |
| } | |
| // Get element bounds in its artboard's world space. | |
| // We'll transform these bounds as we cross artboard boundaries. | |
| AABB elementBounds = layoutComponent->as<LayoutComponent>()->worldBounds(); | |
| // Walk up the hierarchy finding all ScrollConstraints. | |
| // This traversal crosses artboard boundaries via the host chain. | |
| ParentTraversal traversal(this); | |
| while (auto* p = traversal.next()) | |
| { | |
| // When crossing an artboard boundary, transform element bounds from | |
| // nested artboard space to parent artboard space. | |
| if (traversal.didCrossBoundary()) | |
| { | |
| auto* host = traversal.crossingHost(); | |
| auto* sourceArtboard = traversal.sourceArtboard(); | |
| auto* artboardInstance = | |
| sourceArtboard->is<ArtboardInstance>() | |
| ? sourceArtboard->as<ArtboardInstance>() | |
| : nullptr; | |
| Mat2D hostTransform = | |
| host->worldTransformForArtboard(artboardInstance); | |
| Vec2D min = | |
| hostTransform * Vec2D(elementBounds.minX, elementBounds.minY); | |
| Vec2D max = | |
| hostTransform * Vec2D(elementBounds.maxX, elementBounds.maxY); | |
| elementBounds = AABB(min.x, min.y, max.x, max.y); | |
| } | |
| // ScrollConstraint is a Constraint that targets Content, so we check | |
| // TransformComponent::constraints() to find ScrollConstraints. | |
| if (p->is<TransformComponent>()) | |
| { | |
| auto* tc = p->as<TransformComponent>(); | |
| for (auto* constraint : tc->constraints()) | |
| { | |
| if (!constraint->is<ScrollConstraint>()) | |
| { | |
| continue; | |
| } | |
| scrollConstraintToShowBounds(constraint->as<ScrollConstraint>(), | |
| elementBounds); | |
| } | |
| } | |
| } | |
| } | |
| void FocusData::scrollConstraintToShowBounds(ScrollConstraint* constraint, | |
| const AABB& elementBounds) | |
| { | |
| auto* content = constraint->content(); | |
| auto* viewport = constraint->viewport(); | |
| if (content == nullptr || viewport == nullptr) | |
| { | |
| return; | |
| } | |
| // Get viewport position in world space. | |
| const Mat2D& viewportTransform = viewport->worldTransform(); | |
| float viewportWorldX = viewportTransform[4]; | |
| float viewportWorldY = viewportTransform[5]; | |
| float viewportWidth = constraint->viewportWidth(); | |
| float viewportHeight = constraint->viewportHeight(); | |
| // Element position relative to viewport in world space. | |
| // This directly tells us where the element appears in the viewport. | |
| float viewportLeft = elementBounds.minX - viewportWorldX; | |
| float viewportTop = elementBounds.minY - viewportWorldY; | |
| float viewportRight = elementBounds.maxX - viewportWorldX; | |
| float viewportBottom = elementBounds.maxY - viewportWorldY; | |
| // Use effective scroll offset (target if animating) to handle rapid focus | |
| // changes. | |
| float effectiveScrollX = constraint->effectiveScrollOffsetX(); | |
| float effectiveScrollY = constraint->effectiveScrollOffsetY(); | |
| float deltaX = 0.0f; | |
| float deltaY = 0.0f; | |
| // Calculate horizontal scroll adjustment. | |
| if (constraint->constrainsHorizontal()) | |
| { | |
| float elementWidth = viewportRight - viewportLeft; | |
| if (elementWidth > viewportWidth) | |
| { | |
| // Oversized element: keep the left edge visible. | |
| deltaX = -viewportLeft; | |
| } | |
| else if (viewportLeft < 0) | |
| { | |
| // Element is to the left of viewport, scroll right (increase | |
| // offset) | |
| deltaX = -viewportLeft; | |
| } | |
| else if (viewportRight > viewportWidth) | |
| { | |
| // Element is to the right of viewport, scroll left (decrease | |
| // offset) | |
| deltaX = -(viewportRight - viewportWidth); | |
| } | |
| } | |
| // Calculate vertical scroll adjustment | |
| if (constraint->constrainsVertical()) | |
| { | |
| float elementHeight = viewportBottom - viewportTop; | |
| if (elementHeight > viewportHeight) | |
| { | |
| // Oversized element: keep the top edge visible. | |
| deltaY = -viewportTop; | |
| } | |
| else if (viewportTop < 0) | |
| { | |
| // Element is above viewport, scroll up (increase offset) | |
| deltaY = -viewportTop; | |
| } | |
| else if (viewportBottom > viewportHeight) | |
| { | |
| // Element is below viewport, scroll down (decrease offset) | |
| deltaY = -(viewportBottom - viewportHeight); | |
| } | |
| } | |
| // Apply scroll if needed (using animated scroll) | |
| if (deltaX != 0 || deltaY != 0) | |
| { | |
| // Add delta to effective scroll offset to get target position. | |
| Vec2D current(effectiveScrollX, effectiveScrollY); | |
| Vec2D target(effectiveScrollX + deltaX, effectiveScrollY + deltaY); | |
| Vec2D snapped = | |
| constraint->nearestSnapOffsetInDirection(current, target); | |
| constraint->scrollToPosition(snapped.x, snapped.y); | |
| } | |
| } | |
| void FocusData::focused() | |
| { | |
| // Scroll this element into view, crossing artboard boundaries as needed | |
| scrollIntoView(); | |
| // Notify listeners | |
| for (auto* listener : m_focusListeners) | |
| { | |
| listener->onFocused(); | |
| } | |
| // Sync focus state to sibling SemanticData (if any) | |
| auto* sibling = findSiblingSemanticData(this); | |
| if (sibling != nullptr) | |
| { | |
| sibling->setFocusedState(true); | |
| } | |
| auto* parentComponent = parent(); | |
| if (parentComponent != nullptr && parentComponent->is<TextInput>()) | |
| { | |
| parentComponent->as<TextInput>()->focused(); | |
| } | |
| } | |
| void FocusData::blurred() | |
| { | |
| for (auto* listener : m_focusListeners) | |
| { | |
| listener->onBlurred(); | |
| } | |
| // Sync focus state to sibling SemanticData (if any) | |
| auto* sibling = findSiblingSemanticData(this); | |
| if (sibling != nullptr) | |
| { | |
| sibling->setFocusedState(false); | |
| } | |
| auto* parentComponent = parent(); | |
| if (parentComponent != nullptr && parentComponent->is<TextInput>()) | |
| { | |
| parentComponent->as<TextInput>()->blurred(); | |
| } | |
| } | |
| void FocusData::focusFlagsChanged() | |
| { | |
| if (m_focusNode != nullptr) | |
| { | |
| m_focusNode->canFocus((focusFlags() & canFocusBitmask) != 0); | |
| m_focusNode->canTouch((focusFlags() & canTouchBitmask) != 0); | |
| m_focusNode->canTraverse((focusFlags() & canTraverseBitmask) != 0); | |
| } | |
| } | |
| void FocusData::edgeBehaviorValueChanged() | |
| { | |
| if (m_focusNode != nullptr) | |
| { | |
| m_focusNode->edgeBehavior( | |
| static_cast<EdgeBehavior>(m_EdgeBehaviorValue)); | |
| } | |
| } | |
| FocusData* FocusData::findParentFocusData() const | |
| { | |
| // FocusData's parent is typically a Node. Walk up from the parent | |
| // to find any ancestor Node that has a FocusData child. | |
| auto* current = parent(); | |
| while (current != nullptr) | |
| { | |
| if (current->is<Node>()) | |
| { | |
| auto* node = current->as<Node>(); | |
| for (auto* child : node->children()) | |
| { | |
| if (child->is<FocusData>() && child != this) | |
| { | |
| return child->as<FocusData>(); | |
| } | |
| } | |
| } | |
| current = current->parent(); | |
| } | |
| return nullptr; | |
| } | |
| rcp<FocusNode> FocusData::findClosestFocusNode(Component* component) | |
| { | |
| if (component == nullptr) | |
| { | |
| return nullptr; | |
| } | |
| auto* current = component->parent(); | |
| while (current != nullptr) | |
| { | |
| // Check if we've hit an artboard boundary | |
| if (current->is<Artboard>()) | |
| { | |
| auto* artboard = current->as<Artboard>(); | |
| // Try to cross the artboard boundary via the host component | |
| auto* host = artboard->host(); | |
| if (host != nullptr) | |
| { | |
| auto* hostComponent = host->hostComponent(); | |
| if (hostComponent != nullptr && | |
| hostComponent->is<ContainerComponent>()) | |
| { | |
| // Continue searching from the host component (e.g., | |
| // NestedArtboard) | |
| current = hostComponent->as<ContainerComponent>(); | |
| continue; | |
| } | |
| } | |
| #ifdef WITH_RIVE_TOOLS | |
| // Fall back to externally-set parent focus node (editor scenario) | |
| auto externalNode = artboard->externalParentFocusNode(); | |
| if (externalNode != nullptr) | |
| { | |
| return externalNode; | |
| } | |
| #endif | |
| // No way to traverse up, stop searching | |
| return nullptr; | |
| } | |
| // Check if this node has a FocusData child | |
| if (current->is<Node>()) | |
| { | |
| for (auto* child : current->as<Node>()->children()) | |
| { | |
| if (child->is<FocusData>()) | |
| { | |
| return child->as<FocusData>()->focusNode(); | |
| } | |
| } | |
| } | |
| current = current->parent(); | |
| } | |
| return nullptr; | |
| } | |
| namespace | |
| { | |
| bool componentAllowsFocusTraversal(const Component* c) | |
| { | |
| if (c == nullptr || c->isCollapsed()) | |
| { | |
| return false; | |
| } | |
| if (c->is<Drawable>()) | |
| { | |
| if (c->as<Drawable>()->isHidden()) | |
| { | |
| return false; | |
| } | |
| } | |
| if (c->is<TransformComponent>()) | |
| { | |
| if (c->as<TransformComponent>()->renderOpacity() <= 0.0f) | |
| { | |
| return false; | |
| } | |
| } | |
| return true; | |
| } | |
| } // namespace | |
| bool FocusData::isEligibleForFocusTraversal() const | |
| { | |
| if (isCollapsed()) | |
| { | |
| return false; | |
| } | |
| Component* start = parent(); | |
| if (start == nullptr) | |
| { | |
| return true; | |
| } | |
| if (!componentAllowsFocusTraversal(start)) | |
| { | |
| return false; | |
| } | |
| ParentTraversal traversal(start); | |
| while (true) | |
| { | |
| ContainerComponent* p = traversal.next(); | |
| if (p == nullptr) | |
| { | |
| break; | |
| } | |
| if (!componentAllowsFocusTraversal(p)) | |
| { | |
| return false; | |
| } | |
| if (traversal.didCrossBoundary()) | |
| { | |
| ArtboardHost* host = traversal.crossingHost(); | |
| if (host != nullptr) | |
| { | |
| Component* hc = host->hostComponent(); | |
| if (hc != nullptr) | |
| { | |
| if (!componentAllowsFocusTraversal(hc)) | |
| { | |
| return false; | |
| } | |
| if (hc->is<NestedArtboard>() && | |
| hc->as<NestedArtboard>()->isPaused()) | |
| { | |
| return false; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| return true; | |
| } | |
| bool FocusData::worldPosition(Vec2D& outPosition) | |
| { | |
| // Get local position from parent transform component | |
| auto* parentComponent = parent(); | |
| if (!parentComponent || !parentComponent->is<WorldTransformComponent>()) | |
| { | |
| return false; | |
| } | |
| Vec2D localPos = | |
| parentComponent->as<WorldTransformComponent>()->worldTranslation(); | |
| // Transform to root artboard space (handles nested artboards) | |
| auto* ab = artboard(); | |
| if (ab) | |
| { | |
| outPosition = ab->rootTransform(localPos); | |
| } | |
| else | |
| { | |
| outPosition = localPos; | |
| } | |
| return true; | |
| } | |
| void FocusData::nameChanged() | |
| { | |
| if (m_focusNode != nullptr) | |
| { | |
| m_focusNode->name(name()); | |
| } | |
| } | |
| void FocusData::buildDependencies() | |
| { | |
| Super::buildDependencies(); | |
| // Depend on parent's world transform so we update when it moves | |
| auto* parentComponent = parent(); | |
| if (parentComponent != nullptr) | |
| { | |
| parentComponent->addDependent(this); | |
| } | |
| } | |
| void FocusData::update(ComponentDirt value) | |
| { | |
| if ((value & ComponentDirt::WorldTransform) == | |
| ComponentDirt::WorldTransform) | |
| { | |
| updateWorldBounds(); | |
| } | |
| } | |
| bool FocusData::worldBounds(AABB& outBounds) | |
| { | |
| auto* parentComponent = parent(); | |
| if (parentComponent == nullptr || !parentComponent->is<LayoutComponent>()) | |
| { | |
| return false; | |
| } | |
| AABB bounds = parentComponent->as<LayoutComponent>()->worldBounds(); | |
| // Transform to root artboard space (handles nested artboards); all four | |
| // corners so rotated/skewed hosts still yield a valid AABB. | |
| auto* ab = artboard(); | |
| if (ab != nullptr) | |
| { | |
| bounds = SemanticProvider::rootTransformAABB(ab, bounds); | |
| } | |
| outBounds = bounds; | |
| return true; | |
| } | |
| void FocusData::updateWorldBounds() | |
| { | |
| if (m_focusNode == nullptr) | |
| { | |
| return; | |
| } | |
| AABB bounds; | |
| if (worldBounds(bounds)) | |
| { | |
| m_focusNode->worldBounds(bounds); | |
| } | |
| else | |
| { | |
| m_focusNode->clearWorldBounds(); | |
| } | |
| } |