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.
5 #import "chrome/browser/ui/cocoa/tabs/tab_strip_drag_controller.h"
7 #import "base/mac/mac_util.h"
8 #include "base/mac/scoped_cftyperef.h"
9 #import "chrome/browser/ui/cocoa/tabs/tab_controller.h"
10 #import "chrome/browser/ui/cocoa/tabs/tab_controller_target.h"
11 #import "chrome/browser/ui/cocoa/tabs/tab_view.h"
12 #import "chrome/browser/ui/cocoa/tabs/tab_window_controller.h"
14 // Replicate specific 10.7 SDK declarations for building with prior SDKs.
15 #if !defined(MAC_OS_X_VERSION_10_7) || \
16 MAC_OS_X_VERSION_MAX_ALLOWED < MAC_OS_X_VERSION_10_7
19 NSWindowAnimationBehaviorDefault = 0,
20 NSWindowAnimationBehaviorNone = 2,
21 NSWindowAnimationBehaviorDocumentWindow = 3,
22 NSWindowAnimationBehaviorUtilityWindow = 4,
23 NSWindowAnimationBehaviorAlertPanel = 5
25 typedef NSInteger NSWindowAnimationBehavior;
27 @interface NSWindow (LionSDKDeclarations)
28 - (NSWindowAnimationBehavior)animationBehavior;
29 - (void)setAnimationBehavior:(NSWindowAnimationBehavior)newAnimationBehavior;
32 #endif // MAC_OS_X_VERSION_10_7
34 const CGFloat kTearDistance = 36.0;
35 const NSTimeInterval kTearDuration = 0.333;
37 @interface TabStripDragController (Private)
38 - (void)resetDragControllers;
39 - (NSArray*)dropTargetsForController:(TabWindowController*)dragController;
40 - (void)setWindowBackgroundVisibility:(BOOL)shouldBeVisible;
41 - (void)endDrag:(NSEvent*)event;
42 - (void)continueDrag:(NSEvent*)event;
45 ////////////////////////////////////////////////////////////////////////////////
47 @implementation TabStripDragController
49 - (id)initWithTabStripController:(TabStripController*)controller {
50 if ((self = [super init])) {
51 tabStrip_ = controller;
57 [NSObject cancelPreviousPerformRequestsWithTarget:self];
61 - (BOOL)tabCanBeDragged:(TabController*)tab {
62 if ([[tab tabView] isClosing])
64 NSWindowController* controller = [sourceWindow_ windowController];
65 if ([controller isKindOfClass:[TabWindowController class]]) {
66 TabWindowController* realController =
67 static_cast<TabWindowController*>(controller);
68 return [realController isTabDraggable:[tab tabView]];
73 - (void)maybeStartDrag:(NSEvent*)theEvent forTab:(TabController*)tab {
74 [self resetDragControllers];
76 // Resolve overlay back to original window.
77 sourceWindow_ = [[tab view] window];
78 if ([sourceWindow_ isKindOfClass:[NSPanel class]]) {
79 sourceWindow_ = [sourceWindow_ parentWindow];
82 sourceWindowFrame_ = [sourceWindow_ frame];
83 sourceTabFrame_ = [[tab view] frame];
84 sourceController_ = [sourceWindow_ windowController];
88 draggingWithinTabStrip_ = YES;
89 chromeIsVisible_ = NO;
91 // If there's more than one potential window to be a drop target, we want to
92 // treat a drag of a tab just like dragging around a tab that's already
93 // detached. Note that unit tests might have |-numberOfTabs| reporting zero
94 // since the model won't be fully hooked up. We need to be prepared for that
95 // and not send them into the "magnetic" codepath.
96 NSArray* targets = [self dropTargetsForController:sourceController_];
98 ([sourceController_ numberOfTabs] < 2 && ![targets count]) ||
99 ![self tabCanBeDragged:tab] ||
100 ![sourceController_ tabDraggingAllowed];
101 // If we are dragging a tab, a window with a single tab should immediately
102 // snap off and not drag within the tab strip.
103 if (!moveWindowOnDrag_)
104 draggingWithinTabStrip_ = [sourceController_ numberOfTabs] > 1;
106 dragOrigin_ = [NSEvent mouseLocation];
108 // When spinning the event loop, a tab can get detached, which could lead to
109 // our own destruction. Keep ourselves around while spinning the loop as well
110 // as the tab controller being dragged.
111 base::scoped_nsobject<TabStripDragController> keepAlive([self retain]);
112 base::scoped_nsobject<TabController> keepAliveTab([tab retain]);
114 // Because we move views between windows, we need to handle the event loop
115 // ourselves. Ideally we should use the standard event loop.
117 const NSUInteger mask =
118 NSLeftMouseUpMask | NSLeftMouseDraggedMask | NSKeyUpMask;
120 [NSApp nextEventMatchingMask:mask
121 untilDate:[NSDate distantFuture]
122 inMode:NSDefaultRunLoopMode
124 NSEventType type = [theEvent type];
125 if (type == NSKeyUp) {
126 if ([theEvent keyCode] == kVK_Escape) {
127 // Cancel the drag and restore the previous state.
128 if (draggingWithinTabStrip_) {
129 // Simply pretend the tab wasn't dragged (far enough).
132 [targetController_ removePlaceholder];
133 if ([sourceController_ numberOfTabs] < 2) {
134 // Revert to a single-tab window.
135 targetController_ = nil;
137 // Change the target to the source controller.
138 targetController_ = sourceController_;
139 [targetController_ insertPlaceholderForTab:[tab tabView]
140 frame:sourceTabFrame_];
143 // Simply end the drag at this point.
144 [self endDrag:theEvent];
147 } else if (type == NSLeftMouseDragged) {
148 [self continueDrag:theEvent];
149 } else if (type == NSLeftMouseUp) {
150 [tab selectTab:self];
151 [self endDrag:theEvent];
154 // TODO(viettrungluu): [crbug.com/23830] We can receive right-mouse-ups
155 // (and maybe even others?) for reasons I don't understand. So we
156 // explicitly check for both events we're expecting, and log others. We
157 // should figure out what's going on.
158 LOG(WARNING) << "Spurious event received of type " << type << ".";
163 - (void)continueDrag:(NSEvent*)theEvent {
166 // Cancel any delayed -continueDrag: requests that may still be pending.
167 [NSObject cancelPreviousPerformRequestsWithTarget:self];
169 // Special-case this to keep the logic below simpler.
170 if (moveWindowOnDrag_) {
171 if ([sourceController_ windowMovementAllowed]) {
172 NSPoint thisPoint = [NSEvent mouseLocation];
173 NSPoint origin = sourceWindowFrame_.origin;
174 origin.x += (thisPoint.x - dragOrigin_.x);
175 origin.y += (thisPoint.y - dragOrigin_.y);
176 [sourceWindow_ setFrameOrigin:NSMakePoint(origin.x, origin.y)];
177 } // else do nothing.
181 // First, go through the magnetic drag cycle. We break out of this if
182 // "stretchiness" ever exceeds a set amount.
183 tabWasDragged_ = YES;
185 if (draggingWithinTabStrip_) {
186 NSPoint thisPoint = [NSEvent mouseLocation];
187 CGFloat offset = thisPoint.x - dragOrigin_.x;
188 [sourceController_ insertPlaceholderForTab:[draggedTab_ tabView]
189 frame:NSOffsetRect(sourceTabFrame_,
191 // Check that we haven't pulled the tab too far to start a drag. This
192 // can include either pulling it too far down, or off the side of the tab
193 // strip that would cause it to no longer be fully visible.
195 [sourceController_ isTabFullyVisible:[draggedTab_ tabView]];
196 CGFloat tearForce = fabs(thisPoint.y - dragOrigin_.y);
197 if ([sourceController_ tabTearingAllowed] &&
198 (tearForce > kTearDistance || !stillVisible)) {
199 draggingWithinTabStrip_ = NO;
200 // When you finally leave the strip, we treat that as the origin.
201 dragOrigin_.x = thisPoint.x;
203 // Still dragging within the tab strip, wait for the next drag event.
208 // Do not start dragging until the user has "torn" the tab off by
209 // moving more than 3 pixels.
210 NSPoint thisPoint = [NSEvent mouseLocation];
212 // Iterate over possible targets checking for the one the mouse is in.
213 // If the tab is just in the frame, bring the window forward to make it
214 // easier to drop something there. If it's in the tab strip, set the new
215 // target so that it pops into that window. We can't cache this because we
216 // need the z-order to be correct.
217 NSArray* targets = [self dropTargetsForController:draggedController_];
218 TabWindowController* newTarget = nil;
219 for (TabWindowController* target in targets) {
220 NSRect windowFrame = [[target window] frame];
221 if (NSPointInRect(thisPoint, windowFrame)) {
222 [[target window] orderFront:self];
223 NSRect tabStripFrame = [[target tabStripView] frame];
224 tabStripFrame.origin = [[target window]
225 convertBaseToScreen:tabStripFrame.origin];
226 if (NSPointInRect(thisPoint, tabStripFrame)) {
233 // If we're now targeting a new window, re-layout the tabs in the old
234 // target and reset how long we've been hovering over this new one.
235 if (targetController_ != newTarget) {
236 [targetController_ removePlaceholder];
237 targetController_ = newTarget;
239 tearTime_ = [NSDate timeIntervalSinceReferenceDate];
240 tearOrigin_ = [dragWindow_ frame].origin;
244 // Create or identify the dragged controller.
245 if (!draggedController_) {
246 // Get rid of any placeholder remaining in the original source window.
247 [sourceController_ removePlaceholder];
249 // Detach from the current window and put it in a new window. If there are
250 // no more tabs remaining after detaching, the source window is about to
251 // go away (it's been autoreleased) so we need to ensure we don't reference
252 // it any more. In that case the new controller becomes our source
255 [sourceController_ detachTabToNewWindow:[draggedTab_ tabView]];
256 dragWindow_ = [draggedController_ window];
257 [dragWindow_ setAlphaValue:0.0];
258 if (![sourceController_ hasLiveTabs]) {
259 sourceController_ = draggedController_;
260 sourceWindow_ = dragWindow_;
263 // Disable window animation before calling |orderFront:| when detaching
265 NSWindowAnimationBehavior savedAnimationBehavior =
266 NSWindowAnimationBehaviorDefault;
267 bool didSaveAnimationBehavior = false;
268 if ([dragWindow_ respondsToSelector:@selector(animationBehavior)] &&
269 [dragWindow_ respondsToSelector:@selector(setAnimationBehavior:)]) {
270 didSaveAnimationBehavior = true;
271 savedAnimationBehavior = [dragWindow_ animationBehavior];
272 [dragWindow_ setAnimationBehavior:NSWindowAnimationBehaviorNone];
275 // If dragging the tab only moves the current window, do not show overlay
276 // so that sheets stay on top of the window.
277 // Bring the target window to the front and make sure it has a border.
278 [dragWindow_ setLevel:NSFloatingWindowLevel];
279 [dragWindow_ setHasShadow:YES];
280 [dragWindow_ orderFront:nil];
281 [dragWindow_ makeMainWindow];
282 [draggedController_ showOverlay];
283 dragOverlay_ = [draggedController_ overlayWindow];
284 // Force the new tab button to be hidden. We'll reset it on mouse up.
285 [draggedController_ showNewTabButton:NO];
286 tearTime_ = [NSDate timeIntervalSinceReferenceDate];
287 tearOrigin_ = sourceWindowFrame_.origin;
289 // Restore window animation behavior.
290 if (didSaveAnimationBehavior)
291 [dragWindow_ setAnimationBehavior:savedAnimationBehavior];
294 // TODO(pinkerton): http://crbug.com/25682 demonstrates a way to get here by
295 // some weird circumstance that doesn't first go through mouseDown:. We
296 // really shouldn't go any farther.
297 if (!draggedController_ || !sourceController_)
300 // When the user first tears off the window, we want slide the window to
301 // the current mouse location (to reduce the jarring appearance). We do this
302 // by calling ourselves back with additional -continueDrag: calls (not actual
303 // events). |tearProgress| is a normalized measure of how far through this
304 // tear "animation" (of length kTearDuration) we are and has values [0..1].
305 // We use sqrt() so the animation is non-linear (slow down near the end
307 NSTimeInterval tearProgress =
308 [NSDate timeIntervalSinceReferenceDate] - tearTime_;
309 tearProgress /= kTearDuration; // Normalize.
310 tearProgress = sqrtf(MAX(MIN(tearProgress, 1.0), 0.0));
312 // Move the dragged window to the right place on the screen.
313 NSPoint origin = sourceWindowFrame_.origin;
314 origin.x += (thisPoint.x - dragOrigin_.x);
315 origin.y += (thisPoint.y - dragOrigin_.y);
317 if (tearProgress < 1) {
318 // If the tear animation is not complete, call back to ourself with the
319 // same event to animate even if the mouse isn't moving. We need to make
320 // sure these get cancelled in -endDrag:.
321 [NSObject cancelPreviousPerformRequestsWithTarget:self];
322 [self performSelector:@selector(continueDrag:)
324 afterDelay:1.0f/30.0f];
326 // Set the current window origin based on how far we've progressed through
327 // the tear animation.
328 origin.x = (1 - tearProgress) * tearOrigin_.x + tearProgress * origin.x;
329 origin.y = (1 - tearProgress) * tearOrigin_.y + tearProgress * origin.y;
332 if (targetController_) {
333 // In order to "snap" two windows of different sizes together at their
334 // toolbar, we can't just use the origin of the target frame. We also have
335 // to take into consideration the difference in height.
336 NSRect targetFrame = [[targetController_ window] frame];
337 NSRect sourceFrame = [dragWindow_ frame];
338 origin.y = NSMinY(targetFrame) +
339 (NSHeight(targetFrame) - NSHeight(sourceFrame));
341 [dragWindow_ setFrameOrigin:NSMakePoint(origin.x, origin.y)];
343 // If we're not hovering over any window, make the window fully
344 // opaque. Otherwise, find where the tab might be dropped and insert
345 // a placeholder so it appears like it's part of that window.
346 if (targetController_) {
347 if (![[targetController_ window] isKeyWindow])
348 [[targetController_ window] orderFront:nil];
350 // Compute where placeholder should go and insert it into the
351 // destination tab strip.
352 TabView* draggedTabView = (TabView*)[draggedController_ activeTabView];
353 NSRect tabFrame = [draggedTabView frame];
354 tabFrame.origin = [dragWindow_ convertBaseToScreen:tabFrame.origin];
355 tabFrame.origin = [[targetController_ window]
356 convertScreenToBase:tabFrame.origin];
357 tabFrame = [[targetController_ tabStripView]
358 convertRect:tabFrame fromView:nil];
359 [targetController_ insertPlaceholderForTab:[draggedTab_ tabView]
361 [targetController_ layoutTabs];
363 [dragWindow_ makeKeyAndOrderFront:nil];
366 // Adjust the visibility of the window background. If there is a drop target,
367 // we want to hide the window background so the tab stands out for
368 // positioning. If not, we want to show it so it looks like a new window will
370 BOOL chromeShouldBeVisible = targetController_ == nil;
371 [self setWindowBackgroundVisibility:chromeShouldBeVisible];
374 - (void)endDrag:(NSEvent*)event {
375 // Cancel any delayed -continueDrag: requests that may still be pending.
376 [NSObject cancelPreviousPerformRequestsWithTarget:self];
378 // Special-case this to keep the logic below simpler.
379 if (moveWindowOnDrag_) {
380 [self resetDragControllers];
384 // TODO(pinkerton): http://crbug.com/25682 demonstrates a way to get here by
385 // some weird circumstance that doesn't first go through mouseDown:. We
386 // really shouldn't go any farther.
387 if (!sourceController_)
390 // We are now free to re-display the new tab button in the window we're
391 // dragging. It will show when the next call to -layoutTabs (which happens
392 // indirectly by several of the calls below, such as removing the
394 [draggedController_ showNewTabButton:YES];
396 if (draggingWithinTabStrip_) {
397 if (tabWasDragged_) {
398 // Move tab to new location.
399 DCHECK([sourceController_ numberOfTabs]);
400 TabWindowController* dropController = sourceController_;
401 [dropController moveTabView:[dropController activeTabView]
404 } else if (targetController_) {
405 // Move between windows. If |targetController_| is nil, we're not dropping
406 // into any existing window.
407 NSView* draggedTabView = [draggedController_ activeTabView];
408 [targetController_ moveTabView:draggedTabView
409 fromController:draggedController_];
410 // Force redraw to avoid flashes of old content before returning to event
412 [[targetController_ window] display];
413 [targetController_ showWindow:nil];
414 [draggedController_ removeOverlay];
416 // Only move the window around on screen. Make sure it's set back to
417 // normal state (fully opaque, has shadow, has key, etc).
418 [draggedController_ removeOverlay];
419 // Don't want to re-show the window if it was closed during the drag.
420 if ([dragWindow_ isVisible]) {
421 [dragWindow_ setAlphaValue:1.0];
422 [dragOverlay_ setHasShadow:NO];
423 [dragWindow_ setHasShadow:YES];
424 [dragWindow_ makeKeyAndOrderFront:nil];
426 [[draggedController_ window] setLevel:NSNormalWindowLevel];
427 [draggedController_ removePlaceholder];
429 [sourceController_ removePlaceholder];
430 chromeIsVisible_ = YES;
432 [self resetDragControllers];
435 // Private /////////////////////////////////////////////////////////////////////
437 // Call to clear out transient weak references we hold during drags.
438 - (void)resetDragControllers {
440 draggedController_ = nil;
443 sourceController_ = nil;
445 targetController_ = nil;
448 // Returns an array of controllers that could be a drop target, ordered front to
449 // back. It has to be of the appropriate class, and visible (obviously). Note
450 // that the window cannot be a target for itself.
451 - (NSArray*)dropTargetsForController:(TabWindowController*)dragController {
452 NSMutableArray* targets = [NSMutableArray array];
453 NSWindow* dragWindow = [dragController window];
454 for (NSWindow* window in [NSApp orderedWindows]) {
455 if (window == dragWindow) continue;
456 if (![window isVisible]) continue;
457 // Skip windows on the wrong space.
458 if (![window isOnActiveSpace])
460 NSWindowController* controller = [window windowController];
461 if ([controller isKindOfClass:[TabWindowController class]]) {
462 TabWindowController* realController =
463 static_cast<TabWindowController*>(controller);
464 if ([realController canReceiveFrom:dragController])
465 [targets addObject:controller];
471 // Sets whether the window background should be visible or invisible when
472 // dragging a tab. The background should be invisible when the mouse is over a
473 // potential drop target for the tab (the tab strip). It should be visible when
474 // there's no drop target so the window looks more fully realized and ready to
475 // become a stand-alone window.
476 - (void)setWindowBackgroundVisibility:(BOOL)shouldBeVisible {
477 if (chromeIsVisible_ == shouldBeVisible)
480 // There appears to be a race-condition in CoreAnimation where if we use
481 // animators to set the alpha values, we can't guarantee that we cancel them.
482 // This has the side effect of sometimes leaving the dragged window
483 // translucent or invisible. As a result, don't animate the alpha change.
484 [[draggedController_ overlayWindow] setAlphaValue:1.0];
485 if (targetController_) {
486 [dragWindow_ setAlphaValue:0.0];
487 [[draggedController_ overlayWindow] setHasShadow:YES];
488 [[targetController_ window] makeMainWindow];
490 [dragWindow_ setAlphaValue:0.5];
491 [[draggedController_ overlayWindow] setHasShadow:NO];
492 [[draggedController_ window] makeMainWindow];
494 chromeIsVisible_ = shouldBeVisible;