chromium/chrome/browser/renderer_host/chrome_render_widget_host_view_mac_history_swiper.mm

// Copyright 2013 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#import "chrome/browser/renderer_host/chrome_render_widget_host_view_mac_history_swiper.h"

#import "chrome/browser/ui/cocoa/history_overlay_controller.h"
#include "third_party/blink/public/common/input/web_gesture_event.h"
#include "third_party/blink/public/common/input/web_mouse_wheel_event.h"
#include "ui/events/blink/did_overscroll_params.h"

namespace {

// The horizontal distance required to cause the browser to perform a history
// navigation.
const CGFloat kHistorySwipeThreshold = 0.08;

// The horizontal distance required for this class to start consuming events,
// which stops the events from reaching the renderer.
const CGFloat kConsumeEventThreshold = 0.01;

// If there has been sufficient vertical motion, the gesture can't be intended
// for history swiping.
const CGFloat kCancelEventVerticalThreshold = 0.24;

// If there has been sufficient vertical motion, and more vertical than
// horizontal motion, the gesture can't be intended for history swiping.
const CGFloat kCancelEventVerticalLowerThreshold = 0.01;

// Once we call `[NSEvent trackSwipeEventWithOptions:]`, we cannot reliably
// expect NSTouch callbacks. We set this variable to YES and ignore NSTouch
// callbacks.
BOOL forceMagicMouse = NO;

}  // namespace

@interface HistorySwiper ()

// Given a touch event, returns the average touch position.
- (NSPoint)averagePositionInEvent:(NSEvent*)event;

// Updates internal state with the location information from the touch event.
- (void)updateGestureCurrentPointFromEvent:(NSEvent*)event;

// Updates the state machine with the given touch event.
// Returns NO if no further processing of the event should happen.
- (BOOL)processTouchEventForHistorySwiping:(NSEvent*)event;

// Returns whether the wheel event should be consumed, and not passed to the
// renderer.
- (BOOL)shouldConsumeWheelEvent:(NSEvent*)event;

// Shows the history swiper overlay.
- (void)showHistoryOverlay:(history_swiper::NavigationDirection)direction;

// Removes the history swiper overlay.
- (void)removeHistoryOverlay;

// Returns YES if the event was consumed or NO if it should be passed on to the
// renderer. If |event| was generated by a Magic Mouse, this method forwards to
// handleMagicMouseWheelEvent. Otherwise, this method attempts to transition
// the state machine from kPending -> kPotential. If it performs the
// transition, it also shows the history overlay. In order for a history swipe
// gesture to be recognized, the transition must occur.
//
// There are 4 types of scroll wheel events:
// 1. Magic mouse swipe events.
//      These are identical to magic trackpad events, except that there are no
//      -[NSView touches*WithEvent:] callbacks.  The only way to accurately
//      track these events is with the  `trackSwipeEventWithOptions:` API.
//      scrollingDelta{X,Y} is not accurate over long distances (it is computed
//      using the speed of the swipe, rather than just the distance moved by
//      the fingers).
// 2. Magic trackpad swipe events.
//      These are the most common history swipe events. The logic of this
//      method is predominantly designed to handle this use case.
// 3. Traditional mouse scrollwheel events.
//      These should not initiate scrolling. They can be distinguished by the
//      fact that `phase` and `momentumPhase` both return NSEventPhaseNone.
// 4. Momentum swipe events.
//      After a user finishes a swipe, the system continues to generate
//      artificial callbacks. `phase` returns NSEventPhaseNone, but
//      `momentumPhase` does not. Unfortunately, the callbacks don't work
//      properly (OSX 10.9). Sometimes, the system start sending momentum swipe
//      events instead of trackpad swipe events while the user is still
//      2-finger swiping.
- (BOOL)handleScrollWheelEvent:(NSEvent*)event;

// Returns YES if the event was consumed or NO if it should be passed on to the
// renderer. Attempts to initiate history swiping for Magic Mouse events.
- (BOOL)handleMagicMouseWheelEvent:(NSEvent*)theEvent;

