blob: d1f8812d5fdf15ae3fe27775f11f34200a2b085f [file] [log] [blame]
// Copyright 2013 The Chromium Authors. All rights reserved.
// 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 "base/mac/sdk_forward_declarations.h"
#include "chrome/browser/ui/browser.h"
#include "chrome/browser/ui/browser_commands.h"
#include "chrome/browser/ui/browser_finder.h"
#import "chrome/browser/ui/cocoa/history_overlay_controller.h"
#include "chrome/browser/ui/tabs/tab_strip_model.h"
#include "third_party/blink/public/platform/web_gesture_event.h"
#include "third_party/blink/public/platform/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
@synthesize delegate = delegate_;
- (id)initWithDelegate:(id<HistorySwiperDelegate>)delegate {
self = [super init];
if (self) {
delegate_ = delegate;
}
return self;
}
- (void)dealloc {
[self removeHistoryOverlay];
[super dealloc];
}
- (BOOL)handleEvent:(NSEvent*)event {
if ([event type] != NSScrollWheel)
return NO;
return [self handleScrollWheelEvent:event];
}
- (void)rendererHandledWheelEvent:(const blink::WebMouseWheelEvent&)event
consumed:(BOOL)consumed {
if (event.phase != NSEventPhaseBegan)
return;
firstScrollUnconsumed_ = !consumed;
}
- (void)rendererHandledGestureScrollEvent:(const blink::WebGestureEvent&)event
consumed:(BOOL)consumed {
switch (event.GetType()) {
case blink::WebInputEvent::kGestureScrollBegin:
if (event.data.scroll_begin.synthetic ||
event.data.scroll_begin.inertial_phase ==
blink::WebGestureEvent::kMomentumPhase) {
return;
}
// GestureScrollBegin and GestureScrollEnd events are created to wrap
// individual resent GestureScrollUpdates from a plugin. Hence these
// should not be used to indicate the beginning/end of the swipe gesture.
// TODO(mcnee): When we remove BrowserPlugin, delete this code.
// See crbug.com/533069
if (event.resending_plugin_id != -1) {
return;
}
waitingForFirstGestureScroll_ = YES;
break;
case blink::WebInputEvent::kGestureScrollUpdate:
if (waitingForFirstGestureScroll_)
firstScrollUnconsumed_ = !consumed;
waitingForFirstGestureScroll_ = NO;
break;
default:
break;
}
}
- (void)onOverscrolled:(const ui::DidOverscrollParams&)params {
rendererDisabledOverscroll_ = params.overscroll_behavior.x !=
cc::OverscrollBehavior::OverscrollBehaviorType::
kOverscrollBehaviorTypeAuto;
}
- (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 correpond 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;
rendererDisabledOverscroll_ = 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 (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_ release];
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;
}
- (BOOL)isEventDirectionInverted:(NSEvent*)event {
if ([event respondsToSelector:@selector(isDirectionInvertedFromDevice)])
return [event isDirectionInvertedFromDevice];
return NO;
}
- (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;
}
- (BOOL)systemSettingsAllowHistorySwiping:(NSEvent*)event {
if ([NSEvent
respondsToSelector:@selector(isSwipeTrackingFromScrollEventsEnabled)])
return [NSEvent isSwipeTrackingFromScrollEventsEnabled];
return NO;
}
- (void)navigateBrowserInDirection:
(history_swiper::NavigationDirection)direction {
Browser* browser = chrome::FindBrowserWithWindow(
historyOverlay_.view.window);
if (browser) {
if (direction == history_swiper::kForwards)
chrome::GoForward(browser, WindowOpenDisposition::CURRENT_TAB);
else
chrome::GoBack(browser, WindowOpenDisposition::CURRENT_TAB);
}
}
- (BOOL)browserCanNavigateInDirection:
(history_swiper::NavigationDirection)direction
event:(NSEvent*)event {
Browser* browser = chrome::FindBrowserWithWindow([event window]);
if (!browser)
return NO;
if (direction == history_swiper::kForwards) {
return chrome::CanGoForward(browser);
} else {
return chrome::CanGoBack(browser);
}
}
- (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:[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.
Browser* browser =
chrome::FindBrowserWithWindow(historyOverlay.view.window);
if (ended && browser) {
if (isRightScroll)
chrome::GoForward(browser, WindowOpenDisposition::CURRENT_TAB);
else
chrome::GoBack(browser, WindowOpenDisposition::CURRENT_TAB);
}
if (ended || isComplete) {
[historyOverlay dismiss];
[historyOverlay release];
historyOverlay = nil;
}
}];
}
- (BOOL)handleScrollWheelEvent:(NSEvent*)theEvent {
if (![theEvent respondsToSelector:@selector(phase)])
return NO;
// 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;
BOOL systemSettingsValid = [self systemSettingsAllowHistorySwiping:theEvent];
if (!systemSettingsValid)
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 disables it.
if (rendererDisabledOverscroll_)
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;
BOOL inverted = [self isEventDirectionInverted:theEvent];
if (inverted)
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_ = [self isEventDirectionInverted:theEvent];
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