feat(core): mouse button options (#14518) 60eb10b83d
added mouse button options

Co-authored-by: Jonathon Copeland <jcopela4@gmail.com>
diff --git a/.rive_head b/.rive_head
index 1b52e3e..0fbbc87 100644
--- a/.rive_head
+++ b/.rive_head
@@ -1 +1 @@
-30c30acec9ddcb98f7a85cd53cf7065fa4bcc189
+60eb10b83d05147324564606dea22d69e358a6c6
diff --git a/dev/core_generator/lib/main.dart b/dev/core_generator/lib/main.dart
index 1d433d9..a550cfc 100644
--- a/dev/core_generator/lib/main.dart
+++ b/dev/core_generator/lib/main.dart
@@ -9,7 +9,8 @@
   Directory(defsPath).list(recursive: true).listen(
     (entity) {
       if (entity is File && entity.path.toLowerCase().endsWith('.json')) {
-        Definition.make(entity.path.substring(defsPath.length));
+        Definition.make(
+            entity.path.substring(defsPath.length).replaceAll('\\', '/'));
       }
     },
     onDone: Definition.generate,
diff --git a/include/rive/animation/listener_invocation.hpp b/include/rive/animation/listener_invocation.hpp
index 176c0fe..3029a84 100644
--- a/include/rive/animation/listener_invocation.hpp
+++ b/include/rive/animation/listener_invocation.hpp
@@ -7,6 +7,7 @@
 #include "rive/input/standard_gamepad.hpp"
 #include "rive/listener_type.hpp"
 #include "rive/math/vec2d.hpp"
+#include "rive/pointer_button.hpp"
 
 #include <string>
 #include <type_traits>
@@ -44,6 +45,7 @@
     int pointerId = 0;
     ListenerType hitEvent = ListenerType::move;
     float timeStamp = 0.f;
+    PointerButton button = PointerButton::primary;
 };
 
 struct KeyboardInvocation
@@ -130,11 +132,13 @@
 class ListenerInvocation
 {
 public:
-    static ListenerInvocation pointer(Vec2D position,
-                                      Vec2D previousPosition,
-                                      int pointerId,
-                                      ListenerType hitEvent,
-                                      float timeStamp);
+    static ListenerInvocation pointer(
+        Vec2D position,
+        Vec2D previousPosition,
+        int pointerId,
+        ListenerType hitEvent,
+        float timeStamp,
+        PointerButton button = PointerButton::primary);
     static ListenerInvocation keyboard(Key key,
                                        KeyModifiers modifiers,
                                        bool isPressed,
diff --git a/include/rive/animation/listener_types/listener_input_type.hpp b/include/rive/animation/listener_types/listener_input_type.hpp
index 1d616f4..84f79ba 100644
--- a/include/rive/animation/listener_types/listener_input_type.hpp
+++ b/include/rive/animation/listener_types/listener_input_type.hpp
@@ -1,6 +1,7 @@
 #ifndef _RIVE_LISTENER_INPUT_TYPE_HPP_
 #define _RIVE_LISTENER_INPUT_TYPE_HPP_
 #include "rive/generated/animation/listener_types/listener_input_type_base.hpp"
+#include "rive/pointer_button.hpp"
 #include <stdio.h>
 namespace rive
 {
@@ -8,6 +9,10 @@
 {
 public:
     StatusCode import(ImportStack& importStack) override;
+    virtual PointerButton pointerButton() const
+    {
+        return PointerButton::primary;
+    }
 };
 } // namespace rive
 
diff --git a/include/rive/animation/listener_types/listener_input_type_pointer_button.hpp b/include/rive/animation/listener_types/listener_input_type_pointer_button.hpp
new file mode 100644
index 0000000..62ba44c
--- /dev/null
+++ b/include/rive/animation/listener_types/listener_input_type_pointer_button.hpp
@@ -0,0 +1,16 @@
+#ifndef _RIVE_LISTENER_INPUT_TYPE_POINTER_BUTTON_HPP_
+#define _RIVE_LISTENER_INPUT_TYPE_POINTER_BUTTON_HPP_
+#include "rive/generated/animation/listener_types/listener_input_type_pointer_button_base.hpp"
+namespace rive
+{
+class ListenerInputTypePointerButton : public ListenerInputTypePointerButtonBase
+{
+public:
+    PointerButton pointerButton() const override
+    {
+        return (PointerButton)pointerButtonValue();
+    }
+};
+} // namespace rive
+
+#endif
diff --git a/include/rive/animation/nested_state_machine.hpp b/include/rive/animation/nested_state_machine.hpp
index 48dca38..474a898 100644
--- a/include/rive/animation/nested_state_machine.hpp
+++ b/include/rive/animation/nested_state_machine.hpp
@@ -27,8 +27,12 @@
     HitResult pointerMove(Vec2D position,
                           float timeStamp = 0,
                           int pointerId = 0);
-    HitResult pointerDown(Vec2D position, int pointerId = 0);
-    HitResult pointerUp(Vec2D position, int pointerId = 0);
+    HitResult pointerDown(Vec2D position,
+                          int pointerId = 0,
+                          PointerButton button = PointerButton::primary);
+    HitResult pointerUp(Vec2D position,
+                        int pointerId = 0,
+                        PointerButton button = PointerButton::primary);
     HitResult pointerExit(Vec2D position, int pointerId = 0);
     HitResult pointerScroll(Vec2D position,
                             const ScrollEvent& event,
@@ -39,8 +43,15 @@
     bool hasScrollTargetAt(Vec2D position);
     bool scrollOccludedAt(Vec2D position);
     void cancelScroll();
-    HitResult dragStart(Vec2D position, float timeStamp = 0, int pointerId = 0);
-    HitResult dragEnd(Vec2D position, float timeStamp = 0, int pointerId = 0);
+    bool listensToButtonAt(Vec2D position, PointerButton button);
+    HitResult dragStart(Vec2D position,
+                        float timeStamp = 0,
+                        int pointerId = 0,
+                        PointerButton button = PointerButton::primary);
+    HitResult dragEnd(Vec2D position,
+                      float timeStamp = 0,
+                      int pointerId = 0,
+                      PointerButton button = PointerButton::primary);
     bool tryChangeState();
     bool hitTest(Vec2D position) const;
     bool hitTestBounded(Vec2D position) const;
diff --git a/include/rive/animation/state_machine_instance.hpp b/include/rive/animation/state_machine_instance.hpp
index cf905db..4208391 100644
--- a/include/rive/animation/state_machine_instance.hpp
+++ b/include/rive/animation/state_machine_instance.hpp
@@ -85,7 +85,8 @@
     HitResult updateListeners(Vec2D position,
                               ListenerType hitListener,
                               int pointerId = 0,
-                              float timeStamp = 0);
+                              float timeStamp = 0,
+                              PointerButton button = PointerButton::primary);
 
     template <typename SMType, typename InstType>
     InstType* getNamedInput(const std::string& name) const;
@@ -187,14 +188,23 @@
     HitResult pointerMove(Vec2D position,
                           float timeStamp = 0,
                           int pointerId = 0) override;
-    HitResult pointerDown(Vec2D position, int pointerId = 0) override;
-    HitResult pointerUp(Vec2D position, int pointerId = 0) override;
+    HitResult pointerDown(
+        Vec2D position,
+        int pointerId = 0,
+        PointerButton button = PointerButton::primary) override;
+    HitResult pointerUp(Vec2D position,
+                        int pointerId = 0,
+                        PointerButton button = PointerButton::primary) override;
     HitResult pointerExit(Vec2D position, int pointerId = 0) override;
     HitResult dragStart(Vec2D position,
                         float timeStamp = 0,
                         bool disablePointer = true,
-                        int pointerId = 0);
-    HitResult dragEnd(Vec2D position, float timeStamp = 0, int pointerId = 0);
+                        int pointerId = 0,
+                        PointerButton button = PointerButton::primary);
+    HitResult dragEnd(Vec2D position,
+                      float timeStamp = 0,
+                      int pointerId = 0,
+                      PointerButton button = PointerButton::primary);
     HitResult pointerScroll(Vec2D position,
                             const ScrollEvent& event,
                             float timeStamp = 0,
@@ -213,6 +223,10 @@
     bool scrollOccludedAt(Vec2D position);
     /// Ends any scroll gesture in flight here, latch included.
     void cancelScroll();
+    /// Whether a listener under position responds to presses of button,
+    /// looking through nested artboards. Lets a host keep a mouse button for
+    /// its own use wherever nothing in the scene wants it.
+    bool listensToButtonAt(Vec2D position, PointerButton button);
 
     bool tryChangeState();
     bool hitTest(Vec2D position) const;
@@ -530,11 +544,13 @@
         m_component(component), m_stateMachineInstance(stateMachineInstance)
     {}
     virtual ~HitComponent() {}
-    virtual HitResult processEvent(Vec2D position,
-                                   ListenerType hitType,
-                                   bool canHit,
-                                   float timeStamp = 0,
-                                   int pointerId = 0) = 0;
+    virtual HitResult processEvent(
+        Vec2D position,
+        ListenerType hitType,
+        bool canHit,
+        float timeStamp = 0,
+        int pointerId = 0,
+        PointerButton button = PointerButton::primary) = 0;
     virtual HitResult processGamepadInvocation(
         const ListenerInvocation& invocation,
         ScriptedDrawable* alreadyDispatched) = 0;
@@ -565,6 +581,14 @@
     /// Whether an opaque hit that cannot scroll sits under position. Ends
     /// the walk: nothing behind it may take the event, as with hitOpaque.
     virtual bool occludesScroll(Vec2D position) { return false; }
+    /// Whether a listener under position responds to presses of button.
+    virtual bool listensToButtonAt(Vec2D position, PointerButton button)
+    {
+        return false;
+    }
+    /// Whether an opaque hit sits under position, blocking presses from
+    /// reaching whatever lies behind it, as hitOpaque does in processEvent.
+    virtual bool occludesPointer(Vec2D position) { return false; }
     virtual HitResult processScroll(Vec2D position,
                                     const ScrollEvent& event,
                                     float timeStamp)
diff --git a/include/rive/animation/state_machine_listener.hpp b/include/rive/animation/state_machine_listener.hpp
index 7123bc7..86820e9 100644
--- a/include/rive/animation/state_machine_listener.hpp
+++ b/include/rive/animation/state_machine_listener.hpp
@@ -3,6 +3,7 @@
 #include "rive/animation/listener_invocation.hpp"
 #include "rive/generated/animation/state_machine_listener_base.hpp"
 #include "rive/listener_type.hpp"
+#include "rive/pointer_button.hpp"
 #include "rive/span.hpp"
 #include "rive/animation/listener_types/listener_input_type.hpp"
 
@@ -25,6 +26,9 @@
     //     return (ListenerType)listenerTypeValue();
     // }
     virtual bool hasListener(ListenerType) const;
+    virtual bool hasListener(ListenerType, PointerButton) const;
+    // True if any pointer press listener type is bound to button.
+    virtual bool listensToButton(PointerButton) const;
     bool hasListeners(Span<const ListenerType> listenerTypes) const;
     // True if any listener type hit-tests a pointer against the target.
     bool hasPointerListeners() const;
@@ -76,6 +80,20 @@
     }
 #endif
 
+#ifdef TESTING
+    void replaceListenerInputTypeForTesting(
+        size_t index,
+        std::unique_ptr<ListenerInputType> inputType)
+    {
+        m_listenerInputTypes[index] = std::move(inputType);
+    }
+    void addListenerInputTypeForTesting(
+        std::unique_ptr<ListenerInputType> inputType)
+    {
+        m_listenerInputTypes.push_back(std::move(inputType));
+    }
+#endif
+
 private:
     void addAction(std::unique_ptr<ListenerAction>);
     void addListenerInputType(std::unique_ptr<ListenerInputType>);
diff --git a/include/rive/animation/state_machine_listener_single.hpp b/include/rive/animation/state_machine_listener_single.hpp
index f972e02..8cc1a1e 100644
--- a/include/rive/animation/state_machine_listener_single.hpp
+++ b/include/rive/animation/state_machine_listener_single.hpp
@@ -13,6 +13,15 @@
     {
         return listenerTypeValue() == (int)listenerType;
     }
+    bool hasListener(ListenerType listenerType,
+                     PointerButton button) const override
+    {
+        return button == PointerButton::primary && hasListener(listenerType);
+    }
+    bool listensToButton(PointerButton button) const override
+    {
+        return button == PointerButton::primary && hasPointerListeners();
+    }
     void decodeViewModelPathIds(Span<const uint8_t> value) override;
     void copyViewModelPathIds(
         const StateMachineListenerSingleBase& object) override;
diff --git a/include/rive/animation/text_input_listener_group.hpp b/include/rive/animation/text_input_listener_group.hpp
index 6a89c8d..cfa73b0 100644
--- a/include/rive/animation/text_input_listener_group.hpp
+++ b/include/rive/animation/text_input_listener_group.hpp
@@ -27,6 +27,7 @@
         Vec2D position,
         int pointerId,
         ListenerType hitEvent,
+        PointerButton button,
         bool canHit,
         float timeStamp,
         StateMachineInstance* stateMachineInstance) override;
diff --git a/include/rive/command_queue.hpp b/include/rive/command_queue.hpp
index 73d84d1..96e4784 100644
--- a/include/rive/command_queue.hpp
+++ b/include/rive/command_queue.hpp
@@ -8,6 +8,7 @@
 #include "rive/input/focusable.hpp"
 #include "rive/refcnt.hpp"
 #include "rive/math/vec2d.hpp"
+#include "rive/pointer_button.hpp"
 #include "rive/viewmodel/runtime/viewmodel_runtime.hpp"
 #include "rive/animation/semantic_listener_group.hpp"
 #include "rive/semantic/semantic_snapshot.hpp"
@@ -968,6 +969,8 @@
         Vec2D position;      // the cursor position
         float scaleFactor = 1.0f; // scale factor for things like retina display
         int pointerId = 0;        // stable pointer identifier for multitouch
+        PointerButton button = PointerButton::primary; // mouse button for
+                                                       // down/up events
     };
 
     // All pointer events will automatically convert between artboard and screen