@end

@implementation HistorySwiper {
  // This controller will exist if and only if the UI is in history swipe mode.
  HistoryOverlayController* __strong _historyOverlay;
  // The location of the fingers when the gesture started.
  NSPoint _gestureStartPoint;
  // The current location of the fingers in the gesture.
  NSPoint _gestureCurrentPoint;
  // The total Y distance moved since the beginning of the gesture.
  CGFloat _gestureTotalY;
  // A flag that indicates that there is an ongoing gesture. Only used to
  // determine whether swipe events are coming from a Magic Mouse.
  BOOL _inGesture;
  // A flag that indicates that Chrome is receiving a series of touch events.
  BOOL _receivingTouches;
  // Each time a new gesture begins, we must get a new start point.
  // This ivar determines whether the start point is valid.
  int _gestureStartPointValid;

  // The user's intended direction with the history swipe. Set during the
  // transition from kPending -> kPotential.
  history_swiper::NavigationDirection _historySwipeDirection;

  // Whether the history swipe gesture has its direction inverted. Set during
  // the transition from kPending -> kPotential.
  BOOL _historySwipeDirectionInverted;

  // Whether:
  //  1) When wheel gestures are disabled if the wheel event with phase
  //     NSEventPhaseBegan was consumed by the renderer.
  //  2) When wheel gestures are enabled and if the first gesture
  //     scroll was not consumed by the renderer.
  // This variables defaults to NO for new gestures.
  BOOL _firstScrollUnconsumed;

  // Whether the overscroll has been triggered by renderer and is not disabled
  // by CSSOverscrollBehavior.
  BOOL _overscrollTriggeredByRenderer;

  // Whether we have received a gesture scroll begin and are awaiting the first
  // gesture scroll update to determine if the event was consumed by the
  // renderer.
  BOOL _waitingForFirstGestureScroll;

  // What state the gesture recognition is in.
  history_swiper::RecognitionState _recognitionState;

  // Cumulative scroll delta since scroll gesture start. Only valid during
  // scroll gesture handling. Only used to trigger Magic Mouse history swiping.
  NSSize _mouseScrollDelta;
}

@synthesize delegate = _delegate;

- (instancetype)initWithDelegate:(id<HistorySwiperDelegate>)delegate {
  self = [super init];
  if (self) {
    _delegate = delegate;
  }
  return self;
}

- (void)dealloc {
  [self removeHistoryOverlay];
}

- (BOOL)handleEvent:(NSEvent*)event {
  if (event.type != NSEventTypeScrollWheel) {
    return NO;
  }

  return [self handleScrollWheelEvent:event];
}

- (void)rendererHandledWheelEvent:(const blink::WebMouseWheelEvent&)event
                         consumed:(BOOL)consumed {
  if (event.phase != blink::WebMouseWheelEvent::kPhaseBegan) {
    return;
  }
  _firstScrollUnconsumed = !consumed;
}

- (void)rendererHandledGestureScrollEvent:(const blink::WebGestureEvent&)event
                                 consumed:(BOOL)consumed {
  switch (event.GetType()) {
    case blink::WebInputEvent::Type::kGestureScrollBegin:
      if (event.data.scroll_begin.synthetic ||
          event.data.scroll_begin.inertial_phase ==
              blink::WebGestureEvent::InertialPhaseState::kMomentum) {
        return;
      }
      _waitingForFirstGestureScroll = YES;
      break;
    case blink::WebInputEvent::Type::kGestureScrollUpdate:
      if (_waitingForFirstGestureScroll) {
        _firstScrollUnconsumed = !consumed;
      }
      _waitingForFirstGestureScroll = NO;
      break;
    default:
      break;
  }
}

- (void)onOverscrolled:(const ui::DidOverscrollParams&)params {
  _overscrollTriggeredByRenderer =
      params.overscroll_behavior.x == cc::OverscrollBehavior::Type::kAuto;
}

