| #include "rive/semantic/semantic_data.hpp" |
| #include "rive/artboard.hpp" |
| #include "rive/artboard_host.hpp" |
| #include "rive/component_dirt.hpp" |
| #include "rive/container_component.hpp" |
| #include "rive/drawable.hpp" |
| #include "rive/focus_data.hpp" |
| #include "rive/input/focus_manager.hpp" |
| #include "rive/node.hpp" |
| #include "rive/semantic/semantic_listener.hpp" |
| #include "rive/semantic/semantic_manager.hpp" |
| #include "rive/semantic/semantic_node.hpp" |
| #include "rive/semantic/semantic_provider.hpp" |
| #include "rive/semantic/semantic_state.hpp" |
| #include "rive/semantic/semantic_trait.hpp" |
| |
| #include "semantic_inference_registry.hpp" |
| |
| using namespace rive; |
| |
| SemanticData::~SemanticData() |
| { |
| if (m_semanticNode != nullptr) |
| { |
| auto* manager = m_semanticNode->manager(); |
| if (manager != nullptr) |
| { |
| manager->removeChild(m_semanticNode); |
| } |
| } |
| } |
| |
| // Lazily creates the SemanticNode with a globally unique ID, copies |
| // authored properties, applies text inference, and computes initial bounds. |
| rcp<SemanticNode> SemanticData::semanticNode() |
| { |
| if (m_semanticNode == nullptr) |
| { |
| // Id is assigned by SemanticManager on first addChild(); until |
| // then the node has id 0 and can't be looked up. All dirty-marking |
| // call sites in this file guard against a null manager, so id 0 |
| // is safe to carry around pre-registration. |
| m_semanticNode = rcp<SemanticNode>(new SemanticNode()); |
| m_semanticNode->coreOwner(parent()); |
| m_semanticNode->semanticData(this); |
| m_semanticNode->role(m_Role); |
| m_semanticNode->label(m_Label); |
| m_semanticNode->value(m_Value); |
| m_semanticNode->hint(m_Hint); |
| m_semanticNode->headingLevel(m_HeadingLevel); |
| m_semanticNode->stateFlags(m_StateFlags); |
| // Check if a sibling FocusData exists — if so, set the Focusable |
| // trait so the platform knows this node can receive focus. |
| uint32_t initialTraits = m_TraitFlags; |
| auto* parentNode = parent(); |
| if (parentNode != nullptr && parentNode->is<Node>()) |
| { |
| for (auto* child : parentNode->as<Node>()->children()) |
| { |
| if (child->is<FocusData>()) |
| { |
| initialTraits |= |
| static_cast<uint32_t>(SemanticTrait::Focusable); |
| break; |
| } |
| } |
| } |
| m_semanticNode->traitFlags(initialTraits); |
| applyInferredSemanticsIfNeeded(); |
| // Set initial world bounds if available. Sibling shapes may not |
| // have built their paths yet (deferred due to renderOpacity == 0 |
| // during nested artboard init). Flag a retry so updateWorldBounds |
| // re-dirts once if the initial bounds are degenerate. |
| m_boundsRetryPending = true; |
| updateWorldBounds(); |
| } |
| return m_semanticNode; |
| } |
| |
| // Finds parent SemanticData by walking up the component hierarchy. |
| SemanticData* SemanticData::findParentSemanticData() const |
| { |
| auto* current = parent(); |
| while (current != nullptr) |
| { |
| if (current->is<Node>()) |
| { |
| auto* node = current->as<Node>(); |
| for (auto* child : node->children()) |
| { |
| if (child->is<SemanticData>() && child != this) |
| { |
| return child->as<SemanticData>(); |
| } |
| } |
| } |
| current = current->parent(); |
| } |
| return nullptr; |
| } |
| |
| // Finds the closest SemanticNode for a component, crossing artboard |
| // boundaries via ArtboardHost. Walks up the component hierarchy; when |
| // it hits an Artboard, jumps to the host component in the parent artboard. |
| // Starts from the component itself so that SemanticData attached directly |
| // to the component (e.g. a NestedArtboard with role=button) is found. |
| rcp<SemanticNode> SemanticData::findClosestSemanticNode(Component* component) |
| { |
| if (component == nullptr) |
| { |
| return nullptr; |
| } |
| |
| auto* current = component; |
| while (current != nullptr) |
| { |
| 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; |
| } |
| } |
| // No way to traverse up, stop searching |
| return nullptr; |
| } |
| |
| if (current->is<Node>()) |
| { |
| if (auto* data = current->as<Node>()->firstChild<SemanticData>()) |
| { |
| return data->semanticNode(); |
| } |
| } |
| |
| current = current->parent(); |
| } |
| return nullptr; |
| } |
| |
| uint32_t SemanticData::semanticId() const |
| { |
| if (m_semanticNode == nullptr) |
| { |
| return 0; |
| } |
| return m_semanticNode->id(); |
| } |
| |
| void SemanticData::setFocusedState(bool focused) |
| { |
| if (m_semanticNode == nullptr) |
| { |
| return; |
| } |
| uint32_t flags = m_semanticNode->stateFlags(); |
| if (focused) |
| { |
| flags |= static_cast<uint32_t>(SemanticState::Focused); |
| } |
| else |
| { |
| flags &= ~static_cast<uint32_t>(SemanticState::Focused); |
| } |
| m_semanticNode->stateFlags(flags); |
| markContentDirty(); |
| } |
| |
| bool SemanticData::requestFocus() |
| { |
| auto* parentNode = parent(); |
| if (parentNode == nullptr || !parentNode->is<Node>()) |
| { |
| return false; |
| } |
| auto* focusData = parentNode->as<Node>()->firstChild<FocusData>(); |
| if (focusData == nullptr) |
| { |
| return false; |
| } |
| auto* ab = artboard(); |
| if (ab == nullptr) |
| { |
| return false; |
| } |
| auto* focusManager = ab->focusManager(); |
| if (focusManager == nullptr) |
| { |
| return false; |
| } |
| focusManager->setFocus(focusData->focusNode()); |
| return true; |
| } |
| |
| // Collapse/uncollapse: removes or re-adds SemanticNode to the tree. |
| // Collapse caches the manager before removal. Uncollapse re-adds with |
| // correct parent, recomputes bounds, and forces a WorldTransform dirt. |
| bool SemanticData::collapse(bool value) |
| { |
| if (!Super::collapse(value)) |
| { |
| return false; |
| } |
| |
| if (m_semanticNode == nullptr) |
| { |
| return true; |
| } |
| |
| if (value) |
| { |
| // Capture the manager before removeChild clears it. |
| auto* manager = m_semanticNode->manager(); |
| if (manager != nullptr) |
| { |
| m_semanticManager = manager; |
| manager->removeChild(m_semanticNode); |
| } |
| } |
| else |
| { |
| // Re-add to semantic tree using the cached manager. |
| auto* manager = m_semanticManager; |
| if (manager == nullptr) |
| { |
| manager = m_semanticNode->manager(); |
| } |
| if (manager != nullptr) |
| { |
| m_semanticManager = manager; |
| manager->addChild(resolveParentNode(), m_semanticNode); |
| |
| // Re-apply current bounds and content so the node is up to date. |
| m_boundsRetryPending = true; |
| updateWorldBounds(); |
| |
| // Force a WorldTransform update so bounds are recomputed |
| // in the next update cycle after layout has been recalculated. |
| addDirt(ComponentDirt::WorldTransform, false); |
| |
| applyInferredSemanticsIfNeeded(); |
| } |
| } |
| |
| return true; |
| } |
| |
| // Register dependency on parent's world transform so update() fires |
| // when the parent node moves. |
| void SemanticData::buildDependencies() |
| { |
| Super::buildDependencies(); |
| auto* parentComponent = parent(); |
| if (parentComponent != nullptr) |
| { |
| parentComponent->addDependent(this); |
| } |
| } |
| |
| // Update cycle handler. Called when the parent node's world transform |
| // changes or when a sibling Shape's geometry is rebuilt. Re-evaluates |
| // inferred semantics and recomputes world-space bounds. |
| void SemanticData::update(ComponentDirt value) |
| { |
| // Paint dirt (opacity/color) does not affect tree membership — see |
| // shouldExcludeFromSemanticTree(). Only Collapsed + Hidden-state matter. |
| if (hasDirt(value, ComponentDirt::Collapsed)) |
| { |
| syncSemanticTreeVisibility(); |
| } |
| |
| if ((value & ComponentDirt::WorldTransform) == |
| ComponentDirt::WorldTransform) |
| { |
| applyInferredSemanticsIfNeeded(); |
| updateWorldBounds(); |
| } |
| |
| if ((value & ComponentDirt::Path) == ComponentDirt::Path) |
| { |
| updateWorldBounds(); |
| } |
| } |
| |
| // Applies inferred semantics if no explicit role/label was authored. |
| // Currently supports Text → role=label with concatenated run text. |
| void SemanticData::applyInferredSemanticsIfNeeded() |
| { |
| if (m_Role != 0 || !m_Label.empty() || m_semanticNode == nullptr) |
| { |
| return; |
| } |
| ResolvedSemanticData inferred; |
| if (!resolveInferredSemantics(parent(), inferred)) |
| { |
| return; |
| } |
| if (m_semanticNode->role() == inferred.role && |
| m_semanticNode->label() == inferred.label) |
| { |
| return; |
| } |
| m_semanticNode->role(inferred.role); |
| m_semanticNode->label(inferred.label); |
| markContentDirty(); |
| } |
| |
| // Marks semantic content as dirty so manager emits updated diffs. |
| void SemanticData::markContentDirty() |
| { |
| if (m_semanticNode == nullptr) |
| { |
| return; |
| } |
| auto* manager = m_semanticNode->manager(); |
| if (manager != nullptr && parent() != nullptr) |
| { |
| manager->markNodeDirty(semanticId(), SemanticDirt::Content); |
| } |
| } |
| |
| // Property change handlers. Called when role/label/stateFlags change from |
| // deserialization, animation, or data binding. Pushes the new value to the |
| // SemanticNode and marks content-dirty for the next diff. |
| void SemanticData::roleChanged() |
| { |
| if (m_semanticNode == nullptr || m_semanticNode->role() == m_Role) |
| { |
| return; |
| } |
| m_semanticNode->role(m_Role); |
| markContentDirty(); |
| } |
| |
| void SemanticData::labelChanged() |
| { |
| if (m_semanticNode == nullptr || m_semanticNode->label() == m_Label) |
| { |
| return; |
| } |
| m_semanticNode->label(m_Label); |
| markContentDirty(); |
| } |
| |
| void SemanticData::stateFlagsChanged() |
| { |
| if (m_semanticNode == nullptr) |
| { |
| return; |
| } |
| if (m_semanticNode->stateFlags() == m_StateFlags) |
| { |
| return; |
| } |
| m_semanticNode->stateFlags(m_StateFlags); |
| markContentDirty(); |
| // Hidden bit changes affect tree membership, not just content. |
| syncSemanticTreeVisibility(); |
| } |
| |
| void SemanticData::traitFlagsChanged() |
| { |
| if (m_semanticNode == nullptr || |
| m_semanticNode->traitFlags() == m_TraitFlags) |
| { |
| return; |
| } |
| m_semanticNode->traitFlags(m_TraitFlags); |
| markContentDirty(); |
| } |
| |
| void SemanticData::valueChanged() |
| { |
| if (m_semanticNode == nullptr || m_semanticNode->value() == m_Value) |
| { |
| return; |
| } |
| m_semanticNode->value(m_Value); |
| markContentDirty(); |
| } |
| |
| void SemanticData::hintChanged() |
| { |
| if (m_semanticNode == nullptr || m_semanticNode->hint() == m_Hint) |
| { |
| return; |
| } |
| m_semanticNode->hint(m_Hint); |
| markContentDirty(); |
| } |
| |
| void SemanticData::headingLevelChanged() |
| { |
| if (m_semanticNode == nullptr || |
| m_semanticNode->headingLevel() == m_HeadingLevel) |
| { |
| return; |
| } |
| m_semanticNode->headingLevel(m_HeadingLevel); |
| markContentDirty(); |
| } |
| |
| rcp<SemanticNode> SemanticData::resolveParentNode() const |
| { |
| auto* localParent = findParentSemanticData(); |
| rcp<SemanticNode> parentNode = |
| localParent != nullptr ? localParent->semanticNode() : nullptr; |
| if (parentNode == nullptr) |
| { |
| auto* ab = artboard(); |
| if (ab != nullptr) |
| { |
| parentNode = ab->semanticBoundaryNode(); |
| } |
| } |
| return parentNode; |
| } |
| |
| bool SemanticData::shouldExcludeFromSemanticTree() const |
| { |
| if (hasSemanticState(m_StateFlags, SemanticState::Hidden)) |
| { |
| return true; |
| } |
| |
| auto* current = parent(); |
| if (current == nullptr) |
| { |
| return true; |
| } |
| if (current->isCollapsed()) |
| { |
| return true; |
| } |
| if (current->is<Drawable>() && current->as<Drawable>()->isHidden()) |
| { |
| return true; |
| } |
| // Note: opacity 0 does NOT exclude from the semantic tree. A |
| // visually hidden component may still be interactive and should |
| // remain accessible to screen readers. Use the Hidden semantic |
| // state flag for intentional exclusion. |
| return false; |
| } |
| |
| void SemanticData::syncSemanticTreeVisibility() |
| { |
| if (m_semanticNode == nullptr) |
| { |
| return; |
| } |
| |
| const bool shouldExclude = shouldExcludeFromSemanticTree(); |
| if (shouldExclude == m_excludedFromTree) |
| { |
| return; |
| } |
| m_excludedFromTree = shouldExclude; |
| |
| auto* manager = m_semanticNode->manager(); |
| if (shouldExclude) |
| { |
| if (manager != nullptr) |
| { |
| m_semanticManager = manager; |
| manager->removeChild(m_semanticNode); |
| } |
| return; |
| } |
| |
| // Already tracked in the semantic tree. |
| if (manager != nullptr) |
| { |
| return; |
| } |
| |
| manager = m_semanticManager; |
| |
| if (manager == nullptr) |
| { |
| return; |
| } |
| |
| manager->addChild(resolveParentNode(), m_semanticNode); |
| |
| m_boundsRetryPending = true; |
| updateWorldBounds(); |
| applyInferredSemanticsIfNeeded(); |
| } |
| |
| void SemanticData::updateWorldBounds() |
| { |
| if (m_semanticNode == nullptr) |
| { |
| return; |
| } |
| auto* parentComponent = parent(); |
| if (parentComponent == nullptr) |
| { |
| return; |
| } |
| auto bounds = SemanticProvider::semanticBounds(parentComponent); |
| if (bounds.isEmptyOrNaN() && m_boundsRetryPending) |
| { |
| // Bounds not ready (e.g. sibling Shape paths still deferred). |
| // Re-dirty so updateComponents() runs another iteration after |
| // sibling paths have been rebuilt in this same pass. |
| addDirt(ComponentDirt::WorldTransform, false); |
| return; |
| } |
| m_boundsRetryPending = false; |
| if (bounds == m_semanticNode->bounds()) |
| { |
| return; |
| } |
| m_semanticNode->bounds(bounds); |
| auto* manager = m_semanticNode->manager(); |
| if (manager != nullptr) |
| { |
| manager->markNodeDirty(semanticId(), SemanticDirt::Bounds); |
| } |
| } |
| |
| void SemanticData::addSemanticListener(SemanticListener* listener) |
| { |
| m_semanticListeners.push_back(listener); |
| } |
| |
| void SemanticData::removeSemanticListener(SemanticListener* listener) |
| { |
| auto it = std::find(m_semanticListeners.begin(), |
| m_semanticListeners.end(), |
| listener); |
| if (it != m_semanticListeners.end()) |
| { |
| m_semanticListeners.erase(it); |
| } |
| } |
| |
| void SemanticData::fireSemanticTap() |
| { |
| for (auto* listener : m_semanticListeners) |
| { |
| listener->onSemanticTap(); |
| } |
| } |
| |
| void SemanticData::fireSemanticIncrease() |
| { |
| for (auto* listener : m_semanticListeners) |
| { |
| listener->onSemanticIncrease(); |
| } |
| } |
| |
| void SemanticData::fireSemanticDecrease() |
| { |
| for (auto* listener : m_semanticListeners) |
| { |
| listener->onSemanticDecrease(); |
| } |
| } |