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 #include <Carbon/Carbon.h>
9 #include "base/mac/scoped_cftyperef.h"
10 #import "base/mac/sdk_forward_declarations.h"
11 #import "chrome/browser/ui/cocoa/tabs/tab_controller.h"
12 #import "chrome/browser/ui/cocoa/tabs/tab_controller_target.h"
13 #import "chrome/browser/ui/cocoa/tabs/tab_strip_controller.h"
14 #import "chrome/browser/ui/cocoa/tabs/tab_strip_view.h"
15 #import "chrome/browser/ui/cocoa/tabs/tab_view.h"
16 #import "chrome/browser/ui/cocoa/tabs/tab_window_controller.h"
17 #include "ui/gfx/mac/scoped_cocoa_disable_screen_updates.h"
19 const CGFloat kTearDistance = 36.0;
20 const NSTimeInterval kTearDuration = 0.333;
22 // Returns whether |screenPoint| is inside the bounds of |view|.
23 static BOOL PointIsInsideView(NSPoint screenPoint, NSView* view) {
24 if ([view window] == nil)
26 NSPoint windowPoint = [[view window] convertScreenToBase:screenPoint];
27 NSPoint viewPoint = [view convertPoint:windowPoint fromView:nil];
28 return [view mouse:viewPoint inRect:[view bounds]];
31 @interface TabStripDragController (Private)
32 - (NSArray*)selectedTabViews;
33 - (BOOL)canDragSelectedTabs;
34 - (void)resetDragControllers;
35 - (NSArray*)dropTargetsForController:(TabWindowController*)dragController;
36 - (void)setWindowBackgroundVisibility:(BOOL)shouldBeVisible;
37 - (void)endDrag:(NSEvent*)event;
38 - (void)continueDrag:(NSEvent*)event;
41 ////////////////////////////////////////////////////////////////////////////////
43 @implementation TabStripDragController
45 @synthesize draggedTab = draggedTab_;
47 - (id)initWithTabStripController:(TabStripController*)controller {
48 if ((self = [super init])) {
49 tabStrip_ = controller;
55 [NSObject cancelPreviousPerformRequestsWithTarget:self];
59 - (void)maybeStartDrag:(NSEvent*)theEvent forTab:(TabController*)tab {
60 [self resetDragControllers];
62 // Resolve overlay back to original window.
63 sourceWindow_ = [[tab view] window];
64 if ([sourceWindow_ isKindOfClass:[NSPanel class]]) {
65 sourceWindow_ = [sourceWindow_ parentWindow];
68 sourceWindowFrame_ = [sourceWindow_ frame];
69 sourceTabFrame_ = [[tab view] frame];
70 sourceController_ = [sourceWindow_ windowController];
74 draggingWithinTabStrip_ = YES;
75 chromeIsVisible_ = NO;
77 moveWindowOnDrag_ = ![self canDragSelectedTabs] ||
78 ![sourceController_ tabDraggingAllowed];
79 // If we are dragging a tab, a window with a single tab should immediately
80 // snap off and not drag within the tab strip.
81 if (!moveWindowOnDrag_)
82 draggingWithinTabStrip_ = [sourceController_ numberOfTabs] > 1;
84 dragOrigin_ = [NSEvent mouseLocation];
86 // When spinning the event loop, a tab can get detached, which could lead to
87 // our own destruction. Keep ourselves around while spinning the loop as well
88 // as the tab controller being dragged.
89 base::scoped_nsobject<TabStripDragController> keepAlive([self retain]);
90 base::scoped_nsobject<TabController> keepAliveTab([tab retain]);
92 // Because we move views between windows, we need to handle the event loop
93 // ourselves. Ideally we should use the standard event loop.
95 const NSUInteger mask =
96 NSLeftMouseUpMask | NSLeftMouseDraggedMask | NSKeyUpMask;
98 [NSApp nextEventMatchingMask:mask
99 untilDate:[NSDate distantFuture]
100 inMode:NSDefaultRunLoopMode
103 // Ensure that any window changes that happen while handling this event
104 // appear atomically.
105 gfx::ScopedCocoaDisableScreenUpdates disabler;
107 NSEventType type = [theEvent type];
108 if (type == NSKeyUp) {
109 if ([theEvent keyCode] == kVK_Escape) {
110 // Cancel the drag and restore the previous state.
111 if (draggingWithinTabStrip_) {
112 // Simply pretend the tab wasn't dragged (far enough).
115 [targetController_ removePlaceholder];
116 [[sourceController_ window] makeMainWindow];
117 if ([sourceController_ numberOfTabs] < 2) {
118 // Revert to a single-tab window.
119 targetController_ = nil;
121 // Change the target to the source controller.
122 targetController_ = sourceController_;
123 [targetController_ insertPlaceholderForTab:[tab tabView]
124 frame:sourceTabFrame_];
127 // Simply end the drag at this point.
128 [self endDrag:theEvent];
131 } else if (type == NSLeftMouseDragged) {
132 [self continueDrag:theEvent];
133 } else if (type == NSLeftMouseUp) {
134 [tab selectTab:self];
135 [self endDrag:theEvent];
138 // TODO(viettrungluu): [crbug.com/23830] We can receive right-mouse-ups
139 // (and maybe even others?) for reasons I don't understand. So we
140 // explicitly check for both events we're expecting, and log others. We
141 // should figure out what's going on.
142 LOG(WARNING) << "Spurious event received of type " << type << ".";
147 - (void)continueDrag:(NSEvent*)theEvent {
150 // Cancel any delayed -continueDrag: requests that may still be pending.
151 [NSObject cancelPreviousPerformRequestsWithTarget:self];
153 // Special-case this to keep the logic below simpler.
154 if (moveWindowOnDrag_) {
155 if ([sourceController_ windowMovementAllowed]) {
156 NSPoint thisPoint = [NSEvent mouseLocation];
157 NSPoint origin = sourceWindowFrame_.origin;
158 origin.x += (thisPoint.x - dragOrigin_.x);
159 origin.y += (thisPoint.y - dragOrigin_.y);
160 [sourceWindow_ setFrameOrigin:NSMakePoint(origin.x, origin.y)];
161 } // else do nothing.
165 // First, go through the magnetic drag cycle. We break out of this if
166 // "stretchiness" ever exceeds a set amount.
167 tabWasDragged_ = YES;
169 if (draggingWithinTabStrip_) {
170 NSPoint thisPoint = [NSEvent mouseLocation];
171 CGFloat offset = thisPoint.x - dragOrigin_.x;
172 [sourceController_ insertPlaceholderForTab:[draggedTab_ tabView]
173 frame:NSOffsetRect(sourceTabFrame_,
175 // Check that we haven't pulled the tab too far to start a drag. This
176 // can include either pulling it too far down, or off the side of the tab
177 // strip that would cause it to no longer be fully visible.
179 [sourceController_ isTabFullyVisible:[draggedTab_ tabView]];
180 CGFloat tearForce = fabs(thisPoint.y - dragOrigin_.y);
181 if ([sourceController_ tabTearingAllowed] &&
182 (tearForce > kTearDistance || !stillVisible)) {
183 draggingWithinTabStrip_ = NO;
184 // When you finally leave the strip, we treat that as the origin.
185 dragOrigin_.x = thisPoint.x;
187 // Still dragging within the tab strip, wait for the next drag event.
192 NSPoint thisPoint = [NSEvent mouseLocation];
194 // Iterate over possible targets checking for the one the mouse is in.
195 // If the tab is just in the frame, bring the window forward to make it
196 // easier to drop something there. If it's in the tab strip, set the new
197 // target so that it pops into that window. We can't cache this because we
198 // need the z-order to be correct.
199 NSArray* targets = [self dropTargetsForController:draggedController_];
200 TabWindowController* newTarget = nil;
201 for (TabWindowController* target in targets) {
202 NSRect windowFrame = [[target window] frame];
203 if (NSPointInRect(thisPoint, windowFrame)) {
204 [[target window] orderFront:self];
205 if (PointIsInsideView(thisPoint, [target tabStripView])) {
212 // If we're now targeting a new window, re-layout the tabs in the old
213 // target and reset how long we've been hovering over this new one.
214 if (targetController_ != newTarget) {
215 [targetController_ removePlaceholder];
216 targetController_ = newTarget;
218 tearTime_ = [NSDate timeIntervalSinceReferenceDate];
219 tearOrigin_ = [dragWindow_ frame].origin;
223 // Create or identify the dragged controller.
224 if (!draggedController_) {
225 // Detach from the current window and put it in a new window. If there are
226 // no more tabs remaining after detaching, the source window is about to
227 // go away (it's been autoreleased) so we need to ensure we don't reference
228 // it any more. In that case the new controller becomes our source
230 NSArray* tabs = [self selectedTabViews];
232 [sourceController_ detachTabsToNewWindow:tabs
233 draggedTab:[draggedTab_ tabView]];
235 dragWindow_ = [draggedController_ window];
236 [dragWindow_ setAlphaValue:0.0];
237 if ([sourceController_ hasLiveTabs]) {
238 if (PointIsInsideView(thisPoint, [sourceController_ tabStripView])) {
239 // We don't want to remove the source window's placeholder here because
240 // the new tab button may briefly flash in and out if we remove and add
241 // back the placeholder.
242 // Instead, we will remove the placeholder later when the target window
244 targetController_ = sourceController_;
246 [sourceController_ removePlaceholder];
249 [sourceController_ removePlaceholder];
250 sourceController_ = draggedController_;
251 sourceWindow_ = dragWindow_;
254 // Disable window animation before calling |orderFront:| when detaching
256 NSWindowAnimationBehavior savedAnimationBehavior =
257 NSWindowAnimationBehaviorDefault;
258 bool didSaveAnimationBehavior = false;
259 if ([dragWindow_ respondsToSelector:@selector(animationBehavior)] &&
260 [dragWindow_ respondsToSelector:@selector(setAnimationBehavior:)]) {
261 didSaveAnimationBehavior = true;
262 savedAnimationBehavior = [dragWindow_ animationBehavior];
263 [dragWindow_ setAnimationBehavior:NSWindowAnimationBehaviorNone];
266 // If dragging the tab only moves the current window, do not show overlay
267 // so that sheets stay on top of the window.
268 // Bring the target window to the front and make sure it has a border.
269 [dragWindow_ setLevel:NSFloatingWindowLevel];
270 [dragWindow_ setHasShadow:YES];
271 [dragWindow_ orderFront:nil];
272 [dragWindow_ makeMainWindow];
273 [draggedController_ showOverlay];
274 dragOverlay_ = [draggedController_ overlayWindow];
275 [dragOverlay_ setHasShadow:YES];
276 // Force the new tab button to be hidden. We'll reset it on mouse up.
277 [draggedController_ showNewTabButton:NO];
278 tearTime_ = [NSDate timeIntervalSinceReferenceDate];
279 tearOrigin_ = sourceWindowFrame_.origin;
281 // Restore window animation behavior.
282 if (didSaveAnimationBehavior)
283 [dragWindow_ setAnimationBehavior:savedAnimationBehavior];
286 // TODO(pinkerton): http://crbug.com/25682 demonstrates a way to get here by
287 // some weird circumstance that doesn't first go through mouseDown:. We
288 // really shouldn't go any farther.
289 if (!draggedController_ || !sourceController_)
292 // When the user first tears off the window, we want slide the window to
293 // the current mouse location (to reduce the jarring appearance). We do this
294 // by calling ourselves back with additional -continueDrag: calls (not actual
295 // events). |tearProgress| is a normalized measure of how far through this
296 // tear "animation" (of length kTearDuration) we are and has values [0..1].
297 // We use sqrt() so the animation is non-linear (slow down near the end
299 NSTimeInterval tearProgress =
300 [NSDate timeIntervalSinceReferenceDate] - tearTime_;
301 tearProgress /= kTearDuration; // Normalize.
302 tearProgress = sqrtf(MAX(MIN(tearProgress, 1.0), 0.0));
304 // Move the dragged window to the right place on the screen.
305 NSPoint origin = sourceWindowFrame_.origin;
306 origin.x += (thisPoint.x - dragOrigin_.x);
307 origin.y += (thisPoint.y - dragOrigin_.y);
309 if (tearProgress < 1) {
310 // If the tear animation is not complete, call back to ourself with the
311 // same event to animate even if the mouse isn't moving. We need to make
312 // sure these get cancelled in -endDrag:.
313 [NSObject cancelPreviousPerformRequestsWithTarget:self];
314 [self performSelector:@selector(continueDrag:)
316 afterDelay:1.0f/30.0f];
318 // Set the current window origin based on how far we've progressed through
319 // the tear animation.
320 origin.x = (1 - tearProgress) * tearOrigin_.x + tearProgress * origin.x;
321 origin.y = (1 - tearProgress) * tearOrigin_.y + tearProgress * origin.y;
324 if (targetController_) {
325 // In order to "snap" two windows of different sizes together at their
326 // toolbar, we can't just use the origin of the target frame. We also have
327 // to take into consideration the difference in height.
328 NSRect targetFrame = [[targetController_ window] frame];
329 NSRect sourceFrame = [dragWindow_ frame];
330 origin.y = NSMinY(targetFrame) +
331 (NSHeight(targetFrame) - NSHeight(sourceFrame));
333 [dragWindow_ setFrameOrigin:NSMakePoint(origin.x, origin.y)];
335 // If we're not hovering over any window, make the window fully
336 // opaque. Otherwise, find where the tab might be dropped and insert
337 // a placeholder so it appears like it's part of that window.
338 if (targetController_) {
339 if (![[targetController_ window] isKeyWindow])
340 [[targetController_ window] orderFront:nil];
342 // Compute where placeholder should go and insert it into the
343 // destination tab strip.
344 // The placeholder frame is the rect that contains all dragged tabs.
345 NSRect tabFrame = NSZeroRect;
346 for (NSView* tabView in [draggedController_ tabViews]) {
347 tabFrame = NSUnionRect(tabFrame, [tabView frame]);
349 tabFrame.origin = [dragWindow_ convertBaseToScreen:tabFrame.origin];
350 tabFrame.origin = [[targetController_ window]
351 convertScreenToBase:tabFrame.origin];
352 tabFrame = [[targetController_ tabStripView]
353 convertRect:tabFrame fromView:nil];
354 [targetController_ insertPlaceholderForTab:[draggedTab_ tabView]
356 [targetController_ layoutTabs];
358 [dragWindow_ makeKeyAndOrderFront:nil];
361 // Adjust the visibility of the window background. If there is a drop target,
362 // we want to hide the window background so the tab stands out for
363 // positioning. If not, we want to show it so it looks like a new window will
365 BOOL chromeShouldBeVisible = targetController_ == nil;
366 [self setWindowBackgroundVisibility:chromeShouldBeVisible];
369 - (void)endDrag:(NSEvent*)event {
370 // Cancel any delayed -continueDrag: requests that may still be pending.
371 [NSObject cancelPreviousPerformRequestsWithTarget:self];
373 // Special-case this to keep the logic below simpler.
374 if (moveWindowOnDrag_) {
375 [self resetDragControllers];
379 // TODO(pinkerton): http://crbug.com/25682 demonstrates a way to get here by
380 // some weird circumstance that doesn't first go through mouseDown:. We
381 // really shouldn't go any farther.
382 if (!sourceController_)
385 // We are now free to re-display the new tab button in the window we're
386 // dragging. It will show when the next call to -layoutTabs (which happens
387 // indirectly by several of the calls below, such as removing the
389 [draggedController_ showNewTabButton:YES];
391 if (draggingWithinTabStrip_) {
392 if (tabWasDragged_) {
393 // Move tab to new location.
394 DCHECK([sourceController_ numberOfTabs]);
395 TabWindowController* dropController = sourceController_;
396 [dropController moveTabViews:@[ [dropController activeTabView] ]
399 } else if (targetController_) {
400 // Move between windows. If |targetController_| is nil, we're not dropping
401 // into any existing window.
402 [targetController_ moveTabViews:[draggedController_ tabViews]
403 fromController:draggedController_];
404 // Force redraw to avoid flashes of old content before returning to event
406 [[targetController_ window] display];
407 [targetController_ showWindow:nil];
408 [draggedController_ removeOverlay];
410 // Only move the window around on screen. Make sure it's set back to
411 // normal state (fully opaque, has shadow, has key, etc).
412 [draggedController_ removeOverlay];
413 // Don't want to re-show the window if it was closed during the drag.
414 if ([dragWindow_ isVisible]) {
415 [dragWindow_ setAlphaValue:1.0];
416 [dragOverlay_ setHasShadow:NO];
417 [dragWindow_ setHasShadow:YES];
418 [dragWindow_ makeKeyAndOrderFront:nil];
420 [[draggedController_ window] setLevel:NSNormalWindowLevel];
421 [draggedController_ removePlaceholder];
423 [sourceController_ removePlaceholder];
424 chromeIsVisible_ = YES;
426 [self resetDragControllers];
429 // Private /////////////////////////////////////////////////////////////////////
431 - (NSArray*)selectedTabViews {
432 return [draggedTab_ selected] ? [tabStrip_ selectedViews]
433 : @[ [draggedTab_ tabView] ];
436 - (BOOL)canDragSelectedTabs {
437 NSArray* tabs = [self selectedTabViews];
439 // If there's more than one potential window to be a drop target, we want to
440 // treat a drag of a tab just like dragging around a tab that's already
441 // detached. Note that unit tests might have |-numberOfTabs| reporting zero
442 // since the model won't be fully hooked up. We need to be prepared for that
443 // and not send them into the "magnetic" codepath.
444 NSArray* targets = [self dropTargetsForController:sourceController_];
445 if (![targets count] && [sourceController_ numberOfTabs] - [tabs count] == 0)
446 return NO; // I.e. ignore dragging *all* tabs in the last Browser window.
448 for (TabView* tabView in tabs) {
449 if ([tabView isClosing])
453 NSWindowController* controller = [sourceWindow_ windowController];
454 if ([controller isKindOfClass:[TabWindowController class]]) {
455 TabWindowController* realController =
456 static_cast<TabWindowController*>(controller);
457 for (TabView* tabView in tabs) {
458 if (![realController isTabDraggable:tabView])
465 // Call to clear out transient weak references we hold during drags.
466 - (void)resetDragControllers {
468 draggedController_ = nil;
471 sourceController_ = nil;
473 targetController_ = nil;
476 // Returns an array of controllers that could be a drop target, ordered front to
477 // back. It has to be of the appropriate class, and visible (obviously). Note
478 // that the window cannot be a target for itself.
479 - (NSArray*)dropTargetsForController:(TabWindowController*)dragController {
480 NSMutableArray* targets = [NSMutableArray array];
481 NSWindow* dragWindow = [dragController window];
482 for (NSWindow* window in [NSApp orderedWindows]) {
483 if (window == dragWindow) continue;
484 if (![window isVisible]) continue;
485 // Skip windows on the wrong space.
486 if (![window isOnActiveSpace])
488 NSWindowController* controller = [window windowController];
489 if ([controller isKindOfClass:[TabWindowController class]]) {
490 TabWindowController* realController =
491 static_cast<TabWindowController*>(controller);
492 if ([realController canReceiveFrom:dragController])
493 [targets addObject:controller];
499 // Sets whether the window background should be visible or invisible when
500 // dragging a tab. The background should be invisible when the mouse is over a
501 // potential drop target for the tab (the tab strip). It should be visible when
502 // there's no drop target so the window looks more fully realized and ready to
503 // become a stand-alone window.
504 - (void)setWindowBackgroundVisibility:(BOOL)shouldBeVisible {
505 if (chromeIsVisible_ == shouldBeVisible)
508 // There appears to be a race-condition in CoreAnimation where if we use
509 // animators to set the alpha values, we can't guarantee that we cancel them.
510 // This has the side effect of sometimes leaving the dragged window
511 // translucent or invisible. As a result, don't animate the alpha change.
512 [[draggedController_ overlayWindow] setAlphaValue:1.0];
513 if (targetController_) {
514 [dragWindow_ setAlphaValue:0.0];
515 [[draggedController_ overlayWindow] setHasShadow:YES];
516 [[targetController_ window] makeMainWindow];
518 [dragWindow_ setAlphaValue:0.5];
519 [[draggedController_ overlayWindow] setHasShadow:NO];
520 [[draggedController_ window] makeMainWindow];
522 chromeIsVisible_ = shouldBeVisible;