- (void)beginGestureWithEvent:(NSEvent*)event {
  _inGesture = YES;

  // Reset state pertaining to Magic Mouse swipe gestures.
  _mouseScrollDelta = NSZeroSize;
}

- (void)endGestureWithEvent:(NSEvent*)event {
  _inGesture = NO;
}

// This method assumes that there is at least 1 touch in the event.
// The event must correspond to a valid gesture, or else
// [NSEvent touchesMatchingPhase:inView:] will fail.
- (NSPoint)averagePositionInEvent:(NSEvent*)event {
  NSPoint position = NSMakePoint(0, 0);
  int pointCount = 0;
  for (NSTouch* touch in [event touchesMatchingPhase:NSTouchPhaseAny
                                              inView:nil]) {
    position.x += touch.normalizedPosition.x;
    position.y += touch.normalizedPosition.y;
    ++pointCount;
  }

  if (pointCount > 1) {
    position.x /= pointCount;
    position.y /= pointCount;
  }

  return position;
}

- (void)updateGestureCurrentPointFromEvent:(NSEvent*)event {
  NSPoint averagePosition = [self averagePositionInEvent:event];

  // If the start point is valid, then so is the current point.
  if (_gestureStartPointValid) {
    _gestureTotalY += fabs(averagePosition.y - _gestureCurrentPoint.y);
  }

  // Update the current point of the gesture.
  _gestureCurrentPoint = averagePosition;

  // If the gesture doesn't have a start point, set one.
  if (!_gestureStartPointValid) {
    _gestureStartPointValid = YES;
    _gestureStartPoint = _gestureCurrentPoint;
  }
}

// Ideally, we'd set the gestureStartPoint_ here, but this method only gets
// called before the gesture begins, and the touches in an event are only
// available after the gesture begins.
- (void)touchesBeganWithEvent:(NSEvent*)event {
  _receivingTouches = YES;

  // Reset state pertaining to previous trackpad gestures.
  _gestureStartPointValid = NO;
  _gestureTotalY = 0;
  _firstScrollUnconsumed = NO;
  _overscrollTriggeredByRenderer = NO;
  _waitingForFirstGestureScroll = NO;
  _recognitionState = history_swiper::kPending;
}

- (void)touchesMovedWithEvent:(NSEvent*)event {
  [self processTouchEventForHistorySwiping:event];
}

- (void)touchesCancelledWithEvent:(NSEvent*)event {
  _receivingTouches = NO;

  if (![self processTouchEventForHistorySwiping:event]) {
    return;
  }

  [self cancelHistorySwipe];
}

- (void)touchesEndedWithEvent:(NSEvent*)event {
  _receivingTouches = NO;
  if (![self processTouchEventForHistorySwiping:event]) {
    return;
  }

  if (_historyOverlay) {
    BOOL finished = [self updateProgressBar];

    // If the gesture was completed, perform a navigation.
    if (finished) {
      [self navigateBrowserInDirection:_historySwipeDirection];
    }

    [self removeHistoryOverlay];

    // The gesture was completed.
    _recognitionState = history_swiper::kCompleted;
  }
}