diff --git a/include/rive/constraints/draggable_constraint.hpp b/include/rive/constraints/draggable_constraint.hpp
index 6ce251f..54aab73 100644
--- a/include/rive/constraints/draggable_constraint.hpp
+++ b/include/rive/constraints/draggable_constraint.hpp
@@ -112,6 +112,7 @@
         Vec2D position,
         int pointerId,
         ListenerType hitEvent,
+        PointerButton button,
         bool canHit,
         float timeStamp,
         StateMachineInstance* stateMachineInstance) override;
diff --git a/include/rive/generated/animation/listener_types/listener_input_type_pointer_button_base.hpp b/include/rive/generated/animation/listener_types/listener_input_type_pointer_button_base.hpp
new file mode 100644
index 0000000..aaab4c6
--- /dev/null
+++ b/include/rive/generated/animation/listener_types/listener_input_type_pointer_button_base.hpp
@@ -0,0 +1,78 @@
+#ifndef _RIVE_LISTENER_INPUT_TYPE_POINTER_BUTTON_BASE_HPP_
+#define _RIVE_LISTENER_INPUT_TYPE_POINTER_BUTTON_BASE_HPP_
+#include "rive/animation/listener_types/listener_input_type.hpp"
+#include "rive/core/field_types/core_uint_type.hpp"
+namespace rive
+{
+class ListenerInputTypePointerButtonBase : public ListenerInputType
+{
+protected:
+    typedef ListenerInputType Super;
+
+public:
+    static const uint16_t typeKey = 155;
+
+    /// Helper to quickly determine if a core object extends another without
+    /// RTTI at runtime.
+    bool isTypeOf(uint16_t typeKey) const override
+    {
+        switch (typeKey)
+        {
+            case ListenerInputTypePointerButtonBase::typeKey:
+            case ListenerInputTypeBase::typeKey:
+                return true;
+            default:
+                return false;
+        }
+    }
+
+    uint16_t coreType() const override { return typeKey; }
+
+    static const uint16_t pointerButtonValuePropertyKey = 468;
+
+protected:
+    uint8_t m_PointerButtonValue = 0;
+
+public:
+    inline uint8_t pointerButtonValue() const { return m_PointerButtonValue; }
+    void pointerButtonValue(uint8_t value)
+    {
+        if (m_PointerButtonValue == value)
+        {
+            return;
+        }
+        RIVE_EDITOR_CHANGING(pointerButtonValuePropertyKey,
+                             &m_PointerButtonValue,
+                             &value);
+        m_PointerButtonValue = value;
+        RIVE_EDITOR_CHANGED(pointerButtonValueChanged());
+        notifyPropertyChanged(pointerButtonValuePropertyKey);
+    }
+
+    Core* clone() const override;
+    void copy(const ListenerInputTypePointerButtonBase& object)
+    {
+        m_PointerButtonValue = object.m_PointerButtonValue;
+        ListenerInputType::copy(object);
+    }
+
+    bool deserialize(uint16_t propertyKey, BinaryReader& reader) override
+    {
+        switch (propertyKey)
+        {
+            case pointerButtonValuePropertyKey:
+                m_PointerButtonValue = CoreUintType::deserialize(reader);
+                return true;
+        }
+        return ListenerInputType::deserialize(propertyKey, reader);
+    }
+
+protected:
+    virtual void pointerButtonValueChanged() {}
+#ifdef WITH_RIVE_EDITOR
+#include "editor_native/generated/animation/listener_types/listener_input_type_pointer_button_ext.inl"
+#endif
+};
+} // namespace rive
+
+#endif
\ No newline at end of file
diff --git a/include/rive/generated/core_registry.hpp b/include/rive/generated/core_registry.hpp
index 89eb73b..be03434 100644
--- a/include/rive/generated/core_registry.hpp
+++ b/include/rive/generated/core_registry.hpp
@@ -50,6 +50,7 @@
 #include "rive/animation/listener_types/listener_input_type_event.hpp"
 #include "rive/animation/listener_types/listener_input_type_gamepad.hpp"
 #include "rive/animation/listener_types/listener_input_type_keyboard.hpp"
+#include "rive/animation/listener_types/listener_input_type_pointer_button.hpp"
 #include "rive/animation/listener_types/listener_input_type_semantic.hpp"
 #include "rive/animation/listener_types/listener_input_type_text.hpp"
 #include "rive/animation/listener_types/listener_input_type_viewmodel.hpp"
@@ -659,6 +660,8 @@
                 return new ListenerInputTypeGamepad();
             case ListenerInputTypeKeyboardBase::typeKey:
                 return new ListenerInputTypeKeyboard();
+            case ListenerInputTypePointerButtonBase::typeKey:
+                return new ListenerInputTypePointerButton();
             case ListenerInputTypeTextBase::typeKey:
                 return new ListenerInputTypeText();
             case ListenerInputTypeSemanticBase::typeKey:
@@ -1692,6 +1695,11 @@
             case ListenerInputTypeBase::listenerTypeValuePropertyKey:
                 object->as<ListenerInputTypeBase>()->listenerTypeValue(value);
                 break;
+            case ListenerInputTypePointerButtonBase::
+                pointerButtonValuePropertyKey:
+                object->as<ListenerInputTypePointerButtonBase>()
+                    ->pointerButtonValue(value);
+                break;
             case ShapePaintBase::blendModeValuePropertyKey:
                 object->as<ShapePaintBase>()->blendModeValue(value);
                 break;
@@ -3861,6 +3869,10 @@
                 return object->as<ElasticInterpolatorBase>()->easingValue();
             case ListenerInputTypeBase::listenerTypeValuePropertyKey:
                 return object->as<ListenerInputTypeBase>()->listenerTypeValue();
+            case ListenerInputTypePointerButtonBase::
+                pointerButtonValuePropertyKey:
+                return object->as<ListenerInputTypePointerButtonBase>()
+                    ->pointerButtonValue();
             case ShapePaintBase::blendModeValuePropertyKey:
                 return object->as<ShapePaintBase>()->blendModeValue();
             case ShapePaintBase::additiveAmountPropertyKey:
@@ -5219,6 +5231,8 @@
                 propertyKeyPropertyKey:
             case ElasticInterpolatorBase::easingValuePropertyKey:
             case ListenerInputTypeBase::listenerTypeValuePropertyKey:
+            case ListenerInputTypePointerButtonBase::
+                pointerButtonValuePropertyKey:
             case ShapePaintBase::blendModeValuePropertyKey:
             case ShapePaintBase::additiveAmountPropertyKey:
             case ColorChannelsBase::colorRedPropertyKey:
@@ -6157,6 +6171,9 @@
                 return object->is<ElasticInterpolatorBase>();
             case ListenerInputTypeBase::listenerTypeValuePropertyKey:
                 return object->is<ListenerInputTypeBase>();
+            case ListenerInputTypePointerButtonBase::
+                pointerButtonValuePropertyKey:
+                return object->is<ListenerInputTypePointerButtonBase>();
             case ShapePaintBase::blendModeValuePropertyKey:
                 return object->is<ShapePaintBase>();
             case ShapePaintBase::additiveAmountPropertyKey:
diff --git a/include/rive/listener_group.hpp b/include/rive/listener_group.hpp
index e91f300..f44d724 100644
--- a/include/rive/listener_group.hpp
+++ b/include/rive/listener_group.hpp
@@ -8,6 +8,7 @@
 #include "rive/gesture_click_phase.hpp"
 #include "rive/listener_type.hpp"
 #include "rive/math/vec2d.hpp"
+#include "rive/pointer_button.hpp"
 #include "rive/process_event_result.hpp"
 
 namespace rive
@@ -27,6 +28,7 @@
     // so a group-wide flag lets one pointer end another's drag.
     bool hasDragged = false;
     GestureClickPhase phase = GestureClickPhase::out;
+    PointerButton button = PointerButton::primary;
     Vec2D* previousPosition() { return &m_previousPosition; }
 
 private:
@@ -50,10 +52,10 @@
     /// once every group has been cancelled.
     virtual bool cancelPointer(int pointerId, Vec2D position, float timeStamp);
     /// Cancels every pointer this group is tracking, appending to dragEnded the
-    /// ids that still owe a dragEnd.
+    /// ids that still owe a dragEnd along with the button that dragged.
     void cancelPointers(Vec2D position,
                         float timeStamp,
-                        std::vector<int>& dragEnded);
+                        std::vector<std::pair<int, PointerButton>>& dragEnded);
     virtual void enable(int pointerId = 0);
     virtual void disable(int pointerId = 0);
     bool isConsumed() { return m_isConsumed; }
@@ -71,6 +73,7 @@
         Vec2D position,
         int pointerId,
         ListenerType hitEvent,
+        PointerButton button,
         bool canHit,
         float timeStamp,
         StateMachineInstance* stateMachineInstance);
diff --git a/include/rive/pointer_button.hpp b/include/rive/pointer_button.hpp
new file mode 100644
index 0000000..62922e0
--- /dev/null
+++ b/include/rive/pointer_button.hpp
@@ -0,0 +1,12 @@
+#ifndef _RIVE_POINTER_BUTTON_HPP_
+#define _RIVE_POINTER_BUTTON_HPP_
+namespace rive
+{
+enum class PointerButton : int
+{
+    primary = 0,
+    secondary = 1,
+    middle = 2,
+};
+}
+#endif
diff --git a/include/rive/scene.hpp b/include/rive/scene.hpp
index 0b96c40..885b6d5 100644
--- a/include/rive/scene.hpp
+++ b/include/rive/scene.hpp
@@ -7,6 +7,7 @@
 #include "rive/animation/keyed_callback_reporter.hpp"
 #include "rive/core/field_types/core_callback_type.hpp"
 #include "rive/hit_result.hpp"
+#include "rive/pointer_button.hpp"
 #include "rive/scroll_event.hpp"
 #include "rive/refcnt.hpp"
 #include <string>
@@ -53,11 +54,16 @@
     virtual void bindViewModelInstance(
         rcp<ViewModelInstance> viewModelInstance);
 
-    virtual HitResult pointerDown(Vec2D, int pointerId = 0);
+    virtual HitResult pointerDown(
+        Vec2D,
+        int pointerId = 0,
+        PointerButton button = PointerButton::primary);
     virtual HitResult pointerMove(Vec2D position,
                                   float timeStamp = 0,
                                   int pointerId = 0);
-    virtual HitResult pointerUp(Vec2D, int pointerId = 0);
+    virtual HitResult pointerUp(Vec2D,
+                                int pointerId = 0,
+                                PointerButton button = PointerButton::primary);
     virtual HitResult pointerExit(Vec2D, int pointerId = 0);
     /// Indirect scroll input -- a wheel, or a trackpad gesture the platform
     /// already classified as a scroll. Direct touch scrolling stays on the
diff --git a/include/rive/scripted/scripted_drawable.hpp b/include/rive/scripted/scripted_drawable.hpp
index f593c62..ab99757 100644
--- a/include/rive/scripted/scripted_drawable.hpp
+++ b/include/rive/scripted/scripted_drawable.hpp
@@ -105,7 +105,8 @@
                            ListenerType hitType,
                            bool canHit,
                            float timeStamp,
-                           int pointerId) override;
+                           int pointerId,
+                           PointerButton button) override;
     HitResult processGamepadInvocation(
         const ListenerInvocation& invocation,
         ScriptedDrawable* alreadyDispatched) override;
diff --git a/src/animation/listener_invocation.cpp b/src/animation/listener_invocation.cpp
index 129d221..c49a7fe 100644
--- a/src/animation/listener_invocation.cpp
+++ b/src/animation/listener_invocation.cpp
@@ -10,7 +10,8 @@
                                                Vec2D previousPosition,
                                                int pointerId,
                                                ListenerType hitEvent,
-                                               float timeStamp)
+                                               float timeStamp,
+                                               PointerButton button)
 {
     PointerInvocation p;
     p.position = position;
@@ -18,6 +19,7 @@
     p.pointerId = pointerId;
     p.hitEvent = hitEvent;
     p.timeStamp = timeStamp;
+    p.button = button;
     return ListenerInvocation(ListenerInvocationStorage(std::move(p)));
 }
 
diff --git a/src/animation/nested_state_machine.cpp b/src/animation/nested_state_machine.cpp
index b4dd5b8..efb1b38 100644
--- a/src/animation/nested_state_machine.cpp
+++ b/src/animation/nested_state_machine.cpp
@@ -93,20 +93,24 @@
     return HitResult::none;
 }
 
-HitResult NestedStateMachine::pointerDown(Vec2D position, int pointerId)
+HitResult NestedStateMachine::pointerDown(Vec2D position,
+                                          int pointerId,
+                                          PointerButton button)
 {
     if (m_StateMachineInstance != nullptr)
     {
-        return m_StateMachineInstance->pointerDown(position, pointerId);
+        return m_StateMachineInstance->pointerDown(position, pointerId, button);
     }
     return HitResult::none;
 }
 
-HitResult NestedStateMachine::pointerUp(Vec2D position, int pointerId)
+HitResult NestedStateMachine::pointerUp(Vec2D position,
+                                        int pointerId,
+                                        PointerButton button)
 {
     if (m_StateMachineInstance != nullptr)
     {
-        return m_StateMachineInstance->pointerUp(position, pointerId);
+        return m_StateMachineInstance->pointerUp(position, pointerId, button);
     }
     return HitResult::none;
 }
@@ -137,6 +141,12 @@
            m_StateMachineInstance->hasScrollTargetAt(position);
 }
 
+bool NestedStateMachine::listensToButtonAt(Vec2D position, PointerButton button)
+{
+    return m_StateMachineInstance != nullptr &&
+           m_StateMachineInstance->listensToButtonAt(position, button);
+}
+
 bool NestedStateMachine::scrollOccludedAt(Vec2D position)
 {
     return m_StateMachineInstance != nullptr &&
@@ -168,25 +178,31 @@
 
 HitResult NestedStateMachine::dragStart(Vec2D position,
                                         float timeStamp,
-                                        int pointerId)
+                                        int pointerId,
+                                        PointerButton button)
 {
     if (m_StateMachineInstance != nullptr)
     {
         return m_StateMachineInstance->dragStart(position,
                                                  timeStamp,
                                                  true,
-                                                 pointerId);
+                                                 pointerId,
+                                                 button);
     }
     return HitResult::none;
 }
 
 HitResult NestedStateMachine::dragEnd(Vec2D position,
                                       float timeStamp,
-                                      int pointerId)
+                                      int pointerId,
+                                      PointerButton button)
 {
     if (m_StateMachineInstance != nullptr)
     {
-        return m_StateMachineInstance->dragEnd(position, timeStamp, pointerId);
+        return m_StateMachineInstance->dragEnd(position,
+                                               timeStamp,
+                                               pointerId,
+                                               button);
     }
     return HitResult::none;
 }
diff --git a/src/animation/state_machine_instance.cpp b/src/animation/state_machine_instance.cpp
index a81c5c4..0e53403 100644
--- a/src/animation/state_machine_instance.cpp
+++ b/src/animation/state_machine_instance.cpp
@@ -943,6 +943,28 @@
         return false;
     }
 
