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