| // 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 "third_party/WebKit/public/web/WebInputEvent.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; |
| @end |
| |
| @implementation HistorySwiper |
| @synthesize delegate = delegate_; |
| |
| - (id)initWithDelegate:(id<HistorySwiperDelegate>)delegate { |
| self = [super init]; |
| if (self) { |
| // Gesture ids start at 0. |
| currentGestureId_ = 0; |
| // No gestures have been processed |
| lastProcessedGestureId_ = -1; |
| delegate_ = delegate; |
| } |
| return self; |
| } |
| |
| - (void)dealloc { |
| [self endHistorySwipe]; |
| [super dealloc]; |
| } |
| |
| - (BOOL)handleEvent:(NSEvent*)event { |
| if ([event type] == NSScrollWheel) |
| return [self maybeHandleHistorySwiping:event]; |
| |
| return NO; |
| } |
| |
| - (void)rendererHandledWheelEvent:(const blink::WebMouseWheelEvent&)event |
| consumed:(BOOL)consumed { |
| if (event.phase != NSEventPhaseBegan) |
| return; |
| beganEventUnconsumed_ = !consumed; |
| } |
| |
| - (BOOL)canRubberbandLeft:(NSView*)view { |
| Browser* browser = chrome::FindBrowserWithWindow([view window]); |
| // If history swiping isn't possible, allow rubberbanding. |
| if (!browser) |
| return true; |
| if (!chrome::CanGoBack(browser)) |
| return true; |
| // History swiping is possible. By default, disallow rubberbanding. If the |
| // user has both started, and then cancelled history swiping for this |
| // gesture, allow rubberbanding. |
| return inGesture_ && recognitionState_ == history_swiper::kCancelled; |
| } |
| |
| - (BOOL)canRubberbandRight:(NSView*)view { |
| Browser* browser = chrome::FindBrowserWithWindow([view window]); |
| // If history swiping isn't possible, allow rubberbanding. |
| if (!browser) |
| return true; |
| if (!chrome::CanGoForward(browser)) |
| return true; |
| // History swiping is possible. By default, disallow rubberbanding. If the |
| // user has both started, and then cancelled history swiping for this |
| // gesture, allow rubberbanding. |
| return inGesture_ && recognitionState_ == history_swiper::kCancelled; |
| } |
| |
| // Is is theoretically possible for multiple simultaneous gestures to occur, if |
| // the user has multiple input devices. There will be 2 beginGesture events, but |
| // only 1 endGesture event. The unfinished gesture will continue to send |
| // touchesMoved events, but when the gesture finishes there is not endGesture |
| // callback. We ignore this case, because it is sufficiently unlikely to occur. |
| - (void)beginGestureWithEvent:(NSEvent*)event { |
| inGesture_ = YES; |
| ++currentGestureId_; |
| // Reset state pertaining to previous gestures. |
| gestureStartPointValid_ = NO; |
| receivedTouch_ = NO; |
| mouseScrollDelta_ = NSZeroSize; |
| beganEventUnconsumed_ = NO; |
| recognitionState_ = history_swiper::kPending; |
| } |
| |
| - (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 { |
| // The points in an event are not valid unless the event is part of |
| // a gesture. |
| if (inGesture_) { |
| // Update the current point of the gesture. |
| gestureCurrentPoint_ = [self averagePositionInEvent:event]; |
| |
| // 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 { |
| receivedTouch_ = YES; |
| // Do nothing. |
| } |
| |
| - (void)touchesMovedWithEvent:(NSEvent*)event { |
| [self processTouchEventForHistorySwiping:event]; |
| } |
| |
| - (void)touchesCancelledWithEvent:(NSEvent*)event { |
| if (![self processTouchEventForHistorySwiping:event]) |
| return; |
| |
| [self cancelHistorySwipe]; |
| } |
| |
| - (void)touchesEndedWithEvent:(NSEvent*)event { |
| if (![self processTouchEventForHistorySwiping:event]) |
| return; |
| |
| if (historyOverlay_) { |
| BOOL finished = [self updateProgressBar]; |
| |
| // If the gesture was completed, perform a navigation. |
| if (finished) |
| [self navigateBrowserInDirection:historySwipeDirection_]; |
| |
| // Remove the history overlay. |
| [self endHistorySwipe]; |
| // The gesture was completed. |
| recognitionState_ = history_swiper::kCompleted; |
| } |
| } |
| |
| - (BOOL)processTouchEventForHistorySwiping:(NSEvent*)event { |
| receivedTouch_ = YES; |
| |
| 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: |
| [self updateGestureCurrentPointFromEvent:event]; |
| return NO; |
| 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; |
| } |
| |
| 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 = fabs(currentPoint.y - startPoint.y); |
| 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 endHistorySwipe]; |
| recognitionState_ = history_swiper::kCancelled; |
| } |
| |
| - (void)endHistorySwipe { |
| [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; |
| } |
| |
| // goForward indicates whether the user is starting a forward or backward |
| // history swipe. |
| // Creates and displays a history overlay controller. |
| // Responsible for cleaning up after itself when the gesture is finished. |
| // Responsible for starting a browser navigation if necessary. |
| // Does not prevent swipe events from propagating to other handlers. |
| - (void)beginHistorySwipeInDirection: |
| (history_swiper::NavigationDirection)direction |
| event:(NSEvent*)event { |
| // 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 endHistorySwipe]; |
| |
| HistoryOverlayController* historyOverlay = [[HistoryOverlayController alloc] |
| initForMode:(direction == history_swiper::kForwards) |
| ? kHistoryOverlayModeForward |
| : kHistoryOverlayModeBack]; |
| [historyOverlay showPanelForView:[delegate_ viewThatWantsHistoryOverlay]]; |
| historyOverlay_ = historyOverlay; |
| |
| // Record whether the user was swiping forwards or backwards. |
| historySwipeDirection_ = direction; |
| // Record the user's settings. |
| historySwipeDirectionInverted_ = [self isEventDirectionInverted:event]; |
| } |
| |
| - (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, CURRENT_TAB); |
| else |
| chrome::GoBack(browser, 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); |
| } |
| } |
| |
| // We use an entirely different set of logic for magic mouse swipe events, |
| // since we do not get NSTouch callbacks. |
| - (BOOL)maybeHandleMagicMouseHistorySwiping:(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, CURRENT_TAB); |
| else |
| chrome::GoBack(browser, CURRENT_TAB); |
| } |
| |
| if (ended || isComplete) { |
| [historyOverlay dismiss]; |
| [historyOverlay release]; |
| historyOverlay = nil; |
| } |
| }]; |
| } |
| |
| // Checks if |theEvent| should trigger history swiping, and if so, does |
| // history swiping. Returns YES if the event was consumed or NO if it should |
| // be passed on to the renderer. |
| // |
| // 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 |
| // NSTouch 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. Our logic 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)maybeHandleHistorySwiping:(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 (lastProcessedGestureId_ == currentGestureId_ && |
| 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 (!beganEventUnconsumed_) |
| return NO; |
| |
| if (!inGesture_) |
| return NO; |
| |
| if (!receivedTouch_ || forceMagicMouse) |
| return [self maybeHandleMagicMouseHistorySwiping:theEvent]; |
| |
| CGFloat xDelta = gestureCurrentPoint_.x - gestureStartPoint_.x; |
| |
| 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; |
| |
| lastProcessedGestureId_ = currentGestureId_; |
| [self beginHistorySwipeInDirection:direction event:theEvent]; |
| recognitionState_ = history_swiper::kPotential; |
| 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 |