Home | History | Annotate | Download | only in cocoa
      1 // Copyright (c) 2011 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 <Cocoa/Cocoa.h>
      6 #import <QuartzCore/QuartzCore.h>
      7 
      8 #include "base/logging.h"
      9 #include "base/memory/scoped_nsobject.h"
     10 #include "base/metrics/histogram.h"
     11 #include "base/sys_string_conversions.h"
     12 #import "chrome/browser/ui/cocoa/confirm_quit_panel_controller.h"
     13 #include "grit/generated_resources.h"
     14 #include "ui/base/l10n/l10n_util_mac.h"
     15 
     16 // Constants ///////////////////////////////////////////////////////////////////
     17 
     18 // How long the user must hold down Cmd+Q to confirm the quit.
     19 const NSTimeInterval kTimeToConfirmQuit = 1.5;
     20 
     21 // Leeway between the |targetDate| and the current time that will confirm a
     22 // quit.
     23 const NSTimeInterval kTimeDeltaFuzzFactor = 1.0;
     24 
     25 // Duration of the window fade out animation.
     26 const NSTimeInterval kWindowFadeAnimationDuration = 0.2;
     27 
     28 // For metrics recording only: How long the user must hold the keys to
     29 // differentitate kDoubleTap from kTapHold.
     30 const NSTimeInterval kDoubleTapTimeDelta = 0.32;
     31 
     32 // Functions ///////////////////////////////////////////////////////////////////
     33 
     34 namespace confirm_quit {
     35 
     36 void RecordHistogram(ConfirmQuitMetric sample) {
     37   HISTOGRAM_ENUMERATION("ConfirmToQuit", sample, kSampleCount);
     38 }
     39 
     40 }  // namespace confirm_quit
     41 
     42 // Custom Content View /////////////////////////////////////////////////////////
     43 
     44 // The content view of the window that draws a custom frame.
     45 @interface ConfirmQuitFrameView : NSView {
     46  @private
     47   NSTextField* message_;  // Weak, owned by the view hierarchy.
     48 }
     49 - (void)setMessageText:(NSString*)text;
     50 @end
     51 
     52 @implementation ConfirmQuitFrameView
     53 
     54 - (id)initWithFrame:(NSRect)frameRect {
     55   if ((self = [super initWithFrame:frameRect])) {
     56     scoped_nsobject<NSTextField> message(
     57         // The frame will be fixed up when |-setMessageText:| is called.
     58         [[NSTextField alloc] initWithFrame:NSMakeRect(0, 0, 0, 0)]);
     59     message_ = message.get();
     60     [message_ setEditable:NO];
     61     [message_ setSelectable:NO];
     62     [message_ setBezeled:NO];
     63     [message_ setDrawsBackground:NO];
     64     [message_ setFont:[NSFont boldSystemFontOfSize:24]];
     65     [message_ setTextColor:[NSColor whiteColor]];
     66     [self addSubview:message_];
     67   }
     68   return self;
     69 }
     70 
     71 - (void)drawRect:(NSRect)dirtyRect {
     72   const CGFloat kCornerRadius = 5.0;
     73   NSBezierPath* path = [NSBezierPath bezierPathWithRoundedRect:[self bounds]
     74                                                        xRadius:kCornerRadius
     75                                                        yRadius:kCornerRadius];
     76 
     77   NSColor* fillColor = [NSColor colorWithCalibratedWhite:0.2 alpha:0.75];
     78   [fillColor set];
     79   [path fill];
     80 }
     81 
     82 - (void)setMessageText:(NSString*)text {
     83   const CGFloat kHorizontalPadding = 30;
     84 
     85   // Style the string.
     86   scoped_nsobject<NSMutableAttributedString> attrString(
     87       [[NSMutableAttributedString alloc] initWithString:text]);
     88   scoped_nsobject<NSShadow> textShadow([[NSShadow alloc] init]);
     89   [textShadow.get() setShadowColor:[NSColor colorWithCalibratedWhite:0
     90                                                                alpha:0.6]];
     91   [textShadow.get() setShadowOffset:NSMakeSize(0, -1)];
     92   [textShadow setShadowBlurRadius:1.0];
     93   [attrString addAttribute:NSShadowAttributeName
     94                      value:textShadow
     95                      range:NSMakeRange(0, [text length])];
     96   [message_ setAttributedStringValue:attrString];
     97 
     98   // Fixup the frame of the string.
     99   [message_ sizeToFit];
    100   NSRect messageFrame = [message_ frame];
    101   NSRect frame = [[self window] frame];
    102 
    103   if (NSWidth(messageFrame) > NSWidth(frame))
    104     frame.size.width = NSWidth(messageFrame) + kHorizontalPadding;
    105 
    106   messageFrame.origin.y = NSMidY(frame) - NSMidY(messageFrame);
    107   messageFrame.origin.x = NSMidX(frame) - NSMidX(messageFrame);
    108 
    109   [[self window] setFrame:frame display:YES];
    110   [message_ setFrame:messageFrame];
    111 }
    112 
    113 @end
    114 
    115 // Animation ///////////////////////////////////////////////////////////////////
    116 
    117 // This animation will run through all the windows of the passed-in
    118 // NSApplication and will fade their alpha value to 0.0. When the animation is
    119 // complete, this will release itself.
    120 @interface FadeAllWindowsAnimation : NSAnimation<NSAnimationDelegate> {
    121  @private
    122   NSApplication* application_;
    123 }
    124 - (id)initWithApplication:(NSApplication*)app
    125         animationDuration:(NSTimeInterval)duration;
    126 @end
    127 
    128 
    129 @implementation FadeAllWindowsAnimation
    130 
    131 - (id)initWithApplication:(NSApplication*)app
    132         animationDuration:(NSTimeInterval)duration {
    133   if ((self = [super initWithDuration:duration
    134                        animationCurve:NSAnimationLinear])) {
    135     application_ = app;
    136     [self setDelegate:self];
    137   }
    138   return self;
    139 }
    140 
    141 - (void)setCurrentProgress:(NSAnimationProgress)progress {
    142   for (NSWindow* window in [application_ windows]) {
    143     [window setAlphaValue:1.0 - progress];
    144   }
    145 }
    146 
    147 - (void)animationDidStop:(NSAnimation*)anim {
    148   DCHECK_EQ(self, anim);
    149   [self autorelease];
    150 }
    151 
    152 @end
    153 
    154 // Private Interface ///////////////////////////////////////////////////////////
    155 
    156 @interface ConfirmQuitPanelController (Private)
    157 - (void)animateFadeOut;
    158 - (NSEvent*)pumpEventQueueForKeyUp:(NSApplication*)app untilDate:(NSDate*)date;
    159 - (void)hideAllWindowsForApplication:(NSApplication*)app
    160                         withDuration:(NSTimeInterval)duration;
    161 @end
    162 
    163 ConfirmQuitPanelController* g_confirmQuitPanelController = nil;
    164 
    165 ////////////////////////////////////////////////////////////////////////////////
    166 
    167 @implementation ConfirmQuitPanelController
    168 
    169 + (ConfirmQuitPanelController*)sharedController {
    170   if (!g_confirmQuitPanelController) {
    171     g_confirmQuitPanelController =
    172         [[ConfirmQuitPanelController alloc] init];
    173   }
    174   return [[g_confirmQuitPanelController retain] autorelease];
    175 }
    176 
    177 - (id)init {
    178   const NSRect kWindowFrame = NSMakeRect(0, 0, 350, 70);
    179   scoped_nsobject<NSWindow> window(
    180       [[NSWindow alloc] initWithContentRect:kWindowFrame
    181                                   styleMask:NSBorderlessWindowMask
    182                                     backing:NSBackingStoreBuffered
    183                                       defer:NO]);
    184   if ((self = [super initWithWindow:window])) {
    185     [window setDelegate:self];
    186     [window setBackgroundColor:[NSColor clearColor]];
    187     [window setOpaque:NO];
    188     [window setHasShadow:NO];
    189 
    190     // Create the content view. Take the frame from the existing content view.
    191     NSRect frame = [[window contentView] frame];
    192     scoped_nsobject<ConfirmQuitFrameView> frameView(
    193         [[ConfirmQuitFrameView alloc] initWithFrame:frame]);
    194     contentView_ = frameView.get();
    195     [window setContentView:contentView_];
    196 
    197     // Set the proper string.
    198     NSString* message = l10n_util::GetNSStringF(IDS_CONFIRM_TO_QUIT_DESCRIPTION,
    199         base::SysNSStringToUTF16([[self class] keyCommandString]));
    200     [contentView_ setMessageText:message];
    201   }
    202   return self;
    203 }
    204 
    205 + (BOOL)eventTriggersFeature:(NSEvent*)event {
    206   if ([event type] != NSKeyDown)
    207     return NO;
    208   ui::AcceleratorCocoa eventAccelerator([event charactersIgnoringModifiers],
    209       [event modifierFlags] & NSDeviceIndependentModifierFlagsMask);
    210   return [self quitAccelerator] == eventAccelerator;
    211 }
    212 
    213 - (NSApplicationTerminateReply)runModalLoopForApplication:(NSApplication*)app {
    214   scoped_nsobject<ConfirmQuitPanelController> keepAlive([self retain]);
    215 
    216   // If this is the second of two such attempts to quit within a certain time
    217   // interval, then just quit.
    218   // Time of last quit attempt, if any.
    219   static NSDate* lastQuitAttempt;  // Initially nil, as it's static.
    220   NSDate* timeNow = [NSDate date];
    221   if (lastQuitAttempt &&
    222       [timeNow timeIntervalSinceDate:lastQuitAttempt] < kTimeDeltaFuzzFactor) {
    223     // The panel tells users to Hold Cmd+Q. However, we also want to have a
    224     // double-tap shortcut that allows for a quick quit path. For the users who
    225     // tap Cmd+Q and then hold it with the window still open, this double-tap
    226     // logic will run and cause the quit to get committed. If the key
    227     // combination held down, the system will start sending the Cmd+Q event to
    228     // the next key application, and so on. This is bad, so instead we hide all
    229     // the windows (without animation) to look like we've "quit" and then wait
    230     // for the KeyUp event to commit the quit.
    231     [self hideAllWindowsForApplication:app withDuration:0];
    232     NSEvent* nextEvent = [self pumpEventQueueForKeyUp:app
    233                                             untilDate:[NSDate distantFuture]];
    234     [app discardEventsMatchingMask:NSAnyEventMask beforeEvent:nextEvent];
    235 
    236     // Based on how long the user held the keys, record the metric.
    237     if ([[NSDate date] timeIntervalSinceDate:timeNow] < kDoubleTapTimeDelta)
    238       confirm_quit::RecordHistogram(confirm_quit::kDoubleTap);
    239     else
    240       confirm_quit::RecordHistogram(confirm_quit::kTapHold);
    241     return NSTerminateNow;
    242   } else {
    243     [lastQuitAttempt release];  // Harmless if already nil.
    244     lastQuitAttempt = [timeNow retain];  // Record this attempt for next time.
    245   }
    246 
    247   // Show the info panel that explains what the user must to do confirm quit.
    248   [self showWindow:self];
    249 
    250   // Spin a nested run loop until the |targetDate| is reached or a KeyUp event
    251   // is sent.
    252   NSDate* targetDate = [NSDate dateWithTimeIntervalSinceNow:kTimeToConfirmQuit];
    253   BOOL willQuit = NO;
    254   NSEvent* nextEvent = nil;
    255   do {
    256     // Dequeue events until a key up is received. To avoid busy waiting, figure
    257     // out the amount of time that the thread can sleep before taking further
    258     // action.
    259     NSDate* waitDate = [NSDate dateWithTimeIntervalSinceNow:
    260         kTimeToConfirmQuit - kTimeDeltaFuzzFactor];
    261     nextEvent = [self pumpEventQueueForKeyUp:app untilDate:waitDate];
    262 
    263     // Wait for the time expiry to happen. Once past the hold threshold,
    264     // commit to quitting and hide all the open windows.
    265     if (!willQuit) {
    266       NSDate* now = [NSDate date];
    267       NSTimeInterval difference = [targetDate timeIntervalSinceDate:now];
    268       if (difference < kTimeDeltaFuzzFactor) {
    269         willQuit = YES;
    270 
    271         // At this point, the quit has been confirmed and windows should all
    272         // fade out to convince the user to release the key combo to finalize
    273         // the quit.
    274         [self hideAllWindowsForApplication:app
    275                               withDuration:kWindowFadeAnimationDuration];
    276       }
    277     }
    278   } while (!nextEvent);
    279 
    280   // The user has released the key combo. Discard any events (i.e. the
    281   // repeated KeyDown Cmd+Q).
    282   [app discardEventsMatchingMask:NSAnyEventMask beforeEvent:nextEvent];
    283 
    284   if (willQuit) {
    285     // The user held down the combination long enough that quitting should
    286     // happen.
    287     confirm_quit::RecordHistogram(confirm_quit::kHoldDuration);
    288     return NSTerminateNow;
    289   } else {
    290     // Slowly fade the confirm window out in case the user doesn't
    291     // understand what they have to do to quit.
    292     [self dismissPanel];
    293     return NSTerminateCancel;
    294   }
    295 
    296   // Default case: terminate.
    297   return NSTerminateNow;
    298 }
    299 
    300 - (void)windowWillClose:(NSNotification*)notif {
    301   // Release all animations because CAAnimation retains its delegate (self),
    302   // which will cause a retain cycle. Break it!
    303   [[self window] setAnimations:[NSDictionary dictionary]];
    304   g_confirmQuitPanelController = nil;
    305   [self autorelease];
    306 }
    307 
    308 - (void)showWindow:(id)sender {
    309   // If a panel that is fading out is going to be reused here, make sure it
    310   // does not get released when the animation finishes.
    311   scoped_nsobject<ConfirmQuitPanelController> keepAlive([self retain]);
    312   [[self window] setAnimations:[NSDictionary dictionary]];
    313   [[self window] center];
    314   [[self window] setAlphaValue:1.0];
    315   [super showWindow:sender];
    316 }
    317 
    318 - (void)dismissPanel {
    319   [self performSelector:@selector(animateFadeOut)
    320              withObject:nil
    321              afterDelay:1.0];
    322 }
    323 
    324 - (void)animateFadeOut {
    325   NSWindow* window = [self window];
    326   scoped_nsobject<CAAnimation> animation(
    327       [[window animationForKey:@"alphaValue"] copy]);
    328   [animation setDelegate:self];
    329   [animation setDuration:0.2];
    330   NSMutableDictionary* dictionary =
    331       [NSMutableDictionary dictionaryWithDictionary:[window animations]];
    332   [dictionary setObject:animation forKey:@"alphaValue"];
    333   [window setAnimations:dictionary];
    334   [[window animator] setAlphaValue:0.0];
    335 }
    336 
    337 - (void)animationDidStop:(CAAnimation*)theAnimation finished:(BOOL)finished {
    338   [self close];
    339 }
    340 
    341 // This looks at the Main Menu and determines what the user has set as the
    342 // key combination for quit. It then gets the modifiers and builds an object
    343 // to hold the data.
    344 + (ui::AcceleratorCocoa)quitAccelerator {
    345   NSMenu* mainMenu = [NSApp mainMenu];
    346   // Get the application menu (i.e. Chromium).
    347   NSMenu* appMenu = [[mainMenu itemAtIndex:0] submenu];
    348   for (NSMenuItem* item in [appMenu itemArray]) {
    349     // Find the Quit item.
    350     if ([item action] == @selector(terminate:)) {
    351       return ui::AcceleratorCocoa([item keyEquivalent],
    352                                   [item keyEquivalentModifierMask]);
    353     }
    354   }
    355   // Default to Cmd+Q.
    356   return ui::AcceleratorCocoa(@"q", NSCommandKeyMask);
    357 }
    358 
    359 // This looks at the Main Menu and determines what the user has set as the
    360 // key combination for quit. It then gets the modifiers and builds a string
    361 // to display them.
    362 + (NSString*)keyCommandString {
    363   ui::AcceleratorCocoa accelerator = [[self class] quitAccelerator];
    364   return [[self class] keyCombinationForAccelerator:accelerator];
    365 }
    366 
    367 // Runs a nested loop that pumps the event queue until the next KeyUp event.
    368 - (NSEvent*)pumpEventQueueForKeyUp:(NSApplication*)app untilDate:(NSDate*)date {
    369   return [app nextEventMatchingMask:NSKeyUpMask
    370                           untilDate:date
    371                              inMode:NSEventTrackingRunLoopMode
    372                             dequeue:YES];
    373 }
    374 
    375 // Iterates through the list of open windows and hides them all.
    376 - (void)hideAllWindowsForApplication:(NSApplication*)app
    377                         withDuration:(NSTimeInterval)duration {
    378   FadeAllWindowsAnimation* animation =
    379       [[FadeAllWindowsAnimation alloc] initWithApplication:app
    380                                          animationDuration:duration];
    381   // Releases itself when the animation stops.
    382   [animation startAnimation];
    383 }
    384 
    385 + (NSString*)keyCombinationForAccelerator:(const ui::AcceleratorCocoa&)item {
    386   NSMutableString* string = [NSMutableString string];
    387   NSUInteger modifiers = item.modifiers();
    388 
    389   if (modifiers & NSCommandKeyMask)
    390     [string appendString:@"\u2318"];
    391   if (modifiers & NSControlKeyMask)
    392     [string appendString:@"\u2303"];
    393   if (modifiers & NSAlternateKeyMask)
    394     [string appendString:@"\u2325"];
    395   if (modifiers & NSShiftKeyMask)
    396     [string appendString:@"\u21E7"];
    397 
    398   [string appendString:[item.characters() uppercaseString]];
    399   return string;
    400 }
    401 
    402 @end
    403