- (BOOL)processTouchEventForHistorySwiping:(NSEvent*)event {
  NSEventType type = event.type;
  if (type != NSEventTypeBeginGesture && type != NSEventTypeEndGesture &&
      type != NSEventTypeGesture) {
    return NO;
  }

  switch (_recognitionState) {
    case history_swiper::kCancelled:
    case history_swiper::kCompleted:
      return NO;
    case history_swiper::kPending:
    case history_swiper::kPotential:
    case history_swiper::kTracking:
      break;
  }

  [self updateGestureCurrentPointFromEvent:event];

  // Consider cancelling the history swipe gesture.
  if ([self shouldCancelHorizontalSwipeWithCurrentPoint:_gestureCurrentPoint
                                             startPoint:_gestureStartPoint]) {
    [self cancelHistorySwipe];
    return NO;
  }

  // Don't do any more processing if the state machine is in the pending state.
  if (_recognitionState == history_swiper::kPending) {
    return NO;
  }

  if (_recognitionState == history_swiper::kPotential) {
    // The user is in the process of doing history swiping.  If the history
    // swipe has progressed sufficiently far, stop sending events to the
    // renderer.
    BOOL sufficientlyFar = fabs(_gestureCurrentPoint.x - _gestureStartPoint.x) >
                           kConsumeEventThreshold;
    if (sufficientlyFar) {
      _recognitionState = history_swiper::kTracking;

      if (_historySwipeDirection == history_swiper::kBackwards) {
        [_delegate backwardsSwipeNavigationLikely];
      }
    }
  }

  if (_historyOverlay) {
    [self updateProgressBar];
  }
  return YES;
}

// Consider cancelling the horizontal swipe if the user was intending a
// vertical swipe.
- (BOOL)shouldCancelHorizontalSwipeWithCurrentPoint:(NSPoint)currentPoint
                                         startPoint:(NSPoint)startPoint {
  CGFloat yDelta = _gestureTotalY;
  CGFloat xDelta = fabs(currentPoint.x - startPoint.x);

  // The gesture is pretty clearly more vertical than horizontal.
  if (yDelta > 2 * xDelta) {
    return YES;
  }

  // There's been more vertical distance than horizontal distance.
  if (yDelta * 1.3 > xDelta && yDelta > kCancelEventVerticalLowerThreshold) {
    return YES;
  }

  // There's been a lot of vertical distance.
  if (yDelta > kCancelEventVerticalThreshold) {
    return YES;
  }

  return NO;
}

- (void)cancelHistorySwipe {
  [self removeHistoryOverlay];
  _recognitionState = history_swiper::kCancelled;
}

- (void)removeHistoryOverlay {
  [_historyOverlay dismiss];
  _historyOverlay = nil;
}

// Returns whether the progress bar has been 100% filled.
- (BOOL)updateProgressBar {
  NSPoint currentPoint = _gestureCurrentPoint;
  NSPoint startPoint = _gestureStartPoint;

  float progress = 0;
  BOOL finished = NO;

  progress = (currentPoint.x - startPoint.x) / kHistorySwipeThreshold;
  // If the swipe is a backwards gesture, we need to invert progress.
  if (_historySwipeDirection == history_swiper::kBackwards) {
    progress *= -1;
  }

  // If the user has directions reversed, we need to invert progress.
  if (_historySwipeDirectionInverted) {
    progress *= -1;
  }

  if (progress >= 1.0) {
    finished = YES;
  }

  // Progress can't be less than 0 or greater than 1.
  progress = MAX(0.0, progress);
  progress = MIN(1.0, progress);

  [_historyOverlay setProgress:progress finished:finished];

  return finished;
}

- (void)showHistoryOverlay:(history_swiper::NavigationDirection)direction {
  // We cannot make any assumptions about the current state of the
  // historyOverlay_, since users may attempt to use multiple gesture input
  // devices simultaneously, which confuses Cocoa.
  [self removeHistoryOverlay];

  HistoryOverlayController* historyOverlay = [[HistoryOverlayController alloc]
      initForMode:(direction == history_swiper::kForwards)
                      ? kHistoryOverlayModeForward
                      : kHistoryOverlayModeBack];
  [historyOverlay showPanelForView:[_delegate viewThatWantsHistoryOverlay]];
  _historyOverlay = historyOverlay;
}

- (void)navigateBrowserInDirection:
    (history_swiper::NavigationDirection)direction {
  [_delegate navigateInDirection:direction
                        onWindow:_historyOverlay.view.window];
}

- (BOOL)browserCanNavigateInDirection:
            (history_swiper::NavigationDirection)direction
                                event:(NSEvent*)event {
  return [_delegate canNavigateInDirection:direction onWindow:[event window]];
}

