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