blob: d4da9a1b873ea4a247fb91b19b59abe818790eee [file] [edit]
#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
: &currentIt->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();
}
// 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();
}