| #include "rive/semantic/semantic_manager.hpp" | |
| #include "rive/artboard.hpp" | |
| #include "rive/component.hpp" | |
| #include "rive/semantic/semantic_data.hpp" | |
| #include "rive/semantic/semantic_node.hpp" | |
| #include "rive/semantic/semantic_provider.hpp" | |
| #include "rive/semantic/semantic_role.hpp" | |
| #include <algorithm> | |
| #include <cstddef> | |
| #include <cstdint> | |
| #include <cstdio> | |
| #include <unordered_set> | |
| #include <unordered_map> | |
| #include <utility> | |
| #include <vector> | |
| using namespace rive; | |
| namespace | |
| { | |
| // --------------------------------------------------------------------------- | |
| // Label derivation helpers. | |
| // | |
| // Interactive elements (button, toggle, etc.) with no explicit label derive | |
| // their accessible label from child semantic nodes. Children that contribute | |
| // to the derived label are "absorbed" — excluded from the flat accessibility | |
| // output so screen readers don't announce them twice. | |
| // --------------------------------------------------------------------------- | |
| // Trim leading/trailing whitespace and collapse internal runs of whitespace | |
| // to a single space. Preserves logical reading order. | |
| static std::string normalizeLabel(const std::string& input) | |
| { | |
| std::string result; | |
| result.reserve(input.size()); | |
| bool lastWasSpace = true; // treats start as whitespace → trims leading | |
| for (char c : input) | |
| { | |
| if (static_cast<unsigned char>(c) <= ' ') | |
| { | |
| if (!lastWasSpace && !result.empty()) | |
| { | |
| result += ' '; | |
| lastWasSpace = true; | |
| } | |
| } | |
| else | |
| { | |
| result += c; | |
| lastWasSpace = false; | |
| } | |
| } | |
| // Trim trailing space. | |
| if (!result.empty() && result.back() == ' ') | |
| { | |
| result.pop_back(); | |
| } | |
| return result; | |
| } | |
| // Walks the subtree rooted at `node` in tree order, collecting text and | |
| // image labels. All visited (non-interactive) nodes are added to | |
| // `absorbedIds`. Nested interactive elements are left untouched — they | |
| // are not absorbed and their subtrees are not descended into. | |
| static void collectLabelsFromSubtree(const rcp<SemanticNode>& node, | |
| std::string& textOut, | |
| std::string& imageTextOut, | |
| std::unordered_set<uint32_t>& absorbedIds) | |
| { | |
| auto role = static_cast<SemanticRole>(node->role()); | |
| // Nested interactive element: don't absorb, don't descend. | |
| if (isInteractiveRole(role)) | |
| { | |
| return; | |
| } | |
| absorbedIds.insert(node->id()); | |
| if (role == SemanticRole::text && !node->label().empty()) | |
| { | |
| if (!textOut.empty()) | |
| { | |
| textOut += ' '; | |
| } | |
| textOut += node->label(); | |
| } | |
| else if (role == SemanticRole::image && !node->label().empty()) | |
| { | |
| // Only keep the first image label as fallback. | |
| if (imageTextOut.empty()) | |
| { | |
| imageTextOut = node->label(); | |
| } | |
| } | |
| for (const auto& child : node->children()) | |
| { | |
| collectLabelsFromSubtree(child, textOut, imageTextOut, absorbedIds); | |
| } | |
| } | |
| // Reusable scratch state for a single deriveLabelForInteractiveNodes pass. | |
| // Allocation buffers are owned here and clear()-ed between interactive | |
| // nodes so capacity is kept across the walk. | |
| struct LabelDerivationScratch | |
| { | |
| std::string textLabel; | |
| std::string imageLabel; | |
| std::unordered_set<uint32_t> absorbed; | |
| }; | |
| static void deriveLabelVisit( | |
| const rcp<SemanticNode>& node, | |
| std::unordered_map<uint32_t, std::string>& derivedLabels, | |
| std::unordered_set<uint32_t>& excludedIds, | |
| LabelDerivationScratch& scratch) | |
| { | |
| if (isInteractiveRole(node->role()) && node->label().empty()) | |
| { | |
| scratch.textLabel.clear(); | |
| scratch.imageLabel.clear(); | |
| scratch.absorbed.clear(); | |
| for (const auto& child : node->children()) | |
| { | |
| collectLabelsFromSubtree(child, | |
| scratch.textLabel, | |
| scratch.imageLabel, | |
| scratch.absorbed); | |
| } | |
| // Text takes priority; images are fallback only (spec §3.3). | |
| std::string derived = normalizeLabel(scratch.textLabel); | |
| if (derived.empty()) | |
| { | |
| derived = normalizeLabel(scratch.imageLabel); | |
| } | |
| if (!derived.empty()) | |
| { | |
| derivedLabels[node->id()] = std::move(derived); | |
| excludedIds.insert(scratch.absorbed.begin(), | |
| scratch.absorbed.end()); | |
| } | |
| } | |
| for (const auto& child : node->children()) | |
| { | |
| if (excludedIds.count(child->id()) == 0) | |
| { | |
| deriveLabelVisit(child, derivedLabels, excludedIds, scratch); | |
| } | |
| } | |
| } | |
| // Pre-pass that computes derived labels for interactive nodes and builds | |
| // the set of absorbed (excluded) node IDs. | |
| // | |
| // Rules (from spec): | |
| // 1. Explicit label wins — skip derivation. | |
| // 2. Text children contribute in tree order; images only if no text. | |
| // 3. Hidden / non-semantic nodes were already pruned before this point. | |
| // 4. textField never auto-derives (not in isInteractiveRole). | |
| // 5. Nested interactive children are not absorbed. | |
| static void deriveLabelForInteractiveNodes( | |
| const std::vector<rcp<SemanticNode>>& roots, | |
| std::unordered_map<uint32_t, std::string>& derivedLabels, | |
| std::unordered_set<uint32_t>& excludedIds) | |
| { | |
| LabelDerivationScratch scratch; | |
| for (const auto& root : roots) | |
| { | |
| deriveLabelVisit(root, derivedLabels, excludedIds, scratch); | |
| } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Tree flattening. | |
| // --------------------------------------------------------------------------- | |
| // Depth-first pre-order flattening of the SemanticNode tree into a flat | |
| // array of SemanticsDiffNode structs. Nodes in `excludedIds` are skipped | |
| // but their non-excluded children are reparented to the excluded node's | |
| // parent (handles the nested-interactive-inside-absorbed-group case). | |
| // Interactive nodes with entries in `derivedLabels` use the derived label | |
| // instead of their (empty) canonical label. | |
| static void flattenSemanticNode( | |
| const rcp<SemanticNode>& node, | |
| int32_t parentId, | |
| uint32_t& siblingCounter, | |
| std::vector<SemanticsDiffNode>& out, | |
| const std::unordered_set<uint32_t>& excludedIds, | |
| const std::unordered_map<uint32_t, std::string>& derivedLabels) | |
| { | |
| if (excludedIds.count(node->id()) || node->isBoundaryNode()) | |
| { | |
| // Node is absorbed or is a structural boundary node — skip it | |
| // but reparent any non-excluded children to the absorbing parent. | |
| for (const auto& child : node->children()) | |
| { | |
| flattenSemanticNode(child, | |
| parentId, | |
| siblingCounter, | |
| out, | |
| excludedIds, | |
| derivedLabels); | |
| } | |
| return; | |
| } | |
| SemanticsDiffNode flat; | |
| flat.id = node->id(); | |
| flat.role = node->role(); | |
| auto derivedIt = derivedLabels.find(node->id()); | |
| flat.label = | |
| (derivedIt != derivedLabels.end()) ? derivedIt->second : node->label(); | |
| flat.value = node->value(); | |
| flat.hint = node->hint(); | |
| flat.stateFlags = node->stateFlags(); | |
| flat.traitFlags = node->traitFlags(); | |
| flat.headingLevel = node->headingLevel(); | |
| const auto bounds = node->bounds(); | |
| flat.minX = bounds.minX; | |
| flat.minY = bounds.minY; | |
| flat.maxX = bounds.maxX; | |
| flat.maxY = bounds.maxY; | |
| flat.parentId = parentId; | |
| flat.siblingIndex = siblingCounter++; | |
| out.push_back(flat); | |
| uint32_t childSiblingCounter = 0; | |
| for (const auto& child : node->children()) | |
| { | |
| flattenSemanticNode(child, | |
| static_cast<int32_t>(flat.id), | |
| childSiblingCounter, | |
| out, | |
| excludedIds, | |
| derivedLabels); | |
| } | |
| } | |
| // Entry point for full-tree flattening. Iterates all root nodes (nodes with | |
| // no semantic parent, i.e. top-level accessible elements). Root nodes have | |
| // parentId = -1. The reserveHint pre-allocates to avoid repeated | |
| // vector reallocation during the walk. | |
| static std::vector<SemanticsDiffNode> flattenFromSemanticNodes( | |
| const std::vector<rcp<SemanticNode>>& roots, | |
| size_t reserveHint, | |
| const std::unordered_set<uint32_t>& excludedIds, | |
| const std::unordered_map<uint32_t, std::string>& derivedLabels) | |
| { | |
| std::vector<SemanticsDiffNode> out; | |
| if (reserveHint != 0) | |
| { | |
| out.reserve(reserveHint); | |
| } | |
| uint32_t rootSiblingCounter = 0; | |
| for (const auto& root : roots) | |
| { | |
| flattenSemanticNode(root, | |
| -1, | |
| rootSiblingCounter, | |
| out, | |
| excludedIds, | |
| derivedLabels); | |
| } | |
| return out; | |
| } | |
| // Maps parentId → ordered list of child ids. | |
| // Used by buildDiffFromFlats to detect reordering and reparenting. | |
| using ChildrenByParent = std::unordered_map<int32_t, std::vector<uint32_t>>; | |
| // Builds the ChildrenByParent map from a flat snapshot. Groups nodes by | |
| // parentId, then sorts each group by siblingIndex to produce the | |
| // canonical child ordering. This ordering is compared frame-to-frame to | |
| // detect reorder/reparent changes, which are emitted as | |
| // SemanticsChildrenUpdate entries in the diff. | |
| static ChildrenByParent buildChildrenByParent( | |
| const std::vector<SemanticsDiffNode>& nodes) | |
| { | |
| // First pass: group (siblingIndex, id) pairs by parent. | |
| std::unordered_map<int32_t, std::vector<std::pair<uint32_t, uint32_t>>> | |
| grouped; | |
| grouped.reserve(nodes.size()); | |
| for (const auto& node : nodes) | |
| { | |
| grouped[node.parentId].emplace_back(node.siblingIndex, node.id); | |
| } | |
| // Second pass: sort each group by siblingIndex and extract ids | |
| // in order. The result is a map from parentId to an ordered child list | |
| // that can be directly compared via == for change detection. | |
| ChildrenByParent out; | |
| out.reserve(grouped.size()); | |
| for (auto& entry : grouped) | |
| { | |
| auto& nodeList = entry.second; | |
| std::sort(nodeList.begin(), | |
| nodeList.end(), | |
| [](const std::pair<uint32_t, uint32_t>& a, | |
| const std::pair<uint32_t, uint32_t>& b) { | |
| return a.first < b.first; | |
| }); | |
| auto& children = out[entry.first]; | |
| children.reserve(nodeList.size()); | |
| for (const auto& n : nodeList) | |
| { | |
| children.push_back(n.second); | |
| } | |
| } | |
| return out; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Visual position ordering. | |
| // | |
| // Screen-reader traversal order is determined by the order of children in | |
| // each SemanticNode's children vector. These helpers sort children by their | |
| // visual position (top-to-bottom, left-to-right) and detect when bounds | |
| // changes have caused siblings to move past each other. | |
| // --------------------------------------------------------------------------- | |
| // Returns true if the children with valid bounds are already in visual | |
| // order (top-to-bottom, left-to-right). Nodes with empty/NaN bounds are | |
| // skipped — they have no visual position to compare. | |
| static bool childrenInVisualOrder( | |
| const std::vector<rcp<SemanticNode>>& children) | |
| { | |
| AABB prevBounds; | |
| bool hasPrev = false; | |
| for (const auto& child : children) | |
| { | |
| const auto b = child->bounds(); | |
| if (b.isEmptyOrNaN()) | |
| { | |
| continue; | |
| } | |
| if (hasPrev) | |
| { | |
| if (b.minY < prevBounds.minY || | |
| (b.minY == prevBounds.minY && b.minX < prevBounds.minX)) | |
| { | |
| return false; | |
| } | |
| } | |
| prevBounds = b; | |
| hasPrev = true; | |
| } | |
| return true; | |
| } | |
| // Compares two flat snapshots (current vs. previous) and produces a | |
| // SemanticsDiff with added, removed, moved, updatedSemantic, | |
| // updatedGeometry, and childrenUpdated arrays. | |
| static SemanticsDiff buildDiffFromFlats( | |
| const std::vector<SemanticsDiffNode>& currentFlat, | |
| const std::vector<SemanticsDiffNode>& previousFlat, | |
| uint64_t treeVersion) | |
| { | |
| SemanticsDiff diff; | |
| diff.frameNumber = Artboard::frameId(); | |
| diff.treeVersion = treeVersion; | |
| uint32_t rootCount = 0; | |
| uint32_t rootId = 0; | |
| for (const auto& node : currentFlat) | |
| { | |
| if (node.parentId != -1) | |
| { | |
| continue; | |
| } | |
| rootCount++; | |
| rootId = node.id; | |
| } | |
| if (rootCount == 1) | |
| { | |
| diff.rootId = rootId; | |
| } | |
| // First-frame fast path: no previous snapshot to diff against, so | |
| // everything is an addition. Also emit the full children ordering map | |
| // so the platform can set up its tree structure in one pass. | |
| // | |
| // Emit childrenUpdated entries in the order parents first appear as | |
| // parentId in currentFlat (tree pre-order). Consumers applying the diff | |
| // sequentially see parents attached before their children's orderings. | |
| if (previousFlat.empty()) | |
| { | |
| const auto currentChildrenByParent = buildChildrenByParent(currentFlat); | |
| std::unordered_set<int32_t> seenParents; | |
| seenParents.reserve(currentChildrenByParent.size()); | |
| for (const auto& node : currentFlat) | |
| { | |
| if (!seenParents.insert(node.parentId).second) | |
| { | |
| continue; | |
| } | |
| auto it = currentChildrenByParent.find(node.parentId); | |
| if (it == currentChildrenByParent.end()) | |
| { | |
| continue; | |
| } | |
| SemanticsChildrenUpdate update; | |
| update.parentId = it->first; | |
| update.childIds = it->second; | |
| diff.childrenUpdated.push_back(std::move(update)); | |
| } | |
| diff.added.reserve(currentFlat.size()); | |
| for (const auto& node : currentFlat) | |
| { | |
| diff.added.push_back(node); | |
| } | |
| return diff; | |
| } | |
| // Build O(1) lookup maps for both snapshots. These allow per-node | |
| // comparisons without scanning the full arrays. | |
| std::unordered_map<uint32_t, const SemanticsDiffNode*> currentById; | |
| currentById.reserve(currentFlat.size()); | |
| for (const auto& node : currentFlat) | |
| { | |
| currentById[node.id] = &node; | |
| } | |
| std::unordered_map<uint32_t, const SemanticsDiffNode*> previousById; | |
| previousById.reserve(previousFlat.size()); | |
| for (const auto& node : previousFlat) | |
| { | |
| previousById[node.id] = &node; | |
| } | |
| // Build children-by-parent maps for both snapshots. Used later | |
| // to detect reordering and reparenting of child lists. | |
| const auto currentChildrenByParent = buildChildrenByParent(currentFlat); | |
| const auto previousChildrenByParent = buildChildrenByParent(previousFlat); | |
| // Removal detection: any node in the previous snapshot that is absent | |
| // from the current snapshot has been removed. The platform layer uses | |
| // this to tear down the corresponding accessibility node. Iterate | |
| // previousFlat (tree pre-order) so removed ids are emitted | |
| // deterministically. | |
| for (const auto& previousNode : previousFlat) | |
| { | |
| if (currentById.find(previousNode.id) == currentById.end()) | |
| { | |
| diff.removed.push_back(previousNode.id); | |
| } | |
| } | |
| // Added / moved / content / geometry detection for nodes in current. | |
| // For each current node: | |
| // - If absent from previous → added (new node appeared). | |
| // - If present in previous → compare fields: | |
| // - parentId or siblingIndex changed → moved (reparent or | |
| // reorder within same parent). | |
| // - role, label, or stateFlags changed → updatedSemantic. The | |
| // platform layer re-reads these to update screen reader properties. | |
| // - bounds changed → updatedGeometry. The platform layer updates | |
| // the hit-test region and visual position. | |
| // A single node can appear in multiple diff arrays (e.g. both moved | |
| // and updatedSemantic) if multiple aspects changed simultaneously. | |
| // | |
| // Iterate currentFlat (tree pre-order) so parents land in the added/ | |
| // moved arrays before their children, and iteration is deterministic | |
| // frame-to-frame. currentById/previousById are used for O(1) lookup. | |
| for (const auto& currentNode : currentFlat) | |
| { | |
| auto previousIt = previousById.find(currentNode.id); | |
| if (previousIt == previousById.end()) | |
| { | |
| diff.added.push_back(currentNode); | |
| continue; | |
| } | |
| const auto& previousNode = *previousIt->second; | |
| // Moved detection: parent changed (reparent) or sibling position | |
| // changed (reorder within the same parent). | |
| const bool moved = | |
| previousNode.parentId != currentNode.parentId || | |
| previousNode.siblingIndex != currentNode.siblingIndex; | |
| if (moved) | |
| { | |
| diff.moved.push_back(currentNode); | |
| } | |
| // Semantic content detection: any of role, label, stateFlags, or | |
| // traitFlags changed. These affect how a screen reader | |
| // announces the node (e.g. "button, selected" vs "button"). | |
| if (previousNode.role != currentNode.role || | |
| previousNode.label != currentNode.label || | |
| previousNode.stateFlags != currentNode.stateFlags || | |
| previousNode.traitFlags != currentNode.traitFlags) | |
| { | |
| diff.updatedSemantic.push_back(currentNode); | |
| } | |
| // Geometry detection: bounds changed. Affects hit-test region and | |
| // visual overlay positioning. | |
| if (previousNode.minX != currentNode.minX || | |
| previousNode.minY != currentNode.minY || | |
| previousNode.maxX != currentNode.maxX || | |
| previousNode.maxY != currentNode.maxY) | |
| { | |
| diff.updatedGeometry.push_back({currentNode.id, | |
| currentNode.minX, | |
| currentNode.minY, | |
| currentNode.maxX, | |
| currentNode.maxY}); | |
| } | |
| } | |
| // Children ordering updates. Parent ids are collected in currentFlat | |
| // tree order, then appended with any parents that only exist in | |
| // previousFlat. This gives a deterministic emission order matching how | |
| // a consumer would walk the new tree. | |
| std::vector<int32_t> orderedParents; | |
| std::unordered_set<int32_t> seenParents; | |
| orderedParents.reserve(currentChildrenByParent.size() + | |
| previousChildrenByParent.size()); | |
| seenParents.reserve(orderedParents.capacity()); | |
| for (const auto& node : currentFlat) | |
| { | |
| if (seenParents.insert(node.parentId).second) | |
| { | |
| orderedParents.push_back(node.parentId); | |
| } | |
| } | |
| for (const auto& node : previousFlat) | |
| { | |
| if (seenParents.insert(node.parentId).second) | |
| { | |
| orderedParents.push_back(node.parentId); | |
| } | |
| } | |
| for (const auto parentId : orderedParents) | |
| { | |
| auto currentIt = currentChildrenByParent.find(parentId); | |
| auto previousIt = previousChildrenByParent.find(parentId); | |
| std::vector<uint32_t> empty; | |
| const auto* currentChildren = currentIt == currentChildrenByParent.end() | |
| ? &empty | |
| : ¤tIt->second; | |
| const auto* previousChildren = | |
| previousIt == previousChildrenByParent.end() ? &empty | |
| : &previousIt->second; | |
| if (*currentChildren != *previousChildren) | |
| { | |
| SemanticsChildrenUpdate update; | |
| update.parentId = parentId; | |
| update.childIds = *currentChildren; | |
| diff.childrenUpdated.push_back(std::move(update)); | |
| } | |
| } | |
| return diff; | |
| } | |
| } // namespace | |
| // Recursively sorts each node's children by visual position so that the | |
| // flatten traversal produces siblingIndex values matching screen-reader | |
| // order (top-to-bottom, left-to-right). Nodes with empty/NaN bounds are | |
| // sorted to the end, preserving their relative insertion order via | |
| // stable_sort. | |
| void SemanticManager::sortChildrenByVisualPosition( | |
| std::vector<rcp<SemanticNode>>& nodes) | |
| { | |
| if (nodes.size() > 1) | |
| { | |
| std::stable_sort( | |
| nodes.begin(), | |
| nodes.end(), | |
| [](const rcp<SemanticNode>& a, const rcp<SemanticNode>& b) { | |
| const auto ab = a->bounds(); | |
| const auto bb = b->bounds(); | |
| const bool aEmpty = ab.isEmptyOrNaN(); | |
| const bool bEmpty = bb.isEmptyOrNaN(); | |
| // Empty-bounds nodes sort after all valid-bounds nodes. | |
| if (aEmpty != bEmpty) | |
| { | |
| return bEmpty; // a < b when b is empty and a is not | |
| } | |
| if (aEmpty) | |
| { | |
| return false; // both empty — preserve order | |
| } | |
| if (ab.minY != bb.minY) | |
| { | |
| return ab.minY < bb.minY; | |
| } | |
| return ab.minX < bb.minX; | |
| }); | |
| } | |
| for (auto& node : nodes) | |
| { | |
| sortChildrenByVisualPosition(node->mutableChildren()); | |
| } | |
| } | |
| // Marks a specific node dirty. The global m_dirt flag triggers refresh(); | |
| // the per-node sets enable O(K) incremental patching. id == 0 is the | |
| // uninitialized sentinel — we set global dirt but skip per-node. | |
| void SemanticManager::markNodeDirty(uint32_t id, SemanticDirt dirt) | |
| { | |
| markDirty(dirt); | |
| if (id == 0) | |
| { | |
| return; | |
| } | |
| if (enums::is_flag_set(dirt, SemanticDirt::Content)) | |
| { | |
| m_dirtyContentNodes.insert(id); | |
| } | |
| if (enums::is_flag_set(dirt, SemanticDirt::Bounds)) | |
| { | |
| m_dirtyBoundsNodes.insert(id); | |
| } | |
| } | |
| void SemanticManager::markBoundaryDirty(uint32_t boundaryNodeId) | |
| { | |
| m_dirtyBoundaryIds.insert(boundaryNodeId); | |
| markDirty(SemanticDirt::Bounds); | |
| } | |
| // Re-read bounds for every node in the subtree rooted at `node`: | |
| // boundary nodes get their artboard rect mapped through rootTransform, | |
| // non-boundary nodes get their component's semanticBounds(). Each node | |
| // whose bounds actually changed is inserted into m_dirtyBoundsNodes. | |
| void SemanticManager::reconcileBoundsForSubtree(SemanticNode* node) | |
| { | |
| if (node == nullptr) | |
| { | |
| return; | |
| } | |
| if (node->isBoundaryNode()) | |
| { | |
| if (auto* ab = node->boundaryArtboard()) | |
| { | |
| const AABB newBounds = SemanticProvider::rootTransformAABB( | |
| ab, | |
| AABB::fromLTWH(0.0f, 0.0f, ab->width(), ab->height())); | |
| if (newBounds != node->bounds()) | |
| { | |
| node->bounds(newBounds); | |
| m_dirtyBoundsNodes.insert(node->id()); | |
| } | |
| } | |
| } | |
| else | |
| { | |
| auto* coreOwner = node->coreOwner(); | |
| if (coreOwner != nullptr && coreOwner->is<Component>()) | |
| { | |
| auto* owner = coreOwner->as<Component>(); | |
| const AABB liveBounds = SemanticProvider::semanticBounds(owner); | |
| if (liveBounds != node->bounds()) | |
| { | |
| node->bounds(liveBounds); | |
| m_dirtyBoundsNodes.insert(node->id()); | |
| } | |
| } | |
| } | |
| for (const auto& child : node->children()) | |
| { | |
| reconcileBoundsForSubtree(child.get()); | |
| } | |
| } | |
| // Assign a manager-local id to this node, or reconcile the watermark | |
| // for an explicitly-provided id so subsequent auto-assignments don't | |
| // collide. Called from addChild() before the lookup map is updated. | |
| void SemanticManager::ensureNodeId(SemanticNode& node) | |
| { | |
| if (node.id() == 0) | |
| { | |
| // Fresh node: pick the next local id. Skip any id already in | |
| // the map (can happen when an explicit id was preserved earlier | |
| // with a value greater than our watermark). | |
| while (m_nodesById.count(m_nextLocalId) != 0) | |
| { | |
| ++m_nextLocalId; | |
| } | |
| node.id(m_nextLocalId++); | |
| return; | |
| } | |
| // Explicit id: bump the watermark so future auto-assignments skip it. | |
| if (node.id() >= m_nextLocalId) | |
| { | |
| m_nextLocalId = node.id() + 1; | |
| } | |
| } | |
| // Registers a SemanticNode in the tree. Appends the child to the | |
| // parent's children (or roots). Assigns a manager-local id if the node | |
| // was constructed without one. If the node's id collides with a | |
| // *different* node already tracked by this manager (nodes migrating from | |
| // another manager's id space), reassign it. | |
| void SemanticManager::addChild(rcp<SemanticNode> parentNode, | |
| rcp<SemanticNode> child) | |
| { | |
| if (child == nullptr) | |
| { | |
| return; | |
| } | |
| // Collision with a different node already tracked under this id — | |
| // reassign the new node to a fresh manager-local id. | |
| auto existing = m_nodesById.find(child->id()); | |
| if (child->id() != 0 && existing != m_nodesById.end() && | |
| existing->second != child) | |
| { | |
| child->id(0); | |
| } | |
| ensureNodeId(*child); | |
| if (m_nodesById.find(child->id()) == m_nodesById.end()) | |
| { | |
| m_nodesById[child->id()] = child; | |
| } | |
| child->manager(this); | |
| // Append to the end — refresh() sorts children by visual position | |
| // (bounds) before every flatten. | |
| if (parentNode != nullptr) | |
| { | |
| // Parent may have been created by a different artboard's | |
| // buildSemanticTree and never inserted here directly. | |
| if (m_nodesById.find(parentNode->id()) == m_nodesById.end()) | |
| { | |
| m_nodesById[parentNode->id()] = parentNode; | |
| } | |
| child->parent(parentNode.get()); | |
| parentNode->mutableChildren().push_back(child); | |
| } | |
| else | |
| { | |
| child->parent(nullptr); | |
| m_roots.push_back(child); | |
| } | |
| markDirty(SemanticDirt::Structure); | |
| } | |
| // Removes a SemanticNode from the tree. Unlinks from parent (or roots), | |
| // clears the manager back-reference, removes from lookup map, and marks | |
| // Structure dirty so the next refresh() emits a removal diff entry. | |
| void SemanticManager::removeChild(rcp<SemanticNode> node) | |
| { | |
| if (node == nullptr) | |
| { | |
| return; | |
| } | |
| auto* parentNode = node->parent(); | |
| if (parentNode != nullptr) | |
| { | |
| auto& siblings = parentNode->mutableChildren(); | |
| siblings.erase(std::remove_if(siblings.begin(), | |
| siblings.end(), | |
| [&node](const rcp<SemanticNode>& child) { | |
| return child.get() == node.get(); | |
| }), | |
| siblings.end()); | |
| node->parent(nullptr); | |
| } | |
| else | |
| { | |
| m_roots.erase(std::remove_if(m_roots.begin(), | |
| m_roots.end(), | |
| [&node](const rcp<SemanticNode>& root) { | |
| return root.get() == node.get(); | |
| }), | |
| m_roots.end()); | |
| } | |
| node->manager(nullptr); | |
| m_nodesById.erase(node->id()); | |
| markDirty(SemanticDirt::Structure); | |
| } | |
| // Incremental bounds patch for a single dirty node. Compares live bounds | |
| // to the snapshot; if different, updates in-place and emits updatedGeometry. | |
| void SemanticManager::patchBoundsNode(SemanticsDiffNode& entry, | |
| SemanticsDiff& diff) | |
| { | |
| auto nodeIt = m_nodesById.find(entry.id); | |
| if (nodeIt == m_nodesById.end()) | |
| { | |
| return; | |
| } | |
| const auto b = nodeIt->second->bounds(); | |
| // Only emit an update if bounds actually changed. This avoids no-op | |
| // diffs when a node is marked dirty but its bounds haven't moved | |
| // (e.g. a text content change that doesn't affect layout). | |
| if (entry.minX != b.minX || entry.minY != b.minY || entry.maxX != b.maxX || | |
| entry.maxY != b.maxY) | |
| { | |
| entry.minX = b.minX; | |
| entry.minY = b.minY; | |
| entry.maxX = b.maxX; | |
| entry.maxY = b.maxY; | |
| diff.updatedGeometry.push_back( | |
| {entry.id, entry.minX, entry.minY, entry.maxX, entry.maxY}); | |
| } | |
| } | |
| // Incremental content patch for a single dirty node. Compares role, label, | |
| // stateFlags, and traitFlags to the snapshot; if different, updates in-place | |
| // and emits updatedSemantic. This is how animation/data-binding changes to | |
| // stateFlags (e.g. "Selected" toggling) propagate to the platform layer. | |
| void SemanticManager::patchContentNode(SemanticsDiffNode& entry, | |
| SemanticsDiff& diff) | |
| { | |
| auto nodeIt = m_nodesById.find(entry.id); | |
| if (nodeIt == m_nodesById.end()) | |
| { | |
| return; | |
| } | |
| const auto& node = nodeIt->second; | |
| // Use derived label if this node has one, otherwise the canonical label. | |
| auto derivedIt = m_derivedLabels.find(entry.id); | |
| const std::string& effectiveLabel = (derivedIt != m_derivedLabels.end()) | |
| ? derivedIt->second | |
| : node->label(); | |
| // Compare all semantic content fields. Even if only one changed, | |
| // we update all fields to keep the snapshot consistent. | |
| if (entry.role != node->role() || entry.label != effectiveLabel || | |
| entry.value != node->value() || entry.hint != node->hint() || | |
| entry.stateFlags != node->stateFlags() || | |
| entry.traitFlags != node->traitFlags() || | |
| entry.headingLevel != node->headingLevel()) | |
| { | |
| entry.role = node->role(); | |
| entry.label = effectiveLabel; | |
| entry.value = node->value(); | |
| entry.hint = node->hint(); | |
| entry.stateFlags = node->stateFlags(); | |
| entry.traitFlags = node->traitFlags(); | |
| entry.headingLevel = node->headingLevel(); | |
| diff.updatedSemantic.push_back(entry); | |
| } | |
| } | |
| // Core refresh algorithm. Two paths: | |
| // 1. FULL RE-FLATTEN (Structure dirty or first frame): O(N) in tree size. | |
| // 2. INCREMENTAL PATCH (only Bounds/Content dirty): O(K) dirty nodes. | |
| // Before diffing, reconciles live bounds and computes container bounds. | |
| void SemanticManager::refresh() | |
| { | |
| // Read the current dirt flags. | |
| const bool structureDirty = | |
| enums::is_flag_set(m_dirt, SemanticDirt::Structure); | |
| bool boundsDirty = enums::is_flag_set(m_dirt, SemanticDirt::Bounds); | |
| const bool contentDirty = enums::is_flag_set(m_dirt, SemanticDirt::Content); | |
| // ------------------------------------------------------------------------- | |
| // Targeted bounds reconciliation via dirty boundary nodes. | |
| // | |
| // When a host artboard's transform changes, its boundary node is marked | |
| // dirty via markBoundaryDirty(). We re-read bounds only for nodes under | |
| // dirty boundaries — O(K_subtree) instead of O(N_all_nodes). | |
| // ------------------------------------------------------------------------- | |
| if (!m_dirtyBoundaryIds.empty()) | |
| { | |
| for (uint32_t boundaryId : m_dirtyBoundaryIds) | |
| { | |
| auto it = m_nodesById.find(boundaryId); | |
| if (it != m_nodesById.end()) | |
| { | |
| reconcileBoundsForSubtree(it->second.get()); | |
| } | |
| } | |
| m_dirtyBoundaryIds.clear(); | |
| if (!m_dirtyBoundsNodes.empty()) | |
| { | |
| boundsDirty = true; | |
| } | |
| } | |
| // Early exit if nothing is dirty after reconciliation. | |
| if (!structureDirty && !boundsDirty && !contentDirty) | |
| { | |
| return; | |
| } | |
| // ------------------------------------------------------------------------- | |
| // Visual-order check. | |
| // | |
| // Screen-reader traversal order is visual (top-to-bottom, left-to-right). | |
| // If a node's bounds changed, its parent's children may have moved past | |
| // each other and need re-sorting via the full re-flatten path. | |
| // | |
| // Only relevant on the incremental path: the full re-flatten always | |
| // re-sorts children from scratch. Only checks at parents whose direct | |
| // children actually had a bounds change — parents with no dirty child | |
| // can't have reordered. | |
| // ------------------------------------------------------------------------- | |
| bool needsReorder = false; | |
| if (boundsDirty && !structureDirty && !m_lastFlatSnapshot.empty()) | |
| { | |
| std::unordered_set<SemanticNode*> parentsToCheck; | |
| bool anyRootMoved = false; | |
| for (uint32_t id : m_dirtyBoundsNodes) | |
| { | |
| auto it = m_nodesById.find(id); | |
| if (it == m_nodesById.end()) | |
| { | |
| continue; | |
| } | |
| SemanticNode* parent = it->second->parent(); | |
| if (parent != nullptr) | |
| { | |
| parentsToCheck.insert(parent); | |
| } | |
| else | |
| { | |
| anyRootMoved = true; | |
| } | |
| } | |
| for (auto* parent : parentsToCheck) | |
| { | |
| if (parent->children().size() > 1 && | |
| !childrenInVisualOrder(parent->children())) | |
| { | |
| needsReorder = true; | |
| break; | |
| } | |
| } | |
| if (!needsReorder && anyRootMoved && m_roots.size() > 1 && | |
| !childrenInVisualOrder(m_roots)) | |
| { | |
| needsReorder = true; | |
| } | |
| } | |
| // ------------------------------------------------------------------------- | |
| // Diff production — two paths depending on what kind of dirt is present. | |
| // ------------------------------------------------------------------------- | |
| // ------------------------------------------------------------------------- | |
| // Check if any content-dirty node is an absorbed child from the last | |
| // derivation pass. If so, the parent interactive node's derived label | |
| // may be stale — escalate to a full re-flatten so derivation re-runs. | |
| // ------------------------------------------------------------------------- | |
| bool needsRederivation = false; | |
| if (contentDirty && !m_excludedIds.empty()) | |
| { | |
| for (const auto& id : m_dirtyContentNodes) | |
| { | |
| if (m_excludedIds.count(id)) | |
| { | |
| needsRederivation = true; | |
| break; | |
| } | |
| } | |
| } | |
| if (structureDirty || needsRederivation || needsReorder || | |
| m_lastFlatSnapshot.empty()) | |
| { | |
| // FULL RE-FLATTEN PATH. | |
| // The tree topology changed (add/remove/reparent), an absorbed | |
| // child's content changed (needs re-derivation), children moved | |
| // past each other (needs re-sort), or this is the first frame. | |
| // Walk the entire SemanticNode tree to produce a new flat snapshot, | |
| // then diff it against the previous snapshot. | |
| // | |
| // Step 1: Sort children by visual position (bounds) — only needed | |
| // when topology or ordering changed, not for rederivation. | |
| // Step 2: Derive labels for interactive nodes and build exclude set. | |
| // Step 3: Flatten with derivation applied. | |
| // Step 4: Diff against previous snapshot. | |
| if (structureDirty || needsReorder || m_lastFlatSnapshot.empty()) | |
| { | |
| sortChildrenByVisualPosition(m_roots); | |
| } | |
| m_derivedLabels.clear(); | |
| m_excludedIds.clear(); | |
| deriveLabelForInteractiveNodes(m_roots, m_derivedLabels, m_excludedIds); | |
| auto currentFlat = flattenFromSemanticNodes(m_roots, | |
| m_nodesById.size(), | |
| m_excludedIds, | |
| m_derivedLabels); | |
| auto nextDiff = | |
| buildDiffFromFlats(currentFlat, m_lastFlatSnapshot, m_version + 1); | |
| if (!nextDiff.empty()) | |
| { | |
| m_version++; | |
| m_lastDiff = std::move(nextDiff); | |
| m_lastFlatSnapshot = std::move(currentFlat); | |
| } | |
| } | |
| else | |
| { | |
| // INCREMENTAL PATCH PATH. | |
| // Only bounds and/or content changed — no structural changes. | |
| // Iterate m_lastFlatSnapshot, which is already in tree pre-order; | |
| // for each entry whose id is in the bounds- or content-dirty set, | |
| // patch the entry in place and emit a diff record. Iterating the | |
| // dirty sets directly would emit in hash-bucket order, which does | |
| // not track tree structure; consumers expect diff records in tree | |
| // pre-order, so the snapshot drives iteration and the sets are | |
| // used only for O(1) membership checks. | |
| // | |
| // This path skips the structural tree walk, child re-sort, label | |
| // re-derivation, and flat-vs-flat comparison that the full | |
| // re-flatten path performs. | |
| SemanticsDiff nextDiff; | |
| nextDiff.frameNumber = Artboard::frameId(); | |
| if (boundsDirty || contentDirty) | |
| { | |
| if (boundsDirty) | |
| { | |
| nextDiff.updatedGeometry.reserve(m_dirtyBoundsNodes.size()); | |
| } | |
| if (contentDirty) | |
| { | |
| nextDiff.updatedSemantic.reserve(m_dirtyContentNodes.size()); | |
| } | |
| for (auto& entry : m_lastFlatSnapshot) | |
| { | |
| if (boundsDirty && m_dirtyBoundsNodes.count(entry.id)) | |
| { | |
| patchBoundsNode(entry, nextDiff); | |
| } | |
| if (contentDirty && m_dirtyContentNodes.count(entry.id)) | |
| { | |
| patchContentNode(entry, nextDiff); | |
| } | |
| } | |
| } | |
| // Single-root optimization for the incremental path. | |
| if (m_roots.size() == 1) | |
| { | |
| nextDiff.rootId = m_roots[0]->id(); | |
| } | |
| // Only bump version and store the diff if something actually changed. | |
| // The patchBounds/patchContent methods guard against no-op updates, | |
| // so nextDiff can be empty even when dirty flags were set. | |
| if (!nextDiff.empty()) | |
| { | |
| m_version++; | |
| nextDiff.treeVersion = m_version; | |
| m_lastDiff = std::move(nextDiff); | |
| } | |
| } | |
| // Clear all dirt flags and per-node dirty sets for the next frame. | |
| m_dirt = SemanticDirt::None; | |
| m_dirtyContentNodes.clear(); | |
| m_dirtyBoundsNodes.clear(); | |
| } | |
| SemanticsDiff SemanticManager::drainDiff() | |
| { | |
| refresh(); | |
| return consumeDiff(); | |
| } | |
| uint32_t SemanticManager::rootId() const | |
| { | |
| return m_roots.size() == 1 ? m_roots.front()->id() : 0; | |
| } | |
| std::vector<SemanticsBoundsUpdate> SemanticManager::boundsSnapshot() const | |
| { | |
| std::vector<SemanticsBoundsUpdate> bounds; | |
| bounds.reserve(m_lastFlatSnapshot.size()); | |
| for (const auto& node : m_lastFlatSnapshot) | |
| { | |
| bounds.push_back({node.id, node.minX, node.minY, node.maxX, node.maxY}); | |
| } | |
| return bounds; | |
| } | |
| // Returns the diff produced by the most recent refresh() and clears it. | |
| // Subsequent calls return an empty diff until the next dirty refresh. | |
| // | |
| // The move + replace pattern ensures the caller gets ownership of the diff | |
| // data and the manager's internal storage is reset to empty. | |
| SemanticsDiff SemanticManager::consumeDiff() | |
| { | |
| SemanticsDiff result = std::move(m_lastDiff); | |
| m_lastDiff = SemanticsDiff(); | |
| return result; | |
| } | |
| bool SemanticManager::requestFocus(uint32_t semanticNodeId) | |
| { | |
| auto* semanticNode = nodeById(semanticNodeId); | |
| if (semanticNode == nullptr) | |
| { | |
| return false; | |
| } | |
| auto* data = semanticNode->semanticData(); | |
| return data != nullptr && data->requestFocus(); | |
| } |