feat(apple): add semantics support (#12642) 9f3eef8634

Co-authored-by: David Skuza <david@rive.app>
diff --git a/.rive_head b/.rive_head
index 1819944..f8c5e45 100644
--- a/.rive_head
+++ b/.rive_head
@@ -1 +1 @@
-bd587cb83fb48eb8ab67c9bf23eb9ecf184f7125
+9f3eef86340a3ef53568116c197fd343a9e1d4e3
diff --git a/include/rive/command_queue.hpp b/include/rive/command_queue.hpp
index 8208fac..8abc809 100644
--- a/include/rive/command_queue.hpp
+++ b/include/rive/command_queue.hpp
@@ -627,6 +627,10 @@
                               uint32_t semanticNodeId,
                               uint64_t requestId = 0);
 
+    // Clear all semantic focus. Called when the screen reader moves
+    // focus entirely away from the hosting view.
+    void clearSemanticFocus(StateMachineHandle, uint64_t requestId = 0);
+
     RenderImageHandle decodeImage(std::vector<uint8_t> imageEncodedBytes,
                                   RenderImageListener* listener = nullptr,
                                   uint64_t requestId = 0);
@@ -912,6 +916,7 @@
         drainSemanticsDiff,
         fireSemanticAction,
         requestSemanticFocus,
+        clearSemanticFocus,
         bindViewModelInstance,
         runOnce,
         draw,
diff --git a/src/command_queue.cpp b/src/command_queue.cpp
index 54f4220..25d2148 100644
--- a/src/command_queue.cpp
+++ b/src/command_queue.cpp
@@ -720,6 +720,15 @@
     m_commandStream << semanticNodeId;
 }
 
+void CommandQueue::clearSemanticFocus(StateMachineHandle stateMachineHandle,
+                                      uint64_t requestId)
+{
+    AutoLockAndNotify lock(m_commandMutex, m_commandConditionVariable);
+    m_commandStream << Command::clearSemanticFocus;
+    m_commandStream << stateMachineHandle;
+    m_commandStream << requestId;
+}
+
 RenderImageHandle CommandQueue::decodeImage(
     std::vector<uint8_t> imageEncodedBytes,
     RenderImageListener* listener,
diff --git a/src/command_server.cpp b/src/command_server.cpp
index c272c51..2a85442 100644
--- a/src/command_server.cpp
+++ b/src/command_server.cpp
@@ -2113,6 +2113,35 @@
                 break;
             }
 
+            case CommandQueue::Command::clearSemanticFocus:
+            {
+                StateMachineHandle handle;
+                uint64_t requestId;
+                commandStream >> handle;
+                commandStream >> requestId;
+                lock.unlock();
+                if (auto wrapper = getStateMachineWrapper(handle))
+                {
+                    std::unique_lock<std::mutex> stateMachineLock(
+                        wrapper->m_mutex);
+                    if (auto* focusManager = wrapper->instance->focusManager())
+                    {
+                        focusManager->clearFocus();
+                    }
+                }
+                else
+                {
+                    ErrorReporter<StateMachineHandle>(
+                        this,
+                        handle,
+                        requestId,
+                        CommandQueue::Message::stateMachineError)
+                        << "State machine " << handle
+                        << " not found for clearSemanticFocus.";
+                }
+                break;
+            }
+
             case CommandQueue::Command::runOnce:
             {
                 CommandServerCallback callback;
diff --git a/tests/unit_tests/runtime/command_queue_test.cpp b/tests/unit_tests/runtime/command_queue_test.cpp
index b5e4e15..9c25063 100644
--- a/tests/unit_tests/runtime/command_queue_test.cpp
+++ b/tests/unit_tests/runtime/command_queue_test.cpp
@@ -9,6 +9,7 @@
 #include "rive/file.hpp"
 #include "rive/semantic/semantic_role.hpp"
 #include "rive/semantic/semantic_state.hpp"
+#include "rive/semantic/semantic_trait.hpp"
 #include "common/render_context_null.hpp"
 #include <fstream>
 
@@ -5633,14 +5634,15 @@
     ViewModelInstanceHandle viewModelHandle = RIVE_NULL_HANDLE;
     StateMachineHandle stateMachineHandle = RIVE_NULL_HANDLE;
 
-    SemanticFixture(SemanticTestListener* listener = nullptr)
+    SemanticFixture(SemanticTestListener* listener = nullptr,
+                    const char* rivPath = "assets/semantic/simpsons.riv")
     {
         commandQueue = make_rcp<CommandQueue>();
         nullContext = RenderContextNULL::MakeContext();
         server =
             std::make_unique<CommandServer>(commandQueue, nullContext.get());
 
-        std::ifstream stream("assets/semantic/simpsons.riv", std::ios::binary);
+        std::ifstream stream(rivPath, std::ios::binary);
         fileHandle = commandQueue->loadFile(
             std::vector<uint8_t>(std::istreambuf_iterator<char>(stream), {}));
         artboardHandle = commandQueue->instantiateDefaultArtboard(fileHandle);
@@ -5856,12 +5858,13 @@
                                         SemanticActionType::tap,
                                         0xE3);
     fx.commandQueue->requestSemanticFocus(bogusHandle, 42, 0xE4);
+    fx.commandQueue->clearSemanticFocus(bogusHandle, 0xE5);
 
     fx.pump();
 
     // One stateMachineError per semantic command; the instantiation failure is
     // an artboardError, so it does not reach this listener.
-    CHECK(bogusListener.m_errorCount == 4);
+    CHECK(bogusListener.m_errorCount == 5);
     CHECK(bogusListener.m_diffCount == 0);
 }
 
@@ -6000,6 +6003,53 @@
     CHECK(listener.m_errorCount == 0);
 }
 
+TEST_CASE("Semantics clearSemanticFocus removes Focused bit from focused node",
+          "[CommandQueue]")
+{
+    SemanticTestListener listener;
+    SemanticFixture fx(&listener,
+                       "assets/semantic/semantic_list_scroll_focus_fixed.riv");
+
+    fx.commandQueue->enableSemantics(fx.stateMachineHandle);
+    fx.warmup();
+    fx.drain();
+    fx.pump();
+
+    // Find a node with the Focusable trait.
+    uint32_t focusableId = 0;
+    for (auto& kv : listener.m_model.nodes)
+    {
+        if (hasSemanticTrait(kv.second.traitFlags, SemanticTrait::Focusable))
+        {
+            focusableId = kv.second.id;
+            break;
+        }
+    }
+    REQUIRE(focusableId != 0);
+
+    // Focus it through the command queue.
+    fx.commandQueue->requestSemanticFocus(fx.stateMachineHandle, focusableId);
+    fx.warmup();
+    fx.drain();
+    fx.pump();
+
+    auto it = listener.m_model.nodes.find(focusableId);
+    REQUIRE(it != listener.m_model.nodes.end());
+    CHECK(hasSemanticState(it->second.stateFlags, SemanticState::Focused));
+
+    // Clear focus through the command queue.
+    fx.commandQueue->clearSemanticFocus(fx.stateMachineHandle);
+    fx.warmup();
+    fx.drain();
+    fx.pump();
+
+    it = listener.m_model.nodes.find(focusableId);
+    REQUIRE(it != listener.m_model.nodes.end());
+    CHECK_FALSE(
+        hasSemanticState(it->second.stateFlags, SemanticState::Focused));
+    CHECK(listener.m_errorCount == 0);
+}
+
 TEST_CASE("Semantics drainSemanticsDiff honors scaleFactor when the view "
           "matches the artboard",
           "[CommandQueue]")