• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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.
19const NSTimeInterval kTimeToConfirmQuit = 1.5;
20
21// Leeway between the |targetDate| and the current time that will confirm a
22// quit.
23const NSTimeInterval kTimeDeltaFuzzFactor = 1.0;
24
25// Duration of the window fade out animation.
26const NSTimeInterval kWindowFadeAnimationDuration = 0.2;
27
28// For metrics recording only: How long the user must hold the keys to
29// differentitate kDoubleTap from kTapHold.
30const NSTimeInterval kDoubleTapTimeDelta = 0.32;
31
32// Functions ///////////////////////////////////////////////////////////////////
33
34namespace confirm_quit {
35
36void 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
163ConfirmQuitPanelController* 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