+    bool listensToButtonAt(Vec2D position, PointerButton button) override
+    {
+        if (!hitTest(position))
+        {
+            return false;
+        }
+        for (auto listenerGroup : listeners)
+        {
+            auto listener = listenerGroup->listener();
+            if (listener != nullptr && listener->listensToButton(button))
+            {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    bool occludesPointer(Vec2D position) override
+    {
+        return (isOpaque || m_drawable->isTargetOpaque()) && hitTest(position);
+    }
+
     bool occludesScroll(Vec2D position) override
     {
         // A scroll view never occludes: at its edge it declines and the
@@ -1032,7 +1054,8 @@
                            ListenerType hitType,
                            bool canHit,
                            float timeStamp,
-                           int pointerId) override
+                           int pointerId,
+                           PointerButton button) override
     {
         // If the shape doesn't have any ListenerType::move / enter / exit and
         // the event being processed is not of the type it needs to handle.
@@ -1056,6 +1079,7 @@
                                             position,
                                             pointerId,
                                             hitType,
+                                            button,
                                             canHit,
                                             timeStamp,
                                             m_stateMachineInstance) ==
@@ -1294,6 +1318,32 @@
         return false;
     }
 
+    bool listensToButtonAt(Vec2D position, PointerButton button) override
+    {
+        auto nestedArtboard = m_component->as<NestedArtboard>();
+        if (nestedArtboard->isCollapsed() || nestedArtboard->isHidden() ||
+            nestedArtboard->isPaused())
+        {
+            return false;
+        }
+        Vec2D nestedPosition;
+        if (!nestedArtboard->worldToLocal(position, &nestedPosition))
+        {
+            return false;
+        }
+        for (auto nestedAnimation : nestedArtboard->nestedAnimations())
+        {
+            if (nestedAnimation->is<NestedStateMachine>() &&
+                nestedAnimation->as<NestedStateMachine>()->listensToButtonAt(
+                    nestedPosition,
+                    button))
+            {
+                return true;
+            }
+        }
+        return false;
+    }
+
     bool occludesScroll(Vec2D position) override
     {
         auto nestedArtboard = m_component->as<NestedArtboard>();
@@ -1419,7 +1469,8 @@
                            ListenerType hitType,
                            bool canHit,
                            float timeStamp,
-                           int pointerId) override
+                           int pointerId,
+                           PointerButton button) override
     {
         auto nestedArtboard = m_component->as<NestedArtboard>();
         HitResult hitResult = HitResult::none;
@@ -1448,12 +1499,14 @@
                         case ListenerType::down:
                             hitResult =
                                 nestedStateMachine->pointerDown(nestedPosition,
-                                                                pointerId);
+                                                                pointerId,
+                                                                button);
                             break;
                         case ListenerType::up:
                             hitResult =
                                 nestedStateMachine->pointerUp(nestedPosition,
-                                                              pointerId);
+                                                              pointerId,
+                                                              button);
                             break;
                         case ListenerType::move:
                             hitResult =
@@ -1464,12 +1517,14 @@
                         case ListenerType::dragStart:
                             nestedStateMachine->dragStart(nestedPosition,
                                                           timeStamp,
-                                                          pointerId);
+                                                          pointerId,
+                                                          button);
                             break;
                         case ListenerType::dragEnd:
                             nestedStateMachine->dragEnd(nestedPosition,
                                                         timeStamp,
-                                                        pointerId);
+                                                        pointerId,
+                                                        button);
                             break;
                         case ListenerType::exit:
                             hitResult =
@@ -1685,6 +1740,32 @@
         return false;
     }
 
+    bool listensToButtonAt(Vec2D position, PointerButton button) override
+    {
+        auto componentList = m_component->as<ArtboardComponentList>();
+        if (componentList->isCollapsed() || componentList->isHidden())
+        {
+            return false;
+        }
+        const auto& order = componentList->orderedListIndices();
+        for (auto it = order.rbegin(); it != order.rend(); ++it)
+        {
+            const int i = *it;
+            Vec2D listPosition;
+            if (!componentList->worldToLocal(position, &listPosition, i))
+            {
+                continue;
+            }
+            auto stateMachine = componentList->stateMachineInstance(i);
+            if (stateMachine != nullptr &&
+                stateMachine->listensToButtonAt(listPosition, button))
+            {
+                return true;
+            }
+        }
+        return false;
+    }
+
     bool occludesScroll(Vec2D position) override
     {
         auto componentList = m_component->as<ArtboardComponentList>();
@@ -1810,7 +1891,8 @@
                            ListenerType hitType,
                            bool canHit,
                            float timeStamp,
-                           int pointerId) override
+                           int pointerId,
+                           PointerButton button) override
     {
         auto componentList = m_component->as<ArtboardComponentList>();
         HitResult hitResult = HitResult::none;
@@ -1840,12 +1922,14 @@
                         case ListenerType::down:
                             itemHitResult =
                                 stateMachine->pointerDown(listPosition,
-                                                          pointerId);
+                                                          pointerId,
+                                                          button);
                             break;
                         case ListenerType::up:
                             itemHitResult =
                                 stateMachine->pointerUp(listPosition,
-                                                        pointerId);
+                                                        pointerId,
+                                                        button);
                             break;
                         case ListenerType::move:
                             itemHitResult =
@@ -1862,10 +1946,14 @@
                             stateMachine->dragStart(listPosition,
                                                     0,
                                                     true,
-                                                    pointerId);
+                                                    pointerId,
+                                                    button);
                             break;
                         case ListenerType::dragEnd:
-                            stateMachine->dragEnd(listPosition, 0, pointerId);
+                            stateMachine->dragEnd(listPosition,
+                                                  0,
+                                                  pointerId,
+                                                  button);
                             break;
                         case ListenerType::enter:
                         case ListenerType::event:
@@ -2207,7 +2295,8 @@
 HitResult StateMachineInstance::updateListeners(Vec2D position,
                                                 ListenerType hitType,
                                                 int pointerId,
-                                                float timeStamp)
+                                                float timeStamp,
+                                                PointerButton button)
 {
     // Listener hit-testing runs in content space, so undo the frame origin and
     // the artboard's own rotation/scale first.
@@ -2226,7 +2315,7 @@
     }
     // Drag ends owed by cancellation, dispatched once the pass below has had a
     // chance to emit its hover exits.
-    std::vector<int> dragEnded;
+    std::vector<std::pair<int, PointerButton>> dragEnded;
     if (contentsCollapsed)
     {
         // canHit alone won't do this: it marks a target as occluded, and an
@@ -2266,7 +2355,8 @@
                                    hitType,
                                    !hitOpaque && !contentsCollapsed,
                                    timeStamp,
-                                   pointerId);
+                                   pointerId,
+                                   button);
         if (hitResult != HitResult::none)
         {
             hitSomething = true;
@@ -2277,9 +2367,9 @@
         }
     }
     // Hover exits have been emitted, so it's safe to let dragEnd re-enter now.
-    for (auto endedPointerId : dragEnded)
+    for (auto& ended : dragEnded)
     {
-        dragEnd(position, timeStamp, endedPointerId);
+        dragEnd(position, timeStamp, ended.first, ended.second);
     }
     // Finally release events that are complete
     if (hitType == ListenerType::exit)
@@ -2346,13 +2436,17 @@
 {
     return updateListeners(position, ListenerType::move, id, timeStamp);
 }
-HitResult StateMachineInstance::pointerDown(Vec2D position, int id)
+HitResult StateMachineInstance::pointerDown(Vec2D position,
+                                            int id,
+                                            PointerButton button)
 {
-    return updateListeners(position, ListenerType::down, id);
+    return updateListeners(position, ListenerType::down, id, 0, button);
 }
-HitResult StateMachineInstance::pointerUp(Vec2D position, int id)
+HitResult StateMachineInstance::pointerUp(Vec2D position,
+                                          int id,
+                                          PointerButton button)
 {
-    return updateListeners(position, ListenerType::up, id);
+    return updateListeners(position, ListenerType::up, id, 0, button);
 }
 HitResult StateMachineInstance::pointerExit(Vec2D position, int id)
 {
@@ -2361,21 +2455,28 @@
 HitResult StateMachineInstance::dragStart(Vec2D position,
                                           float timeStamp,
                                           bool disablePointer,
-                                          int pointerId)
+                                          int pointerId,
+                                          PointerButton button)
 {
     if (disablePointer)
     {
         disablePointerEvents(pointerId);
     }
-    auto hit = updateListeners(position, ListenerType::dragStart, pointerId);
+    auto hit = updateListeners(position,
+                               ListenerType::dragStart,
+                               pointerId,
+                               0,
+                               button);
     return hit;
 }
 HitResult StateMachineInstance::dragEnd(Vec2D position,
                                         float timeStamp,
-                                        int pointerId)
+                                        int pointerId,
+                                        PointerButton button)
 {
     enablePointerEvents(pointerId);
-    auto hit = updateListeners(position, ListenerType::dragEnd, pointerId);
+    auto hit =
+        updateListeners(position, ListenerType::dragEnd, pointerId, 0, button);
     pointerMove(position, timeStamp, pointerId);
     return hit;
 }
@@ -2499,6 +2600,27 @@
     return false;
 }
 
+bool StateMachineInstance::listensToButtonAt(Vec2D position,
+                                             PointerButton button)
+{
+    if (!mapToContentSpace(position))
+    {
+        return false;
+    }
+    for (const auto& hitShape : m_hitComponents)
+    {
+        if (hitShape->listensToButtonAt(position, button))
+        {
+            return true;
+        }
+        if (hitShape->occludesPointer(position))
+        {
+            return false;
+        }
+    }
+    return false;
+}
+
 bool StateMachineInstance::scrollOccludedAt(Vec2D position)
 {
     if (!mapToContentSpace(position))
diff --git a/src/animation/state_machine_listener.cpp b/src/animation/state_machine_listener.cpp
index 5fddcdc..23a3622 100644
--- a/src/animation/state_machine_listener.cpp
+++ b/src/animation/state_machine_listener.cpp
@@ -52,6 +52,77 @@
     return false;
 }
 