- (BOOL)handleMagicMouseWheelEvent:(NSEvent*)theEvent {
  // The 'trackSwipeEventWithOptions:' api doesn't handle momentum events.
  if (theEvent.phase == NSEventPhaseNone) {
    return NO;
  }

  _mouseScrollDelta.width += theEvent.scrollingDeltaX;
  _mouseScrollDelta.height += theEvent.scrollingDeltaY;

  BOOL isHorizontalGesture =
      std::abs(_mouseScrollDelta.width) > std::abs(_mouseScrollDelta.height);
  if (!isHorizontalGesture) {
    return NO;
  }

  BOOL isRightScroll = theEvent.scrollingDeltaX < 0;
  history_swiper::NavigationDirection direction =
      isRightScroll ? history_swiper::kForwards : history_swiper::kBackwards;
  BOOL browserCanMove = [self browserCanNavigateInDirection:direction
                                                      event:theEvent];
  if (!browserCanMove) {
    return NO;
  }

  [self initiateMagicMouseHistorySwipe:isRightScroll event:theEvent];
  return YES;
}

- (void)initiateMagicMouseHistorySwipe:(BOOL)isRightScroll
                                 event:(NSEvent*)event {
  // Released by the tracking handler once the gesture is complete.
  __block HistoryOverlayController* historyOverlay =
      [[HistoryOverlayController alloc]
          initForMode:isRightScroll ? kHistoryOverlayModeForward
                                    : kHistoryOverlayModeBack];

  // The way this API works: gestureAmount is between -1 and 1 (float).  If
  // the user does the gesture for more than about 30% (i.e. < -0.3 or >
  // 0.3) and then lets go, it is accepted, we get a NSEventPhaseEnded,
  // and after that the block is called with amounts animating towards 1
  // (or -1, depending on the direction).  If the user lets go below that
  // threshold, we get NSEventPhaseCancelled, and the amount animates
  // toward 0.  When gestureAmount has reaches its final value, i.e. the
  // track animation is done, the handler is called with |isComplete| set
  // to |YES|.
  // When starting a backwards navigation gesture (swipe from left to right,
  // gestureAmount will go from 0 to 1), if the user swipes from left to
  // right and then quickly back to the left, this call can send
  // NSEventPhaseEnded and then animate to gestureAmount of -1. For a
  // picture viewer, that makes sense, but for back/forward navigation users
  // find it confusing. There are two ways to prevent this:
  // 1. Set Options to NSEventSwipeTrackingLockDirection. This way,
  //    gestureAmount will always stay > 0.
  // 2. Pass min:0 max:1 (instead of min:-1 max:1). This way, gestureAmount
  //    will become less than 0, but on the quick swipe back to the left,
  //    NSEventPhaseCancelled is sent instead.
  // The current UI looks nicer with (1) so that swiping the opposite
  // direction after the initial swipe doesn't cause the shield to move
  // in the wrong direction.
  forceMagicMouse = YES;
  [event
      trackSwipeEventWithOptions:NSEventSwipeTrackingLockDirection
        dampenAmountThresholdMin:-1
                             max:1
                    usingHandler:^(CGFloat gestureAmount, NSEventPhase phase,
                                   BOOL isComplete, BOOL* stop) {
                      if (phase == NSEventPhaseBegan) {
                        [historyOverlay
                            showPanelForView:
                                [self.delegate viewThatWantsHistoryOverlay]];
                        return;
                      }

                      BOOL ended = phase == NSEventPhaseEnded;

                      // Dismiss the panel before navigation for immediate
                      // visual feedback.
                      CGFloat progress = std::abs(gestureAmount) / 0.3;
                      BOOL finished = progress >= 1.0;
                      progress = MAX(0.0, progress);
                      progress = MIN(1.0, progress);
                      [historyOverlay setProgress:progress finished:finished];

                      // |gestureAmount| obeys -[NSEvent
                      // isDirectionInvertedFromDevice] automatically.
                      if (ended) {
                        [self.delegate
                            navigateInDirection:isRightScroll
                                                    ? history_swiper::kForwards
                                                    : history_swiper::kBackwards
                                       onWindow:historyOverlay.view.window];
                      }

                      if (ended || isComplete) {
                        [historyOverlay dismiss];
                        historyOverlay = nil;
                      }
                    }];
}

