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