+static bool listenerTypeHasButton(ListenerType listenerType)
+{
+    switch (listenerType)
+    {
+        case ListenerType::down:
+        case ListenerType::up:
+        case ListenerType::click:
+        case ListenerType::drag:
+        case ListenerType::dragStart:
+        case ListenerType::dragEnd:
+            return true;
+        default:
+            return false;
+    }
+}
+
+bool StateMachineListener::hasListener(ListenerType listenerType,
+                                       PointerButton button) const
+{
+    if (!listenerTypeHasButton(listenerType))
+    {
+        return hasListener(listenerType);
+    }
+    for (auto& listenerInputType : m_listenerInputTypes)
+    {
+        if (listenerInputType->listenerTypeValue() == (int)listenerType &&
+            listenerInputType->pointerButton() == button)
+        {
+            return true;
+        }
+    }
+#ifdef WITH_RIVE_EDITOR
+    for (auto* listenerInputType : m_editorListenerInputTypes)
+    {
+        if (listenerInputType != nullptr &&
+            listenerInputType->listenerTypeValue() == (int)listenerType &&
+            listenerInputType->pointerButton() == button)
+        {
+            return true;
+        }
+    }
+#endif
+    return false;
+}
+
+bool StateMachineListener::listensToButton(PointerButton button) const
+{
+    for (auto& listenerInputType : m_listenerInputTypes)
+    {
+        if (listenerTypeHasButton(
+                (ListenerType)listenerInputType->listenerTypeValue()) &&
+            listenerInputType->pointerButton() == button)
+        {
+            return true;
+        }
+    }
+#ifdef WITH_RIVE_EDITOR
+    for (auto* listenerInputType : m_editorListenerInputTypes)
+    {
+        if (listenerInputType != nullptr &&
+            listenerTypeHasButton(
+                (ListenerType)listenerInputType->listenerTypeValue()) &&
+            listenerInputType->pointerButton() == button)
+        {
+            return true;
+        }
+    }
+#endif
+    return false;
+}
+
 bool StateMachineListener::hasListeners(
     Span<const ListenerType> listenerTypes) const
 {
diff --git a/src/animation/text_input_listener_group.cpp b/src/animation/text_input_listener_group.cpp
index a8cc8e7..9f1876e 100644
--- a/src/animation/text_input_listener_group.cpp
+++ b/src/animation/text_input_listener_group.cpp
@@ -50,10 +50,16 @@
     Vec2D position,
     int pointerId,
     ListenerType hitEvent,
+    PointerButton button,
     bool canHit,
     float timeStamp,
     StateMachineInstance* stateMachineInstance)
 {
+    if ((hitEvent == ListenerType::down || hitEvent == ListenerType::up) &&
+        button != PointerButton::primary)
+    {
+        return ProcessEventResult::none;
+    }
     // We implement our own click phase tracking instead of calling base class
     // because base class processEvent accesses m_listener which is nullptr
     auto* pData = pointerData(pointerId);
diff --git a/src/command_server.cpp b/src/command_server.cpp
index 8284eea..169f65c 100644
--- a/src/command_server.cpp
+++ b/src/command_server.cpp
@@ -4280,7 +4280,8 @@
                         stateMachineWrapper->m_mutex);
                     auto hitResult = stateMachineWrapper->instance->pointerDown(
                         position,
-                        pointerEvent.pointerId);
+                        pointerEvent.pointerId,
+                        pointerEvent.button);
                     // Process down-driven state changes before a following
                     // pointer up.
                     if (hitResult != HitResult::none)
@@ -4319,7 +4320,8 @@
                         stateMachineWrapper->m_mutex);
                     auto hitResult = stateMachineWrapper->instance->pointerUp(
                         position,
-                        pointerEvent.pointerId);
+                        pointerEvent.pointerId,
+                        pointerEvent.button);
                     // Process up-driven state changes before any following
                     // pointer exit.
                     if (hitResult != HitResult::none)
diff --git a/src/constraints/draggable_constraint.cpp b/src/constraints/draggable_constraint.cpp
index 8142df3..21b7e1b 100644
--- a/src/constraints/draggable_constraint.cpp
+++ b/src/constraints/draggable_constraint.cpp
@@ -57,16 +57,23 @@
     Vec2D position,
     int pointerId,
     ListenerType hitEvent,
+    PointerButton button,
     bool canHit,
     float timeStamp,
     StateMachineInstance* stateMachineInstance)
 {
+    if ((hitEvent == ListenerType::down || hitEvent == ListenerType::up) &&
+        button != PointerButton::primary)
+    {
+        return ProcessEventResult::none;
+    }
     auto pointer = pointerData(pointerId);
     auto prevPhase = pointer->phase;
     ListenerGroup::processEvent(component,
                                 position,
                                 pointerId,
                                 hitEvent,
+                                button,
                                 canHit,
                                 timeStamp,
                                 stateMachineInstance);
diff --git a/src/generated/animation/listener_types/listener_input_type_pointer_button_base.cpp b/src/generated/animation/listener_types/listener_input_type_pointer_button_base.cpp
new file mode 100644
index 0000000..81a3d88
--- /dev/null
+++ b/src/generated/animation/listener_types/listener_input_type_pointer_button_base.cpp
@@ -0,0 +1,11 @@
+#include "rive/generated/animation/listener_types/listener_input_type_pointer_button_base.hpp"
+#include "rive/animation/listener_types/listener_input_type_pointer_button.hpp"
+
+using namespace rive;
+
+Core* ListenerInputTypePointerButtonBase::clone() const
+{
+    auto cloned = new ListenerInputTypePointerButton();
+    cloned->copy(*this);
+    return cloned;
+}
diff --git a/src/listener_group.cpp b/src/listener_group.cpp
index 7b26b9d..6daa25e 100644
--- a/src/listener_group.cpp
+++ b/src/listener_group.cpp
@@ -97,20 +97,22 @@
     return wasDragging;
 }
 
-void ListenerGroup::cancelPointers(Vec2D position,
-                                   float timeStamp,
-                                   std::vector<int>& dragEnded)
+void ListenerGroup::cancelPointers(
+    Vec2D position,
+    float timeStamp,
+    std::vector<std::pair<int, PointerButton>>& dragEnded)
 {
     for (auto& entry : m_pointers)
     {
+        auto button = entry.second->button;
         if (!cancelPointer(entry.first, position, timeStamp))
         {
             continue;
         }
         bool alreadyEnded = false;
-        for (auto id : dragEnded)
+        for (auto& ended : dragEnded)
         {
-            if (id == entry.first)
+            if (ended.first == entry.first)
             {
                 alreadyEnded = true;
                 break;
@@ -118,7 +120,7 @@
         }
         if (!alreadyEnded)
         {
-            dragEnded.push_back(entry.first);
+            dragEnded.emplace_back(entry.first, button);
         }
     }
 }
@@ -182,6 +184,7 @@
     Vec2D position,
     int pointerId,
     ListenerType hitEvent,
+    PointerButton button,
     bool canHit,
     float timeStamp,
     StateMachineInstance* stateMachineInstance)
@@ -219,17 +222,24 @@
     {
         if (hitEvent == ListenerType::down)
         {
-            pointer->phase = GestureClickPhase::down;
+            if (pointer->phase != GestureClickPhase::down)
+            {
+                pointer->phase = GestureClickPhase::down;
+                pointer->button = button;
+            }
         }
         else if (hitEvent == ListenerType::up &&
-                 pointer->phase == GestureClickPhase::down)
+                 pointer->phase == GestureClickPhase::down &&
+                 pointer->button == button)
         {
             pointer->phase = GestureClickPhase::clicked;
         }
     }
     else
     {
-        if (hitEvent == ListenerType::down || hitEvent == ListenerType::up)
+        if ((hitEvent == ListenerType::down || hitEvent == ListenerType::up) &&
+            (pointer->phase != GestureClickPhase::down ||
+             pointer->button == button))
         {
             pointer->phase = GestureClickPhase::out;
         }
@@ -239,12 +249,16 @@
          pointer->phase == GestureClickPhase::out) &&
         pointer->hasDragged)
     {
-        stateMachineInstance->dragEnd(position, timeStamp, pointerId);
+        stateMachineInstance->dragEnd(position,
+                                      timeStamp,
+                                      pointerId,
+                                      pointer->button);
         pointer->hasDragged = false;
     }
     auto _listener = listener();
     bool shouldPerformChanges = false;
     auto listenerTypeMatched = hitEvent;
+    auto buttonMatched = button;
     // Always update hover states regardless of which specific listener type
     // we're trying to trigger.
     // If hover has changed and:
@@ -269,12 +283,13 @@
     // - the event type matches the listener type and it is hovering the
     // group
     if (pointer->phase == GestureClickPhase::clicked &&
-        _listener->hasListener(ListenerType::click))
+        _listener->hasListener(ListenerType::click, pointer->button))
     {
         shouldPerformChanges = true;
         listenerTypeMatched = ListenerType::click;
+        buttonMatched = pointer->button;
     }
-    else if (isGroupHovered && _listener->hasListener(hitEvent))
+    else if (isGroupHovered && _listener->hasListener(hitEvent, button))
     {
         shouldPerformChanges = true;
     }
@@ -283,17 +298,19 @@
     // - the clickPhase is down
     // - the pointer type is move
     if (pointer->phase == GestureClickPhase::down &&
-        _listener->hasListener(ListenerType::drag) &&
+        _listener->hasListener(ListenerType::drag, pointer->button) &&
         hitEvent == ListenerType::move)
     {
         shouldPerformChanges = true;
         listenerTypeMatched = ListenerType::drag;
+        buttonMatched = pointer->button;
         if (!pointer->hasDragged)
         {
             stateMachineInstance->dragStart(position,
                                             timeStamp,
                                             false,
-                                            pointerId);
+                                            pointerId,
+                                            pointer->button);
             pointer->hasDragged = true;
         }
     }
@@ -306,7 +323,8 @@
                 Vec2D(previousPosition->x, previousPosition->y),
                 pointerId,
                 listenerTypeMatched,
-                timeStamp));
+                timeStamp,
+                buttonMatched));
         stateMachineInstance->markNeedsAdvance();
         consume();
 
diff --git a/src/scene.cpp b/src/scene.cpp
index 6f664ca..385619d 100644
--- a/src/scene.cpp
+++ b/src/scene.cpp
@@ -15,12 +15,18 @@
 
 void Scene::draw(Renderer* renderer) { m_artboardInstance->draw(renderer); }
 
-HitResult Scene::pointerDown(Vec2D, int) { return HitResult::none; }
+HitResult Scene::pointerDown(Vec2D, int, PointerButton)
+{
+    return HitResult::none;
+}
 HitResult Scene::pointerMove(Vec2D, float timeStamp, int)
 {
     return HitResult::none;
 }
