Introduce Drag-and-Scroll gesture handling

Enable users to scroll with two fingers while a physical button is
held down. This allows for navigating scroll-able content after
initiating a click.

The interpreter logic is updated to check for multiple fingers on the
touch-pad during a button press. It validates that these fingers
display a scrolling motion before recognizing the
action as a Scroll gesture instead of a standard Move. It uses the
two fastest moving fingers to validate whether a scroll gesture
was performed.

A user can lift all scrolling fingers to initiate a Fling, mirroring
normal scroll behavior. A user can also lift a single scrolling
finger to revert the gesture back to a standard drag.

A new `drag_scroll_enable` property is added to control this feature,
along with unit tests to cover the new interaction scenarios.

BUG=b:322199922
TEST=cros_sdk cros_workon_make --test --board=rex chromeos-base/gestures
TEST=touchtests
TEST=Manual testing

Change-Id: Id9a21fb8ce46b18c3e882ffdaffecc98bbf84e9b
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/platform/gestures/+/6795666
Tested-by: Sairah Amuthan <[email protected]>
Commit-Queue: Sairah Amuthan <[email protected]>
Reviewed-by: Harry Cutts <[email protected]>
Code-Coverage: Zoss <[email protected]>
Reviewed-by: Henry Barnor <[email protected]>
diff --git a/include/immediate_interpreter.h b/include/immediate_interpreter.h
index e8227a3..0a87285 100644
--- a/include/immediate_interpreter.h
+++ b/include/immediate_interpreter.h
@@ -366,9 +366,18 @@
   FRIEND_TEST(ImmediateInterpreterTest, WarpedFingersTappingTest);
   FRIEND_TEST(ImmediateInterpreterTest, ZeroClickInitializationTest);
   FRIEND_TEST(ImmediateInterpreterTtcEnableTest, TapToClickEnableTest);
+  FRIEND_TEST(DragScrollTest, DragScrollDisabledDefaultsToMove);
+  FRIEND_TEST(DragScrollTest, DragScrollEnabledProducesScroll);
+  FRIEND_TEST(DragScrollTest, DragScrollTransitionsToFlingOnLift);
+  FRIEND_TEST(DragScrollTest, DragScrollRevertsToMove);
+  FRIEND_TEST(DragScrollTest, DragScrollWithThreeMovingFingers);
+  FRIEND_TEST(DragScrollTest, DragScrollEnabledNormalDrag);
+  FRIEND_TEST(DragScrollTest, DragScrollTwoFingersOnly);
+
   friend class TapRecord;
   friend class TapToClickStateMachineTest;
   friend class FingerButtonClick;
