Home | History | Annotate | Download | only in renderer_host
      1 // Copyright 2013 The Chromium Authors. All rights reserved.
      2 // Use of this source code is governed by a BSD-style license that can be
      3 // found in the LICENSE file.
      4 
      5 #import "chrome/browser/renderer_host/chrome_render_widget_host_view_mac_history_swiper.h"
      6 
      7 #import "base/mac/sdk_forward_declarations.h"
      8 #include "chrome/browser/ui/browser.h"
      9 #include "chrome/browser/ui/browser_commands.h"
     10 #include "chrome/browser/ui/browser_finder.h"
     11 #import "chrome/browser/ui/cocoa/history_overlay_controller.h"
     12 #include "third_party/WebKit/public/web/WebInputEvent.h"
     13 
     14 namespace {
     15 // The horizontal distance required to cause the browser to perform a history
     16 // navigation.
     17 const CGFloat kHistorySwipeThreshold = 0.08;
     18 
     19 // The horizontal distance required for this class to start consuming events,
     20 // which stops the events from reaching the renderer.
     21 const CGFloat kConsumeEventThreshold = 0.01;
     22 
     23 // If there has been sufficient vertical motion, the gesture can't be intended
     24 // for history swiping.
     25 const CGFloat kCancelEventVerticalThreshold = 0.24;
     26 
     27 // If there has been sufficient vertical motion, and more vertical than
     28 // horizontal motion, the gesture can't be intended for history swiping.
     29 const CGFloat kCancelEventVerticalLowerThreshold = 0.01;
     30 
     31 // Once we call `[NSEvent trackSwipeEventWithOptions:]`, we cannot reliably
     32 // expect NSTouch callbacks. We set this variable to YES and ignore NSTouch
     33 // callbacks.
     34 BOOL forceMagicMouse = NO;
     35 }  // namespace
     36 
     37 @interface HistorySwiper ()
     38 // Given a touch event, returns the average touch position.
     39 - (NSPoint)averagePositionInEvent:(NSEvent*)event;
     40 
     41 // Updates internal state with the location information from the touch event.
     42 - (void)updateGestureCurrentPointFromEvent:(NSEvent*)event;
     43 
     44 // Updates the state machine with the given touch event.
     45 // Returns NO if no further processing of the event should happen.
     46 - (BOOL)processTouchEventForHistorySwiping:(NSEvent*)event;
     47 
     48 // Returns whether the wheel event should be consumed, and not passed to the
     49 // renderer.
     50 - (BOOL)shouldConsumeWheelEvent:(NSEvent*)event;
     51 @end
     52 
     53 @implementation HistorySwiper
     54 @synthesize delegate = delegate_;
     55 
     56 - (id)initWithDelegate:(id<HistorySwiperDelegate>)delegate {
     57   self = [super init];
     58   if (self) {
     59     // Gesture ids start at 0.
     60     currentGestureId_ = 0;
     61     // No gestures have been processed
     62     lastProcessedGestureId_ = -1;
     63     delegate_ = delegate;
     64   }
     65   return self;
     66 }
     67 
     68 - (void)dealloc {
     69   [self endHistorySwipe];
     70   [super dealloc];
     71 }
     72 
     73 - (BOOL)handleEvent:(NSEvent*)event {
     74   if ([event type] == NSScrollWheel)
     75     return [self maybeHandleHistorySwiping:event];
     76 
     77   return NO;
     78 }
     79 
     80 - (void)rendererHandledWheelEvent:(const blink::WebMouseWheelEvent&)event
     81                          consumed:(BOOL)consumed {
     82   if (event.phase != NSEventPhaseBegan)
     83     return;
     84   beganEventUnconsumed_ = !consumed;
     85 }
     86 
     87 - (BOOL)canRubberbandLeft:(NSView*)view {
     88   Browser* browser = chrome::FindBrowserWithWindow([view window]);
     89   // If history swiping isn't possible, allow rubberbanding.
     90   if (!browser)
     91     return true;
     92   if (!chrome::CanGoBack(browser))
     93     return true;
     94   // History swiping is possible. By default, disallow rubberbanding.  If the
     95   // user has both started, and then cancelled history swiping for this
     96   // gesture, allow rubberbanding.
     97   return receivingTouches_ && recognitionState_ == history_swiper::kCancelled;
     98 }
     99 
    100 - (BOOL)canRubberbandRight:(NSView*)view {
    101   Browser* browser = chrome::FindBrowserWithWindow([view window]);
    102   // If history swiping isn't possible, allow rubberbanding.
    103   if (!browser)
    104     return true;
    105   if (!chrome::CanGoForward(browser))
    106     return true;
    107   // History swiping is possible. By default, disallow rubberbanding.  If the
    108   // user has both started, and then cancelled history swiping for this
    109   // gesture, allow rubberbanding.
    110   return receivingTouches_ && recognitionState_ == history_swiper::kCancelled;
    111 }
    112 
    113 - (void)beginGestureWithEvent:(NSEvent*)event {
    114   inGesture_ = YES;
    115 }
    116 
    117 - (void)endGestureWithEvent:(NSEvent*)event {
    118   inGesture_ = NO;
    119 }
    120 
    121 // This method assumes that there is at least 1 touch in the event.
    122 // The event must correpond to a valid gesture, or else
    123 // [NSEvent touchesMatchingPhase:inView:] will fail.
    124 - (NSPoint)averagePositionInEvent:(NSEvent*)event {
    125   NSPoint position = NSMakePoint(0,0);
    126   int pointCount = 0;
    127   for (NSTouch* touch in
    128        [event touchesMatchingPhase:NSTouchPhaseAny inView:nil]) {
    129     position.x += touch.normalizedPosition.x;
    130     position.y += touch.normalizedPosition.y;
    131     ++pointCount;
    132   }
    133 
    134   if (pointCount > 1) {
    135     position.x /= pointCount;
    136     position.y /= pointCount;
    137   }
    138 
    139   return position;
    140 }
    141 
    142 - (void)updateGestureCurrentPointFromEvent:(NSEvent*)event {
    143   // Update the current point of the gesture.
    144   gestureCurrentPoint_ = [self averagePositionInEvent:event];
    145 
    146   // If the gesture doesn't have a start point, set one.
    147   if (!gestureStartPointValid_) {
    148     gestureStartPointValid_ = YES;
    149     gestureStartPoint_ = gestureCurrentPoint_;
    150   }
    151 }
    152 
    153 // Ideally, we'd set the gestureStartPoint_ here, but this method only gets
    154 // called before the gesture begins, and the touches in an event are only
    155 // available after the gesture begins.
    156 - (void)touchesBeganWithEvent:(NSEvent*)event {
    157   receivingTouches_ = YES;
    158   ++currentGestureId_;
    159 
    160   // Reset state pertaining to previous gestures.
    161   gestureStartPointValid_ = NO;
    162   mouseScrollDelta_ = NSZeroSize;
    163   beganEventUnconsumed_ = NO;
    164   recognitionState_ = history_swiper::kPending;
    165 }
    166 
    167 - (void)touchesMovedWithEvent:(NSEvent*)event {
    168   [self processTouchEventForHistorySwiping:event];
    169 }
    170 
    171 - (void)touchesCancelledWithEvent:(NSEvent*)event {
    172   receivingTouches_ = NO;
    173 
    174   if (![self processTouchEventForHistorySwiping:event])
    175     return;
    176 
    177   [self cancelHistorySwipe];
    178 }
    179 
    180 - (void)touchesEndedWithEvent:(NSEvent*)event {
    181   receivingTouches_ = NO;
    182 
    183   if (![self processTouchEventForHistorySwiping:event])
    184     return;
    185 
    186   if (historyOverlay_) {
    187     BOOL finished = [self updateProgressBar];
    188 
    189     // If the gesture was completed, perform a navigation.
    190     if (finished)
    191       [self navigateBrowserInDirection:historySwipeDirection_];
    192 
    193     // Remove the history overlay.
    194     [self endHistorySwipe];
    195     // The gesture was completed.
    196     recognitionState_ = history_swiper::kCompleted;
    197   }
    198 }
    199 
    200 - (BOOL)processTouchEventForHistorySwiping:(NSEvent*)event {
    201   NSEventType type = [event type];
    202   if (type != NSEventTypeBeginGesture && type != NSEventTypeEndGesture &&
    203       type != NSEventTypeGesture) {
    204     return NO;
    205   }
    206 
    207   switch (recognitionState_) {
    208     case history_swiper::kCancelled:
    209     case history_swiper::kCompleted:
    210       return NO;
    211     case history_swiper::kPending:
    212       [self updateGestureCurrentPointFromEvent:event];
    213       return NO;
    214     case history_swiper::kPotential:
    215     case history_swiper::kTracking:
    216       break;
    217   }
    218 
    219   [self updateGestureCurrentPointFromEvent:event];
    220 
    221   // Consider cancelling the history swipe gesture.
    222   if ([self shouldCancelHorizontalSwipeWithCurrentPoint:gestureCurrentPoint_
    223                                              startPoint:gestureStartPoint_]) {
    224     [self cancelHistorySwipe];
    225     return NO;
    226   }
    227 
    228   if (recognitionState_ == history_swiper::kPotential) {
    229     // The user is in the process of doing history swiping.  If the history
    230     // swipe has progressed sufficiently far, stop sending events to the
    231     // renderer.
    232     BOOL sufficientlyFar = fabs(gestureCurrentPoint_.x - gestureStartPoint_.x) >
    233                            kConsumeEventThreshold;
    234     if (sufficientlyFar)
    235       recognitionState_ = history_swiper::kTracking;
    236   }
    237 
    238   if (historyOverlay_)
    239     [self updateProgressBar];
    240   return YES;
    241 }
    242 
    243 // Consider cancelling the horizontal swipe if the user was intending a
    244 // vertical swipe.
    245 - (BOOL)shouldCancelHorizontalSwipeWithCurrentPoint:(NSPoint)currentPoint
    246     startPoint:(NSPoint)startPoint {
    247   CGFloat yDelta = fabs(currentPoint.y - startPoint.y);
    248   CGFloat xDelta = fabs(currentPoint.x - startPoint.x);
    249 
    250   // The gesture is pretty clearly more vertical than horizontal.
    251   if (yDelta > 2 * xDelta)
    252     return YES;
    253 
    254   // There's been more vertical distance than horizontal distance.
    255   if (yDelta * 1.3 > xDelta && yDelta > kCancelEventVerticalLowerThreshold)
    256     return YES;
    257 
    258   // There's been a lot of vertical distance.
    259   if (yDelta > kCancelEventVerticalThreshold)
    260     return YES;
    261 
    262   return NO;
    263 }
    264 
    265 - (void)cancelHistorySwipe {
    266   [self endHistorySwipe];
    267   recognitionState_ = history_swiper::kCancelled;
    268 }
    269 
    270 - (void)endHistorySwipe {
    271   [historyOverlay_ dismiss];
    272   [historyOverlay_ release];
    273   historyOverlay_ = nil;
    274 }
    275 
    276 // Returns whether the progress bar has been 100% filled.
    277 - (BOOL)updateProgressBar {
    278   NSPoint currentPoint = gestureCurrentPoint_;
    279   NSPoint startPoint = gestureStartPoint_;
    280 
    281   float progress = 0;
    282   BOOL finished = NO;
    283 
    284   progress = (currentPoint.x - startPoint.x) / kHistorySwipeThreshold;
    285   // If the swipe is a backwards gesture, we need to invert progress.
    286   if (historySwipeDirection_ == history_swiper::kBackwards)
    287     progress *= -1;
    288 
    289   // If the user has directions reversed, we need to invert progress.
    290   if (historySwipeDirectionInverted_)
    291     progress *= -1;
    292 
    293   if (progress >= 1.0)
    294     finished = YES;
    295 
    296   // Progress can't be less than 0 or greater than 1.
    297   progress = MAX(0.0, progress);
    298   progress = MIN(1.0, progress);
    299 
    300   [historyOverlay_ setProgress:progress finished:finished];
    301 
    302   return finished;
    303 }
    304 
    305 - (BOOL)isEventDirectionInverted:(NSEvent*)event {
    306   if ([event respondsToSelector:@selector(isDirectionInvertedFromDevice)])
    307     return [event isDirectionInvertedFromDevice];
    308   return NO;
    309 }
    310 
    311 // goForward indicates whether the user is starting a forward or backward
    312 // history swipe.
    313 // Creates and displays a history overlay controller.
    314 // Responsible for cleaning up after itself when the gesture is finished.
    315 // Responsible for starting a browser navigation if necessary.
    316 // Does not prevent swipe events from propagating to other handlers.
    317 - (void)beginHistorySwipeInDirection:
    318         (history_swiper::NavigationDirection)direction
    319                                event:(NSEvent*)event {
    320   // We cannot make any assumptions about the current state of the
    321   // historyOverlay_, since users may attempt to use multiple gesture input
    322   // devices simultaneously, which confuses Cocoa.
    323   [self endHistorySwipe];
    324 
    325   HistoryOverlayController* historyOverlay = [[HistoryOverlayController alloc]
    326       initForMode:(direction == history_swiper::kForwards)
    327                      ? kHistoryOverlayModeForward
    328                      : kHistoryOverlayModeBack];
    329   [historyOverlay showPanelForView:[delegate_ viewThatWantsHistoryOverlay]];
    330   historyOverlay_ = historyOverlay;
    331 
    332   // Record whether the user was swiping forwards or backwards.
    333   historySwipeDirection_ = direction;
    334   // Record the user's settings.
    335   historySwipeDirectionInverted_ = [self isEventDirectionInverted:event];
    336 }
    337 
    338 - (BOOL)systemSettingsAllowHistorySwiping:(NSEvent*)event {
    339   if ([NSEvent
    340           respondsToSelector:@selector(isSwipeTrackingFromScrollEventsEnabled)])
    341     return [NSEvent isSwipeTrackingFromScrollEventsEnabled];
    342   return NO;
    343 }
    344 
    345 - (void)navigateBrowserInDirection:
    346             (history_swiper::NavigationDirection)direction {
    347   Browser* browser = chrome::FindBrowserWithWindow(
    348       historyOverlay_.view.window);
    349   if (browser) {
    350     if (direction == history_swiper::kForwards)
    351       chrome::GoForward(browser, CURRENT_TAB);
    352     else
    353       chrome::GoBack(browser, CURRENT_TAB);
    354   }
    355 }
    356 
    357 - (BOOL)browserCanNavigateInDirection:
    358         (history_swiper::NavigationDirection)direction
    359                                 event:(NSEvent*)event {
    360   Browser* browser = chrome::FindBrowserWithWindow([event window]);
    361   if (!browser)
    362     return NO;
    363 
    364   if (direction == history_swiper::kForwards) {
    365     return chrome::CanGoForward(browser);
    366   } else {
    367     return chrome::CanGoBack(browser);
    368   }
    369 }
    370 
    371 // We use an entirely different set of logic for magic mouse swipe events,
    372 // since we do not get NSTouch callbacks.
    373 - (BOOL)maybeHandleMagicMouseHistorySwiping:(NSEvent*)theEvent {
    374   // The 'trackSwipeEventWithOptions:' api doesn't handle momentum events.
    375   if ([theEvent phase] == NSEventPhaseNone)
    376     return NO;
    377 
    378   mouseScrollDelta_.width += [theEvent scrollingDeltaX];
    379   mouseScrollDelta_.height += [theEvent scrollingDeltaY];
    380 
    381   BOOL isHorizontalGesture =
    382     std::abs(mouseScrollDelta_.width) > std::abs(mouseScrollDelta_.height);
    383   if (!isHorizontalGesture)
    384     return NO;
    385 
    386   BOOL isRightScroll = [theEvent scrollingDeltaX] < 0;
    387   history_swiper::NavigationDirection direction =
    388       isRightScroll ? history_swiper::kForwards : history_swiper::kBackwards;
    389   BOOL browserCanMove =
    390       [self browserCanNavigateInDirection:direction event:theEvent];
    391   if (!browserCanMove)
    392     return NO;
    393 
    394   [self initiateMagicMouseHistorySwipe:isRightScroll event:theEvent];
    395   return YES;
    396 }
    397 
    398 - (void)initiateMagicMouseHistorySwipe:(BOOL)isRightScroll
    399                                  event:(NSEvent*)event {
    400   // Released by the tracking handler once the gesture is complete.
    401   __block HistoryOverlayController* historyOverlay =
    402       [[HistoryOverlayController alloc]
    403           initForMode:isRightScroll ? kHistoryOverlayModeForward
    404                                     : kHistoryOverlayModeBack];
    405 
    406   // The way this API works: gestureAmount is between -1 and 1 (float).  If
    407   // the user does the gesture for more than about 30% (i.e. < -0.3 or >
    408   // 0.3) and then lets go, it is accepted, we get a NSEventPhaseEnded,
    409   // and after that the block is called with amounts animating towards 1
    410   // (or -1, depending on the direction).  If the user lets go below that
    411   // threshold, we get NSEventPhaseCancelled, and the amount animates
    412   // toward 0.  When gestureAmount has reaches its final value, i.e. the
    413   // track animation is done, the handler is called with |isComplete| set
    414   // to |YES|.
    415   // When starting a backwards navigation gesture (swipe from left to right,
    416   // gestureAmount will go from 0 to 1), if the user swipes from left to
    417   // right and then quickly back to the left, this call can send
    418   // NSEventPhaseEnded and then animate to gestureAmount of -1. For a
    419   // picture viewer, that makes sense, but for back/forward navigation users
    420   // find it confusing. There are two ways to prevent this:
    421   // 1. Set Options to NSEventSwipeTrackingLockDirection. This way,
    422   //    gestureAmount will always stay > 0.
    423   // 2. Pass min:0 max:1 (instead of min:-1 max:1). This way, gestureAmount
    424   //    will become less than 0, but on the quick swipe back to the left,
    425   //    NSEventPhaseCancelled is sent instead.
    426   // The current UI looks nicer with (1) so that swiping the opposite
    427   // direction after the initial swipe doesn't cause the shield to move
    428   // in the wrong direction.
    429   forceMagicMouse = YES;
    430   [event trackSwipeEventWithOptions:NSEventSwipeTrackingLockDirection
    431       dampenAmountThresholdMin:-1
    432       max:1
    433       usingHandler:^(CGFloat gestureAmount,
    434                      NSEventPhase phase,
    435                      BOOL isComplete,
    436                      BOOL* stop) {
    437           if (phase == NSEventPhaseBegan) {
    438             [historyOverlay
    439                 showPanelForView:[delegate_ viewThatWantsHistoryOverlay]];
    440             return;
    441           }
    442 
    443           BOOL ended = phase == NSEventPhaseEnded;
    444 
    445           // Dismiss the panel before navigation for immediate visual feedback.
    446           CGFloat progress = std::abs(gestureAmount) / 0.3;
    447           BOOL finished = progress >= 1.0;
    448           progress = MAX(0.0, progress);
    449           progress = MIN(1.0, progress);
    450           [historyOverlay setProgress:progress finished:finished];
    451 
    452           // |gestureAmount| obeys -[NSEvent isDirectionInvertedFromDevice]
    453           // automatically.
    454           Browser* browser =
    455               chrome::FindBrowserWithWindow(historyOverlay.view.window);
    456           if (ended && browser) {
    457             if (isRightScroll)
    458               chrome::GoForward(browser, CURRENT_TAB);
    459             else
    460               chrome::GoBack(browser, CURRENT_TAB);
    461           }
    462 
    463           if (ended || isComplete) {
    464             [historyOverlay dismiss];
    465             [historyOverlay release];
    466             historyOverlay = nil;
    467           }
    468       }];
    469 }
    470 
    471 // Checks if |theEvent| should trigger history swiping, and if so, does
    472 // history swiping. Returns YES if the event was consumed or NO if it should
    473 // be passed on to the renderer.
    474 //
    475 // There are 4 types of scroll wheel events:
    476 // 1. Magic mouse swipe events.
    477 //      These are identical to magic trackpad events, except that there are no
    478 //      NSTouch callbacks.  The only way to accurately track these events is
    479 //      with the  `trackSwipeEventWithOptions:` API. scrollingDelta{X,Y} is not
    480 //      accurate over long distances (it is computed using the speed of the
    481 //      swipe, rather than just the distance moved by the fingers).
    482 // 2. Magic trackpad swipe events.
    483 //      These are the most common history swipe events. Our logic is
    484 //      predominantly designed to handle this use case.
    485 // 3. Traditional mouse scrollwheel events.
    486 //      These should not initiate scrolling. They can be distinguished by the
    487 //      fact that `phase` and `momentumPhase` both return NSEventPhaseNone.
    488 // 4. Momentum swipe events.
    489 //      After a user finishes a swipe, the system continues to generate
    490 //      artificial callbacks. `phase` returns NSEventPhaseNone, but
    491 //      `momentumPhase` does not. Unfortunately, the callbacks don't work
    492 //      properly (OSX 10.9). Sometimes, the system start sending momentum swipe
    493 //      events instead of trackpad swipe events while the user is still
    494 //      2-finger swiping.
    495 - (BOOL)maybeHandleHistorySwiping:(NSEvent*)theEvent {
    496   if (![theEvent respondsToSelector:@selector(phase)])
    497     return NO;
    498 
    499   // The only events that this class consumes have type NSEventPhaseChanged.
    500   // This simultaneously weeds our regular mouse wheel scroll events, and
    501   // gesture events with incorrect phase.
    502   if ([theEvent phase] != NSEventPhaseChanged &&
    503       [theEvent momentumPhase] != NSEventPhaseChanged) {
    504     return NO;
    505   }
    506 
    507   // We've already processed this gesture.
    508   if (lastProcessedGestureId_ == currentGestureId_ &&
    509       recognitionState_ != history_swiper::kPending) {
    510     return [self shouldConsumeWheelEvent:theEvent];
    511   }
    512 
    513   // Don't allow momentum events to start history swiping.
    514   if ([theEvent momentumPhase] != NSEventPhaseNone)
    515     return NO;
    516 
    517   BOOL systemSettingsValid = [self systemSettingsAllowHistorySwiping:theEvent];
    518   if (!systemSettingsValid)
    519     return NO;
    520 
    521   if (![delegate_ shouldAllowHistorySwiping])
    522     return NO;
    523 
    524   // Don't enable history swiping until the renderer has decided to not consume
    525   // the event with phase NSEventPhaseBegan.
    526   if (!beganEventUnconsumed_)
    527     return NO;
    528 
    529   // Magic mouse and touchpad swipe events are identical except magic mouse
    530   // events do not generate NSTouch callbacks. Since we rely on NSTouch
    531   // callbacks to perform history swiping, magic mouse swipe events use an
    532   // entirely different set of logic.
    533   if ((inGesture_ && !receivingTouches_) || forceMagicMouse)
    534     return [self maybeHandleMagicMouseHistorySwiping:theEvent];
    535 
    536   // The scrollWheel: callback is only relevant if it happens while the user is
    537   // still actively using the touchpad.
    538   if (!receivingTouches_)
    539     return NO;
    540 
    541   // TODO(erikchen): Ideally, the direction of history swiping should not be
    542   // determined this early in a gesture, when it's unclear what the user is
    543   // intending to do. Since it is determined this early, make sure that there
    544   // is at least a minimal amount of horizontal motion.
    545   CGFloat xDelta = gestureCurrentPoint_.x - gestureStartPoint_.x;
    546   if (fabs(xDelta) < 0.001)
    547     return NO;
    548 
    549   BOOL isRightScroll = xDelta > 0;
    550   BOOL inverted = [self isEventDirectionInverted:theEvent];
    551   if (inverted)
    552     isRightScroll = !isRightScroll;
    553 
    554   history_swiper::NavigationDirection direction =
    555       isRightScroll ? history_swiper::kForwards : history_swiper::kBackwards;
    556   BOOL browserCanMove =
    557       [self browserCanNavigateInDirection:direction event:theEvent];
    558   if (!browserCanMove)
    559     return NO;
    560 
    561   lastProcessedGestureId_ = currentGestureId_;
    562   [self beginHistorySwipeInDirection:direction event:theEvent];
    563   recognitionState_ = history_swiper::kPotential;
    564   return [self shouldConsumeWheelEvent:theEvent];
    565 }
    566 
    567 - (BOOL)shouldConsumeWheelEvent:(NSEvent*)event {
    568   switch (recognitionState_) {
    569     case history_swiper::kPending:
    570     case history_swiper::kCancelled:
    571       return NO;
    572     case history_swiper::kTracking:
    573     case history_swiper::kCompleted:
    574       return YES;
    575     case history_swiper::kPotential:
    576       // It is unclear whether the user is attempting to perform history
    577       // swiping.  If the event has a vertical component, send it on to the
    578       // renderer.
    579       return event.scrollingDeltaY == 0;
    580   }
    581 }
    582 @end
    583 
    584 @implementation HistorySwiper (PrivateExposedForTesting)
    585 + (void)resetMagicMouseState {
    586   forceMagicMouse = NO;
    587 }
    588 @end
    589