-HitResult Scene::pointerUp(Vec2D, int) { return HitResult::none; }
+HitResult Scene::pointerUp(Vec2D, int, PointerButton)
+{
+    return HitResult::none;
+}
 HitResult Scene::pointerExit(Vec2D, int) { return HitResult::none; }
 HitResult Scene::pointerScroll(Vec2D, const ScrollEvent&, float, int)
 {
diff --git a/src/scripted/scripted_drawable.cpp b/src/scripted/scripted_drawable.cpp
index a491259..df5171f 100644
--- a/src/scripted/scripted_drawable.cpp
+++ b/src/scripted/scripted_drawable.cpp
@@ -96,7 +96,8 @@
                                             ListenerType hitType,
                                             bool canHit,
                                             float timeStamp,
-                                            int pointerId)
+                                            int pointerId,
+                                            PointerButton button)
 {
     HitResult hitResult = HitResult::none;
     auto scriptAsset = m_drawable->scriptAsset();
@@ -196,7 +197,8 @@
                                             ListenerType hitType,
                                             bool canHit,
                                             float timeStamp,
-                                            int pointerId)
+                                            int pointerId,
+                                            PointerButton button)
 {
     return HitResult::none;
 }
diff --git a/tests/unit_tests/assets/pointer_button_nested.riv b/tests/unit_tests/assets/pointer_button_nested.riv
new file mode 100644
index 0000000..9f2fd57
--- /dev/null
+++ b/tests/unit_tests/assets/pointer_button_nested.riv
Binary files differ
diff --git a/tests/unit_tests/assets/pointer_button_secondary.riv b/tests/unit_tests/assets/pointer_button_secondary.riv
new file mode 100644
index 0000000..9446d58
--- /dev/null
+++ b/tests/unit_tests/assets/pointer_button_secondary.riv
Binary files differ
diff --git a/tests/unit_tests/runtime/command_queue_test.cpp b/tests/unit_tests/runtime/command_queue_test.cpp
index feedb12..73d7312 100644
--- a/tests/unit_tests/runtime/command_queue_test.cpp
+++ b/tests/unit_tests/runtime/command_queue_test.cpp
@@ -5714,6 +5714,43 @@
     serverThread.join();
 }
 
+TEST_CASE("pointer buttons", "[CommandQueue]")
+{
+    auto commandQueue = make_rcp<CommandQueue>();
+    std::thread serverThread(server_thread, commandQueue);
+
+    // A full-artboard shape with a secondary click listener that sets the
+    // "clicked" bool, exported by rive_core's pointer_button_export_test.dart.
+    std::ifstream stream("assets/pointer_button_secondary.riv",
+                         std::ios::binary);
+    FileHandle fileHandle = commandQueue->loadFile(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(stream), {}));
+    ArtboardHandle artboardHandle =
+        commandQueue->instantiateDefaultArtboard(fileHandle);
+    StateMachineHandle smHandle =
+        commandQueue->instantiateDefaultStateMachine(artboardHandle);
+    commandQueue->advanceStateMachine(smHandle, 0.0f);
+
+    Vec2D center(250.0f, 250.0f);
+    checkStateMachineBool(commandQueue, smHandle, "clicked", false);
+
+    // The default button is primary, which this listener ignores.
+    commandQueue->pointerDown(smHandle, {.position = center});
+    commandQueue->pointerUp(smHandle, {.position = center});
+    checkStateMachineBool(commandQueue, smHandle, "clicked", false);
+
+    commandQueue->pointerDown(
+        smHandle,
+        {.position = center, .button = PointerButton::secondary});
+    commandQueue->pointerUp(
+        smHandle,
+        {.position = center, .button = PointerButton::secondary});
+    checkStateMachineBool(commandQueue, smHandle, "clicked", true);
+
+    commandQueue->disconnect();
+    serverThread.join();
+}
+
 // Captures the view model update emitted by rapid_pointer_events.riv when its
 // state machine reaches the down-driven state.
 class RapidPointerViewModelListener