+  friend class DragScrollTest;
 
  public:
   enum TapToClickState {
@@ -777,6 +786,8 @@
   BoolProperty tap_drag_enable_;
   // True if drag lock is enabled
   BoolProperty drag_lock_enable_;
+  // True if the Drag-and-Scroll gesture enabled
+  BoolProperty drag_scroll_enable_;
   // Time [s] the finger has to be stationary to be considered dragging
   DoubleProperty tap_drag_stationary_time_;
   // Distance [mm] a finger can move and still register a tap
diff --git a/src/immediate_interpreter.cc b/src/immediate_interpreter.cc
index aacba01..ee6e6ba 100644
--- a/src/immediate_interpreter.cc
+++ b/src/immediate_interpreter.cc
@@ -1023,6 +1023,7 @@
       tap_drag_timeout_(prop_reg, "Tap Drag Timeout", 0.3),
       tap_drag_enable_(prop_reg, "Tap Drag Enable", false),
       drag_lock_enable_(prop_reg, "Tap Drag Lock Enable", false),
+      drag_scroll_enable_(prop_reg, "Drag and Scroll Enable", false),
       tap_drag_stationary_time_(prop_reg, "Tap Drag Stationary Time", 0),
       tap_move_dist_(prop_reg, "Tap Move Distance", 2.0),
       tap_min_pressure_(prop_reg, "Tap Minimum Pressure", 25.0),
@@ -1774,6 +1775,39 @@
 
   // Physical button or tap overrides current gesture state
   if (sent_button_down_ || tap_to_click_state_ == kTtcDrag) {
+    if (!drag_scroll_enable_.val_) {
+      // Drag-and-Scroll feature is disabled, so force all interactions to
+      // be a Move gesture.
+      current_gesture_type_ = kGestureTypeMove;
+      return;
+    }
+
+    // A scroll/swipe was in progress, but fingers were lifted.
+    if (IsScrollOrSwipe(prev_gesture_type_) && num_gesturing < 2) {
+      current_gesture_type_ = kGestureTypeNull;
+      return;
+    }
+
+    if (num_gesturing >= 2) {
+      vector<short, kMaxGesturingFingers> sorted_ids;
+      SortFingersByProximity(gs_fingers, hwstate, &sorted_ids);
+
+      const FingerState* finger1 = hwstate.GetFingerState(sorted_ids[0]);
+      const FingerState* finger2 = hwstate.GetFingerState(sorted_ids[1]);
+
+      // Verify this pair is performing a valid scrolling gesture.
+      if (finger1 && finger2 &&
+        GetTwoFingerGestureType(*finger1, *finger2) == kGestureTypeScroll) {
+        current_gesture_type_ = kGestureTypeScroll;
+        // Set the two scrolling fingers as the 'active' ones for this gesture.
+        active_gs_fingers->clear();
+        active_gs_fingers->insert(finger1->tracking_id);
+        active_gs_fingers->insert(finger2->tracking_id);
+        return;
+      }
+    }
+
+    // Default case for a held button is a Move gesture.
     current_gesture_type_ = kGestureTypeMove;
     return;
   }
diff --git a/src/immediate_interpreter_unittest.cc b/src/immediate_interpreter_unittest.cc
index 3dc1922..1e44f0e 100644
--- a/src/immediate_interpreter_unittest.cc
+++ b/src/immediate_interpreter_unittest.cc
@@ -4239,7 +4239,7 @@
   };
   TestInterpreterWrapper wrapper(&ii, &hwprops);
 
-  // Test touchpad with intergrated button switch.
+  // Test touchpad with integrated button switch.
   EXPECT_EQ(0, ii.zero_finger_click_enable_.val_);
   // Test touchpad with separate buttons.
   hwprops.is_button_pad = 0;
@@ -4260,4 +4260,375 @@
   EXPECT_TRUE(point != point_ne1);
 }
 