- (BOOL)handleScrollWheelEvent:(NSEvent*)theEvent {
  // The only events that this class consumes have type NSEventPhaseChanged.
  // This simultaneously weeds our regular mouse wheel scroll events, and
  // gesture events with incorrect phase.
  if (theEvent.phase != NSEventPhaseChanged &&
      theEvent.momentumPhase != NSEventPhaseChanged) {
    return NO;
  }

  // We've already processed this gesture.
  if (_recognitionState != history_swiper::kPending) {
    return [self shouldConsumeWheelEvent:theEvent];
  }

  // Don't allow momentum events to start history swiping.
  if (theEvent.momentumPhase != NSEventPhaseNone) {
    return NO;
  }

  if (!NSEvent.swipeTrackingFromScrollEventsEnabled) {
    return NO;
  }

  if (![_delegate shouldAllowHistorySwiping]) {
    return NO;
  }

  // Don't enable history swiping until the renderer has decided to not consume
  // the event with phase NSEventPhaseBegan.
  if (!_firstScrollUnconsumed) {
    return NO;
  }

  // History swiping should be prevented if the renderer hasn't triggered it.
  if (!_overscrollTriggeredByRenderer) {
    return NO;
  }

  // Magic mouse and touchpad swipe events are identical except magic mouse
  // events do not generate NSTouch callbacks. Since we rely on NSTouch
  // callbacks to perform history swiping, magic mouse swipe events use an
  // entirely different set of logic.
  if ((_inGesture && !_receivingTouches) || forceMagicMouse) {
    return [self handleMagicMouseWheelEvent:theEvent];
  }

  // The scrollWheel: callback is only relevant if it happens while the user is
  // still actively using the touchpad.
  if (!_receivingTouches) {
    return NO;
  }

  // TODO(erikchen): Ideally, the direction of history swiping should not be
  // determined this early in a gesture, when it's unclear what the user is
  // intending to do. Since it is determined this early, make sure that there
  // is at least a minimal amount of horizontal motion.
  CGFloat xDelta = _gestureCurrentPoint.x - _gestureStartPoint.x;
  if (fabs(xDelta) < 0.001) {
    return NO;
  }

  BOOL isRightScroll = xDelta > 0;
  if (theEvent.directionInvertedFromDevice) {
    isRightScroll = !isRightScroll;
  }

  history_swiper::NavigationDirection direction =
      isRightScroll ? history_swiper::kForwards : history_swiper::kBackwards;
  BOOL browserCanMove = [self browserCanNavigateInDirection:direction
                                                      event:theEvent];
  if (!browserCanMove) {
    return NO;
  }

  _historySwipeDirection = direction;
  _historySwipeDirectionInverted = theEvent.directionInvertedFromDevice;
  _recognitionState = history_swiper::kPotential;
  [self showHistoryOverlay:direction];
  return [self shouldConsumeWheelEvent:theEvent];
}

- (BOOL)shouldConsumeWheelEvent:(NSEvent*)event {
  switch (_recognitionState) {
    case history_swiper::kPending:
    case history_swiper::kCancelled:
      return NO;
    case history_swiper::kTracking:
    case history_swiper::kCompleted:
      return YES;
    case history_swiper::kPotential:
      // It is unclear whether the user is attempting to perform history
      // swiping.  If the event has a vertical component, send it on to the
      // renderer.
      return event.scrollingDeltaY == 0;
  }
}

@end

@implementation HistorySwiper (PrivateExposedForTesting)
+ (void)resetMagicMouseState {
  forceMagicMouse = NO;
}
@end