diff --git a/tests/unit_tests/runtime/pointer_button_test.cpp b/tests/unit_tests/runtime/pointer_button_test.cpp
new file mode 100644
index 0000000..978cc50
--- /dev/null
+++ b/tests/unit_tests/runtime/pointer_button_test.cpp
@@ -0,0 +1,546 @@
+#include <rive/animation/listener_types/listener_input_type.hpp>
+#include <rive/animation/listener_types/listener_input_type_pointer_button.hpp>
+#include <rive/animation/state_machine_instance.hpp>
+#include <rive/animation/state_machine_listener.hpp>
+#include <rive/animation/linear_animation_instance.hpp>
+#include <rive/animation/nested_state_machine.hpp>
+#include <rive/animation/state_machine_input_instance.hpp>
+#include <rive/constraints/scrolling/scroll_constraint.hpp>
+#include <rive/nested_artboard.hpp>
+#include <rive/pointer_button.hpp>
+#include <rive/text/text_input.hpp>
+#include <rive/viewmodel/viewmodel_instance_number.hpp>
+#include "rive_file_reader.hpp"
+
+#include <catch.hpp>
+
+using namespace rive;
+
+namespace
+{
+// click_event's "art-1" has two 200x200 rects under one group with a single
+// legacy (StateMachineListenerSingle) click listener that reports an event.
+struct ClickScene
+{
+    rcp<File> file;
+    std::unique_ptr<ArtboardInstance> artboard;
+    std::unique_ptr<StateMachineInstance> stateMachine;
+
+    ClickScene()
+    {
+        file = ReadRiveFile("assets/click_event.riv");
+        auto sourceArtboard = file->artboard("art-1");
+        auto machine = sourceArtboard->stateMachine("sm-1");
+        REQUIRE(machine != nullptr);
+        artboard = sourceArtboard->instance();
+        stateMachine =
+            std::make_unique<StateMachineInstance>(machine, artboard.get());
+        stateMachine->advance(0.0f);
+        artboard->advance(0.0f);
+        stateMachine->advance(0.0f);
+        REQUIRE(stateMachine->hitComponentsCount() == 2);
+        REQUIRE(stateMachine->reportedEventCount() == 0);
+    }
+
+    void down(PointerButton button, Vec2D position = Vec2D(75.0f, 75.0f))
+    {
+        stateMachine->pointerDown(position, 0, button);
+    }
+
+    void up(PointerButton button, Vec2D position = Vec2D(75.0f, 75.0f))
+    {
+        stateMachine->pointerUp(position, 0, button);
+    }
+
+    size_t events() const { return stateMachine->reportedEventCount(); }
+};
+
+// listener_input_values (see assets/rml/listener_input_values.rml) has a
+// full-artboard shape whose first listener is a ListenerInputType "down" that
+// writes the pointer position into the px/py view model numbers. Passing a
+// button swaps that input for a ListenerInputTypePointerButton bound to it.
+struct DownScene
+{
+    rcp<File> file;
+    std::unique_ptr<ArtboardInstance> artboard;
+    std::unique_ptr<StateMachineInstance> stateMachine;
+    rcp<ViewModelInstance> vmi;
+
+    using Input = std::pair<ListenerType, PointerButton>;
+
+    DownScene(std::vector<Input> inputs = {})
+    {
+        file = ReadRiveFile("assets/listener_input_values.riv");
+        auto machine = file->artboard()->stateMachine(0);
+        REQUIRE(machine != nullptr);
+        REQUIRE(machine->listenerCount() > 0);
+        auto listener = machine->listener(0);
+        REQUIRE(listener->listenerInputTypeCount() == 1);
+        auto inputType = listener->listenerInputType(0);
+        REQUIRE(inputType->listenerTypeValue() == (uint32_t)ListenerType::down);
+        REQUIRE(inputType->pointerButton() == PointerButton::primary);
+        for (size_t i = 0; i < inputs.size(); i++)
+        {
+            auto buttonInput =
+                std::make_unique<ListenerInputTypePointerButton>();
+            buttonInput->listenerTypeValue((uint32_t)inputs[i].first);
+            buttonInput->pointerButtonValue((uint8_t)inputs[i].second);
+            auto mutableListener = const_cast<StateMachineListener*>(listener);
+            if (i == 0)
+            {
+                mutableListener->replaceListenerInputTypeForTesting(
+                    0,
+                    std::move(buttonInput));
+            }
+            else
+            {
+                mutableListener->addListenerInputTypeForTesting(
+                    std::move(buttonInput));
+            }
+            REQUIRE(listener->listenerInputType(i)->pointerButton() ==
+                    inputs[i].second);
+        }
+
+        artboard = file->artboardDefault();
+        stateMachine = artboard->stateMachineAt(0);
+        vmi = file->createDefaultViewModelInstance(artboard.get());
+        REQUIRE(vmi != nullptr);
+        stateMachine->bindViewModelInstance(vmi);
+        advance();
+    }
+
+    void advance() { stateMachine->advanceAndApply(0.016f); }
+
+    void down(PointerButton button, Vec2D position)
+    {
+        stateMachine->pointerDown(position, 0, button);
+        advance();
+    }
+
+    void move(Vec2D position)
+    {
+        stateMachine->pointerMove(position);
+        advance();
+    }
+
+    void up(PointerButton button, Vec2D position)
+    {
+        stateMachine->pointerUp(position, 0, button);
+        advance();
+    }
+
+    float px()
+    {
+        auto value = vmi->propertyValue("px");
+        REQUIRE(value != nullptr);
+        return value->as<ViewModelInstanceNumber>()->propertyValue();
+    }
+};
+} // namespace
+
+TEST_CASE("plain listener input types are primary button listeners",
+          "[pointer_button]")
+{
+    auto file = ReadRiveFile("assets/listener_input_values.riv");
+    auto machine = file->artboard()->stateMachine(0);
+    auto listener = machine->listener(0);
+    REQUIRE(listener->listenerInputTypeCount() == 1);
+    REQUIRE(listener->listenerInputType(0)->pointerButton() ==
+            PointerButton::primary);
+    REQUIRE(listener->hasListener(ListenerType::down));
+    REQUIRE(listener->hasListener(ListenerType::down, PointerButton::primary));
+    REQUIRE_FALSE(
+        listener->hasListener(ListenerType::down, PointerButton::secondary));
+    REQUIRE_FALSE(
+        listener->hasListener(ListenerType::down, PointerButton::middle));
+}
+
+TEST_CASE("pointer button input types listen to their button only",
+          "[pointer_button]")
+{
+    ListenerInputTypePointerButton input;
+    input.listenerTypeValue((uint32_t)ListenerType::click);
+    REQUIRE(input.is<ListenerInputType>());
+    REQUIRE(input.pointerButton() == PointerButton::primary);
+    input.pointerButtonValue((uint8_t)PointerButton::secondary);
+    REQUIRE(input.pointerButton() == PointerButton::secondary);
+    input.pointerButtonValue((uint8_t)PointerButton::middle);
+    REQUIRE(input.pointerButton() == PointerButton::middle);
+}
+
+TEST_CASE("legacy single listeners only respond to the primary button",
+          "[pointer_button]")
+{
+    auto file = ReadRiveFile("assets/click_event.riv");
+    auto machine = file->artboard("art-1")->stateMachine("sm-1");
+    auto listener = machine->listener(0);
+    REQUIRE(listener->listenerInputTypeCount() == 0);
+    REQUIRE(listener->hasListener(ListenerType::click));
+    REQUIRE(listener->hasListener(ListenerType::click, PointerButton::primary));
+    REQUIRE_FALSE(
+        listener->hasListener(ListenerType::click, PointerButton::secondary));
+}
+
+TEST_CASE("primary down listener ignores other buttons", "[pointer_button]")
+{
+    DownScene scene;
+    REQUIRE(scene.px() == 0.0f);
+
+    scene.down(PointerButton::secondary, Vec2D(120.0f, 310.0f));
+    CHECK(scene.px() == 0.0f);
+
+    scene.down(PointerButton::middle, Vec2D(120.0f, 310.0f));
+    CHECK(scene.px() == 0.0f);
+
+    scene.down(PointerButton::primary, Vec2D(120.0f, 310.0f));
+    CHECK(scene.px() == 120.0f);
+}
+
+TEST_CASE("secondary down listener fires only on the secondary button",
+          "[pointer_button]")
+{
+    DownScene scene({{ListenerType::down, PointerButton::secondary}});
+    REQUIRE(scene.px() == 0.0f);
+
+    scene.down(PointerButton::primary, Vec2D(120.0f, 310.0f));
+    CHECK(scene.px() == 0.0f);
+
+    scene.down(PointerButton::middle, Vec2D(120.0f, 310.0f));
+    CHECK(scene.px() == 0.0f);
+
+    scene.down(PointerButton::secondary, Vec2D(120.0f, 310.0f));
+    CHECK(scene.px() == 120.0f);
+
+    scene.down(PointerButton::secondary, Vec2D(200.0f, 310.0f));
+    CHECK(scene.px() == 200.0f);
+}
+
+TEST_CASE("listensToButtonAt reports the buttons wanted under a point",
+          "[pointer_button]")
+{
+    Vec2D inside(120.0f, 310.0f);
+    Vec2D outside(-50.0f, -50.0f);
+
+    DownScene primary;
+    CHECK(primary.stateMachine->listensToButtonAt(inside,
+                                                  PointerButton::primary));
+    CHECK_FALSE(
+        primary.stateMachine->listensToButtonAt(inside,
+                                                PointerButton::secondary));
+    CHECK_FALSE(
+        primary.stateMachine->listensToButtonAt(outside,
+                                                PointerButton::primary));
+
+    DownScene scene({{ListenerType::down, PointerButton::secondary}});
+    CHECK(scene.stateMachine->listensToButtonAt(inside,
+                                                PointerButton::secondary));
+    CHECK_FALSE(
+        scene.stateMachine->listensToButtonAt(inside, PointerButton::primary));
+    CHECK_FALSE(
+        scene.stateMachine->listensToButtonAt(inside, PointerButton::middle));
+    CHECK_FALSE(
+        scene.stateMachine->listensToButtonAt(outside,
+                                              PointerButton::secondary));
+
+    ClickScene legacy;
+    CHECK(legacy.stateMachine->listensToButtonAt(Vec2D(75.0f, 75.0f),
+                                                 PointerButton::primary));
+    CHECK_FALSE(
+        legacy.stateMachine->listensToButtonAt(Vec2D(75.0f, 75.0f),
+                                               PointerButton::secondary));
+    CHECK_FALSE(legacy.stateMachine->listensToButtonAt(Vec2D(300.0f, 75.0f),
+                                                       PointerButton::primary));
+}
+
+TEST_CASE("synthetic drag events carry the dragging button", "[pointer_button]")
+{
+    Vec2D start(100.0f, 100.0f);
+    Vec2D dragged(150.0f, 150.0f);
+    Vec2D released(200.0f, 200.0f);
+
+    SECTION("a secondary drag end reaches a secondary drag end listener")
+    {
+        DownScene scene({{ListenerType::drag, PointerButton::secondary},
+                         {ListenerType::dragEnd, PointerButton::secondary}});
+        scene.down(PointerButton::secondary, start);
+        CHECK(scene.px() == 0.0f);
+        scene.move(dragged);
+        CHECK(scene.px() == 150.0f);
+        scene.up(PointerButton::secondary, released);
+        CHECK(scene.px() == 200.0f);
+    }
+
+    SECTION("a secondary drag end skips a primary drag end listener")
+    {
+        DownScene scene({{ListenerType::drag, PointerButton::secondary},
+                         {ListenerType::dragEnd, PointerButton::primary}});
+        scene.down(PointerButton::secondary, start);
+        scene.move(dragged);
+        CHECK(scene.px() == 150.0f);
+        scene.up(PointerButton::secondary, released);
+        CHECK(scene.px() == 150.0f);
+    }
+
+    SECTION("a secondary drag start reaches a secondary drag start listener")
+    {
+        DownScene scene({{ListenerType::drag, PointerButton::secondary},
+                         {ListenerType::dragStart, PointerButton::secondary}});
+        scene.down(PointerButton::secondary, start);
+        scene.move(dragged);
+        CHECK(scene.px() == 150.0f);
+        scene.up(PointerButton::secondary, released);
+        CHECK(scene.px() == 150.0f);
+    }
+
+    SECTION("a primary drag still drives primary drag listeners")
+    {
+        DownScene scene({{ListenerType::drag, PointerButton::primary},
+                         {ListenerType::dragEnd, PointerButton::primary}});
+        scene.down(PointerButton::primary, start);
+        scene.move(dragged);
+        CHECK(scene.px() == 150.0f);
+        scene.up(PointerButton::primary, released);
+        CHECK(scene.px() == 200.0f);
+    }
+
+    SECTION("a primary drag ignores secondary drag listeners")
+    {
+        DownScene scene({{ListenerType::drag, PointerButton::secondary},
+                         {ListenerType::dragEnd, PointerButton::secondary}});
+        scene.down(PointerButton::primary, start);
+        scene.move(dragged);
+        scene.up(PointerButton::primary, released);
+        CHECK(scene.px() == 0.0f);
+    }
+}
+
+TEST_CASE("primary click listener ignores other buttons", "[pointer_button]")
+{
+    ClickScene scene;
+
+    scene.down(PointerButton::primary);
+    scene.up(PointerButton::primary);
+    REQUIRE(scene.events() == 1);
+
+    scene.down(PointerButton::secondary);
+    scene.up(PointerButton::secondary);
+    REQUIRE(scene.events() == 1);
+
+    scene.down(PointerButton::middle);
+    scene.up(PointerButton::middle);
+    REQUIRE(scene.events() == 1);
+
+    scene.down(PointerButton::primary);
+    scene.up(PointerButton::primary);
+    REQUIRE(scene.events() == 2);
+}
+
+TEST_CASE("a click needs the same button down and up", "[pointer_button]")
+{
+    ClickScene scene;
+
+    scene.down(PointerButton::primary);
+    scene.up(PointerButton::secondary);
+    REQUIRE(scene.events() == 0);
+
+    scene.up(PointerButton::primary);
+    REQUIRE(scene.events() == 1);
+
+    scene.down(PointerButton::secondary);
+    scene.up(PointerButton::primary);
+    REQUIRE(scene.events() == 1);
+}
+
+TEST_CASE("a held press survives another button pressed over it",
+          "[pointer_button]")
+{
+    ClickScene scene;
+
+    scene.down(PointerButton::primary);
+    scene.down(PointerButton::secondary);
+    scene.up(PointerButton::secondary);
+    REQUIRE(scene.events() == 0);
+
+    scene.up(PointerButton::primary);
+    REQUIRE(scene.events() == 1);
+}
+
+TEST_CASE("releasing another button outside keeps the held press",
+          "[pointer_button]")
+{
+    ClickScene scene;
+
+    scene.down(PointerButton::primary);
+    scene.up(PointerButton::secondary, Vec2D(300.0f, 75.0f));
+    scene.up(PointerButton::primary);
+    REQUIRE(scene.events() == 1);
+
+    scene.down(PointerButton::primary);
+    scene.up(PointerButton::primary, Vec2D(300.0f, 75.0f));
+    scene.up(PointerButton::primary);
+    REQUIRE(scene.events() == 1);
+}
+
+// pointer_button_secondary.riv is exported by rive_core's
+// pointer_button_export_test.dart: a full-artboard shape with a secondary click
+// listener that reports "Boom" and sets the "clicked" bool input.
+TEST_CASE("an exported secondary click fixture loads as a pointer button input",
+          "[pointer_button]")
+{
+    auto file = ReadRiveFile("assets/pointer_button_secondary.riv");
+    auto machine = file->artboard()->stateMachine(0);
+    REQUIRE(machine != nullptr);
+    REQUIRE(machine->listenerCount() == 1);
+    auto listener = machine->listener(0);
+    REQUIRE(listener->listenerInputTypeCount() == 1);
+    auto input = listener->listenerInputType(0);
+    REQUIRE(input->is<ListenerInputTypePointerButton>());
+    REQUIRE(input->pointerButton() == PointerButton::secondary);
+    REQUIRE(input->listenerTypeValue() == (uint32_t)ListenerType::click);
+
+    auto artboard = file->artboardDefault();
+    auto stateMachine = artboard->stateMachineAt(0);
+    stateMachine->advanceAndApply(0.0f);
+    auto clicked = stateMachine->getBool("clicked");
+    REQUIRE(clicked != nullptr);
+    REQUIRE(clicked->value() == false);
+
+    Vec2D center(250.0f, 250.0f);
+    stateMachine->pointerDown(center, 0, PointerButton::primary);
+    stateMachine->pointerUp(center, 0, PointerButton::primary);
+    CHECK(stateMachine->reportedEventCount() == 0);
+    CHECK(clicked->value() == false);
+
+    stateMachine->pointerDown(center, 0, PointerButton::secondary);
+    stateMachine->pointerUp(center, 0, PointerButton::secondary);
+    CHECK(stateMachine->reportedEventCount() == 1);
+    CHECK(clicked->value() == true);
+}
+
+// pointer_button_nested.riv is exported by the same Dart test: a host artboard
+// holding "Inner" at (100, 100), whose full shape has a secondary click
+// listener ("Boom") and a secondary drag + dragEnd listener ("Drag").
+TEST_CASE("nested pointer button listeners dispatch through the host",
+          "[pointer_button]")
+{
+    auto file = ReadRiveFile("assets/pointer_button_nested.riv");
+    auto artboard = file->artboardDefault();
+    auto stateMachine = artboard->stateMachineAt(0);
+    stateMachine->advanceAndApply(0.0f);
+
+    auto nestedArtboards = artboard->find<NestedArtboard>();
+    REQUIRE(nestedArtboards.size() == 1);
+    auto nestedArtboard = nestedArtboards[0];
+    REQUIRE(nestedArtboard->nestedAnimations().size() == 1);
+    auto inner = nestedArtboard->nestedAnimations()[0]
+                     ->as<NestedStateMachine>()
+                     ->stateMachineInstance();
+    REQUIRE(inner != nullptr);
+
+    Vec2D overInner(200.0f, 200.0f);
+    Vec2D besideInner(20.0f, 20.0f);
+    CHECK(stateMachine->listensToButtonAt(overInner, PointerButton::secondary));
+    CHECK_FALSE(
+        stateMachine->listensToButtonAt(overInner, PointerButton::primary));
+    CHECK_FALSE(
+        stateMachine->listensToButtonAt(besideInner, PointerButton::secondary));
+
+    stateMachine->pointerDown(overInner, 0, PointerButton::primary);
+    stateMachine->pointerUp(overInner, 0, PointerButton::primary);
+    CHECK(inner->reportedEventCount() == 0);
+
+    stateMachine->pointerDown(overInner, 0, PointerButton::secondary);
+    stateMachine->pointerUp(overInner, 0, PointerButton::secondary);
+    CHECK(inner->reportedEventCount() == 1);
+    stateMachine->advanceAndApply(0.016f);
+
+    SECTION("a secondary drag reaches the nested drag listeners")
+    {
+        stateMachine->pointerDown(Vec2D(150.0f, 150.0f),
+                                  0,
+                                  PointerButton::secondary);
+        stateMachine->pointerMove(Vec2D(170.0f, 170.0f));
+        auto afterMove = inner->reportedEventCount();
+        CHECK(afterMove >= 1);
+        stateMachine->pointerUp(Vec2D(180.0f, 180.0f),
+                                0,
+                                PointerButton::secondary);
+        CHECK(inner->reportedEventCount() > afterMove);
+    }
+
+    SECTION("a primary drag leaves the nested secondary drag listeners alone")
+    {
+        stateMachine->pointerDown(Vec2D(150.0f, 150.0f),
+                                  0,
+                                  PointerButton::primary);
+        stateMachine->pointerMove(Vec2D(170.0f, 170.0f));
+        stateMachine->pointerUp(Vec2D(180.0f, 180.0f),
+                                0,
+                                PointerButton::primary);
+        CHECK(inner->reportedEventCount() == 0);
+    }
+}
+
+TEST_CASE("scroll views only drag with the primary button", "[pointer_button]")
+{
+    auto file = ReadRiveFile("assets/layout/layout_scroll_vertical.riv");
+    auto artboard = file->artboard();
+    auto artboardInstance = artboard->instance();
+    auto machine = artboard->stateMachine("State Machine 1");
+    REQUIRE(machine != nullptr);
+    StateMachineInstance smi(machine, artboardInstance.get());
+    auto scroll = artboardInstance->find<ScrollConstraint>()[0];
+    smi.advanceAndApply(0.0f);
+
+    Vec2D over(artboardInstance->width() / 2.0f,
+               artboardInstance->height() / 2.0f);
+    Vec2D dragged(over.x, over.y - 50.0f);
+
+    smi.pointerDown(over, 0, PointerButton::secondary);
+    smi.pointerMove(dragged);
+    smi.pointerUp(dragged, 0, PointerButton::secondary);
+    smi.advanceAndApply(0.0f);
+    CHECK(scroll->offsetY() == 0.0f);
+
+    smi.pointerDown(over, 0, PointerButton::primary);
+    smi.pointerMove(dragged);
+    smi.advanceAndApply(0.0f);
+    CHECK(scroll->offsetY() != 0.0f);
+    smi.pointerUp(dragged, 0, PointerButton::primary);
+}
+
+TEST_CASE("text inputs only select with the primary button", "[pointer_button]")
+{
+    auto file = ReadRiveFile("assets/text_input.riv");
+    auto artboard = file->artboardNamed("Text Input - Multiline");
+    REQUIRE(artboard != nullptr);
+    auto stateMachine = artboard->stateMachineAt(0);
+    REQUIRE(stateMachine != nullptr);
+    stateMachine->advanceAndApply(0.0f);
+    auto textInput = artboard->objects<TextInput>().first();
+    REQUIRE(textInput != nullptr);
+    textInput->rawTextInput()->text("hello world");
+    stateMachine->advanceAndApply(0.0f);
+
+    Vec2D press(8.0f, 8.0f);
+    stateMachine->pointerDown(press, 0, PointerButton::secondary);
+    CHECK(textInput->isDragging() == false);
+    stateMachine->pointerUp(press, 0, PointerButton::secondary);
+
+    stateMachine->pointerDown(press, 0, PointerButton::primary);
+    CHECK(textInput->isDragging() == true);
+    stateMachine->pointerUp(press, 0, PointerButton::primary);
+}
+
+TEST_CASE("scenes without listeners ignore pointer buttons", "[pointer_button]")
+{
+    auto file = ReadRiveFile("assets/listener_input_values.riv");
+    auto artboard = file->artboardDefault();
+    REQUIRE(artboard->animationCount() > 0);
+    auto animation = artboard->animationAt(0);
+    Scene* scene = animation.get();
+    CHECK(scene->pointerDown(Vec2D(1.0f, 1.0f), 0, PointerButton::secondary) ==
+          HitResult::none);
+    CHECK(scene->pointerUp(Vec2D(1.0f, 1.0f), 0, PointerButton::secondary) ==
+          HitResult::none);
+}
diff --git a/tests/unit_tests/runtime/scripting/scripting_drawable_pointer_type_test.cpp b/tests/unit_tests/runtime/scripting/scripting_drawable_pointer_type_test.cpp
index c8d3d69..56abdb1 100644
--- a/tests/unit_tests/runtime/scripting/scripting_drawable_pointer_type_test.cpp
+++ b/tests/unit_tests/runtime/scripting/scripting_drawable_pointer_type_test.cpp
@@ -115,24 +115,44 @@
 
     HitScriptedDrawable hit(&drawable, nullptr);
 