+class DragScrollTest : public ::testing::Test {
+ protected:
+  void SetUp() override {
+    hwprops_ = {
+      .right = 1000,
+      .bottom = 1000,
+      .res_x = 50,
+      .res_y = 50,
+      .orientation_minimum = 0,
+      .orientation_maximum = 0,
+      .max_finger_cnt = 5,
+      .max_touch_cnt = 5,
+      .supports_t5r2 = false,
+      .support_semi_mt = false,
+      .is_button_pad = false,
+      .has_wheel = false,
+      .wheel_is_hi_res = false,
+      .is_haptic_pad = false,
+    };
+    button_finger_ = {0, 0, 0, 0, 50, 0, 500, 500, 1, 0};
+
+    ii_.reset(new ImmediateInterpreter(nullptr, nullptr));
+    wrapper_.reset(new TestInterpreterWrapper(ii_.get(), &hwprops_));
+  }
+
+  std::unique_ptr<ImmediateInterpreter> ii_;
+  std::unique_ptr<TestInterpreterWrapper> wrapper_;
+  HardwareProperties hwprops_;
+  FingerState button_finger_;
+};
+
+TEST_F(DragScrollTest, DragScrollDisabledDefaultsToMove) {
+  ii_->drag_scroll_enable_.val_ = false;
+
+  // Frame 1: Button down.
+  HardwareState curr_frame =
+    make_hwstate(0.1, GESTURES_BUTTON_LEFT, 1, 1, &button_finger_);
+  Gesture* gs = wrapper_->SyncInterpret(curr_frame, nullptr);
+  EXPECT_EQ(nullptr, gs);
+
+  // Frame 2: Button down timeout reached, button click registered.
+  curr_frame = make_hwstate(0.2, GESTURES_BUTTON_LEFT, 1, 1, &button_finger_);
+  gs = wrapper_->SyncInterpret(curr_frame, nullptr);
+  ASSERT_NE(nullptr, gs);
+  EXPECT_EQ(kGestureTypeButtonsChange, gs->type);
+
+  // Frame 3: Fingers added, no movement detected yet.
+  FingerState button_down_scroll_fingers_appear[] = {
+    button_finger_,
+    {0, 0, 0, 0, 50, 0, 450, 400, 2, 0},
+    {0, 0, 0, 0, 50, 0, 550, 400, 3, 0},
+  };
+  curr_frame = make_hwstate(0.3, GESTURES_BUTTON_LEFT, 3, 3,
+    button_down_scroll_fingers_appear);
+  gs = wrapper_->SyncInterpret(curr_frame, nullptr);
+  EXPECT_EQ(nullptr, gs);
+
+  // Frame 4: Fingers move in a scroll-like pattern.
+  // Since the flag is off, it should still be a Move gesture.
+  FingerState button_down_scrolling_1[] = {
+    button_finger_,
+    {0, 0, 0, 0, 50, 0, 442, 376, 2, 0},
+    {0, 0, 0, 0, 50, 0, 555, 383, 3, 0},
+  };
+  curr_frame = make_hwstate(0.4, GESTURES_BUTTON_LEFT,
+    3, 3, button_down_scrolling_1);
+  gs = wrapper_->SyncInterpret(curr_frame, nullptr);
+  ASSERT_NE(nullptr, gs);
+  EXPECT_EQ(kGestureTypeMove, gs->type);
+
+  // Frame 5: Fingers move in a scroll-like pattern.
+  // Since the flag is off, it should still be a Move gesture.
+  FingerState button_down_scrolling_2[] = {
+    button_finger_,
+    {0, 0, 0, 0, 50, 0, 448, 364, 2, 0},
+    {0, 0, 0, 0, 50, 0, 560, 357, 3, 0},
+  };
+  curr_frame = make_hwstate(0.5, GESTURES_BUTTON_LEFT, 3, 3,
+    button_down_scrolling_2);
+  gs = wrapper_->SyncInterpret(curr_frame, nullptr);
+  ASSERT_NE(nullptr, gs);
+  EXPECT_EQ(kGestureTypeMove, gs->type);
+}
+
+TEST_F(DragScrollTest, DragScrollEnabledProducesScroll) {
+  ii_->drag_scroll_enable_.val_ = true;
+
+  // Frame 1: Button down.
+  HardwareState curr_frame =
+    make_hwstate(0.1, GESTURES_BUTTON_LEFT, 1, 1, &button_finger_);
+  Gesture* gs = wrapper_->SyncInterpret(curr_frame, nullptr);
+  EXPECT_EQ(nullptr, gs);
+
+  // Frame 2: Button down timeout reached, button click registered.
+  curr_frame = make_hwstate(0.2, GESTURES_BUTTON_LEFT, 1, 1, &button_finger_);
+  gs = wrapper_->SyncInterpret(curr_frame, nullptr);
+  ASSERT_NE(nullptr, gs);
+  EXPECT_EQ(kGestureTypeButtonsChange, gs->type);
+
+  // Frame 3: Fingers added, no movement detected yet.
+  FingerState fingers_appear[] = {
+    button_finger_,
+    {0, 0, 0, 0, 50, 0, 450, 400, 2, 0},
+    {0, 0, 0, 0, 50, 0, 550, 400, 3, 0},
+  };
+  curr_frame = make_hwstate(0.3, GESTURES_BUTTON_LEFT, 3, 3, fingers_appear);
+  gs = wrapper_->SyncInterpret(curr_frame, nullptr);
+  EXPECT_EQ(nullptr, gs);
+
+  // Frame 4: Fingers move in a scroll-like pattern.
+  FingerState scrolling_fingers_1[] = {
+    button_finger_,
+    {0, 0, 0, 0, 50, 0, 442, 389, 2, 0},
+    {0, 0, 0, 0, 50, 0, 555, 380, 3, 0},
+  };
+  curr_frame =
+    make_hwstate(0.4, GESTURES_BUTTON_LEFT, 3, 3, scrolling_fingers_1);
+  gs = wrapper_->SyncInterpret(curr_frame, nullptr);
+  ASSERT_NE(nullptr, gs);
+  EXPECT_EQ(kGestureTypeScroll, gs->type);
+  EXPECT_EQ(0, gs->details.scroll.dx);
+  EXPECT_EQ(-20, gs->details.scroll.dy);
+
+  // Frame 5: Fingers move in a scroll-like pattern again.
+  FingerState scrolling_fingers_2[] = {
+    button_finger_,
+    {0, 0, 0, 0, 50, 0, 448, 364, 2, 0},
+    {0, 0, 0, 0, 50, 0, 560, 350, 3, 0},
+  };
+  curr_frame =
+    make_hwstate(0.5, GESTURES_BUTTON_LEFT, 3, 3, scrolling_fingers_2);
+  gs = wrapper_->SyncInterpret(curr_frame, nullptr);
+  ASSERT_NE(nullptr, gs);
+  EXPECT_EQ(kGestureTypeScroll, gs->type);
+  EXPECT_EQ(0, gs->details.scroll.dx);
+  EXPECT_EQ(-30, gs->details.scroll.dy);
+}
+
+
+TEST_F(DragScrollTest, DragScrollTransitionsToFlingOnLift) {
+  ii_->drag_scroll_enable_.val_ = true;
+
+  // Frame 1: Button down.
+  HardwareState curr_frame =
+    make_hwstate(0.1, GESTURES_BUTTON_LEFT, 1, 1, &button_finger_);
+  Gesture* gs = wrapper_->SyncInterpret(curr_frame, nullptr);
+  EXPECT_EQ(nullptr, gs);
+
+  // Frame 2: Button down timeout reached, button click registered.
+  curr_frame = make_hwstate(0.2, GESTURES_BUTTON_LEFT, 1, 1, &button_finger_);
+  gs = wrapper_->SyncInterpret(curr_frame, nullptr);
+  ASSERT_NE(nullptr, gs);
+  EXPECT_EQ(kGestureTypeButtonsChange, gs->type);
+
+  // Frame 3: Fingers are added.
+  FingerState fingers_appear[] = {
+    button_finger_,
+    {0, 0, 0, 0, 50, 0, 450, 400, 2, 0},
+    {0, 0, 0, 0, 50, 0, 550, 400, 3, 0},
+  };
+  curr_frame = make_hwstate(0.3, GESTURES_BUTTON_LEFT, 3, 3, fingers_appear);
+  gs = wrapper_->SyncInterpret(curr_frame, nullptr);
+  EXPECT_EQ(nullptr, gs);
+
+  // Frame 4: Scroll occurs.
+  FingerState scrolling_fingers[] = {
+    button_finger_,
+    {0, 0, 0, 0, 50, 0, 442, 391, 2, 0},
+    {0, 0, 0, 0, 50, 0, 555, 379, 3, 0},
+  };
+  curr_frame = make_hwstate(0.4, GESTURES_BUTTON_LEFT, 3, 3, scrolling_fingers);
+  gs = wrapper_->SyncInterpret(curr_frame, nullptr);
+  ASSERT_NE(nullptr, gs);
+  EXPECT_EQ(kGestureTypeScroll, gs->type);
+  EXPECT_EQ(0, gs->details.scroll.dx);
+  EXPECT_EQ(-21, gs->details.scroll.dy);
+
+  // Frame 5: Fingers 2 & 3 are lifted, which should generate a Fling.
+  curr_frame = make_hwstate(0.5, GESTURES_BUTTON_LEFT, 1, 1, &button_finger_);
+  gs = wrapper_->SyncInterpret(curr_frame, nullptr);
+  ASSERT_NE(nullptr, gs);
+  EXPECT_EQ(kGestureTypeFling, gs->type);
+}
+
+
+TEST_F(DragScrollTest, DragScrollRevertsToMove) {
+  ii_->drag_scroll_enable_.val_ = true;
+
+  // Frame 1: Button down.
+  HardwareState curr_frame =
+    make_hwstate(0.1, GESTURES_BUTTON_LEFT, 1, 1, &button_finger_);
+  Gesture* gs = wrapper_->SyncInterpret(curr_frame, nullptr);
+  EXPECT_EQ(nullptr, gs);
+
+  // Frame 2: Button down timeout reached, button click registered.
+  curr_frame = make_hwstate(0.2, GESTURES_BUTTON_LEFT, 1, 1, &button_finger_);
+  gs = wrapper_->SyncInterpret(curr_frame, nullptr);
+  ASSERT_NE(nullptr, gs);
+  EXPECT_EQ(kGestureTypeButtonsChange, gs->type);
+
+  // Frame 3: Fingers are added.
+  FingerState fingers_appear[] = {
+    button_finger_,
+    {0, 0, 0, 0, 50, 0, 450, 400, 2, 0},
+    {0, 0, 0, 0, 50, 0, 550, 400, 3, 0},
+  };
+  curr_frame = make_hwstate(0.3, GESTURES_BUTTON_LEFT, 3, 3, fingers_appear);
+  gs = wrapper_->SyncInterpret(curr_frame, nullptr);
+  EXPECT_EQ(nullptr, gs);
+
+  // Frame 4: Scroll occurs.
+  FingerState scrolling_fingers[] = {
+    button_finger_,
+    {0, 0, 0, 0, 50, 0, 442, 382, 2, 0},
+    {0, 0, 0, 0, 50, 0, 555, 375, 3, 0},
+  };
+  curr_frame = make_hwstate(0.4, GESTURES_BUTTON_LEFT, 3, 3, scrolling_fingers);
+  gs = wrapper_->SyncInterpret(curr_frame, nullptr);
+  ASSERT_NE(nullptr, gs);
+  EXPECT_EQ(kGestureTypeScroll, gs->type);
+  EXPECT_EQ(0, gs->details.scroll.dx);
+  EXPECT_EQ(-25, gs->details.scroll.dy);
+
+  // Frame 5: One scrolling finger (3) lifts. Scroll ends, fling gesture
+  // is created.
+  FingerState one_finger_lifts[] = {
+    button_finger_,
+    {0, 0, 0, 0, 50, 0, 444, 382, 2, 0},
+  };
+  curr_frame = make_hwstate(0.5, GESTURES_BUTTON_LEFT, 2, 2, one_finger_lifts);
+  gs = wrapper_->SyncInterpret(curr_frame, nullptr);
+  ASSERT_NE(nullptr, gs);
+  EXPECT_EQ(kGestureTypeFling, gs->type);
+
+  // Frame 6: With only one moving finger left, it continues as a Move.
+  FingerState remaining_finger_moves[] = {
+    button_finger_,
+    {0, 0, 0, 0, 50, 0, 402, 370, 2, 0},
+  };
+  curr_frame =
+    make_hwstate(0.6, GESTURES_BUTTON_LEFT, 2, 2, remaining_finger_moves);
+  gs = wrapper_->SyncInterpret(curr_frame, nullptr);
+  ASSERT_NE(nullptr, gs);
+  EXPECT_EQ(kGestureTypeMove, gs->type);
+}
+
+TEST_F(DragScrollTest, DragScrollWithThreeMovingFingers) {
+  ii_->drag_scroll_enable_.val_ = true;
+
+  // Frame 1: Button down.
+  HardwareState curr_frame =
+    make_hwstate(0.1, GESTURES_BUTTON_LEFT, 1, 1, &button_finger_);
+  Gesture* gs = wrapper_->SyncInterpret(curr_frame, nullptr);
+  EXPECT_EQ(nullptr, gs);
+
+  // Frame 2: Button down timeout reached, button click registered.
+  curr_frame = make_hwstate(0.2, GESTURES_BUTTON_LEFT, 1, 1, &button_finger_);
+  gs = wrapper_->SyncInterpret(curr_frame, nullptr);
+  ASSERT_NE(nullptr, gs);
+  EXPECT_EQ(kGestureTypeButtonsChange, gs->type);
+
+  // Frame 3: Fingers 2,3 and 4 just arrived.
+  FingerState fingers_appear[] = {
+    button_finger_,
+    {0, 0, 0, 0, 50, 0, 450, 400, 2, 0},
+    {0, 0, 0, 0, 50, 0, 550, 400, 3, 0},
+    {0, 0, 0, 0, 50, 0, 650, 400, 4, 0},
+  };
+  curr_frame = make_hwstate(0.3, GESTURES_BUTTON_LEFT, 4, 4, fingers_appear);
+  gs = wrapper_->SyncInterpret(curr_frame, nullptr);
+  EXPECT_EQ(nullptr, gs);
+
+  // Frame 4: Logic should pick closest pair (2 & 3) for the scroll.
+  FingerState scrolling_fingers[] = {
+    button_finger_,
+    {0, 0, 0, 0, 50, 0, 450, 380, 2, 0},
+    {0, 0, 0, 0, 50, 0, 546, 383, 3, 0},
+    {0, 0, 0, 0, 50, 0, 650, 395, 4, 0},
+  };
+  curr_frame = make_hwstate(0.4, GESTURES_BUTTON_LEFT, 4, 4, scrolling_fingers);
+  gs = wrapper_->SyncInterpret(curr_frame, nullptr);
+  ASSERT_NE(nullptr, gs);
+  EXPECT_EQ(kGestureTypeScroll, gs->type);
+  EXPECT_EQ(-20, gs->details.scroll.dy);
+}
+
+TEST_F(DragScrollTest, DragScrollEnabledNormalDrag) {
+  ii_->drag_scroll_enable_.val_ = true;
+
+  // Frame 1: Button down.
+  FingerState button_drag_finger = {0, 0, 0, 0, 50, 0, 500, 500, 1, 0};
+  HardwareState curr_frame =
+    make_hwstate(0.1, GESTURES_BUTTON_LEFT, 1, 1, &button_drag_finger);
+  Gesture* gs = wrapper_->SyncInterpret(curr_frame, nullptr);
+  EXPECT_EQ(nullptr, gs);
+
+  // Frame 2: Button down timeout reached, button click registered.
+  curr_frame =
+    make_hwstate(0.2, GESTURES_BUTTON_LEFT, 1, 1, &button_drag_finger);
+  gs = wrapper_->SyncInterpret(curr_frame, nullptr);
+  ASSERT_NE(nullptr, gs);
+  EXPECT_EQ(kGestureTypeButtonsChange, gs->type);
+
+  // Frame 3: Finger moves, should be a standard Move gesture.
+  FingerState finger_pos_2 = {0, 0, 0, 0, 50, 0, 512, 533, 1, 0};
+  curr_frame = make_hwstate(0.3, GESTURES_BUTTON_LEFT, 1, 1, &finger_pos_2);
+  gs = wrapper_->SyncInterpret(curr_frame, nullptr);
+  ASSERT_NE(nullptr, gs);
+  EXPECT_EQ(kGestureTypeMove, gs->type);
+  EXPECT_EQ(12, gs->details.move.dx);
+  EXPECT_EQ(33, gs->details.move.dy);
+
+  // Frame 4: Finger moves again.
+  FingerState finger_pos_3 = {0, 0, 0, 0, 50, 0, 536, 552, 1, 0};
+  curr_frame = make_hwstate(0.4, GESTURES_BUTTON_LEFT, 1, 1, &finger_pos_3);
+  gs = wrapper_->SyncInterpret(curr_frame, nullptr);
+  ASSERT_NE(nullptr, gs);
+  EXPECT_EQ(kGestureTypeMove, gs->type);
+  EXPECT_EQ(24, gs->details.move.dx);
+  EXPECT_EQ(19, gs->details.move.dy);
+
+  // Frame 5: Button released.
+  curr_frame = make_hwstate(0.5, 0, 1, 1, &finger_pos_3);
+  gs = wrapper_->SyncInterpret(curr_frame, nullptr);
+  ASSERT_NE(nullptr, gs);
+  EXPECT_EQ(kGestureTypeButtonsChange, gs->type);
+  EXPECT_EQ(GESTURES_BUTTON_LEFT, gs->details.buttons.up);
+}
+
+// The test validates a two-finger drag + scroll where the button finger is also
+// one of the two scrolling fingers.
+TEST_F(DragScrollTest, DragScrollTwoFingersOnly) {
+  ii_->drag_scroll_enable_.val_ = true;
+
+  // Frame 1: Button down.
+  FingerState button_finger = {0, 0, 0, 0, 50, 0, 500, 500, 1, 0};
+  HardwareState curr_frame =
+    make_hwstate(0.1, GESTURES_BUTTON_LEFT, 1, 1, &button_finger);
+  Gesture* gs = wrapper_->SyncInterpret(curr_frame, nullptr);
+  EXPECT_EQ(nullptr, gs);
+
+  // Frame 2: Button down timeout reached, button click registered.
+  curr_frame = make_hwstate(0.2, GESTURES_BUTTON_LEFT, 1, 1, &button_finger);
+  gs = wrapper_->SyncInterpret(curr_frame, nullptr);
+  ASSERT_NE(nullptr, gs);
+  EXPECT_EQ(kGestureTypeButtonsChange, gs->type);
+
+  // Frame 3: A second finger is added.
+  FingerState fingers_appear[] = {
+    {0, 0, 0, 0, 50, 0, 500, 480, 1, 0},
+    {0, 0, 0, 0, 50, 0, 600, 480, 2, 0},
+  };
+  curr_frame = make_hwstate(0.3, GESTURES_BUTTON_LEFT, 2, 2, fingers_appear);
+  gs = wrapper_->SyncInterpret(curr_frame, nullptr);
+  EXPECT_EQ(nullptr, gs);
+
+  // Frame 4: Both fingers now move together in a scroll motion.
+  FingerState scrolling_fingers[] = {
+    {0, 0, 0, 0, 50, 0, 495, 464, 1, 0},
+    {0, 0, 0, 0, 50, 0, 598, 460, 2, 0},
+  };
+  curr_frame = make_hwstate(0.4, GESTURES_BUTTON_LEFT, 2, 2, scrolling_fingers);
+  gs = wrapper_->SyncInterpret(curr_frame, nullptr);
+
+  // The gesture should now be a Scroll.
+  ASSERT_NE(nullptr, gs);
+  EXPECT_EQ(kGestureTypeScroll, gs->type);
+  EXPECT_EQ(0, gs->details.scroll.dx);
+  EXPECT_EQ(-20, gs->details.scroll.dy);
+}
+
 }  // namespace gestures