-    hit.processEvent(Vec2D(1.0f, 1.0f), ListenerType::down, true, 1.5f, 0);
+    hit.processEvent(Vec2D(1.0f, 1.0f),
+                     ListenerType::down,
+                     true,
+                     1.5f,
+                     0,
+                     PointerButton::primary);
     CHECK(readString(L, "getLastHandler") == "pointerDown");
     CHECK(readString(L, "getLastType") == "pointerDown");
     CHECK(readNumber(L, "getLastTimeStamp") == 1.5f);
 
-    hit.processEvent(Vec2D(2.0f, 1.0f), ListenerType::move, true, 2.25f, 0);
+    hit.processEvent(Vec2D(2.0f, 1.0f),
+                     ListenerType::move,
+                     true,
+                     2.25f,
+                     0,
+                     PointerButton::primary);
     CHECK(readString(L, "getLastHandler") == "pointerMove");
     CHECK(readString(L, "getLastType") == "pointerMove");
     CHECK(readNumber(L, "getLastTimeStamp") == 2.25f);
 
-    hit.processEvent(Vec2D(2.0f, 1.0f), ListenerType::up, true, 3.0f, 0);
+    hit.processEvent(Vec2D(2.0f, 1.0f),
+                     ListenerType::up,
+                     true,
+                     3.0f,
+                     0,
+                     PointerButton::primary);
     CHECK(readString(L, "getLastHandler") == "pointerUp");
     CHECK(readString(L, "getLastType") == "pointerUp");
     CHECK(readNumber(L, "getLastTimeStamp") == 3.0f);
 
     // An occluded target is dispatched to pointerExit whatever the incoming
     // event was, and the type follows the handler.
-    hit.processEvent(Vec2D(2.0f, 1.0f), ListenerType::move, false, 4.0f, 0);
+    hit.processEvent(Vec2D(2.0f, 1.0f),
+                     ListenerType::move,
+                     false,
+                     4.0f,
+                     0,
+                     PointerButton::primary);
     CHECK(readString(L, "getLastHandler") == "pointerExit");
     CHECK(readString(L, "getLastType") == "pointerExit");
     CHECK(readNumber(L, "getLastTimeStamp") == 4.0f);
diff --git a/tests/unit_tests/runtime/scripting/scripting_wake_advance_test.cpp b/tests/unit_tests/runtime/scripting/scripting_wake_advance_test.cpp
index dd76c90..e96dc8b 100644
--- a/tests/unit_tests/runtime/scripting/scripting_wake_advance_test.cpp
+++ b/tests/unit_tests/runtime/scripting/scripting_wake_advance_test.cpp
@@ -114,7 +114,12 @@
 
     // Dispatch a pointer event through the hit component.
     HitScriptedDrawable hit(&drawable, nullptr);
-    hit.processEvent(Vec2D(1.0f, 1.0f), ListenerType::down, true, 0.0f, 0);
+    hit.processEvent(Vec2D(1.0f, 1.0f),
+                     ListenerType::down,
+                     true,
+                     0.0f,
+                     0,
+                     PointerButton::primary);
     CHECK(readCounter(L, "getPointerDownCount") == 1);
 
     // The event re-armed the loop: the next frame advances the script again.