diff options
Diffstat (limited to 'chrome/browser/ui')
636 files changed, 95724 insertions, 2 deletions
diff --git a/chrome/browser/ui/browser.cc b/chrome/browser/ui/browser.cc index 9085f5f..3653aa9 100644 --- a/chrome/browser/ui/browser.cc +++ b/chrome/browser/ui/browser.cc @@ -127,7 +127,7 @@ #endif // OS_WIN #if defined(OS_MACOSX) -#include "chrome/browser/cocoa/find_pasteboard.h" +#include "chrome/browser/ui/cocoa/find_pasteboard.h" #endif #if defined(OS_CHROMEOS) diff --git a/chrome/browser/ui/browser_init.cc b/chrome/browser/ui/browser_init.cc index 0ac4f04..2bd55dc 100644 --- a/chrome/browser/ui/browser_init.cc +++ b/chrome/browser/ui/browser_init.cc @@ -69,7 +69,7 @@ #include "webkit/glue/webkit_glue.h" #if defined(OS_MACOSX) -#include "chrome/browser/cocoa/keystone_infobar.h" +#include "chrome/browser/ui/cocoa/keystone_infobar.h" #endif #if defined(OS_WIN) diff --git a/chrome/browser/ui/cocoa/DEPS b/chrome/browser/ui/cocoa/DEPS new file mode 100644 index 0000000..c00f313 --- /dev/null +++ b/chrome/browser/ui/cocoa/DEPS @@ -0,0 +1,4 @@ +include_rules = [ + "+third_party/molokocacao", # For NSBezierPath additions. + "+third_party/ocmock", # For unit tests. +] diff --git a/chrome/browser/ui/cocoa/about_ipc_bridge.h b/chrome/browser/ui/cocoa/about_ipc_bridge.h new file mode 100644 index 0000000..77041b3 --- /dev/null +++ b/chrome/browser/ui/cocoa/about_ipc_bridge.h @@ -0,0 +1,33 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_ABOUT_IPC_BRIDGE_H_ +#define CHROME_BROWSER_UI_COCOA_ABOUT_IPC_BRIDGE_H_ +#pragma once + +#include "ipc/ipc_logging.h" +#include "ipc/ipc_message_utils.h" + +#if defined(IPC_MESSAGE_LOG_ENABLED) + +@class AboutIPCController; + +// On Windows, the AboutIPCDialog is a views::View. On Mac we have a +// Cocoa dialog. This class bridges from C++ to ObjC. +class AboutIPCBridge : public IPC::Logging::Consumer { + public: + AboutIPCBridge(AboutIPCController* controller) : controller_(controller) { } + virtual ~AboutIPCBridge() { } + + // IPC::Logging::Consumer implementation. + virtual void Log(const IPC::LogData& data); + + private: + AboutIPCController* controller_; // weak; owns me + DISALLOW_COPY_AND_ASSIGN(AboutIPCBridge); +}; + +#endif // IPC_MESSAGE_LOG_ENABLED + +#endif // CHROME_BROWSER_UI_COCOA_ABOUT_IPC_BRIDGE_H_ diff --git a/chrome/browser/ui/cocoa/about_ipc_bridge.mm b/chrome/browser/ui/cocoa/about_ipc_bridge.mm new file mode 100644 index 0000000..7a7f41f --- /dev/null +++ b/chrome/browser/ui/cocoa/about_ipc_bridge.mm @@ -0,0 +1,21 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/about_ipc_bridge.h" +#include "chrome/browser/ui/cocoa/about_ipc_controller.h" + +#if defined(IPC_MESSAGE_LOG_ENABLED) + +void AboutIPCBridge::Log(const IPC::LogData& data) { + CocoaLogData* cocoa_data = [[CocoaLogData alloc] initWithLogData:data]; + if ([NSThread isMainThread]) { + [controller_ log:cocoa_data]; + } else { + [controller_ performSelectorOnMainThread:@selector(log:) + withObject:cocoa_data + waitUntilDone:NO]; + } +} + +#endif diff --git a/chrome/browser/ui/cocoa/about_ipc_controller.h b/chrome/browser/ui/cocoa/about_ipc_controller.h new file mode 100644 index 0000000..f0818f9 --- /dev/null +++ b/chrome/browser/ui/cocoa/about_ipc_controller.h @@ -0,0 +1,84 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_ABOUT_IPC_CONTROLLER_H_ +#define CHROME_BROWSER_UI_COCOA_ABOUT_IPC_CONTROLLER_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_ptr.h" +#include "ipc/ipc_logging.h" +#include "ipc/ipc_message_utils.h" +#include "third_party/GTM/Foundation/GTMRegex.h" + +// Must be included after IPC_MESSAGE_LOG_ENABLED gets defined +#import "chrome/browser/ui/cocoa/about_ipc_bridge.h" + +#if defined(IPC_MESSAGE_LOG_ENABLED) + +// An objc wrapper for IPC::LogData to allow use of Cocoa bindings. +@interface CocoaLogData : NSObject { + @private + IPC::LogData data_; +} +- (id)initWithLogData:(const IPC::LogData&)data; +@end + + +// A window controller that handles the about:ipc non-modal dialog. +@interface AboutIPCController : NSWindowController { + @private + scoped_ptr<AboutIPCBridge> bridge_; + IBOutlet NSButton* startStopButton_; + IBOutlet NSTableView* tableView_; + IBOutlet NSArrayController* dataController_; + IBOutlet NSTextField* eventCount_; + IBOutlet NSTextField* filteredEventCount_; + IBOutlet NSTextField* userStringTextField1_; + IBOutlet NSTextField* userStringTextField2_; + IBOutlet NSTextField* userStringTextField3_; + // Count of filtered events. + int filteredEventCounter_; + // Cocoa-bound to check boxes for filtering messages. + // Each BOOL allows events that have that name prefix. + // E.g. if set, appCache_ allows events named AppCache*. + // The actual string to match is defined in the xib. + // The userStrings allow a user-specified prefix. + BOOL appCache_; + BOOL view_; + BOOL utilityHost_; + BOOL viewHost_; + BOOL plugin_; + BOOL npObject_; + BOOL devTools_; + BOOL pluginProcessing_; + BOOL userString1_; + BOOL userString2_; + BOOL userString3_; +} + ++ (AboutIPCController*)sharedController; + +- (IBAction)startStop:(id)sender; +- (IBAction)clear:(id)sender; + +// Called from our C++ bridge class. To accomodate multithreaded +// ownership issues, this method ACCEPTS OWNERSHIP of the arg passed +// in. +- (void)log:(CocoaLogData*)data; + +// Update visible state (e.g. Start/Stop button) based on logging run +// state. Does not change state. +- (void)updateVisibleRunState; + +@end + +@interface AboutIPCController(TestingAPI) +- (BOOL)filterOut:(CocoaLogData*)data; +- (void)setDisplayViewMessages:(BOOL)display; +@end + +#endif // IPC_MESSAGE_LOG_ENABLED +#endif // CHROME_BROWSER_UI_COCOA_ABOUT_IPC_CONTROLLER_H_ diff --git a/chrome/browser/ui/cocoa/about_ipc_controller.mm b/chrome/browser/ui/cocoa/about_ipc_controller.mm new file mode 100644 index 0000000..df6ae24 --- /dev/null +++ b/chrome/browser/ui/cocoa/about_ipc_controller.mm @@ -0,0 +1,198 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/logging.h" +#include "base/mac_util.h" +#include "base/string_util.h" +#include "base/sys_string_conversions.h" +#include "base/time.h" +#include "chrome/browser/browser_process.h" +#import "chrome/browser/ui/cocoa/about_ipc_controller.h" + +#if defined(IPC_MESSAGE_LOG_ENABLED) + +@implementation CocoaLogData + +- (id)initWithLogData:(const IPC::LogData&)data { + if ((self = [super init])) { + data_ = data; + // data_.message_name may not have been filled in if it originated + // somewhere other than the browser process. + IPC::Logging::GetMessageText(data_.type, &data_.message_name, NULL, NULL); + } + return self; +} + +- (NSString*)time { + base::Time t = base::Time::FromInternalValue(data_.sent); + base::Time::Exploded exploded; + t.LocalExplode(&exploded); + return [NSString stringWithFormat:@"%02d:%02d:%02d.%03d", + exploded.hour, exploded.minute, + exploded.second, exploded.millisecond]; +} + +- (NSString*)channel { + return base::SysUTF8ToNSString(data_.channel); +} + +- (NSString*)message { + if (data_.message_name == "") { + int high = data_.type >> 12; + int low = data_.type - (high<<12); + return [NSString stringWithFormat:@"type=(%d,%d) 0x%x,0x%x", + high, low, high, low]; + } + else { + return base::SysUTF8ToNSString(data_.message_name); + } +} + +- (NSString*)flags { + return base::SysUTF8ToNSString(data_.flags); +} + +- (NSString*)dispatch { + base::Time sent = base::Time::FromInternalValue(data_.sent); + int64 delta = (base::Time::FromInternalValue(data_.receive) - + sent).InMilliseconds(); + return [NSString stringWithFormat:@"%d", delta ? (int)delta : 0]; +} + +- (NSString*)process { + base::TimeDelta delta = (base::Time::FromInternalValue(data_.dispatch) - + base::Time::FromInternalValue(data_.receive)); + int64 t = delta.InMilliseconds(); + return [NSString stringWithFormat:@"%d", t ? (int)t : 0]; +} + +- (NSString*)parameters { + return base::SysUTF8ToNSString(data_.params); +} + +@end + +namespace { +AboutIPCController* gSharedController = nil; +} + +@implementation AboutIPCController + ++ (AboutIPCController*)sharedController { + if (gSharedController == nil) + gSharedController = [[AboutIPCController alloc] init]; + return gSharedController; +} + +- (id)init { + NSString* nibpath = [mac_util::MainAppBundle() pathForResource:@"AboutIPC" + ofType:@"nib"]; + if ((self = [super initWithWindowNibPath:nibpath owner:self])) { + // Default to all on + appCache_ = view_ = utilityHost_ = viewHost_ = plugin_ = + npObject_ = devTools_ = pluginProcessing_ = userString1_ = + userString2_ = userString3_ = YES; + } + return self; +} + +- (void)dealloc { + if (gSharedController == self) + gSharedController = nil; + if (g_browser_process) + g_browser_process->SetIPCLoggingEnabled(false); // just in case... + IPC::Logging::current()->SetConsumer(NULL); + [super dealloc]; +} + +- (void)awakeFromNib { + // Running Chrome with the --ipc-logging switch might cause it to + // be enabled before the about:ipc window comes up; accomodate. + [self updateVisibleRunState]; + + // We are now able to display information, so let'er rip. + bridge_.reset(new AboutIPCBridge(self)); + IPC::Logging::current()->SetConsumer(bridge_.get()); +} + +// Delegate callback. Closing the window means there is no more need +// for the me, the controller. +- (void)windowWillClose:(NSNotification*)notification { + [self autorelease]; +} + +- (void)updateVisibleRunState { + if (IPC::Logging::current()->Enabled()) + [startStopButton_ setTitle:@"Stop"]; + else + [startStopButton_ setTitle:@"Start"]; +} + +- (IBAction)startStop:(id)sender { + g_browser_process->SetIPCLoggingEnabled(!IPC::Logging::current()->Enabled()); + [self updateVisibleRunState]; +} + +- (IBAction)clear:(id)sender { + [dataController_ setContent:[NSMutableArray array]]; + [eventCount_ setStringValue:@"0"]; + [filteredEventCount_ setStringValue:@"0"]; + filteredEventCounter_ = 0; +} + +// Return YES if we should filter this out; else NO. +// Just to be clear, [@"any string" hasPrefix:@""] returns NO. +- (BOOL)filterOut:(CocoaLogData*)data { + NSString* name = [data message]; + if ((appCache_) && [name hasPrefix:@"AppCache"]) + return NO; + if ((view_) && [name hasPrefix:@"ViewMsg"]) + return NO; + if ((utilityHost_) && [name hasPrefix:@"UtilityHost"]) + return NO; + if ((viewHost_) && [name hasPrefix:@"ViewHost"]) + return NO; + if ((plugin_) && [name hasPrefix:@"PluginMsg"]) + return NO; + if ((npObject_) && [name hasPrefix:@"NPObject"]) + return NO; + if ((devTools_) && [name hasPrefix:@"DevTools"]) + return NO; + if ((pluginProcessing_) && [name hasPrefix:@"PluginProcessing"]) + return NO; + if ((userString1_) && ([name hasPrefix:[userStringTextField1_ stringValue]])) + return NO; + if ((userString2_) && ([name hasPrefix:[userStringTextField2_ stringValue]])) + return NO; + if ((userString3_) && ([name hasPrefix:[userStringTextField3_ stringValue]])) + return NO; + + // Special case the unknown type. + if ([name hasPrefix:@"type="]) + return NO; + + return YES; // filter out. +} + +- (void)log:(CocoaLogData*)data { + if ([self filterOut:data]) { + [filteredEventCount_ setStringValue:[NSString stringWithFormat:@"%d", + ++filteredEventCounter_]]; + return; + } + [dataController_ addObject:data]; + NSUInteger count = [[dataController_ arrangedObjects] count]; + // Uncomment if you want scroll-to-end behavior... but seems expensive. + // [tableView_ scrollRowToVisible:count-1]; + [eventCount_ setStringValue:[NSString stringWithFormat:@"%d", count]]; +} + +- (void)setDisplayViewMessages:(BOOL)display { + view_ = display; +} + +@end + +#endif // IPC_MESSAGE_LOG_ENABLED + diff --git a/chrome/browser/ui/cocoa/about_ipc_controller_unittest.mm b/chrome/browser/ui/cocoa/about_ipc_controller_unittest.mm new file mode 100644 index 0000000..afde86e --- /dev/null +++ b/chrome/browser/ui/cocoa/about_ipc_controller_unittest.mm @@ -0,0 +1,50 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#import "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/about_ipc_controller.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#include "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +#if defined(IPC_MESSAGE_LOG_ENABLED) + +namespace { + +class AboutIPCControllerTest : public CocoaTest { +}; + +TEST_F(AboutIPCControllerTest, TestFilter) { + AboutIPCController* controller = [[AboutIPCController alloc] init]; + EXPECT_TRUE([controller window]); // force nib load. + IPC::LogData data; + + // Make sure generic names do NOT get filtered. + std::string names[] = { "PluginProcessingIsMyLife", + "ViewMsgFoo", + "NPObjectHell" }; + for (size_t i = 0; i < arraysize(names); i++) { + data.message_name = names[i]; + scoped_nsobject<CocoaLogData> cdata([[CocoaLogData alloc] + initWithLogData:data]); + EXPECT_FALSE([controller filterOut:cdata.get()]); + } + + // Flip a checkbox, see it filtered, flip back, all is fine. + data.message_name = "ViewMsgFoo"; + scoped_nsobject<CocoaLogData> cdata([[CocoaLogData alloc] + initWithLogData:data]); + [controller setDisplayViewMessages:NO]; + EXPECT_TRUE([controller filterOut:cdata.get()]); + [controller setDisplayViewMessages:YES]; + EXPECT_FALSE([controller filterOut:cdata.get()]); + [controller close]; +} + +} // namespace + +#endif // IPC_MESSAGE_LOG_ENABLED diff --git a/chrome/browser/ui/cocoa/about_ipc_dialog.h b/chrome/browser/ui/cocoa/about_ipc_dialog.h new file mode 100644 index 0000000..3eb2bcd --- /dev/null +++ b/chrome/browser/ui/cocoa/about_ipc_dialog.h @@ -0,0 +1,24 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_ABOUT_IPC_DIALOG_H_ +#define CHROME_BROWSER_UI_COCOA_ABOUT_IPC_DIALOG_H_ +#pragma once + +#include "ipc/ipc_message.h" + +#if defined(IPC_MESSAGE_LOG_ENABLED) + +namespace AboutIPCDialog { +// The dialog is a singleton. If the dialog is already opened, it won't do +// anything, so you can just blindly call this function all you want. +// RunDialog() is Called from chrome/browser/browser_about_handler.cc +// in response to an about:ipc URL. +void RunDialog(); +}; + + +#endif /* IPC_MESSAGE_LOG_ENABLED */ + +#endif /* CHROME_BROWSER_UI_COCOA_ABOUT_IPC_DIALOG_H_ */ diff --git a/chrome/browser/ui/cocoa/about_ipc_dialog.mm b/chrome/browser/ui/cocoa/about_ipc_dialog.mm new file mode 100644 index 0000000..7715f24 --- /dev/null +++ b/chrome/browser/ui/cocoa/about_ipc_dialog.mm @@ -0,0 +1,21 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/about_ipc_dialog.h" +#include "chrome/browser/ui/cocoa/about_ipc_controller.h" + +#if defined(IPC_MESSAGE_LOG_ENABLED) + +namespace AboutIPCDialog { + +void RunDialog() { + // The controller gets deallocated when then window is closed, + // so it is safe to "fire and forget". + AboutIPCController* controller = [AboutIPCController sharedController]; + [[controller window] makeKeyAndOrderFront:controller]; +} + +}; // namespace AboutIPCDialog + +#endif // IPC_MESSAGE_LOG_ENABLED diff --git a/chrome/browser/ui/cocoa/about_window_controller.h b/chrome/browser/ui/cocoa/about_window_controller.h new file mode 100644 index 0000000..f5b9851 --- /dev/null +++ b/chrome/browser/ui/cocoa/about_window_controller.h @@ -0,0 +1,69 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_ABOUT_WINDOW_CONTROLLER_H_ +#define CHROME_BROWSER_UI_COCOA_ABOUT_WINDOW_CONTROLLER_H_ +#pragma once + +#import <AppKit/AppKit.h> + +@class BackgroundTileView; +class Profile; + +// This simple subclass of |NSTextView| just doesn't show the (text) cursor +// (|NSTextView| displays the cursor with full keyboard accessibility enabled). +@interface AboutLegalTextView : NSTextView +@end + +// A window controller that handles the About box. +@interface AboutWindowController : NSWindowController { + @private + IBOutlet NSTextField* version_; + IBOutlet BackgroundTileView* backgroundView_; + IBOutlet NSImageView* logoView_; + IBOutlet NSView* legalBlock_; + IBOutlet AboutLegalTextView* legalText_; + + // updateBlock_ holds the update image or throbber, update text, and update + // button. + IBOutlet NSView* updateBlock_; + + IBOutlet NSProgressIndicator* spinner_; + IBOutlet NSImageView* updateStatusIndicator_; + IBOutlet NSTextField* updateText_; + IBOutlet NSButton* updateNowButton_; + IBOutlet NSButton* promoteButton_; + + Profile* profile_; // Weak, probably the default profile. + + // The window frame height. During an animation, this will contain the + // height being animated to. + CGFloat windowHeight_; +} + +// Initialize the controller with the given profile, but does not show it. +// Callers still need to call showWindow: to put it on screen. +- (id)initWithProfile:(Profile*)profile; + +// Trigger an update right now, as initiated by a button. +- (IBAction)updateNow:(id)sender; + +// Install a system Keystone if necessary and promote the ticket to a system +// ticket. +- (IBAction)promoteUpdater:(id)sender; + +@end // @interface AboutWindowController + +@interface AboutWindowController(JustForTesting) + +- (NSTextView*)legalText; +- (NSButton*)updateButton; +- (NSTextField*)updateText; + +// Returns an NSAttributedString that contains locale-specific legal text. ++ (NSAttributedString*)legalTextBlock; + +@end // @interface AboutWindowController(JustForTesting) + +#endif // CHROME_BROWSER_UI_COCOA_ABOUT_WINDOW_CONTROLLER_H_ diff --git a/chrome/browser/ui/cocoa/about_window_controller.mm b/chrome/browser/ui/cocoa/about_window_controller.mm new file mode 100644 index 0000000..f6da6ab --- /dev/null +++ b/chrome/browser/ui/cocoa/about_window_controller.mm @@ -0,0 +1,761 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/about_window_controller.h" + +#include "app/l10n_util.h" +#include "app/l10n_util_mac.h" +#include "app/resource_bundle.h" +#include "base/logging.h" +#include "base/mac_util.h" +#include "base/string_number_conversions.h" +#include "base/string_util.h" +#include "base/sys_string_conversions.h" +#include "chrome/browser/browser_list.h" +#include "chrome/browser/browser_window.h" +#include "chrome/browser/platform_util.h" +#import "chrome/browser/ui/cocoa/background_tile_view.h" +#import "chrome/browser/ui/cocoa/keystone_glue.h" +#include "chrome/browser/ui/cocoa/restart_browser.h" +#include "chrome/common/url_constants.h" +#include "grit/chromium_strings.h" +#include "grit/generated_resources.h" +#include "grit/theme_resources.h" +#include "grit/locale_settings.h" +#include "third_party/GTM/AppKit/GTMUILocalizerAndLayoutTweaker.h" + +namespace { + +void AttributedStringAppendString(NSMutableAttributedString* attr_str, + NSString* str) { + // You might think doing [[attr_str mutableString] appendString:str] would + // work, but it causes any trailing style to get extened, meaning as we + // append links, they grow to include the new text, not what we want. + NSAttributedString* new_attr_str = + [[[NSAttributedString alloc] initWithString:str] autorelease]; + [attr_str appendAttributedString:new_attr_str]; +} + +void AttributedStringAppendHyperlink(NSMutableAttributedString* attr_str, + NSString* text, NSString* url_str) { + // Figure out the range of the text we're adding and add the text. + NSRange range = NSMakeRange([attr_str length], [text length]); + AttributedStringAppendString(attr_str, text); + + // Add the link + [attr_str addAttribute:NSLinkAttributeName value:url_str range:range]; + + // Blue and underlined + [attr_str addAttribute:NSForegroundColorAttributeName + value:[NSColor blueColor] + range:range]; + [attr_str addAttribute:NSUnderlineStyleAttributeName + value:[NSNumber numberWithInt:NSSingleUnderlineStyle] + range:range]; + [attr_str addAttribute:NSCursorAttributeName + value:[NSCursor pointingHandCursor] + range:range]; +} + +} // namespace + +@interface AboutWindowController(Private) + +// Launches a check for available updates. +- (void)checkForUpdate; + +// Turns the update and promotion blocks on and off as needed based on whether +// updates are possible and promotion is desired or required. +- (void)adjustUpdateUIVisibility; + +// Maintains the update and promotion block visibility and window sizing. +// This uses bool instead of BOOL for the convenience of the internal +// implementation. +- (void)setAllowsUpdate:(bool)update allowsPromotion:(bool)promotion; + +// Notification callback, called with the status of asynchronous +// -checkForUpdate and -updateNow: operations. +- (void)updateStatus:(NSNotification*)notification; + +// These methods maintain the image (or throbber) and text displayed regarding +// update status. -setUpdateThrobberMessage: starts a progress throbber and +// sets the text. -setUpdateImage:message: displays an image and sets the +// text. +- (void)setUpdateThrobberMessage:(NSString*)message; +- (void)setUpdateImage:(int)imageID message:(NSString*)message; + +@end // @interface AboutWindowController(Private) + +@implementation AboutLegalTextView + +// Never draw the insertion point (otherwise, it shows up without any user +// action if full keyboard accessibility is enabled). +- (BOOL)shouldDrawInsertionPoint { + return NO; +} + +@end + +@implementation AboutWindowController + +- (id)initWithProfile:(Profile*)profile { + NSString* nibPath = [mac_util::MainAppBundle() pathForResource:@"About" + ofType:@"nib"]; + if ((self = [super initWithWindowNibPath:nibPath owner:self])) { + profile_ = profile; + NSNotificationCenter* center = [NSNotificationCenter defaultCenter]; + [center addObserver:self + selector:@selector(updateStatus:) + name:kAutoupdateStatusNotification + object:nil]; + } + return self; +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [super dealloc]; +} + +// YES when an About box is currently showing the kAutoupdateInstallFailed +// status, or if no About box is visible, if the most recent About box to be +// closed was closed while showing this status. When an About box opens, if +// the recent status is kAutoupdateInstallFailed or kAutoupdatePromoteFailed +// and recentShownUserActionFailedStatus is NO, the failure needs to be shown +// instead of launching a new update check. recentShownInstallFailedStatus is +// maintained by -updateStatus:. +static BOOL recentShownUserActionFailedStatus = NO; + +- (void)awakeFromNib { + NSBundle* bundle = mac_util::MainAppBundle(); + NSString* chromeVersion = + [bundle objectForInfoDictionaryKey:@"CFBundleShortVersionString"]; + + NSString* versionModifier = @""; + NSString* svnRevision = @""; + std::string modifier = platform_util::GetVersionStringModifier(); + if (!modifier.empty()) + versionModifier = [NSString stringWithFormat:@" %@", + base::SysUTF8ToNSString(modifier)]; + +#if !defined(GOOGLE_CHROME_BUILD) + svnRevision = [NSString stringWithFormat:@" (%@)", + [bundle objectForInfoDictionaryKey:@"SVNRevision"]]; +#endif + // The format string is not localized, but this is how the displayed version + // is built on Windows too. + NSString* version = + [NSString stringWithFormat:@"%@%@%@", + chromeVersion, svnRevision, versionModifier]; + + [version_ setStringValue:version]; + + // Put the two images into the UI. + ResourceBundle& rb = ResourceBundle::GetSharedInstance(); + NSImage* backgroundImage = rb.GetNativeImageNamed(IDR_ABOUT_BACKGROUND_COLOR); + DCHECK(backgroundImage); + [backgroundView_ setTileImage:backgroundImage]; + NSImage* logoImage = rb.GetNativeImageNamed(IDR_ABOUT_BACKGROUND); + DCHECK(logoImage); + [logoView_ setImage:logoImage]; + + [[legalText_ textStorage] setAttributedString:[[self class] legalTextBlock]]; + + // Resize our text view now so that the |updateShift| below is set + // correctly. The About box has its controls manually positioned, so we need + // to calculate how much larger (or smaller) our text box is and store that + // difference in |legalShift|. We do something similar with |updateShift| + // below, which is either 0, or the amount of space to offset the window size + // because the view that contains the update button has been removed because + // this build doesn't have Keystone. + NSRect oldLegalRect = [legalBlock_ frame]; + [legalText_ sizeToFit]; + NSRect newRect = oldLegalRect; + newRect.size.height = [legalText_ frame].size.height; + [legalBlock_ setFrame:newRect]; + CGFloat legalShift = newRect.size.height - oldLegalRect.size.height; + + NSRect backgroundFrame = [backgroundView_ frame]; + backgroundFrame.origin.y += legalShift; + [backgroundView_ setFrame:backgroundFrame]; + + NSSize windowDelta = NSMakeSize(0.0, legalShift); + [GTMUILocalizerAndLayoutTweaker + resizeWindowWithoutAutoResizingSubViews:[self window] + delta:windowDelta]; + + windowHeight_ = [[self window] frame].size.height; + + [self adjustUpdateUIVisibility]; + + // Don't do anything update-related if adjustUpdateUIVisibility decided that + // updates aren't possible. + if (![updateBlock_ isHidden]) { + KeystoneGlue* keystoneGlue = [KeystoneGlue defaultKeystoneGlue]; + AutoupdateStatus recentStatus = [keystoneGlue recentStatus]; + if ([keystoneGlue asyncOperationPending] || + recentStatus == kAutoupdateRegisterFailed || + ((recentStatus == kAutoupdateInstallFailed || + recentStatus == kAutoupdatePromoteFailed) && + !recentShownUserActionFailedStatus)) { + // If an asynchronous update operation is currently pending, such as a + // check for updates or an update installation attempt, set the status + // up correspondingly without launching a new update check. + // + // If registration failed, no other operations make sense, so just go + // straight to the error. + // + // If a previous update or promotion attempt was unsuccessful but no + // About box was around to report the error, show it now, and allow + // another chance to perform the action. + [self updateStatus:[keystoneGlue recentNotification]]; + } else { + // Launch a new update check, even if one was already completed, because + // a new update may be available or a new update may have been installed + // in the background since the last time an About box was displayed. + [self checkForUpdate]; + } + } + + [[self window] center]; +} + +- (void)windowWillClose:(NSNotification*)notification { + [self autorelease]; +} + +- (void)adjustUpdateUIVisibility { + bool allowUpdate; + bool allowPromotion; + + KeystoneGlue* keystoneGlue = [KeystoneGlue defaultKeystoneGlue]; + if (keystoneGlue && ![keystoneGlue isOnReadOnlyFilesystem]) { + AutoupdateStatus recentStatus = [keystoneGlue recentStatus]; + if (recentStatus == kAutoupdateRegistering || + recentStatus == kAutoupdateRegisterFailed || + recentStatus == kAutoupdatePromoted) { + // Show the update block while registering so that there's a progress + // spinner, and if registration failed so that there's an error message. + // Show it following a promotion because updates should be possible + // after promotion successfully completes. + allowUpdate = true; + + // Promotion isn't possible at this point. + allowPromotion = false; + } else if (recentStatus == kAutoupdatePromoteFailed) { + // TODO(mark): Add kAutoupdatePromoting to this block. KSRegistration + // currently handles the promotion synchronously, meaning that the main + // thread's loop doesn't spin, meaning that animations and other updates + // to the window won't occur until KSRegistration is done with + // promotion. This looks laggy and bad and probably qualifies as + // "jank." For now, there just won't be any visual feedback while + // promotion is in progress, but it should complete (or fail) very + // quickly. http://b/2290009. + // + // Also see the TODO for kAutoupdatePromoting in -updateStatus:version:. + // + // Show the update block so that there's some visual feedback that + // promotion is under way or that it's failed. Show the promotion block + // because the user either just clicked that button or because the user + // should be able to click it again. + allowUpdate = true; + allowPromotion = true; + } else { + // Show the update block only if a promotion is not absolutely required. + allowUpdate = ![keystoneGlue needsPromotion]; + + // Show the promotion block if promotion is a possibility. + allowPromotion = [keystoneGlue wantsPromotion]; + } + } else { + // There is no glue, or the application is on a read-only filesystem. + // Updates and promotions are impossible. + allowUpdate = false; + allowPromotion = false; + } + + [self setAllowsUpdate:allowUpdate allowsPromotion:allowPromotion]; +} + +- (void)setAllowsUpdate:(bool)update allowsPromotion:(bool)promotion { + bool oldUpdate = ![updateBlock_ isHidden]; + bool oldPromotion = ![promoteButton_ isHidden]; + + if (promotion == oldPromotion && update == oldUpdate) { + return; + } + + NSRect updateFrame = [updateBlock_ frame]; + CGFloat delta = 0.0; + + if (update != oldUpdate) { + [updateBlock_ setHidden:!update]; + delta += (update ? 1.0 : -1.0) * NSHeight(updateFrame); + } + + if (promotion != oldPromotion) { + [promoteButton_ setHidden:!promotion]; + } + + NSRect legalFrame = [legalBlock_ frame]; + + if (delta) { + updateFrame.origin.y += delta; + [updateBlock_ setFrame:updateFrame]; + + legalFrame.origin.y += delta; + [legalBlock_ setFrame:legalFrame]; + + NSRect backgroundFrame = [backgroundView_ frame]; + backgroundFrame.origin.y += delta; + [backgroundView_ setFrame:backgroundFrame]; + + // GTMUILocalizerAndLayoutTweaker resizes the window without any + // opportunity for animation. In order to animate, disable window + // updates, save the current frame, let GTMUILocalizerAndLayoutTweaker do + // its thing, save the desired frame, restore the original frame, and then + // animate. + NSWindow* window = [self window]; + [window disableScreenUpdatesUntilFlush]; + + NSRect oldFrame = [window frame]; + + // GTMUILocalizerAndLayoutTweaker applies its delta to the window's + // current size (like oldFrame.size), but oldFrame isn't trustworthy if + // an animation is in progress. Set the window's frame to + // intermediateFrame, which is a frame of the size that an existing + // animation is animating to, so that GTM can apply the delta to the right + // size. + NSRect intermediateFrame = oldFrame; + intermediateFrame.origin.y -= intermediateFrame.size.height - windowHeight_; + intermediateFrame.size.height = windowHeight_; + [window setFrame:intermediateFrame display:NO]; + + NSSize windowDelta = NSMakeSize(0.0, delta); + [GTMUILocalizerAndLayoutTweaker + resizeWindowWithoutAutoResizingSubViews:window + delta:windowDelta]; + [window setFrameTopLeftPoint:NSMakePoint(NSMinX(intermediateFrame), + NSMaxY(intermediateFrame))]; + NSRect newFrame = [window frame]; + + windowHeight_ += delta; + + if (![[self window] isVisible]) { + // Don't animate if the window isn't on screen yet. + [window setFrame:newFrame display:NO]; + } else { + [window setFrame:oldFrame display:NO]; + [window setFrame:newFrame display:YES animate:YES]; + } + } +} + +- (void)setUpdateThrobberMessage:(NSString*)message { + [updateStatusIndicator_ setHidden:YES]; + + [spinner_ setHidden:NO]; + [spinner_ startAnimation:self]; + + [updateText_ setStringValue:message]; +} + +- (void)setUpdateImage:(int)imageID message:(NSString*)message { + [spinner_ stopAnimation:self]; + [spinner_ setHidden:YES]; + + ResourceBundle& rb = ResourceBundle::GetSharedInstance(); + NSImage* statusImage = rb.GetNativeImageNamed(imageID); + DCHECK(statusImage); + [updateStatusIndicator_ setImage:statusImage]; + [updateStatusIndicator_ setHidden:NO]; + + [updateText_ setStringValue:message]; +} + +- (void)checkForUpdate { + [[KeystoneGlue defaultKeystoneGlue] checkForUpdate]; + + // Immediately, kAutoupdateStatusNotification will be posted, and + // -updateStatus: will be called with status kAutoupdateChecking. + // + // Upon completion, kAutoupdateStatusNotification will be posted, and + // -updateStatus: will be called with a status indicating the result of the + // check. +} + +- (IBAction)updateNow:(id)sender { + [[KeystoneGlue defaultKeystoneGlue] installUpdate]; + + // Immediately, kAutoupdateStatusNotification will be posted, and + // -updateStatus: will be called with status kAutoupdateInstalling. + // + // Upon completion, kAutoupdateStatusNotification will be posted, and + // -updateStatus: will be called with a status indicating the result of the + // installation attempt. +} + +- (IBAction)promoteUpdater:(id)sender { + [[KeystoneGlue defaultKeystoneGlue] promoteTicket]; + + // Immediately, kAutoupdateStatusNotification will be posted, and + // -updateStatus: will be called with status kAutoupdatePromoting. + // + // Upon completion, kAutoupdateStatusNotification will be posted, and + // -updateStatus: will be called with a status indicating a result of the + // installation attempt. + // + // If the promotion was successful, KeystoneGlue will re-register the ticket + // and -updateStatus: will be called again indicating first that + // registration is in progress and subsequently that it has completed. +} + +- (void)updateStatus:(NSNotification*)notification { + recentShownUserActionFailedStatus = NO; + + NSDictionary* dictionary = [notification userInfo]; + AutoupdateStatus status = static_cast<AutoupdateStatus>( + [[dictionary objectForKey:kAutoupdateStatusStatus] intValue]); + + // Don't assume |version| is a real string. It may be nil. + NSString* version = [dictionary objectForKey:kAutoupdateStatusVersion]; + + bool updateMessage = true; + bool throbber = false; + int imageID = 0; + NSString* message; + bool enableUpdateButton = false; + bool enablePromoteButton = true; + + switch (status) { + case kAutoupdateRegistering: + // When registering, use the "checking" message. The check will be + // launched if appropriate immediately after registration. + throbber = true; + message = l10n_util::GetNSStringWithFixup(IDS_UPGRADE_CHECK_STARTED); + enablePromoteButton = false; + + break; + + case kAutoupdateRegistered: + // Once registered, the ability to update and promote is known. + [self adjustUpdateUIVisibility]; + + if (![updateBlock_ isHidden]) { + // If registration completes while the window is visible, go straight + // into an update check. Return immediately, this routine will be + // re-entered shortly with kAutoupdateChecking. + [self checkForUpdate]; + return; + } + + // Nothing actually failed, but updates aren't possible. The throbber + // and message are hidden, but they'll be reset to these dummy values + // just to get the throbber to stop spinning if it's running. + imageID = IDR_UPDATE_FAIL; + message = l10n_util::GetNSStringFWithFixup(IDS_UPGRADE_ERROR, + base::IntToString16(status)); + + break; + + case kAutoupdateChecking: + throbber = true; + message = l10n_util::GetNSStringWithFixup(IDS_UPGRADE_CHECK_STARTED); + enablePromoteButton = false; + + break; + + case kAutoupdateCurrent: + imageID = IDR_UPDATE_UPTODATE; + message = l10n_util::GetNSStringFWithFixup( + IDS_UPGRADE_ALREADY_UP_TO_DATE, + l10n_util::GetStringUTF16(IDS_PRODUCT_NAME), + base::SysNSStringToUTF16(version)); + + break; + + case kAutoupdateAvailable: + imageID = IDR_UPDATE_AVAILABLE; + message = l10n_util::GetNSStringFWithFixup( + IDS_UPGRADE_AVAILABLE, l10n_util::GetStringUTF16(IDS_PRODUCT_NAME)); + enableUpdateButton = true; + + break; + + case kAutoupdateInstalling: + throbber = true; + message = l10n_util::GetNSStringWithFixup(IDS_UPGRADE_STARTED); + enablePromoteButton = false; + + break; + + case kAutoupdateInstalled: + { + imageID = IDR_UPDATE_UPTODATE; + string16 productName = l10n_util::GetStringUTF16(IDS_PRODUCT_NAME); + if (version) { + message = l10n_util::GetNSStringFWithFixup( + IDS_UPGRADE_SUCCESSFUL, + productName, + base::SysNSStringToUTF16(version)); + } else { + message = l10n_util::GetNSStringFWithFixup( + IDS_UPGRADE_SUCCESSFUL_NOVERSION, productName); + } + + // TODO(mark): Turn the button in the dialog into a restart button + // instead of springing this sheet or dialog. + NSWindow* window = [self window]; + NSWindow* restartDialogParent = [window isVisible] ? window : nil; + restart_browser::RequestRestart(restartDialogParent); + } + + break; + + case kAutoupdatePromoting: +#if 1 + // TODO(mark): See the TODO in -adjustUpdateUIVisibility for an + // explanation of why nothing can be done here at the moment. When + // KSRegistration handles promotion asynchronously, this dummy block can + // be replaced with the #else block. For now, just leave the messaging + // alone. http://b/2290009. + updateMessage = false; +#else + // The visibility may be changing. + [self adjustUpdateUIVisibility]; + + // This is not a terminal state, and kAutoupdatePromoted or + // kAutoupdatePromoteFailed will follow. Use the throbber and + // "checking" message so that it looks like something's happening. + throbber = true; + message = l10n_util::GetNSStringWithFixup(IDS_UPGRADE_CHECK_STARTED); +#endif + + enablePromoteButton = false; + + break; + + case kAutoupdatePromoted: + // The visibility may be changing. + [self adjustUpdateUIVisibility]; + + if (![updateBlock_ isHidden]) { + // If promotion completes while the window is visible, go straight + // into an update check. Return immediately, this routine will be + // re-entered shortly with kAutoupdateChecking. + [self checkForUpdate]; + return; + } + + // Nothing actually failed, but updates aren't possible. The throbber + // and message are hidden, but they'll be reset to these dummy values + // just to get the throbber to stop spinning if it's running. + imageID = IDR_UPDATE_FAIL; + message = l10n_util::GetNSStringFWithFixup(IDS_UPGRADE_ERROR, + base::IntToString16(status)); + + break; + + case kAutoupdateRegisterFailed: + imageID = IDR_UPDATE_FAIL; + message = l10n_util::GetNSStringFWithFixup(IDS_UPGRADE_ERROR, + base::IntToString16(status)); + enablePromoteButton = false; + + break; + + case kAutoupdateCheckFailed: + imageID = IDR_UPDATE_FAIL; + message = l10n_util::GetNSStringFWithFixup(IDS_UPGRADE_ERROR, + base::IntToString16(status)); + + break; + + case kAutoupdateInstallFailed: + recentShownUserActionFailedStatus = YES; + + imageID = IDR_UPDATE_FAIL; + message = l10n_util::GetNSStringFWithFixup(IDS_UPGRADE_ERROR, + base::IntToString16(status)); + + // Allow another chance. + enableUpdateButton = true; + + break; + + case kAutoupdatePromoteFailed: + recentShownUserActionFailedStatus = YES; + + imageID = IDR_UPDATE_FAIL; + message = l10n_util::GetNSStringFWithFixup(IDS_UPGRADE_ERROR, + base::IntToString16(status)); + + break; + + default: + NOTREACHED(); + + return; + } + + if (updateMessage) { + if (throbber) { + [self setUpdateThrobberMessage:message]; + } else { + DCHECK_NE(imageID, 0); + [self setUpdateImage:imageID message:message]; + } + } + + // Note that these buttons may be hidden depending on what + // -adjustUpdateUIVisibility did. Their enabled/disabled status doesn't + // necessarily have anything to do with their visibility. + [updateNowButton_ setEnabled:enableUpdateButton]; + [promoteButton_ setEnabled:enablePromoteButton]; +} + +- (BOOL)textView:(NSTextView *)aTextView + clickedOnLink:(id)link + atIndex:(NSUInteger)charIndex { + // We always create a new window, so there's no need to try to re-use + // an existing one just to pass in the NEW_WINDOW disposition. + Browser* browser = Browser::Create(profile_); + browser->OpenURL(GURL([link UTF8String]), GURL(), NEW_FOREGROUND_TAB, + PageTransition::LINK); + browser->window()->Show(); + return YES; +} + +- (NSTextView*)legalText { + return legalText_; +} + +- (NSButton*)updateButton { + return updateNowButton_; +} + +- (NSTextField*)updateText { + return updateText_; +} + ++ (NSAttributedString*)legalTextBlock { + // Windows builds this up in a very complex way, we're just trying to model + // it the best we can to get all the information in (they actually do it + // but created Labels and Links that they carefully place to make it appear + // to be a paragraph of text). + // src/chrome/browser/views/about_chrome_view.cc AboutChromeView::Init() + + NSMutableAttributedString* legal_block = + [[[NSMutableAttributedString alloc] init] autorelease]; + [legal_block beginEditing]; + + NSString* copyright = + l10n_util::GetNSStringWithFixup(IDS_ABOUT_VERSION_COPYRIGHT); + AttributedStringAppendString(legal_block, copyright); + + // These are the markers directly in IDS_ABOUT_VERSION_LICENSE + NSString* kBeginLinkChr = @"BEGIN_LINK_CHR"; + NSString* kBeginLinkOss = @"BEGIN_LINK_OSS"; + NSString* kEndLinkChr = @"END_LINK_CHR"; + NSString* kEndLinkOss = @"END_LINK_OSS"; + // The CHR link should go to here + NSString* kChromiumProject = l10n_util::GetNSString(IDS_CHROMIUM_PROJECT_URL); + // The OSS link should go to here + NSString* kAcknowledgements = + [NSString stringWithUTF8String:chrome::kAboutCreditsURL]; + + // Now fetch the license string and deal with the markers + + NSString* license = + l10n_util::GetNSStringWithFixup(IDS_ABOUT_VERSION_LICENSE); + + NSRange begin_chr = [license rangeOfString:kBeginLinkChr]; + NSRange begin_oss = [license rangeOfString:kBeginLinkOss]; + NSRange end_chr = [license rangeOfString:kEndLinkChr]; + NSRange end_oss = [license rangeOfString:kEndLinkOss]; + DCHECK_NE(begin_chr.location, NSNotFound); + DCHECK_NE(begin_oss.location, NSNotFound); + DCHECK_NE(end_chr.location, NSNotFound); + DCHECK_NE(end_oss.location, NSNotFound); + + // We don't know which link will come first, so we have to deal with things + // like this: + // [text][begin][text][end][text][start][text][end][text] + + bool chromium_link_first = begin_chr.location < begin_oss.location; + + NSRange* begin1 = &begin_chr; + NSRange* begin2 = &begin_oss; + NSRange* end1 = &end_chr; + NSRange* end2 = &end_oss; + NSString* link1 = kChromiumProject; + NSString* link2 = kAcknowledgements; + if (!chromium_link_first) { + // OSS came first, switch! + begin2 = &begin_chr; + begin1 = &begin_oss; + end2 = &end_chr; + end1 = &end_oss; + link2 = kChromiumProject; + link1 = kAcknowledgements; + } + + NSString *sub_str; + + AttributedStringAppendString(legal_block, @"\n"); + sub_str = [license substringWithRange:NSMakeRange(0, begin1->location)]; + AttributedStringAppendString(legal_block, sub_str); + sub_str = [license substringWithRange:NSMakeRange(NSMaxRange(*begin1), + end1->location - + NSMaxRange(*begin1))]; + AttributedStringAppendHyperlink(legal_block, sub_str, link1); + sub_str = [license substringWithRange:NSMakeRange(NSMaxRange(*end1), + begin2->location - + NSMaxRange(*end1))]; + AttributedStringAppendString(legal_block, sub_str); + sub_str = [license substringWithRange:NSMakeRange(NSMaxRange(*begin2), + end2->location - + NSMaxRange(*begin2))]; + AttributedStringAppendHyperlink(legal_block, sub_str, link2); + sub_str = [license substringWithRange:NSMakeRange(NSMaxRange(*end2), + [license length] - + NSMaxRange(*end2))]; + AttributedStringAppendString(legal_block, sub_str); + +#if defined(GOOGLE_CHROME_BUILD) + // Terms of service is only valid for Google Chrome + + // The url within terms should point here: + NSString* kTOS = [NSString stringWithUTF8String:chrome::kAboutTermsURL]; + // Following Windows. There is one marker in the string for where the terms + // link goes, but the text of the link comes from a second string resources. + std::vector<size_t> url_offsets; + NSString* about_terms = l10n_util::GetNSStringF(IDS_ABOUT_TERMS_OF_SERVICE, + string16(), + string16(), + &url_offsets); + DCHECK_EQ(url_offsets.size(), 1U); + NSString* terms_link_text = + l10n_util::GetNSStringWithFixup(IDS_TERMS_OF_SERVICE); + + AttributedStringAppendString(legal_block, @"\n\n"); + sub_str = [about_terms substringToIndex:url_offsets[0]]; + AttributedStringAppendString(legal_block, sub_str); + AttributedStringAppendHyperlink(legal_block, terms_link_text, kTOS); + sub_str = [about_terms substringFromIndex:url_offsets[0]]; + AttributedStringAppendString(legal_block, sub_str); +#endif // GOOGLE_CHROME_BUILD + + // We need to explicitly select Lucida Grande because once we click on + // the NSTextView, it changes to Helvetica 12 otherwise. + NSRange string_range = NSMakeRange(0, [legal_block length]); + [legal_block addAttribute:NSFontAttributeName + value:[NSFont labelFontOfSize:11] + range:string_range]; + + [legal_block endEditing]; + return legal_block; +} + +@end // @implementation AboutWindowController diff --git a/chrome/browser/ui/cocoa/about_window_controller_unittest.mm b/chrome/browser/ui/cocoa/about_window_controller_unittest.mm new file mode 100644 index 0000000..4747efe --- /dev/null +++ b/chrome/browser/ui/cocoa/about_window_controller_unittest.mm @@ -0,0 +1,137 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#import "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/about_window_controller.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#include "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/keystone_glue.h" +#include "testing/gtest/include/gtest/gtest.h" +#import "testing/gtest_mac.h" +#include "testing/platform_test.h" + +namespace { + +void PostAutoupdateStatusNotification(AutoupdateStatus status, + NSString* version) { + NSNumber* statusNumber = [NSNumber numberWithInt:status]; + NSMutableDictionary* dictionary = + [NSMutableDictionary dictionaryWithObjects:&statusNumber + forKeys:&kAutoupdateStatusStatus + count:1]; + if (version) { + [dictionary setObject:version forKey:kAutoupdateStatusVersion]; + } + + NSNotificationCenter* center = [NSNotificationCenter defaultCenter]; + [center postNotificationName:kAutoupdateStatusNotification + object:nil + userInfo:dictionary]; +} + +class AboutWindowControllerTest : public CocoaTest { + public: + virtual void SetUp() { + CocoaTest::SetUp(); + about_window_controller_ = + [[AboutWindowController alloc] initWithProfile:nil]; + EXPECT_TRUE([about_window_controller_ window]); + } + + virtual void TearDown() { + [about_window_controller_ close]; + CocoaTest::TearDown(); + } + + AboutWindowController* about_window_controller_; +}; + +TEST_F(AboutWindowControllerTest, TestCopyright) { + NSString* text = [[AboutWindowController legalTextBlock] string]; + + // Make sure we have the word "Copyright" in it, which is present in all + // locales. + NSRange range = [text rangeOfString:@"Copyright"]; + EXPECT_NE(NSNotFound, range.location); +} + +TEST_F(AboutWindowControllerTest, RemovesLinkAnchors) { + NSString* text = [[AboutWindowController legalTextBlock] string]; + + // Make sure that we removed the "BEGIN_LINK" and "END_LINK" anchors. + NSRange range = [text rangeOfString:@"BEGIN_LINK"]; + EXPECT_EQ(NSNotFound, range.location); + + range = [text rangeOfString:@"END_LINK"]; + EXPECT_EQ(NSNotFound, range.location); +} + +TEST_F(AboutWindowControllerTest, AwakeNibSetsString) { + NSAttributedString* legal_text = [AboutWindowController legalTextBlock]; + NSAttributedString* text_storage = + [[about_window_controller_ legalText] textStorage]; + + EXPECT_TRUE([legal_text isEqualToAttributedString:text_storage]); +} + +TEST_F(AboutWindowControllerTest, TestButton) { + NSButton* button = [about_window_controller_ updateButton]; + ASSERT_TRUE(button); + + // Not enabled until we know if updates are available. + ASSERT_FALSE([button isEnabled]); + PostAutoupdateStatusNotification(kAutoupdateAvailable, nil); + ASSERT_TRUE([button isEnabled]); + + // Make sure the button is hooked up + ASSERT_EQ([button target], about_window_controller_); + ASSERT_EQ([button action], @selector(updateNow:)); +} + +// Doesn't confirm correctness, but does confirm something happens. +TEST_F(AboutWindowControllerTest, TestCallbacks) { + NSString *lastText = [[about_window_controller_ updateText] + stringValue]; + PostAutoupdateStatusNotification(kAutoupdateCurrent, @"foo"); + ASSERT_NSNE(lastText, [[about_window_controller_ updateText] stringValue]); + + lastText = [[about_window_controller_ updateText] stringValue]; + PostAutoupdateStatusNotification(kAutoupdateCurrent, @"foo"); + ASSERT_NSEQ(lastText, [[about_window_controller_ updateText] stringValue]); + + lastText = [[about_window_controller_ updateText] stringValue]; + PostAutoupdateStatusNotification(kAutoupdateCurrent, @"bar"); + ASSERT_NSNE(lastText, [[about_window_controller_ updateText] stringValue]); + + lastText = [[about_window_controller_ updateText] stringValue]; + PostAutoupdateStatusNotification(kAutoupdateAvailable, nil); + ASSERT_NSNE(lastText, [[about_window_controller_ updateText] stringValue]); + + lastText = [[about_window_controller_ updateText] stringValue]; + PostAutoupdateStatusNotification(kAutoupdateCheckFailed, nil); + ASSERT_NSNE(lastText, [[about_window_controller_ updateText] stringValue]); + +#if 0 + // TODO(mark): The kAutoupdateInstalled portion of the test is disabled + // because it leaks restart dialogs. If the About box is revised to use + // a button within the box to advise a restart instead of popping dialogs, + // these tests should be enabled. + + lastText = [[about_window_controller_ updateText] stringValue]; + PostAutoupdateStatusNotification(kAutoupdateInstalled, @"ver"); + ASSERT_NSNE(lastText, [[about_window_controller_ updateText] stringValue]); + + lastText = [[about_window_controller_ updateText] stringValue]; + PostAutoupdateStatusNotification(kAutoupdateInstalled, nil); + ASSERT_NSNE(lastText, [[about_window_controller_ updateText] stringValue]); +#endif + + lastText = [[about_window_controller_ updateText] stringValue]; + PostAutoupdateStatusNotification(kAutoupdateInstallFailed, nil); + ASSERT_NSNE(lastText, [[about_window_controller_ updateText] stringValue]); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/accelerators_cocoa.h b/chrome/browser/ui/cocoa/accelerators_cocoa.h new file mode 100644 index 0000000..b2d2269 --- /dev/null +++ b/chrome/browser/ui/cocoa/accelerators_cocoa.h @@ -0,0 +1,41 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_ACCELERATORS_COCOA_H_ +#define CHROME_BROWSER_UI_COCOA_ACCELERATORS_COCOA_H_ +#pragma once + +#include <map> + +#include "app/menus/accelerator_cocoa.h" + +// This class maintains a map of command_ids to AcceleratorCocoa objects (see +// chrome/app/chrome_command_ids.h). Currently, this only lists the commands +// that are used in the Wrench menu. +// +// It is recommended that this class be used as a singleton so that the key map +// isn't created multiple places. +// +// #import "base/singleton.h" +// ... +// AcceleratorsCocoa* keymap = Singleton<AcceleratorsCocoa>::get(); +// return keymap->GetAcceleratorForCommand(IDC_COPY); +// +class AcceleratorsCocoa { + public: + AcceleratorsCocoa(); + ~AcceleratorsCocoa() {} + + typedef std::map<int, menus::AcceleratorCocoa> AcceleratorCocoaMap; + + // Returns NULL if there is no accelerator for the command. + const menus::AcceleratorCocoa* GetAcceleratorForCommand(int command_id); + + private: + AcceleratorCocoaMap accelerators_; + + DISALLOW_COPY_AND_ASSIGN(AcceleratorsCocoa); +}; + +#endif // CHROME_BROWSER_UI_COCOA_ACCELERATORS_COCOA_H_ diff --git a/chrome/browser/ui/cocoa/accelerators_cocoa.mm b/chrome/browser/ui/cocoa/accelerators_cocoa.mm new file mode 100644 index 0000000..eba1888 --- /dev/null +++ b/chrome/browser/ui/cocoa/accelerators_cocoa.mm @@ -0,0 +1,57 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/accelerators_cocoa.h" + +#import <Cocoa/Cocoa.h> + +#include "chrome/app/chrome_command_ids.h" + +namespace { + +const struct AcceleratorMapping { + int command_id; + NSString* key; + NSUInteger modifiers; +} kAcceleratorMap[] = { + { IDC_CLEAR_BROWSING_DATA, @"\x8", NSCommandKeyMask | NSShiftKeyMask }, + { IDC_COPY, @"c", NSCommandKeyMask }, + { IDC_CUT, @"x", NSCommandKeyMask }, + { IDC_DEV_TOOLS, @"i", NSCommandKeyMask | NSAlternateKeyMask }, + { IDC_DEV_TOOLS_CONSOLE, @"j", NSCommandKeyMask | NSAlternateKeyMask }, + { IDC_FIND, @"f", NSCommandKeyMask }, + { IDC_FULLSCREEN, @"f", NSCommandKeyMask | NSShiftKeyMask }, + { IDC_NEW_INCOGNITO_WINDOW, @"n", NSCommandKeyMask | NSShiftKeyMask }, + { IDC_NEW_TAB, @"t", NSCommandKeyMask }, + { IDC_NEW_WINDOW, @"n", NSCommandKeyMask }, + { IDC_OPTIONS, @",", NSCommandKeyMask }, + { IDC_PASTE, @"v", NSCommandKeyMask }, + { IDC_PRINT, @"p", NSCommandKeyMask }, + { IDC_SAVE_PAGE, @"s", NSCommandKeyMask }, + { IDC_SHOW_BOOKMARK_BAR, @"b", NSCommandKeyMask | NSShiftKeyMask }, + { IDC_SHOW_BOOKMARK_MANAGER, @"b", NSCommandKeyMask | NSAlternateKeyMask }, + { IDC_SHOW_DOWNLOADS, @"j", NSCommandKeyMask | NSShiftKeyMask }, + { IDC_SHOW_HISTORY, @"y", NSCommandKeyMask }, + { IDC_VIEW_SOURCE, @"u", NSCommandKeyMask | NSAlternateKeyMask }, + { IDC_ZOOM_MINUS, @"-", NSCommandKeyMask }, + { IDC_ZOOM_PLUS, @"+", NSCommandKeyMask } +}; + +} // namespace + +AcceleratorsCocoa::AcceleratorsCocoa() { + for (size_t i = 0; i < arraysize(kAcceleratorMap); ++i) { + const AcceleratorMapping& entry = kAcceleratorMap[i]; + menus::AcceleratorCocoa accelerator(entry.key, entry.modifiers); + accelerators_.insert(std::make_pair(entry.command_id, accelerator)); + } +} + +const menus::AcceleratorCocoa* AcceleratorsCocoa::GetAcceleratorForCommand( + int command_id) { + AcceleratorCocoaMap::iterator it = accelerators_.find(command_id); + if (it == accelerators_.end()) + return NULL; + return &it->second; +} diff --git a/chrome/browser/ui/cocoa/accelerators_cocoa_unittest.mm b/chrome/browser/ui/cocoa/accelerators_cocoa_unittest.mm new file mode 100644 index 0000000..e2b27f0 --- /dev/null +++ b/chrome/browser/ui/cocoa/accelerators_cocoa_unittest.mm @@ -0,0 +1,28 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "app/menus/accelerator_cocoa.h" +#include "base/singleton.h" +#include "chrome/app/chrome_command_ids.h" +#import "chrome/browser/ui/cocoa/accelerators_cocoa.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/gtest_mac.h" + +TEST(AcceleratorsCocoaTest, GetAccelerator) { + AcceleratorsCocoa* keymap = Singleton<AcceleratorsCocoa>::get(); + const menus::AcceleratorCocoa* accelerator = + keymap->GetAcceleratorForCommand(IDC_COPY); + ASSERT_TRUE(accelerator); + EXPECT_NSEQ(@"c", accelerator->characters()); + EXPECT_EQ(static_cast<int>(NSCommandKeyMask), accelerator->modifiers()); +} + +TEST(AcceleratorsCocoaTest, GetNullAccelerator) { + AcceleratorsCocoa* keymap = Singleton<AcceleratorsCocoa>::get(); + const menus::AcceleratorCocoa* accelerator = + keymap->GetAcceleratorForCommand(314159265); + EXPECT_FALSE(accelerator); +} diff --git a/chrome/browser/ui/cocoa/animatable_image.h b/chrome/browser/ui/cocoa/animatable_image.h new file mode 100644 index 0000000..65fd023 --- /dev/null +++ b/chrome/browser/ui/cocoa/animatable_image.h @@ -0,0 +1,57 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_ANIMATABLE_IMAGE_H_ +#define CHROME_BROWSER_UI_COCOA_ANIMATABLE_IMAGE_H_ +#pragma once + +#import <Cocoa/Cocoa.h> +#import <QuartzCore/QuartzCore.h> + +#include "base/scoped_nsobject.h" + +// This class helps animate an NSImage's frame and opacity. It works by creating +// a blank NSWindow in the size specified and giving it a layer on which the +// image can be animated. Clients are free to embed this object as a child +// window for easier window management. This class will clean itself up when +// the animation has finished. Clients that install this as a child window +// should listen for the NSWindowWillCloseNotification to perform any additional +// cleanup. +@interface AnimatableImage : NSWindow { + @private + // The image to animate. + scoped_nsobject<NSImage> image_; + + // The frame of the image before and after the animation. This is in this + // window's coordinate system. + CGRect startFrame_; + CGRect endFrame_; + + // Opacity values for the animation. + CGFloat startOpacity_; + CGFloat endOpacity_; + + // The amount of time it takes to animate the image. + CGFloat duration_; +} + +@property (nonatomic) CGRect startFrame; +@property (nonatomic) CGRect endFrame; +@property (nonatomic) CGFloat startOpacity; +@property (nonatomic) CGFloat endOpacity; +@property (nonatomic) CGFloat duration; + +// Designated initializer. Do not use any other NSWindow initializers. Creates +// but does not show the blank animation window of the given size. The +// |animationFrame| should usually be big enough to contain the |startFrame| +// and |endFrame| properties of the animation. +- (id)initWithImage:(NSImage*)image + animationFrame:(NSRect)animationFrame; + +// Begins the animation. +- (void)startAnimation; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_ANIMATABLE_IMAGE_H_ diff --git a/chrome/browser/ui/cocoa/animatable_image.mm b/chrome/browser/ui/cocoa/animatable_image.mm new file mode 100644 index 0000000..a73f5a4 --- /dev/null +++ b/chrome/browser/ui/cocoa/animatable_image.mm @@ -0,0 +1,145 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/animatable_image.h" + +#include "base/logging.h" +#import "base/mac_util.h" +#include "base/mac/scoped_cftyperef.h" +#import "third_party/GTM/AppKit/GTMNSAnimation+Duration.h" + +@interface AnimatableImage (Private) +- (void)setLayerContents:(CALayer*)layer; +@end + +@implementation AnimatableImage + +@synthesize startFrame = startFrame_; +@synthesize endFrame = endFrame_; +@synthesize startOpacity = startOpacity_; +@synthesize endOpacity = endOpacity_; +@synthesize duration = duration_; + +- (id)initWithImage:(NSImage*)image + animationFrame:(NSRect)animationFrame { + if ((self = [super initWithContentRect:animationFrame + styleMask:NSBorderlessWindowMask + backing:NSBackingStoreBuffered + defer:NO])) { + DCHECK(image); + image_.reset([image retain]); + duration_ = 1.0; + startOpacity_ = 1.0; + endOpacity_ = 1.0; + + [self setOpaque:NO]; + [self setBackgroundColor:[NSColor clearColor]]; + [self setIgnoresMouseEvents:YES]; + + // Must be set or else self will be leaked. + [self setReleasedWhenClosed:YES]; + } + return self; +} + +- (void)startAnimation { + // Set up the root layer. By calling -setLayer: followed by -setWantsLayer: + // the view becomes a layer hosting view as opposed to a layer backed view. + NSView* view = [self contentView]; + CALayer* rootLayer = [CALayer layer]; + [view setLayer:rootLayer]; + [view setWantsLayer:YES]; + + // Create the layer that will be animated. + CALayer* layer = [CALayer layer]; + [self setLayerContents:layer]; + [layer setAnchorPoint:CGPointMake(0, 1)]; + [layer setFrame:[self startFrame]]; + [layer setNeedsDisplayOnBoundsChange:YES]; + [rootLayer addSublayer:layer]; + + // Common timing function for all animations. + CAMediaTimingFunction* mediaFunction = + [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseOut]; + + // Animate the bounds only if the image is resized. + CABasicAnimation* boundsAnimation = nil; + if (CGRectGetWidth([self startFrame]) != CGRectGetWidth([self endFrame]) || + CGRectGetHeight([self startFrame]) != CGRectGetHeight([self endFrame])) { + boundsAnimation = [CABasicAnimation animationWithKeyPath:@"bounds"]; + NSRect startRect = NSMakeRect(0, 0, + CGRectGetWidth([self startFrame]), + CGRectGetHeight([self startFrame])); + [boundsAnimation setFromValue:[NSValue valueWithRect:startRect]]; + NSRect endRect = NSMakeRect(0, 0, + CGRectGetWidth([self endFrame]), + CGRectGetHeight([self endFrame])); + [boundsAnimation setToValue:[NSValue valueWithRect:endRect]]; + [boundsAnimation gtm_setDuration:[self duration] + eventMask:NSLeftMouseUpMask]; + [boundsAnimation setTimingFunction:mediaFunction]; + } + + // Positional animation. + CABasicAnimation* positionAnimation = + [CABasicAnimation animationWithKeyPath:@"position"]; + [positionAnimation setFromValue: + [NSValue valueWithPoint:NSPointFromCGPoint([self startFrame].origin)]]; + [positionAnimation setToValue: + [NSValue valueWithPoint:NSPointFromCGPoint([self endFrame].origin)]]; + [positionAnimation gtm_setDuration:[self duration] + eventMask:NSLeftMouseUpMask]; + [positionAnimation setTimingFunction:mediaFunction]; + + // Opacity animation. + CABasicAnimation* opacityAnimation = + [CABasicAnimation animationWithKeyPath:@"opacity"]; + [opacityAnimation setFromValue: + [NSNumber numberWithFloat:[self startOpacity]]]; + [opacityAnimation setToValue:[NSNumber numberWithFloat:[self endOpacity]]]; + [opacityAnimation gtm_setDuration:[self duration] + eventMask:NSLeftMouseUpMask]; + [opacityAnimation setTimingFunction:mediaFunction]; + // Set the delegate just for one of the animations so that this window can + // be closed upon completion. + [opacityAnimation setDelegate:self]; + + // The CAAnimations only affect the presentational value of a layer, not the + // model value. This means that after the animation is done, it can flicker + // back to the original values. To avoid this, create an implicit animation of + // the values, which are then overridden with the CABasicAnimations. + // + // Ideally, a call to |-setBounds:| should be here, but, for reasons that + // are not understood, doing so causes the animation to break. + [layer setPosition:[self endFrame].origin]; + [layer setOpacity:[self endOpacity]]; + + // Start the animations. + [CATransaction begin]; + [CATransaction setValue:[NSNumber numberWithFloat:[self duration]] + forKey:kCATransactionAnimationDuration]; + if (boundsAnimation) { + [layer addAnimation:boundsAnimation forKey:@"bounds"]; + } + [layer addAnimation:positionAnimation forKey:@"position"]; + [layer addAnimation:opacityAnimation forKey:@"opacity"]; + [CATransaction commit]; +} + +// Sets the layer contents by converting the NSImage to a CGImageRef. This will +// rasterize PDF resources. +- (void)setLayerContents:(CALayer*)layer { + base::mac::ScopedCFTypeRef<CGImageRef> image( + mac_util::CopyNSImageToCGImage(image_.get())); + // Create the layer that will be animated. + [layer setContents:(id)image.get()]; +} + +// CAAnimation delegate method called when the animation is complete. +- (void)animationDidStop:(CAAnimation*)animation finished:(BOOL)flag { + // Close the window, releasing self. + [self close]; +} + +@end diff --git a/chrome/browser/ui/cocoa/animatable_image_unittest.mm b/chrome/browser/ui/cocoa/animatable_image_unittest.mm new file mode 100644 index 0000000..acdec8c --- /dev/null +++ b/chrome/browser/ui/cocoa/animatable_image_unittest.mm @@ -0,0 +1,46 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#import "base/nsimage_cache_mac.h" +#import "chrome/browser/ui/cocoa/animatable_image.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +namespace { + +class AnimatableImageTest : public CocoaTest { + public: + AnimatableImageTest() { + NSRect frame = NSMakeRect(0, 0, 500, 500); + NSImage* image = nsimage_cache::ImageNamed(@"forward_Template.pdf"); + animation_ = [[AnimatableImage alloc] initWithImage:image + animationFrame:frame]; + } + + AnimatableImage* animation_; +}; + +TEST_F(AnimatableImageTest, BasicAnimation) { + [animation_ setStartFrame:CGRectMake(0, 0, 10, 10)]; + [animation_ setEndFrame:CGRectMake(500, 500, 100, 100)]; + [animation_ setStartOpacity:0.1]; + [animation_ setEndOpacity:1.0]; + [animation_ setDuration:0.5]; + [animation_ startAnimation]; +} + +TEST_F(AnimatableImageTest, CancelAnimation) { + [animation_ setStartFrame:CGRectMake(0, 0, 10, 10)]; + [animation_ setEndFrame:CGRectMake(500, 500, 100, 100)]; + [animation_ setStartOpacity:0.1]; + [animation_ setEndOpacity:1.0]; + [animation_ setDuration:5.0]; // Long enough to be able to test cancelling. + [animation_ startAnimation]; + [animation_ close]; +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/animatable_view.h b/chrome/browser/ui/cocoa/animatable_view.h new file mode 100644 index 0000000..dcf2c26 --- /dev/null +++ b/chrome/browser/ui/cocoa/animatable_view.h @@ -0,0 +1,59 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_ANIMATABLE_VIEW_H_ +#define CHROME_BROWSER_UI_COCOA_ANIMATABLE_VIEW_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#import "base/cocoa_protocols_mac.h" +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/background_gradient_view.h" +#import "chrome/browser/ui/cocoa/view_resizer.h" + +// A view that provides an animatable height property. Provides methods to +// animate to a new height, set a new height immediately, or cancel any running +// animations. +// +// AnimatableView sends an |animationDidEnd:| message to its delegate when the +// animation ends normally and an |animationDidStop:| message when the animation +// was canceled (even when canceled as a result of a new animation starting). + +@interface AnimatableView : BackgroundGradientView<NSAnimationDelegate> { + @protected + IBOutlet id delegate_; // weak, used to send animation ended messages. + + @private + scoped_nsobject<NSAnimation> currentAnimation_; + id<ViewResizer> resizeDelegate_; // weak, usually owns us +} + +// Properties for bindings. +@property(assign, nonatomic) id delegate; +@property(assign, nonatomic) id<ViewResizer> resizeDelegate; + +// Gets the current height of the view. If an animation is currently running, +// this will give the current height at the time of the call, not the target +// height at the end of the animation. +- (CGFloat)height; + +// Sets the height of the view immediately. Cancels any running animations. +- (void)setHeight:(CGFloat)newHeight; + +// Starts a new animation to the given |newHeight| for the given |duration|. +// Cancels any running animations. +- (void)animateToNewHeight:(CGFloat)newHeight + duration:(NSTimeInterval)duration; + +// Cancels any running animations, leaving the view at its current +// (mid-animation) height. +- (void)stopAnimation; + +// Gets the progress of any current animation. +- (NSAnimationProgress)currentAnimationProgress; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_ANIMATABLE_VIEW_H_ diff --git a/chrome/browser/ui/cocoa/animatable_view.mm b/chrome/browser/ui/cocoa/animatable_view.mm new file mode 100644 index 0000000..74dc4b1 --- /dev/null +++ b/chrome/browser/ui/cocoa/animatable_view.mm @@ -0,0 +1,109 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> +#import <QuartzCore/QuartzCore.h> + +#import "chrome/browser/ui/cocoa/animatable_view.h" +#import "third_party/GTM/AppKit/GTMNSAnimation+Duration.h" + +// NSAnimation subclass that animates the height of an AnimatableView. Allows +// the caller to start and cancel the animation as desired. +@interface HeightAnimation : NSAnimation { + @private + AnimatableView* view_; // weak, owns us. + CGFloat startHeight_; + CGFloat endHeight_; +} + +// Initialize a new height animation for the given view. The animation will not +// start until startAnimation: is called. +- (id)initWithView:(AnimatableView*)view + finalHeight:(CGFloat)height + duration:(NSTimeInterval)duration; +@end + +@implementation HeightAnimation +- (id)initWithView:(AnimatableView*)view + finalHeight:(CGFloat)height + duration:(NSTimeInterval)duration { + if ((self = [super gtm_initWithDuration:duration + eventMask:NSLeftMouseUpMask + animationCurve:NSAnimationEaseIn])) { + view_ = view; + startHeight_ = [view_ height]; + endHeight_ = height; + [self setAnimationBlockingMode:NSAnimationNonblocking]; + [self setDelegate:view_]; + } + return self; +} + +// Overridden to call setHeight for each progress tick. +- (void)setCurrentProgress:(NSAnimationProgress)progress { + [super setCurrentProgress:progress]; + [view_ setHeight:((progress * (endHeight_ - startHeight_)) + startHeight_)]; +} +@end + + +@implementation AnimatableView +@synthesize delegate = delegate_; +@synthesize resizeDelegate = resizeDelegate_; + +- (void)dealloc { + // Stop the animation if it is running, since it holds a pointer to this view. + [self stopAnimation]; + [super dealloc]; +} + +- (CGFloat)height { + return [self frame].size.height; +} + +- (void)setHeight:(CGFloat)newHeight { + // Force the height to be an integer because some animations look terrible + // with non-integer intermediate heights. We only ever set integer heights + // for our views, so this shouldn't be a limitation in practice. + int height = floor(newHeight); + [resizeDelegate_ resizeView:self newHeight:height]; +} + +- (void)animateToNewHeight:(CGFloat)newHeight + duration:(NSTimeInterval)duration { + [currentAnimation_ stopAnimation]; + + currentAnimation_.reset([[HeightAnimation alloc] initWithView:self + finalHeight:newHeight + duration:duration]); + if ([resizeDelegate_ respondsToSelector:@selector(setAnimationInProgress:)]) + [resizeDelegate_ setAnimationInProgress:YES]; + [currentAnimation_ startAnimation]; +} + +- (void)stopAnimation { + [currentAnimation_ stopAnimation]; +} + +- (NSAnimationProgress)currentAnimationProgress { + return [currentAnimation_ currentProgress]; +} + +- (void)animationDidStop:(NSAnimation*)animation { + if ([resizeDelegate_ respondsToSelector:@selector(setAnimationInProgress:)]) + [resizeDelegate_ setAnimationInProgress:NO]; + if ([delegate_ respondsToSelector:@selector(animationDidStop:)]) + [delegate_ animationDidStop:animation]; + currentAnimation_.reset(nil); +} + +- (void)animationDidEnd:(NSAnimation*)animation { + if ([resizeDelegate_ respondsToSelector:@selector(setAnimationInProgress:)]) + [resizeDelegate_ setAnimationInProgress:NO]; + if ([delegate_ respondsToSelector:@selector(animationDidEnd:)]) + [delegate_ animationDidEnd:animation]; + currentAnimation_.reset(nil); +} + +@end diff --git a/chrome/browser/ui/cocoa/animatable_view_unittest.mm b/chrome/browser/ui/cocoa/animatable_view_unittest.mm new file mode 100644 index 0000000..b9073a8 --- /dev/null +++ b/chrome/browser/ui/cocoa/animatable_view_unittest.mm @@ -0,0 +1,48 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/animatable_view.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/view_resizer_pong.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +namespace { + +class AnimatableViewTest : public CocoaTest { + public: + AnimatableViewTest() { + NSRect frame = NSMakeRect(0, 0, 100, 100); + view_.reset([[AnimatableView alloc] initWithFrame:frame]); + [[test_window() contentView] addSubview:view_.get()]; + + resizeDelegate_.reset([[ViewResizerPong alloc] init]); + [view_ setResizeDelegate:resizeDelegate_.get()]; + } + + scoped_nsobject<ViewResizerPong> resizeDelegate_; + scoped_nsobject<AnimatableView> view_; +}; + +// Basic view tests (AddRemove, Display). +TEST_VIEW(AnimatableViewTest, view_); + +TEST_F(AnimatableViewTest, GetAndSetHeight) { + // Make sure the view's height starts out at 100. + NSRect initialFrame = [view_ frame]; + ASSERT_EQ(100, initialFrame.size.height); + EXPECT_EQ(initialFrame.size.height, [view_ height]); + + // Set it directly to 50 and make sure it takes effect. + [resizeDelegate_ setHeight:-1]; + [view_ setHeight:50]; + EXPECT_EQ(50, [resizeDelegate_ height]); +} + +// TODO(rohitrao): Find a way to unittest the animations and delegate messages. + +} // namespace diff --git a/chrome/browser/ui/cocoa/applescript/bookmark_applescript_utils_unittest.h b/chrome/browser/ui/cocoa/applescript/bookmark_applescript_utils_unittest.h new file mode 100644 index 0000000..41e22d2 --- /dev/null +++ b/chrome/browser/ui/cocoa/applescript/bookmark_applescript_utils_unittest.h @@ -0,0 +1,53 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_APPLESCRIPT_BOOKMARK_APPLESCRIPT_UTILS_UNITTEST_H_ +#define CHROME_BROWSER_UI_COCOA_APPLESCRIPT_BOOKMARK_APPLESCRIPT_UTILS_UNITTEST_H_ + +#import <objc/objc-runtime.h> +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#import "chrome/browser/app_controller_mac.h" +#import "chrome/browser/ui/cocoa/applescript/bookmark_folder_applescript.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#include "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "chrome/test/model_test_utils.h" +#include "testing/platform_test.h" + +class BookmarkModel; + +// The fake object that acts as our app's delegate, useful for testing purposes. +@interface FakeAppDelegate : AppController { + @public + BrowserTestHelper* helper_; // weak. +} +@property (nonatomic) BrowserTestHelper* helper; +// Return the |TestingProfile*| which is used for testing. +- (Profile*)defaultProfile; +@end + + +// Used to emulate an active running script, useful for testing purposes. +@interface FakeScriptCommand : NSScriptCommand { + Method originalMethod_; + Method alternateMethod_; +} +@end + + +// The base class for all our bookmark releated unit tests. +class BookmarkAppleScriptTest : public CocoaTest { + public: + BookmarkAppleScriptTest(); + private: + BrowserTestHelper helper_; + scoped_nsobject<FakeAppDelegate> appDelegate_; + protected: + scoped_nsobject<BookmarkFolderAppleScript> bookmarkBar_; + BookmarkModel& model(); +}; + +#endif +// CHROME_BROWSER_UI_COCOA_APPLESCRIPT_BOOKMARK_APPLESCRIPT_UTILS_UNITTEST_H_ diff --git a/chrome/browser/ui/cocoa/applescript/bookmark_applescript_utils_unittest.mm b/chrome/browser/ui/cocoa/applescript/bookmark_applescript_utils_unittest.mm new file mode 100644 index 0000000..108b1fb --- /dev/null +++ b/chrome/browser/ui/cocoa/applescript/bookmark_applescript_utils_unittest.mm @@ -0,0 +1,62 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/applescript/bookmark_applescript_utils_unittest.h" + +#include "chrome/browser/bookmarks/bookmark_model.h" + +@implementation FakeAppDelegate + +@synthesize helper = helper_; + +- (Profile*)defaultProfile { + if (!helper_) + return NULL; + return helper_->profile(); +} +@end + +// Represents the current fake command that is executing. +static FakeScriptCommand* kFakeCurrentCommand; + +@implementation FakeScriptCommand + +- (id)init { + if ((self = [super init])) { + originalMethod_ = class_getClassMethod([NSScriptCommand class], + @selector(currentCommand)); + alternateMethod_ = class_getClassMethod([self class], + @selector(currentCommand)); + method_exchangeImplementations(originalMethod_, alternateMethod_); + kFakeCurrentCommand = self; + } + return self; +} + ++ (NSScriptCommand*)currentCommand { + return kFakeCurrentCommand; +} + +- (void)dealloc { + method_exchangeImplementations(originalMethod_, alternateMethod_); + kFakeCurrentCommand = nil; + [super dealloc]; +} + +@end + +BookmarkAppleScriptTest::BookmarkAppleScriptTest() { + appDelegate_.reset([[FakeAppDelegate alloc] init]); + [appDelegate_.get() setHelper:&helper_]; + [NSApp setDelegate:appDelegate_]; + const BookmarkNode* root = model().GetBookmarkBarNode(); + const std::string modelString("a f1:[ b d c ] d f2:[ e f g ] h "); + model_test_utils::AddNodesFromModelString(model(), root, modelString); + bookmarkBar_.reset([[BookmarkFolderAppleScript alloc] + initWithBookmarkNode:model().GetBookmarkBarNode()]); +} + +BookmarkModel& BookmarkAppleScriptTest::model() { + return *helper_.profile()->GetBookmarkModel(); +} diff --git a/chrome/browser/ui/cocoa/applescript/bookmark_folder_applescript.h b/chrome/browser/ui/cocoa/applescript/bookmark_folder_applescript.h new file mode 100644 index 0000000..3e853b3 --- /dev/null +++ b/chrome/browser/ui/cocoa/applescript/bookmark_folder_applescript.h @@ -0,0 +1,70 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_APPLESCRIPT_BOOKMARK_FOLDER_APPLESCRIPT_H_ +#define CHROME_BROWSER_UI_COCOA_APPLESCRIPT_BOOKMARK_FOLDER_APPLESCRIPT_H_ + +#import <Cocoa/Cocoa.h> + +#import "chrome/browser/ui/cocoa/applescript/bookmark_node_applescript.h" + +@class BookmarkItemAppleScript; + +// Represent a bookmark folder scriptable object in applescript. +@interface BookmarkFolderAppleScript : BookmarkNodeAppleScript { + +} + +// Bookmark folder manipulation methods. +// Returns an array of |BookmarkFolderAppleScript*| of all the bookmark folders +// contained within this particular folder. +- (NSArray*)bookmarkFolders; + +// Inserts a bookmark folder at the end. +- (void)insertInBookmarkFolders:(id)aBookmarkFolder; + +// Inserts a bookmark folder at some position in the list. +// Called by applescript which takes care of bounds checking, make sure of it +// before calling directly. +- (void)insertInBookmarkFolders:(id)aBookmarkFolder atIndex:(int)index; + +// Remove a bookmark folder from the list. +// Called by applescript which takes care of bounds checking, make sure of it +// before calling directly. +- (void)removeFromBookmarkFoldersAtIndex:(int)index; + +// Bookmark item manipulation methods. +// Returns an array of |BookmarkItemAppleScript*| of all the bookmark items +// contained within this particular folder. +- (NSArray*)bookmarkItems; + +// Inserts a bookmark item at the end. +- (void)insertInBookmarkItems:(BookmarkItemAppleScript*)aBookmarkItem; + +// Inserts a bookmark item at some position in the list. +// Called by applescript which takes care of bounds checking, make sure of it +// before calling directly. +- (void)insertInBookmarkItems:(BookmarkItemAppleScript*)aBookmarkItem + atIndex:(int)index; + +// Removes a bookmarks folder from the list. +// Called by applescript which takes care of bounds checking, make sure of it +// before calling directly. +- (void)removeFromBookmarkItemsAtIndex:(int)index; + +// Returns the position of a bookmark folder within the current bookmark folder +// which consists of bookmark folders as well as bookmark items. +// AppleScript makes sure that there is a bookmark folder before calling this +// method, make sure of that before calling directly. +- (int)calculatePositionOfBookmarkFolderAt:(int)index; + +// Returns the position of a bookmark item within the current bookmark folder +// which consists of bookmark folders as well as bookmark items. +// AppleScript makes sure that there is a bookmark item before calling this +// method, make sure of that before calling directly. +- (int)calculatePositionOfBookmarkItemAt:(int)index; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_APPLESCRIPT_BOOKMARK_FOLDER_APPLESCRIPT_H_ diff --git a/chrome/browser/ui/cocoa/applescript/bookmark_folder_applescript.mm b/chrome/browser/ui/cocoa/applescript/bookmark_folder_applescript.mm new file mode 100644 index 0000000..40d84b2 --- /dev/null +++ b/chrome/browser/ui/cocoa/applescript/bookmark_folder_applescript.mm @@ -0,0 +1,204 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/applescript/bookmark_folder_applescript.h" + +#import "base/scoped_nsobject.h" +#import "base/string16.h" +#include "base/sys_string_conversions.h" +#include "chrome/browser/bookmarks/bookmark_model.h" +#import "chrome/browser/ui/cocoa/applescript/bookmark_item_applescript.h" +#import "chrome/browser/ui/cocoa/applescript/constants_applescript.h" +#include "chrome/browser/ui/cocoa/applescript/error_applescript.h" +#include "googleurl/src/gurl.h" + +@implementation BookmarkFolderAppleScript + +- (NSArray*)bookmarkFolders { + NSMutableArray* bookmarkFolders = [NSMutableArray + arrayWithCapacity:bookmarkNode_->GetChildCount()]; + + for (int i = 0; i < bookmarkNode_->GetChildCount(); ++i) { + const BookmarkNode* node = bookmarkNode_->GetChild(i); + + if (!node->is_folder()) + continue; + scoped_nsobject<BookmarkFolderAppleScript> bookmarkFolder( + [[BookmarkFolderAppleScript alloc] + initWithBookmarkNode:node]); + [bookmarkFolder setContainer:self + property:AppleScript::kBookmarkFoldersProperty]; + [bookmarkFolders addObject:bookmarkFolder]; + } + + return bookmarkFolders; +} + +- (void)insertInBookmarkFolders:(id)aBookmarkFolder { + // This method gets called when a new bookmark folder is created so + // the container and property are set here. + [aBookmarkFolder setContainer:self + property:AppleScript::kBookmarkFoldersProperty]; + BookmarkModel* model = [self bookmarkModel]; + if (!model) + return; + + const BookmarkNode* node = model->AddGroup(bookmarkNode_, + bookmarkNode_->GetChildCount(), + string16()); + if (!node) { + AppleScript::SetError(AppleScript::errCreateBookmarkFolder); + return; + } + + [aBookmarkFolder setBookmarkNode:node]; +} + +- (void)insertInBookmarkFolders:(id)aBookmarkFolder atIndex:(int)index { + // This method gets called when a new bookmark folder is created so + // the container and property are set here. + [aBookmarkFolder setContainer:self + property:AppleScript::kBookmarkFoldersProperty]; + int position = [self calculatePositionOfBookmarkFolderAt:index]; + + BookmarkModel* model = [self bookmarkModel]; + if (!model) + return; + + const BookmarkNode* node = model->AddGroup(bookmarkNode_, + position, + string16()); + if (!node) { + AppleScript::SetError(AppleScript::errCreateBookmarkFolder); + return; + } + + [aBookmarkFolder setBookmarkNode:node]; +} + +- (void)removeFromBookmarkFoldersAtIndex:(int)index { + int position = [self calculatePositionOfBookmarkFolderAt:index]; + + BookmarkModel* model = [self bookmarkModel]; + if (!model) + return; + + model->Remove(bookmarkNode_, position); +} + +- (NSArray*)bookmarkItems { + NSMutableArray* bookmarkItems = [NSMutableArray + arrayWithCapacity:bookmarkNode_->GetChildCount()]; + + for (int i = 0; i < bookmarkNode_->GetChildCount(); ++i) { + const BookmarkNode* node = bookmarkNode_->GetChild(i); + + if (!node->is_url()) + continue; + scoped_nsobject<BookmarkFolderAppleScript> bookmarkItem( + [[BookmarkItemAppleScript alloc] + initWithBookmarkNode:node]); + [bookmarkItem setContainer:self + property:AppleScript::kBookmarkItemsProperty]; + [bookmarkItems addObject:bookmarkItem]; + } + + return bookmarkItems; +} + +- (void)insertInBookmarkItems:(BookmarkItemAppleScript*)aBookmarkItem { + // This method gets called when a new bookmark item is created so + // the container and property are set here. + [aBookmarkItem setContainer:self + property:AppleScript::kBookmarkItemsProperty]; + + BookmarkModel* model = [self bookmarkModel]; + if (!model) + return; + + GURL url = GURL(base::SysNSStringToUTF8([aBookmarkItem URL])); + if (!url.is_valid()) { + AppleScript::SetError(AppleScript::errInvalidURL); + return; + } + + const BookmarkNode* node = model->AddURL(bookmarkNode_, + bookmarkNode_->GetChildCount(), + string16(), + url); + if (!node) { + AppleScript::SetError(AppleScript::errCreateBookmarkItem); + return; + } + + [aBookmarkItem setBookmarkNode:node]; +} + +- (void)insertInBookmarkItems:(BookmarkItemAppleScript*)aBookmarkItem + atIndex:(int)index { + // This method gets called when a new bookmark item is created so + // the container and property are set here. + [aBookmarkItem setContainer:self + property:AppleScript::kBookmarkItemsProperty]; + int position = [self calculatePositionOfBookmarkItemAt:index]; + + BookmarkModel* model = [self bookmarkModel]; + if (!model) + return; + + GURL url(base::SysNSStringToUTF8([aBookmarkItem URL])); + if (!url.is_valid()) { + AppleScript::SetError(AppleScript::errInvalidURL); + return; + } + + const BookmarkNode* node = model->AddURL(bookmarkNode_, + position, + string16(), + url); + if (!node) { + AppleScript::SetError(AppleScript::errCreateBookmarkItem); + return; + } + + [aBookmarkItem setBookmarkNode:node]; +} + +- (void)removeFromBookmarkItemsAtIndex:(int)index { + int position = [self calculatePositionOfBookmarkItemAt:index]; + + BookmarkModel* model = [self bookmarkModel]; + if (!model) + return; + + model->Remove(bookmarkNode_, position); +} + +- (int)calculatePositionOfBookmarkFolderAt:(int)index { + // Traverse through all the child nodes till the required node is found and + // return its position. + // AppleScript is 1-based therefore index is incremented by 1. + ++index; + int count = -1; + while (index) { + if (bookmarkNode_->GetChild(++count)->is_folder()) + --index; + } + return count; +} + +- (int)calculatePositionOfBookmarkItemAt:(int)index { + // Traverse through all the child nodes till the required node is found and + // return its position. + // AppleScript is 1-based therefore index is incremented by 1. + ++index; + int count = -1; + while (index) { + if (bookmarkNode_->GetChild(++count)->is_url()) + --index; + } + return count; +} + +@end diff --git a/chrome/browser/ui/cocoa/applescript/bookmark_folder_applescript_unittest.mm b/chrome/browser/ui/cocoa/applescript/bookmark_folder_applescript_unittest.mm new file mode 100644 index 0000000..fbdbf09 --- /dev/null +++ b/chrome/browser/ui/cocoa/applescript/bookmark_folder_applescript_unittest.mm @@ -0,0 +1,200 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#include "base/sys_string_conversions.h" +#import "chrome/browser/ui/cocoa/applescript/bookmark_applescript_utils_unittest.h" +#import "chrome/browser/ui/cocoa/applescript/bookmark_folder_applescript.h" +#import "chrome/browser/ui/cocoa/applescript/bookmark_item_applescript.h" +#import "chrome/browser/ui/cocoa/applescript/constants_applescript.h" +#import "chrome/browser/ui/cocoa/applescript/error_applescript.h" +#include "googleurl/src/gurl.h" +#include "testing/gtest/include/gtest/gtest.h" +#import "testing/gtest_mac.h" +#include "testing/platform_test.h" + +typedef BookmarkAppleScriptTest BookmarkFolderAppleScriptTest; + +namespace { + +// Test all the bookmark folders within. +TEST_F(BookmarkFolderAppleScriptTest, BookmarkFolders) { + NSArray* bookmarkFolders = [bookmarkBar_.get() bookmarkFolders]; + + EXPECT_EQ(2U, [bookmarkFolders count]); + + BookmarkFolderAppleScript* f1 = [bookmarkFolders objectAtIndex:0]; + BookmarkFolderAppleScript* f2 = [bookmarkFolders objectAtIndex:1]; + EXPECT_NSEQ(@"f1", [f1 title]); + EXPECT_NSEQ(@"f2", [f2 title]); + EXPECT_EQ(2, [[f1 index] intValue]); + EXPECT_EQ(4, [[f2 index] intValue]); + + for (BookmarkFolderAppleScript* bookmarkFolder in bookmarkFolders) { + EXPECT_EQ([bookmarkFolder container], bookmarkBar_.get()); + EXPECT_NSEQ(AppleScript::kBookmarkFoldersProperty, + [bookmarkFolder containerProperty]); + } +} + +// Insert a new bookmark folder. +TEST_F(BookmarkFolderAppleScriptTest, InsertBookmarkFolder) { + // Emulate what applescript would do when inserting a new bookmark folder. + // Emulates a script like |set var to make new bookmark folder with + // properties {title:"foo"}|. + scoped_nsobject<BookmarkFolderAppleScript> bookmarkFolder( + [[BookmarkFolderAppleScript alloc] init]); + scoped_nsobject<NSNumber> var([[bookmarkFolder.get() uniqueID] copy]); + [bookmarkFolder.get() setTitle:@"foo"]; + [bookmarkBar_.get() insertInBookmarkFolders:bookmarkFolder.get()]; + + // Represents the bookmark folder after its added. + BookmarkFolderAppleScript* bf = + [[bookmarkBar_.get() bookmarkFolders] objectAtIndex:2]; + EXPECT_NSEQ(@"foo", [bf title]); + EXPECT_EQ([bf container], bookmarkBar_.get()); + EXPECT_NSEQ(AppleScript::kBookmarkFoldersProperty, + [bf containerProperty]); + EXPECT_NSEQ(var.get(), [bf uniqueID]); +} + +// Insert a new bookmark folder at a particular position. +TEST_F(BookmarkFolderAppleScriptTest, InsertBookmarkFolderAtPosition) { + // Emulate what applescript would do when inserting a new bookmark folder. + // Emulates a script like |set var to make new bookmark folder with + // properties {title:"foo"} at after bookmark folder 1|. + scoped_nsobject<BookmarkFolderAppleScript> bookmarkFolder( + [[BookmarkFolderAppleScript alloc] init]); + scoped_nsobject<NSNumber> var([[bookmarkFolder.get() uniqueID] copy]); + [bookmarkFolder.get() setTitle:@"foo"]; + [bookmarkBar_.get() insertInBookmarkFolders:bookmarkFolder.get() atIndex:1]; + + // Represents the bookmark folder after its added. + BookmarkFolderAppleScript* bf = + [[bookmarkBar_.get() bookmarkFolders] objectAtIndex:1]; + EXPECT_NSEQ(@"foo", [bf title]); + EXPECT_EQ([bf container], bookmarkBar_.get()); + EXPECT_NSEQ(AppleScript::kBookmarkFoldersProperty, [bf containerProperty]); + EXPECT_NSEQ(var.get(), [bf uniqueID]); +} + +// Delete bookmark folders. +TEST_F(BookmarkFolderAppleScriptTest, DeleteBookmarkFolders) { + unsigned int folderCount = 2, itemCount = 3; + for (unsigned int i = 0; i < folderCount; ++i) { + EXPECT_EQ(folderCount - i, [[bookmarkBar_.get() bookmarkFolders] count]); + EXPECT_EQ(itemCount, [[bookmarkBar_.get() bookmarkItems] count]); + [bookmarkBar_.get() removeFromBookmarkFoldersAtIndex:0]; + } +} + +// Test all the bookmark items within. +TEST_F(BookmarkFolderAppleScriptTest, BookmarkItems) { + NSArray* bookmarkItems = [bookmarkBar_.get() bookmarkItems]; + + EXPECT_EQ(3U, [bookmarkItems count]); + + BookmarkItemAppleScript* i1 = [bookmarkItems objectAtIndex:0]; + BookmarkItemAppleScript* i2 = [bookmarkItems objectAtIndex:1]; + BookmarkItemAppleScript* i3 = [bookmarkItems objectAtIndex:2]; + EXPECT_NSEQ(@"a", [i1 title]); + EXPECT_NSEQ(@"d", [i2 title]); + EXPECT_NSEQ(@"h", [i3 title]); + EXPECT_EQ(1, [[i1 index] intValue]); + EXPECT_EQ(3, [[i2 index] intValue]); + EXPECT_EQ(5, [[i3 index] intValue]); + + for (BookmarkItemAppleScript* bookmarkItem in bookmarkItems) { + EXPECT_EQ([bookmarkItem container], bookmarkBar_.get()); + EXPECT_NSEQ(AppleScript::kBookmarkItemsProperty, + [bookmarkItem containerProperty]); + } +} + +// Insert a new bookmark item. +TEST_F(BookmarkFolderAppleScriptTest, InsertBookmarkItem) { + // Emulate what applescript would do when inserting a new bookmark folder. + // Emulates a script like |set var to make new bookmark item with + // properties {title:"Google", URL:"http://google.com"}|. + scoped_nsobject<BookmarkItemAppleScript> bookmarkItem( + [[BookmarkItemAppleScript alloc] init]); + scoped_nsobject<NSNumber> var([[bookmarkItem.get() uniqueID] copy]); + [bookmarkItem.get() setTitle:@"Google"]; + [bookmarkItem.get() setURL:@"http://google.com"]; + [bookmarkBar_.get() insertInBookmarkItems:bookmarkItem.get()]; + + // Represents the bookmark item after its added. + BookmarkItemAppleScript* bi = + [[bookmarkBar_.get() bookmarkItems] objectAtIndex:3]; + EXPECT_NSEQ(@"Google", [bi title]); + EXPECT_EQ(GURL("http://google.com/"), + GURL(base::SysNSStringToUTF8([bi URL]))); + EXPECT_EQ([bi container], bookmarkBar_.get()); + EXPECT_NSEQ(AppleScript::kBookmarkItemsProperty, [bi containerProperty]); + EXPECT_NSEQ(var.get(), [bi uniqueID]); + + // Test to see no bookmark item is created when no/invlid URL is entered. + scoped_nsobject<FakeScriptCommand> fakeScriptCommand( + [[FakeScriptCommand alloc] init]); + bookmarkItem.reset([[BookmarkItemAppleScript alloc] init]); + [bookmarkBar_.get() insertInBookmarkItems:bookmarkItem.get()]; + EXPECT_EQ((int)AppleScript::errInvalidURL, + [fakeScriptCommand.get() scriptErrorNumber]); +} + +// Insert a new bookmark item at a particular position. +TEST_F(BookmarkFolderAppleScriptTest, InsertBookmarkItemAtPosition) { + // Emulate what applescript would do when inserting a new bookmark item. + // Emulates a script like |set var to make new bookmark item with + // properties {title:"XKCD", URL:"http://xkcd.org} + // at after bookmark item 1|. + scoped_nsobject<BookmarkItemAppleScript> bookmarkItem( + [[BookmarkItemAppleScript alloc] init]); + scoped_nsobject<NSNumber> var([[bookmarkItem.get() uniqueID] copy]); + [bookmarkItem.get() setTitle:@"XKCD"]; + [bookmarkItem.get() setURL:@"http://xkcd.org"]; + + [bookmarkBar_.get() insertInBookmarkItems:bookmarkItem.get() atIndex:1]; + + // Represents the bookmark item after its added. + BookmarkItemAppleScript* bi = + [[bookmarkBar_.get() bookmarkItems] objectAtIndex:1]; + EXPECT_NSEQ(@"XKCD", [bi title]); + EXPECT_EQ(GURL("http://xkcd.org/"), + GURL(base::SysNSStringToUTF8([bi URL]))); + EXPECT_EQ([bi container], bookmarkBar_.get()); + EXPECT_NSEQ(AppleScript::kBookmarkItemsProperty, + [bi containerProperty]); + EXPECT_NSEQ(var.get(), [bi uniqueID]); + + // Test to see no bookmark item is created when no/invlid URL is entered. + scoped_nsobject<FakeScriptCommand> fakeScriptCommand( + [[FakeScriptCommand alloc] init]); + bookmarkItem.reset([[BookmarkItemAppleScript alloc] init]); + [bookmarkBar_.get() insertInBookmarkItems:bookmarkItem.get() atIndex:1]; + EXPECT_EQ((int)AppleScript::errInvalidURL, + [fakeScriptCommand.get() scriptErrorNumber]); +} + +// Delete bookmark items. +TEST_F(BookmarkFolderAppleScriptTest, DeleteBookmarkItems) { + unsigned int folderCount = 2, itemCount = 3; + for (unsigned int i = 0; i < itemCount; ++i) { + EXPECT_EQ(folderCount, [[bookmarkBar_.get() bookmarkFolders] count]); + EXPECT_EQ(itemCount - i, [[bookmarkBar_.get() bookmarkItems] count]); + [bookmarkBar_.get() removeFromBookmarkItemsAtIndex:0]; + } +} + +// Set and get title. +TEST_F(BookmarkFolderAppleScriptTest, GetAndSetTitle) { + NSArray* bookmarkFolders = [bookmarkBar_.get() bookmarkFolders]; + BookmarkFolderAppleScript* folder1 = [bookmarkFolders objectAtIndex:0]; + [folder1 setTitle:@"Foo"]; + EXPECT_NSEQ(@"Foo", [folder1 title]); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/applescript/bookmark_item_applescript.h b/chrome/browser/ui/cocoa/applescript/bookmark_item_applescript.h new file mode 100644 index 0000000..b84365d --- /dev/null +++ b/chrome/browser/ui/cocoa/applescript/bookmark_item_applescript.h @@ -0,0 +1,33 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_APPLESCRIPT_BOOKMARK_ITEM_APPLESCRIPT_H_ +#define CHROME_BROWSER_UI_COCOA_APPLESCRIPT_BOOKMARK_ITEM_APPLESCRIPT_H_ + +#import <Cocoa/Cocoa.h> + +#import "chrome/browser/ui/cocoa/applescript/bookmark_node_applescript.h" + +// Represents a bookmark item scriptable object in applescript. +@interface BookmarkItemAppleScript : BookmarkNodeAppleScript { + @private + // Contains the temporary title when a user creates a new item with + // title specified like + // |make new bookmarks item with properties {title:"foo"}|. + NSString* tempURL_; +} + +// Assigns a node, sets its unique ID and also copies temporary values. +- (void)setBookmarkNode:(const BookmarkNode*)aBookmarkNode; + +// Returns the URL that the bookmark item holds. +- (NSString*)URL; + +// Sets the URL of the bookmark item, displays error in applescript console +// if URL is invalid. +- (void)setURL:(NSString*)aURL; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_APPLESCRIPT_BOOKMARK_ITEM_APPLESCRIPT_H_ diff --git a/chrome/browser/ui/cocoa/applescript/bookmark_item_applescript.mm b/chrome/browser/ui/cocoa/applescript/bookmark_item_applescript.mm new file mode 100644 index 0000000..9df8a31 --- /dev/null +++ b/chrome/browser/ui/cocoa/applescript/bookmark_item_applescript.mm @@ -0,0 +1,66 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/applescript/bookmark_item_applescript.h" + +#include "base/sys_string_conversions.h" +#include "chrome/browser/bookmarks/bookmark_model.h" +#include "chrome/browser/profile_manager.h" +#import "chrome/browser/ui/cocoa/applescript/error_applescript.h" + +@interface BookmarkItemAppleScript() +@property (nonatomic, copy) NSString* tempURL; +@end + +@implementation BookmarkItemAppleScript + +@synthesize tempURL = tempURL_; + +- (id)init { + if ((self = [super init])) { + [self setTempURL:@""]; + } + return self; +} + +- (void)dealloc { + [tempURL_ release]; + [super dealloc]; +} + +- (void)setBookmarkNode:(const BookmarkNode*)aBookmarkNode { + [super setBookmarkNode:aBookmarkNode]; + [self setURL:[self tempURL]]; +} + +- (NSString*)URL { + if (!bookmarkNode_) + return tempURL_; + + const GURL& url = bookmarkNode_->GetURL(); + return base::SysUTF8ToNSString(url.spec()); +} + +- (void)setURL:(NSString*)aURL { + // If a scripter sets a URL before the node is added, URL is saved at a + // temporary location. + if (!bookmarkNode_) { + [self setTempURL:aURL]; + return; + } + + BookmarkModel* model = [self bookmarkModel]; + if (!model) + return; + + GURL url(base::SysNSStringToUTF8(aURL)); + if (!url.is_valid()) { + AppleScript::SetError(AppleScript::errInvalidURL); + return; + } + + model->SetURL(bookmarkNode_, url); +} + +@end diff --git a/chrome/browser/ui/cocoa/applescript/bookmark_item_applescript_unittest.mm b/chrome/browser/ui/cocoa/applescript/bookmark_item_applescript_unittest.mm new file mode 100644 index 0000000..2cd5a94 --- /dev/null +++ b/chrome/browser/ui/cocoa/applescript/bookmark_item_applescript_unittest.mm @@ -0,0 +1,45 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#include "base/sys_string_conversions.h" +#import "chrome/browser/ui/cocoa/applescript/bookmark_applescript_utils_unittest.h" +#import "chrome/browser/ui/cocoa/applescript/bookmark_item_applescript.h" +#import "chrome/browser/ui/cocoa/applescript/error_applescript.h" +#include "googleurl/src/gurl.h" +#include "testing/gtest/include/gtest/gtest.h" +#import "testing/gtest_mac.h" +#include "testing/platform_test.h" + +typedef BookmarkAppleScriptTest BookmarkItemAppleScriptTest; + +namespace { + +// Set and get title. +TEST_F(BookmarkItemAppleScriptTest, GetAndSetTitle) { + NSArray* bookmarkItems = [bookmarkBar_.get() bookmarkItems]; + BookmarkItemAppleScript* item1 = [bookmarkItems objectAtIndex:0]; + [item1 setTitle:@"Foo"]; + EXPECT_NSEQ(@"Foo", [item1 title]); +} + +// Set and get URL. +TEST_F(BookmarkItemAppleScriptTest, GetAndSetURL) { + NSArray* bookmarkItems = [bookmarkBar_.get() bookmarkItems]; + BookmarkItemAppleScript* item1 = [bookmarkItems objectAtIndex:0]; + [item1 setURL:@"http://foo-bar.org"]; + EXPECT_EQ(GURL("http://foo-bar.org"), + GURL(base::SysNSStringToUTF8([item1 URL]))); + + // If scripter enters invalid URL. + scoped_nsobject<FakeScriptCommand> fakeScriptCommand( + [[FakeScriptCommand alloc] init]); + [item1 setURL:@"invalid-url.org"]; + EXPECT_EQ((int)AppleScript::errInvalidURL, + [fakeScriptCommand.get() scriptErrorNumber]); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/applescript/bookmark_node_applescript.h b/chrome/browser/ui/cocoa/applescript/bookmark_node_applescript.h new file mode 100644 index 0000000..0f1db68 --- /dev/null +++ b/chrome/browser/ui/cocoa/applescript/bookmark_node_applescript.h @@ -0,0 +1,48 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_APPLESCRIPT_BOOKMARK_NODE_APPLESCRIPT_H_ +#define CHROME_BROWSER_UI_COCOA_APPLESCRIPT_BOOKMARK_NODE_APPLESCRIPT_H_ + +#import <Cocoa/Cocoa.h> + +#import "chrome/browser/ui/cocoa/applescript/element_applescript.h" + +class BookmarkModel; +class BookmarkNode; + +// Contains all the elements that are common to both a bookmark folder and +// bookmark item. +@interface BookmarkNodeAppleScript : ElementAppleScript { + @protected + const BookmarkNode* bookmarkNode_; // weak. + // Contains the temporary title when a scripter creates a new folder/item with + // title specified like + // |make new bookmark folder with properties {title:"foo"}|. + NSString* tempTitle_; +} + +// Does not actually create a folder/item but just sets its ID, the folder is +// created in insertInBookmarksFolder: in the corresponding bookmarks folder. +- (id)init; + +// Does not make a folder/item but instead uses an existing one. +- (id)initWithBookmarkNode:(const BookmarkNode*)aBookmarkNode; + +// Assigns a node, sets its unique ID and also copies temporary values. +- (void)setBookmarkNode:(const BookmarkNode*)aBookmarkNode; + +// Get and Set title. +- (NSString*)title; +- (void)setTitle:(NSString*)aTitle; + +// Returns the index with respect to its parent bookmark folder. +- (NSNumber*)index; + +// Returns the bookmark model of the browser, returns NULL if there is an error. +- (BookmarkModel*)bookmarkModel; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_APPLESCRIPT_BOOKMARK_NODE_APPLESCRIPT_H_ diff --git a/chrome/browser/ui/cocoa/applescript/bookmark_node_applescript.mm b/chrome/browser/ui/cocoa/applescript/bookmark_node_applescript.mm new file mode 100644 index 0000000..dc0c14e --- /dev/null +++ b/chrome/browser/ui/cocoa/applescript/bookmark_node_applescript.mm @@ -0,0 +1,130 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/applescript/bookmark_node_applescript.h" + +#include "base/logging.h" +#include "base/sys_string_conversions.h" +#import "base/scoped_nsobject.h" +#import "chrome/browser/app_controller_mac.h" +#include "chrome/browser/bookmarks/bookmark_model.h" +#import "chrome/browser/chrome_browser_application_mac.h" +#include "chrome/browser/profile.h" +#import "chrome/browser/ui/cocoa/applescript/bookmark_item_applescript.h" +#import "chrome/browser/ui/cocoa/applescript/error_applescript.h" + +@interface BookmarkNodeAppleScript() +@property (nonatomic, copy) NSString* tempTitle; +@end + +@implementation BookmarkNodeAppleScript + +@synthesize tempTitle = tempTitle_; + +- (id)init { + if ((self = [super init])) { + BookmarkModel* model = [self bookmarkModel]; + if (!model) { + [self release]; + return nil; + } + + scoped_nsobject<NSNumber> numID( + [[NSNumber alloc] initWithLongLong:model->next_node_id()]); + [self setUniqueID:numID]; + [self setTempTitle:@""]; + } + return self; +} + +- (void)dealloc { + [tempTitle_ release]; + [super dealloc]; +} + + +- (id)initWithBookmarkNode:(const BookmarkNode*)aBookmarkNode { + if (!aBookmarkNode) { + [self release]; + return nil; + } + + if ((self = [super init])) { + // It is safe to be weak, if a bookmark item/folder goes away + // (eg user deleting a folder) the applescript runtime calls + // bookmarkFolders/bookmarkItems in BookmarkFolderAppleScript + // and this particular bookmark item/folder is never returned. + bookmarkNode_ = aBookmarkNode; + + scoped_nsobject<NSNumber> numID( + [[NSNumber alloc] initWithLongLong:aBookmarkNode->id()]); + [self setUniqueID:numID]; + } + return self; +} + +- (void)setBookmarkNode:(const BookmarkNode*)aBookmarkNode { + DCHECK(aBookmarkNode); + // It is safe to be weak, if a bookmark item/folder goes away + // (eg user deleting a folder) the applescript runtime calls + // bookmarkFolders/bookmarkItems in BookmarkFolderAppleScript + // and this particular bookmark item/folder is never returned. + bookmarkNode_ = aBookmarkNode; + + scoped_nsobject<NSNumber> numID( + [[NSNumber alloc] initWithLongLong:aBookmarkNode->id()]); + [self setUniqueID:numID]; + + [self setTitle:[self tempTitle]]; +} + +- (NSString*)title { + if (!bookmarkNode_) + return tempTitle_; + + return base::SysUTF16ToNSString(bookmarkNode_->GetTitle()); +} + +- (void)setTitle:(NSString*)aTitle { + // If the scripter enters |make new bookmarks folder with properties + // {title:"foo"}|, the node has not yet been created so title is stored in the + // temp title. + if (!bookmarkNode_) { + [self setTempTitle:aTitle]; + return; + } + + BookmarkModel* model = [self bookmarkModel]; + if (!model) + return; + + model->SetTitle(bookmarkNode_, base::SysNSStringToUTF16(aTitle)); +} + +- (NSNumber*)index { + const BookmarkNode* parent = bookmarkNode_->GetParent(); + int index = parent->IndexOfChild(bookmarkNode_); + // NOTE: AppleScript is 1-Based. + return [NSNumber numberWithInt:index+1]; +} + +- (BookmarkModel*)bookmarkModel { + AppController* appDelegate = [NSApp delegate]; + + Profile* defaultProfile = [appDelegate defaultProfile]; + if (!defaultProfile) { + AppleScript::SetError(AppleScript::errGetProfile); + return NULL; + } + + BookmarkModel* model = defaultProfile->GetBookmarkModel(); + if (!model->IsLoaded()) { + AppleScript::SetError(AppleScript::errBookmarkModelLoad); + return NULL; + } + + return model; +} + +@end diff --git a/chrome/browser/ui/cocoa/applescript/browsercrapplication+applescript.h b/chrome/browser/ui/cocoa/applescript/browsercrapplication+applescript.h new file mode 100644 index 0000000..795d198 --- /dev/null +++ b/chrome/browser/ui/cocoa/applescript/browsercrapplication+applescript.h @@ -0,0 +1,59 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_APPLESCRIPT_BROWSERCRAPPLICATION_APPLESCRIPT_H_ +#define CHROME_BROWSER_UI_COCOA_APPLESCRIPT_BROWSERCRAPPLICATION_APPLESCRIPT_H_ + +#import <Cocoa/Cocoa.h> + +#import "chrome/browser/chrome_browser_application_mac.h" + +@class BookmarkFolderAppleScript; +@class WindowAppleScript; + +// Represent the top level application scripting object in applescript. +@interface BrowserCrApplication (AppleScriptAdditions) + +// Application window manipulation methods. +// Returns an array of |WindowAppleScript*| of all windows present in the +// application. +- (NSArray*)appleScriptWindows; + +// Inserts a window at the beginning. +- (void)insertInAppleScriptWindows:(WindowAppleScript*)aWindow; + +// Inserts a window at some position in the list. +// Called by applescript which takes care of bounds checking, make sure of it +// before calling directly. +- (void)insertInAppleScriptWindows:(WindowAppleScript*)aWindow + atIndex:(int)index; + +// Removes a window from the list. +// Called by applescript which takes care of bounds checking, make sure of it +// before calling directly. +- (void)removeFromAppleScriptWindowsAtIndex:(int)index; + +// Always returns nil to indicate that it is the root container object. +- (NSScriptObjectSpecifier*)objectSpecifier; + +// Returns the other bookmarks bookmark folder, +// returns nil if there is an error. +- (BookmarkFolderAppleScript*)otherBookmarks; + +// Returns the bookmarks bar bookmark folder, return nil if there is an error. +- (BookmarkFolderAppleScript*)bookmarksBar; + +// Returns the Bookmarks Bar and Other Bookmarks Folders, each is of type +// |BookmarkFolderAppleScript*|. +- (NSArray*)bookmarkFolders; + +// Required functions, even though bookmarkFolders is declared as +// read-only, cocoa scripting does not currently prevent writing. +- (void)insertInBookmarksFolders:(id)aBookmarkFolder; +- (void)insertInBookmarksFolders:(id)aBookmarkFolder atIndex:(int)index; +- (void)removeFromBookmarksFoldersAtIndex:(int)index; + +@end + +#endif// CHROME_BROWSER_UI_COCOA_APPLESCRIPT_BROWSERCRAPPLICATION_APPLESCRIPT_H_ diff --git a/chrome/browser/ui/cocoa/applescript/browsercrapplication+applescript.mm b/chrome/browser/ui/cocoa/applescript/browsercrapplication+applescript.mm new file mode 100644 index 0000000..240ef0d --- /dev/null +++ b/chrome/browser/ui/cocoa/applescript/browsercrapplication+applescript.mm @@ -0,0 +1,136 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/applescript/browsercrapplication+applescript.h" + +#include "base/logging.h" +#import "base/scoped_nsobject.h" +#import "chrome/browser/app_controller_mac.h" +#include "chrome/browser/bookmarks/bookmark_model.h" +#include "chrome/browser/browser_list.h" +#include "chrome/browser/profile.h" +#import "chrome/browser/ui/cocoa/applescript/bookmark_folder_applescript.h" +#import "chrome/browser/ui/cocoa/applescript/constants_applescript.h" +#import "chrome/browser/ui/cocoa/applescript/error_applescript.h" +#import "chrome/browser/ui/cocoa/applescript/window_applescript.h" + +@implementation BrowserCrApplication (AppleScriptAdditions) + +- (NSArray*)appleScriptWindows { + NSMutableArray* appleScriptWindows = [NSMutableArray + arrayWithCapacity:BrowserList::size()]; + // Iterate through all browsers and check if it closing, + // if not add it to list. + for (BrowserList::const_iterator browserIterator = BrowserList::begin(); + browserIterator != BrowserList::end(); ++browserIterator) { + if ((*browserIterator)->IsAttemptingToCloseBrowser()) + continue; + + scoped_nsobject<WindowAppleScript> window( + [[WindowAppleScript alloc] initWithBrowser:*browserIterator]); + [window setContainer:NSApp + property:AppleScript::kWindowsProperty]; + [appleScriptWindows addObject:window]; + } + // Windows sorted by their index value, which is obtained by calling + // orderedIndex: on each window. + [appleScriptWindows sortUsingSelector:@selector(windowComparator:)]; + return appleScriptWindows; +} + +- (void)insertInAppleScriptWindows:(WindowAppleScript*)aWindow { + // This method gets called when a new window is created so + // the container and property are set here. + [aWindow setContainer:self + property:AppleScript::kWindowsProperty]; +} + +- (void)insertInAppleScriptWindows:(WindowAppleScript*)aWindow + atIndex:(int)index { + // This method gets called when a new window is created so + // the container and property are set here. + [aWindow setContainer:self + property:AppleScript::kWindowsProperty]; + // Note: AppleScript is 1-based. + index--; + [aWindow setOrderedIndex:[NSNumber numberWithInt:index]]; +} + +- (void)removeFromAppleScriptWindowsAtIndex:(int)index { + [[[self appleScriptWindows] objectAtIndex:index] + handlesCloseScriptCommand:nil]; +} + +- (NSScriptObjectSpecifier*)objectSpecifier { + return nil; +} + +- (BookmarkFolderAppleScript*)otherBookmarks { + AppController* appDelegate = [NSApp delegate]; + + Profile* defaultProfile = [appDelegate defaultProfile]; + if (!defaultProfile) { + AppleScript::SetError(AppleScript::errGetProfile); + return nil; + } + + BookmarkModel* model = defaultProfile->GetBookmarkModel(); + if (!model->IsLoaded()) { + AppleScript::SetError(AppleScript::errBookmarkModelLoad); + return nil; + } + + BookmarkFolderAppleScript* otherBookmarks = + [[[BookmarkFolderAppleScript alloc] + initWithBookmarkNode:model->other_node()] autorelease]; + [otherBookmarks setContainer:self + property:AppleScript::kBookmarkFoldersProperty]; + return otherBookmarks; +} + +- (BookmarkFolderAppleScript*)bookmarksBar { + AppController* appDelegate = [NSApp delegate]; + + Profile* defaultProfile = [appDelegate defaultProfile]; + if (!defaultProfile) { + AppleScript::SetError(AppleScript::errGetProfile); + return nil; + } + + BookmarkModel* model = defaultProfile->GetBookmarkModel(); + if (!model->IsLoaded()) { + AppleScript::SetError(AppleScript::errBookmarkModelLoad); + return NULL; + } + + BookmarkFolderAppleScript* bookmarksBar = + [[[BookmarkFolderAppleScript alloc] + initWithBookmarkNode:model->GetBookmarkBarNode()] autorelease]; + [bookmarksBar setContainer:self + property:AppleScript::kBookmarkFoldersProperty]; + return bookmarksBar; +} + +- (NSArray*)bookmarkFolders { + BookmarkFolderAppleScript* otherBookmarks = [self otherBookmarks]; + BookmarkFolderAppleScript* bookmarksBar = [self bookmarksBar]; + NSArray* folderArray = [NSArray arrayWithObjects:otherBookmarks, + bookmarksBar, + nil]; + return folderArray; +} + +- (void)insertInBookmarksFolders:(id)aBookmarkFolder { + NOTIMPLEMENTED(); +} + +- (void)insertInBookmarksFolders:(id)aBookmarkFolder atIndex:(int)index { + NOTIMPLEMENTED(); +} + +- (void)removeFromBookmarksFoldersAtIndex:(int)index { + NOTIMPLEMENTED(); +} + +@end diff --git a/chrome/browser/ui/cocoa/applescript/browsercrapplication+applescript_test.mm b/chrome/browser/ui/cocoa/applescript/browsercrapplication+applescript_test.mm new file mode 100644 index 0000000..a2e0f48 --- /dev/null +++ b/chrome/browser/ui/cocoa/applescript/browsercrapplication+applescript_test.mm @@ -0,0 +1,107 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "chrome/browser/profile.h" +#include "chrome/browser/ui/browser.h" +#import "chrome/browser/ui/cocoa/applescript/browsercrapplication+applescript.h" +#import "chrome/browser/ui/cocoa/applescript/constants_applescript.h" +#import "chrome/browser/ui/cocoa/applescript/window_applescript.h" +#include "chrome/test/in_process_browser_test.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/gtest_mac.h" + +typedef InProcessBrowserTest BrowserCrApplicationAppleScriptTest; + +// Create windows of different |Type|. +IN_PROC_BROWSER_TEST_F(BrowserCrApplicationAppleScriptTest, Creation) { + // Create additional |Browser*| objects of different type. + Profile* profile = browser()->profile(); + Browser* b1 = Browser::CreateForType(Browser::TYPE_POPUP, profile); + Browser* b2 = Browser::CreateForApp("", NULL, profile, true); + Browser* b3 = Browser::CreateForApp("", NULL, profile, false); + + EXPECT_EQ(4U, [[NSApp appleScriptWindows] count]); + for (WindowAppleScript* window in [NSApp appleScriptWindows]) { + EXPECT_NSEQ(AppleScript::kWindowsProperty, + [window containerProperty]); + EXPECT_NSEQ(NSApp, [window container]); + } + + // Close the additional browsers. + b1->CloseAllTabs(); + b2->CloseAllTabs(); + b3->CloseAllTabs(); +} + +// Insert a new window. +IN_PROC_BROWSER_TEST_F(BrowserCrApplicationAppleScriptTest, InsertWindow) { + // Emulate what applescript would do when creating a new window. + // Emulate a script like |set var to make new window with properties + // {visible:false}|. + scoped_nsobject<WindowAppleScript> aWindow([[WindowAppleScript alloc] init]); + scoped_nsobject<NSNumber> var([[aWindow.get() uniqueID] copy]); + [aWindow.get() setValue:[NSNumber numberWithBool:YES] forKey:@"isVisible"]; + + [NSApp insertInAppleScriptWindows:aWindow.get()]; + + // Represents the window after it is added. + WindowAppleScript* window = [[NSApp appleScriptWindows] objectAtIndex:0]; + EXPECT_NSEQ([NSNumber numberWithBool:YES], + [aWindow.get() valueForKey:@"isVisible"]); + EXPECT_EQ([window container], NSApp); + EXPECT_NSEQ(AppleScript::kWindowsProperty, + [window containerProperty]); + EXPECT_NSEQ(var, [window uniqueID]); +} + +// Inserting and deleting windows. +IN_PROC_BROWSER_TEST_F(BrowserCrApplicationAppleScriptTest, + InsertAndDeleteWindows) { + scoped_nsobject<WindowAppleScript> aWindow; + int count; + // Create a bunch of windows. + for (int i = 0; i < 5; ++i) { + for (int j = 0; j < 3; ++j) { + aWindow.reset([[WindowAppleScript alloc] init]); + [NSApp insertInAppleScriptWindows:aWindow.get()]; + } + count = 3 * i + 4; + EXPECT_EQ(count, (int)[[NSApp appleScriptWindows] count]); + } + + // Remove all the windows, just created. + count = (int)[[NSApp appleScriptWindows] count]; + for (int i = 0; i < 5; ++i) { + for(int j = 0; j < 3; ++j) { + [NSApp removeFromAppleScriptWindowsAtIndex:0]; + } + count = count - 3; + EXPECT_EQ(count, (int)[[NSApp appleScriptWindows] count]); + } +} + +// Check for objectSpecifer of the root scripting object. +IN_PROC_BROWSER_TEST_F(BrowserCrApplicationAppleScriptTest, ObjectSpecifier) { + // Should always return nil to indicate its the root scripting object. + EXPECT_EQ(nil, [NSApp objectSpecifier]); +} + +// Bookmark folders at the root level. +IN_PROC_BROWSER_TEST_F(BrowserCrApplicationAppleScriptTest, BookmarkFolders) { + NSArray* bookmarkFolders = [NSApp bookmarkFolders]; + EXPECT_EQ(2U, [bookmarkFolders count]); + + for (BookmarkFolderAppleScript* bookmarkFolder in bookmarkFolders) { + EXPECT_EQ(NSApp, + [bookmarkFolder container]); + EXPECT_NSEQ(AppleScript::kBookmarkFoldersProperty, + [bookmarkFolder containerProperty]); + } + + EXPECT_NSEQ(@"Other Bookmarks", [[NSApp otherBookmarks] title]); + EXPECT_NSEQ(@"Bookmarks Bar", [[NSApp bookmarksBar] title]); +} + diff --git a/chrome/browser/ui/cocoa/applescript/constants_applescript.h b/chrome/browser/ui/cocoa/applescript/constants_applescript.h new file mode 100644 index 0000000..7ffa80e --- /dev/null +++ b/chrome/browser/ui/cocoa/applescript/constants_applescript.h @@ -0,0 +1,31 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_APPLESCRIPT_CONSTANTS_APPLESCRIPT_H_ +#define CHROME_BROWSER_UI_COCOA_APPLESCRIPT_CONSTANTS_APPLESCRIPT_H_ + +#import <Cocoa/Cocoa.h> + +// This file contains the constant that are use to set the property of an +// applescript scriptable item. +namespace AppleScript { +// Property to access windows. +extern NSString* const kWindowsProperty; + +// Property to access tabs. +extern NSString* const kTabsProperty; + +// Property to access bookmarks folders. +extern NSString* const kBookmarkFoldersProperty; + +// Property to access bookmark items. +extern NSString* const kBookmarkItemsProperty; + +// To indicate a window in normal mode. +extern NSString* const kNormalWindowMode; + +// To indicate a window in incognito mode. +extern NSString* const kIncognitoWindowMode; +} +#endif // CHROME_BROWSER_UI_COCOA_APPLESCRIPT_CONSTANTS_APPLESCRIPT_H_ diff --git a/chrome/browser/ui/cocoa/applescript/constants_applescript.mm b/chrome/browser/ui/cocoa/applescript/constants_applescript.mm new file mode 100644 index 0000000..090077b --- /dev/null +++ b/chrome/browser/ui/cocoa/applescript/constants_applescript.mm @@ -0,0 +1,25 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/applescript/constants_applescript.h" + +namespace AppleScript { +// Property to access windows. +NSString* const kWindowsProperty = @"appleScriptWindows"; + +// Property to access tabs. +NSString* const kTabsProperty = @"tabs"; + +// Property to access bookmarks folders. +NSString* const kBookmarkFoldersProperty = @"bookmarkFolders"; + +// Property to access bookmark items. +NSString* const kBookmarkItemsProperty = @"bookmarkItems"; + +// To indicate a window in normal mode. +NSString* const kNormalWindowMode = @"normal"; + +// To indicate a window in incognito mode. +NSString* const kIncognitoWindowMode = @"incognito"; +} diff --git a/chrome/browser/ui/cocoa/applescript/element_applescript.h b/chrome/browser/ui/cocoa/applescript/element_applescript.h new file mode 100644 index 0000000..49a3481 --- /dev/null +++ b/chrome/browser/ui/cocoa/applescript/element_applescript.h @@ -0,0 +1,37 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_APPLESCRIPT_ELEMENT_APPLESCRIPT_H_ +#define CHROME_BROWSER_UI_COCOA_APPLESCRIPT_ELEMENT_APPLESCRIPT_H_ + +#import <Cocoa/Cocoa.h> + +// This class is the root class for all the other applescript classes. +// It takes care of all the infrastructure type operations. +@interface ElementAppleScript : NSObject { + @protected + // Used by the applescript runtime to identify each unique scriptable object. + NSNumber* uniqueID_; + // Used by object specifier to find a scriptable object's place in a + // collection. + id container_; + NSString* containerProperty_; +} + +@property (nonatomic, copy) NSNumber* uniqueID; +@property (nonatomic, retain) id container; +@property (nonatomic, copy) NSString* containerProperty; + +// Calculates the objectspecifier by using the uniqueID, container and +// container property. +// An object specifier is used to identify objects within a +// collection. +- (NSScriptObjectSpecifier*)objectSpecifier; + +// Sets both container and property, retains container and copies property. +- (void)setContainer:(id)value property:(NSString*)property; + +@end + +#endif// CHROME_BROWSER_UI_COCOA_APPLESCRIPT_ELEMENT_APPLESCRIPT_H_ diff --git a/chrome/browser/ui/cocoa/applescript/element_applescript.mm b/chrome/browser/ui/cocoa/applescript/element_applescript.mm new file mode 100644 index 0000000..abaf01d --- /dev/null +++ b/chrome/browser/ui/cocoa/applescript/element_applescript.mm @@ -0,0 +1,38 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/applescript/element_applescript.h" + +@implementation ElementAppleScript + +@synthesize uniqueID = uniqueID_; +@synthesize container = container_; +@synthesize containerProperty = containerProperty_; + +// calling objectSpecifier asks an object to return an object specifier +// record referring to itself. You must call setContainer:property: before +// you can call this method. +- (NSScriptObjectSpecifier*)objectSpecifier { + return [[NSUniqueIDSpecifier allocWithZone:[self zone]] + initWithContainerClassDescription: + (NSScriptClassDescription*)[[self container] classDescription] + containerSpecifier: + [[self container] objectSpecifier] + key:[self containerProperty] + uniqueID:[self uniqueID]]; +} + +- (void)setContainer:(id)value property:(NSString*)property { + [self setContainer:value]; + [self setContainerProperty:property]; +} + +- (void)dealloc { + [uniqueID_ release]; + [container_ release]; + [containerProperty_ release]; + [super dealloc]; +} + +@end diff --git a/chrome/browser/ui/cocoa/applescript/error_applescript.h b/chrome/browser/ui/cocoa/applescript/error_applescript.h new file mode 100644 index 0000000..2b91a2c --- /dev/null +++ b/chrome/browser/ui/cocoa/applescript/error_applescript.h @@ -0,0 +1,41 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_APPLESCRIPT_ERROR_APPLESCRIPT_H_ +#define CHROME_BROWSER_UI_COCOA_APPLESCRIPT_ERROR_APPLESCRIPT_H_ + +#import <Cocoa/Cocoa.h> + +namespace AppleScript { + +enum ErrorCode { + // Error when default profile cannot be obtained. + errGetProfile = 1, + // Error when bookmark model fails to load. + errBookmarkModelLoad, + // Error when bookmark folder cannot be created. + errCreateBookmarkFolder, + // Error when bookmark item cannot be created. + errCreateBookmarkItem, + // Error when URL entered is invalid. + errInvalidURL, + // Error when printing cannot be initiated. + errInitiatePrinting, + // Error when invalid tab save type is entered. + errInvalidSaveType, + // Error when invalid browser mode is entered. + errInvalidMode, + // Error when tab index is out of bounds. + errInvalidTabIndex, + // Error when mode is set after browser window is created. + errSetMode, + // Error when index of browser window is out of bounds. + errWrongIndex +}; + +// This function sets an error message to the currently executing command. +void SetError(ErrorCode errorCode); +} + +#endif // CHROME_BROWSER_UI_COCOA_APPLESCRIPT_ERROR_APPLESCRIPT_H_ diff --git a/chrome/browser/ui/cocoa/applescript/error_applescript.mm b/chrome/browser/ui/cocoa/applescript/error_applescript.mm new file mode 100644 index 0000000..e86ffd0 --- /dev/null +++ b/chrome/browser/ui/cocoa/applescript/error_applescript.mm @@ -0,0 +1,56 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/applescript/error_applescript.h" + +#import "app/l10n_util_mac.h" +#include "base/logging.h" +#include "grit/generated_resources.h" + +void AppleScript::SetError(AppleScript::ErrorCode errorCode) { + using namespace l10n_util; + NSScriptCommand* current_command = [NSScriptCommand currentCommand]; + [current_command setScriptErrorNumber:(int)errorCode]; + NSString* error_string = @""; + switch (errorCode) { + case errGetProfile: + error_string = GetNSString(IDS_GET_PROFILE_ERROR_APPLESCRIPT_MAC); + break; + case errBookmarkModelLoad: + error_string = GetNSString(IDS_BOOKMARK_MODEL_LOAD_ERROR_APPLESCRIPT_MAC); + break; + case errCreateBookmarkFolder: + error_string = + GetNSString(IDS_CREATE_BOOKMARK_FOLDER_ERROR_APPLESCRIPT_MAC); + break; + case errCreateBookmarkItem: + error_string = + GetNSString(IDS_CREATE_BOOKMARK_ITEM_ERROR_APPLESCRIPT_MAC); + break; + case errInvalidURL: + error_string = GetNSString(IDS_INVALID_URL_APPLESCRIPT_MAC); + break; + case errInitiatePrinting: + error_string = GetNSString(IDS_INITIATE_PRINTING_ERROR_APPLESCRIPT_MAC); + break; + case errInvalidSaveType: + error_string = GetNSString(IDS_INVALID_SAVE_TYPE_ERROR_APPLESCRIPT_MAC); + break; + case errInvalidMode: + error_string = GetNSString(IDS_INVALID_MODE_ERROR_APPLESCRIPT_MAC); + break; + case errInvalidTabIndex: + error_string = GetNSString(IDS_INVALID_TAB_INDEX_APPLESCRIPT_MAC); + break; + case errSetMode: + error_string = GetNSString(IDS_SET_MODE_APPLESCRIPT_MAC); + break; + case errWrongIndex: + error_string = GetNSString(IDS_WRONG_INDEX_ERROR_APPLESCRIPT_MAC); + break; + default: + NOTREACHED(); + } + [current_command setScriptErrorString:error_string]; +} diff --git a/chrome/browser/ui/cocoa/applescript/examples/advanced_tab_manipulation.applescript b/chrome/browser/ui/cocoa/applescript/examples/advanced_tab_manipulation.applescript new file mode 100644 index 0000000..d45bd86 --- /dev/null +++ b/chrome/browser/ui/cocoa/applescript/examples/advanced_tab_manipulation.applescript @@ -0,0 +1,24 @@ +-- Copyright (c) 2010 The Chromium Authors. All rights reserved. +-- Use of this source code is governed by a BSD-style license that can be +-- found in the LICENSE file. + +tell application "Chromium" + tell tab 1 of window 1 + print -- Prints the tab, prompts the user for location. + end tell + + tell tab 1 of window 1 + save in "/Users/Foo/Documents/Google" as "only html" + -- Saves the contents of the tab without the accompanying resources. + + save in "/Users/Foo/Documents/Google" as "complete html" + -- Saves the contents of the tab with the accompanying resources. + + -- Note: both the |in| and |as| part are optional, without it user is + -- prompted for one. + end tell + + tell tab 1 of window 1 + view source -- View the HTML of the tab in a new tab. + end tell +end tell diff --git a/chrome/browser/ui/cocoa/applescript/examples/app_info.applescript b/chrome/browser/ui/cocoa/applescript/examples/app_info.applescript new file mode 100644 index 0000000..8377e14 --- /dev/null +++ b/chrome/browser/ui/cocoa/applescript/examples/app_info.applescript @@ -0,0 +1,9 @@ +-- Copyright (c) 2010 The Chromium Authors. All rights reserved. +-- Use of this source code is governed by a BSD-style license that can be +-- found in the LICENSE file. + +-- Gets basic information about the app. +tell application "Chromium" + set var1 to name + set var2 to version +end tell diff --git a/chrome/browser/ui/cocoa/applescript/examples/bookmark_current_tabs.applescript b/chrome/browser/ui/cocoa/applescript/examples/bookmark_current_tabs.applescript new file mode 100644 index 0000000..6e88268 --- /dev/null +++ b/chrome/browser/ui/cocoa/applescript/examples/bookmark_current_tabs.applescript @@ -0,0 +1,23 @@ +-- Copyright (c) 2010 The Chromium Authors. All rights reserved. +-- Use of this source code is governed by a BSD-style license that can be +-- found in the LICENSE file. + +-- This script bookmarks the currently open tabs of a window. +tell application "Chromium" + set url_list to {} + set title_list to {} + tell window 1 + repeat with i from 1 to (count tabs) + set end of url_list to (URL of tab i) + set end of title_list to (title of tab i) + end repeat + end tell + tell bookmarks bar + set var to make new bookmark folder with properties {title:"New"} + tell var + repeat with i from 1 to (count url_list) + make new bookmark item with properties {URL:(item i of url_list), title:(item i of title_list)} + end repeat + end tell + end tell +end tell
\ No newline at end of file diff --git a/chrome/browser/ui/cocoa/applescript/examples/copy_html.applescript b/chrome/browser/ui/cocoa/applescript/examples/copy_html.applescript new file mode 100644 index 0000000..fd7b4e0 --- /dev/null +++ b/chrome/browser/ui/cocoa/applescript/examples/copy_html.applescript @@ -0,0 +1,16 @@ +-- Copyright (c) 2010 The Chromium Authors. All rights reserved. +-- Use of this source code is governed by a BSD-style license that can be +-- found in the LICENSE file. + +-- This script copies the HTML of a tab to a TextEdit document. +tell application "Chromium" + tell tab 1 of window 1 to view source + repeat while (loading of tab 2 of window 1) + end repeat + tell tab 2 of window 1 to select all + tell tab 2 of window 1 to copy selection +end tell + +tell application "TextEdit" + set text of document 1 to the clipboard +end tell diff --git a/chrome/browser/ui/cocoa/applescript/examples/delete_bookmarks.applescript b/chrome/browser/ui/cocoa/applescript/examples/delete_bookmarks.applescript new file mode 100644 index 0000000..341b100 --- /dev/null +++ b/chrome/browser/ui/cocoa/applescript/examples/delete_bookmarks.applescript @@ -0,0 +1,13 @@ +-- Copyright (c) 2010 The Chromium Authors. All rights reserved. +-- Use of this source code is governed by a BSD-style license that can be +-- found in the LICENSE file. + +-- This script deletes all the items within a bookmark folder. +tell application "Chromium" + set var to bookmark folder "New" of bookmarks bar + -- Change the folder to whichever you want. + tell var + delete every bookmark item + delete every bookmark folder + end tell +end tell diff --git a/chrome/browser/ui/cocoa/applescript/examples/execute_javascript.applescript b/chrome/browser/ui/cocoa/applescript/examples/execute_javascript.applescript new file mode 100644 index 0000000..33a34b4 --- /dev/null +++ b/chrome/browser/ui/cocoa/applescript/examples/execute_javascript.applescript @@ -0,0 +1,10 @@ +-- Copyright (c) 2010 The Chromium Authors. All rights reserved. +-- Use of this source code is governed by a BSD-style license that can be +-- found in the LICENSE file. + +-- This script execute a string of javascript code. +tell application "Chromium" + tell tab 1 of window 1 + execute javascript "alert('Hello World')" + end tell +end tell diff --git a/chrome/browser/ui/cocoa/applescript/examples/open_tabs_from_bookmark_folder.applescript b/chrome/browser/ui/cocoa/applescript/examples/open_tabs_from_bookmark_folder.applescript new file mode 100644 index 0000000..af36fc1 --- /dev/null +++ b/chrome/browser/ui/cocoa/applescript/examples/open_tabs_from_bookmark_folder.applescript @@ -0,0 +1,12 @@ +-- Copyright (c) 2010 The Chromium Authors. All rights reserved. +-- Use of this source code is governed by a BSD-style license that can be +-- found in the LICENSE file. + +tell application "Chromium" + set var to bookmark folder "New" of bookmarks bar + -- Change the folder to whichever you want. + repeat with i in (bookmark items of var) + set u to URL of i + tell window 1 to make new tab with properties {u} + end repeat +end tell diff --git a/chrome/browser/ui/cocoa/applescript/examples/quit_app.applescript b/chrome/browser/ui/cocoa/applescript/examples/quit_app.applescript new file mode 100644 index 0000000..a6bdd1f --- /dev/null +++ b/chrome/browser/ui/cocoa/applescript/examples/quit_app.applescript @@ -0,0 +1,8 @@ +-- Copyright (c) 2010 The Chromium Authors. All rights reserved. +-- Use of this source code is governed by a BSD-style license that can be +-- found in the LICENSE file. + +-- Quits the application, useful in cases where you want to schedule things. +tell application "Chromium" + quit +end tell diff --git a/chrome/browser/ui/cocoa/applescript/examples/tab_manipulation.applescript b/chrome/browser/ui/cocoa/applescript/examples/tab_manipulation.applescript new file mode 100644 index 0000000..9038c17 --- /dev/null +++ b/chrome/browser/ui/cocoa/applescript/examples/tab_manipulation.applescript @@ -0,0 +1,45 @@ +-- Copyright (c) 2010 The Chromium Authors. All rights reserved. +-- Use of this source code is governed by a BSD-style license that can be +-- found in the LICENSE file. + +-- Contains some common tab manipulation commands. +tell application "Chromium" + tell window 1 to make new tab with properties {URL:"http://google.com"} + -- create a new tab and navigate to a particular URL. + + set var to active tab index of window 1 + set active tab index of window 1 to (var - 1) -- Select the previous tab. + + set var to active tab index of window 1 + set active tab index of window 1 to (var + 1) -- Select the next tab. + + get title of tab 1 of window 1 -- Get the URL that the user can see. + + get loading of tab 1 of window 1 -- Check if a tab is loading. + + -- Common edit/manipulation commands. + tell tab 1 of window 1 + undo + + redo + + cut selection -- Cut a piece of text and place it on the system clipboard. + + copy selection -- Copy a piece of text and place it on the system clipboard. + + paste selection -- Paste a text from the system clipboard. + + select all + end tell + + -- Common navigation commands. + tell tab 1 of window 1 + go back + + go forward + + reload + + stop + end tell +end tell diff --git a/chrome/browser/ui/cocoa/applescript/examples/tab_navigation.applescript b/chrome/browser/ui/cocoa/applescript/examples/tab_navigation.applescript new file mode 100644 index 0000000..2337869 --- /dev/null +++ b/chrome/browser/ui/cocoa/applescript/examples/tab_navigation.applescript @@ -0,0 +1,13 @@ +-- Copyright (c) 2010 The Chromium Authors. All rights reserved. +-- Use of this source code is governed by a BSD-style license that can be +-- found in the LICENSE file. + +tell application "Chromium" + tell window 1 + -- creates a new tab and navigates to a particular URL. + make new tab with properties {URL:"http://google.com"} + -- Duplicate a tab. + set var to URL of tab 2 + make new tab with properties {URL:var} + end tell +end tell diff --git a/chrome/browser/ui/cocoa/applescript/examples/window_creation.applescript b/chrome/browser/ui/cocoa/applescript/examples/window_creation.applescript new file mode 100644 index 0000000..fc1486a --- /dev/null +++ b/chrome/browser/ui/cocoa/applescript/examples/window_creation.applescript @@ -0,0 +1,10 @@ +-- Copyright (c) 2010 The Chromium Authors. All rights reserved. +-- Use of this source code is governed by a BSD-style license that can be +-- found in the LICENSE file. + +-- creates 2 windows, one in normal mode and another in incognito mode. +tell application "Chromium" + make new window + make new window with properties {mode:"incognito"} + count windows -- count how many windows are currently open. +end tell diff --git a/chrome/browser/ui/cocoa/applescript/examples/window_operations.applescript b/chrome/browser/ui/cocoa/applescript/examples/window_operations.applescript new file mode 100644 index 0000000..90a0288 --- /dev/null +++ b/chrome/browser/ui/cocoa/applescript/examples/window_operations.applescript @@ -0,0 +1,22 @@ +-- Copyright (c) 2010 The Chromium Authors. All rights reserved. +-- Use of this source code is governed by a BSD-style license that can be +-- found in the LICENSE file. + +-- Contains usage of common window operations. +tell application "Chromium" + get URL of active tab of window 1 -- The URL currently being seen. + + set minimized of window 1 to true -- Minimizes a window. + set minimized of window 1 to false -- Maximizes a window. + + get mode of window 1 + -- Checks if a window is in |normal mode| or |incognito mode| + + set visible of window 1 to true -- Hides a window. + set visible of window 1 to false -- UnHides a window. + + -- Open multiple tabs. + set active tab index of window 1 to 2 -- Selects the second tab. + + +end tell diff --git a/chrome/browser/ui/cocoa/applescript/scripting.sdef b/chrome/browser/ui/cocoa/applescript/scripting.sdef new file mode 100644 index 0000000..b67b2b7 --- /dev/null +++ b/chrome/browser/ui/cocoa/applescript/scripting.sdef @@ -0,0 +1,304 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE dictionary SYSTEM "file://localhost/System/Library/DTDs/sdef.dtd"> +<dictionary title="Dictionary"> + <!-- + STANDARD SUITE + --> + <suite name="Standard Suite" code="core" description="Common classes and commands for all applications."> + <cocoa name="NSCoreSuite"/> + <class name="application" code="capp" description="The application's top-level scripting object."> + <cocoa class="BrowserCrApplication"/> + <element description="The windows contained within this application, ordered front to back." type="window"> + <cocoa key="appleScriptWindows"/> + </element> + <property name="name" code="pnam" description="The name of the application." type="text" access="r"/> + <property name="frontmost" code="pisf" description="Is this the frontmost (active) application?" type="boolean" access="r"> + <cocoa key="isActive"/> + </property> + <property name="version" code="vers" description="The version of the application." type="text" access="r"/> + <responds-to command="quit"> + <cocoa method="handleQuitScriptCommand:"/> + </responds-to> + </class> + <class name="window" code="cwin" description="A window."> + <cocoa class="WindowAppleScript"/> + <element description="The tabs contained within the window." type="tab"> + <cocoa key="tabs"/> + </element> + <property name="name" code="pnam" description="The full title of the window." type="text" access="r"> + <cocoa key="title"/> + </property> + <property name="id" code="ID " description="The unique identifier of the window." type="integer" access="r"> + <cocoa key="uniqueID"/> + </property> + <property name="index" code="pidx" description="The index of the window, ordered front to back." type="integer"> + <cocoa key="orderedIndex"/> + </property> + <property name="bounds" code="pbnd" description="The bounding rectangle of the window." type="rectangle"> + <cocoa key="boundsAsQDRect"/> + </property> + <property name="closeable" code="hclb" description="Whether the window has a close box." type="boolean" access="r"> + <cocoa key="hasCloseBox"/> + </property> + <property name="minimizable" code="ismn" description="Whether the window can be minimized." type="boolean" access="r"> + <cocoa key="isMiniaturizable"/> + </property> + <property name="minimized" code="pmnd" description="Whether the window is currently minimized." type="boolean"> + <cocoa key="isMiniaturized"/> + </property> + <property name="resizable" code="prsz" description="Whether the window can be resized." type="boolean" access="r"> + <cocoa key="isResizable"/> + </property> + <property name="visible" code="pvis" description="Whether the window is currently visible." type="boolean"> + <cocoa key="isVisible"/> + </property> + <property name="zoomable" code="iszm" description="Whether the window can be zoomed." type="boolean" access="r"> + <cocoa key="isZoomable"/> + </property> + <property name="zoomed" code="pzum" description="Whether the window is currently zoomed." type="boolean"> + <cocoa key="isZoomed"/> + </property> + <property name="active tab" code="acTa" description="Returns the currently selected tab" type="tab" access="r"> + <cocoa key="activeTab"/> + </property> + <property name="mode" code="mode" description="Represents the mode of the window which can be 'normal' or 'incognito', can be set only once during creation of the window." type="text"> + <cocoa key="mode"/> + </property> + <property name="active tab index" code="acTI" description="The index of the active tab." type="integer"/> + <responds-to command="close"> + <cocoa method="handlesCloseScriptCommand:"/> + </responds-to> + </class> + <command name="save" code="coresave" description="Save an object."> + <direct-parameter description="the object to save, usually a document or window" type="specifier"/> + <parameter name="in" code="kfil" description="The file in which to save the object." type="file" optional="yes"> + <cocoa key="File"/> + </parameter> + <parameter name="as" code="fltp" description="The file type in which to save the data. Can be 'only html' or 'complete html', default is 'complete html'." type="text" optional="yes"> + <cocoa key="FileType"/> + </parameter> + </command> + <!-- + According to TN2106, 'open' should return the resulting document + object. However, the Cocoa implementation does not do this yet. + <result type="specifier"/> + --> + <command name="open" code="aevtodoc" description="Open a document."> + <direct-parameter description="The file(s) to be opened."> + <type type="file" list="yes"/> + </direct-parameter> + </command> + <command name="close" code="coreclos" description="Close a window."> + <cocoa class="NSCloseCommand"/> + <direct-parameter description="the document(s) or window(s) to close." type="specifier"/> + </command> + <command name="quit" code="aevtquit" description="Quit the application."> + <cocoa class="NSQuitCommand"/> + </command> + <command name="count" code="corecnte" description="Return the number of elements of a particular class within an object."> + <cocoa class="NSCountCommand"/> + <direct-parameter description="the object whose elements are to be counted" type="specifier"/> + <parameter name="each" code="kocl" description="The class of objects to be counted." type="type" optional="yes"> + <cocoa key="ObjectClass"/> + </parameter> + <result description="the number of elements" type="integer"/> + </command> + <command name="delete" code="coredelo" description="Delete an object."> + <cocoa class="NSDeleteCommand"/> + <direct-parameter description="the object to delete" type="specifier"/> + </command> + <command name="duplicate" code="coreclon" description="Copy object(s) and put the copies at a new location."> + <cocoa class="NSCloneCommand"/> + <direct-parameter description="the object(s) to duplicate" type="specifier"/> + <parameter name="to" code="insh" description="The location for the new object(s)." type="location specifier" optional="yes"> + <cocoa key="ToLocation"/> + </parameter> + <parameter name="with properties" code="prdt" description="Properties to be set in the new duplicated object(s)." type="record" optional="yes"> + <cocoa key="WithProperties"/> + </parameter> + <result description="the duplicated object(s)" type="specifier"/> + </command> + <command name="exists" code="coredoex" description="Verify if an object exists."> + <cocoa class="NSExistsCommand"/> + <direct-parameter description="the object in question" type="any"/> + <result description="true if it exists, false if not" type="boolean"/> + </command> + <command name="make" code="corecrel" description="Make a new object."> + <cocoa class="NSCreateCommand"/> + <parameter name="new" code="kocl" description="The class of the new object." type="type"> + <cocoa key="ObjectClass"/> + </parameter> + <parameter name="at" code="insh" description="The location at which to insert the object." type="location specifier" optional="yes"> + <cocoa key="Location"/> + </parameter> + <parameter name="with data" code="data" description="The initial contents of the object." type="any" optional="yes"> + <cocoa key="ObjectData"/> + </parameter> + <parameter name="with properties" code="prdt" description="The initial values for properties of the object." type="record" optional="yes"> + <cocoa key="KeyDictionary"/> + </parameter> + <result description="to the new object" type="specifier"/> + </command> + <command name="move" code="coremove" description="Move object(s) to a new location."> + <cocoa class="NSMoveCommand"/> + <direct-parameter description="the object(s) to move" type="specifier"/> + <parameter name="to" code="insh" description="The new location for the object(s)." type="location specifier"> + <cocoa key="ToLocation"/> + </parameter> + <result description="the moved object(s)" type="specifier"/> + </command> + <!-- NSCoreSuite doesn't define these. + <command name="run" code="aevtoapp" description="Run an application. Most applications will open an empty, untitled window."/> + <command name="reopen" code="aevtrapp" description="Reactivate a running application. Some applications will open a new untitled window if no window is open."/> + --> + <command name="print" code="aevtpdoc" description="Print an object."> + <!-- type would be better written as "file | document". --> + <direct-parameter description="The file(s) or document(s) to be printed." type="specifier"/> + </command> + <!-- "set" is supposed to be hidden. --> + <command name="set" code="coresetd" description="Set an object's data."> + <cocoa class="NSSetCommand"/> + <direct-parameter type="specifier"/> + <!-- "set" is supposed to return the fully evaluated "to" data. + <result type="any"/> + --> + <parameter name="to" code="data" description="The new value." type="any"> + <cocoa key="Value"/> + </parameter> + </command> + <!-- "get" is supposed to be hidden. --> + <command name="get" code="coregetd" description="Get the data for an object."> + <cocoa class="NSGetCommand"/> + <direct-parameter type="specifier"/> + <result type="any"/> + </command> + </suite> + <suite name="Chromium Suite" code="CrSu" description="Common classes and commands for Chrome."> + <class-extension description="The application's top-level scripting object." extends="application"> + <cocoa class="BrowserCrApplication"/> + <element description="Contains the bookmarks bar and other bookmarks folder." type="bookmark folder" access="r"> + <cocoa key="bookmarkFolders"/> + </element> + <property name="bookmarks bar" code="ChBB" description="The bookmarks bar bookmark folder." type="bookmark folder" access="r"> + <cocoa key="bookmarksBar"/> + </property> + <property name="other bookmarks" code="ChOB" description="The other bookmarks bookmark folder." type="bookmark folder" access="r"> + <cocoa key="otherBookmarks"/> + </property> + </class-extension> + <class name="tab" code="CrTb" description="A tab."> + <cocoa class="TabAppleScript"/> + <property name="id" code="ID " description="Unique ID of the tab." type="integer" access="r"> + <cocoa key="uniqueID"/> + </property> + <property name="title" code="pnam" description="The title of the tab." type="text" access="r"/> + <property name="URL" code="URL " description="The url visible to the user." type="text"/> + <property name="loading" code="ldng" description="Is loading?" type="boolean" access="r"/> + <responds-to command="undo"> + <cocoa method="handlesUndoScriptCommand:"/> + </responds-to> + <responds-to command="redo"> + <cocoa method="handlesRedoScriptCommand:"/> + </responds-to> + <responds-to command="cut selection"> + <cocoa method="handlesCutScriptCommand:"/> + </responds-to> + <responds-to command="copy selection"> + <cocoa method="handlesCopyScriptCommand:"/> + </responds-to> + <responds-to command="paste selection"> + <cocoa method="handlesPasteScriptCommand:"/> + </responds-to> + <responds-to command="select all"> + <cocoa method="handlesSelectAllScriptCommand:"/> + </responds-to> + <responds-to command="go back"> + <cocoa method="handlesGoBackScriptCommand:"/> + </responds-to> + <responds-to command="go forward"> + <cocoa method="handlesGoForwardScriptCommand:"/> + </responds-to> + <responds-to command="reload"> + <cocoa method="handlesReloadScriptCommand:"/> + </responds-to> + <responds-to command="stop"> + <cocoa method="handlesStopScriptCommand:"/> + </responds-to> + <responds-to command="print"> + <cocoa method="handlesPrintScriptCommand:"/> + </responds-to> + <responds-to command="view source"> + <cocoa method="handlesViewSourceScriptCommand:"/> + </responds-to> + <responds-to command="save"> + <cocoa method="handlesSaveScriptCommand:"/> + </responds-to> + <responds-to command="execute"> + <cocoa method="handlesExecuteJavascriptScriptCommand:"/> + </responds-to> + </class> + <class name="bookmark folder" code="CrBF" description="A bookmarks folder that contains other bookmarks folder and bookmark items."> + <cocoa class="BookmarkFolderAppleScript"/> + <element description="The bookmark folders present within." type="bookmark folder"> + <cocoa key="bookmarkFolders"/> + </element> + <element description="The bookmarks present within." type="bookmark item"> + <cocoa key="bookmarkItems"/> + </element> + <property name="id" code="ID " description="Unique ID of the bookmark folder." type="number" access="r"> + <cocoa key="uniqueID"/> + </property> + <property name="title" code="pnam" description="The title of the folder." type="text"/> + <property name="index" code="indx" description="Returns the index with respect to its parent bookmark folder" type="number" access="r"/> + </class> + <class name="bookmark item" code="CrBI" description="An item consists of an URL and the title of a bookmark"> + <cocoa class="BookmarkItemAppleScript"/> + <property name="id" code="ID " description="Unique ID of the bookmark item." type="integer" access="r"> + <cocoa key="uniqueID"/> + </property> + <property name="title" code="pnam" description="The title of the bookmark item." type="text"/> + <property name="URL" code="URL " description="The URL of the bookmark." type="text"/> + <property name="index" code="indx" description="Returns the index with respect to its parent bookmark folder" type="number" access="r"/> + </class> + <command name="reload" code="CrSuRlod" description="Reload a tab."> + <direct-parameter description="The tab to execute the command in." type="specifier"/> + </command> + <command name="go back" code="CrSuBack" description="Go Back (If Possible)."> + <direct-parameter description="The tab to execute the command in." type="specifier"/> + </command> + <command name="go forward" code="CrSuFwd " description="Go Forward (If Possible)."> + <direct-parameter description="The tab to execute the command in." type="specifier"/> + </command> + <command name="select all" code="CrSuSlAl" description="Select all."> + <direct-parameter description="The tab to execute the command in." type="specifier"/> + </command> + <command name="cut selection" code="CrSuCut " description="Cut selected text (If Possible)."> + <direct-parameter description="The tab to execute the command in." type="specifier"/> + </command> + <command name="copy selection" code="CrSuCop " description="Copy text."> + <direct-parameter description="The tab to execute the command in." type="specifier"/> + </command> + <command name="paste selection" code="CrSuPast" description="Paste text (If Possible)."> + <direct-parameter description="The tab to execute the command in." type="specifier"/> + </command> + <command name="undo" code="CrSuUndo" description="Undo the last change."> + <direct-parameter description="The tab to execute the command in." type="specifier"/> + </command> + <command name="redo" code="CrSuRedo" description="Redo the last change."> + <direct-parameter description="The tab to execute the command in." type="specifier"/> + </command> + <command name="stop" code="CrSustop" description="Stop the current tab from loading."> + <direct-parameter description="The tab to execute the command in." type="specifier"/> + </command> + <command name="view source" code="CrSuVSrc" description="View the HTML source of the tab."> + <direct-parameter description="The tab to execute the command in." type="specifier"/> + </command> + <command name="execute" code="CrSuExJa" description="Execute a piece of javascript."> + <direct-parameter description="The tab to execute the command in." type="specifier"/> + <parameter name="javascript" code="JvSc" description="The javascript code to execute." type="text"> + <cocoa key="javascript"/> + </parameter> + <result type="any"/> + </command> + </suite> +</dictionary>
\ No newline at end of file diff --git a/chrome/browser/ui/cocoa/applescript/tab_applescript.h b/chrome/browser/ui/cocoa/applescript/tab_applescript.h new file mode 100644 index 0000000..30064fa --- /dev/null +++ b/chrome/browser/ui/cocoa/applescript/tab_applescript.h @@ -0,0 +1,79 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_APPLESCRIPT_TAB_APPLESCRIPT_H_ +#define CHROME_BROWSER_UI_COCOA_APPLESCRIPT_TAB_APPLESCRIPT_H_ + +#import <Cocoa/Cocoa.h> + +#import "chrome/browser/ui/cocoa/applescript/element_applescript.h" + +class TabContents; + +// Represents a tab scriptable item in applescript. +@interface TabAppleScript : ElementAppleScript { + @private + TabContents* tabContents_; // weak. + // Contains the temporary URL when a user creates a new folder/item with + // url specified like + // |make new tab with properties {url:"http://google.com"}|. + NSString* tempURL_; +} + +// Doesn't actually create the tab here but just assigns the ID, tab is created +// when it calls insertInTabs: of a particular window, it is used in cases +// where user assigns a tab to a variable like |set var to make new tab|. +- (id)init; + +// Does not create a new tab but uses an existing one. +- (id)initWithTabContent:(TabContents*)aTabContent; + +// Assigns a tab, sets its unique ID and also copies temporary values. +- (void)setTabContent:(TabContents*)aTabContent; + +// Return the URL currently visible to the user in the location bar. +- (NSString*)URL; + +// Sets the URL, returns an error if it is invalid. +- (void)setURL:(NSString*)aURL; + +// The title of the tab. +- (NSString*)title; + +// Is the tab loading any resource? +- (NSNumber*)loading; + +// Standard user commands. +- (void)handlesUndoScriptCommand:(NSScriptCommand*)command; +- (void)handlesRedoScriptCommand:(NSScriptCommand*)command; + +// Edit operations on the page. +- (void)handlesCutScriptCommand:(NSScriptCommand*)command; +- (void)handlesCopyScriptCommand:(NSScriptCommand*)command; +- (void)handlesPasteScriptCommand:(NSScriptCommand*)command; + +// Selects all contents on the page. +- (void)handlesSelectAllScriptCommand:(NSScriptCommand*)command; + +// Navigation operations. +- (void)handlesGoBackScriptCommand:(NSScriptCommand*)command; +- (void)handlesGoForwardScriptCommand:(NSScriptCommand*)command; +- (void)handlesReloadScriptCommand:(NSScriptCommand*)command; +- (void)handlesStopScriptCommand:(NSScriptCommand*)command; + +// Used to print a tab. +- (void)handlesPrintScriptCommand:(NSScriptCommand*)command; + +// Used to save a tab, if no file is specified, prompts the user to enter it. +- (void)handlesSaveScriptCommand:(NSScriptCommand*)command; + +// Displays the HTML of the tab in a new tab. +- (void)handlesViewSourceScriptCommand:(NSScriptCommand*)command; + +// Executes a piece of javascript in the tab. +- (id)handlesExecuteJavascriptScriptCommand:(NSScriptCommand*)command; + +@end + +#endif// CHROME_BROWSER_UI_COCOA_APPLESCRIPT_TAB_APPLESCRIPT_H_ diff --git a/chrome/browser/ui/cocoa/applescript/tab_applescript.mm b/chrome/browser/ui/cocoa/applescript/tab_applescript.mm new file mode 100644 index 0000000..3a10095 --- /dev/null +++ b/chrome/browser/ui/cocoa/applescript/tab_applescript.mm @@ -0,0 +1,296 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/applescript/tab_applescript.h" + +#include "base/file_path.h" +#include "base/logging.h" +#import "base/scoped_nsobject.h" +#include "base/sys_string_conversions.h" +#include "base/utf_string_conversions.h" +#include "chrome/browser/download/save_package.h" +#include "chrome/browser/renderer_host/render_view_host.h" +#include "chrome/browser/sessions/session_id.h" +#include "chrome/browser/tab_contents/navigation_controller.h" +#include "chrome/browser/tab_contents/navigation_entry.h" +#include "chrome/browser/tab_contents/tab_contents.h" +#include "chrome/browser/ui/cocoa/applescript/error_applescript.h" +#include "chrome/common/url_constants.h" +#include "googleurl/src/gurl.h" + +@interface TabAppleScript() +@property (nonatomic, copy) NSString* tempURL; +@end + +@implementation TabAppleScript + +@synthesize tempURL = tempURL_; + +- (id)init { + if ((self = [super init])) { + SessionID session; + SessionID::id_type futureSessionIDOfTab = session.id() + 1; + // Holds the SessionID that the new tab is going to get. + scoped_nsobject<NSNumber> numID( + [[NSNumber alloc] + initWithInt:futureSessionIDOfTab]); + [self setUniqueID:numID]; + [self setTempURL:@""]; + } + return self; +} + +- (void)dealloc { + [tempURL_ release]; + [super dealloc]; +} + +- (id)initWithTabContent:(TabContents*)aTabContent { + if (!aTabContent) { + [self release]; + return nil; + } + + if ((self = [super init])) { + // It is safe to be weak, if a tab goes away (eg user closing a tab) + // the applescript runtime calls tabs in AppleScriptWindow and this + // particular tab is never returned. + tabContents_ = aTabContent; + scoped_nsobject<NSNumber> numID( + [[NSNumber alloc] + initWithInt:tabContents_->controller().session_id().id()]); + [self setUniqueID:numID]; + } + return self; +} + +- (void)setTabContent:(TabContents*)aTabContent { + DCHECK(aTabContent); + // It is safe to be weak, if a tab goes away (eg user closing a tab) + // the applescript runtime calls tabs in AppleScriptWindow and this + // particular tab is never returned. + tabContents_ = aTabContent; + scoped_nsobject<NSNumber> numID( + [[NSNumber alloc] + initWithInt:tabContents_->controller().session_id().id()]); + [self setUniqueID:numID]; + + [self setURL:[self tempURL]]; +} + +- (NSString*)URL { + if (!tabContents_) { + return nil; + } + + NavigationEntry* entry = tabContents_->controller().GetActiveEntry(); + if (!entry) { + return nil; + } + const GURL& url = entry->virtual_url(); + return base::SysUTF8ToNSString(url.spec()); +} + +- (void)setURL:(NSString*)aURL { + // If a scripter sets a URL before the node is added save it at a temporary + // location. + if (!tabContents_) { + [self setTempURL:aURL]; + return; + } + + GURL url(base::SysNSStringToUTF8(aURL)); + // check for valid url. + if (!url.is_empty() && !url.is_valid()) { + AppleScript::SetError(AppleScript::errInvalidURL); + return; + } + + NavigationEntry* entry = tabContents_->controller().GetActiveEntry(); + if (!entry) + return; + + const GURL& previousURL = entry->virtual_url(); + tabContents_->OpenURL(url, + previousURL, + CURRENT_TAB, + PageTransition::TYPED); +} + +- (NSString*)title { + NavigationEntry* entry = tabContents_->controller().GetActiveEntry(); + if (!entry) + return nil; + + std::wstring title; + if (entry != NULL) { + title = UTF16ToWideHack(entry->title()); + } + + return base::SysWideToNSString(title); +} + +- (NSNumber*)loading { + BOOL loadingValue = tabContents_->is_loading() ? YES : NO; + return [NSNumber numberWithBool:loadingValue]; +} + +- (void)handlesUndoScriptCommand:(NSScriptCommand*)command { + RenderViewHost* view = tabContents_->render_view_host(); + if (!view) { + NOTREACHED(); + return; + } + + view->Undo(); +} + +- (void)handlesRedoScriptCommand:(NSScriptCommand*)command { + RenderViewHost* view = tabContents_->render_view_host(); + if (!view) { + NOTREACHED(); + return; + } + + view->Redo(); +} + +- (void)handlesCutScriptCommand:(NSScriptCommand*)command { + RenderViewHost* view = tabContents_->render_view_host(); + if (!view) { + NOTREACHED(); + return; + } + + view->Cut(); +} + +- (void)handlesCopyScriptCommand:(NSScriptCommand*)command { + RenderViewHost* view = tabContents_->render_view_host(); + if (!view) { + NOTREACHED(); + return; + } + + view->Copy(); +} + +- (void)handlesPasteScriptCommand:(NSScriptCommand*)command { + RenderViewHost* view = tabContents_->render_view_host(); + if (!view) { + NOTREACHED(); + return; + } + + view->Paste(); +} + +- (void)handlesSelectAllScriptCommand:(NSScriptCommand*)command { + RenderViewHost* view = tabContents_->render_view_host(); + if (!view) { + NOTREACHED(); + return; + } + + view->SelectAll(); +} + +- (void)handlesGoBackScriptCommand:(NSScriptCommand*)command { + NavigationController& navigationController = tabContents_->controller(); + if (navigationController.CanGoBack()) + navigationController.GoBack(); +} + +- (void)handlesGoForwardScriptCommand:(NSScriptCommand*)command { + NavigationController& navigationController = tabContents_->controller(); + if (navigationController.CanGoForward()) + navigationController.GoForward(); +} + +- (void)handlesReloadScriptCommand:(NSScriptCommand*)command { + NavigationController& navigationController = tabContents_->controller(); + const bool checkForRepost = true; + navigationController.Reload(checkForRepost); +} + +- (void)handlesStopScriptCommand:(NSScriptCommand*)command { + RenderViewHost* view = tabContents_->render_view_host(); + if (!view) { + // We tolerate Stop being called even before a view has been created. + // So just log a warning instead of a NOTREACHED(). + DLOG(WARNING) << "Stop: no view for handle "; + return; + } + + view->Stop(); +} + +- (void)handlesPrintScriptCommand:(NSScriptCommand*)command { + bool initiateStatus = tabContents_->PrintNow(); + if (initiateStatus == false) { + AppleScript::SetError(AppleScript::errInitiatePrinting); + } +} + +- (void)handlesSaveScriptCommand:(NSScriptCommand*)command { + NSDictionary* dictionary = [command evaluatedArguments]; + + NSURL* fileURL = [dictionary objectForKey:@"File"]; + // Scripter has not specifed the location at which to save, so we prompt for + // it. + if (!fileURL) { + tabContents_->OnSavePage(); + return; + } + + FilePath mainFile(base::SysNSStringToUTF8([fileURL path])); + // We create a directory path at the folder within which the file exists. + // Eg. if main_file = '/Users/Foo/Documents/Google.html' + // then directory_path = '/Users/Foo/Documents/Google_files/'. + FilePath directoryPath = mainFile.RemoveExtension(); + directoryPath = directoryPath.InsertBeforeExtension(std::string("_files/")); + + NSString* saveType = [dictionary objectForKey:@"FileType"]; + + SavePackage::SavePackageType savePackageType = + SavePackage::SAVE_AS_COMPLETE_HTML; + if (saveType) { + if ([saveType isEqualToString:@"only html"]) { + savePackageType = SavePackage::SAVE_AS_ONLY_HTML; + } else if ([saveType isEqualToString:@"complete html"]) { + savePackageType = SavePackage::SAVE_AS_COMPLETE_HTML; + } else { + AppleScript::SetError(AppleScript::errInvalidSaveType); + return; + } + } + + tabContents_->SavePage(mainFile, directoryPath, savePackageType); +} + + +- (void)handlesViewSourceScriptCommand:(NSScriptCommand*)command { + NavigationEntry* entry = tabContents_->controller().GetLastCommittedEntry(); + if (entry) { + tabContents_->OpenURL(GURL(chrome::kViewSourceScheme + std::string(":") + + entry->url().spec()), GURL(), NEW_FOREGROUND_TAB, PageTransition::LINK); + } +} + +- (id)handlesExecuteJavascriptScriptCommand:(NSScriptCommand*)command { + RenderViewHost* view = tabContents_->render_view_host(); + if (!view) { + NOTREACHED(); + return nil; + } + + std::wstring script = base::SysNSStringToWide( + [[command evaluatedArguments] objectForKey:@"javascript"]); + view->ExecuteJavascriptInWebFrame(L"", script); + + // TODO(Shreyas): Figure out a way to get the response back. + return nil; +} + +@end diff --git a/chrome/browser/ui/cocoa/applescript/window_applescript.h b/chrome/browser/ui/cocoa/applescript/window_applescript.h new file mode 100644 index 0000000..6d98d10 --- /dev/null +++ b/chrome/browser/ui/cocoa/applescript/window_applescript.h @@ -0,0 +1,81 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_APPLESCRIPT_WINDOW_APPLESCRIPT_H_ +#define CHROME_BROWSER_UI_COCOA_APPLESCRIPT_WINDOW_APPLESCRIPT_H_ + +#import <Cocoa/Cocoa.h> + +#import "chrome/browser/ui/cocoa/applescript/element_applescript.h" + +class Browser; +class Profile; +@class TabAppleScript; + +// Represents a window class. +@interface WindowAppleScript : ElementAppleScript { + @private + Browser* browser_; // weak. +} + +// Creates a new window, returns nil if there is an error. +- (id)init; + +// Creates a new window with a particular profile. +- (id)initWithProfile:(Profile*)aProfile; + +// Does not create a new window but uses an existing one. +- (id)initWithBrowser:(Browser*)aBrowser; + +// Sets and gets the index of the currently selected tab. +- (NSNumber*)activeTabIndex; +- (void)setActiveTabIndex:(NSNumber*)anActiveTabIndex; + +// Mode refers to whether a window is a normal window or an incognito window +// it can be set only once while creating the window. +- (NSString*)mode; +- (void)setMode:(NSString*)theMode; + +// Returns the currently selected tab. +- (TabAppleScript*)activeTab; + +// Tab manipulation functions. +// The tabs inside the window. +// Returns |TabAppleScript*| of all the tabs contained +// within this particular folder. +- (NSArray*)tabs; + +// Insert a tab at the end. +- (void)insertInTabs:(TabAppleScript*)aTab; + +// Insert a tab at some position in the list. +// Called by applescript which takes care of bounds checking, make sure of it +// before calling directly. +- (void)insertInTabs:(TabAppleScript*)aTab atIndex:(int)index; + +// Remove a window from the list. +// Called by applescript which takes care of bounds checking, make sure of it +// before calling directly. +- (void)removeFromTabsAtIndex:(int)index; + +// Set the index of a window. +- (void)setOrderedIndex:(NSNumber*)anIndex; + +// Used to sort windows by index. +- (NSComparisonResult)windowComparator:(WindowAppleScript*)otherWindow; + +// For standard window functions like zoomable, bounds etc, we dont handle it +// but instead pass it onto the NSWindow associated with the window. +- (id)valueForUndefinedKey:(NSString*)key; +- (void)setValue:(id)value forUndefinedKey:(NSString*)key; + +// Used to close window. +- (void)handlesCloseScriptCommand:(NSCloseCommand*)command; + +// The index of the window, windows are ordered front to back. +- (NSNumber*)orderedIndex; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_APPLESCRIPT_WINDOW_APPLESCRIPT_H_ diff --git a/chrome/browser/ui/cocoa/applescript/window_applescript.mm b/chrome/browser/ui/cocoa/applescript/window_applescript.mm new file mode 100644 index 0000000..d5c2fa9 --- /dev/null +++ b/chrome/browser/ui/cocoa/applescript/window_applescript.mm @@ -0,0 +1,246 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/applescript/window_applescript.h" + +#include "base/logging.h" +#import "base/scoped_nsobject.h" +#include "base/scoped_ptr.h" +#include "base/time.h" +#import "chrome/browser/app_controller_mac.h" +#import "chrome/browser/chrome_browser_application_mac.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/tab_contents/tab_contents.h" +#include "chrome/browser/tab_contents_wrapper.h" +#include "chrome/browser/tabs/tab_strip_model.h" +#include "chrome/browser/ui/browser.h" +#include "chrome/browser/ui/browser_list.h" +#include "chrome/browser/ui/browser_navigator.h" +#include "chrome/browser/ui/browser_window.h" +#include "chrome/browser/ui/cocoa/applescript/constants_applescript.h" +#include "chrome/browser/ui/cocoa/applescript/error_applescript.h" +#import "chrome/browser/ui/cocoa/applescript/tab_applescript.h" +#include "chrome/common/url_constants.h" + +@interface WindowAppleScript(WindowAppleScriptPrivateMethods) +// The NSWindow that corresponds to this window. +- (NSWindow*)nativeHandle; +@end + +@implementation WindowAppleScript + +- (id)init { + // Check which mode to open a new window. + NSScriptCommand* command = [NSScriptCommand currentCommand]; + NSString* mode = [[[command evaluatedArguments] + objectForKey:@"KeyDictionary"] objectForKey:@"mode"]; + AppController* appDelegate = [NSApp delegate]; + + Profile* defaultProfile = [appDelegate defaultProfile]; + + if (!defaultProfile) { + AppleScript::SetError(AppleScript::errGetProfile); + return nil; + } + + Profile* profile; + if ([mode isEqualToString:AppleScript::kIncognitoWindowMode]) { + profile = defaultProfile->GetOffTheRecordProfile(); + } + else if ([mode isEqualToString:AppleScript::kNormalWindowMode] || !mode) { + profile = defaultProfile; + } else { + // Mode cannot be anything else + AppleScript::SetError(AppleScript::errInvalidMode); + return nil; + } + // Set the mode to nil, to ensure that it is not set once more. + [[[command evaluatedArguments] objectForKey:@"KeyDictionary"] + setValue:nil forKey:@"mode"]; + return [self initWithProfile:profile]; +} + +- (id)initWithProfile:(Profile*)aProfile { + if (!aProfile) { + [self release]; + return nil; + } + + if ((self = [super init])) { + browser_ = Browser::Create(aProfile); + browser_->NewTab(); + browser_->window()->Show(); + scoped_nsobject<NSNumber> numID( + [[NSNumber alloc] initWithInt:browser_->session_id().id()]); + [self setUniqueID:numID]; + } + return self; +} + +- (id)initWithBrowser:(Browser*)aBrowser { + if (!aBrowser) { + [self release]; + return nil; + } + + if ((self = [super init])) { + // It is safe to be weak, if a window goes away (eg user closing a window) + // the applescript runtime calls appleScriptWindows in + // BrowserCrApplication and this particular window is never returned. + browser_ = aBrowser; + scoped_nsobject<NSNumber> numID( + [[NSNumber alloc] initWithInt:browser_->session_id().id()]); + [self setUniqueID:numID]; + } + return self; +} + +- (NSWindow*)nativeHandle { + // window() can be NULL during startup. + if (browser_->window()) + return browser_->window()->GetNativeHandle(); + return nil; +} + +- (NSNumber*)activeTabIndex { + // Note: applescript is 1-based, that is lists begin with index 1. + int activeTabIndex = browser_->selected_index() + 1; + if (!activeTabIndex) { + return nil; + } + return [NSNumber numberWithInt:activeTabIndex]; +} + +- (void)setActiveTabIndex:(NSNumber*)anActiveTabIndex { + // Note: applescript is 1-based, that is lists begin with index 1. + int atIndex = [anActiveTabIndex intValue] - 1; + if (atIndex >= 0 && atIndex < browser_->tab_count()) + browser_->SelectTabContentsAt(atIndex, true); + else + AppleScript::SetError(AppleScript::errInvalidTabIndex); +} + +- (NSString*)mode { + Profile* profile = browser_->profile(); + if (profile->IsOffTheRecord()) + return AppleScript::kIncognitoWindowMode; + return AppleScript::kNormalWindowMode; +} + +- (void)setMode:(NSString*)theMode { + // cannot set mode after window is created. + if (theMode) { + AppleScript::SetError(AppleScript::errSetMode); + } +} + +- (TabAppleScript*)activeTab { + TabAppleScript* currentTab = [[[TabAppleScript alloc] + initWithTabContent:browser_->GetSelectedTabContents()] autorelease]; + [currentTab setContainer:self + property:AppleScript::kTabsProperty]; + return currentTab; +} + +- (NSArray*)tabs { + NSMutableArray* tabs = [NSMutableArray + arrayWithCapacity:browser_->tab_count()]; + + for (int i = 0; i < browser_->tab_count(); ++i) { + // Check to see if tab is closing. + if (browser_->GetTabContentsAt(i)->is_being_destroyed()) { + continue; + } + + scoped_nsobject<TabAppleScript> tab( + [[TabAppleScript alloc] + initWithTabContent:(browser_->GetTabContentsAt(i))]); + [tab setContainer:self + property:AppleScript::kTabsProperty]; + [tabs addObject:tab]; + } + return tabs; +} + +- (void)insertInTabs:(TabAppleScript*)aTab { + // This method gets called when a new tab is created so + // the container and property are set here. + [aTab setContainer:self + property:AppleScript::kTabsProperty]; + + // Set how long it takes a tab to be created. + base::TimeTicks newTabStartTime = base::TimeTicks::Now(); + TabContentsWrapper* contents = + browser_->AddSelectedTabWithURL(GURL(chrome::kChromeUINewTabURL), + PageTransition::TYPED); + contents->tab_contents()->set_new_tab_start_time(newTabStartTime); + [aTab setTabContent:contents->tab_contents()]; +} + +- (void)insertInTabs:(TabAppleScript*)aTab atIndex:(int)index { + // This method gets called when a new tab is created so + // This method gets called when a new tab is created so + // the container and property are set here. + [aTab setContainer:self + property:AppleScript::kTabsProperty]; + + // Set how long it takes a tab to be created. + base::TimeTicks newTabStartTime = base::TimeTicks::Now(); + browser::NavigateParams params(browser_, + GURL(chrome::kChromeUINewTabURL), + PageTransition::TYPED); + params.disposition = NEW_FOREGROUND_TAB; + params.tabstrip_index = index; + browser::Navigate(¶ms); + params.target_contents->tab_contents()->set_new_tab_start_time( + newTabStartTime); + + [aTab setTabContent:params.target_contents->tab_contents()]; +} + +- (void)removeFromTabsAtIndex:(int)index { + browser_->tabstrip_model()->DetachTabContentsAt(index); +} + +- (NSNumber*)orderedIndex{ + return [NSNumber numberWithInt:[[self nativeHandle] orderedIndex]]; +} + +- (void)setOrderedIndex:(NSNumber*)anIndex { + int index = [anIndex intValue] - 1; + if (index < 0 || index >= (int)BrowserList::size()) { + AppleScript::SetError(AppleScript::errWrongIndex); + return; + } + [[self nativeHandle] setOrderedIndex:index]; +} + +- (NSComparisonResult)windowComparator:(WindowAppleScript*)otherWindow { + int thisIndex = [[self orderedIndex] intValue]; + int otherIndex = [[otherWindow orderedIndex] intValue]; + if (thisIndex < otherIndex) + return NSOrderedAscending; + else if (thisIndex > otherIndex) + return NSOrderedDescending; + // Indexes can never be same. + NOTREACHED(); + return NSOrderedSame; +} + +// Get and set values from the associated NSWindow. +- (id)valueForUndefinedKey:(NSString*)key { + return [[self nativeHandle] valueForKey:key]; +} + +- (void)setValue:(id)value forUndefinedKey:(NSString*)key { + [[self nativeHandle] setValue:(id)value forKey:key]; +} + +- (void)handlesCloseScriptCommand:(NSCloseCommand*)command { + // window() can be NULL during startup. + if (browser_->window()) + browser_->window()->Close(); +} + +@end diff --git a/chrome/browser/ui/cocoa/applescript/window_applescript_test.mm b/chrome/browser/ui/cocoa/applescript/window_applescript_test.mm new file mode 100644 index 0000000..b40e5d5 --- /dev/null +++ b/chrome/browser/ui/cocoa/applescript/window_applescript_test.mm @@ -0,0 +1,178 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#import "base/scoped_nsobject.h" +#include "base/sys_string_conversions.h" +#import "chrome/browser/app_controller_mac.h" +#import "chrome/browser/chrome_browser_application_mac.h" +#include "chrome/browser/profile.h" +#import "chrome/browser/ui/cocoa/applescript/constants_applescript.h" +#import "chrome/browser/ui/cocoa/applescript/error_applescript.h" +#import "chrome/browser/ui/cocoa/applescript/tab_applescript.h" +#import "chrome/browser/ui/cocoa/applescript/window_applescript.h" +#include "chrome/test/in_process_browser_test.h" +#include "googleurl/src/gurl.h" +#include "testing/gtest/include/gtest/gtest.h" +#import "testing/gtest_mac.h" + +typedef InProcessBrowserTest WindowAppleScriptTest; + +// Create a window in default/normal mode. +IN_PROC_BROWSER_TEST_F(WindowAppleScriptTest, DefaultCreation) { + scoped_nsobject<WindowAppleScript> aWindow( + [[WindowAppleScript alloc] init]); + EXPECT_TRUE(aWindow.get()); + NSString* mode = [aWindow.get() mode]; + EXPECT_NSEQ(AppleScript::kNormalWindowMode, + mode); +} + +// Create a window with a |NULL profile|. +IN_PROC_BROWSER_TEST_F(WindowAppleScriptTest, CreationWithNoProfile) { + scoped_nsobject<WindowAppleScript> aWindow( + [[WindowAppleScript alloc] initWithProfile:NULL]); + EXPECT_FALSE(aWindow.get()); +} + +// Create a window with a particular profile. +IN_PROC_BROWSER_TEST_F(WindowAppleScriptTest, CreationWithProfile) { + Profile* defaultProfile = [[NSApp delegate] defaultProfile]; + scoped_nsobject<WindowAppleScript> aWindow( + [[WindowAppleScript alloc] initWithProfile:defaultProfile]); + EXPECT_TRUE(aWindow.get()); + EXPECT_TRUE([aWindow.get() uniqueID]); +} + +// Create a window with no |Browser*|. +IN_PROC_BROWSER_TEST_F(WindowAppleScriptTest, CreationWithNoBrowser) { + scoped_nsobject<WindowAppleScript> aWindow( + [[WindowAppleScript alloc] initWithBrowser:NULL]); + EXPECT_FALSE(aWindow.get()); +} + +// Create a window with |Browser*| already present. +IN_PROC_BROWSER_TEST_F(WindowAppleScriptTest, CreationWithBrowser) { + scoped_nsobject<WindowAppleScript> aWindow( + [[WindowAppleScript alloc] initWithBrowser:browser()]); + EXPECT_TRUE(aWindow.get()); + EXPECT_TRUE([aWindow.get() uniqueID]); +} + +// Tabs within the window. +IN_PROC_BROWSER_TEST_F(WindowAppleScriptTest, Tabs) { + scoped_nsobject<WindowAppleScript> aWindow( + [[WindowAppleScript alloc] initWithBrowser:browser()]); + NSArray* tabs = [aWindow.get() tabs]; + EXPECT_EQ(1U, [tabs count]); + TabAppleScript* tab1 = [tabs objectAtIndex:0]; + EXPECT_EQ([tab1 container], aWindow.get()); + EXPECT_NSEQ(AppleScript::kTabsProperty, + [tab1 containerProperty]); +} + +// Insert a new tab. +IN_PROC_BROWSER_TEST_F(WindowAppleScriptTest, InsertTab) { + // Emulate what applescript would do when creating a new tab. + // Emulates a script like |set var to make new tab with + // properties URL:"http://google.com"}|. + scoped_nsobject<TabAppleScript> aTab([[TabAppleScript alloc] init]); + scoped_nsobject<NSNumber> var([[aTab.get() uniqueID] copy]); + [aTab.get() setURL:@"http://google.com"]; + scoped_nsobject<WindowAppleScript> aWindow( + [[WindowAppleScript alloc] initWithBrowser:browser()]); + [aWindow.get() insertInTabs:aTab.get()]; + + // Represents the tab after it is inserted. + TabAppleScript* tab = [[aWindow.get() tabs] objectAtIndex:1]; + EXPECT_EQ(GURL("http://google.com"), + GURL(base::SysNSStringToUTF8([tab URL]))); + EXPECT_EQ([tab container], aWindow.get()); + EXPECT_NSEQ(AppleScript::kTabsProperty, + [tab containerProperty]); + EXPECT_NSEQ(var.get(), [tab uniqueID]); +} + +// Insert a new tab at a particular position +IN_PROC_BROWSER_TEST_F(WindowAppleScriptTest, InsertTabAtPosition) { + // Emulate what applescript would do when creating a new tab. + // Emulates a script like |set var to make new tab with + // properties URL:"http://google.com"} at before tab 1|. + scoped_nsobject<TabAppleScript> aTab([[TabAppleScript alloc] init]); + scoped_nsobject<NSNumber> var([[aTab.get() uniqueID] copy]); + [aTab.get() setURL:@"http://google.com"]; + scoped_nsobject<WindowAppleScript> aWindow( + [[WindowAppleScript alloc] initWithBrowser:browser()]); + [aWindow.get() insertInTabs:aTab.get() atIndex:0]; + + // Represents the tab after it is inserted. + TabAppleScript* tab = [[aWindow.get() tabs] objectAtIndex:0]; + EXPECT_EQ(GURL("http://google.com"), + GURL(base::SysNSStringToUTF8([tab URL]))); + EXPECT_EQ([tab container], aWindow.get()); + EXPECT_NSEQ(AppleScript::kTabsProperty, [tab containerProperty]); + EXPECT_NSEQ(var.get(), [tab uniqueID]); +} + +// Inserting and deleting tabs. +IN_PROC_BROWSER_TEST_F(WindowAppleScriptTest, InsertAndDeleteTabs) { + scoped_nsobject<WindowAppleScript> aWindow( + [[WindowAppleScript alloc] initWithBrowser:browser()]); + scoped_nsobject<TabAppleScript> aTab; + int count; + for (int i = 0; i < 5; ++i) { + for (int j = 0; j < 3; ++j) { + aTab.reset([[TabAppleScript alloc] init]); + [aWindow.get() insertInTabs:aTab.get()]; + } + count = 3 * i + 4; + EXPECT_EQ((int)[[aWindow.get() tabs] count], count); + } + + count = (int)[[aWindow.get() tabs] count]; + for (int i = 0; i < 5; ++i) { + for(int j = 0; j < 3; ++j) { + [aWindow.get() removeFromTabsAtIndex:0]; + } + count = count - 3; + EXPECT_EQ((int)[[aWindow.get() tabs] count], count); + } +} + +// Getting and setting values from the NSWindow. +IN_PROC_BROWSER_TEST_F(WindowAppleScriptTest, NSWindowTest) { + scoped_nsobject<WindowAppleScript> aWindow( + [[WindowAppleScript alloc] initWithBrowser:browser()]); + [aWindow.get() setValue:[NSNumber numberWithBool:YES] + forKey:@"isMiniaturized"]; + EXPECT_TRUE([[aWindow.get() valueForKey:@"isMiniaturized"] boolValue]); + [aWindow.get() setValue:[NSNumber numberWithBool:NO] + forKey:@"isMiniaturized"]; + EXPECT_FALSE([[aWindow.get() valueForKey:@"isMiniaturized"] boolValue]); +} + +// Getting and setting the active tab. +IN_PROC_BROWSER_TEST_F(WindowAppleScriptTest, ActiveTab) { + scoped_nsobject<WindowAppleScript> aWindow( + [[WindowAppleScript alloc] initWithBrowser:browser()]); + scoped_nsobject<TabAppleScript> aTab([[TabAppleScript alloc] init]); + [aWindow.get() insertInTabs:aTab.get()]; + [aWindow.get() setActiveTabIndex:[NSNumber numberWithInt:2]]; + EXPECT_EQ(2, [[aWindow.get() activeTabIndex] intValue]); + TabAppleScript* tab2 = [[aWindow.get() tabs] objectAtIndex:1]; + EXPECT_NSEQ([[aWindow.get() activeTab] uniqueID], + [tab2 uniqueID]); +} + +// Order of windows. +IN_PROC_BROWSER_TEST_F(WindowAppleScriptTest, WindowOrder) { + scoped_nsobject<WindowAppleScript> window2( + [[WindowAppleScript alloc] initWithBrowser:browser()]); + scoped_nsobject<WindowAppleScript> window1( + [[WindowAppleScript alloc] init]); + EXPECT_EQ([window1.get() windowComparator:window2.get()], NSOrderedAscending); + EXPECT_EQ([window2.get() windowComparator:window1.get()], + NSOrderedDescending); +} diff --git a/chrome/browser/ui/cocoa/authorization_util.h b/chrome/browser/ui/cocoa/authorization_util.h new file mode 100644 index 0000000..9694998 --- /dev/null +++ b/chrome/browser/ui/cocoa/authorization_util.h @@ -0,0 +1,67 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_AUTHORIZATION_UTIL_H_ +#define CHROME_BROWSER_UI_COCOA_AUTHORIZATION_UTIL_H_ +#pragma once + +// AuthorizationExecuteWithPrivileges fork()s and exec()s the tool, but it +// does not wait() for it. It also doesn't provide the caller with access to +// the forked pid. If used irresponsibly, zombie processes will accumulate. +// +// Apple's really gotten us between a rock and a hard place, here. +// +// Fortunately, AuthorizationExecuteWithPrivileges does give access to the +// tool's stdout (and stdin) via a FILE* pipe. The tool can output its pid +// to this pipe, and the main program can read it, and then have something +// that it can wait() for. +// +// The contract is that any tool executed by the wrappers declared in this +// file must print its pid to stdout on a line by itself before doing anything +// else. +// +// http://developer.apple.com/mac/library/samplecode/BetterAuthorizationSample/listing1.html +// (Look for "What's This About Zombies?") + +#include <CoreFoundation/CoreFoundation.h> +#include <Security/Authorization.h> +#include <stdio.h> +#include <sys/types.h> + +namespace authorization_util { + +// Obtains an AuthorizationRef that can be used to run commands as root. If +// necessary, prompts the user for authentication. If the user is prompted, +// |prompt| will be used as the prompt string and an icon appropriate for the +// application will be displayed in a prompt dialog. Note that the system +// appends its own text to the prompt string. Returns NULL on failure. +AuthorizationRef AuthorizationCreateToRunAsRoot(CFStringRef prompt); + +// Calls straight through to AuthorizationExecuteWithPrivileges. If that +// call succeeds, |pid| will be set to the pid of the executed tool. If the +// pid can't be determined, |pid| will be set to -1. |pid| must not be NULL. +// |pipe| may be NULL, but the tool will always be executed with a pipe in +// order to read the pid from its stdout. +OSStatus ExecuteWithPrivilegesAndGetPID(AuthorizationRef authorization, + const char* tool_path, + AuthorizationFlags options, + const char** arguments, + FILE** pipe, + pid_t* pid); + +// Calls ExecuteWithPrivilegesAndGetPID, and if that call succeeds, calls +// waitpid() to wait for the process to exit. If waitpid() succeeds, the +// exit status is placed in |exit_status|, otherwise, -1 is stored. +// |exit_status| may be NULL and this function will still wait for the process +// to exit. +OSStatus ExecuteWithPrivilegesAndWait(AuthorizationRef authorization, + const char* tool_path, + AuthorizationFlags options, + const char** arguments, + FILE** pipe, + int* exit_status); + +} // namespace authorization_util + +#endif // CHROME_BROWSER_UI_COCOA_AUTHORIZATION_UTIL_H_ diff --git a/chrome/browser/ui/cocoa/authorization_util.mm b/chrome/browser/ui/cocoa/authorization_util.mm new file mode 100644 index 0000000..e92dd53 --- /dev/null +++ b/chrome/browser/ui/cocoa/authorization_util.mm @@ -0,0 +1,184 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/authorization_util.h" + +#import <Foundation/Foundation.h> +#include <sys/wait.h> + +#include <string> + +#include "base/basictypes.h" +#include "base/eintr_wrapper.h" +#include "base/logging.h" +#import "base/mac_util.h" +#include "base/string_number_conversions.h" +#include "base/string_util.h" +#include "chrome/browser/ui/cocoa/scoped_authorizationref.h" + +namespace authorization_util { + +AuthorizationRef AuthorizationCreateToRunAsRoot(CFStringRef prompt) { + // Create an empty AuthorizationRef. + scoped_AuthorizationRef authorization; + OSStatus status = AuthorizationCreate(NULL, + kAuthorizationEmptyEnvironment, + kAuthorizationFlagDefaults, + &authorization); + if (status != errAuthorizationSuccess) { + LOG(ERROR) << "AuthorizationCreate: " << status; + return NULL; + } + + // Specify the "system.privilege.admin" right, which allows + // AuthorizationExecuteWithPrivileges to run commands as root. + AuthorizationItem right_items[] = { + {kAuthorizationRightExecute, 0, NULL, 0} + }; + AuthorizationRights rights = {arraysize(right_items), right_items}; + + // product_logo_32.png is used instead of app.icns because Authorization + // Services can't deal with .icns files. + NSString* icon_path = + [mac_util::MainAppBundle() pathForResource:@"product_logo_32" + ofType:@"png"]; + const char* icon_path_c = [icon_path fileSystemRepresentation]; + size_t icon_path_length = icon_path_c ? strlen(icon_path_c) : 0; + + // The OS will append " Type an administrator's name and password to allow + // <CFBundleDisplayName> to make changes." + NSString* prompt_ns = const_cast<NSString*>( + reinterpret_cast<const NSString*>(prompt)); + const char* prompt_c = [prompt_ns UTF8String]; + size_t prompt_length = prompt_c ? strlen(prompt_c) : 0; + + AuthorizationItem environment_items[] = { + {kAuthorizationEnvironmentIcon, icon_path_length, (void*)icon_path_c, 0}, + {kAuthorizationEnvironmentPrompt, prompt_length, (void*)prompt_c, 0} + }; + + AuthorizationEnvironment environment = {arraysize(environment_items), + environment_items}; + + AuthorizationFlags flags = kAuthorizationFlagDefaults | + kAuthorizationFlagInteractionAllowed | + kAuthorizationFlagExtendRights | + kAuthorizationFlagPreAuthorize; + + status = AuthorizationCopyRights(authorization, + &rights, + &environment, + flags, + NULL); + if (status != errAuthorizationSuccess) { + if (status != errAuthorizationCanceled) { + LOG(ERROR) << "AuthorizationCopyRights: " << status; + } + return NULL; + } + + return authorization.release(); +} + +OSStatus ExecuteWithPrivilegesAndGetPID(AuthorizationRef authorization, + const char* tool_path, + AuthorizationFlags options, + const char** arguments, + FILE** pipe, + pid_t* pid) { + // pipe may be NULL, but this function needs one. In that case, use a local + // pipe. + FILE* local_pipe; + FILE** pipe_pointer; + if (pipe) { + pipe_pointer = pipe; + } else { + pipe_pointer = &local_pipe; + } + + // AuthorizationExecuteWithPrivileges wants |char* const*| for |arguments|, + // but it doesn't actually modify the arguments, and that type is kind of + // silly and callers probably aren't dealing with that. Put the cast here + // to make things a little easier on callers. + OSStatus status = AuthorizationExecuteWithPrivileges(authorization, + tool_path, + options, + (char* const*)arguments, + pipe_pointer); + if (status != errAuthorizationSuccess) { + return status; + } + + int line_pid = -1; + size_t line_length = 0; + char* line_c = fgetln(*pipe_pointer, &line_length); + if (line_c) { + if (line_length > 0 && line_c[line_length - 1] == '\n') { + // line_c + line_length is the start of the next line if there is one. + // Back up one character. + --line_length; + } + std::string line(line_c, line_length); + if (!base::StringToInt(line, &line_pid)) { + // StringToInt may have set line_pid to something, but if the conversion + // was imperfect, use -1. + LOG(ERROR) << "ExecuteWithPrivilegesAndGetPid: funny line: " << line; + line_pid = -1; + } + } else { + LOG(ERROR) << "ExecuteWithPrivilegesAndGetPid: no line"; + } + + if (!pipe) { + fclose(*pipe_pointer); + } + + if (pid) { + *pid = line_pid; + } + + return status; +} + +OSStatus ExecuteWithPrivilegesAndWait(AuthorizationRef authorization, + const char* tool_path, + AuthorizationFlags options, + const char** arguments, + FILE** pipe, + int* exit_status) { + pid_t pid; + OSStatus status = ExecuteWithPrivilegesAndGetPID(authorization, + tool_path, + options, + arguments, + pipe, + &pid); + if (status != errAuthorizationSuccess) { + return status; + } + + // exit_status may be NULL, but this function needs it. In that case, use a + // local version. + int local_exit_status; + int* exit_status_pointer; + if (exit_status) { + exit_status_pointer = exit_status; + } else { + exit_status_pointer = &local_exit_status; + } + + if (pid != -1) { + pid_t wait_result = HANDLE_EINTR(waitpid(pid, exit_status_pointer, 0)); + if (wait_result != pid) { + PLOG(ERROR) << "waitpid"; + *exit_status_pointer = -1; + } + } else { + *exit_status_pointer = -1; + } + + return status; +} + +} // namespace authorization_util diff --git a/chrome/browser/ui/cocoa/back_forward_menu_controller.h b/chrome/browser/ui/cocoa/back_forward_menu_controller.h new file mode 100644 index 0000000..6ec82f6 --- /dev/null +++ b/chrome/browser/ui/cocoa/back_forward_menu_controller.h @@ -0,0 +1,43 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_BACK_FORWARD_MENU_CONTROLLER_H_ +#define CHROME_BROWSER_UI_COCOA_BACK_FORWARD_MENU_CONTROLLER_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#include "base/scoped_ptr.h" +#include "chrome/browser/back_forward_menu_model.h" + +@class DelayedMenuButton; + +typedef BackForwardMenuModel::ModelType BackForwardMenuType; +const BackForwardMenuType BACK_FORWARD_MENU_TYPE_BACK = + BackForwardMenuModel::BACKWARD_MENU; +const BackForwardMenuType BACK_FORWARD_MENU_TYPE_FORWARD = + BackForwardMenuModel::FORWARD_MENU; + +// A class that manages the back/forward menu (and delayed-menu button, and +// model). + +@interface BackForwardMenuController : NSObject { + @private + BackForwardMenuType type_; + DelayedMenuButton* button_; // Weak; comes from nib. + scoped_ptr<BackForwardMenuModel> model_; + scoped_nsobject<NSMenu> backForwardMenu_; +} + +// Type (back or forwards); can only be set on initialization. +@property(readonly, nonatomic) BackForwardMenuType type; + +- (id)initWithBrowser:(Browser*)browser + modelType:(BackForwardMenuType)type + button:(DelayedMenuButton*)button; + +@end // @interface BackForwardMenuController + +#endif // CHROME_BROWSER_UI_COCOA_BACK_FORWARD_MENU_CONTROLLER_H_ diff --git a/chrome/browser/ui/cocoa/back_forward_menu_controller.mm b/chrome/browser/ui/cocoa/back_forward_menu_controller.mm new file mode 100644 index 0000000..a3e89b2 --- /dev/null +++ b/chrome/browser/ui/cocoa/back_forward_menu_controller.mm @@ -0,0 +1,102 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/back_forward_menu_controller.h" + +#include "base/logging.h" +#include "base/scoped_ptr.h" +#include "base/sys_string_conversions.h" +#include "chrome/browser/back_forward_menu_model.h" +#import "chrome/browser/ui/cocoa/delayedmenu_button.h" +#import "chrome/browser/ui/cocoa/event_utils.h" +#include "skia/ext/skia_utils_mac.h" +#include "third_party/skia/include/core/SkBitmap.h" + +using base::SysUTF16ToNSString; +using gfx::SkBitmapToNSImage; + +@implementation BackForwardMenuController + +// Accessors and mutators: + +@synthesize type = type_; + +// Own methods: + +- (id)initWithBrowser:(Browser*)browser + modelType:(BackForwardMenuType)type + button:(DelayedMenuButton*)button { + if ((self = [super init])) { + type_ = type; + button_ = button; + model_.reset(new BackForwardMenuModel(browser, type_)); + DCHECK(model_.get()); + backForwardMenu_.reset([[NSMenu alloc] initWithTitle:@""]); + DCHECK(backForwardMenu_.get()); + [backForwardMenu_ setDelegate:self]; + + [button_ setAttachedMenu:backForwardMenu_]; + [button_ setAttachedMenuEnabled:YES]; + } + return self; +} + +// Methods as delegate: + +// Called by backForwardMenu_ just before tracking begins. +//TODO(viettrungluu): should we do anything for chapter stops (see model)? +- (void)menuNeedsUpdate:(NSMenu*)menu { + DCHECK(menu == backForwardMenu_); + + // Remove old menu items (backwards order is as good as any). + for (NSInteger i = [menu numberOfItems]; i > 0; i--) + [menu removeItemAtIndex:(i - 1)]; + + // 0-th item must be blank. (This is because we use a pulldown list, for which + // Cocoa uses the 0-th item as "title" in the button.) + [menu insertItemWithTitle:@"" + action:nil + keyEquivalent:@"" + atIndex:0]; + for (int menuID = 0; menuID < model_->GetItemCount(); menuID++) { + if (model_->IsSeparator(menuID)) { + [menu insertItem:[NSMenuItem separatorItem] + atIndex:(menuID + 1)]; + } else { + // Create a menu item with the right label. + NSMenuItem* menuItem = [[NSMenuItem alloc] + initWithTitle:SysUTF16ToNSString(model_->GetLabelAt(menuID)) + action:nil + keyEquivalent:@""]; + [menuItem autorelease]; + + SkBitmap icon; + // Icon (if it has one). + if (model_->GetIconAt(menuID, &icon)) + [menuItem setImage:SkBitmapToNSImage(icon)]; + + // This will make it call our |-executeMenuItem:| method. We store the + // |menuID| (or |menu_id|) in the tag. + [menuItem setTag:menuID]; + [menuItem setTarget:self]; + [menuItem setAction:@selector(executeMenuItem:)]; + + // Put it in the menu! + [menu insertItem:menuItem + atIndex:(menuID + 1)]; + } + } +} + +// Action methods: + +- (void)executeMenuItem:(id)sender { + DCHECK([sender isKindOfClass:[NSMenuItem class]]); + int menuID = [sender tag]; + model_->ActivatedAtWithDisposition( + menuID, + event_utils::WindowOpenDispositionFromNSEvent([NSApp currentEvent])); +} + +@end // @implementation BackForwardMenuController diff --git a/chrome/browser/ui/cocoa/background_gradient_view.h b/chrome/browser/ui/cocoa/background_gradient_view.h new file mode 100644 index 0000000..d72fa57 --- /dev/null +++ b/chrome/browser/ui/cocoa/background_gradient_view.h @@ -0,0 +1,29 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_BACKGROUND_GRADIENT_VIEW_H_ +#define CHROME_BROWSER_UI_COCOA_BACKGROUND_GRADIENT_VIEW_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +// A custom view that draws a 'standard' background gradient. +// Base class for other Chromium views. +@interface BackgroundGradientView : NSView { + @private + BOOL showsDivider_; +} + +// The color used for the bottom stroke. Public so subclasses can use. +- (NSColor *)strokeColor; + +// Draws the background for this view. Make sure that your patternphase +// is set up correctly in your graphics context before calling. +- (void)drawBackground; + +// Controls whether the bar draws a dividing line at the bottom. +@property(nonatomic, assign) BOOL showsDivider; +@end + +#endif // CHROME_BROWSER_UI_COCOA_BACKGROUND_GRADIENT_VIEW_H_ diff --git a/chrome/browser/ui/cocoa/background_gradient_view.mm b/chrome/browser/ui/cocoa/background_gradient_view.mm new file mode 100644 index 0000000..1c5735f --- /dev/null +++ b/chrome/browser/ui/cocoa/background_gradient_view.mm @@ -0,0 +1,81 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/background_gradient_view.h" + +#import "chrome/browser/themes/browser_theme_provider.h" +#import "chrome/browser/ui/cocoa/themed_window.h" +#include "grit/theme_resources.h" + +#define kToolbarTopOffset 12 +#define kToolbarMaxHeight 100 + +@implementation BackgroundGradientView +@synthesize showsDivider = showsDivider_; + +- (id)initWithFrame:(NSRect)frameRect { + self = [super initWithFrame:frameRect]; + if (self != nil) { + showsDivider_ = YES; + } + return self; +} + +- (void)awakeFromNib { + showsDivider_ = YES; +} + +- (void)setShowsDivider:(BOOL)show { + showsDivider_ = show; + [self setNeedsDisplay:YES]; +} + +- (void)drawBackground { + BOOL isKey = [[self window] isKeyWindow]; + ThemeProvider* themeProvider = [[self window] themeProvider]; + if (themeProvider) { + NSColor* backgroundImageColor = + themeProvider->GetNSImageColorNamed(IDR_THEME_TOOLBAR, false); + if (backgroundImageColor) { + [backgroundImageColor set]; + NSRectFill([self bounds]); + } else { + CGFloat winHeight = NSHeight([[self window] frame]); + NSGradient* gradient = themeProvider->GetNSGradient( + isKey ? BrowserThemeProvider::GRADIENT_TOOLBAR : + BrowserThemeProvider::GRADIENT_TOOLBAR_INACTIVE); + NSPoint startPoint = + [self convertPoint:NSMakePoint(0, winHeight - kToolbarTopOffset) + fromView:nil]; + NSPoint endPoint = + NSMakePoint(0, winHeight - kToolbarTopOffset - kToolbarMaxHeight); + endPoint = [self convertPoint:endPoint fromView:nil]; + + [gradient drawFromPoint:startPoint + toPoint:endPoint + options:(NSGradientDrawsBeforeStartingLocation | + NSGradientDrawsAfterEndingLocation)]; + } + + if (showsDivider_) { + // Draw bottom stroke + [[self strokeColor] set]; + NSRect borderRect, contentRect; + NSDivideRect([self bounds], &borderRect, &contentRect, 1, NSMinYEdge); + NSRectFillUsingOperation(borderRect, NSCompositeSourceOver); + } + } +} + +- (NSColor*)strokeColor { + BOOL isKey = [[self window] isKeyWindow]; + ThemeProvider* themeProvider = [[self window] themeProvider]; + if (!themeProvider) + return [NSColor blackColor]; + return themeProvider->GetNSColor( + isKey ? BrowserThemeProvider::COLOR_TOOLBAR_STROKE : + BrowserThemeProvider::COLOR_TOOLBAR_STROKE_INACTIVE, true); +} + +@end diff --git a/chrome/browser/ui/cocoa/background_gradient_view_unittest.mm b/chrome/browser/ui/cocoa/background_gradient_view_unittest.mm new file mode 100644 index 0000000..693c21a --- /dev/null +++ b/chrome/browser/ui/cocoa/background_gradient_view_unittest.mm @@ -0,0 +1,47 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/background_gradient_view.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +// Since BackgroundGradientView doesn't do any drawing by default, we +// create a subclass to call its draw method for us. +@interface BackgroundGradientSubClassTest : BackgroundGradientView +@end + +@implementation BackgroundGradientSubClassTest +- (void)drawRect:(NSRect)rect { + [self drawBackground]; +} +@end + +namespace { + +class BackgroundGradientViewTest : public CocoaTest { + public: + BackgroundGradientViewTest() { + NSRect frame = NSMakeRect(0, 0, 100, 30); + scoped_nsobject<BackgroundGradientSubClassTest> view; + view.reset([[BackgroundGradientSubClassTest alloc] initWithFrame:frame]); + view_ = view.get(); + [[test_window() contentView] addSubview:view_]; + } + + BackgroundGradientSubClassTest* view_; +}; + +TEST_VIEW(BackgroundGradientViewTest, view_) + +// Test drawing, mostly to ensure nothing leaks or crashes. +TEST_F(BackgroundGradientViewTest, DisplayWithDivider) { + [view_ setShowsDivider:YES]; + [view_ display]; +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/background_tile_view.h b/chrome/browser/ui/cocoa/background_tile_view.h new file mode 100644 index 0000000..9a08113 --- /dev/null +++ b/chrome/browser/ui/cocoa/background_tile_view.h @@ -0,0 +1,23 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_BACKGROUND_TILE_VIEW_H_ +#define CHROME_BROWSER_UI_COCOA_BACKGROUND_TILE_VIEW_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +// A custom view that draws a image tiled as the background. This isn't meant +// to be used where themes might be need, and is for other windows (about box). + +@interface BackgroundTileView : NSView { + @private + BOOL showsDivider_; + NSImage* tileImage_; +} + +@property(nonatomic, retain) NSImage* tileImage; +@end + +#endif // CHROME_BROWSER_UI_COCOA_BACKGROUND_TILE_VIEW_H_ diff --git a/chrome/browser/ui/cocoa/background_tile_view.mm b/chrome/browser/ui/cocoa/background_tile_view.mm new file mode 100644 index 0000000..e63141b --- /dev/null +++ b/chrome/browser/ui/cocoa/background_tile_view.mm @@ -0,0 +1,32 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/background_tile_view.h" + +@implementation BackgroundTileView +@synthesize tileImage = tileImage_; + +- (void)setTileImage:(NSImage*)tileImage { + [tileImage_ autorelease]; + tileImage_ = [tileImage retain]; + [self setNeedsDisplay:YES]; +} + +- (void)drawRect:(NSRect)rect { + // Tile within the view, so set the phase to start at the view bottom. + NSPoint phase = NSMakePoint(0.0, NSMinY([self frame])); + [[NSGraphicsContext currentContext] setPatternPhase:phase]; + + if (tileImage_) { + NSColor *color = [NSColor colorWithPatternImage:tileImage_]; + [color set]; + } else { + // Something to catch the missing image + [[NSColor magentaColor] set]; + } + + NSRectFill([self bounds]); +} + +@end diff --git a/chrome/browser/ui/cocoa/background_tile_view_unittest.mm b/chrome/browser/ui/cocoa/background_tile_view_unittest.mm new file mode 100644 index 0000000..4af5751 --- /dev/null +++ b/chrome/browser/ui/cocoa/background_tile_view_unittest.mm @@ -0,0 +1,37 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/background_tile_view.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +namespace { + +class BackgroundTileViewTest : public CocoaTest { + public: + BackgroundTileViewTest() { + NSRect frame = NSMakeRect(0, 0, 100, 30); + scoped_nsobject<BackgroundTileView> view([[BackgroundTileView alloc] + initWithFrame:frame]); + view_ = view.get(); + [[test_window() contentView] addSubview:view_]; + } + + BackgroundTileView *view_; +}; + +TEST_VIEW(BackgroundTileViewTest, view_) + +// Test drawing with an Image +TEST_F(BackgroundTileViewTest, DisplayImage) { + NSImage* image = [NSImage imageNamed:@"NSApplicationIcon"]; + [view_ setTileImage:image]; + [view_ display]; +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/base_bubble_controller.h b/chrome/browser/ui/cocoa/base_bubble_controller.h new file mode 100644 index 0000000..7cc2c5b --- /dev/null +++ b/chrome/browser/ui/cocoa/base_bubble_controller.h @@ -0,0 +1,67 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#import "base/cocoa_protocols_mac.h" +#include "base/scoped_ptr.h" + +namespace BaseBubbleControllerInternal { +class Bridge; +} + +@class InfoBubbleView; + +// Base class for bubble controllers. Manages a xib that contains an +// InfoBubbleWindow which contains an InfoBubbleView. Contains code to close +// the bubble window on clicks outside of the window, and the like. +// To use this class: +// 1. Create a new xib that contains a window. Change the window's class to +// InfoBubbleWindow. Give it a child view that autosizes to the window's full +// size, give it class InfoBubbleView. Make the controller the window's +// delegate. +// 2. Create a subclass of BaseBubbleController. +// 3. Change the xib's File Owner to your subclass. +// 4. Hook up the File Owner's |bubble_| to the InfoBubbleView in the xib. +@interface BaseBubbleController : NSWindowController<NSWindowDelegate> { + @private + NSWindow* parentWindow_; // weak + NSPoint anchor_; + IBOutlet InfoBubbleView* bubble_; // to set arrow position + // Bridge that listens for notifications. + scoped_ptr<BaseBubbleControllerInternal::Bridge> base_bridge_; +} + +@property (nonatomic, readonly) NSWindow* parentWindow; +@property (nonatomic, assign) NSPoint anchorPoint; +@property (nonatomic, readonly) InfoBubbleView* bubble; + +// Creates a bubble. |nibPath| is just the basename, e.g. @"FirstRunBubble". +// |anchoredAt| is in screen space. You need to call -showWindow: to make the +// bubble visible. It will autorelease itself when the user dismisses the +// bubble. +// This is the designated initializer. +- (id)initWithWindowNibPath:(NSString*)nibPath + parentWindow:(NSWindow*)parentWindow + anchoredAt:(NSPoint)anchoredAt; + + +// Creates a bubble. |nibPath| is just the basename, e.g. @"FirstRunBubble". +// |view| must be in a window. The bubble will point at |offset| relative to +// |view|'s lower left corner. You need to call -showWindow: to make the +// bubble visible. It will autorelease itself when the user dismisses the +// bubble. +- (id)initWithWindowNibPath:(NSString*)nibPath + relativeToView:(NSView*)view + offset:(NSPoint)offset; + + +// For subclasses that do not load from a XIB, this will simply set the instance +// variables appropriately. This will also replace the |-[self window]|'s +// contentView with an instance of InfoBubbleView. +- (id)initWithWindow:(NSWindow*)theWindow + parentWindow:(NSWindow*)parentWindow + anchoredAt:(NSPoint)anchoredAt; + +@end diff --git a/chrome/browser/ui/cocoa/base_bubble_controller.mm b/chrome/browser/ui/cocoa/base_bubble_controller.mm new file mode 100644 index 0000000..1ebef4c --- /dev/null +++ b/chrome/browser/ui/cocoa/base_bubble_controller.mm @@ -0,0 +1,201 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/base_bubble_controller.h" + +#include "app/l10n_util.h" +#include "base/logging.h" +#include "base/mac_util.h" +#include "base/scoped_nsobject.h" +#include "base/string_util.h" +#import "chrome/browser/ui/cocoa/info_bubble_view.h" +#include "chrome/common/notification_observer.h" +#include "chrome/common/notification_registrar.h" +#include "chrome/common/notification_service.h" +#include "chrome/common/notification_type.h" +#include "grit/generated_resources.h" + +@interface BaseBubbleController (Private) +- (void)updateOriginFromAnchor; +@end + +namespace BaseBubbleControllerInternal { + +// This bridge listens for notifications so that the bubble closes when a user +// switches tabs (including by opening a new one). +class Bridge : public NotificationObserver { + public: + explicit Bridge(BaseBubbleController* controller) : controller_(controller) { + registrar_.Add(this, NotificationType::TAB_CONTENTS_HIDDEN, + NotificationService::AllSources()); + } + + // NotificationObserver: + virtual void Observe(NotificationType type, + const NotificationSource& source, + const NotificationDetails& details) { + [controller_ close]; + } + + private: + BaseBubbleController* controller_; // Weak, owns this. + NotificationRegistrar registrar_; +}; + +} // namespace BaseBubbleControllerInternal + +@implementation BaseBubbleController + +@synthesize parentWindow = parentWindow_; +@synthesize anchorPoint = anchor_; +@synthesize bubble = bubble_; + +- (id)initWithWindowNibPath:(NSString*)nibPath + parentWindow:(NSWindow*)parentWindow + anchoredAt:(NSPoint)anchoredAt { + nibPath = [mac_util::MainAppBundle() pathForResource:nibPath + ofType:@"nib"]; + if ((self = [super initWithWindowNibPath:nibPath owner:self])) { + parentWindow_ = parentWindow; + anchor_ = anchoredAt; + + // Watch to see if the parent window closes, and if so, close this one. + NSNotificationCenter* center = [NSNotificationCenter defaultCenter]; + [center addObserver:self + selector:@selector(parentWindowWillClose:) + name:NSWindowWillCloseNotification + object:parentWindow_]; + } + return self; +} + +- (id)initWithWindowNibPath:(NSString*)nibPath + relativeToView:(NSView*)view + offset:(NSPoint)offset { + DCHECK([view window]); + NSWindow* window = [view window]; + NSRect bounds = [view convertRect:[view bounds] toView:nil]; + NSPoint anchor = NSMakePoint(NSMinX(bounds) + offset.x, + NSMinY(bounds) + offset.y); + anchor = [window convertBaseToScreen:anchor]; + return [self initWithWindowNibPath:nibPath + parentWindow:window + anchoredAt:anchor]; +} + +- (id)initWithWindow:(NSWindow*)theWindow + parentWindow:(NSWindow*)parentWindow + anchoredAt:(NSPoint)anchoredAt { + DCHECK(theWindow); + if ((self = [super initWithWindow:theWindow])) { + parentWindow_ = parentWindow; + anchor_ = anchoredAt; + + DCHECK(![[self window] delegate]); + [theWindow setDelegate:self]; + + scoped_nsobject<InfoBubbleView> contentView( + [[InfoBubbleView alloc] initWithFrame:NSMakeRect(0, 0, 0, 0)]); + [theWindow setContentView:contentView.get()]; + bubble_ = contentView.get(); + + // Watch to see if the parent window closes, and if so, close this one. + NSNotificationCenter* center = [NSNotificationCenter defaultCenter]; + [center addObserver:self + selector:@selector(parentWindowWillClose:) + name:NSWindowWillCloseNotification + object:parentWindow_]; + + [self awakeFromNib]; + } + return self; +} + +- (void)awakeFromNib { + // Check all connections have been made in Interface Builder. + DCHECK([self window]); + DCHECK(bubble_); + DCHECK_EQ(self, [[self window] delegate]); + + base_bridge_.reset(new BaseBubbleControllerInternal::Bridge(self)); + + [bubble_ setBubbleType:info_bubble::kWhiteInfoBubble]; + [bubble_ setArrowLocation:info_bubble::kTopRight]; +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [super dealloc]; +} + +- (void)setAnchorPoint:(NSPoint)anchor { + anchor_ = anchor; + [self updateOriginFromAnchor]; +} + +- (void)parentWindowWillClose:(NSNotification*)notification { + [self close]; +} + +- (void)windowWillClose:(NSNotification*)notification { + // We caught a close so we don't need to watch for the parent closing. + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [self autorelease]; +} + +// We want this to be a child of a browser window. addChildWindow: +// (called from this function) will bring the window on-screen; +// unfortunately, [NSWindowController showWindow:] will also bring it +// on-screen (but will cause unexpected changes to the window's +// position). We cannot have an addChildWindow: and a subsequent +// showWindow:. Thus, we have our own version. +- (void)showWindow:(id)sender { + NSWindow* window = [self window]; // completes nib load + [self updateOriginFromAnchor]; + [parentWindow_ addChildWindow:window ordered:NSWindowAbove]; + [window makeKeyAndOrderFront:self]; +} + +- (void)close { + [parentWindow_ removeChildWindow:[self window]]; + [super close]; +} + +// The controller is the delegate of the window so it receives did resign key +// notifications. When key is resigned mirror Windows behavior and close the +// window. +- (void)windowDidResignKey:(NSNotification*)notification { + NSWindow* window = [self window]; + DCHECK_EQ([notification object], window); + if ([window isVisible]) { + // If the window isn't visible, it is already closed, and this notification + // has been sent as part of the closing operation, so no need to close. + [self close]; + } +} + +// By implementing this, ESC causes the window to go away. +- (IBAction)cancel:(id)sender { + // This is not a "real" cancel as potential changes to the radio group are not + // undone. That's ok. + [self close]; +} + +// Takes the |anchor_| point and adjusts the window's origin accordingly. +- (void)updateOriginFromAnchor { + NSWindow* window = [self window]; + NSPoint origin = anchor_; + NSSize offsets = NSMakeSize(info_bubble::kBubbleArrowXOffset + + info_bubble::kBubbleArrowWidth / 2.0, 0); + offsets = [[parentWindow_ contentView] convertSize:offsets toView:nil]; + if ([bubble_ arrowLocation] == info_bubble::kTopRight) { + origin.x -= NSWidth([window frame]) - offsets.width; + } else { + origin.x -= offsets.width; + } + origin.y -= NSHeight([window frame]); + [window setFrameOrigin:origin]; +} + +@end // BaseBubbleController diff --git a/chrome/browser/ui/cocoa/base_view.h b/chrome/browser/ui/cocoa/base_view.h new file mode 100644 index 0000000..0a8da9e --- /dev/null +++ b/chrome/browser/ui/cocoa/base_view.h @@ -0,0 +1,45 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_BASE_VIEW_H_ +#define CHROME_BROWSER_UI_COCOA_BASE_VIEW_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#include "gfx/rect.h" + +// A view that provides common functionality that many views will need: +// - Automatic registration for mouse-moved events. +// - Funneling of mouse and key events to two methods +// - Coordinate conversion utilities + +@interface BaseView : NSView { + @private + NSTrackingArea *trackingArea_; + BOOL dragging_; + scoped_nsobject<NSEvent> pendingExitEvent_; +} + +- (id)initWithFrame:(NSRect)frame; + +// Override these methods in a subclass. +- (void)mouseEvent:(NSEvent *)theEvent; +- (void)keyEvent:(NSEvent *)theEvent; + +// Useful rect conversions (doing coordinate flipping) +- (gfx::Rect)flipNSRectToRect:(NSRect)rect; +- (NSRect)flipRectToNSRect:(gfx::Rect)rect; + +@end + +// A notification that a view may issue when it receives first responder status. +// The name is |kViewDidBecomeFirstResponder|, the object is the view, and the +// NSSelectionDirection is wrapped in an NSNumber under the key +// |kSelectionDirection|. +extern NSString* kViewDidBecomeFirstResponder; +extern NSString* kSelectionDirection; + +#endif // CHROME_BROWSER_UI_COCOA_BASE_VIEW_H_ diff --git a/chrome/browser/ui/cocoa/base_view.mm b/chrome/browser/ui/cocoa/base_view.mm new file mode 100644 index 0000000..b26c390 --- /dev/null +++ b/chrome/browser/ui/cocoa/base_view.mm @@ -0,0 +1,147 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/base_view.h" + +NSString* kViewDidBecomeFirstResponder = + @"Chromium.kViewDidBecomeFirstResponder"; +NSString* kSelectionDirection = @"Chromium.kSelectionDirection"; + +@implementation BaseView + +- (id)initWithFrame:(NSRect)frame { + self = [super initWithFrame:frame]; + if (self) { + trackingArea_ = + [[NSTrackingArea alloc] initWithRect:frame + options:NSTrackingMouseMoved | + NSTrackingMouseEnteredAndExited | + NSTrackingActiveInActiveApp | + NSTrackingInVisibleRect + owner:self + userInfo:nil]; + [self addTrackingArea:trackingArea_]; + } + return self; +} + +- (void)dealloc { + [self removeTrackingArea:trackingArea_]; + [trackingArea_ release]; + + [super dealloc]; +} + +- (void)mouseEvent:(NSEvent *)theEvent { + // This method left intentionally blank. +} + +- (void)keyEvent:(NSEvent *)theEvent { + // This method left intentionally blank. +} + +- (void)mouseDown:(NSEvent *)theEvent { + dragging_ = YES; + [self mouseEvent:theEvent]; +} + +- (void)rightMouseDown:(NSEvent *)theEvent { + [self mouseEvent:theEvent]; +} + +- (void)otherMouseDown:(NSEvent *)theEvent { + [self mouseEvent:theEvent]; +} + +- (void)mouseUp:(NSEvent *)theEvent { + [self mouseEvent:theEvent]; + + dragging_ = NO; + if (pendingExitEvent_.get()) { + NSEvent* exitEvent = + [NSEvent enterExitEventWithType:NSMouseExited + location:[theEvent locationInWindow] + modifierFlags:[theEvent modifierFlags] + timestamp:[theEvent timestamp] + windowNumber:[theEvent windowNumber] + context:[theEvent context] + eventNumber:[pendingExitEvent_.get() eventNumber] + trackingNumber:[pendingExitEvent_.get() trackingNumber] + userData:[pendingExitEvent_.get() userData]]; + [self mouseEvent:exitEvent]; + pendingExitEvent_.reset(); + } +} + +- (void)rightMouseUp:(NSEvent *)theEvent { + [self mouseEvent:theEvent]; +} + +- (void)otherMouseUp:(NSEvent *)theEvent { + [self mouseEvent:theEvent]; +} + +- (void)mouseMoved:(NSEvent *)theEvent { + [self mouseEvent:theEvent]; +} + +- (void)mouseDragged:(NSEvent *)theEvent { + [self mouseEvent:theEvent]; +} + +- (void)rightMouseDragged:(NSEvent *)theEvent { + [self mouseEvent:theEvent]; +} + +- (void)otherMouseDragged:(NSEvent *)theEvent { + [self mouseEvent:theEvent]; +} + +- (void)mouseEntered:(NSEvent *)theEvent { + if (pendingExitEvent_.get()) { + pendingExitEvent_.reset(); + return; + } + + [self mouseEvent:theEvent]; +} + +- (void)mouseExited:(NSEvent *)theEvent { + // The tracking area will send an exit event even during a drag, which isn't + // how the event flow for drags should work. This stores the exit event, and + // sends it when the drag completes instead. + if (dragging_) { + pendingExitEvent_.reset([theEvent retain]); + return; + } + + [self mouseEvent:theEvent]; +} + +- (void)keyDown:(NSEvent *)theEvent { + [self keyEvent:theEvent]; +} + +- (void)keyUp:(NSEvent *)theEvent { + [self keyEvent:theEvent]; +} + +- (void)flagsChanged:(NSEvent *)theEvent { + [self keyEvent:theEvent]; +} + +- (gfx::Rect)flipNSRectToRect:(NSRect)rect { + gfx::Rect new_rect(NSRectToCGRect(rect)); + new_rect.set_y([self bounds].size.height - new_rect.y() - new_rect.height()); + return new_rect; +} + +- (NSRect)flipRectToNSRect:(gfx::Rect)rect { + NSRect new_rect(NSRectFromCGRect(rect.ToCGRect())); + new_rect.origin.y = + [self bounds].size.height - new_rect.origin.y - new_rect.size.height; + return new_rect; +} + +@end diff --git a/chrome/browser/ui/cocoa/base_view_unittest.mm b/chrome/browser/ui/cocoa/base_view_unittest.mm new file mode 100644 index 0000000..bd356b4 --- /dev/null +++ b/chrome/browser/ui/cocoa/base_view_unittest.mm @@ -0,0 +1,48 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/base_view.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +namespace { + +class BaseViewTest : public CocoaTest { + public: + BaseViewTest() { + NSRect frame = NSMakeRect(0, 0, 100, 100); + scoped_nsobject<BaseView> view([[BaseView alloc] initWithFrame:frame]); + view_ = view.get(); + [[test_window() contentView] addSubview:view_]; + } + + BaseView* view_; // weak +}; + +TEST_VIEW(BaseViewTest, view_) + +// Convert a rect in |view_|'s Cocoa coordinate system to gfx::Rect's top-left +// coordinate system. Repeat the process in reverse and make sure we come out +// with the original rect. +TEST_F(BaseViewTest, flipNSRectToRect) { + NSRect convert = NSMakeRect(10, 10, 50, 50); + gfx::Rect converted = [view_ flipNSRectToRect:convert]; + EXPECT_EQ(converted.x(), 10); + EXPECT_EQ(converted.y(), 40); // Due to view being 100px tall. + EXPECT_EQ(converted.width(), convert.size.width); + EXPECT_EQ(converted.height(), convert.size.height); + + // Go back the other way. + NSRect back_again = [view_ flipRectToNSRect:converted]; + EXPECT_EQ(back_again.origin.x, convert.origin.x); + EXPECT_EQ(back_again.origin.y, convert.origin.y); + EXPECT_EQ(back_again.size.width, convert.size.width); + EXPECT_EQ(back_again.size.height, convert.size.height); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_all_tabs_controller.h b/chrome/browser/ui/cocoa/bookmarks/bookmark_all_tabs_controller.h new file mode 100644 index 0000000..505211c --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_all_tabs_controller.h @@ -0,0 +1,46 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_ALL_TABS_CONTROLLER_H_ +#define CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_ALL_TABS_CONTROLLER_H_ +#pragma once + +#include <utility> +#include <vector> + +#include "base/string16.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_editor_base_controller.h" + +// A list of pairs containing the name and URL associated with each +// currently active tab in the active browser window. +typedef std::pair<string16, GURL> ActiveTabNameURLPair; +typedef std::vector<ActiveTabNameURLPair> ActiveTabsNameURLPairVector; + +// A controller for the Bookmark All Tabs sheet which is presented upon +// selecting the Bookmark All Tabs... menu item shown by the contextual +// menu in the bookmarks bar. +@interface BookmarkAllTabsController : BookmarkEditorBaseController { + @private + ActiveTabsNameURLPairVector activeTabPairsVector_; +} + +- (id)initWithParentWindow:(NSWindow*)parentWindow + profile:(Profile*)profile + parent:(const BookmarkNode*)parent + configuration:(BookmarkEditor::Configuration)configuration; + +@end + +@interface BookmarkAllTabsController(TestingAPI) + +// Initializes the list of all tab names and URLs. Overridden by unit test +// to provide canned test data. +- (void)UpdateActiveTabPairs; + +// Provides testing access to tab pairs list. +- (ActiveTabsNameURLPairVector*)activeTabPairsVector; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_ALL_TABS_CONTROLLER_H_ diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_all_tabs_controller.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_all_tabs_controller.mm new file mode 100644 index 0000000..e8ebaec --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_all_tabs_controller.mm @@ -0,0 +1,88 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_all_tabs_controller.h" + +#include "app/l10n_util_mac.h" +#include "base/string16.h" +#include "base/sys_string_conversions.h" +#include "chrome/browser/bookmarks/bookmark_model.h" +#include "chrome/browser/tab_contents/tab_contents.h" +#include "chrome/browser/tab_contents_wrapper.h" +#include "chrome/browser/tabs/tab_strip_model.h" +#include "chrome/browser/ui/browser.h" +#include "chrome/browser/ui/browser_list.h" +#include "grit/generated_resources.h" + +@implementation BookmarkAllTabsController + +- (id)initWithParentWindow:(NSWindow*)parentWindow + profile:(Profile*)profile + parent:(const BookmarkNode*)parent + configuration:(BookmarkEditor::Configuration)configuration { + NSString* nibName = @"BookmarkAllTabs"; + if ((self = [super initWithParentWindow:parentWindow + nibName:nibName + profile:profile + parent:parent + configuration:configuration])) { + } + return self; +} + +- (void)awakeFromNib { + [self setInitialName: + l10n_util::GetNSStringWithFixup(IDS_BOOMARK_EDITOR_NEW_FOLDER_NAME)]; + [super awakeFromNib]; +} + +#pragma mark Bookmark Editing + +- (void)UpdateActiveTabPairs { + activeTabPairsVector_.clear(); + Browser* browser = BrowserList::GetLastActive(); + TabStripModel* tabstrip_model = browser->tabstrip_model(); + const int tabCount = tabstrip_model->count(); + for (int i = 0; i < tabCount; ++i) { + TabContents* tc = tabstrip_model->GetTabContentsAt(i)->tab_contents(); + const string16 tabTitle = tc->GetTitle(); + const GURL& tabURL(tc->GetURL()); + ActiveTabNameURLPair tabPair(tabTitle, tabURL); + activeTabPairsVector_.push_back(tabPair); + } +} + +// Called by -[BookmarkEditorBaseController ok:]. Creates the container +// folder for the tabs and then the bookmarks in that new folder. +// Returns a BOOL as an NSNumber indicating that the commit may proceed. +- (NSNumber*)didCommit { + NSString* name = [[self displayName] stringByTrimmingCharactersInSet: + [NSCharacterSet newlineCharacterSet]]; + std::wstring newTitle = base::SysNSStringToWide(name); + const BookmarkNode* newParentNode = [self selectedNode]; + int newIndex = newParentNode->GetChildCount(); + // Create the new folder which will contain all of the tab URLs. + NSString* newFolderName = [self displayName]; + string16 newFolderString = base::SysNSStringToUTF16(newFolderName); + BookmarkModel* model = [self bookmarkModel]; + const BookmarkNode* newFolder = model->AddGroup(newParentNode, newIndex, + newFolderString); + // Get a list of all open tabs, create nodes for them, and add + // to the new folder node. + [self UpdateActiveTabPairs]; + int i = 0; + for (ActiveTabsNameURLPairVector::const_iterator it = + activeTabPairsVector_.begin(); + it != activeTabPairsVector_.end(); ++it, ++i) { + model->AddURL(newFolder, i, it->first, it->second); + } + return [NSNumber numberWithBool:YES]; +} + +- (ActiveTabsNameURLPairVector*)activeTabPairsVector { + return &activeTabPairsVector_; +} + +@end // BookmarkAllTabsController + diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_all_tabs_controller_unittest.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_all_tabs_controller_unittest.mm new file mode 100644 index 0000000..9f8a7d8 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_all_tabs_controller_unittest.mm @@ -0,0 +1,82 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#include "base/sys_string_conversions.h" +#include "base/utf_string_conversions.h" +#include "chrome/browser/bookmarks/bookmark_model.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_all_tabs_controller.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +@interface BookmarkAllTabsControllerOverride : BookmarkAllTabsController +@end + +@implementation BookmarkAllTabsControllerOverride + +- (void)UpdateActiveTabPairs { + ActiveTabsNameURLPairVector* activeTabPairsVector = + [self activeTabPairsVector]; + activeTabPairsVector->clear(); + activeTabPairsVector->push_back( + ActiveTabNameURLPair(ASCIIToUTF16("at-0"), GURL("http://at-0.com"))); + activeTabPairsVector->push_back( + ActiveTabNameURLPair(ASCIIToUTF16("at-1"), GURL("http://at-1.com"))); + activeTabPairsVector->push_back( + ActiveTabNameURLPair(ASCIIToUTF16("at-2"), GURL("http://at-2.com"))); +} + +@end + +class BookmarkAllTabsControllerTest : public CocoaTest { + public: + BrowserTestHelper helper_; + const BookmarkNode* parent_node_; + BookmarkAllTabsControllerOverride* controller_; + const BookmarkNode* group_a_; + + BookmarkAllTabsControllerTest() { + BookmarkModel& model(*(helper_.profile()->GetBookmarkModel())); + const BookmarkNode* root = model.GetBookmarkBarNode(); + group_a_ = model.AddGroup(root, 0, ASCIIToUTF16("a")); + model.AddURL(group_a_, 0, ASCIIToUTF16("a-0"), GURL("http://a-0.com")); + model.AddURL(group_a_, 1, ASCIIToUTF16("a-1"), GURL("http://a-1.com")); + model.AddURL(group_a_, 2, ASCIIToUTF16("a-2"), GURL("http://a-2.com")); + } + + virtual BookmarkAllTabsControllerOverride* CreateController() { + return [[BookmarkAllTabsControllerOverride alloc] + initWithParentWindow:test_window() + profile:helper_.profile() + parent:group_a_ + configuration:BookmarkEditor::SHOW_TREE]; + } + + virtual void SetUp() { + CocoaTest::SetUp(); + controller_ = CreateController(); + [controller_ runAsModalSheet]; + } + + virtual void TearDown() { + controller_ = NULL; + CocoaTest::TearDown(); + } +}; + +TEST_F(BookmarkAllTabsControllerTest, BookmarkAllTabs) { + // OK button should always be enabled. + EXPECT_TRUE([controller_ okButtonEnabled]); + [controller_ selectTestNodeInBrowser:group_a_]; + [controller_ setDisplayName:@"ALL MY TABS"]; + [controller_ ok:nil]; + EXPECT_EQ(4, group_a_->GetChildCount()); + const BookmarkNode* folderChild = group_a_->GetChild(3); + EXPECT_EQ(folderChild->GetTitle(), ASCIIToUTF16("ALL MY TABS")); + EXPECT_EQ(3, folderChild->GetChildCount()); +} diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_bridge.h b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_bridge.h new file mode 100644 index 0000000..811c450 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_bridge.h @@ -0,0 +1,60 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// C++ bridge class between Chromium and Cocoa to connect the +// Bookmarks (model) with the Bookmark Bar (view). +// +// There is exactly one BookmarkBarBridge per BookmarkBarController / +// BrowserWindowController / Browser. + +#ifndef CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_BAR_BRIDGE_H_ +#define CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_BAR_BRIDGE_H_ +#pragma once + +#include "base/basictypes.h" +#include "chrome/browser/bookmarks/bookmark_model_observer.h" + +class Browser; +@class BookmarkBarController; + +class BookmarkBarBridge : public BookmarkModelObserver { + public: + BookmarkBarBridge(BookmarkBarController* controller, + BookmarkModel* model); + virtual ~BookmarkBarBridge(); + + // Overridden from BookmarkModelObserver + virtual void Loaded(BookmarkModel* model); + virtual void BookmarkModelBeingDeleted(BookmarkModel* model); + virtual void BookmarkNodeMoved(BookmarkModel* model, + const BookmarkNode* old_parent, + int old_index, + const BookmarkNode* new_parent, + int new_index); + virtual void BookmarkNodeAdded(BookmarkModel* model, + const BookmarkNode* parent, + int index); + virtual void BookmarkNodeRemoved(BookmarkModel* model, + const BookmarkNode* parent, + int old_index, + const BookmarkNode* node); + virtual void BookmarkNodeChanged(BookmarkModel* model, + const BookmarkNode* node); + virtual void BookmarkNodeFavIconLoaded(BookmarkModel* model, + const BookmarkNode* node); + virtual void BookmarkNodeChildrenReordered(BookmarkModel* model, + const BookmarkNode* node); + + virtual void BookmarkImportBeginning(BookmarkModel* model); + virtual void BookmarkImportEnding(BookmarkModel* model); + + private: + BookmarkBarController* controller_; // weak; owns me + BookmarkModel* model_; // weak; it is owned by a Profile. + bool batch_mode_; + + DISALLOW_COPY_AND_ASSIGN(BookmarkBarBridge); +}; + +#endif // CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_BAR_BRIDGE_H_ diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_bridge.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_bridge.mm new file mode 100644 index 0000000..54f5e81 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_bridge.mm @@ -0,0 +1,82 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_bridge.h" + +#include "chrome/browser/bookmarks/bookmark_model.h" +#include "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_controller.h" + +BookmarkBarBridge::BookmarkBarBridge(BookmarkBarController* controller, + BookmarkModel* model) + : controller_(controller), + model_(model), + batch_mode_(false) { + model_->AddObserver(this); + + // Bookmark loading is async; it may may not have happened yet. + // We will be notified when that happens with the AddObserver() call. + if (model->IsLoaded()) + Loaded(model); +} + +BookmarkBarBridge::~BookmarkBarBridge() { + model_->RemoveObserver(this); +} + +void BookmarkBarBridge::Loaded(BookmarkModel* model) { + [controller_ loaded:model]; +} + +void BookmarkBarBridge::BookmarkModelBeingDeleted(BookmarkModel* model) { + [controller_ beingDeleted:model]; +} + +void BookmarkBarBridge::BookmarkNodeMoved(BookmarkModel* model, + const BookmarkNode* old_parent, + int old_index, + const BookmarkNode* new_parent, + int new_index) { + [controller_ nodeMoved:model + oldParent:old_parent oldIndex:old_index + newParent:new_parent newIndex:new_index]; +} + +void BookmarkBarBridge::BookmarkNodeAdded(BookmarkModel* model, + const BookmarkNode* parent, + int index) { + if (!batch_mode_) { + [controller_ nodeAdded:model parent:parent index:index]; + } +} + +void BookmarkBarBridge::BookmarkNodeRemoved(BookmarkModel* model, + const BookmarkNode* parent, + int old_index, + const BookmarkNode* node) { + [controller_ nodeRemoved:model parent:parent index:old_index]; +} + +void BookmarkBarBridge::BookmarkNodeChanged(BookmarkModel* model, + const BookmarkNode* node) { + [controller_ nodeChanged:model node:node]; +} + +void BookmarkBarBridge::BookmarkNodeFavIconLoaded(BookmarkModel* model, + const BookmarkNode* node) { + [controller_ nodeFavIconLoaded:model node:node]; +} + +void BookmarkBarBridge::BookmarkNodeChildrenReordered( + BookmarkModel* model, const BookmarkNode* node) { + [controller_ nodeChildrenReordered:model node:node]; +} + +void BookmarkBarBridge::BookmarkImportBeginning(BookmarkModel* model) { + batch_mode_ = true; +} + +void BookmarkBarBridge::BookmarkImportEnding(BookmarkModel* model) { + batch_mode_ = false; + [controller_ loaded:model]; +} diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_bridge_unittest.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_bridge_unittest.mm new file mode 100644 index 0000000..067d327 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_bridge_unittest.mm @@ -0,0 +1,135 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_bridge.h" +#include "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_controller.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#include "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "testing/gtest/include/gtest/gtest.h" +#import "testing/gtest_mac.h" +#include "testing/platform_test.h" + +// TODO(jrg): use OCMock. + +namespace { + +// Information needed to open a URL, as passed to the +// BookmarkBarController's delegate. +typedef std::pair<GURL,WindowOpenDisposition> OpenInfo; + +} // The namespace must end here -- I need to use OpenInfo in + // FakeBookmarkBarController but can't place + // FakeBookmarkBarController itself in the namespace ("error: + // Objective-C declarations may only appear in global scope") + +// Oddly, we are our own delegate. +@interface FakeBookmarkBarController : BookmarkBarController { + @public + scoped_nsobject<NSMutableArray> callbacks_; + std::vector<OpenInfo> opens_; +} +@end + +@implementation FakeBookmarkBarController + +- (id)initWithBrowser:(Browser*)browser { + if ((self = [super initWithBrowser:browser + initialWidth:100 // arbitrary + delegate:nil + resizeDelegate:nil])) { + callbacks_.reset([[NSMutableArray alloc] init]); + } + return self; +} + +- (void)loaded:(BookmarkModel*)model { + [callbacks_ addObject:[NSNumber numberWithInt:0]]; +} + +- (void)beingDeleted:(BookmarkModel*)model { + [callbacks_ addObject:[NSNumber numberWithInt:1]]; +} + +- (void)nodeMoved:(BookmarkModel*)model + oldParent:(const BookmarkNode*)oldParent oldIndex:(int)oldIndex + newParent:(const BookmarkNode*)newParent newIndex:(int)newIndex { + [callbacks_ addObject:[NSNumber numberWithInt:2]]; +} + +- (void)nodeAdded:(BookmarkModel*)model + parent:(const BookmarkNode*)oldParent index:(int)index { + [callbacks_ addObject:[NSNumber numberWithInt:3]]; +} + +- (void)nodeChanged:(BookmarkModel*)model + node:(const BookmarkNode*)node { + [callbacks_ addObject:[NSNumber numberWithInt:4]]; +} + +- (void)nodeFavIconLoaded:(BookmarkModel*)model + node:(const BookmarkNode*)node { + [callbacks_ addObject:[NSNumber numberWithInt:5]]; +} + +- (void)nodeChildrenReordered:(BookmarkModel*)model + node:(const BookmarkNode*)node { + [callbacks_ addObject:[NSNumber numberWithInt:6]]; +} + +- (void)nodeRemoved:(BookmarkModel*)model + parent:(const BookmarkNode*)oldParent index:(int)index { + [callbacks_ addObject:[NSNumber numberWithInt:7]]; +} + +// Save the request. +- (void)openBookmarkURL:(const GURL&)url + disposition:(WindowOpenDisposition)disposition { + opens_.push_back(OpenInfo(url, disposition)); +} + +@end + + +class BookmarkBarBridgeTest : public CocoaTest { + public: + BrowserTestHelper browser_test_helper_; +}; + +// Call all the callbacks; make sure they are all redirected to the objc object. +TEST_F(BookmarkBarBridgeTest, TestRedirect) { + Browser* browser = browser_test_helper_.browser(); + Profile* profile = browser_test_helper_.profile(); + BookmarkModel* model = profile->GetBookmarkModel(); + + scoped_nsobject<NSView> parentView([[NSView alloc] + initWithFrame:NSMakeRect(0,0,100,100)]); + scoped_nsobject<NSView> webView([[NSView alloc] + initWithFrame:NSMakeRect(0,0,100,100)]); + scoped_nsobject<NSView> infoBarsView( + [[NSView alloc] initWithFrame:NSMakeRect(0,0,100,100)]); + + scoped_nsobject<FakeBookmarkBarController> + controller([[FakeBookmarkBarController alloc] initWithBrowser:browser]); + EXPECT_TRUE(controller.get()); + scoped_ptr<BookmarkBarBridge> bridge(new BookmarkBarBridge(controller.get(), + model)); + EXPECT_TRUE(bridge.get()); + + bridge->Loaded(NULL); + bridge->BookmarkModelBeingDeleted(NULL); + bridge->BookmarkNodeMoved(NULL, NULL, 0, NULL, 0); + bridge->BookmarkNodeAdded(NULL, NULL, 0); + bridge->BookmarkNodeChanged(NULL, NULL); + bridge->BookmarkNodeFavIconLoaded(NULL, NULL); + bridge->BookmarkNodeChildrenReordered(NULL, NULL); + bridge->BookmarkNodeRemoved(NULL, NULL, 0, NULL); + + // 8 calls above plus an initial Loaded() in init routine makes 9 + EXPECT_TRUE([controller.get()->callbacks_ count] == 9); + + for (int x = 1; x < 9; x++) { + NSNumber* num = [NSNumber numberWithInt:x-1]; + EXPECT_NSEQ(num, [controller.get()->callbacks_ objectAtIndex:x]); + } +} diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_constants.h b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_constants.h new file mode 100644 index 0000000..b4e4bef --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_constants.h @@ -0,0 +1,38 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// Constants used for positioning the bookmark bar. These aren't placed in a +// different file because they're conditionally included in cross platform code +// and thus no Objective-C++ stuff. + +#ifndef CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_BAR_CONSTANTS_H_ +#define CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_BAR_CONSTANTS_H_ +#pragma once + +namespace bookmarks { + +// Correction used for computing other values based on the height. +const int kVisualHeightOffset = 2; + +// Bar height, when opened in "always visible" mode. This is actually a little +// smaller than it should be (by |kVisualHeightOffset| points) because of the +// visual overlap with the main toolbar. When using this to compute values +// other than the actual height of the toolbar, be sure to add +// |kVisualHeightOffset|. +const int kBookmarkBarHeight = 26; + +// Our height, when visible in "new tab page" mode. +const int kNTPBookmarkBarHeight = 40; + +// The amount of space between the inner bookmark bar and the outer toolbar on +// new tab pages. +const int kNTPBookmarkBarPadding = + (kNTPBookmarkBarHeight - (kBookmarkBarHeight + kVisualHeightOffset)) / 2; + +// The height of buttons in the bookmark bar. +const int kBookmarkButtonHeight = kBookmarkBarHeight + kVisualHeightOffset; + +} // namespace bookmarks + +#endif // CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_BAR_CONSTANTS_H_ diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_controller.h b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_controller.h new file mode 100644 index 0000000..a9bca8a --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_controller.h @@ -0,0 +1,399 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_BAR_CONTROLLER_H_ +#define CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_BAR_CONTROLLER_H_ +#pragma once + +#import <Cocoa/Cocoa.h> +#include <map> + +#import "base/chrome_application_mac.h" +#include "base/scoped_nsobject.h" +#include "base/scoped_ptr.h" +#include "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_bridge.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_constants.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_state.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_toolbar_view.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_button.h" +#include "chrome/browser/ui/cocoa/tab_strip_model_observer_bridge.h" +#include "webkit/glue/window_open_disposition.h" + +@class BookmarkBarController; +@class BookmarkBarFolderController; +@class BookmarkBarView; +@class BookmarkButton; +@class BookmarkButtonCell; +@class BookmarkFolderTarget; +class BookmarkModel; +@class BookmarkMenu; +class BookmarkNode; +class Browser; +class GURL; +class PrefService; +class TabContents; +@class ToolbarController; +@protocol ViewResizer; + +namespace bookmarks { + +// Magic numbers from Cole +// TODO(jrg): create an objc-friendly version of bookmark_bar_constants.h? + +// Used as a maximum width for buttons on the bar. +const CGFloat kDefaultBookmarkWidth = 150.0; + +// Horizontal frame inset for buttons in the bookmark bar. +const CGFloat kBookmarkHorizontalPadding = 1.0; + +// Vertical frame inset for buttons in the bookmark bar. +const CGFloat kBookmarkVerticalPadding = 2.0; + +// Used as a min/max width for buttons on menus (not on the bar). +const CGFloat kBookmarkMenuButtonMinimumWidth = 100.0; +const CGFloat kBookmarkMenuButtonMaximumWidth = 485.0; + +// Horizontal separation between a menu button and both edges of its menu. +const CGFloat kBookmarkSubMenuHorizontalPadding = 5.0; + +// TODO(mrossetti): Add constant (kBookmarkVerticalSeparation) for the gap +// between buttons in a folder menu. Right now we're using +// kBookmarkVerticalPadding, which is dual purpose and wrong. +// http://crbug.com/59057 + +// Convenience constant giving the vertical distance from the top extent of one +// folder button to the next button. +const CGFloat kBookmarkButtonVerticalSpan = + kBookmarkButtonHeight + kBookmarkVerticalPadding; + +// The minimum separation between a folder menu and the edge of the screen. +// If the menu gets closer to the edge of the screen (either right or left) +// then it is pops up in the opposite direction. +// (See -[BookmarkBarFolderController childFolderWindowLeftForWidth:]). +const CGFloat kBookmarkHorizontalScreenPadding = 8.0; + +// Our NSScrollView is supposed to be just barely big enough to fit its +// contentView. It is actually a hair too small. +// This turns on horizontal scrolling which, although slight, is awkward. +// Make sure our window (and NSScrollView) are wider than its documentView +// by at least this much. +const CGFloat kScrollViewContentWidthMargin = 2; + +// Make subfolder menus overlap their parent menu a bit to give a better +// perception of a menuing system. +const CGFloat kBookmarkMenuOverlap = 5.0; + +// Delay before opening a subfolder (and closing the previous one) +// when hovering over a folder button. +const NSTimeInterval kHoverOpenDelay = 0.3; + +// Delay on hover before a submenu opens when dragging. +// Experimentally a drag hover open delay needs to be bigger than a +// normal (non-drag) menu hover open such as used in the bookmark folder. +// TODO(jrg): confirm feel of this constant with ui-team. +// http://crbug.com/36276 +const NSTimeInterval kDragHoverOpenDelay = 0.7; + +// Notes on use of kDragHoverCloseDelay in +// -[BookmarkBarFolderController draggingEntered:]. +// +// We have an implicit delay on stop-hover-open before a submenu +// closes. This cannot be zero since it's nice to move the mouse in a +// direct line from "current position" to "position of item in +// submenu". However, by doing so, it's possible to overlap a +// different button on the current menu. Example: +// +// Folder1 +// Folder2 ---> Sub1 +// Folder3 Sub2 +// Sub3 +// +// If you hover over the F in Folder2 to open the sub, and then want to +// select Sub3, a direct line movement of the mouse may cross over +// Folder3. Without this delay, that'll cause Sub to be closed before +// you get there, since a "hover over" of Folder3 gets activated. +// It's subtle but without the delay it feels broken. +// +// This is only really a problem with vertical menu --> vertical menu +// movement; the bookmark bar (horizontal menu, sort of) seems fine, +// perhaps because mouse move direction is purely vertical so there is +// no opportunity for overlap. +const NSTimeInterval kDragHoverCloseDelay = 0.4; + +} // namespace bookmarks + +// The interface for the bookmark bar controller's delegate. Currently, the +// delegate is the BWC and is responsible for ensuring that the toolbar is +// displayed correctly (as specified by |-getDesiredToolbarHeightCompression| +// and |-toolbarDividerOpacity|) at the beginning and at the end of an animation +// (or after a state change). +@protocol BookmarkBarControllerDelegate + +// Sent when the state has changed (after any animation), but before the final +// display update. +- (void)bookmarkBar:(BookmarkBarController*)controller + didChangeFromState:(bookmarks::VisualState)oldState + toState:(bookmarks::VisualState)newState; + +// Sent before the animation begins. +- (void)bookmarkBar:(BookmarkBarController*)controller +willAnimateFromState:(bookmarks::VisualState)oldState + toState:(bookmarks::VisualState)newState; + +@end + +// A controller for the bookmark bar in the browser window. Handles showing +// and hiding based on the preference in the given profile. +@interface BookmarkBarController : + NSViewController<BookmarkBarState, + BookmarkBarToolbarViewController, + BookmarkButtonDelegate, + BookmarkButtonControllerProtocol, + CrApplicationEventHookProtocol, + NSUserInterfaceValidations> { + @private + // The visual state of the bookmark bar. If an animation is running, this is + // set to the "destination" and |lastVisualState_| is set to the "original" + // state. This is set to |kInvalidState| on initialization (when the + // appropriate state is not yet known). + bookmarks::VisualState visualState_; + + // The "original" state of the bookmark bar if an animation is running, + // otherwise it should be |kInvalidState|. + bookmarks::VisualState lastVisualState_; + + Browser* browser_; // weak; owned by its window + BookmarkModel* bookmarkModel_; // weak; part of the profile owned by the + // top-level Browser object. + + // Our initial view width, which is applied in awakeFromNib. + CGFloat initialWidth_; + + // BookmarkNodes have a 64bit id. NSMenuItems have a 32bit tag used + // to represent the bookmark node they refer to. This map provides + // a mapping from one to the other, so we can properly identify the + // node from the item. When adding items in, we start with seedId_. + int32 seedId_; + std::map<int32,int64> menuTagMap_; + + // Our bookmark buttons, ordered from L-->R. + scoped_nsobject<NSMutableArray> buttons_; + + // The folder image so we can use one copy for all buttons + scoped_nsobject<NSImage> folderImage_; + + // The default image, so we can use one copy for all buttons. + scoped_nsobject<NSImage> defaultImage_; + + // If the bar is disabled, we hide it and ignore show/hide commands. + // Set when using fullscreen mode. + BOOL barIsEnabled_; + + // Bridge from Chrome-style C++ notifications (e.g. derived from + // BookmarkModelObserver) + scoped_ptr<BookmarkBarBridge> bridge_; + + // Delegate that is informed about state changes in the bookmark bar. + id<BookmarkBarControllerDelegate> delegate_; // weak + + // Delegate that can resize us. + id<ViewResizer> resizeDelegate_; // weak + + // Logic for dealing with a click on a bookmark folder button. + scoped_nsobject<BookmarkFolderTarget> folderTarget_; + + // A controller for a pop-up bookmark folder window (custom menu). + // This is not a scoped_nsobject because it owns itself (when its + // window closes the controller gets autoreleased). + BookmarkBarFolderController* folderController_; + + // Are watching for a "click outside" or other event which would + // signal us to close the bookmark bar folder menus? + BOOL watchingForExitEvent_; + + IBOutlet BookmarkBarView* buttonView_; // Contains 'no items' text fields. + IBOutlet BookmarkButton* offTheSideButton_; // aka the chevron. + IBOutlet NSMenu* buttonContextMenu_; + + NSRect originalNoItemsRect_; // Original, pre-resized field rect. + NSRect originalImportBookmarksRect_; // Original, pre-resized field rect. + + // "Other bookmarks" button on the right side. + scoped_nsobject<BookmarkButton> otherBookmarksButton_; + + // We have a special menu for folder buttons. This starts as a copy + // of the bar menu. + scoped_nsobject<BookmarkMenu> buttonFolderContextMenu_; + + // When doing a drag, this is folder button "hovered over" which we + // may want to open after a short delay. There are cases where a + // mouse-enter can open a folder (e.g. if the menus are "active") + // but that doesn't use this variable or need a delay so "hover" is + // the wrong term. + scoped_nsobject<BookmarkButton> hoverButton_; + + // We save the view width when we add bookmark buttons. This lets + // us avoid a rebuild until we've grown the window bigger than our + // initial build. + CGFloat savedFrameWidth_; + + // The number of buttons we display in the bookmark bar. This does + // not include the "off the side" chevron or the "Other Bookmarks" + // button. We use this number to determine if we need to display + // the chevron, and to know what to place in the chevron's menu. + // Since we create everything before doing layout we can't be sure + // that all bookmark buttons we create will be visible. Thus, + // [buttons_ count] isn't a definitive check. + int displayedButtonCount_; + + // A state flag which tracks when the bar's folder menus should be shown. + // An initial click in any of the folder buttons turns this on and + // one of the following will turn it off: another click in the button, + // the window losing focus, a click somewhere other than in the bar + // or a folder menu. + BOOL showFolderMenus_; + + // Set to YES to prevent any node animations. Useful for unit testing so that + // incomplete animations do not cause valgrind complaints. + BOOL ignoreAnimations_; +} + +@property(readonly, nonatomic) bookmarks::VisualState visualState; +@property(readonly, nonatomic) bookmarks::VisualState lastVisualState; +@property(assign, nonatomic) id<BookmarkBarControllerDelegate> delegate; + +// Initializes the bookmark bar controller with the given browser +// profile and delegates. +- (id)initWithBrowser:(Browser*)browser + initialWidth:(CGFloat)initialWidth + delegate:(id<BookmarkBarControllerDelegate>)delegate + resizeDelegate:(id<ViewResizer>)resizeDelegate; + +// Updates the bookmark bar (from its current, possibly in-transition) state to +// the one appropriate for the new conditions. +- (void)updateAndShowNormalBar:(BOOL)showNormalBar + showDetachedBar:(BOOL)showDetachedBar + withAnimation:(BOOL)animate; + +// Update the visible state of the bookmark bar. +- (void)updateVisibility; + +// Turn on or off the bookmark bar and prevent or reallow its appearance. On +// disable, toggle off if shown. On enable, show only if needed. App and popup +// windows do not show a bookmark bar. +- (void)setBookmarkBarEnabled:(BOOL)enabled; + +// Returns the amount by which the toolbar above should be compressed. +- (CGFloat)getDesiredToolbarHeightCompression; + +// Gets the appropriate opacity for the toolbar's divider; 0 means that it +// shouldn't be shown. +- (CGFloat)toolbarDividerOpacity; + +// Updates the sizes and positions of the subviews. +// TODO(viettrungluu): I'm not convinced this should be public, but I currently +// need it for animations. Try not to propagate its use. +- (void)layoutSubviews; + +// Called by our view when it is moved to a window. +- (void)viewDidMoveToWindow; + +// Import bookmarks from another browser. +- (IBAction)importBookmarks:(id)sender; + +// Provide a favIcon for a bookmark node. May return nil. +- (NSImage*)favIconForNode:(const BookmarkNode*)node; + +// Used for situations where the bookmark bar folder menus should no longer +// be actively popping up. Called when the window loses focus, a click has +// occured outside the menus or a bookmark has been activated. (Note that this +// differs from the behavior of the -[BookmarkButtonControllerProtocol +// closeAllBookmarkFolders] method in that the latter does not terminate menu +// tracking since it may be being called in response to actions (such as +// dragging) where a 'stale' menu presentation should first be collapsed before +// presenting a new menu.) +- (void)closeFolderAndStopTrackingMenus; + +// Checks if operations such as edit or delete are allowed. +- (BOOL)canEditBookmark:(const BookmarkNode*)node; + +// Actions for manipulating bookmarks. +// Open a normal bookmark or folder from a button, ... +- (IBAction)openBookmark:(id)sender; +- (IBAction)openBookmarkFolderFromButton:(id)sender; +// From the "off the side" button, ... +- (IBAction)openOffTheSideFolderFromButton:(id)sender; +// From a context menu over the button, ... +- (IBAction)openBookmarkInNewForegroundTab:(id)sender; +- (IBAction)openBookmarkInNewWindow:(id)sender; +- (IBAction)openBookmarkInIncognitoWindow:(id)sender; +- (IBAction)editBookmark:(id)sender; +- (IBAction)cutBookmark:(id)sender; +- (IBAction)copyBookmark:(id)sender; +- (IBAction)pasteBookmark:(id)sender; +- (IBAction)deleteBookmark:(id)sender; +// From a context menu over the bar, ... +- (IBAction)openAllBookmarks:(id)sender; +- (IBAction)openAllBookmarksNewWindow:(id)sender; +- (IBAction)openAllBookmarksIncognitoWindow:(id)sender; +// Or from a context menu over either the bar or a button. +- (IBAction)addPage:(id)sender; +- (IBAction)addFolder:(id)sender; + +@end + +// Redirects from BookmarkBarBridge, the C++ object which glues us to +// the rest of Chromium. Internal to BookmarkBarController. +@interface BookmarkBarController(BridgeRedirect) +- (void)loaded:(BookmarkModel*)model; +- (void)beingDeleted:(BookmarkModel*)model; +- (void)nodeAdded:(BookmarkModel*)model + parent:(const BookmarkNode*)oldParent index:(int)index; +- (void)nodeChanged:(BookmarkModel*)model + node:(const BookmarkNode*)node; +- (void)nodeMoved:(BookmarkModel*)model + oldParent:(const BookmarkNode*)oldParent oldIndex:(int)oldIndex + newParent:(const BookmarkNode*)newParent newIndex:(int)newIndex; +- (void)nodeRemoved:(BookmarkModel*)model + parent:(const BookmarkNode*)oldParent index:(int)index; +- (void)nodeFavIconLoaded:(BookmarkModel*)model + node:(const BookmarkNode*)node; +- (void)nodeChildrenReordered:(BookmarkModel*)model + node:(const BookmarkNode*)node; +@end + +// These APIs should only be used by unit tests (or used internally). +@interface BookmarkBarController(InternalOrTestingAPI) +- (BookmarkBarView*)buttonView; +- (NSMutableArray*)buttons; +- (NSMenu*)offTheSideMenu; +- (NSButton*)offTheSideButton; +- (BOOL)offTheSideButtonIsHidden; +- (BookmarkButton*)otherBookmarksButton; +- (BookmarkBarFolderController*)folderController; +- (id)folderTarget; +- (int)displayedButtonCount; +- (void)openURL:(GURL)url disposition:(WindowOpenDisposition)disposition; +- (void)clearBookmarkBar; +- (BookmarkButtonCell*)cellForBookmarkNode:(const BookmarkNode*)node; +- (NSRect)frameForBookmarkButtonFromCell:(NSCell*)cell xOffset:(int*)xOffset; +- (void)checkForBookmarkButtonGrowth:(NSButton*)button; +- (void)frameDidChange; +- (int64)nodeIdFromMenuTag:(int32)tag; +- (int32)menuTagFromNodeId:(int64)menuid; +- (const BookmarkNode*)nodeFromMenuItem:(id)sender; +- (void)updateTheme:(ThemeProvider*)themeProvider; +- (BookmarkButton*)buttonForDroppingOnAtPoint:(NSPoint)point; +- (BOOL)isEventAnExitEvent:(NSEvent*)event; +- (BOOL)shrinkOrHideView:(NSView*)view forMaxX:(CGFloat)maxViewX; + +// The following are for testing purposes only and are not used internally. +- (NSMenu *)menuForFolderNode:(const BookmarkNode*)node; +- (NSMenu*)buttonContextMenu; +- (void)setButtonContextMenu:(id)menu; +// Set to YES in order to prevent animations. +- (void)setIgnoreAnimations:(BOOL)ignore; +@end + +#endif // CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_BAR_CONTROLLER_H_ diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_controller.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_controller.mm new file mode 100644 index 0000000..f8ed23f --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_controller.mm @@ -0,0 +1,2497 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_controller.h" + +#include "app/l10n_util_mac.h" +#include "app/resource_bundle.h" +#include "base/mac_util.h" +#include "base/sys_string_conversions.h" +#include "chrome/browser/bookmarks/bookmark_editor.h" +#include "chrome/browser/bookmarks/bookmark_model.h" +#include "chrome/browser/bookmarks/bookmark_utils.h" +#include "chrome/browser/metrics/user_metrics.h" +#include "chrome/browser/prefs/pref_service.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/tab_contents/tab_contents.h" +#include "chrome/browser/tab_contents/tab_contents_view.h" +#import "chrome/browser/themes/browser_theme_provider.h" +#include "chrome/browser/ui/browser.h" +#include "chrome/browser/ui/browser_list.h" +#import "chrome/browser/ui/cocoa/background_gradient_view.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_bridge.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_controller.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_window.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_toolbar_view.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_view.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_button.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_button_cell.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_editor_controller.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_folder_target.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_menu.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_menu_cocoa_controller.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_name_folder_controller.h" +#import "chrome/browser/ui/cocoa/browser_window_controller.h" +#import "chrome/browser/ui/cocoa/event_utils.h" +#import "chrome/browser/ui/cocoa/fullscreen_controller.h" +#import "chrome/browser/ui/cocoa/import_settings_dialog.h" +#import "chrome/browser/ui/cocoa/menu_button.h" +#import "chrome/browser/ui/cocoa/themed_window.h" +#import "chrome/browser/ui/cocoa/toolbar_controller.h" +#import "chrome/browser/ui/cocoa/view_id_util.h" +#import "chrome/browser/ui/cocoa/view_resizer.h" +#include "chrome/common/pref_names.h" +#include "grit/app_resources.h" +#include "grit/generated_resources.h" +#include "grit/theme_resources.h" +#include "skia/ext/skia_utils_mac.h" + +// Bookmark bar state changing and animations +// +// The bookmark bar has three real states: "showing" (a normal bar attached to +// the toolbar), "hidden", and "detached" (pretending to be part of the web +// content on the NTP). It can, or at least should be able to, animate between +// these states. There are several complications even without animation: +// - The placement of the bookmark bar is done by the BWC, and it needs to know +// the state in order to place the bookmark bar correctly (immediately below +// the toolbar when showing, below the infobar when detached). +// - The "divider" (a black line) needs to be drawn by either the toolbar (when +// the bookmark bar is hidden or detached) or by the bookmark bar (when it is +// showing). It should not be drawn by both. +// - The toolbar needs to vertically "compress" when the bookmark bar is +// showing. This ensures the proper display of both the bookmark bar and the +// toolbar, and gives a padded area around the bookmark bar items for right +// clicks, etc. +// +// Our model is that the BWC controls us and also the toolbar. We try not to +// talk to the browser nor the toolbar directly, instead centralizing control in +// the BWC. The key method by which the BWC controls us is +// |-updateAndShowNormalBar:showDetachedBar:withAnimation:|. This invokes state +// changes, and at appropriate times we request that the BWC do things for us +// via either the resize delegate or our general delegate. If the BWC needs any +// information about what it should do, or tell the toolbar to do, it can then +// query us back (e.g., |-isShownAs...|, |-getDesiredToolbarHeightCompression|, +// |-toolbarDividerOpacity|, etc.). +// +// Animation-related complications: +// - Compression of the toolbar is touchy during animation. It must not be +// compressed while the bookmark bar is animating to/from showing (from/to +// hidden), otherwise it would look like the bookmark bar's contents are +// sliding out of the controls inside the toolbar. As such, we have to make +// sure that the bookmark bar is shown at the right location and at the +// right height (at various points in time). +// - Showing the divider is also complicated during animation between hidden +// and showing. We have to make sure that the toolbar does not show the +// divider despite the fact that it's not compressed. The exception to this +// is at the beginning/end of the animation when the toolbar is still +// uncompressed but the bookmark bar has height 0. If we're not careful, we +// get a flicker at this point. +// - We have to ensure that we do the right thing if we're told to change state +// while we're running an animation. The generic/easy thing to do is to jump +// to the end state of our current animation, and (if the new state change +// again involves an animation) begin the new animation. We can do better +// than that, however, and sometimes just change the current animation to go +// to the new end state (e.g., by "reversing" the animation in the showing -> +// hidden -> showing case). We also have to ensure that demands to +// immediately change state are always honoured. +// +// Pointers to animation logic: +// - |-moveToVisualState:withAnimation:| starts animations, deciding which ones +// we know how to handle. +// - |-doBookmarkBarAnimation| has most of the actual logic. +// - |-getDesiredToolbarHeightCompression| and |-toolbarDividerOpacity| contain +// related logic. +// - The BWC's |-layoutSubviews| needs to know how to position things. +// - The BWC should implement |-bookmarkBar:didChangeFromState:toState:| and +// |-bookmarkBar:willAnimateFromState:toState:| in order to inform the +// toolbar of required changes. + +namespace { + +// Overlap (in pixels) between the toolbar and the bookmark bar (when showing in +// normal mode). +const CGFloat kBookmarkBarOverlap = 3.0; + +// Duration of the bookmark bar animations. +const NSTimeInterval kBookmarkBarAnimationDuration = 0.12; + +} // namespace + +@interface BookmarkBarController(Private) + +// Determines the appropriate state for the given situation. ++ (bookmarks::VisualState)visualStateToShowNormalBar:(BOOL)showNormalBar + showDetachedBar:(BOOL)showDetachedBar; + +// Moves to the given next state (from the current state), possibly animating. +// If |animate| is NO, it will stop any running animation and jump to the given +// state. If YES, it may either (depending on implementation) jump to the end of +// the current animation and begin the next one, or stop the current animation +// mid-flight and animate to the next state. +- (void)moveToVisualState:(bookmarks::VisualState)nextVisualState + withAnimation:(BOOL)animate; + +// Return the backdrop to the bookmark bar as various types. +- (BackgroundGradientView*)backgroundGradientView; +- (AnimatableView*)animatableView; + +// Create buttons for all items in the given bookmark node tree. +// Modifies self->buttons_. Do not add more buttons than will fit on the view. +- (void)addNodesToButtonList:(const BookmarkNode*)node; + +// Create an autoreleased button appropriate for insertion into the bookmark +// bar. Update |xOffset| with the offset appropriate for the subsequent button. +- (BookmarkButton*)buttonForNode:(const BookmarkNode*)node + xOffset:(int*)xOffset; + +// Puts stuff into the final visual state without animating, stopping a running +// animation if necessary. +- (void)finalizeVisualState; + +// Stops any current animation in its tracks (midway). +- (void)stopCurrentAnimation; + +// Show/hide the bookmark bar. +// if |animate| is YES, the changes are made using the animator; otherwise they +// are made immediately. +- (void)showBookmarkBarWithAnimation:(BOOL)animate; + +// Handles animating the resize of the content view. Returns YES if it handled +// the animation, NO if not (and hence it should be done instantly). +- (BOOL)doBookmarkBarAnimation; + +// |point| is in the base coordinate system of the destination window; +// it comes from an id<NSDraggingInfo>. |copy| is YES if a copy is to be +// made and inserted into the new location while leaving the bookmark in +// the old location, otherwise move the bookmark by removing from its old +// location and inserting into the new location. +- (BOOL)dragBookmark:(const BookmarkNode*)sourceNode + to:(NSPoint)point + copy:(BOOL)copy; + +// Returns the index in the model for a drag to the location given by +// |point|. This is determined by finding the first button before the center +// of which |point| falls, scanning left to right. Note that, currently, only +// the x-coordinate of |point| is considered. Though not currently implemented, +// we may check for errors, in which case this would return negative value; +// callers should check for this. +- (int)indexForDragToPoint:(NSPoint)point; + +// Add or remove buttons to/from the bar until it is filled but not overflowed. +- (void)redistributeButtonsOnBarAsNeeded; + +// Determine the nature of the bookmark bar contents based on the number of +// buttons showing. If too many then show the off-the-side list, if none +// then show the no items label. +- (void)reconfigureBookmarkBar; + +- (void)addNode:(const BookmarkNode*)child toMenu:(NSMenu*)menu; +- (void)addFolderNode:(const BookmarkNode*)node toMenu:(NSMenu*)menu; +- (void)tagEmptyMenu:(NSMenu*)menu; +- (void)clearMenuTagMap; +- (int)preferredHeight; +- (void)addNonBookmarkButtonsToView; +- (void)addButtonsToView; +- (void)centerNoItemsLabel; +- (void)setNodeForBarMenu; + +- (void)watchForExitEvent:(BOOL)watch; + +@end + +@implementation BookmarkBarController + +@synthesize visualState = visualState_; +@synthesize lastVisualState = lastVisualState_; +@synthesize delegate = delegate_; + +- (id)initWithBrowser:(Browser*)browser + initialWidth:(float)initialWidth + delegate:(id<BookmarkBarControllerDelegate>)delegate + resizeDelegate:(id<ViewResizer>)resizeDelegate { + if ((self = [super initWithNibName:@"BookmarkBar" + bundle:mac_util::MainAppBundle()])) { + // Initialize to an invalid state. + visualState_ = bookmarks::kInvalidState; + lastVisualState_ = bookmarks::kInvalidState; + + browser_ = browser; + initialWidth_ = initialWidth; + bookmarkModel_ = browser_->profile()->GetBookmarkModel(); + buttons_.reset([[NSMutableArray alloc] init]); + delegate_ = delegate; + resizeDelegate_ = resizeDelegate; + folderTarget_.reset([[BookmarkFolderTarget alloc] initWithController:self]); + + ResourceBundle& rb = ResourceBundle::GetSharedInstance(); + folderImage_.reset( + [rb.GetNativeImageNamed(IDR_BOOKMARK_BAR_FOLDER) retain]); + defaultImage_.reset([rb.GetNativeImageNamed(IDR_DEFAULT_FAVICON) retain]); + + // Register for theme changes, bookmark button pulsing, ... + NSNotificationCenter* defaultCenter = [NSNotificationCenter defaultCenter]; + [defaultCenter addObserver:self + selector:@selector(themeDidChangeNotification:) + name:kBrowserThemeDidChangeNotification + object:nil]; + [defaultCenter addObserver:self + selector:@selector(pulseBookmarkNotification:) + name:bookmark_button::kPulseBookmarkButtonNotification + object:nil]; + + // This call triggers an awakeFromNib, which builds the bar, which + // might uses folderImage_. So make sure it happens after + // folderImage_ is loaded. + [[self animatableView] setResizeDelegate:resizeDelegate]; + } + return self; +} + +- (void)pulseBookmarkNotification:(NSNotification*)notification { + NSDictionary* dict = [notification userInfo]; + const BookmarkNode* node = NULL; + NSValue *value = [dict objectForKey:bookmark_button::kBookmarkKey]; + DCHECK(value); + if (value) + node = static_cast<const BookmarkNode*>([value pointerValue]); + NSNumber* number = [dict + objectForKey:bookmark_button::kBookmarkPulseFlagKey]; + DCHECK(number); + BOOL doPulse = number ? [number boolValue] : NO; + + // 3 cases: + // button on the bar: flash it + // button in "other bookmarks" folder: flash other bookmarks + // button in "off the side" folder: flash the chevron + for (BookmarkButton* button in [self buttons]) { + if ([button bookmarkNode] == node) { + [button setIsContinuousPulsing:doPulse]; + return; + } + } + if ([otherBookmarksButton_ bookmarkNode] == node) { + [otherBookmarksButton_ setIsContinuousPulsing:doPulse]; + return; + } + if (node->GetParent() == bookmarkModel_->GetBookmarkBarNode()) { + [offTheSideButton_ setIsContinuousPulsing:doPulse]; + return; + } + + NOTREACHED() << "no bookmark button found to pulse!"; +} + +- (void)dealloc { + // We better stop any in-flight animation if we're being killed. + [[self animatableView] stopAnimation]; + + // Remove our view from its superview so it doesn't attempt to reference + // it when the controller is gone. + //TODO(dmaclach): Remove -- http://crbug.com/25845 + [[self view] removeFromSuperview]; + + // Be sure there is no dangling pointer. + if ([[self view] respondsToSelector:@selector(setController:)]) + [[self view] performSelector:@selector(setController:) withObject:nil]; + + // For safety, make sure the buttons can no longer call us. + for (BookmarkButton* button in buttons_.get()) { + [button setDelegate:nil]; + [button setTarget:nil]; + [button setAction:nil]; + } + + bridge_.reset(NULL); + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [self watchForExitEvent:NO]; + [super dealloc]; +} + +- (void)awakeFromNib { + // We default to NOT open, which means height=0. + DCHECK([[self view] isHidden]); // Hidden so it's OK to change. + + // Set our initial height to zero, since that is what the superview + // expects. We will resize ourselves open later if needed. + [[self view] setFrame:NSMakeRect(0, 0, initialWidth_, 0)]; + + // Complete init of the "off the side" button, as much as we can. + [offTheSideButton_ setDraggable:NO]; + + // We are enabled by default. + barIsEnabled_ = YES; + + // Remember the original sizes of the 'no items' and 'import bookmarks' + // fields to aid in resizing when the window frame changes. + originalNoItemsRect_ = [[buttonView_ noItemTextfield] frame]; + originalImportBookmarksRect_ = [[buttonView_ importBookmarksButton] frame]; + + // To make life happier when the bookmark bar is floating, the chevron is a + // child of the button view. + [offTheSideButton_ removeFromSuperview]; + [buttonView_ addSubview:offTheSideButton_]; + + // Copy the bar menu so we know if it's from the bar or a folder. + // Then we set its represented item to be the bookmark bar. + buttonFolderContextMenu_.reset([[[self view] menu] copy]); + + // When resized we may need to add new buttons, or remove them (if + // no longer visible), or add/remove the "off the side" menu. + [[self view] setPostsFrameChangedNotifications:YES]; + [[NSNotificationCenter defaultCenter] + addObserver:self + selector:@selector(frameDidChange) + name:NSViewFrameDidChangeNotification + object:[self view]]; + + // Watch for things going to or from fullscreen. + [[NSNotificationCenter defaultCenter] + addObserver:self + selector:@selector(willEnterOrLeaveFullscreen:) + name:kWillEnterFullscreenNotification + object:nil]; + [[NSNotificationCenter defaultCenter] + addObserver:self + selector:@selector(willEnterOrLeaveFullscreen:) + name:kWillLeaveFullscreenNotification + object:nil]; + + // Don't pass ourself along (as 'self') until our init is completely + // done. Thus, this call is (almost) last. + bridge_.reset(new BookmarkBarBridge(self, bookmarkModel_)); +} + +// Called by our main view (a BookmarkBarView) when it gets moved to a +// window. We perform operations which need to know the relevant +// window (e.g. watch for a window close) so they can't be performed +// earlier (such as in awakeFromNib). +- (void)viewDidMoveToWindow { + NSNotificationCenter* defaultCenter = [NSNotificationCenter defaultCenter]; + + // Remove any existing notifications before registering for new ones. + [defaultCenter removeObserver:self + name:NSWindowWillCloseNotification + object:nil]; + [defaultCenter removeObserver:self + name:NSWindowDidResignKeyNotification + object:nil]; + + [defaultCenter addObserver:self + selector:@selector(parentWindowWillClose:) + name:NSWindowWillCloseNotification + object:[[self view] window]]; + [defaultCenter addObserver:self + selector:@selector(parentWindowDidResignKey:) + name:NSWindowDidResignKeyNotification + object:[[self view] window]]; +} + +// When going fullscreen we can run into trouble. Our view is removed +// from the non-fullscreen window before the non-fullscreen window +// loses key, so our parentDidResignKey: callback never gets called. +// In addition, a bookmark folder controller needs to be autoreleased +// (in case it's in the event chain when closed), but the release +// implicitly needs to happen while it's connected to the original +// (non-fullscreen) window to "unlock bar visibility". Such a +// contract isn't honored when going fullscreen with the menu option +// (not with the keyboard shortcut). We fake it as best we can here. +// We have a similar problem leaving fullscreen. +- (void)willEnterOrLeaveFullscreen:(NSNotification*)notification { + if (folderController_) { + [self childFolderWillClose:folderController_]; + [self closeFolderAndStopTrackingMenus]; + } +} + +// NSNotificationCenter callback. +- (void)parentWindowWillClose:(NSNotification*)notification { + [self closeFolderAndStopTrackingMenus]; +} + +// NSNotificationCenter callback. +- (void)parentWindowDidResignKey:(NSNotification*)notification { + [self closeFolderAndStopTrackingMenus]; +} + +// Change the layout of the bookmark bar's subviews in response to a visibility +// change (e.g., show or hide the bar) or style change (attached or floating). +- (void)layoutSubviews { + NSRect frame = [[self view] frame]; + NSRect buttonViewFrame = NSMakeRect(0, 0, NSWidth(frame), NSHeight(frame)); + + // The state of our morph (if any); 1 is total bubble, 0 is the regular bar. + CGFloat morph = [self detachedMorphProgress]; + + // Add padding to the detached bookmark bar. + buttonViewFrame = NSInsetRect(buttonViewFrame, + morph * bookmarks::kNTPBookmarkBarPadding, + morph * bookmarks::kNTPBookmarkBarPadding); + + [buttonView_ setFrame:buttonViewFrame]; +} + +// We don't change a preference; we only change visibility. Preference changing +// (global state) is handled in |BrowserWindowCocoa::ToggleBookmarkBar()|. We +// simply update based on what we're told. +- (void)updateVisibility { + [self showBookmarkBarWithAnimation:NO]; +} + +- (void)setBookmarkBarEnabled:(BOOL)enabled { + if (enabled != barIsEnabled_) { + barIsEnabled_ = enabled; + [self updateVisibility]; + } +} + +- (CGFloat)getDesiredToolbarHeightCompression { + // Some special cases.... + if (!barIsEnabled_) + return 0; + + if ([self isAnimationRunning]) { + // No toolbar compression when animating between hidden and showing, nor + // between showing and detached. + if ([self isAnimatingBetweenState:bookmarks::kHiddenState + andState:bookmarks::kShowingState] || + [self isAnimatingBetweenState:bookmarks::kShowingState + andState:bookmarks::kDetachedState]) + return 0; + + // If we ever need any other animation cases, code would go here. + } + + return [self isInState:bookmarks::kShowingState] ? kBookmarkBarOverlap : 0; +} + +- (CGFloat)toolbarDividerOpacity { + // Some special cases.... + if ([self isAnimationRunning]) { + // In general, the toolbar shouldn't show a divider while we're animating + // between showing and hidden. The exception is when our height is < 1, in + // which case we can't draw it. It's all-or-nothing (no partial opacity). + if ([self isAnimatingBetweenState:bookmarks::kHiddenState + andState:bookmarks::kShowingState]) + return (NSHeight([[self view] frame]) < 1) ? 1 : 0; + + // The toolbar should show the divider when animating between showing and + // detached (but opacity will vary). + if ([self isAnimatingBetweenState:bookmarks::kShowingState + andState:bookmarks::kDetachedState]) + return static_cast<CGFloat>([self detachedMorphProgress]); + + // If we ever need any other animation cases, code would go here. + } + + // In general, only show the divider when it's in the normal showing state. + return [self isInState:bookmarks::kShowingState] ? 0 : 1; +} + +- (NSImage*)favIconForNode:(const BookmarkNode*)node { + if (!node) + return defaultImage_; + + if (node->is_folder()) + return folderImage_; + + const SkBitmap& favIcon = bookmarkModel_->GetFavIcon(node); + if (!favIcon.isNull()) + return gfx::SkBitmapToNSImage(favIcon); + + return defaultImage_; +} + +- (void)closeFolderAndStopTrackingMenus { + showFolderMenus_ = NO; + [self closeAllBookmarkFolders]; +} + +- (BOOL)canEditBookmark:(const BookmarkNode*)node { + // Don't allow edit/delete of the bar node, or of "Other Bookmarks" + if ((node == nil) || + (node == bookmarkModel_->other_node()) || + (node == bookmarkModel_->GetBookmarkBarNode())) + return NO; + return YES; +} + +#pragma mark Actions + +- (IBAction)openBookmark:(id)sender { + [self closeFolderAndStopTrackingMenus]; + DCHECK([sender respondsToSelector:@selector(bookmarkNode)]); + const BookmarkNode* node = [sender bookmarkNode]; + WindowOpenDisposition disposition = + event_utils::WindowOpenDispositionFromNSEvent([NSApp currentEvent]); + [self openURL:node->GetURL() disposition:disposition]; +} + +// Redirect to our logic shared with BookmarkBarFolderController. +- (IBAction)openBookmarkFolderFromButton:(id)sender { + if (sender != offTheSideButton_) { + // Toggle presentation of bar folder menus. + showFolderMenus_ = !showFolderMenus_; + [folderTarget_ openBookmarkFolderFromButton:sender]; + } else { + // Off-the-side requires special handling. + [self openOffTheSideFolderFromButton:sender]; + } +} + +// The button that sends this one is special; the "off the side" +// button (chevron) opens like a folder button but isn't exactly a +// parent folder. +- (IBAction)openOffTheSideFolderFromButton:(id)sender { + DCHECK([sender isKindOfClass:[BookmarkButton class]]); + DCHECK([[sender cell] isKindOfClass:[BookmarkButtonCell class]]); + [[sender cell] setStartingChildIndex:displayedButtonCount_]; + [folderTarget_ openBookmarkFolderFromButton:sender]; +} + +- (IBAction)openBookmarkInNewForegroundTab:(id)sender { + const BookmarkNode* node = [self nodeFromMenuItem:sender]; + if (node) + [self openURL:node->GetURL() disposition:NEW_FOREGROUND_TAB]; + [self closeAllBookmarkFolders]; +} + +- (IBAction)openBookmarkInNewWindow:(id)sender { + const BookmarkNode* node = [self nodeFromMenuItem:sender]; + if (node) + [self openURL:node->GetURL() disposition:NEW_WINDOW]; +} + +- (IBAction)openBookmarkInIncognitoWindow:(id)sender { + const BookmarkNode* node = [self nodeFromMenuItem:sender]; + if (node) + [self openURL:node->GetURL() disposition:OFF_THE_RECORD]; +} + +- (IBAction)editBookmark:(id)sender { + const BookmarkNode* node = [self nodeFromMenuItem:sender]; + if (!node) + return; + + if (node->is_folder()) { + BookmarkNameFolderController* controller = + [[BookmarkNameFolderController alloc] + initWithParentWindow:[[self view] window] + profile:browser_->profile() + node:node]; + [controller runAsModalSheet]; + return; + } + + // There is no real need to jump to a platform-common routine at + // this point (which just jumps back to objc) other than consistency + // across platforms. + // + // TODO(jrg): identify when we NO_TREE. I can see it in the code + // for the other platforms but can't find a way to trigger it in the + // UI. + BookmarkEditor::Show([[self view] window], + browser_->profile(), + node->GetParent(), + BookmarkEditor::EditDetails(node), + BookmarkEditor::SHOW_TREE); +} + +- (IBAction)cutBookmark:(id)sender { + const BookmarkNode* node = [self nodeFromMenuItem:sender]; + if (node) { + std::vector<const BookmarkNode*> nodes; + nodes.push_back(node); + bookmark_utils::CopyToClipboard(bookmarkModel_, nodes, true); + } +} + +- (IBAction)copyBookmark:(id)sender { + const BookmarkNode* node = [self nodeFromMenuItem:sender]; + if (node) { + std::vector<const BookmarkNode*> nodes; + nodes.push_back(node); + bookmark_utils::CopyToClipboard(bookmarkModel_, nodes, false); + } +} + +// Paste the copied node immediately after the node for which the context +// menu has been presented if the node is a non-folder bookmark, otherwise +// past at the end of the folder node. +- (IBAction)pasteBookmark:(id)sender { + const BookmarkNode* node = [self nodeFromMenuItem:sender]; + if (node) { + int index = -1; + if (node != bookmarkModel_->GetBookmarkBarNode() && !node->is_folder()) { + const BookmarkNode* parent = node->GetParent(); + index = parent->IndexOfChild(node) + 1; + if (index > parent->GetChildCount()) + index = -1; + node = parent; + } + bookmark_utils::PasteFromClipboard(bookmarkModel_, node, index); + } +} + +- (IBAction)deleteBookmark:(id)sender { + const BookmarkNode* node = [self nodeFromMenuItem:sender]; + if (node) { + bookmarkModel_->Remove(node->GetParent(), + node->GetParent()->IndexOfChild(node)); + } +} + +- (IBAction)openAllBookmarks:(id)sender { + const BookmarkNode* node = [self nodeFromMenuItem:sender]; + if (node) { + [self openAll:node disposition:NEW_FOREGROUND_TAB]; + UserMetrics::RecordAction(UserMetricsAction("OpenAllBookmarks"), + browser_->profile()); + } +} + +- (IBAction)openAllBookmarksNewWindow:(id)sender { + const BookmarkNode* node = [self nodeFromMenuItem:sender]; + if (node) { + [self openAll:node disposition:NEW_WINDOW]; + UserMetrics::RecordAction(UserMetricsAction("OpenAllBookmarksNewWindow"), + browser_->profile()); + } +} + +- (IBAction)openAllBookmarksIncognitoWindow:(id)sender { + const BookmarkNode* node = [self nodeFromMenuItem:sender]; + if (node) { + [self openAll:node disposition:OFF_THE_RECORD]; + UserMetrics::RecordAction( + UserMetricsAction("OpenAllBookmarksIncognitoWindow"), + browser_->profile()); + } +} + +// May be called from the bar or from a folder button. +// If called from a button, that button becomes the parent. +- (IBAction)addPage:(id)sender { + const BookmarkNode* parent = [self nodeFromMenuItem:sender]; + if (!parent) + parent = bookmarkModel_->GetBookmarkBarNode(); + BookmarkEditor::Show([[self view] window], + browser_->profile(), + parent, + BookmarkEditor::EditDetails(), + BookmarkEditor::SHOW_TREE); +} + +// Might be called from the context menu over the bar OR over a +// button. If called from a button, that button becomes a sibling of +// the new node. If called from the bar, add to the end of the bar. +- (IBAction)addFolder:(id)sender { + const BookmarkNode* senderNode = [self nodeFromMenuItem:sender]; + const BookmarkNode* parent = NULL; + int newIndex = 0; + // If triggered from the bar, folder or "others" folder - add as a child to + // the end. + // If triggered from a bookmark, add as next sibling. + BookmarkNode::Type type = senderNode->type(); + if (type == BookmarkNode::BOOKMARK_BAR || + type == BookmarkNode::OTHER_NODE || + type == BookmarkNode::FOLDER) { + parent = senderNode; + newIndex = parent->GetChildCount(); + } else { + parent = senderNode->GetParent(); + newIndex = parent->IndexOfChild(senderNode) + 1; + } + BookmarkNameFolderController* controller = + [[BookmarkNameFolderController alloc] + initWithParentWindow:[[self view] window] + profile:browser_->profile() + parent:parent + newIndex:newIndex]; + [controller runAsModalSheet]; +} + +- (IBAction)importBookmarks:(id)sender { + [ImportSettingsDialogController showImportSettingsDialogForProfile: + browser_->profile()]; +} + +#pragma mark Private Methods + +// Called after the current theme has changed. +- (void)themeDidChangeNotification:(NSNotification*)aNotification { + ThemeProvider* themeProvider = + static_cast<ThemeProvider*>([[aNotification object] pointerValue]); + [self updateTheme:themeProvider]; +} + +// (Private) Method is the same as [self view], but is provided to be explicit. +- (BackgroundGradientView*)backgroundGradientView { + DCHECK([[self view] isKindOfClass:[BackgroundGradientView class]]); + return (BackgroundGradientView*)[self view]; +} + +// (Private) Method is the same as [self view], but is provided to be explicit. +- (AnimatableView*)animatableView { + DCHECK([[self view] isKindOfClass:[AnimatableView class]]); + return (AnimatableView*)[self view]; +} + +// Position the off-the-side chevron to the left of the otherBookmarks button. +- (void)positionOffTheSideButton { + NSRect frame = [offTheSideButton_ frame]; + if (otherBookmarksButton_.get()) { + frame.origin.x = ([otherBookmarksButton_ frame].origin.x - + (frame.size.width + + bookmarks::kBookmarkHorizontalPadding)); + [offTheSideButton_ setFrame:frame]; + } +} + +// Configure the off-the-side button (e.g. specify the node range, +// check if we should enable or disable it, etc). +- (void)configureOffTheSideButtonContentsAndVisibility { + // If deleting a button while off-the-side is open, buttons may be + // promoted from off-the-side to the bar. Accomodate. + if (folderController_ && + ([folderController_ parentButton] == offTheSideButton_)) { + [folderController_ reconfigureMenu]; + } + + [[offTheSideButton_ cell] setStartingChildIndex:displayedButtonCount_]; + [[offTheSideButton_ cell] + setBookmarkNode:bookmarkModel_->GetBookmarkBarNode()]; + int bookmarkChildren = bookmarkModel_->GetBookmarkBarNode()->GetChildCount(); + if (bookmarkChildren > displayedButtonCount_) { + [offTheSideButton_ setHidden:NO]; + } else { + // If we just deleted the last item in an off-the-side menu so the + // button will be going away, make sure the menu goes away. + if (folderController_ && + ([folderController_ parentButton] == offTheSideButton_)) + [self closeAllBookmarkFolders]; + // (And hide the button, too.) + [offTheSideButton_ setHidden:YES]; + } +} + +// Begin (or end) watching for a click outside this window. Unlike +// normal NSWindows, bookmark folder "fake menu" windows do not become +// key or main. Thus, traditional notification (e.g. WillResignKey) +// won't work. Our strategy is to watch (at the app level) for a +// "click outside" these windows to detect when they logically lose +// focus. +- (void)watchForExitEvent:(BOOL)watch { + CrApplication* app = static_cast<CrApplication*>([NSApplication + sharedApplication]); + DCHECK([app isKindOfClass:[CrApplication class]]); + if (watch) { + if (!watchingForExitEvent_) + [app addEventHook:self]; + } else { + if (watchingForExitEvent_) + [app removeEventHook:self]; + } + watchingForExitEvent_ = watch; +} + +// Keep the "no items" label centered in response to a frame size change. +- (void)centerNoItemsLabel { + // Note that this computation is done in the parent's coordinate system, + // which is unflipped. Also, we want the label to be a fixed distance from + // the bottom, so that it slides up properly (on animating to hidden). + // The textfield sits in the itemcontainer, so to center it we maintain + // equal vertical padding on the top and bottom. + int yoffset = (NSHeight([[buttonView_ noItemTextfield] frame]) - + NSHeight([[buttonView_ noItemContainer] frame])) / 2; + [[buttonView_ noItemContainer] setFrameOrigin:NSMakePoint(0, yoffset)]; +} + +// (Private) +- (void)showBookmarkBarWithAnimation:(BOOL)animate { + if (animate && !ignoreAnimations_) { + // If |-doBookmarkBarAnimation| does the animation, we're done. + if ([self doBookmarkBarAnimation]) + return; + + // Else fall through and do the change instantly. + } + + // Set our height. + [resizeDelegate_ resizeView:[self view] + newHeight:[self preferredHeight]]; + + // Only show the divider if showing the normal bookmark bar. + BOOL showsDivider = [self isInState:bookmarks::kShowingState]; + [[self backgroundGradientView] setShowsDivider:showsDivider]; + + // Make sure we're shown. + [[self view] setHidden:![self isVisible]]; + + // Update everything else. + [self layoutSubviews]; + [self frameDidChange]; +} + +// (Private) +- (BOOL)doBookmarkBarAnimation { + if ([self isAnimatingFromState:bookmarks::kHiddenState + toState:bookmarks::kShowingState]) { + [[self backgroundGradientView] setShowsDivider:YES]; + [[self view] setHidden:NO]; + AnimatableView* view = [self animatableView]; + // Height takes into account the extra height we have since the toolbar + // only compresses when we're done. + [view animateToNewHeight:(bookmarks::kBookmarkBarHeight - + kBookmarkBarOverlap) + duration:kBookmarkBarAnimationDuration]; + } else if ([self isAnimatingFromState:bookmarks::kShowingState + toState:bookmarks::kHiddenState]) { + [[self backgroundGradientView] setShowsDivider:YES]; + [[self view] setHidden:NO]; + AnimatableView* view = [self animatableView]; + [view animateToNewHeight:0 + duration:kBookmarkBarAnimationDuration]; + } else if ([self isAnimatingFromState:bookmarks::kShowingState + toState:bookmarks::kDetachedState]) { + [[self backgroundGradientView] setShowsDivider:YES]; + [[self view] setHidden:NO]; + AnimatableView* view = [self animatableView]; + [view animateToNewHeight:bookmarks::kNTPBookmarkBarHeight + duration:kBookmarkBarAnimationDuration]; + } else if ([self isAnimatingFromState:bookmarks::kDetachedState + toState:bookmarks::kShowingState]) { + [[self backgroundGradientView] setShowsDivider:YES]; + [[self view] setHidden:NO]; + AnimatableView* view = [self animatableView]; + // Height takes into account the extra height we have since the toolbar + // only compresses when we're done. + [view animateToNewHeight:(bookmarks::kBookmarkBarHeight - + kBookmarkBarOverlap) + duration:kBookmarkBarAnimationDuration]; + } else { + // Oops! An animation we don't know how to handle. + return NO; + } + + return YES; +} + +// Enable or disable items. We are the menu delegate for both the bar +// and for bookmark folder buttons. +- (BOOL)validateUserInterfaceItem:(id<NSValidatedUserInterfaceItem>)anItem { + // NSUserInterfaceValidations says that the passed-in object has type + // |id<NSValidatedUserInterfaceItem>|, but this function needs to call the + // NSObject method -isKindOfClass: on the parameter. In theory, this is not + // correct, but this is probably a bug in the method signature. + NSMenuItem* item = static_cast<NSMenuItem*>(anItem); + // Yes for everything we don't explicitly deny. + if (![item isKindOfClass:[NSMenuItem class]]) + return YES; + + // Yes if we're not a special BookmarkMenu. + if (![[item menu] isKindOfClass:[BookmarkMenu class]]) + return YES; + + // No if we think it's a special BookmarkMenu but have trouble. + const BookmarkNode* node = [self nodeFromMenuItem:item]; + if (!node) + return NO; + + // If this is the bar menu, we only have things to do if there are + // buttons. If this is a folder button menu, we only have things to + // do if the folder has items. + NSMenu* menu = [item menu]; + BOOL thingsToDo = NO; + if (menu == [[self view] menu]) { + thingsToDo = [buttons_ count] ? YES : NO; + } else { + if (node && node->is_folder() && node->GetChildCount()) { + thingsToDo = YES; + } + } + + // Disable openAll* if we have nothing to do. + SEL action = [item action]; + if ((!thingsToDo) && + ((action == @selector(openAllBookmarks:)) || + (action == @selector(openAllBookmarksNewWindow:)) || + (action == @selector(openAllBookmarksIncognitoWindow:)))) { + return NO; + } + + if ((action == @selector(editBookmark:)) || + (action == @selector(deleteBookmark:)) || + (action == @selector(cutBookmark:)) || + (action == @selector(copyBookmark:))) { + if (![self canEditBookmark:node]) { + return NO; + } + } + + if (action == @selector(pasteBookmark:) && + !bookmark_utils::CanPasteFromClipboard(node)) + return NO; + + // If this is an incognito window, don't allow "open in incognito". + if ((action == @selector(openBookmarkInIncognitoWindow:)) || + (action == @selector(openAllBookmarksIncognitoWindow:))) { + if (browser_->profile()->IsOffTheRecord()) { + return NO; + } + } + + // Enabled by default. + return YES; +} + +// Actually open the URL. This is the last chance for a unit test to +// override. +- (void)openURL:(GURL)url disposition:(WindowOpenDisposition)disposition { + browser_->OpenURL(url, GURL(), disposition, PageTransition::AUTO_BOOKMARK); +} + +- (void)clearMenuTagMap { + seedId_ = 0; + menuTagMap_.clear(); +} + +- (int)preferredHeight { + DCHECK(![self isAnimationRunning]); + + if (!barIsEnabled_) + return 0; + + switch (visualState_) { + case bookmarks::kShowingState: + return bookmarks::kBookmarkBarHeight; + case bookmarks::kDetachedState: + return bookmarks::kNTPBookmarkBarHeight; + case bookmarks::kHiddenState: + return 0; + case bookmarks::kInvalidState: + default: + NOTREACHED(); + return 0; + } +} + +// Recursively add the given bookmark node and all its children to +// menu, one menu item per node. +- (void)addNode:(const BookmarkNode*)child toMenu:(NSMenu*)menu { + NSString* title = [BookmarkMenuCocoaController menuTitleForNode:child]; + NSMenuItem* item = [[[NSMenuItem alloc] initWithTitle:title + action:nil + keyEquivalent:@""] autorelease]; + [menu addItem:item]; + [item setImage:[self favIconForNode:child]]; + if (child->is_folder()) { + NSMenu* submenu = [[[NSMenu alloc] initWithTitle:title] autorelease]; + [menu setSubmenu:submenu forItem:item]; + if (child->GetChildCount()) { + [self addFolderNode:child toMenu:submenu]; // potentially recursive + } else { + [self tagEmptyMenu:submenu]; + } + } else { + [item setTarget:self]; + [item setAction:@selector(openBookmarkMenuItem:)]; + [item setTag:[self menuTagFromNodeId:child->id()]]; + // Add a tooltip + std::string url_string = child->GetURL().possibly_invalid_spec(); + NSString* tooltip = [NSString stringWithFormat:@"%@\n%s", + base::SysUTF16ToNSString(child->GetTitle()), + url_string.c_str()]; + [item setToolTip:tooltip]; + } +} + +// Empty menus are odd; if empty, add something to look at. +// Matches windows behavior. +- (void)tagEmptyMenu:(NSMenu*)menu { + NSString* empty_menu_title = l10n_util::GetNSString(IDS_MENU_EMPTY_SUBMENU); + [menu addItem:[[[NSMenuItem alloc] initWithTitle:empty_menu_title + action:NULL + keyEquivalent:@""] autorelease]]; +} + +// Add the children of the given bookmark node (and their children...) +// to menu, one menu item per node. +- (void)addFolderNode:(const BookmarkNode*)node toMenu:(NSMenu*)menu { + for (int i = 0; i < node->GetChildCount(); i++) { + const BookmarkNode* child = node->GetChild(i); + [self addNode:child toMenu:menu]; + } +} + +// Return an autoreleased NSMenu that represents the given bookmark +// folder node. +- (NSMenu *)menuForFolderNode:(const BookmarkNode*)node { + if (!node->is_folder()) + return nil; + NSString* title = base::SysUTF16ToNSString(node->GetTitle()); + NSMenu* menu = [[[NSMenu alloc] initWithTitle:title] autorelease]; + [self addFolderNode:node toMenu:menu]; + + if (![menu numberOfItems]) { + [self tagEmptyMenu:menu]; + } + return menu; +} + +// Return an appropriate width for the given bookmark button cell. +// The "+2" is needed because, sometimes, Cocoa is off by a tad. +// Example: for a bookmark named "Moma" or "SFGate", it is one pixel +// too small. For "FBL" it is 2 pixels too small. +// For a bookmark named "SFGateFooWoo", it is just fine. +- (CGFloat)widthForBookmarkButtonCell:(NSCell*)cell { + CGFloat desired = [cell cellSize].width + 2; + return std::min(desired, bookmarks::kDefaultBookmarkWidth); +} + +- (IBAction)openBookmarkMenuItem:(id)sender { + int64 tag = [self nodeIdFromMenuTag:[sender tag]]; + const BookmarkNode* node = bookmarkModel_->GetNodeByID(tag); + WindowOpenDisposition disposition = + event_utils::WindowOpenDispositionFromNSEvent([NSApp currentEvent]); + [self openURL:node->GetURL() disposition:disposition]; +} + +// For the given root node of the bookmark bar, show or hide (as +// appropriate) the "no items" container (text which says "bookmarks +// go here"). +- (void)showOrHideNoItemContainerForNode:(const BookmarkNode*)node { + BOOL hideNoItemWarning = node->GetChildCount() > 0; + [[buttonView_ noItemContainer] setHidden:hideNoItemWarning]; +} + +// TODO(jrg): write a "build bar" so there is a nice spot for things +// like the contextual menu which is invoked when not over a +// bookmark. On Safari that menu has a "new folder" option. +- (void)addNodesToButtonList:(const BookmarkNode*)node { + [self showOrHideNoItemContainerForNode:node]; + + CGFloat maxViewX = NSMaxX([[self view] bounds]); + int xOffset = 0; + for (int i = 0; i < node->GetChildCount(); i++) { + const BookmarkNode* child = node->GetChild(i); + BookmarkButton* button = [self buttonForNode:child xOffset:&xOffset]; + if (NSMinX([button frame]) >= maxViewX) + break; + [buttons_ addObject:button]; + } +} + +- (BookmarkButton*)buttonForNode:(const BookmarkNode*)node + xOffset:(int*)xOffset { + BookmarkButtonCell* cell = [self cellForBookmarkNode:node]; + NSRect frame = [self frameForBookmarkButtonFromCell:cell xOffset:xOffset]; + + scoped_nsobject<BookmarkButton> + button([[BookmarkButton alloc] initWithFrame:frame]); + DCHECK(button.get()); + + // [NSButton setCell:] warns to NOT use setCell: other than in the + // initializer of a control. However, we are using a basic + // NSButton whose initializer does not take an NSCell as an + // object. To honor the assumed semantics, we do nothing with + // NSButton between alloc/init and setCell:. + [button setCell:cell]; + [button setDelegate:self]; + + // We cannot set the button cell's text color until it is placed in + // the button (e.g. the [button setCell:cell] call right above). We + // also cannot set the cell's text color until the view is added to + // the hierarchy. If that second part is now true, set the color. + // (If not we'll set the color on the 1st themeChanged: + // notification.) + ThemeProvider* themeProvider = [[[self view] window] themeProvider]; + if (themeProvider) { + NSColor* color = + themeProvider->GetNSColor(BrowserThemeProvider::COLOR_BOOKMARK_TEXT, + true); + [cell setTextColor:color]; + } + + if (node->is_folder()) { + [button setTarget:self]; + [button setAction:@selector(openBookmarkFolderFromButton:)]; + } else { + // Make the button do something + [button setTarget:self]; + [button setAction:@selector(openBookmark:)]; + // Add a tooltip. + NSString* title = base::SysUTF16ToNSString(node->GetTitle()); + std::string url_string = node->GetURL().possibly_invalid_spec(); + NSString* tooltip = [NSString stringWithFormat:@"%@\n%s", title, + url_string.c_str()]; + [button setToolTip:tooltip]; + } + return [[button.get() retain] autorelease]; +} + +// Add non-bookmark buttons to the view. This includes the chevron +// and the "other bookmarks" button. Technically "other bookmarks" is +// a bookmark button but it is treated specially. Only needs to be +// called when these buttons are new or when the bookmark bar is +// cleared (e.g. on a loaded: call). Unlike addButtonsToView below, +// we don't need to add/remove these dynamically in response to window +// resize. +- (void)addNonBookmarkButtonsToView { + [buttonView_ addSubview:otherBookmarksButton_.get()]; + [buttonView_ addSubview:offTheSideButton_]; +} + +// Add bookmark buttons to the view only if they are completely +// visible and don't overlap the "other bookmarks". Remove buttons +// which are clipped. Called when building the bookmark bar the first time. +- (void)addButtonsToView { + displayedButtonCount_ = 0; + NSMutableArray* buttons = [self buttons]; + for (NSButton* button in buttons) { + if (NSMaxX([button frame]) > (NSMinX([offTheSideButton_ frame]) - + bookmarks::kBookmarkHorizontalPadding)) + break; + [buttonView_ addSubview:button]; + ++displayedButtonCount_; + } + NSUInteger removalCount = + [buttons count] - (NSUInteger)displayedButtonCount_; + if (removalCount > 0) { + NSRange removalRange = NSMakeRange(displayedButtonCount_, removalCount); + [buttons removeObjectsInRange:removalRange]; + } +} + +// Create the button for "Other Bookmarks" on the right of the bar. +- (void)createOtherBookmarksButton { + // Can't create this until the model is loaded, but only need to + // create it once. + if (otherBookmarksButton_.get()) + return; + + // TODO(jrg): remove duplicate code + NSCell* cell = [self cellForBookmarkNode:bookmarkModel_->other_node()]; + int ignored = 0; + NSRect frame = [self frameForBookmarkButtonFromCell:cell xOffset:&ignored]; + frame.origin.x = [[self buttonView] bounds].size.width - frame.size.width; + frame.origin.x -= bookmarks::kBookmarkHorizontalPadding; + BookmarkButton* button = [[BookmarkButton alloc] initWithFrame:frame]; + [button setDraggable:NO]; + otherBookmarksButton_.reset(button); + view_id_util::SetID(button, VIEW_ID_OTHER_BOOKMARKS); + + // Make sure this button, like all other BookmarkButtons, lives + // until the end of the current event loop. + [[button retain] autorelease]; + + // Peg at right; keep same height as bar. + [button setAutoresizingMask:(NSViewMinXMargin)]; + [button setCell:cell]; + [button setDelegate:self]; + [button setTarget:self]; + [button setAction:@selector(openBookmarkFolderFromButton:)]; + [buttonView_ addSubview:button]; + + // Now that it's here, move the chevron over. + [self positionOffTheSideButton]; +} + +// Now that the model is loaded, set the bookmark bar root as the node +// represented by the bookmark bar (default, background) menu. +- (void)setNodeForBarMenu { + const BookmarkNode* node = bookmarkModel_->GetBookmarkBarNode(); + BookmarkMenu* menu = static_cast<BookmarkMenu*>([[self view] menu]); + + // Make sure types are compatible + DCHECK(sizeof(long long) == sizeof(int64)); + [menu setRepresentedObject:[NSNumber numberWithLongLong:node->id()]]; +} + +// To avoid problems with sync, changes that may impact the current +// bookmark (e.g. deletion) make sure context menus are closed. This +// prevents deleting a node which no longer exists. +- (void)cancelMenuTracking { + [buttonContextMenu_ cancelTracking]; + [buttonFolderContextMenu_ cancelTracking]; +} + +// Determines the appropriate state for the given situation. ++ (bookmarks::VisualState)visualStateToShowNormalBar:(BOOL)showNormalBar + showDetachedBar:(BOOL)showDetachedBar { + if (showNormalBar) + return bookmarks::kShowingState; + if (showDetachedBar) + return bookmarks::kDetachedState; + return bookmarks::kHiddenState; +} + +- (void)moveToVisualState:(bookmarks::VisualState)nextVisualState + withAnimation:(BOOL)animate { + BOOL isAnimationRunning = [self isAnimationRunning]; + + // No-op if the next state is the same as the "current" one, subject to the + // following conditions: + // - no animation is running; or + // - an animation is running and |animate| is YES ([*] if it's NO, we'd want + // to cancel the animation and jump to the final state). + if ((nextVisualState == visualState_) && (!isAnimationRunning || animate)) + return; + + // If an animation is running, we want to finalize it. Otherwise we'd have to + // be able to animate starting from the middle of one type of animation. We + // assume that animations that we know about can be "reversed". + if (isAnimationRunning) { + // Don't cancel if we're going to reverse the animation. + if (nextVisualState != lastVisualState_) { + [self stopCurrentAnimation]; + [self finalizeVisualState]; + } + + // If we're in case [*] above, we can stop here. + if (nextVisualState == visualState_) + return; + } + + // Now update with the new state change. + lastVisualState_ = visualState_; + visualState_ = nextVisualState; + + // Animate only if told to and if bar is enabled. + if (animate && !ignoreAnimations_ && barIsEnabled_) { + [self closeAllBookmarkFolders]; + // Take care of any animation cases we know how to handle. + + // We know how to handle hidden <-> normal, normal <-> detached.... + if ([self isAnimatingBetweenState:bookmarks::kHiddenState + andState:bookmarks::kShowingState] || + [self isAnimatingBetweenState:bookmarks::kShowingState + andState:bookmarks::kDetachedState]) { + [delegate_ bookmarkBar:self willAnimateFromState:lastVisualState_ + toState:visualState_]; + [self showBookmarkBarWithAnimation:YES]; + return; + } + + // If we ever need any other animation cases, code would go here. + // Let any animation cases which we don't know how to handle fall through to + // the unanimated case. + } + + // Just jump to the state. + [self finalizeVisualState]; +} + +// N.B.: |-moveToVisualState:...| will check if this should be a no-op or not. +- (void)updateAndShowNormalBar:(BOOL)showNormalBar + showDetachedBar:(BOOL)showDetachedBar + withAnimation:(BOOL)animate { + bookmarks::VisualState newVisualState = + [BookmarkBarController visualStateToShowNormalBar:showNormalBar + showDetachedBar:showDetachedBar]; + [self moveToVisualState:newVisualState + withAnimation:animate && !ignoreAnimations_]; +} + +// (Private) +- (void)finalizeVisualState { + // We promise that our delegate that the variables will be finalized before + // the call to |-bookmarkBar:didChangeFromState:toState:|. + bookmarks::VisualState oldVisualState = lastVisualState_; + lastVisualState_ = bookmarks::kInvalidState; + + // Notify our delegate. + [delegate_ bookmarkBar:self didChangeFromState:oldVisualState + toState:visualState_]; + + // Update ourselves visually. + [self updateVisibility]; +} + +// (Private) +- (void)stopCurrentAnimation { + [[self animatableView] stopAnimation]; +} + +// Delegate method for |AnimatableView| (a superclass of +// |BookmarkBarToolbarView|). +- (void)animationDidEnd:(NSAnimation*)animation { + [self finalizeVisualState]; +} + +- (void)reconfigureBookmarkBar { + [self redistributeButtonsOnBarAsNeeded]; + [self positionOffTheSideButton]; + [self configureOffTheSideButtonContentsAndVisibility]; + [self centerNoItemsLabel]; +} + +// Determine if the given |view| can completely fit within the constraint of +// maximum x, given by |maxViewX|, and, if not, narrow the view up to a minimum +// width. If the minimum width is not achievable then hide the view. Return YES +// if the view was hidden. +- (BOOL)shrinkOrHideView:(NSView*)view forMaxX:(CGFloat)maxViewX { + BOOL wasHidden = NO; + // See if the view needs to be narrowed. + NSRect frame = [view frame]; + if (NSMaxX(frame) > maxViewX) { + // Resize if more than 30 pixels are showing, otherwise hide. + if (NSMinX(frame) + 30.0 < maxViewX) { + frame.size.width = maxViewX - NSMinX(frame); + [view setFrame:frame]; + } else { + [view setHidden:YES]; + wasHidden = YES; + } + } + return wasHidden; +} + +// Adjust the horizontal width and the visibility of the "For quick access" +// text field and "Import bookmarks..." button based on the current width +// of the containing |buttonView_| (which is affected by window width). +- (void)adjustNoItemContainerWidthsForMaxX:(CGFloat)maxViewX { + if (![[buttonView_ noItemContainer] isHidden]) { + // Reset initial frames for the two items, then adjust as necessary. + NSTextField* noItemTextfield = [buttonView_ noItemTextfield]; + [noItemTextfield setFrame:originalNoItemsRect_]; + [noItemTextfield setHidden:NO]; + NSButton* importBookmarksButton = [buttonView_ importBookmarksButton]; + [importBookmarksButton setFrame:originalImportBookmarksRect_]; + [importBookmarksButton setHidden:NO]; + // Check each to see if they need to be shrunk or hidden. + if ([self shrinkOrHideView:importBookmarksButton forMaxX:maxViewX]) + [self shrinkOrHideView:noItemTextfield forMaxX:maxViewX]; + } +} + +- (void)redistributeButtonsOnBarAsNeeded { + const BookmarkNode* node = bookmarkModel_->GetBookmarkBarNode(); + NSInteger barCount = node->GetChildCount(); + + // Determine the current maximum extent of the visible buttons. + CGFloat maxViewX = NSMaxX([[self view] bounds]); + NSButton* otherBookmarksButton = otherBookmarksButton_.get(); + // If necessary, pull in the width to account for the Other Bookmarks button. + if (otherBookmarksButton_) + maxViewX = [otherBookmarksButton frame].origin.x - + bookmarks::kBookmarkHorizontalPadding; + // If we're already overflowing, then we need to account for the chevron. + if (barCount > displayedButtonCount_) + maxViewX = [offTheSideButton_ frame].origin.x - + bookmarks::kBookmarkHorizontalPadding; + + // As a result of pasting or dragging, the bar may now have more buttons + // than will fit so remove any which overflow. They will be shown in + // the off-the-side folder. + while (displayedButtonCount_ > 0) { + BookmarkButton* button = [buttons_ lastObject]; + if (NSMaxX([button frame]) < maxViewX) + break; + [buttons_ removeLastObject]; + [button setDelegate:nil]; + [button removeFromSuperview]; + --displayedButtonCount_; + } + + // As a result of cutting, deleting and dragging, the bar may now have room + // for more buttons. + int xOffset = displayedButtonCount_ > 0 ? + NSMaxX([[buttons_ lastObject] frame]) + + bookmarks::kBookmarkHorizontalPadding : 0; + for (int i = displayedButtonCount_; i < barCount; ++i) { + const BookmarkNode* child = node->GetChild(i); + BookmarkButton* button = [self buttonForNode:child xOffset:&xOffset]; + // If we're testing against the last possible button then account + // for the chevron no longer needing to be shown. + if (i == barCount + 1) + maxViewX += NSWidth([offTheSideButton_ frame]) + + bookmarks::kBookmarkHorizontalPadding; + if (NSMaxX([button frame]) >= maxViewX) + break; + ++displayedButtonCount_; + [buttons_ addObject:button]; + [buttonView_ addSubview:button]; + } + + // While we're here, adjust the horizontal width and the visibility + // of the "For quick access" and "Import bookmarks..." text fields. + if (![buttons_ count]) + [self adjustNoItemContainerWidthsForMaxX:maxViewX]; +} + +#pragma mark Private Methods Exposed for Testing + +- (BookmarkBarView*)buttonView { + return buttonView_; +} + +- (NSMutableArray*)buttons { + return buttons_.get(); +} + +- (NSButton*)offTheSideButton { + return offTheSideButton_; +} + +- (BOOL)offTheSideButtonIsHidden { + return [offTheSideButton_ isHidden]; +} + +- (BookmarkButton*)otherBookmarksButton { + return otherBookmarksButton_.get(); +} + +- (BookmarkBarFolderController*)folderController { + return folderController_; +} + +- (id)folderTarget { + return folderTarget_.get(); +} + +- (int)displayedButtonCount { + return displayedButtonCount_; +} + +// Delete all buttons (bookmarks, chevron, "other bookmarks") from the +// bookmark bar; reset knowledge of bookmarks. +- (void)clearBookmarkBar { + for (BookmarkButton* button in buttons_.get()) { + [button setDelegate:nil]; + [button removeFromSuperview]; + } + [buttons_ removeAllObjects]; + [self clearMenuTagMap]; + displayedButtonCount_ = 0; + + // Make sure there are no stale pointers in the pasteboard. This + // can be important if a bookmark is deleted (via bookmark sync) + // while in the middle of a drag. The "drag completed" code + // (e.g. [BookmarkBarView performDragOperationForBookmarkButton:]) is + // careful enough to bail if there is no data found at "drop" time. + // + // Unfortunately the clearContents selector is 10.6 only. The best + // we can do is make sure something else is present in place of the + // stale bookmark. + NSPasteboard* pboard = [NSPasteboard pasteboardWithName:NSDragPboard]; + [pboard declareTypes:[NSArray arrayWithObject:NSStringPboardType] owner:self]; + [pboard setString:@"" forType:NSStringPboardType]; +} + +// Return an autoreleased NSCell suitable for a bookmark button. +// TODO(jrg): move much of the cell config into the BookmarkButtonCell class. +- (BookmarkButtonCell*)cellForBookmarkNode:(const BookmarkNode*)node { + NSImage* image = node ? [self favIconForNode:node] : nil; + NSMenu* menu = node && node->is_folder() ? buttonFolderContextMenu_ : + buttonContextMenu_; + BookmarkButtonCell* cell = [BookmarkButtonCell buttonCellForNode:node + contextMenu:menu + cellText:nil + cellImage:image]; + [cell setTag:kStandardButtonTypeWithLimitedClickFeedback]; + + // Note: a quirk of setting a cell's text color is that it won't work + // until the cell is associated with a button, so we can't theme the cell yet. + + return cell; +} + +// Returns a frame appropriate for the given bookmark cell, suitable +// for creating an NSButton that will contain it. |xOffset| is the X +// offset for the frame; it is increased to be an appropriate X offset +// for the next button. +- (NSRect)frameForBookmarkButtonFromCell:(NSCell*)cell + xOffset:(int*)xOffset { + DCHECK(xOffset); + NSRect bounds = [buttonView_ bounds]; + bounds.size.height = bookmarks::kBookmarkButtonHeight; + + NSRect frame = NSInsetRect(bounds, + bookmarks::kBookmarkHorizontalPadding, + bookmarks::kBookmarkVerticalPadding); + frame.size.width = [self widthForBookmarkButtonCell:cell]; + + // Add an X offset based on what we've already done + frame.origin.x += *xOffset; + + // And up the X offset for next time. + *xOffset = NSMaxX(frame); + + return frame; +} + +// A bookmark button's contents changed. Check for growth +// (e.g. increase the width up to the maximum). If we grew, move +// other bookmark buttons over. +- (void)checkForBookmarkButtonGrowth:(NSButton*)button { + NSRect frame = [button frame]; + CGFloat desiredSize = [self widthForBookmarkButtonCell:[button cell]]; + CGFloat delta = desiredSize - frame.size.width; + if (delta) { + frame.size.width = desiredSize; + [button setFrame:frame]; + for (NSButton* button in buttons_.get()) { + NSRect buttonFrame = [button frame]; + if (buttonFrame.origin.x > frame.origin.x) { + buttonFrame.origin.x += delta; + [button setFrame:buttonFrame]; + } + } + } + // We may have just crossed a threshold to enable the off-the-side + // button. + [self configureOffTheSideButtonContentsAndVisibility]; +} + +// Called when our controlled frame has changed size. +- (void)frameDidChange { + if (!bookmarkModel_->IsLoaded()) + return; + [self updateTheme:[[[self view] window] themeProvider]]; + [self reconfigureBookmarkBar]; +} + +// Given a NSMenuItem tag, return the appropriate bookmark node id. +- (int64)nodeIdFromMenuTag:(int32)tag { + return menuTagMap_[tag]; +} + +// Create and return a new tag for the given node id. +- (int32)menuTagFromNodeId:(int64)menuid { + int tag = seedId_++; + menuTagMap_[tag] = menuid; + return tag; +} + +// Return the BookmarkNode associated with the given NSMenuItem. Can +// return NULL which means "do nothing". One case where it would +// return NULL is if the bookmark model gets modified while you have a +// context menu open. +- (const BookmarkNode*)nodeFromMenuItem:(id)sender { + const BookmarkNode* node = NULL; + BookmarkMenu* menu = (BookmarkMenu*)[sender menu]; + if ([menu isKindOfClass:[BookmarkMenu class]]) { + int64 id = [menu id]; + node = bookmarkModel_->GetNodeByID(id); + } + return node; +} + +// Adapt appearance of buttons to the current theme. Called after +// theme changes, or when our view is added to the view hierarchy. +// Oddly, the view pings us instead of us pinging our view. This is +// because our trigger is an [NSView viewWillMoveToWindow:], which the +// controller doesn't normally know about. Otherwise we don't have +// access to the theme before we know what window we will be on. +- (void)updateTheme:(ThemeProvider*)themeProvider { + if (!themeProvider) + return; + NSColor* color = + themeProvider->GetNSColor(BrowserThemeProvider::COLOR_BOOKMARK_TEXT, + true); + for (BookmarkButton* button in buttons_.get()) { + BookmarkButtonCell* cell = [button cell]; + [cell setTextColor:color]; + } + [[otherBookmarksButton_ cell] setTextColor:color]; +} + +// Return YES if the event indicates an exit from the bookmark bar +// folder menus. E.g. "click outside" of the area we are watching. +// At this time we are watching the area that includes all popup +// bookmark folder windows. +- (BOOL)isEventAnExitEvent:(NSEvent*)event { + NSWindow* eventWindow = [event window]; + NSWindow* myWindow = [[self view] window]; + switch ([event type]) { + case NSLeftMouseDown: + case NSRightMouseDown: + // If the click is in my window but NOT in the bookmark bar, consider + // it a click 'outside'. Clicks directly on an active button (i.e. one + // that is a folder and for which its folder menu is showing) are 'in'. + // All other clicks on the bookmarks bar are counted as 'outside' + // because they should close any open bookmark folder menu. + if (eventWindow == myWindow) { + NSView* hitView = + [[eventWindow contentView] hitTest:[event locationInWindow]]; + if (hitView == [folderController_ parentButton]) + return NO; + if (![hitView isDescendantOf:[self view]] || hitView == buttonView_) + return YES; + } + // If a click in a bookmark bar folder window and that isn't + // one of my bookmark bar folders, YES is click outside. + if (![eventWindow isKindOfClass:[BookmarkBarFolderWindow + class]]) { + return YES; + } + break; + case NSKeyDown: + case NSKeyUp: + // Any key press ends things. + return YES; + case NSLeftMouseDragged: + // We can get here with the following sequence: + // - open a bookmark folder + // - right-click (and unclick) on it to open context menu + // - move mouse to window titlebar then click-drag it by the titlebar + // http://crbug.com/49333 + return YES; + default: + break; + } + return NO; +} + +#pragma mark Drag & Drop + +// Find something like std::is_between<T>? I can't believe one doesn't exist. +static BOOL ValueInRangeInclusive(CGFloat low, CGFloat value, CGFloat high) { + return ((value >= low) && (value <= high)); +} + +// Return the proposed drop target for a hover open button from the +// given array, or nil if none. We use this for distinguishing +// between a hover-open candidate or drop-indicator draw. +// Helper for buttonForDroppingOnAtPoint:. +// Get UI review on "middle half" ness. +// http://crbug.com/36276 +- (BookmarkButton*)buttonForDroppingOnAtPoint:(NSPoint)point + fromArray:(NSArray*)array { + for (BookmarkButton* button in array) { + // Break early if we've gone too far. + if ((NSMinX([button frame]) > point.x) || (![button superview])) + return nil; + // Careful -- this only applies to the bar with horiz buttons. + // Intentionally NOT using NSPointInRect() so that scrolling into + // a submenu doesn't cause it to be closed. + if (ValueInRangeInclusive(NSMinX([button frame]), + point.x, + NSMaxX([button frame]))) { + // Over a button but let's be a little more specific (make sure + // it's over the middle half, not just over it). + NSRect frame = [button frame]; + NSRect middleHalfOfButton = NSInsetRect(frame, frame.size.width / 4, 0); + if (ValueInRangeInclusive(NSMinX(middleHalfOfButton), + point.x, + NSMaxX(middleHalfOfButton))) { + // It makes no sense to drop on a non-folder; there is no hover. + if (![button isFolder]) + return nil; + // Got it! + return button; + } else { + // Over a button but not over the middle half. + return nil; + } + } + } + // Not hovering over a button. + return nil; +} + +// Return the proposed drop target for a hover open button, or nil if +// none. Works with both the bookmark buttons and the "Other +// Bookmarks" button. Point is in [self view] coordinates. +- (BookmarkButton*)buttonForDroppingOnAtPoint:(NSPoint)point { + point = [[self view] convertPoint:point + fromView:[[[self view] window] contentView]]; + BookmarkButton* button = [self buttonForDroppingOnAtPoint:point + fromArray:buttons_.get()]; + // One more chance -- try "Other Bookmarks" and "off the side" (if visible). + // This is different than BookmarkBarFolderController. + if (!button) { + NSMutableArray* array = [NSMutableArray array]; + if (![self offTheSideButtonIsHidden]) + [array addObject:offTheSideButton_]; + [array addObject:otherBookmarksButton_]; + button = [self buttonForDroppingOnAtPoint:point + fromArray:array]; + } + return button; +} + +- (int)indexForDragToPoint:(NSPoint)point { + // TODO(jrg): revisit position info based on UI team feedback. + // dropLocation is in bar local coordinates. + NSPoint dropLocation = + [[self view] convertPoint:point + fromView:[[[self view] window] contentView]]; + BookmarkButton* buttonToTheRightOfDraggedButton = nil; + for (BookmarkButton* button in buttons_.get()) { + CGFloat midpoint = NSMidX([button frame]); + if (dropLocation.x <= midpoint) { + buttonToTheRightOfDraggedButton = button; + break; + } + } + if (buttonToTheRightOfDraggedButton) { + const BookmarkNode* afterNode = + [buttonToTheRightOfDraggedButton bookmarkNode]; + DCHECK(afterNode); + int index = afterNode->GetParent()->IndexOfChild(afterNode); + // Make sure we don't get confused by buttons which aren't visible. + return std::min(index, displayedButtonCount_); + } + + // If nothing is to my right I am at the end! + return displayedButtonCount_; +} + +// TODO(mrossetti,jrg): Yet more duplicated code. +// http://crbug.com/35966 +- (BOOL)dragBookmark:(const BookmarkNode*)sourceNode + to:(NSPoint)point + copy:(BOOL)copy { + DCHECK(sourceNode); + // Drop destination. + const BookmarkNode* destParent = NULL; + int destIndex = 0; + + // First check if we're dropping on a button. If we have one, and + // it's a folder, drop in it. + BookmarkButton* button = [self buttonForDroppingOnAtPoint:point]; + if ([button isFolder]) { + destParent = [button bookmarkNode]; + // Drop it at the end. + destIndex = [button bookmarkNode]->GetChildCount(); + } else { + // Else we're dropping somewhere on the bar, so find the right spot. + destParent = bookmarkModel_->GetBookmarkBarNode(); + destIndex = [self indexForDragToPoint:point]; + } + + // Be sure we don't try and drop a folder into itself. + if (sourceNode != destParent) { + if (copy) + bookmarkModel_->Copy(sourceNode, destParent, destIndex); + else + bookmarkModel_->Move(sourceNode, destParent, destIndex); + } + + [self closeFolderAndStopTrackingMenus]; + + // Movement of a node triggers observers (like us) to rebuild the + // bar so we don't have to do so explicitly. + + return YES; +} + +- (void)draggingEnded:(id<NSDraggingInfo>)info { + [self closeFolderAndStopTrackingMenus]; +} + +#pragma mark Bridge Notification Handlers + +// TODO(jrg): for now this is brute force. +- (void)loaded:(BookmarkModel*)model { + DCHECK(model == bookmarkModel_); + if (!model->IsLoaded()) + return; + + // If this is a rebuild request while we have a folder open, close it. + // TODO(mrossetti): Eliminate the need for this because it causes the folder + // menu to disappear after a cut/copy/paste/delete change. + // See: http://crbug.com/36614 + if (folderController_) + [self closeAllBookmarkFolders]; + + // Brute force nuke and build. + savedFrameWidth_ = NSWidth([[self view] frame]); + const BookmarkNode* node = model->GetBookmarkBarNode(); + [self clearBookmarkBar]; + [self addNodesToButtonList:node]; + [self createOtherBookmarksButton]; + [self updateTheme:[[[self view] window] themeProvider]]; + [self positionOffTheSideButton]; + [self addNonBookmarkButtonsToView]; + [self addButtonsToView]; + [self configureOffTheSideButtonContentsAndVisibility]; + [self setNodeForBarMenu]; +} + +- (void)beingDeleted:(BookmarkModel*)model { + // The browser may be being torn down; little is safe to do. As an + // example, it may not be safe to clear the pasteboard. + // http://crbug.com/38665 +} + +- (void)nodeAdded:(BookmarkModel*)model + parent:(const BookmarkNode*)newParent index:(int)newIndex { + // If a context menu is open, close it. + [self cancelMenuTracking]; + + const BookmarkNode* newNode = newParent->GetChild(newIndex); + id<BookmarkButtonControllerProtocol> newController = + [self controllerForNode:newParent]; + [newController addButtonForNode:newNode atIndex:newIndex]; + // If we go from 0 --> 1 bookmarks we may need to hide the + // "bookmarks go here" text container. + [self showOrHideNoItemContainerForNode:model->GetBookmarkBarNode()]; +} + +// TODO(jrg): for now this is brute force. +- (void)nodeChanged:(BookmarkModel*)model + node:(const BookmarkNode*)node { + [self loaded:model]; +} + +- (void)nodeMoved:(BookmarkModel*)model + oldParent:(const BookmarkNode*)oldParent oldIndex:(int)oldIndex + newParent:(const BookmarkNode*)newParent newIndex:(int)newIndex { + const BookmarkNode* movedNode = newParent->GetChild(newIndex); + id<BookmarkButtonControllerProtocol> oldController = + [self controllerForNode:oldParent]; + id<BookmarkButtonControllerProtocol> newController = + [self controllerForNode:newParent]; + if (newController == oldController) { + [oldController moveButtonFromIndex:oldIndex toIndex:newIndex]; + } else { + [oldController removeButton:oldIndex animate:NO]; + [newController addButtonForNode:movedNode atIndex:newIndex]; + } + // If the bar is one of the parents we may need to update the visibility + // of the "bookmarks go here" presentation. + [self showOrHideNoItemContainerForNode:model->GetBookmarkBarNode()]; + // If we moved the only item on the "off the side" menu somewhere + // else, we may no longer need to show it. + [self configureOffTheSideButtonContentsAndVisibility]; +} + +- (void)nodeRemoved:(BookmarkModel*)model + parent:(const BookmarkNode*)oldParent index:(int)index { + // If a context menu is open, close it. + [self cancelMenuTracking]; + + // Locate the parent node. The parent may not be showing, in which case + // we do nothing. + id<BookmarkButtonControllerProtocol> parentController = + [self controllerForNode:oldParent]; + [parentController removeButton:index animate:YES]; + // If we go from 1 --> 0 bookmarks we may need to show the + // "bookmarks go here" text container. + [self showOrHideNoItemContainerForNode:model->GetBookmarkBarNode()]; + // If we deleted the only item on the "off the side" menu we no + // longer need to show it. + [self configureOffTheSideButtonContentsAndVisibility]; +} + +// TODO(jrg): linear searching is bad. +// Need a BookmarkNode-->NSCell mapping. +// +// TODO(jrg): if the bookmark bar is open on launch, we see the +// buttons all placed, then "scooted over" as the favicons load. If +// this looks bad I may need to change widthForBookmarkButtonCell to +// add space for an image even if not there on the assumption that +// favicons will eventually load. +- (void)nodeFavIconLoaded:(BookmarkModel*)model + node:(const BookmarkNode*)node { + for (BookmarkButton* button in buttons_.get()) { + const BookmarkNode* cellnode = [button bookmarkNode]; + if (cellnode == node) { + [[button cell] setBookmarkCellText:[button title] + image:[self favIconForNode:node]]; + // Adding an image means we might need more room for the + // bookmark. Test for it by growing the button (if needed) + // and shifting everything else over. + [self checkForBookmarkButtonGrowth:button]; + } + } +} + +// TODO(jrg): for now this is brute force. +- (void)nodeChildrenReordered:(BookmarkModel*)model + node:(const BookmarkNode*)node { + [self loaded:model]; +} + +#pragma mark BookmarkBarState Protocol + +// (BookmarkBarState protocol) +- (BOOL)isVisible { + return barIsEnabled_ && (visualState_ == bookmarks::kShowingState || + visualState_ == bookmarks::kDetachedState || + lastVisualState_ == bookmarks::kShowingState || + lastVisualState_ == bookmarks::kDetachedState); +} + +// (BookmarkBarState protocol) +- (BOOL)isAnimationRunning { + return lastVisualState_ != bookmarks::kInvalidState; +} + +// (BookmarkBarState protocol) +- (BOOL)isInState:(bookmarks::VisualState)state { + return visualState_ == state && + lastVisualState_ == bookmarks::kInvalidState; +} + +// (BookmarkBarState protocol) +- (BOOL)isAnimatingToState:(bookmarks::VisualState)state { + return visualState_ == state && + lastVisualState_ != bookmarks::kInvalidState; +} + +// (BookmarkBarState protocol) +- (BOOL)isAnimatingFromState:(bookmarks::VisualState)state { + return lastVisualState_ == state; +} + +// (BookmarkBarState protocol) +- (BOOL)isAnimatingFromState:(bookmarks::VisualState)fromState + toState:(bookmarks::VisualState)toState { + return lastVisualState_ == fromState && visualState_ == toState; +} + +// (BookmarkBarState protocol) +- (BOOL)isAnimatingBetweenState:(bookmarks::VisualState)fromState + andState:(bookmarks::VisualState)toState { + return (lastVisualState_ == fromState && visualState_ == toState) || + (visualState_ == fromState && lastVisualState_ == toState); +} + +// (BookmarkBarState protocol) +- (CGFloat)detachedMorphProgress { + if ([self isInState:bookmarks::kDetachedState]) { + return 1; + } + if ([self isAnimatingToState:bookmarks::kDetachedState]) { + return static_cast<CGFloat>( + [[self animatableView] currentAnimationProgress]); + } + if ([self isAnimatingFromState:bookmarks::kDetachedState]) { + return static_cast<CGFloat>( + 1 - [[self animatableView] currentAnimationProgress]); + } + return 0; +} + +#pragma mark BookmarkBarToolbarViewController Protocol + +- (int)currentTabContentsHeight { + TabContents* tc = browser_->GetSelectedTabContents(); + return tc ? tc->view()->GetContainerSize().height() : 0; +} + +- (ThemeProvider*)themeProvider { + return browser_->profile()->GetThemeProvider(); +} + +#pragma mark BookmarkButtonDelegate Protocol + +- (void)fillPasteboard:(NSPasteboard*)pboard + forDragOfButton:(BookmarkButton*)button { + [[self folderTarget] fillPasteboard:pboard forDragOfButton:button]; +} + +// BookmarkButtonDelegate protocol implementation. When menus are +// "active" (e.g. you clicked to open one), moving the mouse over +// another folder button should close the 1st and open the 2nd (like +// real menus). We detect and act here. +- (void)mouseEnteredButton:(id)sender event:(NSEvent*)event { + DCHECK([sender isKindOfClass:[BookmarkButton class]]); + + // If folder menus are not being shown, do nothing. This is different from + // BookmarkBarFolderController's implementation because the bar should NOT + // automatically open folder menus when the mouse passes over a folder + // button while the BookmarkBarFolderController DOES automically open + // a subfolder menu. + if (!showFolderMenus_) + return; + + // From here down: same logic as BookmarkBarFolderController. + // TODO(jrg): find a way to share these 4 non-comment lines? + // http://crbug.com/35966 + // If already opened, then we exited but re-entered the button, so do nothing. + if ([folderController_ parentButton] == sender) + return; + // Else open a new one if it makes sense to do so. + if ([sender bookmarkNode]->is_folder()) { + [folderTarget_ openBookmarkFolderFromButton:sender]; + } else { + // We're over a non-folder bookmark so close any old folders. + [folderController_ close]; + folderController_ = nil; + } +} + +// BookmarkButtonDelegate protocol implementation. +- (void)mouseExitedButton:(id)sender event:(NSEvent*)event { + // Don't care; do nothing. + // This is different behavior that the folder menus. +} + +- (NSWindow*)browserWindow { + return [[self view] window]; +} + +- (BOOL)canDragBookmarkButtonToTrash:(BookmarkButton*)button { + return [self canEditBookmark:[button bookmarkNode]]; +} + +- (void)didDragBookmarkToTrash:(BookmarkButton*)button { + // TODO(mrossetti): Refactor BookmarkBarFolder common code. + // http://crbug.com/35966 + const BookmarkNode* node = [button bookmarkNode]; + if (node) { + const BookmarkNode* parent = node->GetParent(); + bookmarkModel_->Remove(parent, + parent->IndexOfChild(node)); + } +} + +#pragma mark BookmarkButtonControllerProtocol + +// Close all bookmark folders. "Folder" here is the fake menu for +// bookmark folders, not a button context menu. +- (void)closeAllBookmarkFolders { + [self watchForExitEvent:NO]; + [folderController_ close]; + folderController_ = nil; +} + +- (void)closeBookmarkFolder:(id)sender { + // We're the top level, so close one means close them all. + [self closeAllBookmarkFolders]; +} + +- (BookmarkModel*)bookmarkModel { + return bookmarkModel_; +} + +// TODO(jrg): much of this logic is duped with +// [BookmarkBarFolderController draggingEntered:] except when noted. +// http://crbug.com/35966 +- (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)info { + NSPoint point = [info draggingLocation]; + BookmarkButton* button = [self buttonForDroppingOnAtPoint:point]; + + // Don't allow drops that would result in cycles. + if (button) { + NSData* data = [[info draggingPasteboard] + dataForType:kBookmarkButtonDragType]; + if (data && [info draggingSource]) { + BookmarkButton* sourceButton = nil; + [data getBytes:&sourceButton length:sizeof(sourceButton)]; + const BookmarkNode* sourceNode = [sourceButton bookmarkNode]; + const BookmarkNode* destNode = [button bookmarkNode]; + if (destNode->HasAncestor(sourceNode)) + button = nil; + } + } + + if ([button isFolder]) { + if (hoverButton_ == button) { + return NSDragOperationMove; // already open or timed to open + } + if (hoverButton_) { + // Oops, another one triggered or open. + [NSObject cancelPreviousPerformRequestsWithTarget:[hoverButton_ + target]]; + // Unlike BookmarkBarFolderController, we do not delay the close + // of the previous one. Given the lack of diagonal movement, + // there is no need, and it feels awkward to do so. See + // comments about kDragHoverCloseDelay in + // bookmark_bar_folder_controller.mm for more details. + [[hoverButton_ target] closeBookmarkFolder:hoverButton_]; + hoverButton_.reset(); + } + hoverButton_.reset([button retain]); + DCHECK([[hoverButton_ target] + respondsToSelector:@selector(openBookmarkFolderFromButton:)]); + [[hoverButton_ target] + performSelector:@selector(openBookmarkFolderFromButton:) + withObject:hoverButton_ + afterDelay:bookmarks::kDragHoverOpenDelay]; + } + if (!button) { + if (hoverButton_) { + [NSObject cancelPreviousPerformRequestsWithTarget:[hoverButton_ target]]; + [[hoverButton_ target] closeBookmarkFolder:hoverButton_]; + hoverButton_.reset(); + } + } + + // Thrown away but kept to be consistent with the draggingEntered: interface. + return NSDragOperationMove; +} + +- (void)draggingExited:(id<NSDraggingInfo>)info { + // NOT the same as a cancel --> we may have moved the mouse into the submenu. + if (hoverButton_) { + [NSObject cancelPreviousPerformRequestsWithTarget:[hoverButton_ target]]; + hoverButton_.reset(); + } +} + +- (BOOL)dragShouldLockBarVisibility { + return ![self isInState:bookmarks::kDetachedState] && + ![self isAnimatingToState:bookmarks::kDetachedState]; +} + +// TODO(mrossetti,jrg): Yet more code dup with BookmarkBarFolderController. +// http://crbug.com/35966 +- (BOOL)dragButton:(BookmarkButton*)sourceButton + to:(NSPoint)point + copy:(BOOL)copy { + DCHECK([sourceButton isKindOfClass:[BookmarkButton class]]); + const BookmarkNode* sourceNode = [sourceButton bookmarkNode]; + return [self dragBookmark:sourceNode to:point copy:copy]; +} + +- (BOOL)dragBookmarkData:(id<NSDraggingInfo>)info { + BOOL dragged = NO; + std::vector<const BookmarkNode*> nodes([self retrieveBookmarkNodeData]); + if (nodes.size()) { + BOOL copy = !([info draggingSourceOperationMask] & NSDragOperationMove); + NSPoint dropPoint = [info draggingLocation]; + for (std::vector<const BookmarkNode*>::const_iterator it = nodes.begin(); + it != nodes.end(); ++it) { + const BookmarkNode* sourceNode = *it; + dragged = [self dragBookmark:sourceNode to:dropPoint copy:copy]; + } + } + return dragged; +} + +- (std::vector<const BookmarkNode*>)retrieveBookmarkNodeData { + std::vector<const BookmarkNode*> dragDataNodes; + BookmarkNodeData dragData; + if(dragData.ReadFromDragClipboard()) { + BookmarkModel* bookmarkModel = [self bookmarkModel]; + Profile* profile = bookmarkModel->profile(); + std::vector<const BookmarkNode*> nodes(dragData.GetNodes(profile)); + dragDataNodes.assign(nodes.begin(), nodes.end()); + } + return dragDataNodes; +} + +// Return YES if we should show the drop indicator, else NO. +- (BOOL)shouldShowIndicatorShownForPoint:(NSPoint)point { + return ![self buttonForDroppingOnAtPoint:point]; +} + +// Return the x position for a drop indicator. +- (CGFloat)indicatorPosForDragToPoint:(NSPoint)point { + CGFloat x = 0; + int destIndex = [self indexForDragToPoint:point]; + int numButtons = displayedButtonCount_; + + // If it's a drop strictly between existing buttons ... + if (destIndex >= 0 && destIndex < numButtons) { + // ... put the indicator right between the buttons. + BookmarkButton* button = + [buttons_ objectAtIndex:static_cast<NSUInteger>(destIndex)]; + DCHECK(button); + NSRect buttonFrame = [button frame]; + x = buttonFrame.origin.x - 0.5 * bookmarks::kBookmarkHorizontalPadding; + + // If it's a drop at the end (past the last button, if there are any) ... + } else if (destIndex == numButtons) { + // and if it's past the last button ... + if (numButtons > 0) { + // ... find the last button, and put the indicator to its right. + BookmarkButton* button = + [buttons_ objectAtIndex:static_cast<NSUInteger>(destIndex - 1)]; + DCHECK(button); + NSRect buttonFrame = [button frame]; + x = NSMaxX(buttonFrame) + 0.5 * bookmarks::kBookmarkHorizontalPadding; + + // Otherwise, put it right at the beginning. + } else { + x = 0.5 * bookmarks::kBookmarkHorizontalPadding; + } + } else { + NOTREACHED(); + } + + return x; +} + +- (void)childFolderWillShow:(id<BookmarkButtonControllerProtocol>)child { + // If the bookmarkbar is not in detached mode, lock bar visibility, forcing + // the overlay to stay open when in fullscreen mode. + if (![self isInState:bookmarks::kDetachedState] && + ![self isAnimatingToState:bookmarks::kDetachedState]) { + BrowserWindowController* browserController = + [BrowserWindowController browserWindowControllerForView:[self view]]; + [browserController lockBarVisibilityForOwner:child + withAnimation:NO + delay:NO]; + } +} + +- (void)childFolderWillClose:(id<BookmarkButtonControllerProtocol>)child { + // Release bar visibility, allowing the overlay to close if in fullscreen + // mode. + BrowserWindowController* browserController = + [BrowserWindowController browserWindowControllerForView:[self view]]; + [browserController releaseBarVisibilityForOwner:child + withAnimation:NO + delay:NO]; +} + +// Add a new folder controller as triggered by the given folder button. +- (void)addNewFolderControllerWithParentButton:(BookmarkButton*)parentButton { + + // If doing a close/open, make sure the fullscreen chrome doesn't + // have a chance to begin animating away in the middle of things. + BrowserWindowController* browserController = + [BrowserWindowController browserWindowControllerForView:[self view]]; + // Confirm we're not re-locking with ourself as an owner before locking. + DCHECK([browserController isBarVisibilityLockedForOwner:self] == NO); + [browserController lockBarVisibilityForOwner:self + withAnimation:NO + delay:NO]; + + if (folderController_) + [self closeAllBookmarkFolders]; + + // Folder controller, like many window controllers, owns itself. + folderController_ = + [[BookmarkBarFolderController alloc] initWithParentButton:parentButton + parentController:nil + barController:self]; + [folderController_ showWindow:self]; + + // Only BookmarkBarController has this; the + // BookmarkBarFolderController does not. + [self watchForExitEvent:YES]; + + // No longer need to hold the lock; the folderController_ now owns it. + [browserController releaseBarVisibilityForOwner:self + withAnimation:NO + delay:NO]; +} + +- (void)openAll:(const BookmarkNode*)node + disposition:(WindowOpenDisposition)disposition { + [self closeFolderAndStopTrackingMenus]; + bookmark_utils::OpenAll([[self view] window], + browser_->profile(), + browser_, + node, + disposition); +} + +- (void)addButtonForNode:(const BookmarkNode*)node + atIndex:(NSInteger)buttonIndex { + int newOffset = 0; + if (buttonIndex == -1) + buttonIndex = [buttons_ count]; // New button goes at the end. + if (buttonIndex <= (NSInteger)[buttons_ count]) { + if (buttonIndex) { + BookmarkButton* targetButton = [buttons_ objectAtIndex:buttonIndex - 1]; + NSRect targetFrame = [targetButton frame]; + newOffset = targetFrame.origin.x + NSWidth(targetFrame) + + bookmarks::kBookmarkHorizontalPadding; + } + BookmarkButton* newButton = [self buttonForNode:node xOffset:&newOffset]; + CGFloat xOffset = + NSWidth([newButton frame]) + bookmarks::kBookmarkHorizontalPadding; + NSUInteger buttonCount = [buttons_ count]; + for (NSUInteger i = buttonIndex; i < buttonCount; ++i) { + BookmarkButton* button = [buttons_ objectAtIndex:i]; + NSPoint buttonOrigin = [button frame].origin; + buttonOrigin.x += xOffset; + [button setFrameOrigin:buttonOrigin]; + } + ++displayedButtonCount_; + [buttons_ insertObject:newButton atIndex:buttonIndex]; + [buttonView_ addSubview:newButton]; + + // See if any buttons need to be pushed off to or brought in from the side. + [self reconfigureBookmarkBar]; + } else { + // A button from somewhere else (not the bar) is being moved to the + // off-the-side so insure it gets redrawn if its showing. + [self reconfigureBookmarkBar]; + [folderController_ reconfigureMenu]; + } +} + +// TODO(mrossetti): Duplicate code with BookmarkBarFolderController. +// http://crbug.com/35966 +- (BOOL)addURLs:(NSArray*)urls withTitles:(NSArray*)titles at:(NSPoint)point { + DCHECK([urls count] == [titles count]); + BOOL nodesWereAdded = NO; + // Figure out where these new bookmarks nodes are to be added. + BookmarkButton* button = [self buttonForDroppingOnAtPoint:point]; + const BookmarkNode* destParent = NULL; + int destIndex = 0; + if ([button isFolder]) { + destParent = [button bookmarkNode]; + // Drop it at the end. + destIndex = [button bookmarkNode]->GetChildCount(); + } else { + // Else we're dropping somewhere on the bar, so find the right spot. + destParent = bookmarkModel_->GetBookmarkBarNode(); + destIndex = [self indexForDragToPoint:point]; + } + + // Don't add the bookmarks if the destination index shows an error. + if (destIndex >= 0) { + // Create and add the new bookmark nodes. + size_t urlCount = [urls count]; + for (size_t i = 0; i < urlCount; ++i) { + GURL gurl; + const char* string = [[urls objectAtIndex:i] UTF8String]; + if (string) + gurl = GURL(string); + // We only expect to receive valid URLs. + DCHECK(gurl.is_valid()); + if (gurl.is_valid()) { + bookmarkModel_->AddURL(destParent, + destIndex++, + base::SysNSStringToUTF16( + [titles objectAtIndex:i]), + gurl); + nodesWereAdded = YES; + } + } + } + return nodesWereAdded; +} + +// TODO(mrossetti): jrg wants this broken up into smaller functions. +- (void)moveButtonFromIndex:(NSInteger)fromIndex toIndex:(NSInteger)toIndex { + if (fromIndex != toIndex) { + NSInteger buttonCount = (NSInteger)[buttons_ count]; + if (toIndex == -1) + toIndex = buttonCount; + // See if we have a simple move within the bar, which will be the case if + // both button indexes are in the visible space. + if (fromIndex < buttonCount && toIndex < buttonCount) { + BookmarkButton* movedButton = [buttons_ objectAtIndex:fromIndex]; + NSRect movedFrame = [movedButton frame]; + NSPoint toOrigin = movedFrame.origin; + CGFloat xOffset = + NSWidth(movedFrame) + bookmarks::kBookmarkHorizontalPadding; + // Hide the button to reduce flickering while drawing the window. + [movedButton setHidden:YES]; + [buttons_ removeObjectAtIndex:fromIndex]; + if (fromIndex < toIndex) { + // Move the button from left to right within the bar. + BookmarkButton* targetButton = [buttons_ objectAtIndex:toIndex - 1]; + NSRect toFrame = [targetButton frame]; + toOrigin.x = toFrame.origin.x - NSWidth(movedFrame) + NSWidth(toFrame); + for (NSInteger i = fromIndex; i < toIndex; ++i) { + BookmarkButton* button = [buttons_ objectAtIndex:i]; + NSRect frame = [button frame]; + frame.origin.x -= xOffset; + [button setFrameOrigin:frame.origin]; + } + } else { + // Move the button from right to left within the bar. + BookmarkButton* targetButton = [buttons_ objectAtIndex:toIndex]; + toOrigin = [targetButton frame].origin; + for (NSInteger i = fromIndex - 1; i >= toIndex; --i) { + BookmarkButton* button = [buttons_ objectAtIndex:i]; + NSRect buttonFrame = [button frame]; + buttonFrame.origin.x += xOffset; + [button setFrameOrigin:buttonFrame.origin]; + } + } + [buttons_ insertObject:movedButton atIndex:toIndex]; + [movedButton setFrameOrigin:toOrigin]; + [movedButton setHidden:NO]; + } else if (fromIndex < buttonCount) { + // A button is being removed from the bar and added to off-the-side. + // By now the node has already been inserted into the model so the + // button to be added is represented by |toIndex|. Things get + // complicated because the off-the-side is showing and must be redrawn + // while possibly re-laying out the bookmark bar. + [self removeButton:fromIndex animate:NO]; + [self reconfigureBookmarkBar]; + [folderController_ reconfigureMenu]; + } else if (toIndex < buttonCount) { + // A button is being added to the bar and removed from off-the-side. + // By now the node has already been inserted into the model so the + // button to be added is represented by |toIndex|. + const BookmarkNode* node = bookmarkModel_->GetBookmarkBarNode(); + const BookmarkNode* movedNode = node->GetChild(toIndex); + DCHECK(movedNode); + [self addButtonForNode:movedNode atIndex:toIndex]; + [self reconfigureBookmarkBar]; + } else { + // A button is being moved within the off-the-side. + fromIndex -= buttonCount; + toIndex -= buttonCount; + [folderController_ moveButtonFromIndex:fromIndex toIndex:toIndex]; + } + } +} + +- (void)removeButton:(NSInteger)buttonIndex animate:(BOOL)animate { + if (buttonIndex < (NSInteger)[buttons_ count]) { + // The button being removed is showing in the bar. + BookmarkButton* oldButton = [buttons_ objectAtIndex:buttonIndex]; + if (oldButton == [folderController_ parentButton]) { + // If we are deleting a button whose folder is currently open, close it! + [self closeAllBookmarkFolders]; + } + NSPoint poofPoint = [oldButton screenLocationForRemoveAnimation]; + NSRect oldFrame = [oldButton frame]; + [oldButton setDelegate:nil]; + [oldButton removeFromSuperview]; + if (animate && !ignoreAnimations_ && [self isVisible]) + NSShowAnimationEffect(NSAnimationEffectDisappearingItemDefault, poofPoint, + NSZeroSize, nil, nil, nil); + CGFloat xOffset = NSWidth(oldFrame) + bookmarks::kBookmarkHorizontalPadding; + [buttons_ removeObjectAtIndex:buttonIndex]; + NSUInteger buttonCount = [buttons_ count]; + for (NSUInteger i = buttonIndex; i < buttonCount; ++i) { + BookmarkButton* button = [buttons_ objectAtIndex:i]; + NSRect buttonFrame = [button frame]; + buttonFrame.origin.x -= xOffset; + [button setFrame:buttonFrame]; + // If this button is showing its menu then we need to move the menu, too. + if (button == [folderController_ parentButton]) + [folderController_ offsetFolderMenuWindow:NSMakeSize(xOffset, 0.0)]; + } + --displayedButtonCount_; + [self reconfigureBookmarkBar]; + } else if (folderController_ && + [folderController_ parentButton] == offTheSideButton_) { + // The button being removed is in the OTS (off-the-side) and the OTS + // menu is showing so we need to remove the button. + NSInteger index = buttonIndex - displayedButtonCount_; + [folderController_ removeButton:index animate:YES]; + } +} + +- (id<BookmarkButtonControllerProtocol>)controllerForNode: + (const BookmarkNode*)node { + // See if it's in the bar, then if it is in the hierarchy of visible + // folder menus. + if (bookmarkModel_->GetBookmarkBarNode() == node) + return self; + return [folderController_ controllerForNode:node]; +} + +#pragma mark BookmarkButtonControllerProtocol + +// NOT an override of a standard Cocoa call made to NSViewControllers. +- (void)hookForEvent:(NSEvent*)theEvent { + if ([self isEventAnExitEvent:theEvent]) + [self closeFolderAndStopTrackingMenus]; +} + +#pragma mark TestingAPI Only + +- (NSMenu*)buttonContextMenu { + return buttonContextMenu_; +} + +// Intentionally ignores ownership issues; used for testing and we try +// to minimize touching the object passed in (likely a mock). +- (void)setButtonContextMenu:(id)menu { + buttonContextMenu_ = menu; +} + +- (void)setIgnoreAnimations:(BOOL)ignore { + ignoreAnimations_ = ignore; +} + +@end diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_controller_unittest.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_controller_unittest.mm new file mode 100644 index 0000000..80f6bc7 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_controller_unittest.mm @@ -0,0 +1,2169 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "app/theme_provider.h" +#include "base/basictypes.h" +#include "base/scoped_nsobject.h" +#include "base/string16.h" +#include "base/string_util.h" +#include "base/sys_string_conversions.h" +#include "base/utf_string_conversions.h" +#include "chrome/browser/bookmarks/bookmark_model.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_constants.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_controller.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_window.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_unittest_helper.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_view.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_button.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_button_cell.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_menu.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "chrome/browser/ui/cocoa/test_event_utils.h" +#import "chrome/browser/ui/cocoa/view_resizer_pong.h" +#include "chrome/common/pref_names.h" +#include "chrome/test/model_test_utils.h" +#include "testing/gtest/include/gtest/gtest.h" +#import "testing/gtest_mac.h" +#include "testing/platform_test.h" +#import "third_party/ocmock/OCMock/OCMock.h" + +// Just like a BookmarkBarController but openURL: is stubbed out. +@interface BookmarkBarControllerNoOpen : BookmarkBarController { + @public + std::vector<GURL> urls_; + std::vector<WindowOpenDisposition> dispositions_; +} +@end + +@implementation BookmarkBarControllerNoOpen +- (void)openURL:(GURL)url disposition:(WindowOpenDisposition)disposition { + urls_.push_back(url); + dispositions_.push_back(disposition); +} +- (void)clear { + urls_.clear(); + dispositions_.clear(); +} +@end + + +// NSCell that is pre-provided with a desired size that becomes the +// return value for -(NSSize)cellSize:. +@interface CellWithDesiredSize : NSCell { + @private + NSSize cellSize_; +} +@property (nonatomic, readonly) NSSize cellSize; +@end + +@implementation CellWithDesiredSize + +@synthesize cellSize = cellSize_; + +- (id)initTextCell:(NSString*)string desiredSize:(NSSize)size { + if ((self = [super initTextCell:string])) { + cellSize_ = size; + } + return self; +} + +@end + +// Remember the number of times we've gotten a frameDidChange notification. +@interface BookmarkBarControllerTogglePong : BookmarkBarControllerNoOpen { + @private + int toggles_; +} +@property (nonatomic, readonly) int toggles; +@end + +@implementation BookmarkBarControllerTogglePong + +@synthesize toggles = toggles_; + +- (void)frameDidChange { + toggles_++; +} + +@end + +// Remembers if a notification callback was called. +@interface BookmarkBarControllerNotificationPong : BookmarkBarControllerNoOpen { + BOOL windowWillCloseReceived_; + BOOL windowDidResignKeyReceived_; +} +@property (nonatomic, readonly) BOOL windowWillCloseReceived; +@property (nonatomic, readonly) BOOL windowDidResignKeyReceived; +@end + +@implementation BookmarkBarControllerNotificationPong +@synthesize windowWillCloseReceived = windowWillCloseReceived_; +@synthesize windowDidResignKeyReceived = windowDidResignKeyReceived_; + +// Override NSNotificationCenter callback. +- (void)parentWindowWillClose:(NSNotification*)notification { + windowWillCloseReceived_ = YES; +} + +// NSNotificationCenter callback. +- (void)parentWindowDidResignKey:(NSNotification*)notification { + windowDidResignKeyReceived_ = YES; +} +@end + +// Remembers if and what kind of openAll was performed. +@interface BookmarkBarControllerOpenAllPong : BookmarkBarControllerNoOpen { + WindowOpenDisposition dispositionDetected_; +} +@property (nonatomic) WindowOpenDisposition dispositionDetected; +@end + +@implementation BookmarkBarControllerOpenAllPong +@synthesize dispositionDetected = dispositionDetected_; + +// Intercede for the openAll:disposition: method. +- (void)openAll:(const BookmarkNode*)node + disposition:(WindowOpenDisposition)disposition { + [self setDispositionDetected:disposition]; +} + +@end + +// Just like a BookmarkBarController but intercedes when providing +// pasteboard drag data. +@interface BookmarkBarControllerDragData : BookmarkBarController { + const BookmarkNode* dragDataNode_; // Weak +} +- (void)setDragDataNode:(const BookmarkNode*)node; +@end + +@implementation BookmarkBarControllerDragData + +- (id)initWithBrowser:(Browser*)browser + initialWidth:(CGFloat)initialWidth + delegate:(id<BookmarkBarControllerDelegate>)delegate + resizeDelegate:(id<ViewResizer>)resizeDelegate { + if ((self = [super initWithBrowser:browser + initialWidth:initialWidth + delegate:delegate + resizeDelegate:resizeDelegate])) { + dragDataNode_ = NULL; + } + return self; +} + +- (void)setDragDataNode:(const BookmarkNode*)node { + dragDataNode_ = node; +} + +- (std::vector<const BookmarkNode*>)retrieveBookmarkNodeData { + std::vector<const BookmarkNode*> dragDataNodes; + if(dragDataNode_) { + dragDataNodes.push_back(dragDataNode_); + } + return dragDataNodes; +} + +@end + + +class FakeTheme : public ThemeProvider { + public: + FakeTheme(NSColor* color) : color_(color) { } + scoped_nsobject<NSColor> color_; + + virtual void Init(Profile* profile) { } + virtual SkBitmap* GetBitmapNamed(int id) const { return nil; } + virtual SkColor GetColor(int id) const { return SkColor(); } + virtual bool GetDisplayProperty(int id, int* result) const { return false; } + virtual bool ShouldUseNativeFrame() const { return false; } + virtual bool HasCustomImage(int id) const { return false; } + virtual RefCountedMemory* GetRawData(int id) const { return NULL; } + virtual NSImage* GetNSImageNamed(int id, bool allow_default) const { + return nil; + } + virtual NSColor* GetNSImageColorNamed(int id, bool allow_default) const { + return nil; + } + virtual NSColor* GetNSColor(int id, bool allow_default) const { + return color_.get(); + } + virtual NSColor* GetNSColorTint(int id, bool allow_default) const { + return nil; + } + virtual NSGradient* GetNSGradient(int id) const { + return nil; + } +}; + + +@interface FakeDragInfo : NSObject { + @public + NSPoint dropLocation_; + NSDragOperation sourceMask_; +} +@property (nonatomic, assign) NSPoint dropLocation; +- (void)setDraggingSourceOperationMask:(NSDragOperation)mask; +@end + +@implementation FakeDragInfo + +@synthesize dropLocation = dropLocation_; + +- (id)init { + if ((self = [super init])) { + dropLocation_ = NSZeroPoint; + sourceMask_ = NSDragOperationMove; + } + return self; +} + +// NSDraggingInfo protocol functions. + +- (id)draggingPasteboard { + return self; +} + +- (id)draggingSource { + return self; +} + +- (NSDragOperation)draggingSourceOperationMask { + return sourceMask_; +} + +- (NSPoint)draggingLocation { + return dropLocation_; +} + +// Other functions. + +- (void)setDraggingSourceOperationMask:(NSDragOperation)mask { + sourceMask_ = mask; +} + +@end + + +namespace { + +class BookmarkBarControllerTestBase : public CocoaTest { + public: + BrowserTestHelper helper_; + scoped_nsobject<NSView> parent_view_; + scoped_nsobject<ViewResizerPong> resizeDelegate_; + + BookmarkBarControllerTestBase() { + resizeDelegate_.reset([[ViewResizerPong alloc] init]); + NSRect parent_frame = NSMakeRect(0, 0, 800, 50); + parent_view_.reset([[NSView alloc] initWithFrame:parent_frame]); + [parent_view_ setHidden:YES]; + } + + void InstallAndToggleBar(BookmarkBarController* bar) { + // Force loading of the nib. + [bar view]; + // Awkwardness to look like we've been installed. + for (NSView* subView in [parent_view_ subviews]) + [subView removeFromSuperview]; + [parent_view_ addSubview:[bar view]]; + NSRect frame = [[[bar view] superview] frame]; + frame.origin.y = 100; + [[[bar view] superview] setFrame:frame]; + + // Make sure it's on in a window so viewDidMoveToWindow is called + NSView* contentView = [test_window() contentView]; + if (![parent_view_ isDescendantOf:contentView]) + [contentView addSubview:parent_view_]; + + // Make sure it's open so certain things aren't no-ops. + [bar updateAndShowNormalBar:YES + showDetachedBar:NO + withAnimation:NO]; + } +}; + +class BookmarkBarControllerTest : public BookmarkBarControllerTestBase { + public: + scoped_nsobject<BookmarkMenu> menu_; + scoped_nsobject<NSMenuItem> menu_item_; + scoped_nsobject<NSButtonCell> cell_; + scoped_nsobject<BookmarkBarControllerNoOpen> bar_; + + BookmarkBarControllerTest() { + bar_.reset( + [[BookmarkBarControllerNoOpen alloc] + initWithBrowser:helper_.browser() + initialWidth:NSWidth([parent_view_ frame]) + delegate:nil + resizeDelegate:resizeDelegate_.get()]); + + InstallAndToggleBar(bar_.get()); + + // Create a menu/item to act like a sender + menu_.reset([[BookmarkMenu alloc] initWithTitle:@"I_dont_care"]); + menu_item_.reset([[NSMenuItem alloc] + initWithTitle:@"still_dont_care" + action:NULL + keyEquivalent:@""]); + cell_.reset([[NSButtonCell alloc] init]); + [menu_item_ setMenu:menu_.get()]; + [menu_ setDelegate:cell_.get()]; + } + + // Return a menu item that points to the given URL. + NSMenuItem* ItemForBookmarkBarMenu(GURL& gurl) { + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + const BookmarkNode* parent = model->GetBookmarkBarNode(); + const BookmarkNode* node = model->AddURL(parent, parent->GetChildCount(), + ASCIIToUTF16("A title"), gurl); + [menu_ setRepresentedObject:[NSNumber numberWithLongLong:node->id()]]; + return menu_item_; + } + + // Does NOT take ownership of node. + NSMenuItem* ItemForBookmarkBarMenu(const BookmarkNode* node) { + [menu_ setRepresentedObject:[NSNumber numberWithLongLong:node->id()]]; + return menu_item_; + } + + BookmarkBarControllerNoOpen* noOpenBar() { + return (BookmarkBarControllerNoOpen*)bar_.get(); + } +}; + +TEST_F(BookmarkBarControllerTest, ShowWhenShowBookmarkBarTrue) { + [bar_ updateAndShowNormalBar:YES + showDetachedBar:NO + withAnimation:NO]; + EXPECT_TRUE([bar_ isInState:bookmarks::kShowingState]); + EXPECT_FALSE([bar_ isInState:bookmarks::kDetachedState]); + EXPECT_TRUE([bar_ isVisible]); + EXPECT_FALSE([bar_ isAnimationRunning]); + EXPECT_FALSE([[bar_ view] isHidden]); + EXPECT_GT([resizeDelegate_ height], 0); + EXPECT_GT([[bar_ view] frame].size.height, 0); +} + +TEST_F(BookmarkBarControllerTest, HideWhenShowBookmarkBarFalse) { + [bar_ updateAndShowNormalBar:NO + showDetachedBar:NO + withAnimation:NO]; + EXPECT_FALSE([bar_ isInState:bookmarks::kShowingState]); + EXPECT_FALSE([bar_ isInState:bookmarks::kDetachedState]); + EXPECT_FALSE([bar_ isVisible]); + EXPECT_FALSE([bar_ isAnimationRunning]); + EXPECT_TRUE([[bar_ view] isHidden]); + EXPECT_EQ(0, [resizeDelegate_ height]); + EXPECT_EQ(0, [[bar_ view] frame].size.height); +} + +TEST_F(BookmarkBarControllerTest, HideWhenShowBookmarkBarTrueButDisabled) { + [bar_ setBookmarkBarEnabled:NO]; + [bar_ updateAndShowNormalBar:YES + showDetachedBar:NO + withAnimation:NO]; + EXPECT_TRUE([bar_ isInState:bookmarks::kShowingState]); + EXPECT_FALSE([bar_ isInState:bookmarks::kDetachedState]); + EXPECT_FALSE([bar_ isVisible]); + EXPECT_FALSE([bar_ isAnimationRunning]); + EXPECT_TRUE([[bar_ view] isHidden]); + EXPECT_EQ(0, [resizeDelegate_ height]); + EXPECT_EQ(0, [[bar_ view] frame].size.height); +} + +TEST_F(BookmarkBarControllerTest, ShowOnNewTabPage) { + [bar_ updateAndShowNormalBar:NO + showDetachedBar:YES + withAnimation:NO]; + EXPECT_FALSE([bar_ isInState:bookmarks::kShowingState]); + EXPECT_TRUE([bar_ isInState:bookmarks::kDetachedState]); + EXPECT_TRUE([bar_ isVisible]); + EXPECT_FALSE([bar_ isAnimationRunning]); + EXPECT_FALSE([[bar_ view] isHidden]); + EXPECT_GT([resizeDelegate_ height], 0); + EXPECT_GT([[bar_ view] frame].size.height, 0); + + // Make sure no buttons fall off the bar, either now or when resized + // bigger or smaller. + CGFloat sizes[] = { 300.0, -100.0, 200.0, -420.0 }; + CGFloat previousX = 0.0; + for (unsigned x = 0; x < arraysize(sizes); x++) { + // Confirm the buttons moved from the last check (which may be + // init but that's fine). + CGFloat newX = [[bar_ offTheSideButton] frame].origin.x; + EXPECT_NE(previousX, newX); + previousX = newX; + + // Confirm the buttons have a reasonable bounds. Recall that |-frame| + // returns rectangles in the superview's coordinates. + NSRect buttonViewFrame = + [[bar_ buttonView] convertRect:[[bar_ buttonView] frame] + fromView:[[bar_ buttonView] superview]]; + EXPECT_EQ([bar_ buttonView], [[bar_ offTheSideButton] superview]); + EXPECT_TRUE(NSContainsRect(buttonViewFrame, + [[bar_ offTheSideButton] frame])); + EXPECT_EQ([bar_ buttonView], [[bar_ otherBookmarksButton] superview]); + EXPECT_TRUE(NSContainsRect(buttonViewFrame, + [[bar_ otherBookmarksButton] frame])); + + // Now move them implicitly. + // We confirm FrameChangeNotification works in the next unit test; + // we simply assume it works here to resize or reposition the + // buttons above. + NSRect frame = [[bar_ view] frame]; + frame.size.width += sizes[x]; + [[bar_ view] setFrame:frame]; + } +} + +// Test whether |-updateAndShowNormalBar:...| sets states as we expect. Make +// sure things don't crash. +TEST_F(BookmarkBarControllerTest, StateChanges) { + // First, go in one-at-a-time cycle. + [bar_ updateAndShowNormalBar:NO + showDetachedBar:NO + withAnimation:NO]; + EXPECT_EQ(bookmarks::kHiddenState, [bar_ visualState]); + EXPECT_FALSE([bar_ isVisible]); + EXPECT_FALSE([bar_ isAnimationRunning]); + [bar_ updateAndShowNormalBar:YES + showDetachedBar:NO + withAnimation:NO]; + EXPECT_EQ(bookmarks::kShowingState, [bar_ visualState]); + EXPECT_TRUE([bar_ isVisible]); + EXPECT_FALSE([bar_ isAnimationRunning]); + [bar_ updateAndShowNormalBar:YES + showDetachedBar:YES + withAnimation:NO]; + EXPECT_EQ(bookmarks::kShowingState, [bar_ visualState]); + EXPECT_TRUE([bar_ isVisible]); + EXPECT_FALSE([bar_ isAnimationRunning]); + [bar_ updateAndShowNormalBar:NO + showDetachedBar:YES + withAnimation:NO]; + EXPECT_EQ(bookmarks::kDetachedState, [bar_ visualState]); + EXPECT_TRUE([bar_ isVisible]); + EXPECT_FALSE([bar_ isAnimationRunning]); + + // Now try some "jumps". + for (int i = 0; i < 2; i++) { + [bar_ updateAndShowNormalBar:NO + showDetachedBar:NO + withAnimation:NO]; + EXPECT_EQ(bookmarks::kHiddenState, [bar_ visualState]); + EXPECT_FALSE([bar_ isVisible]); + EXPECT_FALSE([bar_ isAnimationRunning]); + [bar_ updateAndShowNormalBar:YES + showDetachedBar:YES + withAnimation:NO]; + EXPECT_EQ(bookmarks::kShowingState, [bar_ visualState]); + EXPECT_TRUE([bar_ isVisible]); + EXPECT_FALSE([bar_ isAnimationRunning]); + } + + // Now try some "jumps". + for (int i = 0; i < 2; i++) { + [bar_ updateAndShowNormalBar:YES + showDetachedBar:NO + withAnimation:NO]; + EXPECT_EQ(bookmarks::kShowingState, [bar_ visualState]); + EXPECT_TRUE([bar_ isVisible]); + EXPECT_FALSE([bar_ isAnimationRunning]); + [bar_ updateAndShowNormalBar:NO + showDetachedBar:YES + withAnimation:NO]; + EXPECT_EQ(bookmarks::kDetachedState, [bar_ visualState]); + EXPECT_TRUE([bar_ isVisible]); + EXPECT_FALSE([bar_ isAnimationRunning]); + } +} + +// Make sure we're watching for frame change notifications. +TEST_F(BookmarkBarControllerTest, FrameChangeNotification) { + scoped_nsobject<BookmarkBarControllerTogglePong> bar; + bar.reset( + [[BookmarkBarControllerTogglePong alloc] + initWithBrowser:helper_.browser() + initialWidth:100 // arbitrary + delegate:nil + resizeDelegate:resizeDelegate_.get()]); + InstallAndToggleBar(bar.get()); + + // Send a frame did change notification for the pong's view. + [[NSNotificationCenter defaultCenter] + postNotificationName:NSViewFrameDidChangeNotification + object:[bar view]]; + + EXPECT_GT([bar toggles], 0); +} + +// Confirm our "no items" container goes away when we add the 1st +// bookmark, and comes back when we delete the bookmark. +TEST_F(BookmarkBarControllerTest, NoItemContainerGoesAway) { + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + const BookmarkNode* bar = model->GetBookmarkBarNode(); + + [bar_ loaded:model]; + BookmarkBarView* view = [bar_ buttonView]; + DCHECK(view); + NSView* noItemContainer = [view noItemContainer]; + DCHECK(noItemContainer); + + EXPECT_FALSE([noItemContainer isHidden]); + const BookmarkNode* node = model->AddURL(bar, bar->GetChildCount(), + ASCIIToUTF16("title"), + GURL("http://www.google.com")); + EXPECT_TRUE([noItemContainer isHidden]); + model->Remove(bar, bar->IndexOfChild(node)); + EXPECT_FALSE([noItemContainer isHidden]); + + // Now try it using a bookmark from the Other Bookmarks. + const BookmarkNode* otherBookmarks = model->other_node(); + node = model->AddURL(otherBookmarks, otherBookmarks->GetChildCount(), + ASCIIToUTF16("TheOther"), + GURL("http://www.other.com")); + EXPECT_FALSE([noItemContainer isHidden]); + // Move it from Other Bookmarks to the bar. + model->Move(node, bar, 0); + EXPECT_TRUE([noItemContainer isHidden]); + // Move it back to Other Bookmarks from the bar. + model->Move(node, otherBookmarks, 0); + EXPECT_FALSE([noItemContainer isHidden]); +} + +// Confirm off the side button only enabled when reasonable. +TEST_F(BookmarkBarControllerTest, OffTheSideButtonHidden) { + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + [bar_ setIgnoreAnimations:YES]; + + [bar_ loaded:model]; + EXPECT_TRUE([bar_ offTheSideButtonIsHidden]); + + for (int i = 0; i < 2; i++) { + model->SetURLStarred(GURL("http://www.foo.com"), ASCIIToUTF16("small"), + true); + EXPECT_TRUE([bar_ offTheSideButtonIsHidden]); + } + + const BookmarkNode* parent = model->GetBookmarkBarNode(); + for (int i = 0; i < 20; i++) { + model->AddURL(parent, parent->GetChildCount(), + ASCIIToUTF16("super duper wide title"), + GURL("http://superfriends.hall-of-justice.edu")); + } + EXPECT_FALSE([bar_ offTheSideButtonIsHidden]); + + // Open the "off the side" and start deleting nodes. Make sure + // deletion of the last node in "off the side" causes the folder to + // close. + EXPECT_FALSE([bar_ offTheSideButtonIsHidden]); + NSButton* offTheSideButton = [bar_ offTheSideButton]; + // Open "off the side" menu. + [bar_ openOffTheSideFolderFromButton:offTheSideButton]; + BookmarkBarFolderController* bbfc = [bar_ folderController]; + EXPECT_TRUE(bbfc); + [bbfc setIgnoreAnimations:YES]; + while (parent->GetChildCount()) { + // We've completed the job so we're done. + if ([bar_ offTheSideButtonIsHidden]) + break; + // Delete the last button. + model->Remove(parent, parent->GetChildCount()-1); + // If last one make sure the menu is closed and the button is hidden. + // Else make sure menu stays open. + if ([bar_ offTheSideButtonIsHidden]) { + EXPECT_FALSE([bar_ folderController]); + } else { + EXPECT_TRUE([bar_ folderController]); + } + } +} + +// http://crbug.com/46175 is a crash when deleting bookmarks from the +// off-the-side menu while it is open. This test tries to bang hard +// in this area to reproduce the crash. +TEST_F(BookmarkBarControllerTest, DeleteFromOffTheSideWhileItIsOpen) { + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + [bar_ setIgnoreAnimations:YES]; + [bar_ loaded:model]; + + // Add a lot of bookmarks (per the bug). + const BookmarkNode* parent = model->GetBookmarkBarNode(); + for (int i = 0; i < 100; i++) { + std::ostringstream title; + title << "super duper wide title " << i; + model->AddURL(parent, parent->GetChildCount(), ASCIIToUTF16(title.str()), + GURL("http://superfriends.hall-of-justice.edu")); + } + EXPECT_FALSE([bar_ offTheSideButtonIsHidden]); + + // Open "off the side" menu. + NSButton* offTheSideButton = [bar_ offTheSideButton]; + [bar_ openOffTheSideFolderFromButton:offTheSideButton]; + BookmarkBarFolderController* bbfc = [bar_ folderController]; + EXPECT_TRUE(bbfc); + [bbfc setIgnoreAnimations:YES]; + + // Start deleting items; try and delete randomish ones in case it + // makes a difference. + int indices[] = { 2, 4, 5, 1, 7, 9, 2, 0, 10, 9 }; + while (parent->GetChildCount()) { + for (unsigned int i = 0; i < arraysize(indices); i++) { + if (indices[i] < parent->GetChildCount()) { + // First we mouse-enter the button to make things harder. + NSArray* buttons = [bbfc buttons]; + for (BookmarkButton* button in buttons) { + if ([button bookmarkNode] == parent->GetChild(indices[i])) { + [bbfc mouseEnteredButton:button event:nil]; + break; + } + } + // Then we remove the node. This triggers the button to get + // deleted. + model->Remove(parent, indices[i]); + // Force visual update which is otherwise delayed. + [[bbfc window] displayIfNeeded]; + } + } + } +} + +// Test whether |-dragShouldLockBarVisibility| returns NO iff the bar is +// detached. +TEST_F(BookmarkBarControllerTest, TestDragShouldLockBarVisibility) { + [bar_ updateAndShowNormalBar:NO + showDetachedBar:NO + withAnimation:NO]; + EXPECT_TRUE([bar_ dragShouldLockBarVisibility]); + + [bar_ updateAndShowNormalBar:YES + showDetachedBar:NO + withAnimation:NO]; + EXPECT_TRUE([bar_ dragShouldLockBarVisibility]); + + [bar_ updateAndShowNormalBar:YES + showDetachedBar:YES + withAnimation:NO]; + EXPECT_TRUE([bar_ dragShouldLockBarVisibility]); + + [bar_ updateAndShowNormalBar:NO + showDetachedBar:YES + withAnimation:NO]; + EXPECT_FALSE([bar_ dragShouldLockBarVisibility]); +} + +TEST_F(BookmarkBarControllerTest, TagMap) { + int64 ids[] = { 1, 3, 4, 40, 400, 4000, 800000000, 2, 123456789 }; + std::vector<int32> tags; + + // Generate some tags + for (unsigned int i = 0; i < arraysize(ids); i++) { + tags.push_back([bar_ menuTagFromNodeId:ids[i]]); + } + + // Confirm reverse mapping. + for (unsigned int i = 0; i < arraysize(ids); i++) { + EXPECT_EQ(ids[i], [bar_ nodeIdFromMenuTag:tags[i]]); + } + + // Confirm uniqueness. + std::sort(tags.begin(), tags.end()); + for (unsigned int i=0; i<(tags.size()-1); i++) { + EXPECT_NE(tags[i], tags[i+1]); + } +} + +TEST_F(BookmarkBarControllerTest, MenuForFolderNode) { + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + + // First make sure something (e.g. "(empty)" string) is always present. + NSMenu* menu = [bar_ menuForFolderNode:model->GetBookmarkBarNode()]; + EXPECT_GT([menu numberOfItems], 0); + + // Test two bookmarks. + GURL gurl("http://www.foo.com"); + model->SetURLStarred(gurl, ASCIIToUTF16("small"), true); + model->SetURLStarred(GURL("http://www.cnn.com"), ASCIIToUTF16("bigger title"), + true); + menu = [bar_ menuForFolderNode:model->GetBookmarkBarNode()]; + EXPECT_EQ([menu numberOfItems], 2); + NSMenuItem *item = [menu itemWithTitle:@"bigger title"]; + EXPECT_TRUE(item); + item = [menu itemWithTitle:@"small"]; + EXPECT_TRUE(item); + if (item) { + int64 tag = [bar_ nodeIdFromMenuTag:[item tag]]; + const BookmarkNode* node = model->GetNodeByID(tag); + EXPECT_TRUE(node); + EXPECT_EQ(gurl, node->GetURL()); + } + + // Test with an actual folder as well + const BookmarkNode* parent = model->GetBookmarkBarNode(); + const BookmarkNode* folder = model->AddGroup(parent, + parent->GetChildCount(), + ASCIIToUTF16("group")); + model->AddURL(folder, folder->GetChildCount(), + ASCIIToUTF16("f1"), GURL("http://framma-lamma.com")); + model->AddURL(folder, folder->GetChildCount(), + ASCIIToUTF16("f2"), GURL("http://framma-lamma-ding-dong.com")); + menu = [bar_ menuForFolderNode:model->GetBookmarkBarNode()]; + EXPECT_EQ([menu numberOfItems], 3); + + item = [menu itemWithTitle:@"group"]; + EXPECT_TRUE(item); + EXPECT_TRUE([item hasSubmenu]); + NSMenu *submenu = [item submenu]; + EXPECT_TRUE(submenu); + EXPECT_EQ(2, [submenu numberOfItems]); + EXPECT_TRUE([submenu itemWithTitle:@"f1"]); + EXPECT_TRUE([submenu itemWithTitle:@"f2"]); +} + +// Confirm openBookmark: forwards the request to the controller's delegate +TEST_F(BookmarkBarControllerTest, OpenBookmark) { + GURL gurl("http://walla.walla.ding.dong.com"); + scoped_ptr<BookmarkNode> node(new BookmarkNode(gurl)); + + scoped_nsobject<BookmarkButtonCell> cell([[BookmarkButtonCell alloc] init]); + [cell setBookmarkNode:node.get()]; + scoped_nsobject<BookmarkButton> button([[BookmarkButton alloc] init]); + [button setCell:cell.get()]; + [cell setRepresentedObject:[NSValue valueWithPointer:node.get()]]; + + [bar_ openBookmark:button]; + EXPECT_EQ(noOpenBar()->urls_[0], node->GetURL()); + EXPECT_EQ(noOpenBar()->dispositions_[0], CURRENT_TAB); +} + +// Confirm opening of bookmarks works from the menus (different +// dispositions than clicking on the button). +TEST_F(BookmarkBarControllerTest, OpenBookmarkFromMenus) { + const char* urls[] = { "http://walla.walla.ding.dong.com", + "http://i_dont_know.com", + "http://cee.enn.enn.dot.com" }; + SEL selectors[] = { @selector(openBookmarkInNewForegroundTab:), + @selector(openBookmarkInNewWindow:), + @selector(openBookmarkInIncognitoWindow:) }; + WindowOpenDisposition dispositions[] = { NEW_FOREGROUND_TAB, + NEW_WINDOW, + OFF_THE_RECORD }; + for (unsigned int i = 0; i < arraysize(dispositions); i++) { + GURL gurl(urls[i]); + [bar_ performSelector:selectors[i] + withObject:ItemForBookmarkBarMenu(gurl)]; + EXPECT_EQ(noOpenBar()->urls_[0], gurl); + EXPECT_EQ(noOpenBar()->dispositions_[0], dispositions[i]); + [bar_ clear]; + } +} + +TEST_F(BookmarkBarControllerTest, TestAddRemoveAndClear) { + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + NSView* buttonView = [bar_ buttonView]; + EXPECT_EQ(0U, [[bar_ buttons] count]); + unsigned int initial_subview_count = [[buttonView subviews] count]; + + // Make sure a redundant call doesn't choke + [bar_ clearBookmarkBar]; + EXPECT_EQ(0U, [[bar_ buttons] count]); + EXPECT_EQ(initial_subview_count, [[buttonView subviews] count]); + + GURL gurl1("http://superfriends.hall-of-justice.edu"); + // Short titles increase the chances of this test succeeding if the view is + // narrow. + // TODO(viettrungluu): make the test independent of window/view size, font + // metrics, button size and spacing, and everything else. + string16 title1(ASCIIToUTF16("x")); + model->SetURLStarred(gurl1, title1, true); + EXPECT_EQ(1U, [[bar_ buttons] count]); + EXPECT_EQ(1+initial_subview_count, [[buttonView subviews] count]); + + GURL gurl2("http://legion-of-doom.gov"); + string16 title2(ASCIIToUTF16("y")); + model->SetURLStarred(gurl2, title2, true); + EXPECT_EQ(2U, [[bar_ buttons] count]); + EXPECT_EQ(2+initial_subview_count, [[buttonView subviews] count]); + + for (int i = 0; i < 3; i++) { + // is_starred=false --> remove the bookmark + model->SetURLStarred(gurl2, title2, false); + EXPECT_EQ(1U, [[bar_ buttons] count]); + EXPECT_EQ(1+initial_subview_count, [[buttonView subviews] count]); + + // and bring it back + model->SetURLStarred(gurl2, title2, true); + EXPECT_EQ(2U, [[bar_ buttons] count]); + EXPECT_EQ(2+initial_subview_count, [[buttonView subviews] count]); + } + + [bar_ clearBookmarkBar]; + EXPECT_EQ(0U, [[bar_ buttons] count]); + EXPECT_EQ(initial_subview_count, [[buttonView subviews] count]); + + // Explicit test of loaded: since this is a convenient spot + [bar_ loaded:model]; + EXPECT_EQ(2U, [[bar_ buttons] count]); + EXPECT_EQ(2+initial_subview_count, [[buttonView subviews] count]); +} + +// Make sure we don't create too many buttons; we only really need +// ones that will be visible. +TEST_F(BookmarkBarControllerTest, TestButtonLimits) { + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + EXPECT_EQ(0U, [[bar_ buttons] count]); + // Add one; make sure we see it. + const BookmarkNode* parent = model->GetBookmarkBarNode(); + model->AddURL(parent, parent->GetChildCount(), + ASCIIToUTF16("title"), GURL("http://www.google.com")); + EXPECT_EQ(1U, [[bar_ buttons] count]); + + // Add 30 which we expect to be 'too many'. Make sure we don't see + // 30 buttons. + model->Remove(parent, 0); + EXPECT_EQ(0U, [[bar_ buttons] count]); + for (int i=0; i<30; i++) { + model->AddURL(parent, parent->GetChildCount(), + ASCIIToUTF16("title"), GURL("http://www.google.com")); + } + int count = [[bar_ buttons] count]; + EXPECT_LT(count, 30L); + + // Add 10 more (to the front of the list so the on-screen buttons + // would change) and make sure the count stays the same. + for (int i=0; i<10; i++) { + model->AddURL(parent, 0, /* index is 0, so front, not end */ + ASCIIToUTF16("title"), GURL("http://www.google.com")); + } + + // Finally, grow the view and make sure the button count goes up. + NSRect frame = [[bar_ view] frame]; + frame.size.width += 600; + [[bar_ view] setFrame:frame]; + int finalcount = [[bar_ buttons] count]; + EXPECT_GT(finalcount, count); +} + +// Make sure that each button we add marches to the right and does not +// overlap with the previous one. +TEST_F(BookmarkBarControllerTest, TestButtonMarch) { + scoped_nsobject<NSMutableArray> cells([[NSMutableArray alloc] init]); + + CGFloat widths[] = { 10, 10, 100, 10, 500, 500, 80000, 60000, 1, 345 }; + for (unsigned int i = 0; i < arraysize(widths); i++) { + NSCell* cell = [[CellWithDesiredSize alloc] + initTextCell:@"foo" + desiredSize:NSMakeSize(widths[i], 30)]; + [cells addObject:cell]; + [cell release]; + } + + int x_offset = 0; + CGFloat x_end = x_offset; // end of the previous button + for (unsigned int i = 0; i < arraysize(widths); i++) { + NSRect r = [bar_ frameForBookmarkButtonFromCell:[cells objectAtIndex:i] + xOffset:&x_offset]; + EXPECT_GE(r.origin.x, x_end); + x_end = NSMaxX(r); + } +} + +TEST_F(BookmarkBarControllerTest, CheckForGrowth) { + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + GURL gurl1("http://www.google.com"); + string16 title1(ASCIIToUTF16("x")); + model->SetURLStarred(gurl1, title1, true); + + GURL gurl2("http://www.google.com/blah"); + string16 title2(ASCIIToUTF16("y")); + model->SetURLStarred(gurl2, title2, true); + + EXPECT_EQ(2U, [[bar_ buttons] count]); + CGFloat width_1 = [[[bar_ buttons] objectAtIndex:0] frame].size.width; + CGFloat x_2 = [[[bar_ buttons] objectAtIndex:1] frame].origin.x; + + NSButton* first = [[bar_ buttons] objectAtIndex:0]; + [[first cell] setTitle:@"This is a really big title; watch out mom!"]; + [bar_ checkForBookmarkButtonGrowth:first]; + + // Make sure the 1st button is now wider, the 2nd one is moved over, + // and they don't overlap. + NSRect frame_1 = [[[bar_ buttons] objectAtIndex:0] frame]; + NSRect frame_2 = [[[bar_ buttons] objectAtIndex:1] frame]; + EXPECT_GT(frame_1.size.width, width_1); + EXPECT_GT(frame_2.origin.x, x_2); + EXPECT_GE(frame_2.origin.x, frame_1.origin.x + frame_1.size.width); +} + +TEST_F(BookmarkBarControllerTest, DeleteBookmark) { + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + + const char* urls[] = { "https://secret.url.com", + "http://super.duper.web.site.for.doodz.gov", + "http://www.foo-bar-baz.com/" }; + const BookmarkNode* parent = model->GetBookmarkBarNode(); + for (unsigned int i = 0; i < arraysize(urls); i++) { + model->AddURL(parent, parent->GetChildCount(), + ASCIIToUTF16("title"), GURL(urls[i])); + } + EXPECT_EQ(3, parent->GetChildCount()); + const BookmarkNode* middle_node = parent->GetChild(1); + + NSMenuItem* item = ItemForBookmarkBarMenu(middle_node); + [bar_ deleteBookmark:item]; + EXPECT_EQ(2, parent->GetChildCount()); + EXPECT_EQ(parent->GetChild(0)->GetURL(), GURL(urls[0])); + // node 2 moved into spot 1 + EXPECT_EQ(parent->GetChild(1)->GetURL(), GURL(urls[2])); +} + +// TODO(jrg): write a test to confirm that nodeFavIconLoaded calls +// checkForBookmarkButtonGrowth:. + +TEST_F(BookmarkBarControllerTest, Cell) { + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + [bar_ loaded:model]; + + const BookmarkNode* parent = model->GetBookmarkBarNode(); + model->AddURL(parent, parent->GetChildCount(), + ASCIIToUTF16("supertitle"), + GURL("http://superfriends.hall-of-justice.edu")); + const BookmarkNode* node = parent->GetChild(0); + + NSCell* cell = [bar_ cellForBookmarkNode:node]; + EXPECT_TRUE(cell); + EXPECT_NSEQ(@"supertitle", [cell title]); + EXPECT_EQ(node, [[cell representedObject] pointerValue]); + EXPECT_TRUE([cell menu]); + + // Empty cells have no menu. + cell = [bar_ cellForBookmarkNode:nil]; + EXPECT_FALSE([cell menu]); + // Even empty cells have a title (of "(empty)") + EXPECT_TRUE([cell title]); + + // cell is autoreleased; no need to release here +} + +// Test drawing, mostly to ensure nothing leaks or crashes. +TEST_F(BookmarkBarControllerTest, Display) { + [[bar_ view] display]; +} + +// Test that middle clicking on a bookmark button results in an open action. +TEST_F(BookmarkBarControllerTest, MiddleClick) { + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + GURL gurl1("http://www.google.com/"); + string16 title1(ASCIIToUTF16("x")); + model->SetURLStarred(gurl1, title1, true); + + EXPECT_EQ(1U, [[bar_ buttons] count]); + NSButton* first = [[bar_ buttons] objectAtIndex:0]; + EXPECT_TRUE(first); + + [first otherMouseUp:test_event_utils::MakeMouseEvent(NSOtherMouseUp, 0)]; + EXPECT_EQ(noOpenBar()->urls_.size(), 1U); +} + +TEST_F(BookmarkBarControllerTest, DisplaysHelpMessageOnEmpty) { + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + [bar_ loaded:model]; + EXPECT_FALSE([[[bar_ buttonView] noItemContainer] isHidden]); +} + +TEST_F(BookmarkBarControllerTest, HidesHelpMessageWithBookmark) { + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + + const BookmarkNode* parent = model->GetBookmarkBarNode(); + model->AddURL(parent, parent->GetChildCount(), + ASCIIToUTF16("title"), GURL("http://one.com")); + + [bar_ loaded:model]; + EXPECT_TRUE([[[bar_ buttonView] noItemContainer] isHidden]); +} + +TEST_F(BookmarkBarControllerTest, BookmarkButtonSizing) { + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + + const BookmarkNode* parent = model->GetBookmarkBarNode(); + model->AddURL(parent, parent->GetChildCount(), + ASCIIToUTF16("title"), GURL("http://one.com")); + + [bar_ loaded:model]; + + // Make sure the internal bookmark button also is the correct height. + NSArray* buttons = [bar_ buttons]; + EXPECT_GT([buttons count], 0u); + for (NSButton* button in buttons) { + EXPECT_FLOAT_EQ( + (bookmarks::kBookmarkBarHeight + bookmarks::kVisualHeightOffset) - 2 * + bookmarks::kBookmarkVerticalPadding, + [button frame].size.height); + } +} + +TEST_F(BookmarkBarControllerTest, DropBookmarks) { + const char* urls[] = { + "http://qwantz.com", + "http://xkcd.com", + "javascript:alert('lolwut')", + "file://localhost/tmp/local-file.txt" // As if dragged from the desktop. + }; + const char* titles[] = { + "Philosophoraptor", + "Can't draw", + "Inspiration", + "Frum stuf" + }; + EXPECT_EQ(arraysize(urls), arraysize(titles)); + + NSMutableArray* nsurls = [NSMutableArray array]; + NSMutableArray* nstitles = [NSMutableArray array]; + for (size_t i = 0; i < arraysize(urls); ++i) { + [nsurls addObject:base::SysUTF8ToNSString(urls[i])]; + [nstitles addObject:base::SysUTF8ToNSString(titles[i])]; + } + + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + const BookmarkNode* parent = model->GetBookmarkBarNode(); + [bar_ addURLs:nsurls withTitles:nstitles at:NSZeroPoint]; + EXPECT_EQ(4, parent->GetChildCount()); + for (int i = 0; i < parent->GetChildCount(); ++i) { + GURL gurl = parent->GetChild(i)->GetURL(); + if (gurl.scheme() == "http" || + gurl.scheme() == "javascript") { + EXPECT_EQ(parent->GetChild(i)->GetURL(), GURL(urls[i])); + } else { + // Be flexible if the scheme needed to be added. + std::string gurl_string = gurl.spec(); + std::string my_string = parent->GetChild(i)->GetURL().spec(); + EXPECT_NE(gurl_string.find(my_string), std::string::npos); + } + EXPECT_EQ(parent->GetChild(i)->GetTitle(), ASCIIToUTF16(titles[i])); + } +} + +TEST_F(BookmarkBarControllerTest, TestButtonOrBar) { + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + GURL gurl1("http://www.google.com"); + string16 title1(ASCIIToUTF16("x")); + model->SetURLStarred(gurl1, title1, true); + + GURL gurl2("http://www.google.com/gurl_power"); + string16 title2(ASCIIToUTF16("gurl power")); + model->SetURLStarred(gurl2, title2, true); + + NSButton* first = [[bar_ buttons] objectAtIndex:0]; + NSButton* second = [[bar_ buttons] objectAtIndex:1]; + EXPECT_TRUE(first && second); + + NSMenuItem* menuItem = [[[first cell] menu] itemAtIndex:0]; + const BookmarkNode* node = [bar_ nodeFromMenuItem:menuItem]; + EXPECT_TRUE(node); + EXPECT_EQ(node, model->GetBookmarkBarNode()->GetChild(0)); + + menuItem = [[[second cell] menu] itemAtIndex:0]; + node = [bar_ nodeFromMenuItem:menuItem]; + EXPECT_TRUE(node); + EXPECT_EQ(node, model->GetBookmarkBarNode()->GetChild(1)); + + menuItem = [[[bar_ view] menu] itemAtIndex:0]; + node = [bar_ nodeFromMenuItem:menuItem]; + EXPECT_TRUE(node); + EXPECT_EQ(node, model->GetBookmarkBarNode()); +} + +TEST_F(BookmarkBarControllerTest, TestMenuNodeAndDisable) { + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + const BookmarkNode* parent = model->GetBookmarkBarNode(); + const BookmarkNode* folder = model->AddGroup(parent, + parent->GetChildCount(), + ASCIIToUTF16("group")); + NSButton* button = [[bar_ buttons] objectAtIndex:0]; + EXPECT_TRUE(button); + + // Confirm the menu knows which node it is talking about + BookmarkMenu* menu = static_cast<BookmarkMenu*>([[button cell] menu]); + EXPECT_TRUE(menu); + EXPECT_TRUE([menu isKindOfClass:[BookmarkMenu class]]); + EXPECT_EQ(folder->id(), [menu id]); + + // Make sure "Open All" is disabled (nothing to open -- no children!) + // (Assumes "Open All" is the 1st item) + NSMenuItem* item = [menu itemAtIndex:0]; + EXPECT_FALSE([bar_ validateUserInterfaceItem:item]); + + // Now add a child and make sure the item would be enabled. + model->AddURL(folder, folder->GetChildCount(), + ASCIIToUTF16("super duper wide title"), + GURL("http://superfriends.hall-of-justice.edu")); + EXPECT_TRUE([bar_ validateUserInterfaceItem:item]); +} + +TEST_F(BookmarkBarControllerTest, TestDragButton) { + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + + GURL gurls[] = { GURL("http://www.google.com/a"), + GURL("http://www.google.com/b"), + GURL("http://www.google.com/c") }; + string16 titles[] = { ASCIIToUTF16("a"), + ASCIIToUTF16("b"), + ASCIIToUTF16("c") }; + for (unsigned i = 0; i < arraysize(titles); i++) { + model->SetURLStarred(gurls[i], titles[i], true); + } + + EXPECT_EQ([[bar_ buttons] count], arraysize(titles)); + EXPECT_NSEQ(@"a", [[[bar_ buttons] objectAtIndex:0] title]); + + [bar_ dragButton:[[bar_ buttons] objectAtIndex:2] + to:NSMakePoint(0, 0) + copy:NO]; + EXPECT_NSEQ(@"c", [[[bar_ buttons] objectAtIndex:0] title]); + // Make sure a 'copy' did not happen. + EXPECT_EQ([[bar_ buttons] count], arraysize(titles)); + + [bar_ dragButton:[[bar_ buttons] objectAtIndex:1] + to:NSMakePoint(1000, 0) + copy:NO]; + EXPECT_NSEQ(@"c", [[[bar_ buttons] objectAtIndex:0] title]); + EXPECT_NSEQ(@"b", [[[bar_ buttons] objectAtIndex:1] title]); + EXPECT_NSEQ(@"a", [[[bar_ buttons] objectAtIndex:2] title]); + EXPECT_EQ([[bar_ buttons] count], arraysize(titles)); + + // A drop of the 1st between the next 2. + CGFloat x = NSMinX([[[bar_ buttons] objectAtIndex:2] frame]); + x += [[bar_ view] frame].origin.x; + [bar_ dragButton:[[bar_ buttons] objectAtIndex:0] + to:NSMakePoint(x, 0) + copy:NO]; + EXPECT_NSEQ(@"b", [[[bar_ buttons] objectAtIndex:0] title]); + EXPECT_NSEQ(@"c", [[[bar_ buttons] objectAtIndex:1] title]); + EXPECT_NSEQ(@"a", [[[bar_ buttons] objectAtIndex:2] title]); + EXPECT_EQ([[bar_ buttons] count], arraysize(titles)); + + // A drop on a non-folder button. (Shouldn't try and go in it.) + x = NSMidX([[[bar_ buttons] objectAtIndex:0] frame]); + x += [[bar_ view] frame].origin.x; + [bar_ dragButton:[[bar_ buttons] objectAtIndex:2] + to:NSMakePoint(x, 0) + copy:NO]; + EXPECT_EQ(arraysize(titles), [[bar_ buttons] count]); + + // A drop on a folder button. + const BookmarkNode* folder = model->AddGroup(model->GetBookmarkBarNode(), + 0, + ASCIIToUTF16("awesome group")); + DCHECK(folder); + model->AddURL(folder, 0, ASCIIToUTF16("already"), + GURL("http://www.google.com")); + EXPECT_EQ(arraysize(titles) + 1, [[bar_ buttons] count]); + EXPECT_EQ(1, folder->GetChildCount()); + x = NSMidX([[[bar_ buttons] objectAtIndex:0] frame]); + x += [[bar_ view] frame].origin.x; + string16 title = [[[bar_ buttons] objectAtIndex:2] bookmarkNode]->GetTitle(); + [bar_ dragButton:[[bar_ buttons] objectAtIndex:2] + to:NSMakePoint(x, 0) + copy:NO]; + // Gone from the bar + EXPECT_EQ(arraysize(titles), [[bar_ buttons] count]); + // In the folder + EXPECT_EQ(2, folder->GetChildCount()); + // At the end + EXPECT_EQ(title, folder->GetChild(1)->GetTitle()); +} + +TEST_F(BookmarkBarControllerTest, TestCopyButton) { + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + + GURL gurls[] = { GURL("http://www.google.com/a"), + GURL("http://www.google.com/b"), + GURL("http://www.google.com/c") }; + string16 titles[] = { ASCIIToUTF16("a"), + ASCIIToUTF16("b"), + ASCIIToUTF16("c") }; + for (unsigned i = 0; i < arraysize(titles); i++) { + model->SetURLStarred(gurls[i], titles[i], true); + } + EXPECT_EQ([[bar_ buttons] count], arraysize(titles)); + EXPECT_NSEQ(@"a", [[[bar_ buttons] objectAtIndex:0] title]); + + // Drag 'a' between 'b' and 'c'. + CGFloat x = NSMinX([[[bar_ buttons] objectAtIndex:2] frame]); + x += [[bar_ view] frame].origin.x; + [bar_ dragButton:[[bar_ buttons] objectAtIndex:0] + to:NSMakePoint(x, 0) + copy:YES]; + EXPECT_NSEQ(@"a", [[[bar_ buttons] objectAtIndex:0] title]); + EXPECT_NSEQ(@"b", [[[bar_ buttons] objectAtIndex:1] title]); + EXPECT_NSEQ(@"a", [[[bar_ buttons] objectAtIndex:2] title]); + EXPECT_NSEQ(@"c", [[[bar_ buttons] objectAtIndex:3] title]); + EXPECT_EQ([[bar_ buttons] count], 4U); +} + +// Fake a theme with colored text. Apply it and make sure bookmark +// buttons have the same colored text. Repeat more than once. +TEST_F(BookmarkBarControllerTest, TestThemedButton) { + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + model->SetURLStarred(GURL("http://www.foo.com"), ASCIIToUTF16("small"), true); + BookmarkButton* button = [[bar_ buttons] objectAtIndex:0]; + EXPECT_TRUE(button); + + NSArray* colors = [NSArray arrayWithObjects:[NSColor redColor], + [NSColor blueColor], + nil]; + for (NSColor* color in colors) { + FakeTheme theme(color); + [bar_ updateTheme:&theme]; + NSAttributedString* astr = [button attributedTitle]; + EXPECT_TRUE(astr); + EXPECT_NSEQ(@"small", [astr string]); + // Pick a char in the middle to test (index 3) + NSDictionary* attributes = [astr attributesAtIndex:3 effectiveRange:NULL]; + NSColor* newColor = + [attributes objectForKey:NSForegroundColorAttributeName]; + EXPECT_NSEQ(newColor, color); + } +} + +// Test that delegates and targets of buttons are cleared on dealloc. +TEST_F(BookmarkBarControllerTest, TestClearOnDealloc) { + // Make some bookmark buttons. + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + GURL gurls[] = { GURL("http://www.foo.com/"), + GURL("http://www.bar.com/"), + GURL("http://www.baz.com/") }; + string16 titles[] = { ASCIIToUTF16("a"), + ASCIIToUTF16("b"), + ASCIIToUTF16("c") }; + for (size_t i = 0; i < arraysize(titles); i++) + model->SetURLStarred(gurls[i], titles[i], true); + + // Get and retain the buttons so we can examine them after dealloc. + scoped_nsobject<NSArray> buttons([[bar_ buttons] retain]); + EXPECT_EQ([buttons count], arraysize(titles)); + + // Make sure that everything is set. + for (BookmarkButton* button in buttons.get()) { + ASSERT_TRUE([button isKindOfClass:[BookmarkButton class]]); + EXPECT_TRUE([button delegate]); + EXPECT_TRUE([button target]); + EXPECT_TRUE([button action]); + } + + // This will dealloc.... + bar_.reset(); + + // Make sure that everything is cleared. + for (BookmarkButton* button in buttons.get()) { + EXPECT_FALSE([button delegate]); + EXPECT_FALSE([button target]); + EXPECT_FALSE([button action]); + } +} + +TEST_F(BookmarkBarControllerTest, TestFolders) { + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + + // Create some folder buttons. + const BookmarkNode* parent = model->GetBookmarkBarNode(); + const BookmarkNode* folder = model->AddGroup(parent, + parent->GetChildCount(), + ASCIIToUTF16("group")); + model->AddURL(folder, folder->GetChildCount(), + ASCIIToUTF16("f1"), GURL("http://framma-lamma.com")); + folder = model->AddGroup(parent, parent->GetChildCount(), + ASCIIToUTF16("empty")); + + EXPECT_EQ([[bar_ buttons] count], 2U); + + // First confirm mouseEntered does nothing if "menus" aren't active. + NSEvent* event = test_event_utils::MakeMouseEvent(NSOtherMouseUp, 0); + [bar_ mouseEnteredButton:[[bar_ buttons] objectAtIndex:0] event:event]; + EXPECT_FALSE([bar_ folderController]); + + // Make one active. Entering it is now a no-op. + [bar_ openBookmarkFolderFromButton:[[bar_ buttons] objectAtIndex:0]]; + BookmarkBarFolderController* bbfc = [bar_ folderController]; + EXPECT_TRUE(bbfc); + [bar_ mouseEnteredButton:[[bar_ buttons] objectAtIndex:0] event:event]; + EXPECT_EQ(bbfc, [bar_ folderController]); + + // Enter a different one; a new folderController is active. + [bar_ mouseEnteredButton:[[bar_ buttons] objectAtIndex:1] event:event]; + EXPECT_NE(bbfc, [bar_ folderController]); + + // Confirm exited is a no-op. + [bar_ mouseExitedButton:[[bar_ buttons] objectAtIndex:1] event:event]; + EXPECT_NE(bbfc, [bar_ folderController]); + + // Clean up. + [bar_ closeBookmarkFolder:nil]; +} + +// Verify that the folder menu presentation properly tracks mouse movements +// over the bar. Until there is a click no folder menus should show. After a +// click on a folder folder menus should show until another click on a folder +// button, and a click outside the bar and its folder menus. +TEST_F(BookmarkBarControllerTest, TestFolderButtons) { + BookmarkModel& model(*helper_.profile()->GetBookmarkModel()); + const BookmarkNode* root = model.GetBookmarkBarNode(); + const std::string model_string("1b 2f:[ 2f1b 2f2b ] 3b 4f:[ 4f1b 4f2b ] "); + model_test_utils::AddNodesFromModelString(model, root, model_string); + + // Validate initial model and that we do not have a folder controller. + std::string actualModelString = model_test_utils::ModelStringFromNode(root); + EXPECT_EQ(model_string, actualModelString); + EXPECT_FALSE([bar_ folderController]); + + // Add a real bookmark so we can click on it. + const BookmarkNode* folder = root->GetChild(3); + model.AddURL(folder, folder->GetChildCount(), ASCIIToUTF16("CLICK ME"), + GURL("http://www.google.com/")); + + // Click on a folder button. + BookmarkButton* button = [bar_ buttonWithTitleEqualTo:@"4f"]; + EXPECT_TRUE(button); + [bar_ openBookmarkFolderFromButton:button]; + BookmarkBarFolderController* bbfc = [bar_ folderController]; + EXPECT_TRUE(bbfc); + + // Make sure a 2nd click on the same button closes things. + [bar_ openBookmarkFolderFromButton:button]; + EXPECT_FALSE([bar_ folderController]); + + // Next open is a different button. + button = [bar_ buttonWithTitleEqualTo:@"2f"]; + EXPECT_TRUE(button); + [bar_ openBookmarkFolderFromButton:button]; + EXPECT_TRUE([bar_ folderController]); + + // Mouse over a non-folder button and confirm controller has gone away. + button = [bar_ buttonWithTitleEqualTo:@"1b"]; + EXPECT_TRUE(button); + NSEvent* event = test_event_utils::MouseEventAtPoint([button center], + NSMouseMoved, 0); + [bar_ mouseEnteredButton:button event:event]; + EXPECT_FALSE([bar_ folderController]); + + // Mouse over the original folder and confirm a new controller. + button = [bar_ buttonWithTitleEqualTo:@"2f"]; + EXPECT_TRUE(button); + [bar_ mouseEnteredButton:button event:event]; + BookmarkBarFolderController* oldBBFC = [bar_ folderController]; + EXPECT_TRUE(oldBBFC); + + // 'Jump' over to a different folder and confirm a new controller. + button = [bar_ buttonWithTitleEqualTo:@"4f"]; + EXPECT_TRUE(button); + [bar_ mouseEnteredButton:button event:event]; + BookmarkBarFolderController* newBBFC = [bar_ folderController]; + EXPECT_TRUE(newBBFC); + EXPECT_NE(oldBBFC, newBBFC); + + // A click on a real bookmark should close and stop tracking the folder menus. + BookmarkButton* bookmarkButton = [newBBFC buttonWithTitleEqualTo:@"CLICK ME"]; + EXPECT_TRUE(bookmarkButton); + [newBBFC openBookmark:bookmarkButton]; + EXPECT_FALSE([bar_ folderController]); + [bar_ mouseEnteredButton:button event:event]; + EXPECT_FALSE([bar_ folderController]); +} + +// Make sure the "off the side" folder looks like a bookmark folder +// but only contains "off the side" items. +TEST_F(BookmarkBarControllerTest, OffTheSideFolder) { + + // It starts hidden. + EXPECT_TRUE([bar_ offTheSideButtonIsHidden]); + + // Create some buttons. + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + const BookmarkNode* parent = model->GetBookmarkBarNode(); + for (int x = 0; x < 30; x++) { + model->AddURL(parent, parent->GetChildCount(), + ASCIIToUTF16("medium-size-title"), + GURL("http://framma-lamma.com")); + } + // Add a couple more so we can delete one and make sure its button goes away. + model->AddURL(parent, parent->GetChildCount(), + ASCIIToUTF16("DELETE_ME"), GURL("http://ashton-tate.com")); + model->AddURL(parent, parent->GetChildCount(), + ASCIIToUTF16("medium-size-title"), + GURL("http://framma-lamma.com")); + + // Should no longer be hidden. + EXPECT_FALSE([bar_ offTheSideButtonIsHidden]); + + // Open it; make sure we have a folder controller. + EXPECT_FALSE([bar_ folderController]); + [bar_ openOffTheSideFolderFromButton:[bar_ offTheSideButton]]; + BookmarkBarFolderController* bbfc = [bar_ folderController]; + EXPECT_TRUE(bbfc); + + // Confirm the contents are only buttons which fell off the side by + // making sure that none of the nodes in the off-the-side folder are + // found in bar buttons. Be careful since not all the bar buttons + // may be currently displayed. + NSArray* folderButtons = [bbfc buttons]; + NSArray* barButtons = [bar_ buttons]; + for (BookmarkButton* folderButton in folderButtons) { + for (BookmarkButton* barButton in barButtons) { + if ([barButton superview]) { + EXPECT_NE([folderButton bookmarkNode], [barButton bookmarkNode]); + } + } + } + + // Delete a bookmark in the off-the-side and verify it's gone. + BookmarkButton* button = [bbfc buttonWithTitleEqualTo:@"DELETE_ME"]; + EXPECT_TRUE(button); + model->Remove(parent, parent->GetChildCount() - 2); + button = [bbfc buttonWithTitleEqualTo:@"DELETE_ME"]; + EXPECT_FALSE(button); +} + +TEST_F(BookmarkBarControllerTest, EventToExitCheck) { + NSEvent* event = test_event_utils::MakeMouseEvent(NSMouseMoved, 0); + EXPECT_FALSE([bar_ isEventAnExitEvent:event]); + + BookmarkBarFolderWindow* folderWindow = [[[BookmarkBarFolderWindow alloc] + init] autorelease]; + [[[bar_ view] window] addChildWindow:folderWindow + ordered:NSWindowAbove]; + event = test_event_utils::LeftMouseDownAtPointInWindow(NSMakePoint(1,1), + folderWindow); + EXPECT_FALSE([bar_ isEventAnExitEvent:event]); + + event = test_event_utils::LeftMouseDownAtPointInWindow(NSMakePoint(100,100), + test_window()); + EXPECT_TRUE([bar_ isEventAnExitEvent:event]); + + // Many components are arbitrary (e.g. location, keycode). + event = [NSEvent keyEventWithType:NSKeyDown + location:NSMakePoint(1,1) + modifierFlags:0 + timestamp:0 + windowNumber:0 + context:nil + characters:@"x" + charactersIgnoringModifiers:@"x" + isARepeat:NO + keyCode:87]; + EXPECT_TRUE([bar_ isEventAnExitEvent:event]); + + [[[bar_ view] window] removeChildWindow:folderWindow]; +} + +TEST_F(BookmarkBarControllerTest, DropDestination) { + // Make some buttons. + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + const BookmarkNode* parent = model->GetBookmarkBarNode(); + model->AddGroup(parent, parent->GetChildCount(), ASCIIToUTF16("group 1")); + model->AddGroup(parent, parent->GetChildCount(), ASCIIToUTF16("group 2")); + EXPECT_EQ([[bar_ buttons] count], 2U); + + // Confirm "off to left" and "off to right" match nothing. + NSPoint p = NSMakePoint(-1, 2); + EXPECT_FALSE([bar_ buttonForDroppingOnAtPoint:p]); + EXPECT_TRUE([bar_ shouldShowIndicatorShownForPoint:p]); + p = NSMakePoint(50000, 10); + EXPECT_FALSE([bar_ buttonForDroppingOnAtPoint:p]); + EXPECT_TRUE([bar_ shouldShowIndicatorShownForPoint:p]); + + // Confirm "right in the center" (give or take a pixel) is a match, + // and confirm "just barely in the button" is not. Anything more + // specific seems likely to be tweaked. + CGFloat viewFrameXOffset = [[bar_ view] frame].origin.x; + for (BookmarkButton* button in [bar_ buttons]) { + CGFloat x = NSMidX([button frame]) + viewFrameXOffset; + // Somewhere near the center: a match + EXPECT_EQ(button, + [bar_ buttonForDroppingOnAtPoint:NSMakePoint(x-1, 10)]); + EXPECT_EQ(button, + [bar_ buttonForDroppingOnAtPoint:NSMakePoint(x+1, 10)]); + EXPECT_FALSE([bar_ shouldShowIndicatorShownForPoint:NSMakePoint(x, 10)]);; + + // On the very edges: NOT a match + x = NSMinX([button frame]) + viewFrameXOffset; + EXPECT_NE(button, + [bar_ buttonForDroppingOnAtPoint:NSMakePoint(x, 9)]); + x = NSMaxX([button frame]) + viewFrameXOffset; + EXPECT_NE(button, + [bar_ buttonForDroppingOnAtPoint:NSMakePoint(x, 11)]); + } +} + +TEST_F(BookmarkBarControllerTest, NodeDeletedWhileMenuIsOpen) { + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + [bar_ loaded:model]; + + const BookmarkNode* parent = model->GetBookmarkBarNode(); + const BookmarkNode* initialNode = model->AddURL( + parent, parent->GetChildCount(), + ASCIIToUTF16("initial"), + GURL("http://www.google.com")); + + NSMenuItem* item = ItemForBookmarkBarMenu(initialNode); + EXPECT_EQ(0U, noOpenBar()->urls_.size()); + + // Basic check of the menu item and an IBOutlet it can call. + EXPECT_EQ(initialNode, [bar_ nodeFromMenuItem:item]); + [bar_ openBookmarkInNewWindow:item]; + EXPECT_EQ(1U, noOpenBar()->urls_.size()); + [bar_ clear]; + + // Now delete the node and make sure things are happy (no crash, + // NULL node caught). + model->Remove(parent, parent->IndexOfChild(initialNode)); + EXPECT_EQ(nil, [bar_ nodeFromMenuItem:item]); + // Should not crash by referencing a deleted node. + [bar_ openBookmarkInNewWindow:item]; + // Confirm the above did nothing in case it somehow didn't crash. + EXPECT_EQ(0U, noOpenBar()->urls_.size()); + + // Confirm some more non-crashes. + [bar_ openBookmarkInNewForegroundTab:item]; + [bar_ openBookmarkInIncognitoWindow:item]; + [bar_ editBookmark:item]; + [bar_ copyBookmark:item]; + [bar_ deleteBookmark:item]; + [bar_ openAllBookmarks:item]; + [bar_ openAllBookmarksNewWindow:item]; + [bar_ openAllBookmarksIncognitoWindow:item]; +} + +TEST_F(BookmarkBarControllerTest, NodeDeletedWhileContextMenuIsOpen) { + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + [bar_ loaded:model]; + + const BookmarkNode* parent = model->GetBookmarkBarNode(); + const BookmarkNode* folder = model->AddGroup(parent, + parent->GetChildCount(), + ASCIIToUTF16("group")); + const BookmarkNode* framma = model->AddURL(folder, folder->GetChildCount(), + ASCIIToUTF16("f1"), + GURL("http://framma-lamma.com")); + + // Mock in a menu + id origMenu = [bar_ buttonContextMenu]; + id fakeMenu = [OCMockObject partialMockForObject:origMenu]; + [[fakeMenu expect] cancelTracking]; + [bar_ setButtonContextMenu:fakeMenu]; + + // Force a delete which should cancelTracking on the menu. + model->Remove(framma->GetParent(), framma->GetParent()->IndexOfChild(framma)); + + // Restore, then confirm cancelTracking was called. + [bar_ setButtonContextMenu:origMenu]; + [fakeMenu verify]; +} + +TEST_F(BookmarkBarControllerTest, CloseFolderOnAnimate) { + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + const BookmarkNode* parent = model->GetBookmarkBarNode(); + const BookmarkNode* folder = model->AddGroup(parent, + parent->GetChildCount(), + ASCIIToUTF16("group")); + model->AddGroup(parent, parent->GetChildCount(), + ASCIIToUTF16("sibbling group")); + model->AddURL(folder, folder->GetChildCount(), ASCIIToUTF16("title a"), + GURL("http://www.google.com/a")); + model->AddURL(folder, folder->GetChildCount(), + ASCIIToUTF16("title super duper long long whoa momma title you betcha"), + GURL("http://www.google.com/b")); + BookmarkButton* button = [[bar_ buttons] objectAtIndex:0]; + EXPECT_FALSE([bar_ folderController]); + [bar_ openBookmarkFolderFromButton:button]; + BookmarkBarFolderController* bbfc = [bar_ folderController]; + // The following tells us that the folder menu is showing. We want to make + // sure the folder menu goes away if the bookmark bar is hidden. + EXPECT_TRUE(bbfc); + EXPECT_TRUE([bar_ isVisible]); + + // Hide the bookmark bar. + [bar_ updateAndShowNormalBar:NO + showDetachedBar:YES + withAnimation:YES]; + EXPECT_TRUE([bar_ isAnimationRunning]); + + // Now that we've closed the bookmark bar (with animation) the folder menu + // should have been closed thus releasing the folderController. + EXPECT_FALSE([bar_ folderController]); +} + +TEST_F(BookmarkBarControllerTest, MoveRemoveAddButtons) { + BookmarkModel& model(*helper_.profile()->GetBookmarkModel()); + const BookmarkNode* root = model.GetBookmarkBarNode(); + const std::string model_string("1b 2f:[ 2f1b 2f2b ] 3b "); + model_test_utils::AddNodesFromModelString(model, root, model_string); + + // Validate initial model. + std::string actualModelString = model_test_utils::ModelStringFromNode(root); + EXPECT_EQ(model_string, actualModelString); + + // Remember how many buttons are showing. + int oldDisplayedButtons = [bar_ displayedButtonCount]; + NSArray* buttons = [bar_ buttons]; + + // Move a button around a bit. + [bar_ moveButtonFromIndex:0 toIndex:2]; + EXPECT_NSEQ(@"2f", [[buttons objectAtIndex:0] title]); + EXPECT_NSEQ(@"3b", [[buttons objectAtIndex:1] title]); + EXPECT_NSEQ(@"1b", [[buttons objectAtIndex:2] title]); + EXPECT_EQ(oldDisplayedButtons, [bar_ displayedButtonCount]); + [bar_ moveButtonFromIndex:2 toIndex:0]; + EXPECT_NSEQ(@"1b", [[buttons objectAtIndex:0] title]); + EXPECT_NSEQ(@"2f", [[buttons objectAtIndex:1] title]); + EXPECT_NSEQ(@"3b", [[buttons objectAtIndex:2] title]); + EXPECT_EQ(oldDisplayedButtons, [bar_ displayedButtonCount]); + + // Add a couple of buttons. + const BookmarkNode* parent = root->GetChild(1); // Purloin an existing node. + const BookmarkNode* node = parent->GetChild(0); + [bar_ addButtonForNode:node atIndex:0]; + EXPECT_NSEQ(@"2f1b", [[buttons objectAtIndex:0] title]); + EXPECT_NSEQ(@"1b", [[buttons objectAtIndex:1] title]); + EXPECT_NSEQ(@"2f", [[buttons objectAtIndex:2] title]); + EXPECT_NSEQ(@"3b", [[buttons objectAtIndex:3] title]); + EXPECT_EQ(oldDisplayedButtons + 1, [bar_ displayedButtonCount]); + node = parent->GetChild(1); + [bar_ addButtonForNode:node atIndex:-1]; + EXPECT_NSEQ(@"2f1b", [[buttons objectAtIndex:0] title]); + EXPECT_NSEQ(@"1b", [[buttons objectAtIndex:1] title]); + EXPECT_NSEQ(@"2f", [[buttons objectAtIndex:2] title]); + EXPECT_NSEQ(@"3b", [[buttons objectAtIndex:3] title]); + EXPECT_NSEQ(@"2f2b", [[buttons objectAtIndex:4] title]); + EXPECT_EQ(oldDisplayedButtons + 2, [bar_ displayedButtonCount]); + + // Remove a couple of buttons. + [bar_ removeButton:4 animate:NO]; + [bar_ removeButton:1 animate:NO]; + EXPECT_NSEQ(@"2f1b", [[buttons objectAtIndex:0] title]); + EXPECT_NSEQ(@"2f", [[buttons objectAtIndex:1] title]); + EXPECT_NSEQ(@"3b", [[buttons objectAtIndex:2] title]); + EXPECT_EQ(oldDisplayedButtons, [bar_ displayedButtonCount]); +} + +TEST_F(BookmarkBarControllerTest, ShrinkOrHideView) { + NSRect viewFrame = NSMakeRect(0.0, 0.0, 500.0, 50.0); + NSView* view = [[[NSView alloc] initWithFrame:viewFrame] autorelease]; + EXPECT_FALSE([view isHidden]); + [bar_ shrinkOrHideView:view forMaxX:500.0]; + EXPECT_EQ(500.0, NSWidth([view frame])); + EXPECT_FALSE([view isHidden]); + [bar_ shrinkOrHideView:view forMaxX:450.0]; + EXPECT_EQ(450.0, NSWidth([view frame])); + EXPECT_FALSE([view isHidden]); + [bar_ shrinkOrHideView:view forMaxX:40.0]; + EXPECT_EQ(40.0, NSWidth([view frame])); + EXPECT_FALSE([view isHidden]); + [bar_ shrinkOrHideView:view forMaxX:31.0]; + EXPECT_EQ(31.0, NSWidth([view frame])); + EXPECT_FALSE([view isHidden]); + [bar_ shrinkOrHideView:view forMaxX:29.0]; + EXPECT_TRUE([view isHidden]); +} + +class BookmarkBarControllerOpenAllTest : public BookmarkBarControllerTest { +public: + BookmarkBarControllerOpenAllTest() { + resizeDelegate_.reset([[ViewResizerPong alloc] init]); + NSRect parent_frame = NSMakeRect(0, 0, 800, 50); + bar_.reset( + [[BookmarkBarControllerOpenAllPong alloc] + initWithBrowser:helper_.browser() + initialWidth:NSWidth(parent_frame) + delegate:nil + resizeDelegate:resizeDelegate_.get()]); + [bar_ view]; + // Awkwardness to look like we've been installed. + [parent_view_ addSubview:[bar_ view]]; + NSRect frame = [[[bar_ view] superview] frame]; + frame.origin.y = 100; + [[[bar_ view] superview] setFrame:frame]; + + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + parent_ = model->GetBookmarkBarNode(); + // { one, { two-one, two-two }, three } + model->AddURL(parent_, parent_->GetChildCount(), ASCIIToUTF16("title"), + GURL("http://one.com")); + folder_ = model->AddGroup(parent_, parent_->GetChildCount(), + ASCIIToUTF16("group")); + model->AddURL(folder_, folder_->GetChildCount(), + ASCIIToUTF16("title"), GURL("http://two-one.com")); + model->AddURL(folder_, folder_->GetChildCount(), + ASCIIToUTF16("title"), GURL("http://two-two.com")); + model->AddURL(parent_, parent_->GetChildCount(), + ASCIIToUTF16("title"), GURL("https://three.com")); + } + const BookmarkNode* parent_; // Weak + const BookmarkNode* folder_; // Weak +}; + +TEST_F(BookmarkBarControllerOpenAllTest, OpenAllBookmarks) { + // Our first OpenAll... is from the bar itself. + [bar_ openAllBookmarks:ItemForBookmarkBarMenu(parent_)]; + BookmarkBarControllerOpenAllPong* specialBar = + (BookmarkBarControllerOpenAllPong*)bar_.get(); + EXPECT_EQ([specialBar dispositionDetected], NEW_FOREGROUND_TAB); + + // Now try an OpenAll... from a folder node. + [specialBar setDispositionDetected:IGNORE_ACTION]; // Reset + [bar_ openAllBookmarks:ItemForBookmarkBarMenu(folder_)]; + EXPECT_EQ([specialBar dispositionDetected], NEW_FOREGROUND_TAB); +} + +TEST_F(BookmarkBarControllerOpenAllTest, OpenAllNewWindow) { + // Our first OpenAll... is from the bar itself. + [bar_ openAllBookmarksNewWindow:ItemForBookmarkBarMenu(parent_)]; + BookmarkBarControllerOpenAllPong* specialBar = + (BookmarkBarControllerOpenAllPong*)bar_.get(); + EXPECT_EQ([specialBar dispositionDetected], NEW_WINDOW); + + // Now try an OpenAll... from a folder node. + [specialBar setDispositionDetected:IGNORE_ACTION]; // Reset + [bar_ openAllBookmarksNewWindow:ItemForBookmarkBarMenu(folder_)]; + EXPECT_EQ([specialBar dispositionDetected], NEW_WINDOW); +} + +TEST_F(BookmarkBarControllerOpenAllTest, OpenAllIncognito) { + // Our first OpenAll... is from the bar itself. + [bar_ openAllBookmarksIncognitoWindow:ItemForBookmarkBarMenu(parent_)]; + BookmarkBarControllerOpenAllPong* specialBar = + (BookmarkBarControllerOpenAllPong*)bar_.get(); + EXPECT_EQ([specialBar dispositionDetected], OFF_THE_RECORD); + + // Now try an OpenAll... from a folder node. + [specialBar setDispositionDetected:IGNORE_ACTION]; // Reset + [bar_ openAllBookmarksIncognitoWindow:ItemForBookmarkBarMenu(folder_)]; + EXPECT_EQ([specialBar dispositionDetected], OFF_THE_RECORD); +} + +// Command-click on a folder should open all the bookmarks in it. +TEST_F(BookmarkBarControllerOpenAllTest, CommandClickOnFolder) { + NSButton* first = [[bar_ buttons] objectAtIndex:0]; + EXPECT_TRUE(first); + + // Create the right kind of event; mock NSApp so [NSApp + // currentEvent] finds it. + NSEvent* commandClick = test_event_utils::MouseEventAtPoint(NSZeroPoint, + NSLeftMouseDown, + NSCommandKeyMask); + id fakeApp = [OCMockObject partialMockForObject:NSApp]; + [[[fakeApp stub] andReturn:commandClick] currentEvent]; + id oldApp = NSApp; + NSApp = fakeApp; + size_t originalDispositionCount = noOpenBar()->dispositions_.size(); + + // Click! + [first performClick:first]; + + size_t dispositionCount = noOpenBar()->dispositions_.size(); + EXPECT_EQ(originalDispositionCount+1, dispositionCount); + EXPECT_EQ(noOpenBar()->dispositions_[dispositionCount-1], NEW_BACKGROUND_TAB); + + // Replace NSApp + NSApp = oldApp; +} + +class BookmarkBarControllerNotificationTest : public CocoaTest { + public: + BookmarkBarControllerNotificationTest() { + resizeDelegate_.reset([[ViewResizerPong alloc] init]); + NSRect parent_frame = NSMakeRect(0, 0, 800, 50); + parent_view_.reset([[NSView alloc] initWithFrame:parent_frame]); + [parent_view_ setHidden:YES]; + bar_.reset( + [[BookmarkBarControllerNotificationPong alloc] + initWithBrowser:helper_.browser() + initialWidth:NSWidth(parent_frame) + delegate:nil + resizeDelegate:resizeDelegate_.get()]); + + // Force loading of the nib. + [bar_ view]; + // Awkwardness to look like we've been installed. + [parent_view_ addSubview:[bar_ view]]; + NSRect frame = [[[bar_ view] superview] frame]; + frame.origin.y = 100; + [[[bar_ view] superview] setFrame:frame]; + + // Do not add the bar to a window, yet. + } + + BrowserTestHelper helper_; + scoped_nsobject<NSView> parent_view_; + scoped_nsobject<ViewResizerPong> resizeDelegate_; + scoped_nsobject<BookmarkBarControllerNotificationPong> bar_; +}; + +TEST_F(BookmarkBarControllerNotificationTest, DeregistersForNotifications) { + NSWindow* window = [[CocoaTestHelperWindow alloc] init]; + [window setReleasedWhenClosed:YES]; + + // First add the bookmark bar to the temp window, then to another window. + [[window contentView] addSubview:parent_view_]; + [[test_window() contentView] addSubview:parent_view_]; + + // Post a fake windowDidResignKey notification for the temp window and make + // sure the bookmark bar controller wasn't listening. + [[NSNotificationCenter defaultCenter] + postNotificationName:NSWindowDidResignKeyNotification + object:window]; + EXPECT_FALSE([bar_ windowDidResignKeyReceived]); + + // Close the temp window and make sure no notification was received. + [window close]; + EXPECT_FALSE([bar_ windowWillCloseReceived]); +} + + +// TODO(jrg): draggingEntered: and draggingExited: trigger timers so +// they are hard to test. Factor out "fire timers" into routines +// which can be overridden to fire immediately to make behavior +// confirmable. + +// TODO(jrg): add unit test to make sure "Other Bookmarks" responds +// properly to a hover open. + +// TODO(viettrungluu): figure out how to test animations. + +class BookmarkBarControllerDragDropTest : public BookmarkBarControllerTestBase { + public: + scoped_nsobject<BookmarkBarControllerDragData> bar_; + + BookmarkBarControllerDragDropTest() { + bar_.reset( + [[BookmarkBarControllerDragData alloc] + initWithBrowser:helper_.browser() + initialWidth:NSWidth([parent_view_ frame]) + delegate:nil + resizeDelegate:resizeDelegate_.get()]); + InstallAndToggleBar(bar_.get()); + } +}; + +TEST_F(BookmarkBarControllerDragDropTest, DragMoveBarBookmarkToOffTheSide) { + BookmarkModel& model(*helper_.profile()->GetBookmarkModel()); + const BookmarkNode* root = model.GetBookmarkBarNode(); + const std::string model_string("1bWithLongName 2fWithLongName:[ " + "2f1bWithLongName 2f2fWithLongName:[ 2f2f1bWithLongName " + "2f2f2bWithLongName 2f2f3bWithLongName 2f4b ] 2f3bWithLongName ] " + "3bWithLongName 4bWithLongName 5bWithLongName 6bWithLongName " + "7bWithLongName 8bWithLongName 9bWithLongName 10bWithLongName " + "11bWithLongName 12bWithLongName 13b "); + model_test_utils::AddNodesFromModelString(model, root, model_string); + + // Validate initial model. + std::string actualModelString = model_test_utils::ModelStringFromNode(root); + EXPECT_EQ(model_string, actualModelString); + + // Insure that the off-the-side is not showing. + ASSERT_FALSE([bar_ offTheSideButtonIsHidden]); + + // Remember how many buttons are showing and are available. + int oldDisplayedButtons = [bar_ displayedButtonCount]; + int oldChildCount = root->GetChildCount(); + + // Pop up the off-the-side menu. + BookmarkButton* otsButton = (BookmarkButton*)[bar_ offTheSideButton]; + ASSERT_TRUE(otsButton); + [[otsButton target] performSelector:@selector(openOffTheSideFolderFromButton:) + withObject:otsButton]; + BookmarkBarFolderController* otsController = [bar_ folderController]; + EXPECT_TRUE(otsController); + NSWindow* toWindow = [otsController window]; + EXPECT_TRUE(toWindow); + BookmarkButton* draggedButton = + [bar_ buttonWithTitleEqualTo:@"3bWithLongName"]; + ASSERT_TRUE(draggedButton); + int oldOTSCount = (int)[[otsController buttons] count]; + EXPECT_EQ(oldOTSCount, oldChildCount - oldDisplayedButtons); + BookmarkButton* targetButton = [[otsController buttons] objectAtIndex:0]; + ASSERT_TRUE(targetButton); + [otsController dragButton:draggedButton + to:[targetButton center] + copy:YES]; + // There should still be the same number of buttons in the bar + // and off-the-side should have one more. + int newDisplayedButtons = [bar_ displayedButtonCount]; + int newChildCount = root->GetChildCount(); + int newOTSCount = (int)[[otsController buttons] count]; + EXPECT_EQ(oldDisplayedButtons, newDisplayedButtons); + EXPECT_EQ(oldChildCount + 1, newChildCount); + EXPECT_EQ(oldOTSCount + 1, newOTSCount); + EXPECT_EQ(newOTSCount, newChildCount - newDisplayedButtons); +} + +TEST_F(BookmarkBarControllerDragDropTest, DragOffTheSideToOther) { + BookmarkModel& model(*helper_.profile()->GetBookmarkModel()); + const BookmarkNode* root = model.GetBookmarkBarNode(); + const std::string model_string("1bWithLongName 2bWithLongName " + "3bWithLongName 4bWithLongName 5bWithLongName 6bWithLongName " + "7bWithLongName 8bWithLongName 9bWithLongName 10bWithLongName " + "11bWithLongName 12bWithLongName 13bWithLongName 14bWithLongName " + "15bWithLongName 16bWithLongName 17bWithLongName 18bWithLongName " + "19bWithLongName 20bWithLongName "); + model_test_utils::AddNodesFromModelString(model, root, model_string); + + const BookmarkNode* other = model.other_node(); + const std::string other_string("1other 2other 3other "); + model_test_utils::AddNodesFromModelString(model, other, other_string); + + // Validate initial model. + std::string actualModelString = model_test_utils::ModelStringFromNode(root); + EXPECT_EQ(model_string, actualModelString); + std::string actualOtherString = model_test_utils::ModelStringFromNode(other); + EXPECT_EQ(other_string, actualOtherString); + + // Insure that the off-the-side is showing. + ASSERT_FALSE([bar_ offTheSideButtonIsHidden]); + + // Remember how many buttons are showing and are available. + int oldDisplayedButtons = [bar_ displayedButtonCount]; + int oldRootCount = root->GetChildCount(); + int oldOtherCount = other->GetChildCount(); + + // Pop up the off-the-side menu. + BookmarkButton* otsButton = (BookmarkButton*)[bar_ offTheSideButton]; + ASSERT_TRUE(otsButton); + [[otsButton target] performSelector:@selector(openOffTheSideFolderFromButton:) + withObject:otsButton]; + BookmarkBarFolderController* otsController = [bar_ folderController]; + EXPECT_TRUE(otsController); + int oldOTSCount = (int)[[otsController buttons] count]; + EXPECT_EQ(oldOTSCount, oldRootCount - oldDisplayedButtons); + + // Pick an off-the-side button and drag it to the other bookmarks. + BookmarkButton* draggedButton = + [otsController buttonWithTitleEqualTo:@"20bWithLongName"]; + ASSERT_TRUE(draggedButton); + BookmarkButton* targetButton = [bar_ otherBookmarksButton]; + ASSERT_TRUE(targetButton); + [bar_ dragButton:draggedButton to:[targetButton center] copy:NO]; + + // There should one less button in the bar, one less in off-the-side, + // and one more in other bookmarks. + int newRootCount = root->GetChildCount(); + int newOTSCount = (int)[[otsController buttons] count]; + int newOtherCount = other->GetChildCount(); + EXPECT_EQ(oldRootCount - 1, newRootCount); + EXPECT_EQ(oldOTSCount - 1, newOTSCount); + EXPECT_EQ(oldOtherCount + 1, newOtherCount); +} + +TEST_F(BookmarkBarControllerDragDropTest, DragBookmarkData) { + BookmarkModel& model(*helper_.profile()->GetBookmarkModel()); + const BookmarkNode* root = model.GetBookmarkBarNode(); + const std::string model_string("1b 2f:[ 2f1b 2f2f:[ 2f2f1b 2f2f2b 2f2f3b ] " + "2f3b ] 3b 4b "); + model_test_utils::AddNodesFromModelString(model, root, model_string); + const BookmarkNode* other = model.other_node(); + const std::string other_string("O1b O2b O3f:[ O3f1b O3f2f ] " + "O4f:[ O4f1b O4f2f ] 05b "); + model_test_utils::AddNodesFromModelString(model, other, other_string); + + // Validate initial model. + std::string actual = model_test_utils::ModelStringFromNode(root); + EXPECT_EQ(model_string, actual); + actual = model_test_utils::ModelStringFromNode(other); + EXPECT_EQ(other_string, actual); + + // Remember the little ones. + int oldChildCount = root->GetChildCount(); + + BookmarkButton* targetButton = [bar_ buttonWithTitleEqualTo:@"3b"]; + ASSERT_TRUE(targetButton); + + // Gen up some dragging data. + const BookmarkNode* newNode = other->GetChild(2); + [bar_ setDragDataNode:newNode]; + scoped_nsobject<FakeDragInfo> dragInfo([[FakeDragInfo alloc] init]); + [dragInfo setDropLocation:[targetButton center]]; + [bar_ dragBookmarkData:(id<NSDraggingInfo>)dragInfo.get()]; + + // There should one more button in the bar. + int newChildCount = root->GetChildCount(); + EXPECT_EQ(oldChildCount + 1, newChildCount); + // Verify the model. + const std::string expected("1b 2f:[ 2f1b 2f2f:[ 2f2f1b 2f2f2b 2f2f3b ] " + "2f3b ] O3f:[ O3f1b O3f2f ] 3b 4b "); + actual = model_test_utils::ModelStringFromNode(root); + EXPECT_EQ(expected, actual); + oldChildCount = newChildCount; + + // Now do it over a folder button. + targetButton = [bar_ buttonWithTitleEqualTo:@"2f"]; + ASSERT_TRUE(targetButton); + NSPoint targetPoint = [targetButton center]; + newNode = other->GetChild(2); // Should be O4f. + EXPECT_EQ(newNode->GetTitle(), ASCIIToUTF16("O4f")); + [bar_ setDragDataNode:newNode]; + [dragInfo setDropLocation:targetPoint]; + [bar_ dragBookmarkData:(id<NSDraggingInfo>)dragInfo.get()]; + + newChildCount = root->GetChildCount(); + EXPECT_EQ(oldChildCount, newChildCount); + // Verify the model. + const std::string expected1("1b 2f:[ 2f1b 2f2f:[ 2f2f1b 2f2f2b 2f2f3b ] " + "2f3b O4f:[ O4f1b O4f2f ] ] O3f:[ O3f1b O3f2f ] " + "3b 4b "); + actual = model_test_utils::ModelStringFromNode(root); + EXPECT_EQ(expected1, actual); +} + +TEST_F(BookmarkBarControllerDragDropTest, AddURLs) { + BookmarkModel& model(*helper_.profile()->GetBookmarkModel()); + const BookmarkNode* root = model.GetBookmarkBarNode(); + const std::string model_string("1b 2f:[ 2f1b 2f2f:[ 2f2f1b 2f2f2b 2f2f3b ] " + "2f3b ] 3b 4b "); + model_test_utils::AddNodesFromModelString(model, root, model_string); + + // Validate initial model. + std::string actual = model_test_utils::ModelStringFromNode(root); + EXPECT_EQ(model_string, actual); + + // Remember the children. + int oldChildCount = root->GetChildCount(); + + BookmarkButton* targetButton = [bar_ buttonWithTitleEqualTo:@"3b"]; + ASSERT_TRUE(targetButton); + + NSArray* urls = [NSArray arrayWithObjects: @"http://www.a.com/", + @"http://www.b.com/", nil]; + NSArray* titles = [NSArray arrayWithObjects: @"SiteA", @"SiteB", nil]; + [bar_ addURLs:urls withTitles:titles at:[targetButton center]]; + + // There should two more nodes in the bar. + int newChildCount = root->GetChildCount(); + EXPECT_EQ(oldChildCount + 2, newChildCount); + // Verify the model. + const std::string expected("1b 2f:[ 2f1b 2f2f:[ 2f2f1b 2f2f2b 2f2f3b ] " + "2f3b ] SiteA SiteB 3b 4b "); + actual = model_test_utils::ModelStringFromNode(root); + EXPECT_EQ(expected, actual); +} + +TEST_F(BookmarkBarControllerDragDropTest, ControllerForNode) { + BookmarkModel& model(*helper_.profile()->GetBookmarkModel()); + const BookmarkNode* root = model.GetBookmarkBarNode(); + const std::string model_string("1b 2f:[ 2f1b 2f2b ] 3b "); + model_test_utils::AddNodesFromModelString(model, root, model_string); + + // Validate initial model. + std::string actualModelString = model_test_utils::ModelStringFromNode(root); + EXPECT_EQ(model_string, actualModelString); + + // Find the main bar controller. + const void* expectedController = bar_; + const void* actualController = [bar_ controllerForNode:root]; + EXPECT_EQ(expectedController, actualController); +} + +TEST_F(BookmarkBarControllerDragDropTest, DropPositionIndicator) { + BookmarkModel& model(*helper_.profile()->GetBookmarkModel()); + const BookmarkNode* root = model.GetBookmarkBarNode(); + const std::string model_string("1b 2f:[ 2f1b 2f2b 2f3b ] 3b 4b "); + model_test_utils::AddNodesFromModelString(model, root, model_string); + + // Validate initial model. + std::string actualModel = model_test_utils::ModelStringFromNode(root); + EXPECT_EQ(model_string, actualModel); + + // Test a series of points starting at the right edge of the bar. + BookmarkButton* targetButton = [bar_ buttonWithTitleEqualTo:@"1b"]; + ASSERT_TRUE(targetButton); + NSPoint targetPoint = [targetButton left]; + const CGFloat xDelta = 0.5 * bookmarks::kBookmarkHorizontalPadding; + const CGFloat baseOffset = targetPoint.x; + CGFloat expected = xDelta; + CGFloat actual = [bar_ indicatorPosForDragToPoint:targetPoint]; + EXPECT_CGFLOAT_EQ(expected, actual); + targetButton = [bar_ buttonWithTitleEqualTo:@"2f"]; + actual = [bar_ indicatorPosForDragToPoint:[targetButton right]]; + targetButton = [bar_ buttonWithTitleEqualTo:@"3b"]; + expected = [targetButton left].x - baseOffset + xDelta; + EXPECT_CGFLOAT_EQ(expected, actual); + targetButton = [bar_ buttonWithTitleEqualTo:@"4b"]; + targetPoint = [targetButton right]; + targetPoint.x += 100; // Somewhere off to the right. + expected = NSMaxX([targetButton frame]) + xDelta; + actual = [bar_ indicatorPosForDragToPoint:targetPoint]; + EXPECT_CGFLOAT_EQ(expected, actual); +} + +TEST_F(BookmarkBarControllerDragDropTest, PulseButton) { + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + const BookmarkNode* root = model->GetBookmarkBarNode(); + GURL gurl("http://www.google.com"); + const BookmarkNode* node = model->AddURL(root, root->GetChildCount(), + ASCIIToUTF16("title"), gurl); + + BookmarkButton* button = [[bar_ buttons] objectAtIndex:0]; + EXPECT_FALSE([button isContinuousPulsing]); + + NSValue *value = [NSValue valueWithPointer:node]; + NSDictionary *dict = [NSDictionary + dictionaryWithObjectsAndKeys:value, + bookmark_button::kBookmarkKey, + [NSNumber numberWithBool:YES], + bookmark_button::kBookmarkPulseFlagKey, + nil]; + [[NSNotificationCenter defaultCenter] + postNotificationName:bookmark_button::kPulseBookmarkButtonNotification + object:nil + userInfo:dict]; + EXPECT_TRUE([button isContinuousPulsing]); + + dict = [NSDictionary dictionaryWithObjectsAndKeys:value, + bookmark_button::kBookmarkKey, + [NSNumber numberWithBool:NO], + bookmark_button::kBookmarkPulseFlagKey, + nil]; + [[NSNotificationCenter defaultCenter] + postNotificationName:bookmark_button::kPulseBookmarkButtonNotification + object:nil + userInfo:dict]; + EXPECT_FALSE([button isContinuousPulsing]); +} + +TEST_F(BookmarkBarControllerDragDropTest, DragBookmarkDataToTrash) { + BookmarkModel& model(*helper_.profile()->GetBookmarkModel()); + const BookmarkNode* root = model.GetBookmarkBarNode(); + const std::string model_string("1b 2f:[ 2f1b 2f2f:[ 2f2f1b 2f2f2b 2f2f3b ] " + "2f3b ] 3b 4b "); + model_test_utils::AddNodesFromModelString(model, root, model_string); + + // Validate initial model. + std::string actual = model_test_utils::ModelStringFromNode(root); + EXPECT_EQ(model_string, actual); + + int oldChildCount = root->GetChildCount(); + + // Drag a button to the trash. + BookmarkButton* buttonToDelete = [bar_ buttonWithTitleEqualTo:@"3b"]; + ASSERT_TRUE(buttonToDelete); + EXPECT_TRUE([bar_ canDragBookmarkButtonToTrash:buttonToDelete]); + [bar_ didDragBookmarkToTrash:buttonToDelete]; + + // There should be one less button in the bar. + int newChildCount = root->GetChildCount(); + EXPECT_EQ(oldChildCount - 1, newChildCount); + // Verify the model. + const std::string expected("1b 2f:[ 2f1b 2f2f:[ 2f2f1b 2f2f2b 2f2f3b ] " + "2f3b ] 4b "); + actual = model_test_utils::ModelStringFromNode(root); + EXPECT_EQ(expected, actual); + + // Verify that the other bookmark folder can't be deleted. + BookmarkButton *otherButton = [bar_ otherBookmarksButton]; + EXPECT_FALSE([bar_ canDragBookmarkButtonToTrash:otherButton]); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_button_cell.h b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_button_cell.h new file mode 100644 index 0000000..f599e0a --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_button_cell.h @@ -0,0 +1,31 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_BAR_FOLDER_BUTTON_CELL_H_ +#define CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_BAR_FOLDER_BUTTON_CELL_H_ +#pragma once + +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_button_cell.h" + +class BookmarkNode; + +// A button cell that handles drawing/highlighting of buttons in the +// bookmark bar. This cell forwards mouseEntered/mouseExited events +// to its control view so that pseudo-menu operations +// (e.g. hover-over to open) can be implemented. +@interface BookmarkBarFolderButtonCell : BookmarkButtonCell { + @private + scoped_nsobject<NSColor> frameColor_; +} + +// Create a button cell which draws without a theme and with a frame +// color provided by the BrowserThemeProvider defaults. ++ (id)buttonCellForNode:(const BookmarkNode*)node + contextMenu:(NSMenu*)contextMenu + cellText:(NSString*)cellText + cellImage:(NSImage*)cellImage; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_BAR_FOLDER_BUTTON_CELL_H_ diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_button_cell.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_button_cell.mm new file mode 100644 index 0000000..c03500d --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_button_cell.mm @@ -0,0 +1,22 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_button_cell.h" + +@implementation BookmarkBarFolderButtonCell + ++ (id)buttonCellForNode:(const BookmarkNode*)node + contextMenu:(NSMenu*)contextMenu + cellText:(NSString*)cellText + cellImage:(NSImage*)cellImage { + id buttonCell = + [[[BookmarkBarFolderButtonCell alloc] initForNode:node + contextMenu:contextMenu + cellText:cellText + cellImage:cellImage] + autorelease]; + return buttonCell; +} + +@end diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_button_cell_unittest.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_button_cell_unittest.mm new file mode 100644 index 0000000..3c20cf7 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_button_cell_unittest.mm @@ -0,0 +1,24 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_button_cell.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" + +namespace { + +class BookmarkBarFolderButtonCellTest : public CocoaTest { +}; + +// Basic creation. +TEST_F(BookmarkBarFolderButtonCellTest, Create) { + scoped_nsobject<BookmarkBarFolderButtonCell> cell; + cell.reset([[BookmarkBarFolderButtonCell buttonCellForNode:nil + contextMenu:nil + cellText:nil + cellImage:nil] retain]); + EXPECT_TRUE(cell); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_controller.h b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_controller.h new file mode 100644 index 0000000..083efac --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_controller.h @@ -0,0 +1,182 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_button.h" + +@class BookmarkBarController; +@class BookmarkBarFolderView; +@class BookmarkFolderTarget; +@class BookmarkBarFolderHoverState; + +// A controller for the pop-up windows from bookmark folder buttons +// which look sort of like menus. +@interface BookmarkBarFolderController : + NSWindowController<BookmarkButtonDelegate, + BookmarkButtonControllerProtocol> { + @private + // The button whose click opened us. + scoped_nsobject<BookmarkButton> parentButton_; + + // Bookmark bar folder controller chains are torn down in two ways: + // 1. Clicking "outside" the folder (see use of + // CrApplicationEventHookProtocol in the bookmark bar controller). + // 2. Engaging a different folder (via hover over or explicit click). + // + // In either case, the BookmarkButtonControllerProtocol method + // closeAllBookmarkFolders gets called. For bookmark bar folder + // controllers, this is passed up the chain so we begin with a top + // level "close". + // When any bookmark folder window closes, it necessarily tells + // subcontroller windows to close (down the chain), and autoreleases + // the controller. (Must autorelease since the controller can still + // get delegate events such as windowDidClose). + // + // Bookmark bar folder controllers own their buttons. When doing + // drag and drop of a button from one sub-sub-folder to a different + // sub-sub-folder, we need to make sure the button's pointers stay + // valid until we've dropped (or cancelled). Note that such a drag + // causes the source sub-sub-folder (previous parent window) to go + // away (windows close, controllers autoreleased) since you're + // hovering over a different folder chain for dropping. To keep + // things valid (like the button's target, its delegate, the parent + // cotroller that we have a pointer to below [below], etc), we heep + // strong pointers to our owning controller, so the entire chain + // stays owned. + + // Our parent controller, if we are a nested folder, otherwise nil. + // Strong to insure the object lives as long as we need it. + scoped_nsobject<BookmarkBarFolderController> parentController_; + + // The main bar controller from whence we or a parent sprang. + BookmarkBarController* barController_; // WEAK: It owns us. + + // Our buttons. We do not have buttons for nested folders. + scoped_nsobject<NSMutableArray> buttons_; + + // The scroll view that contains our main button view (below). + IBOutlet NSScrollView* scrollView_; + + // Are we scrollable? If no, the full contents of the folder are + // always visible. + BOOL scrollable_; + + BOOL scrollUpArrowShown_; + BOOL scrollDownArrowShown_; + + // YES if subfolders should grow to the right (the default). + // Direction switches if we'd grow off the screen. + BOOL subFolderGrowthToRight_; + + // The main view of this window (where the buttons go). + IBOutlet BookmarkBarFolderView* mainView_; + + // Weak; we keep track to work around a + // setShowsBorderOnlyWhileMouseInside bug. + BookmarkButton* buttonThatMouseIsIn_; + + // The context menu for a bookmark button which represents an URL. + IBOutlet NSMenu* buttonMenu_; + + // The context menu for a bookmark button which represents a folder. + IBOutlet NSMenu* folderMenu_; + + // We model hover state as a state machine with specific allowable + // transitions. |hoverState_| is the state of this machine at any + // given time. + scoped_nsobject<BookmarkBarFolderHoverState> hoverState_; + + // Logic for dealing with a click on a bookmark folder button. + scoped_nsobject<BookmarkFolderTarget> folderTarget_; + + // A controller for a pop-up bookmark folder window (custom menu). + // We (self) are the parentController_ for our folderController_. + // This is not a scoped_nsobject because it owns itself (when its + // window closes the controller gets autoreleased). + BookmarkBarFolderController* folderController_; + + // Implement basic menu scrolling through this tracking area. + scoped_nsobject<NSTrackingArea> scrollTrackingArea_; + + // Timer to continue scrolling as needed. We own the timer but + // don't release it when done (we invalidate it). + NSTimer* scrollTimer_; + + // Amount to scroll by on each timer fire. Can be + or -. + CGFloat verticalScrollDelta_; + + // We need to know the size of the vertical scrolling arrows so we + // can obscure/unobscure them. + CGFloat verticalScrollArrowHeight_; + + // Set to YES to prevent any node animations. Useful for unit testing so that + // incomplete animations do not cause valgrind complaints. + BOOL ignoreAnimations_; +} + +// Designated initializer. +- (id)initWithParentButton:(BookmarkButton*)button + parentController:(BookmarkBarFolderController*)parentController + barController:(BookmarkBarController*)barController; + +// Return the parent button that owns the bookmark folder we represent. +- (BookmarkButton*)parentButton; + +// Offset our folder menu window. This is usually needed in response to a +// parent folder menu window or the bookmark bar changing position due to +// the dragging of a bookmark node from the parent into this folder menu. +- (void)offsetFolderMenuWindow:(NSSize)offset; + +// Re-layout the window menu in case some buttons were added or removed, +// specifically as a result of the bookmark bar changing configuration +// and altering the contents of the off-the-side folder. +- (void)reconfigureMenu; + +// Actions from a context menu over a button or folder. +- (IBAction)cutBookmark:(id)sender; +- (IBAction)copyBookmark:(id)sender; +- (IBAction)pasteBookmark:(id)sender; +- (IBAction)deleteBookmark:(id)sender; + +// Passed up by a child view to tell us of a desire to scroll. +- (void)scrollWheel:(NSEvent *)theEvent; + +// Forwarded to the associated BookmarkBarController. +- (IBAction)addFolder:(id)sender; +- (IBAction)addPage:(id)sender; +- (IBAction)editBookmark:(id)sender; +- (IBAction)openBookmark:(id)sender; +- (IBAction)openAllBookmarks:(id)sender; +- (IBAction)openAllBookmarksIncognitoWindow:(id)sender; +- (IBAction)openAllBookmarksNewWindow:(id)sender; +- (IBAction)openBookmarkInIncognitoWindow:(id)sender; +- (IBAction)openBookmarkInNewForegroundTab:(id)sender; +- (IBAction)openBookmarkInNewWindow:(id)sender; + +@property (assign, nonatomic) BOOL subFolderGrowthToRight; + +@end + +@interface BookmarkBarFolderController(TestingAPI) +- (NSView*)mainView; +- (NSPoint)windowTopLeftForWidth:(int)windowWidth; +- (NSArray*)buttons; +- (BookmarkBarFolderController*)folderController; +- (id)folderTarget; +- (void)configureWindowLevel; +- (void)performOneScroll:(CGFloat)delta; +- (BookmarkButton*)buttonThatMouseIsIn; +// Set to YES in order to prevent animations. +- (void)setIgnoreAnimations:(BOOL)ignore; + +// Return YES if we can scroll up or down. +- (BOOL)canScrollUp; +- (BOOL)canScrollDown; +// Return YES if the scrollable_ flag has been set. +- (BOOL)scrollable; + +- (BookmarkButton*)buttonForDroppingOnAtPoint:(NSPoint)point; +@end diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_controller.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_controller.mm new file mode 100644 index 0000000..7720c90 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_controller.mm @@ -0,0 +1,1459 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_controller.h" + +#include "base/mac_util.h" +#include "base/nsimage_cache_mac.h" +#include "base/sys_string_conversions.h" +#include "chrome/browser/bookmarks/bookmark_model.h" +#include "chrome/browser/bookmarks/bookmark_utils.h" +#import "chrome/browser/themes/browser_theme_provider.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_constants.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_controller.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_button_cell.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_hover_state.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_view.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_folder_target.h" +#import "chrome/browser/ui/cocoa/browser_window_controller.h" +#import "chrome/browser/ui/cocoa/event_utils.h" + +namespace { + +// Frequency of the scrolling timer in seconds. +const NSTimeInterval kBookmarkBarFolderScrollInterval = 0.1; + +// Amount to scroll by per timer fire. We scroll rather slowly; to +// accomodate we do several at a time. +const CGFloat kBookmarkBarFolderScrollAmount = + 3 * bookmarks::kBookmarkButtonVerticalSpan; + +// Amount to scroll for each scroll wheel delta. +const CGFloat kBookmarkBarFolderScrollWheelAmount = + 1 * bookmarks::kBookmarkButtonVerticalSpan; + +// When constraining a scrolling bookmark bar folder window to the +// screen, shrink the "constrain" by this much vertically. Currently +// this is 0.0 to avoid a problem with tracking areas leaving the +// window, but should probably be 8.0 or something. +// TODO(jrg): http://crbug.com/36225 +const CGFloat kScrollWindowVerticalMargin = 0.0; + +} // namespace + +@interface BookmarkBarFolderController(Private) +- (void)configureWindow; +- (void)addOrUpdateScrollTracking; +- (void)removeScrollTracking; +- (void)endScroll; +- (void)addScrollTimerWithDelta:(CGFloat)delta; + +// Determine the best button width (which will be the widest button or the +// maximum allowable button width, whichever is less) and resize all buttons. +// Return the new width (so that the window can be adjusted, if necessary). +- (CGFloat)adjustButtonWidths; + +// Returns the total menu height needed to display |buttonCount| buttons. +// Does not do any fancy tricks like trimming the height to fit on the screen. +- (int)windowHeightForButtonCount:(int)buttonCount; + +// Adjust the height and horizontal position of the window such that the +// scroll arrows are shown as needed and the window appears completely +// on screen. +- (void)adjustWindowForHeight:(int)windowHeight; + +// Show or hide the scroll arrows at the top/bottom of the window. +- (void)showOrHideScrollArrows; + +// |point| is in the base coordinate system of the destination window; +// it comes from an id<NSDraggingInfo>. |copy| is YES if a copy is to be +// made and inserted into the new location while leaving the bookmark in +// the old location, otherwise move the bookmark by removing from its old +// location and inserting into the new location. +- (BOOL)dragBookmark:(const BookmarkNode*)sourceNode + to:(NSPoint)point + copy:(BOOL)copy; + +@end + +@interface BookmarkButton (BookmarkBarFolderMenuHighlighting) + +// Make the button's border frame always appear when |forceOn| is YES, +// otherwise only border the button when the mouse is inside the button. +- (void)forceButtonBorderToStayOnAlways:(BOOL)forceOn; + +// On 10.6 event dispatch for an NSButtonCell's +// showsBorderOnlyWhileMouseInside seems broken if scrolling the +// view that contains the button. It appears that a mouseExited: +// gets lost, so the button stays highlit forever. We accomodate +// here. +- (void)toggleButtonBorderingWhileMouseInside; +@end + +@implementation BookmarkButton (BookmarkBarFolderMenuHighlighting) + +- (void)forceButtonBorderToStayOnAlways:(BOOL)forceOn { + [self setShowsBorderOnlyWhileMouseInside:!forceOn]; + [self setNeedsDisplay]; +} + +- (void)toggleButtonBorderingWhileMouseInside { + BOOL toggle = [self showsBorderOnlyWhileMouseInside]; + [self setShowsBorderOnlyWhileMouseInside:!toggle]; + [self setShowsBorderOnlyWhileMouseInside:toggle]; +} + +@end + +@implementation BookmarkBarFolderController + +@synthesize subFolderGrowthToRight = subFolderGrowthToRight_; + +- (id)initWithParentButton:(BookmarkButton*)button + parentController:(BookmarkBarFolderController*)parentController + barController:(BookmarkBarController*)barController { + NSString* nibPath = + [mac_util::MainAppBundle() pathForResource:@"BookmarkBarFolderWindow" + ofType:@"nib"]; + if ((self = [super initWithWindowNibPath:nibPath owner:self])) { + parentButton_.reset([button retain]); + + // We want the button to remain bordered as part of the menu path. + [button forceButtonBorderToStayOnAlways:YES]; + + parentController_.reset([parentController retain]); + if (!parentController_) + [self setSubFolderGrowthToRight:YES]; + else + [self setSubFolderGrowthToRight:[parentController + subFolderGrowthToRight]]; + barController_ = barController; // WEAK + buttons_.reset([[NSMutableArray alloc] init]); + folderTarget_.reset([[BookmarkFolderTarget alloc] initWithController:self]); + NSImage* image = nsimage_cache::ImageNamed(@"menu_overflow_up.pdf"); + DCHECK(image); + verticalScrollArrowHeight_ = [image size].height; + [self configureWindow]; + hoverState_.reset([[BookmarkBarFolderHoverState alloc] init]); + } + return self; +} + +- (void)dealloc { + // The button is no longer part of the menu path. + [parentButton_ forceButtonBorderToStayOnAlways:NO]; + [parentButton_ setNeedsDisplay]; + + [self removeScrollTracking]; + [self endScroll]; + [hoverState_ draggingExited]; + + // Delegate pattern does not retain; make sure pointers to us are removed. + for (BookmarkButton* button in buttons_.get()) { + [button setDelegate:nil]; + [button setTarget:nil]; + [button setAction:nil]; + } + + // Note: we don't need to + // [NSObject cancelPreviousPerformRequestsWithTarget:self]; + // Because all of our performSelector: calls use withDelay: which + // retains us. + [super dealloc]; +} + +// Overriden from NSWindowController to call childFolderWillShow: before showing +// the window. +- (void)showWindow:(id)sender { + [barController_ childFolderWillShow:self]; + [super showWindow:sender]; +} + +- (BookmarkButton*)parentButton { + return parentButton_.get(); +} + +- (void)offsetFolderMenuWindow:(NSSize)offset { + NSWindow* window = [self window]; + NSRect windowFrame = [window frame]; + windowFrame.origin.x -= offset.width; + windowFrame.origin.y += offset.height; // Yes, in the opposite direction! + [window setFrame:windowFrame display:YES]; + [folderController_ offsetFolderMenuWindow:offset]; +} + +- (void)reconfigureMenu { + [NSObject cancelPreviousPerformRequestsWithTarget:self]; + for (BookmarkButton* button in buttons_.get()) { + [button setDelegate:nil]; + [button removeFromSuperview]; + } + [buttons_ removeAllObjects]; + [self configureWindow]; +} + +#pragma mark Private Methods + +- (BookmarkButtonCell*)cellForBookmarkNode:(const BookmarkNode*)child { + NSImage* image = child ? [barController_ favIconForNode:child] : nil; + NSMenu* menu = child ? child->is_folder() ? folderMenu_ : buttonMenu_ : nil; + BookmarkBarFolderButtonCell* cell = + [BookmarkBarFolderButtonCell buttonCellForNode:child + contextMenu:menu + cellText:nil + cellImage:image]; + [cell setTag:kStandardButtonTypeWithLimitedClickFeedback]; + return cell; +} + +// Redirect to our logic shared with BookmarkBarController. +- (IBAction)openBookmarkFolderFromButton:(id)sender { + [folderTarget_ openBookmarkFolderFromButton:sender]; +} + +// Create a bookmark button for the given node using frame. +// +// If |node| is NULL this is an "(empty)" button. +// Does NOT add this button to our button list. +// Returns an autoreleased button. +// Adjusts the input frame width as appropriate. +// +// TODO(jrg): combine with addNodesToButtonList: code from +// bookmark_bar_controller.mm, and generalize that to use both x and y +// offsets. +// http://crbug.com/35966 +- (BookmarkButton*)makeButtonForNode:(const BookmarkNode*)node + frame:(NSRect)frame { + BookmarkButtonCell* cell = [self cellForBookmarkNode:node]; + DCHECK(cell); + + // We must decide if we draw the folder arrow before we ask the cell + // how big it needs to be. + if (node && node->is_folder()) { + // Warning when combining code with bookmark_bar_controller.mm: + // this call should NOT be made for the bar buttons; only for the + // subfolder buttons. + [cell setDrawFolderArrow:YES]; + } + + // The "+2" is needed because, sometimes, Cocoa is off by a tad when + // returning the value it thinks it needs. + CGFloat desired = [cell cellSize].width + 2; + // The width is determined from the maximum of the proposed width + // (provided in |frame|) or the natural width of the title, then + // limited by the abolute minimum and maximum allowable widths. + frame.size.width = + std::min(std::max(bookmarks::kBookmarkMenuButtonMinimumWidth, + std::max(frame.size.width, desired)), + bookmarks::kBookmarkMenuButtonMaximumWidth); + + BookmarkButton* button = [[[BookmarkButton alloc] initWithFrame:frame] + autorelease]; + DCHECK(button); + + [button setCell:cell]; + [button setDelegate:self]; + if (node) { + if (node->is_folder()) { + [button setTarget:self]; + [button setAction:@selector(openBookmarkFolderFromButton:)]; + } else { + // Make the button do something. + [button setTarget:self]; + [button setAction:@selector(openBookmark:)]; + // Add a tooltip. + NSString* title = base::SysUTF16ToNSString(node->GetTitle()); + std::string urlString = node->GetURL().possibly_invalid_spec(); + NSString* tooltip = [NSString stringWithFormat:@"%@\n%s", title, + urlString.c_str()]; + [button setToolTip:tooltip]; + } + } else { + [button setEnabled:NO]; + [button setBordered:NO]; + } + return button; +} + +// Exposed for testing. +- (NSView*)mainView { + return mainView_; +} + +- (id)folderTarget { + return folderTarget_.get(); +} + + +// Our parent controller is another BookmarkBarFolderController, so +// our window is to the right or left of it. We use a little overlap +// since it looks much more menu-like than with none. If we would +// grow off the screen, switch growth to the other direction. Growth +// direction sticks for folder windows which are descendents of us. +// If we have tried both directions and neither fits, degrade to a +// default. +- (CGFloat)childFolderWindowLeftForWidth:(int)windowWidth { + // We may legitimately need to try two times (growth to right and + // left but not in that order). Limit us to three tries in case + // the folder window can't fit on either side of the screen; we + // don't want to loop forever. + CGFloat x; + int tries = 0; + while (tries < 2) { + // Try to grow right. + if ([self subFolderGrowthToRight]) { + tries++; + x = NSMaxX([[parentButton_ window] frame]) - + bookmarks::kBookmarkMenuOverlap; + // If off the screen, switch direction. + if ((x + windowWidth + + bookmarks::kBookmarkHorizontalScreenPadding) > + NSMaxX([[[self window] screen] frame])) { + [self setSubFolderGrowthToRight:NO]; + } else { + return x; + } + } + // Try to grow left. + if (![self subFolderGrowthToRight]) { + tries++; + x = NSMinX([[parentButton_ window] frame]) + + bookmarks::kBookmarkMenuOverlap - + windowWidth; + // If off the screen, switch direction. + if (x < NSMinX([[[self window] screen] frame])) { + [self setSubFolderGrowthToRight:YES]; + } else { + return x; + } + } + } + // Unhappy; do the best we can. + return NSMaxX([[[self window] screen] frame]) - windowWidth; +} + + +// Compute and return the top left point of our window (screen +// coordinates). The top left is positioned in a manner similar to +// cascading menus. Windows may grow to either the right or left of +// their parent (if a sub-folder) so we need to know |windowWidth|. +- (NSPoint)windowTopLeftForWidth:(int)windowWidth { + NSPoint newWindowTopLeft; + if (![parentController_ isKindOfClass:[self class]]) { + // If we're not popping up from one of ourselves, we must be + // popping up from the bookmark bar itself. In this case, start + // BELOW the parent button. Our left is the button left; our top + // is bottom of button's parent view. + NSPoint buttonBottomLeftInScreen = + [[parentButton_ window] + convertBaseToScreen:[parentButton_ + convertPoint:NSZeroPoint toView:nil]]; + NSPoint bookmarkBarBottomLeftInScreen = + [[parentButton_ window] + convertBaseToScreen:[[parentButton_ superview] + convertPoint:NSZeroPoint toView:nil]]; + newWindowTopLeft = NSMakePoint(buttonBottomLeftInScreen.x, + bookmarkBarBottomLeftInScreen.y); + // Make sure the window is on-screen; if not, push left. It is + // intentional that top level folders "push left" slightly + // different than subfolders. + NSRect screenFrame = [[[parentButton_ window] screen] frame]; + CGFloat spillOff = (newWindowTopLeft.x + windowWidth) - NSMaxX(screenFrame); + if (spillOff > 0.0) { + newWindowTopLeft.x = std::max(newWindowTopLeft.x - spillOff, + NSMinX(screenFrame)); + } + } else { + // Parent is a folder; grow right/left. + newWindowTopLeft.x = [self childFolderWindowLeftForWidth:windowWidth]; + NSPoint top = NSMakePoint(0, (NSMaxY([parentButton_ frame]) + + bookmarks::kBookmarkVerticalPadding)); + NSPoint topOfWindow = + [[parentButton_ window] + convertBaseToScreen:[[parentButton_ superview] + convertPoint:top toView:nil]]; + newWindowTopLeft.y = topOfWindow.y; + } + return newWindowTopLeft; +} + +// Set our window level to the right spot so we're above the menubar, dock, etc. +// Factored out so we can override/noop in a unit test. +- (void)configureWindowLevel { + [[self window] setLevel:NSPopUpMenuWindowLevel]; +} + +- (int)windowHeightForButtonCount:(int)buttonCount { + return (buttonCount * bookmarks::kBookmarkButtonVerticalSpan) + + bookmarks::kBookmarkVerticalPadding; +} + +- (void)adjustWindowForHeight:(int)windowHeight { + // Adjust all button widths to be consistent, determine the best size for + // the window, and set the window frame. + CGFloat windowWidth = + [self adjustButtonWidths] + + (2 * bookmarks::kBookmarkSubMenuHorizontalPadding); + NSPoint newWindowTopLeft = [self windowTopLeftForWidth:windowWidth]; + NSSize windowSize = NSMakeSize(windowWidth, windowHeight); + windowSize = [scrollView_ convertSize:windowSize toView:nil]; + NSWindow* window = [self window]; + // If the window is already visible then make sure its top remains stable. + BOOL windowAlreadyShowing = [window isVisible]; + CGFloat deltaY = windowHeight - NSHeight([mainView_ frame]); + if (windowAlreadyShowing) { + NSRect oldFrame = [window frame]; + newWindowTopLeft.y = oldFrame.origin.y + NSHeight(oldFrame); + } + NSRect windowFrame = NSMakeRect(newWindowTopLeft.x, + newWindowTopLeft.y - windowHeight, windowSize.width, windowHeight); + // Make the scrolled content be the right size (full size). + NSRect mainViewFrame = NSMakeRect(0, 0, NSWidth(windowFrame) - + bookmarks::kScrollViewContentWidthMargin, NSHeight(windowFrame)); + [mainView_ setFrame:mainViewFrame]; + // Make sure the window fits on the screen. If not, constrain. + // We'll scroll to allow the user to see all the content. + NSRect screenFrame = [[[self window] screen] frame]; + screenFrame = NSInsetRect(screenFrame, 0, kScrollWindowVerticalMargin); + BOOL wasScrollable = scrollable_; + if (!NSContainsRect(screenFrame, windowFrame)) { + scrollable_ = YES; + windowFrame = NSIntersectionRect(screenFrame, windowFrame); + } else { + scrollable_ = NO; + } + [window setFrame:windowFrame display:NO]; + if (wasScrollable != scrollable_) { + // If scrollability changed then rework visibility of the scroll arrows + // and the scroll offset of the menu view. + NSSize windowLocalSize = + [scrollView_ convertSize:windowFrame.size fromView:nil]; + CGFloat scrollPointY = NSHeight(mainViewFrame) - windowLocalSize.height + + bookmarks::kBookmarkVerticalPadding; + [mainView_ scrollPoint:NSMakePoint(0, scrollPointY)]; + [self showOrHideScrollArrows]; + [self addOrUpdateScrollTracking]; + } else if (scrollable_ && windowAlreadyShowing) { + // If the window was already showing and is still scrollable then make + // sure the main view moves upward, not downward so that the content + // at the bottom of the menu, not the top, appears to move. + // The edge case is when the menu is scrolled all the way to top (hence + // the test of scrollDownArrowShown_) - don't scroll then. + NSView* superView = [mainView_ superview]; + DCHECK([superView isKindOfClass:[NSClipView class]]); + NSClipView* clipView = static_cast<NSClipView*>(superView); + CGFloat scrollPointY = [clipView bounds].origin.y + + bookmarks::kBookmarkVerticalPadding; + if (scrollDownArrowShown_ || deltaY > 0.0) + scrollPointY += deltaY; + [mainView_ scrollPoint:NSMakePoint(0, scrollPointY)]; + } + [window display]; +} + +// Determine window size and position. +// Create buttons for all our nodes. +// TODO(jrg): break up into more and smaller routines for easier unit testing. +- (void)configureWindow { + const BookmarkNode* node = [parentButton_ bookmarkNode]; + DCHECK(node); + int startingIndex = [[parentButton_ cell] startingChildIndex]; + DCHECK_LE(startingIndex, node->GetChildCount()); + // Must have at least 1 button (for "empty") + int buttons = std::max(node->GetChildCount() - startingIndex, 1); + + // Prelim height of the window. We'll trim later as needed. + int height = [self windowHeightForButtonCount:buttons]; + // We'll need this soon... + [self window]; + + // TODO(jrg): combine with frame code in bookmark_bar_controller.mm + // http://crbug.com/35966 + NSRect buttonsOuterFrame = NSMakeRect( + bookmarks::kBookmarkSubMenuHorizontalPadding, + (height - bookmarks::kBookmarkButtonVerticalSpan), + bookmarks::kDefaultBookmarkWidth, + bookmarks::kBookmarkButtonHeight); + + // TODO(jrg): combine with addNodesToButtonList: code from + // bookmark_bar_controller.mm (but use y offset) + // http://crbug.com/35966 + if (!node->GetChildCount()) { + // If no children we are the empty button. + BookmarkButton* button = [self makeButtonForNode:nil + frame:buttonsOuterFrame]; + [buttons_ addObject:button]; + [mainView_ addSubview:button]; + } else { + for (int i = startingIndex; + i < node->GetChildCount(); + i++) { + const BookmarkNode* child = node->GetChild(i); + BookmarkButton* button = [self makeButtonForNode:child + frame:buttonsOuterFrame]; + [buttons_ addObject:button]; + [mainView_ addSubview:button]; + buttonsOuterFrame.origin.y -= bookmarks::kBookmarkButtonVerticalSpan; + } + } + + [self adjustWindowForHeight:height]; + // Finally pop me up. + [self configureWindowLevel]; +} + +// TODO(mrossetti): See if the following can be moved into view's viewWillDraw:. +- (CGFloat)adjustButtonWidths { + CGFloat width = bookmarks::kBookmarkMenuButtonMinimumWidth; + // Use the cell's size as the base for determining the desired width of the + // button rather than the button's current width. -[cell cellSize] always + // returns the 'optimum' size of the cell based on the cell's contents even + // if it's less than the current button size. Relying on the button size + // would result in buttons that could only get wider but we want to handle + // the case where the widest button gets removed from a folder menu. + for (BookmarkButton* button in buttons_.get()) + width = std::max(width, [[button cell] cellSize].width); + width = std::min(width, bookmarks::kBookmarkMenuButtonMaximumWidth); + // Things look and feel more menu-like if all the buttons are the + // full width of the window, especially if there are submenus. + for (BookmarkButton* button in buttons_.get()) { + NSRect buttonFrame = [button frame]; + buttonFrame.size.width = width; + [button setFrame:buttonFrame]; + } + return width; +} + +- (BOOL)canScrollUp { + // If removal of an arrow would make things "finished", state as + // such. + CGFloat scrollY = [scrollView_ documentVisibleRect].origin.y; + if (scrollUpArrowShown_) + scrollY -= verticalScrollArrowHeight_; + + if (scrollY <= 0) + return NO; + return YES; +} + +- (BOOL)canScrollDown { + CGFloat arrowAdjustment = 0.0; + + // We do NOT adjust based on the scrollDOWN arrow. This keeps + // things from "jumping"; if removal of the down arrow (at the top + // of the window) would cause a scroll to end, we'll end. + if (scrollUpArrowShown_) + arrowAdjustment += verticalScrollArrowHeight_; + + NSPoint scrollPosition = [scrollView_ documentVisibleRect].origin; + NSRect documentRect = [[scrollView_ documentView] frame]; + + // If we are exactly the right height, return no. We need this + // extra conditional in the case where we've just scrolled/grown + // into position. + if (NSHeight([[self window] frame]) == NSHeight(documentRect)) + return NO; + + if ((scrollPosition.y + NSHeight([[self window] frame])) >= + (NSHeight(documentRect) + arrowAdjustment)) { + return NO; + } + return YES; +} + +- (void)showOrHideScrollArrows { + NSRect frame = [scrollView_ frame]; + CGFloat scrollDelta = 0.0; + BOOL canScrollDown = [self canScrollDown]; + BOOL canScrollUp = [self canScrollUp]; + + if (canScrollUp != scrollUpArrowShown_) { + if (scrollUpArrowShown_) { + frame.origin.y -= verticalScrollArrowHeight_; + frame.size.height += verticalScrollArrowHeight_; + scrollDelta = verticalScrollArrowHeight_; + } else { + frame.origin.y += verticalScrollArrowHeight_; + frame.size.height -= verticalScrollArrowHeight_; + scrollDelta = -verticalScrollArrowHeight_; + } + } + if (canScrollDown != scrollDownArrowShown_) { + if (scrollDownArrowShown_) { + frame.size.height += verticalScrollArrowHeight_; + } else { + frame.size.height -= verticalScrollArrowHeight_; + } + } + scrollUpArrowShown_ = canScrollUp; + scrollDownArrowShown_ = canScrollDown; + [scrollView_ setFrame:frame]; + + // Adjust scroll based on new frame. For example, if we make room + // for an arrow at the bottom, adjust the scroll so the topmost item + // is still fully visible. + if (scrollDelta) { + NSPoint scrollPosition = [scrollView_ documentVisibleRect].origin; + scrollPosition.y -= scrollDelta; + [[scrollView_ documentView] scrollPoint:scrollPosition]; + } +} + +- (BOOL)scrollable { + return scrollable_; +} + +// Start a "scroll up" timer. +- (void)beginScrollWindowUp { + [self addScrollTimerWithDelta:kBookmarkBarFolderScrollAmount]; +} + +// Start a "scroll down" timer. +- (void)beginScrollWindowDown { + [self addScrollTimerWithDelta:-kBookmarkBarFolderScrollAmount]; +} + +// End a scrolling timer. Can be called excessively with no harm. +- (void)endScroll { + if (scrollTimer_) { + [scrollTimer_ invalidate]; + scrollTimer_ = nil; + verticalScrollDelta_ = 0; + } +} + +// Perform a single scroll of the specified amount. +// Scroll up: +// Scroll the documentView by the growth amount. +// If we cannot grow the window, simply scroll the documentView. +// If we can grow the window up without falling off the screen, do it. +// Scroll down: +// Never change the window size; only scroll the documentView. +- (void)performOneScroll:(CGFloat)delta { + NSRect windowFrame = [[self window] frame]; + NSRect screenFrame = [[[self window] screen] frame]; + + // First scroll the "document" area. + NSPoint scrollPosition = [scrollView_ documentVisibleRect].origin; + scrollPosition.y -= delta; + [[scrollView_ documentView] scrollPoint:scrollPosition]; + + if (buttonThatMouseIsIn_) + [buttonThatMouseIsIn_ toggleButtonBorderingWhileMouseInside]; + + // We update the window size after shifting the scroll to avoid a race. + CGFloat screenHeightMinusMargin = (NSHeight(screenFrame) - + (2 * kScrollWindowVerticalMargin)); + if (delta) { + // If we can, grow the window (up). + if (NSHeight(windowFrame) < screenHeightMinusMargin) { + CGFloat growAmount = delta; + // Don't scroll more than enough to "finish". + if (scrollPosition.y < 0) + growAmount += scrollPosition.y; + windowFrame.size.height += growAmount; + windowFrame.size.height = std::min(NSHeight(windowFrame), + screenHeightMinusMargin); + // Watch out for a finish that isn't the full height of the screen. + // We get here if using the scroll wheel to scroll by small amounts. + windowFrame.size.height = std::min(NSHeight(windowFrame), + NSHeight([mainView_ frame])); + // Don't allow scrolling to make the window smaller, ever. This + // conditional is important when processing scrollWheel events. + if (windowFrame.size.height > [[self window] frame].size.height) { + [[self window] setFrame:windowFrame display:YES]; + [self addOrUpdateScrollTracking]; + } + } + } + + // If we're at either end, happiness. + if ((scrollPosition.y <= 0) || + ((scrollPosition.y + NSHeight(windowFrame) >= + NSHeight([mainView_ frame])) && + (windowFrame.size.height == screenHeightMinusMargin))) { + [self endScroll]; + + // If we can't scroll either up or down we are completely done. + // For example, perhaps we've scrolled a little and grown the + // window on-screen until there is now room for everything. + if (![self canScrollUp] && ![self canScrollDown]) { + scrollable_ = NO; + [self removeScrollTracking]; + } + } + + [self showOrHideScrollArrows]; +} + +// Perform a scroll of the window on the screen. +// Called by a timer when scrolling. +- (void)performScroll:(NSTimer*)timer { + DCHECK(verticalScrollDelta_); + [self performOneScroll:verticalScrollDelta_]; +} + + +// Add a timer to fire at a regular interveral which scrolls the +// window vertically |delta|. +- (void)addScrollTimerWithDelta:(CGFloat)delta { + if (scrollTimer_ && verticalScrollDelta_ == delta) + return; + [self endScroll]; + verticalScrollDelta_ = delta; + scrollTimer_ = + [NSTimer scheduledTimerWithTimeInterval:kBookmarkBarFolderScrollInterval + target:self + selector:@selector(performScroll:) + userInfo:nil + repeats:YES]; +} + +// Called as a result of our tracking area. Warning: on the main +// screen (of a single-screened machine), the minimum mouse y value is +// 1, not 0. Also, we do not get events when the mouse is above the +// menubar (to be fixed by setting the proper window level; see +// initializer). +- (void)mouseMoved:(NSEvent*)theEvent { + DCHECK([theEvent window] == [self window]); + + NSPoint eventScreenLocation = + [[theEvent window] convertBaseToScreen:[theEvent locationInWindow]]; + + // We use frame (not visibleFrame) since our bookmark folder is on + // TOP of the menubar. + NSRect visibleRect = [[[self window] screen] frame]; + CGFloat closeToTopOfScreen = NSMaxY(visibleRect) - + verticalScrollArrowHeight_; + CGFloat closeToBottomOfScreen = NSMinY(visibleRect) + + verticalScrollArrowHeight_; + + if (eventScreenLocation.y <= closeToBottomOfScreen) { + [self beginScrollWindowUp]; + } else if (eventScreenLocation.y > closeToTopOfScreen) { + [self beginScrollWindowDown]; + } else { + [self endScroll]; + } +} + +- (void)mouseExited:(NSEvent*)theEvent { + [self endScroll]; +} + +// Add a tracking area so we know when the mouse is pinned to the top +// or bottom of the screen. If that happens, and if the mouse +// position overlaps the window, scroll it. +- (void)addOrUpdateScrollTracking { + [self removeScrollTracking]; + NSView* view = [[self window] contentView]; + scrollTrackingArea_.reset([[NSTrackingArea alloc] + initWithRect:[view bounds] + options:(NSTrackingMouseMoved | + NSTrackingMouseEnteredAndExited | + NSTrackingActiveAlways) + owner:self + userInfo:nil]); + [view addTrackingArea:scrollTrackingArea_]; +} + +// Remove the tracking area associated with scrolling. +- (void)removeScrollTracking { + if (scrollTrackingArea_.get()) { + [[[self window] contentView] removeTrackingArea:scrollTrackingArea_]; + } + scrollTrackingArea_.reset(); +} + +// Delegate callback. +- (void)windowWillClose:(NSNotification*)notification { + // If a "hover open" is pending when the bookmark bar folder is + // closed, be sure it gets cancelled. + [NSObject cancelPreviousPerformRequestsWithTarget:self]; + + [barController_ childFolderWillClose:self]; + [self closeBookmarkFolder:self]; + [self autorelease]; +} + +// Close the old hover-open bookmark folder, and open a new one. We +// do both in one step to allow for a delay in closing the old one. +// See comments above kDragHoverCloseDelay (bookmark_bar_controller.h) +// for more details. +- (void)openBookmarkFolderFromButtonAndCloseOldOne:(id)sender { + // If an old submenu exists, close it immediately. + [self closeBookmarkFolder:sender]; + + // Open a new one if meaningful. + if ([sender isFolder]) + [folderTarget_ openBookmarkFolderFromButton:sender]; +} + +- (NSArray*)buttons { + return buttons_.get(); +} + +- (void)close { + [folderController_ close]; + [super close]; +} + +- (void)scrollWheel:(NSEvent *)theEvent { + if (scrollable_) { + // We go negative since an NSScrollView has a flipped coordinate frame. + CGFloat amt = kBookmarkBarFolderScrollWheelAmount * -[theEvent deltaY]; + [self performOneScroll:amt]; + } +} + +#pragma mark Actions Forwarded to Parent BookmarkBarController + +- (IBAction)openBookmark:(id)sender { + [barController_ openBookmark:sender]; +} + +- (IBAction)openBookmarkInNewForegroundTab:(id)sender { + [barController_ openBookmarkInNewForegroundTab:sender]; +} + +- (IBAction)openBookmarkInNewWindow:(id)sender { + [barController_ openBookmarkInNewWindow:sender]; +} + +- (IBAction)openBookmarkInIncognitoWindow:(id)sender { + [barController_ openBookmarkInIncognitoWindow:sender]; +} + +- (IBAction)editBookmark:(id)sender { + [barController_ editBookmark:sender]; +} + +- (IBAction)cutBookmark:(id)sender { + [self closeBookmarkFolder:self]; + [barController_ cutBookmark:sender]; +} + +- (IBAction)copyBookmark:(id)sender { + [barController_ copyBookmark:sender]; +} + +- (IBAction)pasteBookmark:(id)sender { + [barController_ pasteBookmark:sender]; +} + +- (IBAction)deleteBookmark:(id)sender { + [self closeBookmarkFolder:self]; + [barController_ deleteBookmark:sender]; +} + +- (IBAction)openAllBookmarks:(id)sender { + [barController_ openAllBookmarks:sender]; +} + +- (IBAction)openAllBookmarksNewWindow:(id)sender { + [barController_ openAllBookmarksNewWindow:sender]; +} + +- (IBAction)openAllBookmarksIncognitoWindow:(id)sender { + [barController_ openAllBookmarksIncognitoWindow:sender]; +} + +- (IBAction)addPage:(id)sender { + [barController_ addPage:sender]; +} + +- (IBAction)addFolder:(id)sender { + [barController_ addFolder:sender]; +} + +#pragma mark Drag & Drop + +// Find something like std::is_between<T>? I can't believe one doesn't exist. +// http://crbug.com/35966 +static BOOL ValueInRangeInclusive(CGFloat low, CGFloat value, CGFloat high) { + return ((value >= low) && (value <= high)); +} + +// Return the proposed drop target for a hover open button, or nil if none. +// +// TODO(jrg): this is just like the version in +// bookmark_bar_controller.mm, but vertical instead of horizontal. +// Generalize to be axis independent then share code. +// http://crbug.com/35966 +- (BookmarkButton*)buttonForDroppingOnAtPoint:(NSPoint)point { + for (BookmarkButton* button in buttons_.get()) { + // No early break -- makes no assumption about button ordering. + + // Intentionally NOT using NSPointInRect() so that scrolling into + // a submenu doesn't cause it to be closed. + if (ValueInRangeInclusive(NSMinY([button frame]), + point.y, + NSMaxY([button frame]))) { + + // Over a button but let's be a little more specific + // (e.g. over the middle half). + NSRect frame = [button frame]; + NSRect middleHalfOfButton = NSInsetRect(frame, 0, frame.size.height / 4); + if (ValueInRangeInclusive(NSMinY(middleHalfOfButton), + point.y, + NSMaxY(middleHalfOfButton))) { + // It makes no sense to drop on a non-folder; there is no hover. + if (![button isFolder]) + return nil; + // Got it! + return button; + } else { + // Over a button but not over the middle half. + return nil; + } + } + } + // Not hovering over a button. + return nil; +} + +// TODO(jrg): again we have code dup, sort of, with +// bookmark_bar_controller.mm, but the axis is changed. One minor +// difference is accomodation for the "empty" button (which may not +// exist in the future). +// http://crbug.com/35966 +- (int)indexForDragToPoint:(NSPoint)point { + // Identify which buttons we are between. For now, assume a button + // location is at the center point of its view, and that an exact + // match means "place before". + // TODO(jrg): revisit position info based on UI team feedback. + // dropLocation is in bar local coordinates. + // http://crbug.com/36276 + NSPoint dropLocation = + [mainView_ convertPoint:point + fromView:[[self window] contentView]]; + BookmarkButton* buttonToTheTopOfDraggedButton = nil; + // Buttons are laid out in this array from top to bottom (screen + // wise), which means "biggest y" --> "smallest y". + for (BookmarkButton* button in buttons_.get()) { + CGFloat midpoint = NSMidY([button frame]); + if (dropLocation.y > midpoint) { + break; + } + buttonToTheTopOfDraggedButton = button; + } + + // TODO(jrg): On Windows, dropping onto (empty) highlights the + // entire drop location and does not use an insertion point. + // http://crbug.com/35967 + if (!buttonToTheTopOfDraggedButton) { + // We are at the very top (we broke out of the loop on the first try). + return 0; + } + if ([buttonToTheTopOfDraggedButton isEmpty]) { + // There is a button but it's an empty placeholder. + // Default to inserting on top of it. + return 0; + } + const BookmarkNode* beforeNode = [buttonToTheTopOfDraggedButton + bookmarkNode]; + DCHECK(beforeNode); + // Be careful if the number of buttons != number of nodes. + return ((beforeNode->GetParent()->IndexOfChild(beforeNode) + 1) - + [[parentButton_ cell] startingChildIndex]); +} + +// TODO(jrg): Yet more code dup. +// http://crbug.com/35966 +- (BOOL)dragBookmark:(const BookmarkNode*)sourceNode + to:(NSPoint)point + copy:(BOOL)copy { + DCHECK(sourceNode); + + // Drop destination. + const BookmarkNode* destParent = NULL; + int destIndex = 0; + + // First check if we're dropping on a button. If we have one, and + // it's a folder, drop in it. + BookmarkButton* button = [self buttonForDroppingOnAtPoint:point]; + if ([button isFolder]) { + destParent = [button bookmarkNode]; + // Drop it at the end. + destIndex = [button bookmarkNode]->GetChildCount(); + } else { + // Else we're dropping somewhere in the folder, so find the right spot. + destParent = [parentButton_ bookmarkNode]; + destIndex = [self indexForDragToPoint:point]; + // Be careful if the number of buttons != number of nodes. + destIndex += [[parentButton_ cell] startingChildIndex]; + } + + // Prevent cycles. + BOOL wasCopiedOrMoved = NO; + if (!destParent->HasAncestor(sourceNode)) { + if (copy) + [self bookmarkModel]->Copy(sourceNode, destParent, destIndex); + else + [self bookmarkModel]->Move(sourceNode, destParent, destIndex); + wasCopiedOrMoved = YES; + // Movement of a node triggers observers (like us) to rebuild the + // bar so we don't have to do so explicitly. + } + + return wasCopiedOrMoved; +} + +#pragma mark BookmarkButtonDelegate Protocol + +- (void)fillPasteboard:(NSPasteboard*)pboard + forDragOfButton:(BookmarkButton*)button { + [[self folderTarget] fillPasteboard:pboard forDragOfButton:button]; + + // Close our folder menu and submenus since we know we're going to be dragged. + [self closeBookmarkFolder:self]; +} + +// Called from BookmarkButton. +// Unlike bookmark_bar_controller's version, we DO default to being enabled. +- (void)mouseEnteredButton:(id)sender event:(NSEvent*)event { + buttonThatMouseIsIn_ = sender; + + // Cancel a previous hover if needed. + [NSObject cancelPreviousPerformRequestsWithTarget:self]; + + // If already opened, then we exited but re-entered the button + // (without entering another button open), do nothing. + if ([folderController_ parentButton] == sender) + return; + + [self performSelector:@selector(openBookmarkFolderFromButtonAndCloseOldOne:) + withObject:sender + afterDelay:bookmarks::kHoverOpenDelay]; +} + +// Called from the BookmarkButton +- (void)mouseExitedButton:(id)sender event:(NSEvent*)event { + if (buttonThatMouseIsIn_ == sender) + buttonThatMouseIsIn_ = nil; + + // Stop any timer about opening a new hover-open folder. + + // Since a performSelector:withDelay: on self retains self, it is + // possible that a cancelPreviousPerformRequestsWithTarget: reduces + // the refcount to 0, releasing us. That's a bad thing to do while + // this object (or others it may own) is in the event chain. Thus + // we have a retain/autorelease. + [self retain]; + [NSObject cancelPreviousPerformRequestsWithTarget:self]; + [self autorelease]; +} + +- (NSWindow*)browserWindow { + return [parentController_ browserWindow]; +} + +- (BOOL)canDragBookmarkButtonToTrash:(BookmarkButton*)button { + return [barController_ canEditBookmark:[button bookmarkNode]]; +} + +- (void)didDragBookmarkToTrash:(BookmarkButton*)button { + // TODO(mrossetti): Refactor BookmarkBarFolder common code. + // http://crbug.com/35966 + const BookmarkNode* node = [button bookmarkNode]; + if (node) { + const BookmarkNode* parent = node->GetParent(); + [self bookmarkModel]->Remove(parent, + parent->IndexOfChild(node)); + } +} + +#pragma mark BookmarkButtonControllerProtocol + +// Recursively close all bookmark folders. +- (void)closeAllBookmarkFolders { + // Closing the top level implicitly closes all children. + [barController_ closeAllBookmarkFolders]; +} + +// Close our bookmark folder (a sub-controller) if we have one. +- (void)closeBookmarkFolder:(id)sender { + if (folderController_) { + [self setSubFolderGrowthToRight:YES]; + [[folderController_ window] close]; + folderController_ = nil; + } +} + +- (BookmarkModel*)bookmarkModel { + return [barController_ bookmarkModel]; +} + +// TODO(jrg): Refactor BookmarkBarFolder common code. http://crbug.com/35966 +// Most of the work (e.g. drop indicator) is taken care of in the +// folder_view. Here we handle hover open issues for subfolders. +// Caution: there are subtle differences between this one and +// bookmark_bar_controller.mm's version. +- (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)info { + NSPoint currentLocation = [info draggingLocation]; + BookmarkButton* button = [self buttonForDroppingOnAtPoint:currentLocation]; + + // Don't allow drops that would result in cycles. + if (button) { + NSData* data = [[info draggingPasteboard] + dataForType:kBookmarkButtonDragType]; + if (data && [info draggingSource]) { + BookmarkButton* sourceButton = nil; + [data getBytes:&sourceButton length:sizeof(sourceButton)]; + const BookmarkNode* sourceNode = [sourceButton bookmarkNode]; + const BookmarkNode* destNode = [button bookmarkNode]; + if (destNode->HasAncestor(sourceNode)) + button = nil; + } + } + // Delegate handling of dragging over a button to the |hoverState_| member. + return [hoverState_ draggingEnteredButton:button]; +} + +// Unlike bookmark_bar_controller, we need to keep track of dragging state. +// We also need to make sure we cancel the delayed hover close. +- (void)draggingExited:(id<NSDraggingInfo>)info { + // NOT the same as a cancel --> we may have moved the mouse into the submenu. + // Delegate handling of the hover button to the |hoverState_| member. + [hoverState_ draggingExited]; +} + +- (BOOL)dragShouldLockBarVisibility { + return [parentController_ dragShouldLockBarVisibility]; +} + +// TODO(jrg): ARGH more code dup. +// http://crbug.com/35966 +- (BOOL)dragButton:(BookmarkButton*)sourceButton + to:(NSPoint)point + copy:(BOOL)copy { + DCHECK([sourceButton isKindOfClass:[BookmarkButton class]]); + const BookmarkNode* sourceNode = [sourceButton bookmarkNode]; + return [self dragBookmark:sourceNode to:point copy:copy]; +} + +// TODO(mrossetti,jrg): Identical to the same function in BookmarkBarController. +// http://crbug.com/35966 +- (BOOL)dragBookmarkData:(id<NSDraggingInfo>)info { + BOOL dragged = NO; + std::vector<const BookmarkNode*> nodes([self retrieveBookmarkNodeData]); + if (nodes.size()) { + BOOL copy = !([info draggingSourceOperationMask] & NSDragOperationMove); + NSPoint dropPoint = [info draggingLocation]; + for (std::vector<const BookmarkNode*>::const_iterator it = nodes.begin(); + it != nodes.end(); ++it) { + const BookmarkNode* sourceNode = *it; + dragged = [self dragBookmark:sourceNode to:dropPoint copy:copy]; + } + } + return dragged; +} + +// TODO(mrossetti,jrg): Identical to the same function in BookmarkBarController. +// http://crbug.com/35966 +- (std::vector<const BookmarkNode*>)retrieveBookmarkNodeData { + std::vector<const BookmarkNode*> dragDataNodes; + BookmarkNodeData dragData; + if(dragData.ReadFromDragClipboard()) { + BookmarkModel* bookmarkModel = [self bookmarkModel]; + Profile* profile = bookmarkModel->profile(); + std::vector<const BookmarkNode*> nodes(dragData.GetNodes(profile)); + dragDataNodes.assign(nodes.begin(), nodes.end()); + } + return dragDataNodes; +} + +// Return YES if we should show the drop indicator, else NO. +// TODO(jrg): ARGH code dup! +// http://crbug.com/35966 +- (BOOL)shouldShowIndicatorShownForPoint:(NSPoint)point { + return ![self buttonForDroppingOnAtPoint:point]; +} + +// Return the y position for a drop indicator. +// +// TODO(jrg): again we have code dup, sort of, with +// bookmark_bar_controller.mm, but the axis is changed. +// http://crbug.com/35966 +- (CGFloat)indicatorPosForDragToPoint:(NSPoint)point { + CGFloat y = 0; + int destIndex = [self indexForDragToPoint:point]; + int numButtons = static_cast<int>([buttons_ count]); + + // If it's a drop strictly between existing buttons or at the very beginning + if (destIndex >= 0 && destIndex < numButtons) { + // ... put the indicator right between the buttons. + BookmarkButton* button = + [buttons_ objectAtIndex:static_cast<NSUInteger>(destIndex)]; + DCHECK(button); + NSRect buttonFrame = [button frame]; + y = NSMaxY(buttonFrame) + 0.5 * bookmarks::kBookmarkVerticalPadding; + + // If it's a drop at the end (past the last button, if there are any) ... + } else if (destIndex == numButtons) { + // and if it's past the last button ... + if (numButtons > 0) { + // ... find the last button, and put the indicator below it. + BookmarkButton* button = + [buttons_ objectAtIndex:static_cast<NSUInteger>(destIndex - 1)]; + DCHECK(button); + NSRect buttonFrame = [button frame]; + y = buttonFrame.origin.y - 0.5 * bookmarks::kBookmarkVerticalPadding; + + } + } else { + NOTREACHED(); + } + + return y; +} + +- (ThemeProvider*)themeProvider { + return [parentController_ themeProvider]; +} + +- (void)childFolderWillShow:(id<BookmarkButtonControllerProtocol>)child { + // Do nothing. +} + +- (void)childFolderWillClose:(id<BookmarkButtonControllerProtocol>)child { + // Do nothing. +} + +- (BookmarkBarFolderController*)folderController { + return folderController_; +} + +// Add a new folder controller as triggered by the given folder button. +- (void)addNewFolderControllerWithParentButton:(BookmarkButton*)parentButton { + if (folderController_) + [self closeBookmarkFolder:self]; + + // Folder controller, like many window controllers, owns itself. + folderController_ = + [[BookmarkBarFolderController alloc] initWithParentButton:parentButton + parentController:self + barController:barController_]; + [folderController_ showWindow:self]; +} + +- (void)openAll:(const BookmarkNode*)node + disposition:(WindowOpenDisposition)disposition { + [barController_ openAll:node disposition:disposition]; +} + +- (void)addButtonForNode:(const BookmarkNode*)node + atIndex:(NSInteger)buttonIndex { + // Propose the frame for the new button. By default, this will be set to the + // topmost button's frame (and there will always be one) offset upward in + // anticipation of insertion. + NSRect newButtonFrame = [[buttons_ objectAtIndex:0] frame]; + newButtonFrame.origin.y += bookmarks::kBookmarkButtonVerticalSpan; + // When adding a button to an empty folder we must remove the 'empty' + // placeholder button. This can be detected by checking for a parent + // child count of 1. + const BookmarkNode* parentNode = node->GetParent(); + if (parentNode->GetChildCount() == 1) { + BookmarkButton* emptyButton = [buttons_ lastObject]; + newButtonFrame = [emptyButton frame]; + [emptyButton setDelegate:nil]; + [emptyButton removeFromSuperview]; + [buttons_ removeLastObject]; + } + + if (buttonIndex == -1 || buttonIndex > (NSInteger)[buttons_ count]) + buttonIndex = [buttons_ count]; + + // Offset upward by one button height all buttons above insertion location. + BookmarkButton* button = nil; // Remember so it can be de-highlighted. + for (NSInteger i = 0; i < buttonIndex; ++i) { + button = [buttons_ objectAtIndex:i]; + // Remember this location in case it's the last button being moved + // which is where the new button will be located. + newButtonFrame = [button frame]; + NSRect buttonFrame = [button frame]; + buttonFrame.origin.y += bookmarks::kBookmarkButtonVerticalSpan; + [button setFrame:buttonFrame]; + } + [[button cell] mouseExited:nil]; // De-highlight. + BookmarkButton* newButton = [self makeButtonForNode:node + frame:newButtonFrame]; + [buttons_ insertObject:newButton atIndex:buttonIndex]; + [mainView_ addSubview:newButton]; + + // Close any child folder(s) which may still be open. + [self closeBookmarkFolder:self]; + + // Prelim height of the window. We'll trim later as needed. + int height = [self windowHeightForButtonCount:[buttons_ count]]; + [self adjustWindowForHeight:height]; +} + +// More code which essentially duplicates that of BookmarkBarController. +// TODO(mrossetti,jrg): http://crbug.com/35966 +- (BOOL)addURLs:(NSArray*)urls withTitles:(NSArray*)titles at:(NSPoint)point { + DCHECK([urls count] == [titles count]); + BOOL nodesWereAdded = NO; + // Figure out where these new bookmarks nodes are to be added. + BookmarkButton* button = [self buttonForDroppingOnAtPoint:point]; + BookmarkModel* bookmarkModel = [self bookmarkModel]; + const BookmarkNode* destParent = NULL; + int destIndex = 0; + if ([button isFolder]) { + destParent = [button bookmarkNode]; + // Drop it at the end. + destIndex = [button bookmarkNode]->GetChildCount(); + } else { + // Else we're dropping somewhere in the folder, so find the right spot. + destParent = [parentButton_ bookmarkNode]; + destIndex = [self indexForDragToPoint:point]; + // Be careful if the number of buttons != number of nodes. + destIndex += [[parentButton_ cell] startingChildIndex]; + } + + // Create and add the new bookmark nodes. + size_t urlCount = [urls count]; + for (size_t i = 0; i < urlCount; ++i) { + GURL gurl; + const char* string = [[urls objectAtIndex:i] UTF8String]; + if (string) + gurl = GURL(string); + // We only expect to receive valid URLs. + DCHECK(gurl.is_valid()); + if (gurl.is_valid()) { + bookmarkModel->AddURL(destParent, + destIndex++, + base::SysNSStringToUTF16([titles objectAtIndex:i]), + gurl); + nodesWereAdded = YES; + } + } + return nodesWereAdded; +} + +- (void)moveButtonFromIndex:(NSInteger)fromIndex toIndex:(NSInteger)toIndex { + if (fromIndex != toIndex) { + if (toIndex == -1) + toIndex = [buttons_ count]; + BookmarkButton* movedButton = [buttons_ objectAtIndex:fromIndex]; + [buttons_ removeObjectAtIndex:fromIndex]; + NSRect movedFrame = [movedButton frame]; + NSPoint toOrigin = movedFrame.origin; + [movedButton setHidden:YES]; + if (fromIndex < toIndex) { + BookmarkButton* targetButton = [buttons_ objectAtIndex:toIndex - 1]; + toOrigin = [targetButton frame].origin; + for (NSInteger i = fromIndex; i < toIndex; ++i) { + BookmarkButton* button = [buttons_ objectAtIndex:i]; + NSRect frame = [button frame]; + frame.origin.y += bookmarks::kBookmarkButtonVerticalSpan; + [button setFrameOrigin:frame.origin]; + } + } else { + BookmarkButton* targetButton = [buttons_ objectAtIndex:toIndex]; + toOrigin = [targetButton frame].origin; + for (NSInteger i = fromIndex - 1; i >= toIndex; --i) { + BookmarkButton* button = [buttons_ objectAtIndex:i]; + NSRect buttonFrame = [button frame]; + buttonFrame.origin.y -= bookmarks::kBookmarkButtonVerticalSpan; + [button setFrameOrigin:buttonFrame.origin]; + } + } + [buttons_ insertObject:movedButton atIndex:toIndex]; + [movedButton setFrameOrigin:toOrigin]; + [movedButton setHidden:NO]; + } +} + +// TODO(jrg): Refactor BookmarkBarFolder common code. http://crbug.com/35966 +- (void)removeButton:(NSInteger)buttonIndex animate:(BOOL)animate { + // TODO(mrossetti): Get disappearing animation to work. http://crbug.com/42360 + BookmarkButton* oldButton = [buttons_ objectAtIndex:buttonIndex]; + NSPoint poofPoint = [oldButton screenLocationForRemoveAnimation]; + + // If a hover-open is pending, cancel it. + if (oldButton == buttonThatMouseIsIn_) { + [NSObject cancelPreviousPerformRequestsWithTarget:self]; + buttonThatMouseIsIn_ = nil; + } + + // Deleting a button causes rearrangement that enables us to lose a + // mouse-exited event. This problem doesn't appear to exist with + // other keep-menu-open options (e.g. add folder). Since the + // showsBorderOnlyWhileMouseInside uses a tracking area, simple + // tricks (e.g. sending an extra mouseExited: to the button) don't + // fix the problem. + // http://crbug.com/54324 + for (NSButton* button in buttons_.get()) { + if ([button showsBorderOnlyWhileMouseInside]) { + [button setShowsBorderOnlyWhileMouseInside:NO]; + [button setShowsBorderOnlyWhileMouseInside:YES]; + } + } + + [oldButton setDelegate:nil]; + [oldButton removeFromSuperview]; + if (animate && !ignoreAnimations_) + NSShowAnimationEffect(NSAnimationEffectDisappearingItemDefault, poofPoint, + NSZeroSize, nil, nil, nil); + [buttons_ removeObjectAtIndex:buttonIndex]; + for (NSInteger i = 0; i < buttonIndex; ++i) { + BookmarkButton* button = [buttons_ objectAtIndex:i]; + NSRect buttonFrame = [button frame]; + buttonFrame.origin.y -= bookmarks::kBookmarkButtonVerticalSpan; + [button setFrame:buttonFrame]; + } + // Search for and adjust submenus, if necessary. + NSInteger buttonCount = [buttons_ count]; + if (buttonCount) { + BookmarkButton* subButton = [folderController_ parentButton]; + for (NSInteger i = buttonIndex; i < buttonCount; ++i) { + BookmarkButton* aButton = [buttons_ objectAtIndex:i]; + // If this button is showing its menu then we need to move the menu, too. + if (aButton == subButton) + [folderController_ offsetFolderMenuWindow:NSMakeSize(0.0, + bookmarks::kBookmarkBarHeight)]; + } + } else { + // If all nodes have been removed from this folder then add in the + // 'empty' placeholder button. + NSRect buttonFrame = + NSMakeRect(bookmarks::kBookmarkSubMenuHorizontalPadding, + bookmarks::kBookmarkButtonHeight - + (bookmarks::kBookmarkBarHeight - + bookmarks::kBookmarkVerticalPadding), + bookmarks::kDefaultBookmarkWidth, + (bookmarks::kBookmarkBarHeight - + 2 * bookmarks::kBookmarkVerticalPadding)); + BookmarkButton* button = [self makeButtonForNode:nil + frame:buttonFrame]; + [buttons_ addObject:button]; + [mainView_ addSubview:button]; + buttonCount = 1; + } + + // Propose a height for the window. We'll trim later as needed. + [self adjustWindowForHeight:[self windowHeightForButtonCount:buttonCount]]; +} + +- (id<BookmarkButtonControllerProtocol>)controllerForNode: + (const BookmarkNode*)node { + // See if we are holding this node, otherwise see if it is in our + // hierarchy of visible folder menus. + if ([parentButton_ bookmarkNode] == node) + return self; + return [folderController_ controllerForNode:node]; +} + +#pragma mark TestingAPI Only + +- (void)setIgnoreAnimations:(BOOL)ignore { + ignoreAnimations_ = ignore; +} + +- (BookmarkButton*)buttonThatMouseIsIn { + return buttonThatMouseIsIn_; +} + +@end // BookmarkBarFolderController diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_controller_unittest.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_controller_unittest.mm new file mode 100644 index 0000000..cae8766 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_controller_unittest.mm @@ -0,0 +1,1552 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/basictypes.h" +#include "base/scoped_nsobject.h" +#include "base/utf_string_conversions.h" +#include "chrome/browser/bookmarks/bookmark_model.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_constants.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_controller.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_button_cell.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_controller.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_unittest_helper.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/view_resizer_pong.h" +#include "chrome/test/model_test_utils.h" +#include "testing/gtest/include/gtest/gtest.h" +#import "testing/gtest_mac.h" +#include "testing/platform_test.h" + +// Add a redirect to make testing easier. +@interface BookmarkBarFolderController(MakeTestingEasier) +- (IBAction)openBookmarkFolderFromButton:(id)sender; +- (void)validateMenuSpacing; +@end + +@implementation BookmarkBarFolderController(MakeTestingEasier) +- (IBAction)openBookmarkFolderFromButton:(id)sender { + [[self folderTarget] openBookmarkFolderFromButton:sender]; +} + +// Utility function to verify that the buttons in this folder are all +// evenly spaced in a progressive manner. +- (void)validateMenuSpacing { + BOOL firstButton = YES; + CGFloat lastVerticalOffset = 0.0; + for (BookmarkButton* button in [self buttons]) { + if (firstButton) { + firstButton = NO; + lastVerticalOffset = [button frame].origin.y; + } else { + CGFloat nextVerticalOffset = [button frame].origin.y; + EXPECT_CGFLOAT_EQ(lastVerticalOffset - + bookmarks::kBookmarkButtonVerticalSpan, + nextVerticalOffset); + lastVerticalOffset = nextVerticalOffset; + } + } +} +@end + +// Don't use a high window level when running unit tests -- it'll +// interfere with anything else you are working on. +// For testing. +@interface BookmarkBarFolderControllerNoLevel : BookmarkBarFolderController +@end + +@implementation BookmarkBarFolderControllerNoLevel +- (void)configureWindowLevel { + // Intentionally empty. +} +@end + +// No window level and the ability to fake the "top left" point of the window. +// For testing. +@interface BookmarkBarFolderControllerLow : BookmarkBarFolderControllerNoLevel { + BOOL realTopLeft_; // Use the real windowTopLeft call? +} +@property (nonatomic) BOOL realTopLeft; +@end + + +@implementation BookmarkBarFolderControllerLow + +@synthesize realTopLeft = realTopLeft_; + +- (NSPoint)windowTopLeftForWidth:(int)width { + return realTopLeft_ ? [super windowTopLeftForWidth:width] : + NSMakePoint(200,200); +} + +@end + + +@interface BookmarkBarFolderControllerPong : BookmarkBarFolderControllerLow { + BOOL childFolderWillShow_; + BOOL childFolderWillClose_; +} +@property (nonatomic, readonly) BOOL childFolderWillShow; +@property (nonatomic, readonly) BOOL childFolderWillClose; +@end + +@implementation BookmarkBarFolderControllerPong +@synthesize childFolderWillShow = childFolderWillShow_; +@synthesize childFolderWillClose = childFolderWillClose_; + +- (void)childFolderWillShow:(id<BookmarkButtonControllerProtocol>)child { + childFolderWillShow_ = YES; +} + +- (void)childFolderWillClose:(id<BookmarkButtonControllerProtocol>)child { + childFolderWillClose_ = YES; +} + +// We don't have a real BookmarkBarController as our parent root so +// we fake this one out. +- (void)closeAllBookmarkFolders { + [self closeBookmarkFolder:self]; +} + +@end + +namespace { +const int kLotsOfNodesCount = 150; +}; + + +// Redirect certain calls so they can be seen by tests. + +@interface BookmarkBarControllerChildFolderRedirect : BookmarkBarController { + BookmarkBarFolderController* childFolderDelegate_; +} +@property (nonatomic, assign) BookmarkBarFolderController* childFolderDelegate; +@end + +@implementation BookmarkBarControllerChildFolderRedirect + +@synthesize childFolderDelegate = childFolderDelegate_; + +- (void)childFolderWillShow:(id<BookmarkButtonControllerProtocol>)child { + [childFolderDelegate_ childFolderWillShow:child]; +} + +- (void)childFolderWillClose:(id<BookmarkButtonControllerProtocol>)child { + [childFolderDelegate_ childFolderWillClose:child]; +} + +@end + + +class BookmarkBarFolderControllerTest : public CocoaTest { + public: + BrowserTestHelper helper_; + scoped_nsobject<BookmarkBarControllerChildFolderRedirect> bar_; + const BookmarkNode* folderA_; // owned by model + const BookmarkNode* longTitleNode_; // owned by model + + BookmarkBarFolderControllerTest() { + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + const BookmarkNode* parent = model->GetBookmarkBarNode(); + const BookmarkNode* folderA = model->AddGroup(parent, + parent->GetChildCount(), + ASCIIToUTF16("group")); + folderA_ = folderA; + model->AddGroup(parent, parent->GetChildCount(), + ASCIIToUTF16("sibbling group")); + const BookmarkNode* folderB = model->AddGroup(folderA, + folderA->GetChildCount(), + ASCIIToUTF16("subgroup 1")); + model->AddGroup(folderA, + folderA->GetChildCount(), + ASCIIToUTF16("subgroup 2")); + model->AddURL(folderA, folderA->GetChildCount(), ASCIIToUTF16("title a"), + GURL("http://www.google.com/a")); + longTitleNode_ = model->AddURL( + folderA, folderA->GetChildCount(), + ASCIIToUTF16("title super duper long long whoa momma title you betcha"), + GURL("http://www.google.com/b")); + model->AddURL(folderB, folderB->GetChildCount(), ASCIIToUTF16("t"), + GURL("http://www.google.com/c")); + + bar_.reset( + [[BookmarkBarControllerChildFolderRedirect alloc] + initWithBrowser:helper_.browser() + initialWidth:300 + delegate:nil + resizeDelegate:nil]); + [bar_ loaded:model]; + // Make parent frame for bookmark bar then open it. + NSRect frame = [[test_window() contentView] frame]; + frame = NSInsetRect(frame, 100, 200); + NSView* fakeToolbarView = [[[NSView alloc] initWithFrame:frame] + autorelease]; + [[test_window() contentView] addSubview:fakeToolbarView]; + [fakeToolbarView addSubview:[bar_ view]]; + [bar_ setBookmarkBarEnabled:YES]; + } + + // Remove the bookmark with the long title. + void RemoveLongTitleNode() { + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + model->Remove(longTitleNode_->GetParent(), + longTitleNode_->GetParent()->IndexOfChild(longTitleNode_)); + } + + // Add LOTS of nodes to our model if needed (e.g. scrolling). + // Returns the number of nodes added. + int AddLotsOfNodes() { + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + for (int i = 0; i < kLotsOfNodesCount; i++) { + model->AddURL(folderA_, folderA_->GetChildCount(), + ASCIIToUTF16("repeated title"), + GURL("http://www.google.com/repeated/url")); + } + return kLotsOfNodesCount; + } + + // Return a simple BookmarkBarFolderController. + BookmarkBarFolderControllerPong* SimpleBookmarkBarFolderController() { + BookmarkButton* parentButton = [[bar_ buttons] objectAtIndex:0]; + BookmarkBarFolderControllerPong* c = + [[BookmarkBarFolderControllerPong alloc] + initWithParentButton:parentButton + parentController:nil + barController:bar_]; + [c window]; // Force nib load. + return c; + } +}; + +TEST_F(BookmarkBarFolderControllerTest, InitCreateAndDelete) { + scoped_nsobject<BookmarkBarFolderController> bbfc; + bbfc.reset(SimpleBookmarkBarFolderController()); + + // Make sure none of the buttons overlap, that all are inside + // the content frame, and their cells are of the proper class. + NSArray* buttons = [bbfc buttons]; + EXPECT_TRUE([buttons count]); + for (unsigned int i = 0; i < ([buttons count]-1); i++) { + EXPECT_FALSE(NSContainsRect([[buttons objectAtIndex:i] frame], + [[buttons objectAtIndex:i+1] frame])); + } + Class cellClass = [BookmarkBarFolderButtonCell class]; + for (BookmarkButton* button in buttons) { + NSRect r = [[bbfc mainView] convertRect:[button frame] fromView:button]; + // TODO(jrg): remove this adjustment. + NSRect bigger = NSInsetRect([[bbfc mainView] frame], -2, 0); + EXPECT_TRUE(NSContainsRect(bigger, r)); + EXPECT_TRUE([[button cell] isKindOfClass:cellClass]); + } + + // Confirm folder buttons have no tooltip. The important thing + // really is that we insure folders and non-folders are treated + // differently; not sure of any other generic way to do this. + for (BookmarkButton* button in buttons) { + if ([button isFolder]) + EXPECT_FALSE([button toolTip]); + else + EXPECT_TRUE([button toolTip]); + } +} + +// Make sure closing of the window releases the controller. +// (e.g. valgrind shouldn't complain if we do this). +TEST_F(BookmarkBarFolderControllerTest, ReleaseOnClose) { + scoped_nsobject<BookmarkBarFolderController> bbfc; + bbfc.reset(SimpleBookmarkBarFolderController()); + EXPECT_TRUE(bbfc.get()); + + [bbfc retain]; // stop the scoped_nsobject from doing anything + [[bbfc window] close]; // trigger an autorelease of bbfc.get() +} + +TEST_F(BookmarkBarFolderControllerTest, BasicPosition) { + BookmarkButton* parentButton = [[bar_ buttons] objectAtIndex:0]; + EXPECT_TRUE(parentButton); + + // If parent is a BookmarkBarController, grow down. + scoped_nsobject<BookmarkBarFolderControllerLow> bbfc; + bbfc.reset([[BookmarkBarFolderControllerLow alloc] + initWithParentButton:parentButton + parentController:nil + barController:bar_]); + [bbfc window]; + [bbfc setRealTopLeft:YES]; + NSPoint pt = [bbfc windowTopLeftForWidth:0]; // screen coords + NSPoint buttonOriginInScreen = + [[parentButton window] + convertBaseToScreen:[parentButton + convertRectToBase:[parentButton frame]].origin]; + // Within margin + EXPECT_LE(abs(pt.x - buttonOriginInScreen.x), + bookmarks::kBookmarkMenuOverlap+1); + EXPECT_LE(abs(pt.y - buttonOriginInScreen.y), + bookmarks::kBookmarkMenuOverlap+1); + + // Make sure we see the window shift left if it spills off the screen + pt = [bbfc windowTopLeftForWidth:0]; + NSPoint shifted = [bbfc windowTopLeftForWidth:9999999]; + EXPECT_LT(shifted.x, pt.x); + + // If parent is a BookmarkBarFolderController, grow right. + scoped_nsobject<BookmarkBarFolderControllerLow> bbfc2; + bbfc2.reset([[BookmarkBarFolderControllerLow alloc] + initWithParentButton:[[bbfc buttons] objectAtIndex:0] + parentController:bbfc.get() + barController:bar_]); + [bbfc2 window]; + [bbfc2 setRealTopLeft:YES]; + pt = [bbfc2 windowTopLeftForWidth:0]; + // We're now overlapping the window a bit. + EXPECT_EQ(pt.x, NSMaxX([[bbfc.get() window] frame]) - + bookmarks::kBookmarkMenuOverlap); +} + +// Confirm we grow right until end of screen, then start growing left +// until end of screen again, then right. +TEST_F(BookmarkBarFolderControllerTest, PositionRightLeftRight) { + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + const BookmarkNode* parent = model->GetBookmarkBarNode(); + const BookmarkNode* folder = parent; + + const int count = 100; + int i; + // Make some super duper deeply nested folders. + for (i=0; i<count; i++) { + folder = model->AddGroup(folder, 0, ASCIIToUTF16("nested folder")); + } + + // Setup initial state for opening all folders. + folder = parent; + BookmarkButton* parentButton = [[bar_ buttons] objectAtIndex:0]; + BookmarkBarFolderController* parentController = nil; + EXPECT_TRUE(parentButton); + + // Open them all. + scoped_nsobject<NSMutableArray> folder_controller_array; + folder_controller_array.reset([[NSMutableArray array] retain]); + for (i=0; i<count; i++) { + BookmarkBarFolderControllerNoLevel* bbfcl = + [[BookmarkBarFolderControllerNoLevel alloc] + initWithParentButton:parentButton + parentController:parentController + barController:bar_]; + [folder_controller_array addObject:bbfcl]; + [bbfcl autorelease]; + [bbfcl window]; + parentController = bbfcl; + parentButton = [[bbfcl buttons] objectAtIndex:0]; + } + + // Make vector of all x positions. + std::vector<CGFloat> leftPositions; + for (i=0; i<count; i++) { + CGFloat x = [[[folder_controller_array objectAtIndex:i] window] + frame].origin.x; + leftPositions.push_back(x); + } + + // Make sure the first few grow right. + for (i=0; i<3; i++) + EXPECT_TRUE(leftPositions[i+1] > leftPositions[i]); + + // Look for the first "grow left". + while (leftPositions[i] > leftPositions[i-1]) + i++; + // Confirm the next few also grow left. + int j; + for (j=i; j<i+3; j++) + EXPECT_TRUE(leftPositions[j+1] < leftPositions[j]); + i = j; + + // Finally, confirm we see a "grow right" once more. + while (leftPositions[i] < leftPositions[i-1]) + i++; + // (No need to EXPECT a final "grow right"; if we didn't find one + // we'd get a C++ array bounds exception). +} + +TEST_F(BookmarkBarFolderControllerTest, DropDestination) { + scoped_nsobject<BookmarkBarFolderController> bbfc; + bbfc.reset(SimpleBookmarkBarFolderController()); + EXPECT_TRUE(bbfc.get()); + + // Confirm "off the top" and "off the bottom" match no buttons. + NSPoint p = NSMakePoint(NSMidX([[bbfc mainView] frame]), 10000); + EXPECT_FALSE([bbfc buttonForDroppingOnAtPoint:p]); + EXPECT_TRUE([bbfc shouldShowIndicatorShownForPoint:p]); + p = NSMakePoint(NSMidX([[bbfc mainView] frame]), -1); + EXPECT_FALSE([bbfc buttonForDroppingOnAtPoint:p]); + EXPECT_TRUE([bbfc shouldShowIndicatorShownForPoint:p]); + + // Confirm "right in the center" (give or take a pixel) is a match, + // and confirm "just barely in the button" is not. Anything more + // specific seems likely to be tweaked. We don't loop over all + // buttons because the scroll view makes them not visible. + for (BookmarkButton* button in [bbfc buttons]) { + CGFloat x = NSMidX([button frame]); + CGFloat y = NSMidY([button frame]); + // Somewhere near the center: a match (but only if a folder!) + if ([button isFolder]) { + EXPECT_EQ(button, + [bbfc buttonForDroppingOnAtPoint:NSMakePoint(x-1, y+1)]); + EXPECT_EQ(button, + [bbfc buttonForDroppingOnAtPoint:NSMakePoint(x+1, y-1)]); + EXPECT_FALSE([bbfc shouldShowIndicatorShownForPoint:NSMakePoint(x, y)]);; + } else { + // If not a folder we don't drop into it. + EXPECT_FALSE([bbfc buttonForDroppingOnAtPoint:NSMakePoint(x-1, y+1)]); + EXPECT_FALSE([bbfc buttonForDroppingOnAtPoint:NSMakePoint(x+1, y-1)]); + EXPECT_TRUE([bbfc shouldShowIndicatorShownForPoint:NSMakePoint(x, y)]);; + } + } +} + +TEST_F(BookmarkBarFolderControllerTest, OpenFolder) { + scoped_nsobject<BookmarkBarFolderController> bbfc; + bbfc.reset(SimpleBookmarkBarFolderController()); + EXPECT_TRUE(bbfc.get()); + + EXPECT_FALSE([bbfc folderController]); + BookmarkButton* button = [[bbfc buttons] objectAtIndex:0]; + [bbfc openBookmarkFolderFromButton:button]; + id controller = [bbfc folderController]; + EXPECT_TRUE(controller); + EXPECT_EQ([controller parentButton], button); + + // Click the same one --> it gets closed. + [bbfc openBookmarkFolderFromButton:[[bbfc buttons] objectAtIndex:0]]; + EXPECT_FALSE([bbfc folderController]); + + // Open a new one --> change. + [bbfc openBookmarkFolderFromButton:[[bbfc buttons] objectAtIndex:1]]; + EXPECT_NE(controller, [bbfc folderController]); + EXPECT_NE([[bbfc folderController] parentButton], button); + + // Close it --> all gone! + [bbfc closeBookmarkFolder:nil]; + EXPECT_FALSE([bbfc folderController]); +} + +TEST_F(BookmarkBarFolderControllerTest, ChildFolderCallbacks) { + scoped_nsobject<BookmarkBarFolderControllerPong> bbfc; + bbfc.reset(SimpleBookmarkBarFolderController()); + EXPECT_TRUE(bbfc.get()); + [bar_ setChildFolderDelegate:bbfc.get()]; + + EXPECT_FALSE([bbfc childFolderWillShow]); + [bbfc openBookmarkFolderFromButton:[[bbfc buttons] objectAtIndex:0]]; + EXPECT_TRUE([bbfc childFolderWillShow]); + + EXPECT_FALSE([bbfc childFolderWillClose]); + [bbfc closeBookmarkFolder:nil]; + EXPECT_TRUE([bbfc childFolderWillClose]); + + [bar_ setChildFolderDelegate:nil]; +} + +// Make sure bookmark folders have variable widths. +TEST_F(BookmarkBarFolderControllerTest, ChildFolderWidth) { + scoped_nsobject<BookmarkBarFolderController> bbfc; + + bbfc.reset(SimpleBookmarkBarFolderController()); + EXPECT_TRUE(bbfc.get()); + [bbfc showWindow:bbfc.get()]; + CGFloat wideWidth = NSWidth([[bbfc window] frame]); + + RemoveLongTitleNode(); + bbfc.reset(SimpleBookmarkBarFolderController()); + EXPECT_TRUE(bbfc.get()); + CGFloat thinWidth = NSWidth([[bbfc window] frame]); + + // Make sure window size changed as expected. + EXPECT_GT(wideWidth, thinWidth); +} + +// Simple scrolling tests. +TEST_F(BookmarkBarFolderControllerTest, SimpleScroll) { + scoped_nsobject<BookmarkBarFolderController> bbfc; + + int nodecount = AddLotsOfNodes(); + bbfc.reset(SimpleBookmarkBarFolderController()); + EXPECT_TRUE(bbfc.get()); + [bbfc showWindow:bbfc.get()]; + + // Make sure the window fits on the screen. + EXPECT_LT(NSHeight([[bbfc window] frame]), + NSHeight([[NSScreen mainScreen] frame])); + + // Verify the logic used by the scroll arrow code. + EXPECT_TRUE([bbfc canScrollUp]); + EXPECT_FALSE([bbfc canScrollDown]); + + // Scroll it up. Make sure the window has gotten bigger each time. + // Also, for each scroll, make sure our hit test finds a new button + // (to confirm the content area changed). + NSView* savedHit = nil; + for (int i=0; i<3; i++) { + CGFloat height = NSHeight([[bbfc window] frame]); + [bbfc performOneScroll:60]; + EXPECT_GT(NSHeight([[bbfc window] frame]), height); + NSView* hit = [[[bbfc window] contentView] hitTest:NSMakePoint(22, 22)]; + EXPECT_NE(hit, savedHit); + savedHit = hit; + } + + // Keep scrolling up; make sure we never get bigger than the screen. + // Also confirm we never scroll the window off the screen. + bool bothAtOnce = false; + NSRect screenFrame = [[NSScreen mainScreen] frame]; + for (int i = 0; i < nodecount; i++) { + [bbfc performOneScroll:60]; + EXPECT_TRUE(NSContainsRect(screenFrame, + [[bbfc window] frame])); + // Make sure, sometime during our scroll, we have the ability to + // scroll in either direction. + if ([bbfc canScrollUp] && + [bbfc canScrollDown]) + bothAtOnce = true; + } + EXPECT_TRUE(bothAtOnce); + + // Once we've scrolled to the end, our only option should be to scroll back. + EXPECT_FALSE([bbfc canScrollUp]); + EXPECT_TRUE([bbfc canScrollDown]); + + // Now scroll down and make sure the window size does not change. + // Also confirm we never scroll the window off the screen the other + // way. + for (int i=0; i<nodecount+50; i++) { + CGFloat height = NSHeight([[bbfc window] frame]); + [bbfc performOneScroll:-60]; + EXPECT_EQ(height, NSHeight([[bbfc window] frame])); + EXPECT_TRUE(NSContainsRect(screenFrame, + [[bbfc window] frame])); + } +} + +// Folder menu sizing and placementwhile deleting bookmarks and scrolling tests. +TEST_F(BookmarkBarFolderControllerTest, MenuPlacementWhileScrollingDeleting) { + scoped_nsobject<BookmarkBarFolderController> bbfc; + AddLotsOfNodes(); + bbfc.reset(SimpleBookmarkBarFolderController()); + [bbfc showWindow:bbfc.get()]; + NSWindow* menuWindow = [bbfc window]; + BookmarkBarFolderController* folder = [bar_ folderController]; + NSArray* buttons = [folder buttons]; + + // Before scrolling any, delete a bookmark and make sure the window top has + // not moved. Pick a button which is near the top and visible. + CGFloat oldTop = [menuWindow frame].origin.y + NSHeight([menuWindow frame]); + BookmarkButton* button = [buttons objectAtIndex:3]; + [folder deleteBookmark:button]; + CGFloat newTop = [menuWindow frame].origin.y + NSHeight([menuWindow frame]); + EXPECT_CGFLOAT_EQ(oldTop, newTop); + + // Scroll so that both the top and bottom scroll arrows show, make sure + // the top of the window has moved up, then delete a visible button and + // make sure the top has not moved. + oldTop = newTop; + const CGFloat scrollOneBookmark = bookmarks::kBookmarkButtonHeight + + bookmarks::kBookmarkVerticalPadding; + NSUInteger buttonCounter = 0; + NSUInteger extraButtonLimit = 3; + while (![bbfc canScrollDown] || extraButtonLimit > 0) { + [bbfc performOneScroll:scrollOneBookmark]; + ++buttonCounter; + if ([bbfc canScrollDown]) + --extraButtonLimit; + } + newTop = [menuWindow frame].origin.y + NSHeight([menuWindow frame]); + EXPECT_NE(oldTop, newTop); + oldTop = newTop; + button = [buttons objectAtIndex:buttonCounter + 3]; + [folder deleteBookmark:button]; + newTop = [menuWindow frame].origin.y + NSHeight([menuWindow frame]); + EXPECT_CGFLOAT_EQ(oldTop, newTop); + + // Scroll so that the top scroll arrow is no longer showing, make sure + // the top of the window has not moved, then delete a visible button and + // make sure the top has not moved. + while ([bbfc canScrollDown]) { + [bbfc performOneScroll:-scrollOneBookmark]; + --buttonCounter; + } + button = [buttons objectAtIndex:buttonCounter + 3]; + [folder deleteBookmark:button]; + newTop = [menuWindow frame].origin.y + NSHeight([menuWindow frame]); + EXPECT_CGFLOAT_EQ(oldTop, newTop); +} + +@interface FakedDragInfo : NSObject { +@public + NSPoint dropLocation_; + NSDragOperation sourceMask_; +} +@property (nonatomic, assign) NSPoint dropLocation; +- (void)setDraggingSourceOperationMask:(NSDragOperation)mask; +@end + +@implementation FakedDragInfo + +@synthesize dropLocation = dropLocation_; + +- (id)init { + if ((self = [super init])) { + dropLocation_ = NSZeroPoint; + sourceMask_ = NSDragOperationMove; + } + return self; +} + +// NSDraggingInfo protocol functions. + +- (id)draggingPasteboard { + return self; +} + +- (id)draggingSource { + return self; +} + +- (NSDragOperation)draggingSourceOperationMask { + return sourceMask_; +} + +- (NSPoint)draggingLocation { + return dropLocation_; +} + +// Other functions. + +- (void)setDraggingSourceOperationMask:(NSDragOperation)mask { + sourceMask_ = mask; +} + +@end + + +class BookmarkBarFolderControllerMenuTest : public CocoaTest { + public: + BrowserTestHelper helper_; + scoped_nsobject<NSView> parent_view_; + scoped_nsobject<ViewResizerPong> resizeDelegate_; + scoped_nsobject<BookmarkBarController> bar_; + + BookmarkBarFolderControllerMenuTest() { + resizeDelegate_.reset([[ViewResizerPong alloc] init]); + NSRect parent_frame = NSMakeRect(0, 0, 800, 50); + parent_view_.reset([[NSView alloc] initWithFrame:parent_frame]); + [parent_view_ setHidden:YES]; + bar_.reset([[BookmarkBarController alloc] + initWithBrowser:helper_.browser() + initialWidth:NSWidth(parent_frame) + delegate:nil + resizeDelegate:resizeDelegate_.get()]); + InstallAndToggleBar(bar_.get()); + } + + void InstallAndToggleBar(BookmarkBarController* bar) { + // Force loading of the nib. + [bar view]; + // Awkwardness to look like we've been installed. + [parent_view_ addSubview:[bar view]]; + NSRect frame = [[[bar view] superview] frame]; + frame.origin.y = 100; + [[[bar view] superview] setFrame:frame]; + + // Make sure it's on in a window so viewDidMoveToWindow is called + [[test_window() contentView] addSubview:parent_view_]; + + // Make sure it's open so certain things aren't no-ops. + [bar updateAndShowNormalBar:YES + showDetachedBar:NO + withAnimation:NO]; + } +}; + +TEST_F(BookmarkBarFolderControllerMenuTest, DragMoveBarBookmarkToFolder) { + BookmarkModel& model(*helper_.profile()->GetBookmarkModel()); + const BookmarkNode* root = model.GetBookmarkBarNode(); + const std::string model_string("1b 2f:[ 2f1b 2f2f:[ 2f2f1b 2f2f2b " + "2f2f3b ] 2f3b ] 3b 4f:[ 4f1f:[ 4f1f1b 4f1f2b 4f1f3b ] 4f2f:[ 4f2f1b " + "4f2f2b 4f2f3b ] 4f3f:[ 4f3f1b 4f3f2b 4f3f3b ] ] 5b "); + model_test_utils::AddNodesFromModelString(model, root, model_string); + + // Validate initial model. + std::string actualModelString = model_test_utils::ModelStringFromNode(root); + EXPECT_EQ(model_string, actualModelString); + + // Pop up a folder menu and drag in a button from the bar. + BookmarkButton* toFolder = [bar_ buttonWithTitleEqualTo:@"2f"]; + NSRect oldToFolderFrame = [toFolder frame]; + [[toFolder target] performSelector:@selector(openBookmarkFolderFromButton:) + withObject:toFolder]; + BookmarkBarFolderController* folderController = [bar_ folderController]; + EXPECT_TRUE(folderController); + NSWindow* toWindow = [folderController window]; + EXPECT_TRUE(toWindow); + NSRect oldToWindowFrame = [toWindow frame]; + // Drag a bar button onto a bookmark (i.e. not a folder) in a folder + // so it should end up below the target bookmark. + BookmarkButton* draggedButton = [bar_ buttonWithTitleEqualTo:@"1b"]; + ASSERT_TRUE(draggedButton); + CGFloat horizontalShift = + NSWidth([draggedButton frame]) + bookmarks::kBookmarkHorizontalPadding; + BookmarkButton* targetButton = + [folderController buttonWithTitleEqualTo:@"2f1b"]; + ASSERT_TRUE(targetButton); + [folderController dragButton:draggedButton + to:[targetButton center] + copy:NO]; + // The button should have landed just after "2f1b". + const std::string expected_string("2f:[ 2f1b 1b 2f2f:[ 2f2f1b " + "2f2f2b 2f2f3b ] 2f3b ] 3b 4f:[ 4f1f:[ 4f1f1b 4f1f2b 4f1f3b ] 4f2f:[ " + "4f2f1b 4f2f2b 4f2f3b ] 4f3f:[ 4f3f1b 4f3f2b 4f3f3b ] ] 5b "); + EXPECT_EQ(expected_string, model_test_utils::ModelStringFromNode(root)); + + // Verify the window still appears by looking for its controller. + EXPECT_TRUE([bar_ folderController]); + + // Gather the new frames. + NSRect newToFolderFrame = [toFolder frame]; + NSRect newToWindowFrame = [toWindow frame]; + // The toFolder should have shifted left horizontally but not vertically. + NSRect expectedToFolderFrame = + NSOffsetRect(oldToFolderFrame, -horizontalShift, 0); + EXPECT_NSRECT_EQ(expectedToFolderFrame, newToFolderFrame); + // The toWindow should have shifted left horizontally, down vertically, + // and grown vertically. + NSRect expectedToWindowFrame = oldToWindowFrame; + expectedToWindowFrame.origin.x -= horizontalShift; + CGFloat diff = (bookmarks::kBookmarkBarHeight + + 2*bookmarks::kBookmarkVerticalPadding); + expectedToWindowFrame.origin.y -= diff; + expectedToWindowFrame.size.height += diff; + EXPECT_NSRECT_EQ(expectedToWindowFrame, newToWindowFrame); + + // Check button spacing. + [folderController validateMenuSpacing]; + + // Move the button back to the bar at the beginning. + draggedButton = [folderController buttonWithTitleEqualTo:@"1b"]; + ASSERT_TRUE(draggedButton); + targetButton = [bar_ buttonWithTitleEqualTo:@"2f"]; + ASSERT_TRUE(targetButton); + [bar_ dragButton:draggedButton + to:[targetButton left] + copy:NO]; + EXPECT_EQ(model_string, model_test_utils::ModelStringFromNode(root)); + // Don't check the folder window since it's not supposed to be showing. +} + +TEST_F(BookmarkBarFolderControllerMenuTest, DragCopyBarBookmarkToFolder) { + BookmarkModel& model(*helper_.profile()->GetBookmarkModel()); + const BookmarkNode* root = model.GetBookmarkBarNode(); + const std::string model_string("1b 2f:[ 2f1b 2f2f:[ 2f2f1b 2f2f2b " + "2f2f3b ] 2f3b ] 3b 4f:[ 4f1f:[ 4f1f1b 4f1f2b 4f1f3b ] 4f2f:[ 4f2f1b " + "4f2f2b 4f2f3b ] 4f3f:[ 4f3f1b 4f3f2b 4f3f3b ] ] 5b "); + model_test_utils::AddNodesFromModelString(model, root, model_string); + + // Validate initial model. + std::string actualModelString = model_test_utils::ModelStringFromNode(root); + EXPECT_EQ(model_string, actualModelString); + + // Pop up a folder menu and copy in a button from the bar. + BookmarkButton* toFolder = [bar_ buttonWithTitleEqualTo:@"2f"]; + ASSERT_TRUE(toFolder); + NSRect oldToFolderFrame = [toFolder frame]; + [[toFolder target] performSelector:@selector(openBookmarkFolderFromButton:) + withObject:toFolder]; + BookmarkBarFolderController* folderController = [bar_ folderController]; + EXPECT_TRUE(folderController); + NSWindow* toWindow = [folderController window]; + EXPECT_TRUE(toWindow); + NSRect oldToWindowFrame = [toWindow frame]; + // Drag a bar button onto a bookmark (i.e. not a folder) in a folder + // so it should end up below the target bookmark. + BookmarkButton* draggedButton = [bar_ buttonWithTitleEqualTo:@"1b"]; + ASSERT_TRUE(draggedButton); + BookmarkButton* targetButton = + [folderController buttonWithTitleEqualTo:@"2f1b"]; + ASSERT_TRUE(targetButton); + [folderController dragButton:draggedButton + to:[targetButton center] + copy:YES]; + // The button should have landed just after "2f1b". + const std::string expected_1("1b 2f:[ 2f1b 1b 2f2f:[ 2f2f1b " + "2f2f2b 2f2f3b ] 2f3b ] 3b 4f:[ 4f1f:[ 4f1f1b 4f1f2b 4f1f3b ] 4f2f:[ " + "4f2f1b 4f2f2b 4f2f3b ] 4f3f:[ 4f3f1b 4f3f2b 4f3f3b ] ] 5b "); + EXPECT_EQ(expected_1, model_test_utils::ModelStringFromNode(root)); + + // Gather the new frames. + NSRect newToFolderFrame = [toFolder frame]; + NSRect newToWindowFrame = [toWindow frame]; + // The toFolder should have shifted. + EXPECT_NSRECT_EQ(oldToFolderFrame, newToFolderFrame); + // The toWindow should have shifted down vertically and grown vertically. + NSRect expectedToWindowFrame = oldToWindowFrame; + CGFloat diff = (bookmarks::kBookmarkBarHeight + + 2*bookmarks::kBookmarkVerticalPadding); + expectedToWindowFrame.origin.y -= diff; + expectedToWindowFrame.size.height += diff; + EXPECT_NSRECT_EQ(expectedToWindowFrame, newToWindowFrame); + + // Copy the button back to the bar after "3b". + draggedButton = [folderController buttonWithTitleEqualTo:@"1b"]; + ASSERT_TRUE(draggedButton); + targetButton = [bar_ buttonWithTitleEqualTo:@"4f"]; + ASSERT_TRUE(targetButton); + [bar_ dragButton:draggedButton + to:[targetButton left] + copy:YES]; + const std::string expected_2("1b 2f:[ 2f1b 1b 2f2f:[ 2f2f1b " + "2f2f2b 2f2f3b ] 2f3b ] 3b 1b 4f:[ 4f1f:[ 4f1f1b 4f1f2b 4f1f3b ] 4f2f:[ " + "4f2f1b 4f2f2b 4f2f3b ] 4f3f:[ 4f3f1b 4f3f2b 4f3f3b ] ] 5b "); + EXPECT_EQ(expected_2, model_test_utils::ModelStringFromNode(root)); +} + +TEST_F(BookmarkBarFolderControllerMenuTest, DragMoveBarBookmarkToSubfolder) { + BookmarkModel& model(*helper_.profile()->GetBookmarkModel()); + const BookmarkNode* root = model.GetBookmarkBarNode(); + const std::string model_string("1b 2f:[ 2f1b 2f2f:[ 2f2f1b 2f2f2b " + "2f2f3b ] 2f3b ] 3b 4f:[ 4f1f:[ 4f1f1b 4f1f2b 4f1f3b ] 4f2f:[ 4f2f1b " + "4f2f2b 4f2f3b ] 4f3f:[ 4f3f1b 4f3f2b 4f3f3b ] ] 5b "); + model_test_utils::AddNodesFromModelString(model, root, model_string); + + // Validate initial model. + std::string actualModelString = model_test_utils::ModelStringFromNode(root); + EXPECT_EQ(model_string, actualModelString); + + // Pop up a folder menu and a subfolder menu. + BookmarkButton* toFolder = [bar_ buttonWithTitleEqualTo:@"4f"]; + ASSERT_TRUE(toFolder); + [[toFolder target] performSelector:@selector(openBookmarkFolderFromButton:) + withObject:toFolder]; + BookmarkBarFolderController* folderController = [bar_ folderController]; + EXPECT_TRUE(folderController); + NSWindow* toWindow = [folderController window]; + EXPECT_TRUE(toWindow); + NSRect oldToWindowFrame = [toWindow frame]; + BookmarkButton* toSubfolder = + [folderController buttonWithTitleEqualTo:@"4f2f"]; + ASSERT_TRUE(toSubfolder); + [[toSubfolder target] performSelector:@selector(openBookmarkFolderFromButton:) + withObject:toSubfolder]; + BookmarkBarFolderController* subfolderController = + [folderController folderController]; + EXPECT_TRUE(subfolderController); + NSWindow* toSubwindow = [subfolderController window]; + EXPECT_TRUE(toSubwindow); + NSRect oldToSubwindowFrame = [toSubwindow frame]; + // Drag a bar button onto a bookmark (i.e. not a folder) in a folder + // so it should end up below the target bookmark. + BookmarkButton* draggedButton = [bar_ buttonWithTitleEqualTo:@"5b"]; + ASSERT_TRUE(draggedButton); + BookmarkButton* targetButton = + [subfolderController buttonWithTitleEqualTo:@"4f2f3b"]; + ASSERT_TRUE(targetButton); + [subfolderController dragButton:draggedButton + to:[targetButton center] + copy:NO]; + // The button should have landed just after "2f". + const std::string expected_string("1b 2f:[ 2f1b 2f2f:[ 2f2f1b " + "2f2f2b 2f2f3b ] 2f3b ] 3b 4f:[ 4f1f:[ 4f1f1b 4f1f2b 4f1f3b ] 4f2f:[ " + "4f2f1b 4f2f2b 4f2f3b 5b ] 4f3f:[ 4f3f1b 4f3f2b 4f3f3b ] ] "); + EXPECT_EQ(expected_string, model_test_utils::ModelStringFromNode(root)); + + // Check button spacing. + [folderController validateMenuSpacing]; + [subfolderController validateMenuSpacing]; + + // Check the window layouts. The folder window should not have changed, + // but the subfolder window should have shifted vertically and grown. + NSRect newToWindowFrame = [toWindow frame]; + EXPECT_NSRECT_EQ(oldToWindowFrame, newToWindowFrame); + NSRect newToSubwindowFrame = [toSubwindow frame]; + NSRect expectedToSubwindowFrame = oldToSubwindowFrame; + expectedToSubwindowFrame.origin.y -= + bookmarks::kBookmarkBarHeight + bookmarks::kVisualHeightOffset; + expectedToSubwindowFrame.size.height += + bookmarks::kBookmarkBarHeight + bookmarks::kVisualHeightOffset; + EXPECT_NSRECT_EQ(expectedToSubwindowFrame, newToSubwindowFrame); +} + +TEST_F(BookmarkBarFolderControllerMenuTest, DragMoveWithinFolder) { + BookmarkModel& model(*helper_.profile()->GetBookmarkModel()); + const BookmarkNode* root = model.GetBookmarkBarNode(); + const std::string model_string("1b 2f:[ 2f1b 2f2f:[ 2f2f1b 2f2f2b " + "2f2f3b ] 2f3b ] 3b 4f:[ 4f1f:[ 4f1f1b 4f1f2b 4f1f3b ] 4f2f:[ 4f2f1b " + "4f2f2b 4f2f3b ] 4f3f:[ 4f3f1b 4f3f2b 4f3f3b ] ] 5b "); + model_test_utils::AddNodesFromModelString(model, root, model_string); + + // Validate initial model. + std::string actualModelString = model_test_utils::ModelStringFromNode(root); + EXPECT_EQ(model_string, actualModelString); + + // Pop up a folder menu. + BookmarkButton* toFolder = [bar_ buttonWithTitleEqualTo:@"4f"]; + ASSERT_TRUE(toFolder); + [[toFolder target] performSelector:@selector(openBookmarkFolderFromButton:) + withObject:toFolder]; + BookmarkBarFolderController* folderController = [bar_ folderController]; + EXPECT_TRUE(folderController); + NSWindow* toWindow = [folderController window]; + EXPECT_TRUE(toWindow); + NSRect oldToWindowFrame = [toWindow frame]; + // Drag a folder button to the top within the same parent. + BookmarkButton* draggedButton = + [folderController buttonWithTitleEqualTo:@"4f2f"]; + ASSERT_TRUE(draggedButton); + BookmarkButton* targetButton = + [folderController buttonWithTitleEqualTo:@"4f1f"]; + ASSERT_TRUE(targetButton); + [folderController dragButton:draggedButton + to:[targetButton top] + copy:NO]; + // The button should have landed above "4f1f". + const std::string expected_string("1b 2f:[ 2f1b 2f2f:[ 2f2f1b " + "2f2f2b 2f2f3b ] 2f3b ] 3b 4f:[ 4f2f:[ 4f2f1b 4f2f2b 4f2f3b ] " + "4f1f:[ 4f1f1b 4f1f2b 4f1f3b ] 4f3f:[ 4f3f1b 4f3f2b 4f3f3b ] ] 5b "); + EXPECT_EQ(expected_string, model_test_utils::ModelStringFromNode(root)); + + // The window should not have gone away. + EXPECT_TRUE([bar_ folderController]); + + // The folder window should not have changed. + NSRect newToWindowFrame = [toWindow frame]; + EXPECT_NSRECT_EQ(oldToWindowFrame, newToWindowFrame); + + // Check button spacing. + [folderController validateMenuSpacing]; +} + +TEST_F(BookmarkBarFolderControllerMenuTest, DragParentOntoChild) { + BookmarkModel& model(*helper_.profile()->GetBookmarkModel()); + const BookmarkNode* root = model.GetBookmarkBarNode(); + const std::string model_string("1b 2f:[ 2f1b 2f2f:[ 2f2f1b 2f2f2b " + "2f2f3b ] 2f3b ] 3b 4f:[ 4f1f:[ 4f1f1b 4f1f2b 4f1f3b ] 4f2f:[ 4f2f1b " + "4f2f2b 4f2f3b ] 4f3f:[ 4f3f1b 4f3f2b 4f3f3b ] ] 5b "); + model_test_utils::AddNodesFromModelString(model, root, model_string); + + // Validate initial model. + std::string actualModelString = model_test_utils::ModelStringFromNode(root); + EXPECT_EQ(model_string, actualModelString); + + // Pop up a folder menu. + BookmarkButton* toFolder = [bar_ buttonWithTitleEqualTo:@"4f"]; + ASSERT_TRUE(toFolder); + [[toFolder target] performSelector:@selector(openBookmarkFolderFromButton:) + withObject:toFolder]; + BookmarkBarFolderController* folderController = [bar_ folderController]; + EXPECT_TRUE(folderController); + NSWindow* toWindow = [folderController window]; + EXPECT_TRUE(toWindow); + // Drag a folder button to one of its children. + BookmarkButton* draggedButton = [bar_ buttonWithTitleEqualTo:@"4f"]; + ASSERT_TRUE(draggedButton); + BookmarkButton* targetButton = + [folderController buttonWithTitleEqualTo:@"4f3f"]; + ASSERT_TRUE(targetButton); + [folderController dragButton:draggedButton + to:[targetButton top] + copy:NO]; + // The model should not have changed. + EXPECT_EQ(model_string, model_test_utils::ModelStringFromNode(root)); + + // Check button spacing. + [folderController validateMenuSpacing]; +} + +TEST_F(BookmarkBarFolderControllerMenuTest, DragMoveChildToParent) { + BookmarkModel& model(*helper_.profile()->GetBookmarkModel()); + const BookmarkNode* root = model.GetBookmarkBarNode(); + const std::string model_string("1b 2f:[ 2f1b 2f2f:[ 2f2f1b 2f2f2b " + "2f2f3b ] 2f3b ] 3b 4f:[ 4f1f:[ 4f1f1b 4f1f2b 4f1f3b ] 4f2f:[ 4f2f1b " + "4f2f2b 4f2f3b ] 4f3f:[ 4f3f1b 4f3f2b 4f3f3b ] ] 5b "); + model_test_utils::AddNodesFromModelString(model, root, model_string); + + // Validate initial model. + std::string actualModelString = model_test_utils::ModelStringFromNode(root); + EXPECT_EQ(model_string, actualModelString); + + // Pop up a folder menu and a subfolder menu. + BookmarkButton* toFolder = [bar_ buttonWithTitleEqualTo:@"4f"]; + ASSERT_TRUE(toFolder); + [[toFolder target] performSelector:@selector(openBookmarkFolderFromButton:) + withObject:toFolder]; + BookmarkBarFolderController* folderController = [bar_ folderController]; + EXPECT_TRUE(folderController); + BookmarkButton* toSubfolder = + [folderController buttonWithTitleEqualTo:@"4f2f"]; + ASSERT_TRUE(toSubfolder); + [[toSubfolder target] performSelector:@selector(openBookmarkFolderFromButton:) + withObject:toSubfolder]; + BookmarkBarFolderController* subfolderController = + [folderController folderController]; + EXPECT_TRUE(subfolderController); + + // Drag a subfolder bookmark to the parent folder. + BookmarkButton* draggedButton = + [subfolderController buttonWithTitleEqualTo:@"4f2f3b"]; + ASSERT_TRUE(draggedButton); + BookmarkButton* targetButton = + [folderController buttonWithTitleEqualTo:@"4f2f"]; + ASSERT_TRUE(targetButton); + [folderController dragButton:draggedButton + to:[targetButton top] + copy:NO]; + // The button should have landed above "4f2f". + const std::string expected_string("1b 2f:[ 2f1b 2f2f:[ 2f2f1b 2f2f2b " + "2f2f3b ] 2f3b ] 3b 4f:[ 4f1f:[ 4f1f1b 4f1f2b 4f1f3b ] 4f2f3b 4f2f:[ " + "4f2f1b 4f2f2b ] 4f3f:[ 4f3f1b 4f3f2b 4f3f3b ] ] 5b "); + EXPECT_EQ(expected_string, model_test_utils::ModelStringFromNode(root)); + + // Check button spacing. + [folderController validateMenuSpacing]; + // The window should not have gone away. + EXPECT_TRUE([bar_ folderController]); + // The subfolder should have gone away. + EXPECT_FALSE([folderController folderController]); +} + +TEST_F(BookmarkBarFolderControllerMenuTest, DragWindowResizing) { + BookmarkModel& model(*helper_.profile()->GetBookmarkModel()); + const BookmarkNode* root = model.GetBookmarkBarNode(); + const std::string + model_string("a b:[ b1 b2 b3 ] reallyReallyLongBookmarkName c "); + model_test_utils::AddNodesFromModelString(model, root, model_string); + + // Validate initial model. + std::string actualModelString = model_test_utils::ModelStringFromNode(root); + EXPECT_EQ(model_string, actualModelString); + + // Pop up a folder menu. + BookmarkButton* toFolder = [bar_ buttonWithTitleEqualTo:@"b"]; + ASSERT_TRUE(toFolder); + [[toFolder target] performSelector:@selector(openBookmarkFolderFromButton:) + withObject:toFolder]; + BookmarkBarFolderController* folderController = [bar_ folderController]; + EXPECT_TRUE(folderController); + NSWindow* toWindow = [folderController window]; + EXPECT_TRUE(toWindow); + CGFloat oldWidth = NSWidth([toWindow frame]); + // Drag the bookmark with a long name to the folder. + BookmarkButton* draggedButton = + [bar_ buttonWithTitleEqualTo:@"reallyReallyLongBookmarkName"]; + ASSERT_TRUE(draggedButton); + BookmarkButton* targetButton = + [folderController buttonWithTitleEqualTo:@"b1"]; + ASSERT_TRUE(targetButton); + [folderController dragButton:draggedButton + to:[targetButton center] + copy:NO]; + // Verify the model change. + const std::string + expected_string("a b:[ b1 reallyReallyLongBookmarkName b2 b3 ] c "); + EXPECT_EQ(expected_string, model_test_utils::ModelStringFromNode(root)); + // Verify the window grew. Just test a reasonable width gain. + CGFloat newWidth = NSWidth([toWindow frame]); + EXPECT_LT(oldWidth + 30.0, newWidth); +} + +TEST_F(BookmarkBarFolderControllerMenuTest, MoveRemoveAddButtons) { + BookmarkModel& model(*helper_.profile()->GetBookmarkModel()); + const BookmarkNode* root = model.GetBookmarkBarNode(); + const std::string model_string("1b 2f:[ 2f1b 2f2b 2f3b ] 3b 4b "); + model_test_utils::AddNodesFromModelString(model, root, model_string); + + // Validate initial model. + std::string actualModelString = model_test_utils::ModelStringFromNode(root); + EXPECT_EQ(model_string, actualModelString); + + // Pop up a folder menu. + BookmarkButton* toFolder = [bar_ buttonWithTitleEqualTo:@"2f"]; + ASSERT_TRUE(toFolder); + [[toFolder target] performSelector:@selector(openBookmarkFolderFromButton:) + withObject:toFolder]; + BookmarkBarFolderController* folder = [bar_ folderController]; + EXPECT_TRUE(folder); + + // Remember how many buttons are showing. + NSArray* buttons = [folder buttons]; + NSUInteger oldDisplayedButtons = [buttons count]; + + // Move a button around a bit. + [folder moveButtonFromIndex:0 toIndex:2]; + EXPECT_NSEQ(@"2f2b", [[buttons objectAtIndex:0] title]); + EXPECT_NSEQ(@"2f3b", [[buttons objectAtIndex:1] title]); + EXPECT_NSEQ(@"2f1b", [[buttons objectAtIndex:2] title]); + EXPECT_EQ(oldDisplayedButtons, [buttons count]); + [folder moveButtonFromIndex:2 toIndex:0]; + EXPECT_NSEQ(@"2f1b", [[buttons objectAtIndex:0] title]); + EXPECT_NSEQ(@"2f2b", [[buttons objectAtIndex:1] title]); + EXPECT_NSEQ(@"2f3b", [[buttons objectAtIndex:2] title]); + EXPECT_EQ(oldDisplayedButtons, [buttons count]); + + // Add a couple of buttons. + const BookmarkNode* node = root->GetChild(2); // Purloin an existing node. + [folder addButtonForNode:node atIndex:0]; + EXPECT_NSEQ(@"3b", [[buttons objectAtIndex:0] title]); + EXPECT_NSEQ(@"2f1b", [[buttons objectAtIndex:1] title]); + EXPECT_NSEQ(@"2f2b", [[buttons objectAtIndex:2] title]); + EXPECT_NSEQ(@"2f3b", [[buttons objectAtIndex:3] title]); + EXPECT_EQ(oldDisplayedButtons + 1, [buttons count]); + node = root->GetChild(3); + [folder addButtonForNode:node atIndex:-1]; + EXPECT_NSEQ(@"3b", [[buttons objectAtIndex:0] title]); + EXPECT_NSEQ(@"2f1b", [[buttons objectAtIndex:1] title]); + EXPECT_NSEQ(@"2f2b", [[buttons objectAtIndex:2] title]); + EXPECT_NSEQ(@"2f3b", [[buttons objectAtIndex:3] title]); + EXPECT_NSEQ(@"4b", [[buttons objectAtIndex:4] title]); + EXPECT_EQ(oldDisplayedButtons + 2, [buttons count]); + + // Remove a couple of buttons. + [folder removeButton:4 animate:NO]; + [folder removeButton:1 animate:NO]; + EXPECT_NSEQ(@"3b", [[buttons objectAtIndex:0] title]); + EXPECT_NSEQ(@"2f2b", [[buttons objectAtIndex:1] title]); + EXPECT_NSEQ(@"2f3b", [[buttons objectAtIndex:2] title]); + EXPECT_EQ(oldDisplayedButtons, [buttons count]); + + // Check button spacing. + [folder validateMenuSpacing]; +} + +TEST_F(BookmarkBarFolderControllerMenuTest, ControllerForNode) { + BookmarkModel& model(*helper_.profile()->GetBookmarkModel()); + const BookmarkNode* root = model.GetBookmarkBarNode(); + const std::string model_string("1b 2f:[ 2f1b 2f2b ] 3b "); + model_test_utils::AddNodesFromModelString(model, root, model_string); + + // Validate initial model. + std::string actualModelString = model_test_utils::ModelStringFromNode(root); + EXPECT_EQ(model_string, actualModelString); + + // Find the main bar controller. + const void* expectedController = bar_; + const void* actualController = [bar_ controllerForNode:root]; + EXPECT_EQ(expectedController, actualController); + + // Pop up the folder menu. + BookmarkButton* targetFolder = [bar_ buttonWithTitleEqualTo:@"2f"]; + ASSERT_TRUE(targetFolder); + [[targetFolder target] + performSelector:@selector(openBookmarkFolderFromButton:) + withObject:targetFolder]; + BookmarkBarFolderController* folder = [bar_ folderController]; + EXPECT_TRUE(folder); + + // Find the folder controller using the folder controller. + const BookmarkNode* targetNode = root->GetChild(1); + expectedController = folder; + actualController = [bar_ controllerForNode:targetNode]; + EXPECT_EQ(expectedController, actualController); + + // Find the folder controller from the bar. + actualController = [folder controllerForNode:targetNode]; + EXPECT_EQ(expectedController, actualController); +} + +TEST_F(BookmarkBarFolderControllerMenuTest, MenuSizingAndScrollArrows) { + BookmarkModel& model(*helper_.profile()->GetBookmarkModel()); + const BookmarkNode* root = model.GetBookmarkBarNode(); + const std::string model_string("1b 2b 3b "); + model_test_utils::AddNodesFromModelString(model, root, model_string); + + // Validate initial model. + std::string actualModelString = model_test_utils::ModelStringFromNode(root); + EXPECT_EQ(model_string, actualModelString); + + const BookmarkNode* parent = model.GetBookmarkBarNode(); + const BookmarkNode* folder = model.AddGroup(parent, + parent->GetChildCount(), + ASCIIToUTF16("BIG")); + + // Pop open the new folder window and verify it has one (empty) item. + BookmarkButton* button = [bar_ buttonWithTitleEqualTo:@"BIG"]; + [[button target] performSelector:@selector(openBookmarkFolderFromButton:) + withObject:button]; + BookmarkBarFolderController* folderController = [bar_ folderController]; + EXPECT_TRUE(folderController); + NSWindow* folderMenu = [folderController window]; + EXPECT_TRUE(folderMenu); + CGFloat expectedHeight = (CGFloat)bookmarks::kBookmarkButtonHeight + + (2*bookmarks::kBookmarkVerticalPadding); + NSRect menuFrame = [folderMenu frame]; + CGFloat menuHeight = NSHeight(menuFrame); + EXPECT_CGFLOAT_EQ(expectedHeight, menuHeight); + EXPECT_FALSE([folderController scrollable]); + + // Now add a real bookmark and reopen. + model.AddURL(folder, folder->GetChildCount(), ASCIIToUTF16("a"), + GURL("http://a.com/")); + folderController = [bar_ folderController]; + EXPECT_TRUE(folderController); + folderMenu = [folderController window]; + EXPECT_TRUE(folderMenu); + menuFrame = [folderMenu frame]; + menuHeight = NSHeight(menuFrame); + EXPECT_CGFLOAT_EQ(expectedHeight, menuHeight); + CGFloat menuWidth = NSWidth(menuFrame); + button = [folderController buttonWithTitleEqualTo:@"a"]; + CGFloat buttonWidth = NSWidth([button frame]); + CGFloat expectedWidth = + buttonWidth + (2 * bookmarks::kBookmarkSubMenuHorizontalPadding); + EXPECT_CGFLOAT_EQ(expectedWidth, menuWidth); + + // Add a wider bookmark and make sure the button widths match. + model.AddURL(folder, folder->GetChildCount(), + ASCIIToUTF16("A really, really long name"), + GURL("http://www.google.com/a")); + EXPECT_LT(menuWidth, NSWidth([folderMenu frame])); + EXPECT_LT(buttonWidth, NSWidth([button frame])); + buttonWidth = NSWidth([button frame]); + BookmarkButton* buttonB = + [folderController buttonWithTitleEqualTo:@"A really, really long name"]; + EXPECT_TRUE(buttonB); + CGFloat buttonWidthB = NSWidth([buttonB frame]); + EXPECT_CGFLOAT_EQ(buttonWidth, buttonWidthB); + // Add a bunch of bookmarks until the window grows no more, then check for + // a scroll down arrow. + CGFloat oldMenuHeight = 0.0; // It just has to be different for first run. + menuHeight = NSHeight([folderMenu frame]); + NSUInteger tripWire = 0; // Prevent a runaway. + while (![folderController scrollable] && ++tripWire < 100) { + model.AddURL(folder, folder->GetChildCount(), ASCIIToUTF16("B"), + GURL("http://b.com/")); + oldMenuHeight = menuHeight; + menuHeight = NSHeight([folderMenu frame]); + } + EXPECT_TRUE([folderController scrollable]); + EXPECT_TRUE([folderController canScrollUp]); + + // Remove one bookmark and make sure the scroll down arrow has been removed. + // We'll remove the really long node so we can see if the buttons get resized. + menuWidth = NSWidth([folderMenu frame]); + buttonWidth = NSWidth([button frame]); + model.Remove(folder, 1); + EXPECT_FALSE([folderController scrollable]); + EXPECT_FALSE([folderController canScrollUp]); + EXPECT_FALSE([folderController canScrollDown]); + + // Check the size. It should have reduced. + EXPECT_GT(menuWidth, NSWidth([folderMenu frame])); + EXPECT_GT(buttonWidth, NSWidth([button frame])); + + // Check button spacing. + [folderController validateMenuSpacing]; +} + +// See http://crbug.com/46101 +TEST_F(BookmarkBarFolderControllerMenuTest, HoverThenDeleteBookmark) { + BookmarkModel& model(*helper_.profile()->GetBookmarkModel()); + const BookmarkNode* root = model.GetBookmarkBarNode(); + const BookmarkNode* folder = model.AddGroup(root, + root->GetChildCount(), + ASCIIToUTF16("BIG")); + for (int i = 0; i < kLotsOfNodesCount; i++) + model.AddURL(folder, folder->GetChildCount(), ASCIIToUTF16("kid"), + GURL("http://kid.com/smile")); + + // Pop open the new folder window and hover one of its kids. + BookmarkButton* button = [bar_ buttonWithTitleEqualTo:@"BIG"]; + [[button target] performSelector:@selector(openBookmarkFolderFromButton:) + withObject:button]; + BookmarkBarFolderController* bbfc = [bar_ folderController]; + NSArray* buttons = [bbfc buttons]; + + // Hover over a button and verify that it is now known. + button = [buttons objectAtIndex:3]; + BookmarkButton* buttonThatMouseIsIn = [bbfc buttonThatMouseIsIn]; + EXPECT_FALSE(buttonThatMouseIsIn); + [bbfc mouseEnteredButton:button event:nil]; + buttonThatMouseIsIn = [bbfc buttonThatMouseIsIn]; + EXPECT_EQ(button, buttonThatMouseIsIn); + + // Delete the bookmark and verify that it is now not known. + model.Remove(folder, 3); + buttonThatMouseIsIn = [bbfc buttonThatMouseIsIn]; + EXPECT_FALSE(buttonThatMouseIsIn); +} + +// Just like a BookmarkBarFolderController but intercedes when providing +// pasteboard drag data. +@interface BookmarkBarFolderControllerDragData : BookmarkBarFolderController { + const BookmarkNode* dragDataNode_; // Weak +} +- (void)setDragDataNode:(const BookmarkNode*)node; +@end + +@implementation BookmarkBarFolderControllerDragData + +- (id)initWithParentButton:(BookmarkButton*)button + parentController:(BookmarkBarFolderController*)parentController + barController:(BookmarkBarController*)barController { + if ((self = [super initWithParentButton:button + parentController:parentController + barController:barController])) { + dragDataNode_ = NULL; + } + return self; +} + +- (void)setDragDataNode:(const BookmarkNode*)node { + dragDataNode_ = node; +} + +- (std::vector<const BookmarkNode*>)retrieveBookmarkNodeData { + std::vector<const BookmarkNode*> dragDataNodes; + if(dragDataNode_) { + dragDataNodes.push_back(dragDataNode_); + } + return dragDataNodes; +} + +@end + +TEST_F(BookmarkBarFolderControllerMenuTest, DragBookmarkData) { + BookmarkModel& model(*helper_.profile()->GetBookmarkModel()); + const BookmarkNode* root = model.GetBookmarkBarNode(); + const std::string model_string("1b 2f:[ 2f1b 2f2f:[ 2f2f1b 2f2f2b 2f2f3b ] " + "2f3b ] 3b 4b "); + model_test_utils::AddNodesFromModelString(model, root, model_string); + const BookmarkNode* other = model.other_node(); + const std::string other_string("O1b O2b O3f:[ O3f1b O3f2f ] " + "O4f:[ O4f1b O4f2f ] 05b "); + model_test_utils::AddNodesFromModelString(model, other, other_string); + + // Validate initial model. + std::string actual = model_test_utils::ModelStringFromNode(root); + EXPECT_EQ(model_string, actual); + actual = model_test_utils::ModelStringFromNode(other); + EXPECT_EQ(other_string, actual); + + // Pop open a folder. + BookmarkButton* button = [bar_ buttonWithTitleEqualTo:@"2f"]; + scoped_nsobject<BookmarkBarFolderControllerDragData> folderController; + folderController.reset([[BookmarkBarFolderControllerDragData alloc] + initWithParentButton:button + parentController:nil + barController:bar_]); + BookmarkButton* targetButton = + [folderController buttonWithTitleEqualTo:@"2f1b"]; + ASSERT_TRUE(targetButton); + + // Gen up some dragging data. + const BookmarkNode* newNode = other->GetChild(2); + [folderController setDragDataNode:newNode]; + scoped_nsobject<FakedDragInfo> dragInfo([[FakedDragInfo alloc] init]); + [dragInfo setDropLocation:[targetButton top]]; + [folderController dragBookmarkData:(id<NSDraggingInfo>)dragInfo.get()]; + + // Verify the model. + const std::string expected("1b 2f:[ O3f:[ O3f1b O3f2f ] 2f1b 2f2f:[ 2f2f1b " + "2f2f2b 2f2f3b ] 2f3b ] 3b 4b "); + actual = model_test_utils::ModelStringFromNode(root); + EXPECT_EQ(expected, actual); + + // Now drag over a folder button. + targetButton = [folderController buttonWithTitleEqualTo:@"2f2f"]; + ASSERT_TRUE(targetButton); + newNode = other->GetChild(2); // Should be O4f. + EXPECT_EQ(newNode->GetTitle(), ASCIIToUTF16("O4f")); + [folderController setDragDataNode:newNode]; + [dragInfo setDropLocation:[targetButton center]]; + [folderController dragBookmarkData:(id<NSDraggingInfo>)dragInfo.get()]; + + // Verify the model. + const std::string expectedA("1b 2f:[ O3f:[ O3f1b O3f2f ] 2f1b 2f2f:[ " + "2f2f1b 2f2f2b 2f2f3b O4f:[ O4f1b O4f2f ] ] " + "2f3b ] 3b 4b "); + actual = model_test_utils::ModelStringFromNode(root); + EXPECT_EQ(expectedA, actual); + + // Check button spacing. + [folderController validateMenuSpacing]; +} + +TEST_F(BookmarkBarFolderControllerMenuTest, DragBookmarkDataToTrash) { + BookmarkModel& model(*helper_.profile()->GetBookmarkModel()); + const BookmarkNode* root = model.GetBookmarkBarNode(); + const std::string model_string("1b 2f:[ 2f1b 2f2f:[ 2f2f1b 2f2f2b 2f2f3b ] " + "2f3b ] 3b 4b "); + model_test_utils::AddNodesFromModelString(model, root, model_string); + + // Validate initial model. + std::string actual = model_test_utils::ModelStringFromNode(root); + EXPECT_EQ(model_string, actual); + + const BookmarkNode* folderNode = root->GetChild(1); + int oldFolderChildCount = folderNode->GetChildCount(); + + // Pop open a folder. + BookmarkButton* button = [bar_ buttonWithTitleEqualTo:@"2f"]; + scoped_nsobject<BookmarkBarFolderControllerDragData> folderController; + folderController.reset([[BookmarkBarFolderControllerDragData alloc] + initWithParentButton:button + parentController:nil + barController:bar_]); + + // Drag a button to the trash. + BookmarkButton* buttonToDelete = + [folderController buttonWithTitleEqualTo:@"2f1b"]; + ASSERT_TRUE(buttonToDelete); + EXPECT_TRUE([folderController canDragBookmarkButtonToTrash:buttonToDelete]); + [folderController didDragBookmarkToTrash:buttonToDelete]; + + // There should be one less button in the folder. + int newFolderChildCount = folderNode->GetChildCount(); + EXPECT_EQ(oldFolderChildCount - 1, newFolderChildCount); + // Verify the model. + const std::string expected("1b 2f:[ 2f2f:[ 2f2f1b 2f2f2b 2f2f3b ] " + "2f3b ] 3b 4b "); + actual = model_test_utils::ModelStringFromNode(root); + EXPECT_EQ(expected, actual); + + // Check button spacing. + [folderController validateMenuSpacing]; +} + +TEST_F(BookmarkBarFolderControllerMenuTest, AddURLs) { + BookmarkModel& model(*helper_.profile()->GetBookmarkModel()); + const BookmarkNode* root = model.GetBookmarkBarNode(); + const std::string model_string("1b 2f:[ 2f1b 2f2f:[ 2f2f1b 2f2f2b 2f2f3b ] " + "2f3b ] 3b 4b "); + model_test_utils::AddNodesFromModelString(model, root, model_string); + + // Validate initial model. + std::string actual = model_test_utils::ModelStringFromNode(root); + EXPECT_EQ(model_string, actual); + + // Pop open a folder. + BookmarkButton* button = [bar_ buttonWithTitleEqualTo:@"2f"]; + [[button target] performSelector:@selector(openBookmarkFolderFromButton:) + withObject:button]; + BookmarkBarFolderController* folderController = [bar_ folderController]; + EXPECT_TRUE(folderController); + NSArray* buttons = [folderController buttons]; + EXPECT_TRUE(buttons); + + // Remember how many buttons are showing. + int oldDisplayedButtons = [buttons count]; + + BookmarkButton* targetButton = + [folderController buttonWithTitleEqualTo:@"2f1b"]; + ASSERT_TRUE(targetButton); + + NSArray* urls = [NSArray arrayWithObjects: @"http://www.a.com/", + @"http://www.b.com/", nil]; + NSArray* titles = [NSArray arrayWithObjects: @"SiteA", @"SiteB", nil]; + [folderController addURLs:urls withTitles:titles at:[targetButton top]]; + + // There should two more buttons in the folder. + int newDisplayedButtons = [buttons count]; + EXPECT_EQ(oldDisplayedButtons + 2, newDisplayedButtons); + // Verify the model. + const std::string expected("1b 2f:[ SiteA SiteB 2f1b 2f2f:[ 2f2f1b 2f2f2b " + "2f2f3b ] 2f3b ] 3b 4b "); + actual = model_test_utils::ModelStringFromNode(root); + EXPECT_EQ(expected, actual); + + // Check button spacing. + [folderController validateMenuSpacing]; +} + +TEST_F(BookmarkBarFolderControllerMenuTest, DropPositionIndicator) { + BookmarkModel& model(*helper_.profile()->GetBookmarkModel()); + const BookmarkNode* root = model.GetBookmarkBarNode(); + const std::string model_string("1b 2f:[ 2f1b 2f2f:[ 2f2f1b 2f2f2b 2f2f3b ] " + "2f3b ] 3b 4b "); + model_test_utils::AddNodesFromModelString(model, root, model_string); + + // Validate initial model. + std::string actual = model_test_utils::ModelStringFromNode(root); + EXPECT_EQ(model_string, actual); + + // Pop open the folder. + BookmarkButton* button = [bar_ buttonWithTitleEqualTo:@"2f"]; + [[button target] performSelector:@selector(openBookmarkFolderFromButton:) + withObject:button]; + BookmarkBarFolderController* folder = [bar_ folderController]; + EXPECT_TRUE(folder); + + // Test a series of points starting at the top of the folder. + const CGFloat yOffset = 0.5 * bookmarks::kBookmarkVerticalPadding; + BookmarkButton* targetButton = [folder buttonWithTitleEqualTo:@"2f1b"]; + ASSERT_TRUE(targetButton); + NSPoint targetPoint = [targetButton top]; + CGFloat pos = [folder indicatorPosForDragToPoint:targetPoint]; + EXPECT_CGFLOAT_EQ(targetPoint.y + yOffset, pos); + pos = [folder indicatorPosForDragToPoint:[targetButton bottom]]; + targetButton = [folder buttonWithTitleEqualTo:@"2f2f"]; + EXPECT_CGFLOAT_EQ([targetButton top].y + yOffset, pos); + pos = [folder indicatorPosForDragToPoint:NSMakePoint(10,0)]; + targetButton = [folder buttonWithTitleEqualTo:@"2f3b"]; + EXPECT_CGFLOAT_EQ([targetButton bottom].y - yOffset, pos); +} + +@interface BookmarkBarControllerNoDelete : BookmarkBarController +- (IBAction)deleteBookmark:(id)sender; +@end + +@implementation BookmarkBarControllerNoDelete +- (IBAction)deleteBookmark:(id)sender { + // NOP +} +@end + +class BookmarkBarFolderControllerClosingTest : public + BookmarkBarFolderControllerMenuTest { + public: + BookmarkBarFolderControllerClosingTest() { + bar_.reset([[BookmarkBarControllerNoDelete alloc] + initWithBrowser:helper_.browser() + initialWidth:NSWidth([parent_view_ frame]) + delegate:nil + resizeDelegate:resizeDelegate_.get()]); + InstallAndToggleBar(bar_.get()); + } +}; + +TEST_F(BookmarkBarFolderControllerClosingTest, DeleteClosesFolder) { + BookmarkModel& model(*helper_.profile()->GetBookmarkModel()); + const BookmarkNode* root = model.GetBookmarkBarNode(); + const std::string model_string("1b 2f:[ 2f1b 2f2f:[ 2f2f1b 2f2f2b ] " + "2f3b ] 3b "); + model_test_utils::AddNodesFromModelString(model, root, model_string); + + // Validate initial model. + std::string actualModelString = model_test_utils::ModelStringFromNode(root); + EXPECT_EQ(model_string, actualModelString); + + // Open the folder menu and submenu. + BookmarkButton* target = [bar_ buttonWithTitleEqualTo:@"2f"]; + ASSERT_TRUE(target); + [[target target] performSelector:@selector(openBookmarkFolderFromButton:) + withObject:target]; + BookmarkBarFolderController* folder = [bar_ folderController]; + EXPECT_TRUE(folder); + BookmarkButton* subTarget = [folder buttonWithTitleEqualTo:@"2f2f"]; + ASSERT_TRUE(subTarget); + [[subTarget target] performSelector:@selector(openBookmarkFolderFromButton:) + withObject:subTarget]; + BookmarkBarFolderController* subFolder = [folder folderController]; + EXPECT_TRUE(subFolder); + + // Delete the folder node and verify the window closed down by looking + // for its controller again. + [folder deleteBookmark:folder]; + EXPECT_FALSE([folder folderController]); +} + +// TODO(jrg): draggingEntered: and draggingExited: trigger timers so +// they are hard to test. Factor out "fire timers" into routines +// which can be overridden to fire immediately to make behavior +// confirmable. +// There is a similar problem with mouseEnteredButton: and +// mouseExitedButton:. diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_hover_state.h b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_hover_state.h new file mode 100644 index 0000000..373e0e6 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_hover_state.h @@ -0,0 +1,78 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_button.h" + +// Hover state machine. Encapsulates the hover state for +// BookmarkBarFolderController. +// A strict call order is implied with these calls. It is ONLY valid to make +// the following state transitions: +// From: To: Via: +// closed opening scheduleOpen...: +// opening closed cancelPendingOpen...: or +// open scheduleOpen...: completes. +// open closing scheduleClose...: +// closing open cancelPendingClose...: or +// closed scheduleClose...: completes. +// +@interface BookmarkBarFolderHoverState : NSObject { + @private + // Enumeration of the valid states that the |hoverButton_| member can be in. + // Because the opening and closing of hover views can be done asyncronously + // there are periods where the hover state is in transtion between open and + // closed. During those times of transition the opening or closing operation + // can be cancelled. We serialize the opening and closing of the + // |hoverButton_| using this state information. This serialization is to + // avoid race conditions where one hover button is being opened while another + // is closing. + enum HoverState { + kHoverStateClosed = 0, + kHoverStateOpening = 1, + kHoverStateOpen = 2, + kHoverStateClosing = 3 + }; + + // Like normal menus, hovering over a folder button causes it to + // open. This variable is set when a hover is initiated (but has + // not necessarily fired yet). + scoped_nsobject<BookmarkButton> hoverButton_; + + // We model hover state as a state machine with specific allowable + // transitions. |hoverState_| is the state of this machine at any + // given time. + HoverState hoverState_; +} + +// Designated initializer. +- (id)init; + +// The BookmarkBarFolderHoverState decides when it is appropriate to hide +// and show the button that the BookmarkBarFolderController drags over. +- (NSDragOperation)draggingEnteredButton:(BookmarkButton*)button; + +// The BookmarkBarFolderHoverState decides the fate of the hover button +// when the BookmarkBarFolderController's view is exited. +- (void)draggingExited; + +@end + +// Exposing these for unit testing purposes. They are used privately in the +// implementation as well. +@interface BookmarkBarFolderHoverState(PrivateAPI) +// State change APIs. +- (void)scheduleCloseBookmarkFolderOnHoverButton; +- (void)cancelPendingCloseBookmarkFolderOnHoverButton; +- (void)scheduleOpenBookmarkFolderOnHoverButton:(BookmarkButton*)hoverButton; +- (void)cancelPendingOpenBookmarkFolderOnHoverButton; +@end + +// Exposing these for unit testing purposes. They are used only in tests. +@interface BookmarkBarFolderHoverState(TestingAPI) +// Accessors and setters for button and hover state. +- (BookmarkButton*)hoverButton; +- (HoverState)hoverState; +@end diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_hover_state.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_hover_state.mm new file mode 100644 index 0000000..b762bb3c --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_hover_state.mm @@ -0,0 +1,171 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_hover_state.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_controller.h" + +@interface BookmarkBarFolderHoverState(Private) +- (void)setHoverState:(HoverState)state; +- (void)closeBookmarkFolderOnHoverButton:(BookmarkButton*)button; +- (void)openBookmarkFolderOnHoverButton:(BookmarkButton*)button; +@end + +@implementation BookmarkBarFolderHoverState + +- (id)init { + if ((self = [super init])) { + hoverState_ = kHoverStateClosed; + } + return self; +} + +- (NSDragOperation)draggingEnteredButton:(BookmarkButton*)button { + if ([button isFolder]) { + if (hoverButton_ == button) { + // CASE A: hoverButton_ == button implies we've dragged over + // the same folder so no need to open or close anything new. + } else if (hoverButton_ && + hoverButton_ != button) { + // CASE B: we have a hoverButton_ but it is different from the new button. + // This implies we've dragged over a new folder, so we'll close the old + // and open the new. + // Note that we only schedule the open or close if we have no other tasks + // currently pending. + + if (hoverState_ == kHoverStateOpen) { + // Close the old. + [self scheduleCloseBookmarkFolderOnHoverButton]; + } else if (hoverState_ == kHoverStateClosed) { + // Open the new. + [self scheduleOpenBookmarkFolderOnHoverButton:button]; + } + } else if (!hoverButton_) { + // CASE C: we don't have a current hoverButton_ but we have dragged onto + // a new folder so we open the new one. + [self scheduleOpenBookmarkFolderOnHoverButton:button]; + } + } else if (!button) { + if (hoverButton_) { + // CASE D: We have a hoverButton_ but we've moved onto an area that + // requires no hover. We close the hoverButton_ in this case. This + // means cancelling if the open is pending (i.e. |kHoverStateOpening|) + // or closing if we don't alrealy have once in progress. + + // Intiate close only if we have not already done so. + if (hoverState_ == kHoverStateOpening) { + // Cancel the pending open. + [self cancelPendingOpenBookmarkFolderOnHoverButton]; + } else if (hoverState_ != kHoverStateClosing) { + // Schedule the close. + [self scheduleCloseBookmarkFolderOnHoverButton]; + } + } else { + // CASE E: We have neither a hoverButton_ nor a new button that requires + // a hover. In this case we do nothing. + } + } + + return NSDragOperationMove; +} + +- (void)draggingExited { + if (hoverButton_) { + if (hoverState_ == kHoverStateOpening) { + [self cancelPendingOpenBookmarkFolderOnHoverButton]; + } else if (hoverState_ == kHoverStateClosing) { + [self cancelPendingCloseBookmarkFolderOnHoverButton]; + } + } +} + +// Schedule close of hover button. Transition to kHoverStateClosing state. +- (void)scheduleCloseBookmarkFolderOnHoverButton { + DCHECK(hoverButton_); + [self setHoverState:kHoverStateClosing]; + [self performSelector:@selector(closeBookmarkFolderOnHoverButton:) + withObject:hoverButton_ + afterDelay:bookmarks::kDragHoverCloseDelay]; +} + +// Cancel pending hover close. Transition to kHoverStateOpen state. +- (void)cancelPendingCloseBookmarkFolderOnHoverButton { + [self setHoverState:kHoverStateOpen]; + [NSObject + cancelPreviousPerformRequestsWithTarget:self + selector:@selector(closeBookmarkFolderOnHoverButton:) + object:hoverButton_]; +} + +// Schedule open of hover button. Transition to kHoverStateOpening state. +- (void)scheduleOpenBookmarkFolderOnHoverButton:(BookmarkButton*)button { + DCHECK(button); + hoverButton_.reset([button retain]); + [self setHoverState:kHoverStateOpening]; + [self performSelector:@selector(openBookmarkFolderOnHoverButton:) + withObject:hoverButton_ + afterDelay:bookmarks::kDragHoverOpenDelay]; +} + +// Cancel pending hover open. Transition to kHoverStateClosed state. +- (void)cancelPendingOpenBookmarkFolderOnHoverButton { + [self setHoverState:kHoverStateClosed]; + [NSObject + cancelPreviousPerformRequestsWithTarget:self + selector:@selector(openBookmarkFolderOnHoverButton:) + object:hoverButton_]; + hoverButton_.reset(); +} + +// Hover button accessor. For testing only. +- (BookmarkButton*)hoverButton { + return hoverButton_; +} + +// Hover state accessor. For testing only. +- (HoverState)hoverState { + return hoverState_; +} + +// This method encodes the rules of our |hoverButton_| state machine. Only +// specific state transitions are allowable (encoded in the DCHECK). +// Note that there is no state for simultaneously opening and closing. A +// pending open must complete before scheduling a close, and vice versa. And +// it is not possible to make a transition directly from open to closed, and +// vice versa. +- (void)setHoverState:(HoverState)state { + DCHECK( + (hoverState_ == kHoverStateClosed && state == kHoverStateOpening) || + (hoverState_ == kHoverStateOpening && state == kHoverStateClosed) || + (hoverState_ == kHoverStateOpening && state == kHoverStateOpen) || + (hoverState_ == kHoverStateOpen && state == kHoverStateClosing) || + (hoverState_ == kHoverStateClosing && state == kHoverStateOpen) || + (hoverState_ == kHoverStateClosing && state == kHoverStateClosed) + ) << "bad transition: old = " << hoverState_ << " new = " << state; + + hoverState_ = state; +} + +// Called after a delay to close a previously hover-opened folder. +// Note: this method is not meant to be invoked directly, only through +// a delayed call to |scheduleCloseBookmarkFolderOnHoverButton:|. +- (void)closeBookmarkFolderOnHoverButton:(BookmarkButton*)button { + [NSObject + cancelPreviousPerformRequestsWithTarget:self + selector:@selector(closeBookmarkFolderOnHoverButton:) + object:hoverButton_]; + [self setHoverState:kHoverStateClosed]; + [[button target] closeBookmarkFolder:button]; + hoverButton_.reset(); +} + +// Called after a delay to open a new hover folder. +// Note: this method is not meant to be invoked directly, only through +// a delayed call to |scheduleOpenBookmarkFolderOnHoverButton:|. +- (void)openBookmarkFolderOnHoverButton:(BookmarkButton*)button { + [self setHoverState:kHoverStateOpen]; + [[button target] performSelector:@selector(openBookmarkFolderFromButton:) + withObject:button]; +} + +@end diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_hover_state_unittest.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_hover_state_unittest.mm new file mode 100644 index 0000000..3d0a50f --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_hover_state_unittest.mm @@ -0,0 +1,77 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/message_loop.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_controller.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_hover_state.h" +#import "chrome/browser/ui/cocoa/browser_test_helper.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" + +namespace { + +typedef CocoaTest BookmarkBarFolderHoverStateTest; + +// Hover state machine interface. +// A strict call order is implied with these calls. It is ONLY valid to make +// these specific state transitions. +TEST(BookmarkBarFolderHoverStateTest, HoverState) { + BrowserTestHelper helper; + scoped_nsobject<BookmarkBarFolderHoverState> bbfhs; + bbfhs.reset([[BookmarkBarFolderHoverState alloc] init]); + + // Initial state. + EXPECT_FALSE([bbfhs hoverButton]); + ASSERT_EQ(kHoverStateClosed, [bbfhs hoverState]); + + scoped_nsobject<BookmarkButton> button; + button.reset([[BookmarkButton alloc] initWithFrame:NSMakeRect(0, 0, 20, 20)]); + + // Test transition from closed to opening. + ASSERT_EQ(kHoverStateClosed, [bbfhs hoverState]); + [bbfhs scheduleOpenBookmarkFolderOnHoverButton:button]; + ASSERT_EQ(kHoverStateOpening, [bbfhs hoverState]); + + // Test transition from opening to closed (aka cancel open). + [bbfhs cancelPendingOpenBookmarkFolderOnHoverButton]; + ASSERT_EQ(kHoverStateClosed, [bbfhs hoverState]); + ASSERT_EQ(nil, [bbfhs hoverButton]); + + // Test transition from closed to opening. + ASSERT_EQ(kHoverStateClosed, [bbfhs hoverState]); + [bbfhs scheduleOpenBookmarkFolderOnHoverButton:button]; + ASSERT_EQ(kHoverStateOpening, [bbfhs hoverState]); + + // Test transition from opening to opened. + MessageLoop::current()->PostDelayedTask( + FROM_HERE, + new MessageLoop::QuitTask, + bookmarks::kDragHoverOpenDelay * 1000.0 * 1.5); + MessageLoop::current()->Run(); + ASSERT_EQ(kHoverStateOpen, [bbfhs hoverState]); + ASSERT_EQ(button, [bbfhs hoverButton]); + + // Test transition from opening to opened. + [bbfhs scheduleCloseBookmarkFolderOnHoverButton]; + ASSERT_EQ(kHoverStateClosing, [bbfhs hoverState]); + + // Test transition from closing to open (aka cancel close). + [bbfhs cancelPendingCloseBookmarkFolderOnHoverButton]; + ASSERT_EQ(kHoverStateOpen, [bbfhs hoverState]); + ASSERT_EQ(button, [bbfhs hoverButton]); + + // Test transition from closing to closed. + [bbfhs scheduleCloseBookmarkFolderOnHoverButton]; + ASSERT_EQ(kHoverStateClosing, [bbfhs hoverState]); + MessageLoop::current()->PostDelayedTask( + FROM_HERE, + new MessageLoop::QuitTask, + bookmarks::kDragHoverCloseDelay * 1000.0 * 1.5); + MessageLoop::current()->Run(); + ASSERT_EQ(kHoverStateClosed, [bbfhs hoverState]); + ASSERT_EQ(nil, [bbfhs hoverButton]); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_view.h b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_view.h new file mode 100644 index 0000000..8f60b8e --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_view.h @@ -0,0 +1,29 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +@protocol BookmarkButtonControllerProtocol; +@class BookmarkBarFolderController; + +// Main content view for a bookmark bar folder "menu" window. This is +// logically similar to a BookmarkBarView but is oriented vertically. +@interface BookmarkBarFolderView : NSView { + @private + BOOL inDrag_; // Are we in the middle of a drag? + BOOL dropIndicatorShown_; + CGFloat dropIndicatorPosition_; // y position + // The following |controller_| is weak; used for testing only. See the imple- + // mentation comment for - (id<BookmarkButtonControllerProtocol>)controller. + BookmarkBarFolderController* controller_; +} +// Return the controller that owns this view. +- (id<BookmarkButtonControllerProtocol>)controller; +@end + +@interface BookmarkBarFolderView() // TestingOrInternalAPI +@property (assign) BOOL dropIndicatorShown; +@property (readonly) CGFloat dropIndicatorPosition; +- (void)setController:(id)controller; +@end diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_view.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_view.mm new file mode 100644 index 0000000..5a451a8 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_view.mm @@ -0,0 +1,204 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_view.h" + +#include "chrome/browser/bookmarks/bookmark_pasteboard_helper_mac.h" +#include "chrome/browser/metrics/user_metrics.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_controller.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_folder_target.h" +#import "third_party/mozilla/NSPasteboard+Utils.h" + +@implementation BookmarkBarFolderView + +@synthesize dropIndicatorShown = dropIndicatorShown_; +@synthesize dropIndicatorPosition = dropIndicatorPosition_; + +- (void)awakeFromNib { + NSArray* types = [NSArray arrayWithObjects: + NSStringPboardType, + NSHTMLPboardType, + NSURLPboardType, + kBookmarkButtonDragType, + kBookmarkDictionaryListPboardType, + nil]; + [self registerForDraggedTypes:types]; +} + +- (void)dealloc { + [self unregisterDraggedTypes]; + [super dealloc]; +} + +- (id<BookmarkButtonControllerProtocol>)controller { + // When needed for testing, set the local data member |controller_| to + // the test controller. + return controller_ ? controller_ : [[self window] windowController]; +} + +- (void)setController:(id)controller { + controller_ = controller; +} + +- (void)drawRect:(NSRect)rect { + // TODO(jrg): copied from bookmark_bar_view but orientation changed. + // Code dup sucks but I'm not sure I can take 16 lines and make it + // generic for horiz vs vertical while keeping things simple. + // TODO(jrg): when throwing it all away and using animations, try + // hard to make a common routine for both. + // http://crbug.com/35966, http://crbug.com/35968 + + // Draw the bookmark-button-dragging drop indicator if necessary. + if (dropIndicatorShown_) { + const CGFloat kBarHeight = 1; + const CGFloat kBarHorizPad = 4; + const CGFloat kBarOpacity = 0.85; + + NSRect uglyBlackBar = + NSMakeRect(kBarHorizPad, dropIndicatorPosition_, + NSWidth([self bounds]) - 2*kBarHorizPad, + kBarHeight); + NSColor* uglyBlackBarColor = [NSColor blackColor]; + [[uglyBlackBarColor colorWithAlphaComponent:kBarOpacity] setFill]; + [[NSBezierPath bezierPathWithRect:uglyBlackBar] fill]; + } +} + +// TODO(mrossetti,jrg): Identical to -[BookmarkBarView +// dragClipboardContainsBookmarks]. http://crbug.com/35966 +// Shim function to assist in unit testing. +- (BOOL)dragClipboardContainsBookmarks { + return bookmark_pasteboard_helper_mac::DragClipboardContainsBookmarks(); +} + +// Virtually identical to [BookmarkBarView draggingEntered:]. +// TODO(jrg): find a way to share code. Lack of multiple inheritance +// makes things more of a pain but there should be no excuse for laziness. +// http://crbug.com/35966 +- (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)info { + inDrag_ = YES; + if ([[info draggingPasteboard] dataForType:kBookmarkButtonDragType] || + [self dragClipboardContainsBookmarks] || + [[info draggingPasteboard] containsURLData]) { + // Find the position of the drop indicator. + BOOL showIt = [[self controller] + shouldShowIndicatorShownForPoint:[info draggingLocation]]; + if (!showIt) { + if (dropIndicatorShown_) { + dropIndicatorShown_ = NO; + [self setNeedsDisplay:YES]; + } + } else { + CGFloat y = + [[self controller] + indicatorPosForDragToPoint:[info draggingLocation]]; + + // Need an update if the indicator wasn't previously shown or if it has + // moved. + if (!dropIndicatorShown_ || dropIndicatorPosition_ != y) { + dropIndicatorShown_ = YES; + dropIndicatorPosition_ = y; + [self setNeedsDisplay:YES]; + } + } + + [[self controller] draggingEntered:info]; // allow hover-open to work + return [info draggingSource] ? NSDragOperationMove : NSDragOperationCopy; + } + return NSDragOperationNone; +} + +- (void)draggingExited:(id<NSDraggingInfo>)info { + [[self controller] draggingExited:info]; + + // Regardless of the type of dragging which ended, we need to get rid of the + // drop indicator if one was shown. + if (dropIndicatorShown_) { + dropIndicatorShown_ = NO; + [self setNeedsDisplay:YES]; + } +} + +- (void)draggingEnded:(id<NSDraggingInfo>)info { + // Awkwardness since views open and close out from under us. + if (inDrag_) { + inDrag_ = NO; + } + + [self draggingExited:info]; +} + +- (BOOL)wantsPeriodicDraggingUpdates { + // TODO(jrg): This should probably return |YES| and the controller should + // slide the existing bookmark buttons interactively to the side to make + // room for the about-to-be-dropped bookmark. + // http://crbug.com/35968 + return NO; +} + +- (NSDragOperation)draggingUpdated:(id<NSDraggingInfo>)info { + // For now it's the same as draggingEntered:. + // TODO(jrg): once we return YES for wantsPeriodicDraggingUpdates, + // this should ping the [self controller] to perform animations. + // http://crbug.com/35968 + return [self draggingEntered:info]; +} + +- (BOOL)prepareForDragOperation:(id<NSDraggingInfo>)info { + return YES; +} + +// This code is practically identical to the same function in BookmarkBarView +// with the only difference being how the controller is retrieved. +// TODO(mrossetti,jrg): http://crbug.com/35966 +// Implement NSDraggingDestination protocol method +// performDragOperation: for URLs. +- (BOOL)performDragOperationForURL:(id<NSDraggingInfo>)info { + NSPasteboard* pboard = [info draggingPasteboard]; + DCHECK([pboard containsURLData]); + + NSArray* urls = nil; + NSArray* titles = nil; + [pboard getURLs:&urls andTitles:&titles convertingFilenames:YES]; + + return [[self controller] addURLs:urls + withTitles:titles + at:[info draggingLocation]]; +} + +// This code is practically identical to the same function in BookmarkBarView +// with the only difference being how the controller is retrieved. +// http://crbug.com/35966 +// Implement NSDraggingDestination protocol method +// performDragOperation: for bookmark buttons. +- (BOOL)performDragOperationForBookmarkButton:(id<NSDraggingInfo>)info { + BOOL doDrag = NO; + NSData* data = [[info draggingPasteboard] + dataForType:kBookmarkButtonDragType]; + // [info draggingSource] is nil if not the same application. + if (data && [info draggingSource]) { + BookmarkButton* button = nil; + [data getBytes:&button length:sizeof(button)]; + BOOL copy = !([info draggingSourceOperationMask] & NSDragOperationMove); + doDrag = [[self controller] dragButton:button + to:[info draggingLocation] + copy:copy]; + UserMetrics::RecordAction(UserMetricsAction("BookmarkBarFolder_DragEnd")); + } + return doDrag; +} + +- (BOOL)performDragOperation:(id<NSDraggingInfo>)info { + if ([[self controller] dragBookmarkData:info]) + return YES; + NSPasteboard* pboard = [info draggingPasteboard]; + if ([pboard dataForType:kBookmarkButtonDragType] && + [self performDragOperationForBookmarkButton:info]) + return YES; + if ([pboard containsURLData] && [self performDragOperationForURL:info]) + return YES; + return NO; +} + +@end diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_view_unittest.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_view_unittest.mm new file mode 100644 index 0000000..07aca2b --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_view_unittest.mm @@ -0,0 +1,211 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_controller.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_view.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_button.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_folder_target.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/url_drop_target.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" +#import "third_party/mozilla/NSPasteboard+Utils.h" + +namespace { + const CGFloat kFakeIndicatorPos = 7.0; +}; + +// Fake DraggingInfo, fake BookmarkBarController, fake NSPasteboard... +@interface FakeDraggingInfo : NSObject { + @public + BOOL dragButtonToPong_; + BOOL dragURLsPong_; + BOOL dragBookmarkDataPong_; + BOOL dropIndicatorShown_; + BOOL draggingEnteredCalled_; + // Only mock one type of drag data at a time. + NSString* dragDataType_; +} +@property (readwrite) BOOL dropIndicatorShown; +@property (readwrite) BOOL draggingEnteredCalled; +@property (copy) NSString* dragDataType; +@end + +@implementation FakeDraggingInfo + +@synthesize dropIndicatorShown = dropIndicatorShown_; +@synthesize draggingEnteredCalled = draggingEnteredCalled_; +@synthesize dragDataType = dragDataType_; + +- (id)init { + if ((self = [super init])) { + dropIndicatorShown_ = YES; + } + return self; +} + +- (void)dealloc { + [dragDataType_ release]; + [super dealloc]; +} + +- (void)reset { + [dragDataType_ release]; + dragDataType_ = nil; + dragButtonToPong_ = NO; + dragURLsPong_ = NO; + dragBookmarkDataPong_ = NO; + dropIndicatorShown_ = YES; + draggingEnteredCalled_ = NO; +} + +// NSDragInfo mocking functions. + +- (id)draggingPasteboard { + return self; +} + +// So we can look local. +- (id)draggingSource { + return self; +} + +- (NSDragOperation)draggingSourceOperationMask { + return NSDragOperationCopy | NSDragOperationMove; +} + +- (NSPoint)draggingLocation { + return NSMakePoint(10, 10); +} + +// NSPasteboard mocking functions. + +- (BOOL)containsURLData { + NSArray* urlTypes = [URLDropTargetHandler handledDragTypes]; + if (dragDataType_) + return [urlTypes containsObject:dragDataType_]; + return NO; +} + +- (NSData*)dataForType:(NSString*)type { + if (dragDataType_ && [dragDataType_ isEqualToString:type]) + return [NSData data]; // Return something, anything. + return nil; +} + +// Fake a controller for callback ponging + +- (BOOL)dragButton:(BookmarkButton*)button to:(NSPoint)point copy:(BOOL)copy { + dragButtonToPong_ = YES; + return YES; +} + +- (BOOL)addURLs:(NSArray*)urls withTitles:(NSArray*)titles at:(NSPoint)point { + dragURLsPong_ = YES; + return YES; +} + +- (void)getURLs:(NSArray**)outUrls + andTitles:(NSArray**)outTitles + convertingFilenames:(BOOL)convertFilenames { +} + +- (BOOL)dragBookmarkData:(id<NSDraggingInfo>)info { + dragBookmarkDataPong_ = YES; + return NO; +} + +// Confirm the pongs. + +- (BOOL)dragButtonToPong { + return dragButtonToPong_; +} + +- (BOOL)dragURLsPong { + return dragURLsPong_; +} + +- (BOOL)dragBookmarkDataPong { + return dragBookmarkDataPong_; +} + +- (CGFloat)indicatorPosForDragToPoint:(NSPoint)point { + return kFakeIndicatorPos; +} + +- (BOOL)shouldShowIndicatorShownForPoint:(NSPoint)point { + return dropIndicatorShown_; +} + +- (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)info { + draggingEnteredCalled_ = YES; + return NSDragOperationNone; +} + +@end + +namespace { + +class BookmarkBarFolderViewTest : public CocoaTest { + public: + virtual void SetUp() { + CocoaTest::SetUp(); + view_.reset([[BookmarkBarFolderView alloc] init]); + } + + scoped_nsobject<BookmarkBarFolderView> view_; +}; + +TEST_F(BookmarkBarFolderViewTest, BookmarkButtonDragAndDrop) { + [view_ awakeFromNib]; + scoped_nsobject<FakeDraggingInfo> info([[FakeDraggingInfo alloc] init]); + [view_ setController:info.get()]; + [info reset]; + + [info setDragDataType:kBookmarkButtonDragType]; + EXPECT_EQ([view_ draggingEntered:(id)info.get()], NSDragOperationMove); + EXPECT_TRUE([view_ performDragOperation:(id)info.get()]); + EXPECT_TRUE([info dragButtonToPong]); + EXPECT_FALSE([info dragURLsPong]); + EXPECT_TRUE([info dragBookmarkDataPong]); +} + +TEST_F(BookmarkBarFolderViewTest, URLDragAndDrop) { + [view_ awakeFromNib]; + scoped_nsobject<FakeDraggingInfo> info([[FakeDraggingInfo alloc] init]); + [view_ setController:info.get()]; + [info reset]; + + NSArray* dragTypes = [URLDropTargetHandler handledDragTypes]; + for (NSString* type in dragTypes) { + [info setDragDataType:type]; + EXPECT_EQ([view_ draggingEntered:(id)info.get()], NSDragOperationMove); + EXPECT_TRUE([view_ performDragOperation:(id)info.get()]); + EXPECT_FALSE([info dragButtonToPong]); + EXPECT_TRUE([info dragURLsPong]); + EXPECT_TRUE([info dragBookmarkDataPong]); + [info reset]; + } +} + +TEST_F(BookmarkBarFolderViewTest, BookmarkButtonDropIndicator) { + [view_ awakeFromNib]; + scoped_nsobject<FakeDraggingInfo> info([[FakeDraggingInfo alloc] init]); + [view_ setController:info.get()]; + [info reset]; + + [info setDragDataType:kBookmarkButtonDragType]; + EXPECT_FALSE([info draggingEnteredCalled]); + EXPECT_EQ([view_ draggingEntered:(id)info.get()], NSDragOperationMove); + EXPECT_TRUE([info draggingEnteredCalled]); // Ensure controller pinged. + EXPECT_TRUE([view_ dropIndicatorShown]); + EXPECT_EQ([view_ dropIndicatorPosition], kFakeIndicatorPos); + + [info setDropIndicatorShown:NO]; + EXPECT_EQ([view_ draggingEntered:(id)info.get()], NSDragOperationMove); + EXPECT_FALSE([view_ dropIndicatorShown]); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_window.h b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_window.h new file mode 100644 index 0000000..1b80d91 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_window.h @@ -0,0 +1,34 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_BAR_FOLDER_WINDOW_H_ +#define CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_BAR_FOLDER_WINDOW_H_ +#pragma once + +#import <Cocoa/Cocoa.h> +#import "base/cocoa_protocols_mac.h" +#include "base/scoped_nsobject.h" + + +// Window for a bookmark folder "menu". This menu pops up when you +// click on a bookmark button that represents a folder of bookmarks. +// This window is borderless. +@interface BookmarkBarFolderWindow : NSWindow +@end + +// Content view for the above window. "Stock" other than the drawing +// of rounded corners. Only used in the nib. +@interface BookmarkBarFolderWindowContentView : NSView { + // Arrows to show ability to scroll up and down as needed. + scoped_nsobject<NSImage> arrowUpImage_; + scoped_nsobject<NSImage> arrowDownImage_; +} +@end + +// Scroll view that contains the main view (where the buttons go). +@interface BookmarkBarFolderWindowScrollView : NSScrollView +@end + + +#endif // CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_BAR_FOLDER_WINDOW_H_ diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_window.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_window.mm new file mode 100644 index 0000000..0915188 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_window.mm @@ -0,0 +1,136 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_window.h" + +#import "base/logging.h" +#include "base/nsimage_cache_mac.h" +#import "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_controller.h" +#import "chrome/browser/ui/cocoa/image_utils.h" +#import "third_party/GTM/AppKit/GTMNSColor+Luminance.h" +#import "third_party/GTM/AppKit/GTMNSBezierPath+RoundRect.h" + + +@implementation BookmarkBarFolderWindow + +- (id)initWithContentRect:(NSRect)contentRect + styleMask:(NSUInteger)windowStyle + backing:(NSBackingStoreType)bufferingType + defer:(BOOL)deferCreation { + if ((self = [super initWithContentRect:contentRect + styleMask:NSBorderlessWindowMask // override + backing:bufferingType + defer:deferCreation])) { + [self setBackgroundColor:[NSColor clearColor]]; + [self setOpaque:NO]; + } + return self; +} + +@end + + +namespace { +// Corner radius for our bookmark bar folder window. +// Copied from bubble_view.mm. +const CGFloat kViewCornerRadius = 4.0; +} + +@implementation BookmarkBarFolderWindowContentView + +- (void)awakeFromNib { + arrowUpImage_.reset([nsimage_cache::ImageNamed(@"menu_overflow_up.pdf") + retain]); + arrowDownImage_.reset([nsimage_cache::ImageNamed(@"menu_overflow_down.pdf") + retain]); +} + +// Draw the arrows at the top and bottom of the folder window as a +// visual indication that scrolling is possible. We always draw the +// scrolling arrows; when not relevant (e.g. when not scrollable), the +// scroll view overlaps the window and the arrows aren't visible. +- (void)drawScrollArrows:(NSRect)rect { + NSRect visibleRect = [self bounds]; + + // On top + NSRect imageRect = NSZeroRect; + imageRect.size = [arrowUpImage_ size]; + NSRect drawRect = NSOffsetRect( + imageRect, + (NSWidth(visibleRect) - NSWidth(imageRect)) / 2, + NSHeight(visibleRect) - NSHeight(imageRect)); + [arrowUpImage_ drawInRect:drawRect + fromRect:imageRect + operation:NSCompositeSourceOver + fraction:1.0 + neverFlipped:YES]; + + // On bottom + imageRect = NSZeroRect; + imageRect.size = [arrowDownImage_ size]; + drawRect = NSOffsetRect(imageRect, + (NSWidth(visibleRect) - NSWidth(imageRect)) / 2, + 0); + [arrowDownImage_ drawInRect:drawRect + fromRect:imageRect + operation:NSCompositeSourceOver + fraction:1.0 + neverFlipped:YES]; +} + +- (void)drawRect:(NSRect)rect { + NSRect bounds = [self bounds]; + // Like NSMenus, only the bottom corners are rounded. + NSBezierPath* bezier = + [NSBezierPath gtm_bezierPathWithRoundRect:bounds + topLeftCornerRadius:0 + topRightCornerRadius:0 + bottomLeftCornerRadius:kViewCornerRadius + bottomRightCornerRadius:kViewCornerRadius]; + [bezier closePath]; + + // TODO(jrg): share code with info_bubble_view.mm? Or bubble_view.mm? + NSColor* base_color = [NSColor colorWithCalibratedWhite:0.5 alpha:1.0]; + NSColor* startColor = + [base_color gtm_colorAdjustedFor:GTMColorationLightHighlight + faded:YES]; + NSColor* midColor = + [base_color gtm_colorAdjustedFor:GTMColorationLightMidtone + faded:YES]; + NSColor* endColor = + [base_color gtm_colorAdjustedFor:GTMColorationLightShadow + faded:YES]; + NSColor* glowColor = + [base_color gtm_colorAdjustedFor:GTMColorationLightPenumbra + faded:YES]; + + scoped_nsobject<NSGradient> gradient( + [[NSGradient alloc] initWithColorsAndLocations:startColor, 0.0, + midColor, 0.25, + endColor, 0.5, + glowColor, 0.75, + nil]); + [gradient drawInBezierPath:bezier angle:0.0]; + + [self drawScrollArrows:rect]; +} + +@end + + +@implementation BookmarkBarFolderWindowScrollView + +// We want "draw background" of the NSScrollView in the xib to be NOT +// checked. That allows us to round the bottom corners of the folder +// window. However that also allows some scrollWheel: events to leak +// into the NSWindow behind it (even in a different application). +// Better to plug the scroll leak than to round corners for M5. +- (void)scrollWheel:(NSEvent *)theEvent { + DCHECK([[[self window] windowController] + respondsToSelector:@selector(scrollWheel:)]); + [[[self window] windowController] scrollWheel:theEvent]; +} + +@end diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_window_unittest.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_window_unittest.mm new file mode 100644 index 0000000..7dd6c06 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_window_unittest.mm @@ -0,0 +1,49 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/scoped_ptr.h" +#include "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_window.h" +#include "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +class BookmarkBarFolderWindowTest : public CocoaTest { +}; + +TEST_F(BookmarkBarFolderWindowTest, Borderless) { + scoped_nsobject<BookmarkBarFolderWindow> window_; + window_.reset([[BookmarkBarFolderWindow alloc] + initWithContentRect:NSMakeRect(0,0,20,20) + styleMask:0 + backing:NSBackingStoreBuffered + defer:NO]); + EXPECT_EQ(NSBorderlessWindowMask, [window_ styleMask]); +} + + +class BookmarkBarFolderWindowContentViewTest : public CocoaTest { + public: + BookmarkBarFolderWindowContentViewTest() { + view_.reset([[BookmarkBarFolderWindowContentView alloc] + initWithFrame:NSMakeRect(0, 0, 100, 100)]); + [[test_window() contentView] addSubview:view_.get()]; + } + scoped_nsobject<BookmarkBarFolderWindowContentView> view_; + scoped_nsobject<BookmarkBarFolderWindowScrollView> scroll_view_; +}; + +TEST_VIEW(BookmarkBarFolderWindowContentViewTest, view_); + + +class BookmarkBarFolderWindowScrollViewTest : public CocoaTest { + public: + BookmarkBarFolderWindowScrollViewTest() { + scroll_view_.reset([[BookmarkBarFolderWindowScrollView alloc] + initWithFrame:NSMakeRect(0, 0, 100, 100)]); + [[test_window() contentView] addSubview:scroll_view_.get()]; + } + scoped_nsobject<BookmarkBarFolderWindowScrollView> scroll_view_; +}; + +TEST_VIEW(BookmarkBarFolderWindowScrollViewTest, scroll_view_); diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_state.h b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_state.h new file mode 100644 index 0000000..c21c75d --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_state.h @@ -0,0 +1,62 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_BAR_STATE_H_ +#define CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_BAR_STATE_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +namespace bookmarks { + +// States for the bookmark bar. +enum VisualState { + kInvalidState = 0, + kHiddenState = 1, + kShowingState = 2, + kDetachedState = 3, +}; + +} // namespace bookmarks + +// The interface for controllers (etc.) which can give information about the +// bookmark bar's state. +@protocol BookmarkBarState + +// Returns YES if the bookmark bar is currently visible (as a normal toolbar or +// as a detached bar on the NTP), NO otherwise. +- (BOOL)isVisible; + +// Returns YES if an animation is currently running, NO otherwise. +- (BOOL)isAnimationRunning; + +// Returns YES if the bookmark bar is in the given state and not in an +// animation, NO otherwise. +- (BOOL)isInState:(bookmarks::VisualState)state; + +// Returns YES if the bookmark bar is animating from the given state (to any +// other state), NO otherwise. +- (BOOL)isAnimatingToState:(bookmarks::VisualState)state; + +// Returns YES if the bookmark bar is animating to the given state (from any +// other state), NO otherwise. +- (BOOL)isAnimatingFromState:(bookmarks::VisualState)state; + +// Returns YES if the bookmark bar is animating from the first given state to +// the second given state, NO otherwise. +- (BOOL)isAnimatingFromState:(bookmarks::VisualState)fromState + toState:(bookmarks::VisualState)toState; + +// Returns YES if the bookmark bar is animating between the two given states (in +// either direction), NO otherwise. +- (BOOL)isAnimatingBetweenState:(bookmarks::VisualState)fromState + andState:(bookmarks::VisualState)toState; + +// Returns how morphed into the detached bubble the bookmark bar should be (1 = +// completely detached, 0 = normal). +- (CGFloat)detachedMorphProgress; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_BAR_STATE_H_ diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_toolbar_view.h b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_toolbar_view.h new file mode 100644 index 0000000..1942ebd --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_toolbar_view.h @@ -0,0 +1,44 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// The BookmarkBarToolbarView is responsible for drawing the background of the +// BookmarkBar's toolbar in either of its two display modes - permanently +// attached (slimline with a stroke at the bottom edge) or New Tab Page style +// (padded with a round rect border and the New Tab Page theme behind). + +#ifndef CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_BAR_TOOLBAR_VIEW_H_ +#define CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_BAR_TOOLBAR_VIEW_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#import "chrome/browser/ui/cocoa/animatable_view.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_state.h" + +@class BookmarkBarView; +class TabContents; +class ThemeProvider; + +// An interface to allow mocking of a BookmarkBarController by the +// BookmarkBarToolbarView. +@protocol BookmarkBarToolbarViewController <BookmarkBarState> +// Displaying the bookmark toolbar background in bubble (floating) mode requires +// the size of the currently selected tab to properly calculate where the +// background image is joined. +- (int)currentTabContentsHeight; + +// Current theme provider, passed to the cross platform NtpBackgroundUtil class. +- (ThemeProvider*)themeProvider; + +@end + +@interface BookmarkBarToolbarView : AnimatableView { + @private + // The controller which tells us how we should be drawing (as normal or as a + // floating bar). + IBOutlet id<BookmarkBarToolbarViewController> controller_; +} +@end + +#endif // CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_BAR_TOOLBAR_VIEW_H_ diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_toolbar_view.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_toolbar_view.mm new file mode 100644 index 0000000..760de17 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_toolbar_view.mm @@ -0,0 +1,135 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_toolbar_view.h" + +#include "app/theme_provider.h" +#include "gfx/rect.h" +#include "chrome/browser/ntp_background_util.h" +#include "chrome/browser/themes/browser_theme_provider.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_constants.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_controller.h" +#import "chrome/browser/ui/cocoa/browser_window_controller.h" +#import "chrome/browser/ui/cocoa/themed_window.h" +#include "gfx/canvas_skia_paint.h" + +const CGFloat kBorderRadius = 3.0; + +@interface BookmarkBarToolbarView (Private) +- (void)drawRectAsBubble:(NSRect)rect; +@end + +@implementation BookmarkBarToolbarView + +- (BOOL)isOpaque { + return [controller_ isInState:bookmarks::kDetachedState]; +} + +- (void)drawRect:(NSRect)rect { + if ([controller_ isInState:bookmarks::kDetachedState] || + [controller_ isAnimatingToState:bookmarks::kDetachedState] || + [controller_ isAnimatingFromState:bookmarks::kDetachedState]) { + [self drawRectAsBubble:rect]; + } else { + NSPoint phase = [[self window] themePatternPhase]; + [[NSGraphicsContext currentContext] setPatternPhase:phase]; + [self drawBackground]; + } +} + +- (void)drawRectAsBubble:(NSRect)rect { + // The state of our morph; 1 is total bubble, 0 is the regular bar. We use it + // to morph the bubble to a regular bar (shape and colour). + CGFloat morph = [controller_ detachedMorphProgress]; + + NSRect bounds = [self bounds]; + + ThemeProvider* themeProvider = [controller_ themeProvider]; + if (!themeProvider) + return; + + NSGraphicsContext* context = [NSGraphicsContext currentContext]; + [context saveGraphicsState]; + + // Draw the background. + { + // CanvasSkiaPaint draws to the NSGraphicsContext during its destructor, so + // explicitly scope this. + // + // Paint the entire bookmark bar, even if the damage rect is much smaller + // because PaintBackgroundDetachedMode() assumes that area's origin is + // (0, 0) and that its size is the size of the bookmark bar. + // + // In practice, this sounds worse than it is because redraw time is still + // minimal compared to the pause between frames of animations. We were + // already repainting the rest of the bookmark bar below without setting a + // clip area, anyway. Also, the only time we weren't asked to redraw the + // whole bookmark bar is when the find bar is drawn over it. + gfx::CanvasSkiaPaint canvas(bounds, true); + gfx::Rect area(0, 0, NSWidth(bounds), NSHeight(bounds)); + + NtpBackgroundUtil::PaintBackgroundDetachedMode(themeProvider, &canvas, + area, [controller_ currentTabContentsHeight]); + } + + // Draw our bookmark bar border on top of the background. + NSRect frameRect = + NSMakeRect( + morph * bookmarks::kNTPBookmarkBarPadding, + morph * bookmarks::kNTPBookmarkBarPadding, + NSWidth(bounds) - 2 * morph * bookmarks::kNTPBookmarkBarPadding, + NSHeight(bounds) - 2 * morph * bookmarks::kNTPBookmarkBarPadding); + // Now draw a bezier path with rounded rectangles around the area. + frameRect = NSInsetRect(frameRect, morph * 0.5, morph * 0.5); + NSBezierPath* border = + [NSBezierPath bezierPathWithRoundedRect:frameRect + xRadius:(morph * kBorderRadius) + yRadius:(morph * kBorderRadius)]; + + // Draw the rounded rectangle. + NSColor* toolbarColor = + themeProvider->GetNSColor(BrowserThemeProvider::COLOR_TOOLBAR, true); + CGFloat alpha = morph * [toolbarColor alphaComponent]; + [[toolbarColor colorWithAlphaComponent:alpha] set]; // Set with opacity. + [border fill]; + + // Fade in/out the background. + [context saveGraphicsState]; + [border setClip]; + CGContextRef cgContext = (CGContextRef)[context graphicsPort]; + CGContextBeginTransparencyLayer(cgContext, NULL); + CGContextSetAlpha(cgContext, 1 - morph); + [context setPatternPhase:[[self window] themePatternPhase]]; + [self drawBackground]; + CGContextEndTransparencyLayer(cgContext); + [context restoreGraphicsState]; + + // Draw the border of the rounded rectangle. + NSColor* borderColor = themeProvider->GetNSColor( + BrowserThemeProvider::COLOR_TOOLBAR_BUTTON_STROKE, true); + alpha = morph * [borderColor alphaComponent]; + [[borderColor colorWithAlphaComponent:alpha] set]; // Set with opacity. + [border stroke]; + + // Fade in/out the divider. + // TODO(viettrungluu): It's not obvious that this divider lines up exactly + // with |BackgroundGradientView|'s (in fact, it probably doesn't). + NSColor* strokeColor = [self strokeColor]; + alpha = (1 - morph) * [strokeColor alphaComponent]; + [[strokeColor colorWithAlphaComponent:alpha] set]; + NSBezierPath* divider = [NSBezierPath bezierPath]; + NSPoint dividerStart = + NSMakePoint(morph * bookmarks::kNTPBookmarkBarPadding + morph * 0.5, + morph * bookmarks::kNTPBookmarkBarPadding + morph * 0.5); + CGFloat dividerWidth = + NSWidth(bounds) - 2 * morph * bookmarks::kNTPBookmarkBarPadding - 2 * 0.5; + [divider moveToPoint:dividerStart]; + [divider relativeLineToPoint:NSMakePoint(dividerWidth, 0)]; + [divider stroke]; + + // Restore the graphics context. + [context restoreGraphicsState]; +} + +@end // @implementation BookmarkBarToolbarView diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_toolbar_view_unittest.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_toolbar_view_unittest.mm new file mode 100644 index 0000000..24d971a --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_toolbar_view_unittest.mm @@ -0,0 +1,191 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "app/theme_provider.h" +#include "base/scoped_nsobject.h" +#include "chrome/browser/themes/browser_theme_provider.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_controller.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_toolbar_view.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "grit/theme_resources.h" +#include "testing/gmock/include/gmock/gmock.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" +#include "third_party/skia/include/core/SkBitmap.h" +#include "third_party/skia/include/core/SkColor.h" + +using ::testing::_; +using ::testing::DoAll; +using ::testing::Return; +using ::testing::SetArgumentPointee; + +// When testing the floating drawing, we need to have a source of theme data. +class MockThemeProvider : public ThemeProvider { + public: + // Cross platform methods + MOCK_METHOD1(Init, void(Profile*)); + MOCK_CONST_METHOD1(GetBitmapNamed, SkBitmap*(int)); + MOCK_CONST_METHOD1(GetColor, SkColor(int)); + MOCK_CONST_METHOD2(GetDisplayProperty, bool(int, int*)); + MOCK_CONST_METHOD0(ShouldUseNativeFrame, bool()); + MOCK_CONST_METHOD1(HasCustomImage, bool(int)); + MOCK_CONST_METHOD1(GetRawData, RefCountedMemory*(int)); + + // OSX stuff + MOCK_CONST_METHOD2(GetNSImageNamed, NSImage*(int, bool)); + MOCK_CONST_METHOD2(GetNSImageColorNamed, NSColor*(int, bool)); + MOCK_CONST_METHOD2(GetNSColor, NSColor*(int, bool)); + MOCK_CONST_METHOD2(GetNSColorTint, NSColor*(int, bool)); + MOCK_CONST_METHOD1(GetNSGradient, NSGradient*(int)); +}; + +// Allows us to inject our fake controller below. +@interface BookmarkBarToolbarView (TestingAPI) +-(void)setController:(id<BookmarkBarToolbarViewController>)controller; +@end + +@implementation BookmarkBarToolbarView (TestingAPI) +-(void)setController:(id<BookmarkBarToolbarViewController>)controller { + controller_ = controller; +} +@end + +// Allows us to control which way the view is rendered. +@interface DrawDetachedBarFakeController : + NSObject<BookmarkBarState, BookmarkBarToolbarViewController> { + @private + int currentTabContentsHeight_; + ThemeProvider* themeProvider_; + bookmarks::VisualState visualState_; +} +@property (nonatomic, assign) int currentTabContentsHeight; +@property (nonatomic, assign) ThemeProvider* themeProvider; +@property (nonatomic, assign) bookmarks::VisualState visualState; + +// |BookmarkBarState| protocol: +- (BOOL)isVisible; +- (BOOL)isAnimationRunning; +- (BOOL)isInState:(bookmarks::VisualState)state; +- (BOOL)isAnimatingToState:(bookmarks::VisualState)state; +- (BOOL)isAnimatingFromState:(bookmarks::VisualState)state; +- (BOOL)isAnimatingFromState:(bookmarks::VisualState)fromState + toState:(bookmarks::VisualState)toState; +- (BOOL)isAnimatingBetweenState:(bookmarks::VisualState)fromState + andState:(bookmarks::VisualState)toState; +- (CGFloat)detachedMorphProgress; +@end + +@implementation DrawDetachedBarFakeController +@synthesize currentTabContentsHeight = currentTabContentsHeight_; +@synthesize themeProvider = themeProvider_; +@synthesize visualState = visualState_; + +- (id)init { + if ((self = [super init])) { + [self setVisualState:bookmarks::kHiddenState]; + } + return self; +} + +- (BOOL)isVisible { return YES; } +- (BOOL)isAnimationRunning { return NO; } +- (BOOL)isInState:(bookmarks::VisualState)state + { return ([self visualState] == state) ? YES : NO; } +- (BOOL)isAnimatingToState:(bookmarks::VisualState)state { return NO; } +- (BOOL)isAnimatingFromState:(bookmarks::VisualState)state { return NO; } +- (BOOL)isAnimatingFromState:(bookmarks::VisualState)fromState + toState:(bookmarks::VisualState)toState { return NO; } +- (BOOL)isAnimatingBetweenState:(bookmarks::VisualState)fromState + andState:(bookmarks::VisualState)toState { return NO; } +- (CGFloat)detachedMorphProgress { return 1; } +@end + +class BookmarkBarToolbarViewTest : public CocoaTest { + public: + BookmarkBarToolbarViewTest() { + controller_.reset([[DrawDetachedBarFakeController alloc] init]); + NSRect frame = NSMakeRect(0, 0, 400, 40); + scoped_nsobject<BookmarkBarToolbarView> view( + [[BookmarkBarToolbarView alloc] initWithFrame:frame]); + view_ = view.get(); + [[test_window() contentView] addSubview:view_]; + [view_ setController:controller_.get()]; + } + + scoped_nsobject<DrawDetachedBarFakeController> controller_; + BookmarkBarToolbarView* view_; +}; + +TEST_VIEW(BookmarkBarToolbarViewTest, view_) + +// Test drawing (part 1), mostly to ensure nothing leaks or crashes. +TEST_F(BookmarkBarToolbarViewTest, DisplayAsNormalBar) { + [controller_.get() setVisualState:bookmarks::kShowingState]; + [view_ display]; +} + +// Test drawing (part 2), mostly to ensure nothing leaks or crashes. +TEST_F(BookmarkBarToolbarViewTest, DisplayAsDetachedBarWithNoImage) { + [controller_.get() setVisualState:bookmarks::kDetachedState]; + + // Tests where we don't have a background image, only a color. + MockThemeProvider provider; + EXPECT_CALL(provider, GetColor(BrowserThemeProvider::COLOR_NTP_BACKGROUND)) + .WillRepeatedly(Return(SK_ColorWHITE)); + EXPECT_CALL(provider, HasCustomImage(IDR_THEME_NTP_BACKGROUND)) + .WillRepeatedly(Return(false)); + [controller_.get() setThemeProvider:&provider]; + + [view_ display]; +} + +// Actions used in DisplayAsDetachedBarWithBgImage. +ACTION(SetBackgroundTiling) { + *arg1 = BrowserThemeProvider::NO_REPEAT; + return true; +} + +ACTION(SetAlignLeft) { + *arg1 = BrowserThemeProvider::ALIGN_LEFT; + return true; +} + +// Test drawing (part 3), mostly to ensure nothing leaks or crashes. +TEST_F(BookmarkBarToolbarViewTest, DisplayAsDetachedBarWithBgImage) { + [controller_.get() setVisualState:bookmarks::kDetachedState]; + + // Tests where we have a background image, with positioning information. + MockThemeProvider provider; + + // Advertise having an image. + EXPECT_CALL(provider, GetColor(BrowserThemeProvider::COLOR_NTP_BACKGROUND)) + .WillRepeatedly(Return(SK_ColorRED)); + EXPECT_CALL(provider, HasCustomImage(IDR_THEME_NTP_BACKGROUND)) + .WillRepeatedly(Return(true)); + + // Return the correct tiling/alignment information. + EXPECT_CALL(provider, + GetDisplayProperty(BrowserThemeProvider::NTP_BACKGROUND_TILING, _)) + .WillRepeatedly(SetBackgroundTiling()); + EXPECT_CALL(provider, + GetDisplayProperty(BrowserThemeProvider::NTP_BACKGROUND_ALIGNMENT, _)) + .WillRepeatedly(SetAlignLeft()); + + // Create a dummy bitmap full of not-red to blit with. + SkBitmap fake_bg; + fake_bg.setConfig(SkBitmap::kARGB_8888_Config, 800, 800); + fake_bg.allocPixels(); + fake_bg.eraseColor(SK_ColorGREEN); + EXPECT_CALL(provider, GetBitmapNamed(IDR_THEME_NTP_BACKGROUND)) + .WillRepeatedly(Return(&fake_bg)); + + [controller_.get() setThemeProvider:&provider]; + [controller_.get() setCurrentTabContentsHeight:200]; + + [view_ display]; +} + +// TODO(viettrungluu): write more unit tests, especially after my refactoring. diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_unittest_helper.h b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_unittest_helper.h new file mode 100644 index 0000000..d0221b2 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_unittest_helper.h @@ -0,0 +1,57 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_BAR_UNITTEST_HELPER_H_ +#define CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_BAR_UNITTEST_HELPER_H_ +#pragma once + +#import <Foundation/Foundation.h> + +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_controller.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_controller.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_button.h" + +@interface BookmarkBarController (BookmarkBarUnitTestHelper) + +// Return the bookmark button from this bar controller with the given +// |title|, otherwise nil. This does not recurse into folders. +- (BookmarkButton*)buttonWithTitleEqualTo:(NSString*)title; + +@end + + +@interface BookmarkBarFolderController (BookmarkBarUnitTestHelper) + +// Return the bookmark button from this folder controller with the given +// |title|, otherwise nil. This does not recurse into subfolders. +- (BookmarkButton*)buttonWithTitleEqualTo:(NSString*)title; + +@end + + +@interface BookmarkButton (BookmarkBarUnitTestHelper) + +// Return the center of the button in the base coordinate system of the +// containing window. Useful for simulating mouse clicks or drags. +- (NSPoint)center; + +// Return the top of the button in the base coordinate system of the +// containing window. +- (NSPoint)top; + +// Return the bottom of the button in the base coordinate system of the +// containing window. +- (NSPoint)bottom; + +// Return the center-left point of the button in the base coordinate system +// of the containing window. +- (NSPoint)left; + +// Return the center-right point of the button in the base coordinate system +// of the containing window. +- (NSPoint)right; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_BAR_UNITTEST_HELPER_H_ diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_unittest_helper.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_unittest_helper.mm new file mode 100644 index 0000000..7cddec4 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_unittest_helper.mm @@ -0,0 +1,81 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_unittest_helper.h" + +@interface NSArray (BookmarkBarUnitTestHelper) + +// A helper function for scanning an array of buttons looking for the +// button with the given |title|. +- (BookmarkButton*)buttonWithTitleEqualTo:(NSString*)title; + +@end + + +@implementation NSArray (BookmarkBarUnitTestHelper) + +- (BookmarkButton*)buttonWithTitleEqualTo:(NSString*)title { + for (BookmarkButton* button in self) { + if ([[button title] isEqualToString:title]) + return button; + } + return nil; +} + +@end + +@implementation BookmarkBarController (BookmarkBarUnitTestHelper) + +- (BookmarkButton*)buttonWithTitleEqualTo:(NSString*)title { + return [[self buttons] buttonWithTitleEqualTo:title]; +} + +@end + +@implementation BookmarkBarFolderController(BookmarkBarUnitTestHelper) + +- (BookmarkButton*)buttonWithTitleEqualTo:(NSString*)title { + return [[self buttons] buttonWithTitleEqualTo:title]; +} + +@end + +@implementation BookmarkButton(BookmarkBarUnitTestHelper) + +- (NSPoint)center { + NSRect frame = [self frame]; + NSPoint center = NSMakePoint(NSMidX(frame), NSMidY(frame)); + center = [[self superview] convertPoint:center toView:nil]; + return center; +} + +- (NSPoint)top { + NSRect frame = [self frame]; + NSPoint top = NSMakePoint(NSMidX(frame), NSMaxY(frame)); + top = [[self superview] convertPoint:top toView:nil]; + return top; +} + +- (NSPoint)bottom { + NSRect frame = [self frame]; + NSPoint bottom = NSMakePoint(NSMidX(frame), NSMinY(frame)); + bottom = [[self superview] convertPoint:bottom toView:nil]; + return bottom; +} + +- (NSPoint)left { + NSRect frame = [self frame]; + NSPoint left = NSMakePoint(NSMinX(frame), NSMidY(frame)); + left = [[self superview] convertPoint:left toView:nil]; + return left; +} + +- (NSPoint)right { + NSRect frame = [self frame]; + NSPoint right = NSMakePoint(NSMaxX(frame), NSMidY(frame)); + right = [[self superview] convertPoint:right toView:nil]; + return right; +} + +@end diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_view.h b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_view.h new file mode 100644 index 0000000..abcbdf0 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_view.h @@ -0,0 +1,41 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +// +// A simple custom NSView for the bookmark bar used to prevent clicking and +// dragging from moving the browser window. + +#ifndef CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_BAR_VIEW_H_ +#define CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_BAR_VIEW_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#import "chrome/browser/ui/cocoa/background_gradient_view.h" + +@class BookmarkBarController; + +@interface BookmarkBarView : BackgroundGradientView { + @private + BOOL dropIndicatorShown_; + CGFloat dropIndicatorPosition_; // x position + + IBOutlet BookmarkBarController* controller_; + IBOutlet NSTextField* noItemTextfield_; + IBOutlet NSButton* importBookmarksButton_; + NSView* noItemContainer_; +} +- (NSTextField*)noItemTextfield; +- (NSButton*)importBookmarksButton; +- (BookmarkBarController*)controller; + +@property (nonatomic, assign) IBOutlet NSView* noItemContainer; +@end + +@interface BookmarkBarView() // TestingOrInternalAPI +@property (nonatomic, readonly) BOOL dropIndicatorShown; +@property (nonatomic, readonly) CGFloat dropIndicatorPosition; +- (void)setController:(id)controller; +@end + +#endif // CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_BAR_VIEW_H_ diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_view.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_view.mm new file mode 100644 index 0000000..5083367 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_view.mm @@ -0,0 +1,259 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_view.h" + +#include "chrome/browser/bookmarks/bookmark_pasteboard_helper_mac.h" +#include "chrome/browser/metrics/user_metrics.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_controller.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_button.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_folder_target.h" +#import "chrome/browser/ui/cocoa/themed_window.h" +#import "chrome/browser/ui/cocoa/view_id_util.h" +#import "chrome/browser/themes/browser_theme_provider.h" +#import "third_party/mozilla/NSPasteboard+Utils.h" + +@interface BookmarkBarView (Private) +- (void)themeDidChangeNotification:(NSNotification*)aNotification; +- (void)updateTheme:(ThemeProvider*)themeProvider; +@end + +@implementation BookmarkBarView + +@synthesize dropIndicatorShown = dropIndicatorShown_; +@synthesize dropIndicatorPosition = dropIndicatorPosition_; +@synthesize noItemContainer = noItemContainer_; + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; + // This probably isn't strictly necessary, but can't hurt. + [self unregisterDraggedTypes]; + [super dealloc]; + + // To be clear, our controller_ is an IBOutlet and owns us, so we + // don't deallocate it explicitly. It is owned by the browser + // window controller, so gets deleted with a browser window is + // closed. +} + +- (void)awakeFromNib { + NSNotificationCenter* defaultCenter = [NSNotificationCenter defaultCenter]; + [defaultCenter addObserver:self + selector:@selector(themeDidChangeNotification:) + name:kBrowserThemeDidChangeNotification + object:nil]; + + DCHECK(controller_) << "Expected this to be hooked up via Interface Builder"; + NSArray* types = [NSArray arrayWithObjects: + NSStringPboardType, + NSHTMLPboardType, + NSURLPboardType, + kBookmarkButtonDragType, + kBookmarkDictionaryListPboardType, + nil]; + [self registerForDraggedTypes:types]; +} + +// We need the theme to color the bookmark buttons properly. But our +// controller desn't have access to it until it's placed in the view +// hierarchy. This is the spot where we close the loop. +- (void)viewWillMoveToWindow:(NSWindow*)window { + ThemeProvider* themeProvider = [window themeProvider]; + [self updateTheme:themeProvider]; + [controller_ updateTheme:themeProvider]; +} + +- (void)viewDidMoveToWindow { + [controller_ viewDidMoveToWindow]; +} + +// Called after the current theme has changed. +- (void)themeDidChangeNotification:(NSNotification*)aNotification { + ThemeProvider* themeProvider = + static_cast<ThemeProvider*>([[aNotification object] pointerValue]); + [self updateTheme:themeProvider]; +} + +// Adapt appearance to the current theme. Called after theme changes and before +// this is shown for the first time. +- (void)updateTheme:(ThemeProvider*)themeProvider { + if (!themeProvider) + return; + + NSColor* color = + themeProvider->GetNSColor(BrowserThemeProvider::COLOR_BOOKMARK_TEXT, + true); + [noItemTextfield_ setTextColor:color]; +} + +// Mouse down events on the bookmark bar should not allow dragging the parent +// window around. +- (BOOL)mouseDownCanMoveWindow { + return NO; +} + +-(NSTextField*)noItemTextfield { + return noItemTextfield_; +} + +-(NSButton*)importBookmarksButton { + return importBookmarksButton_; +} + +- (BookmarkBarController*)controller { + return controller_; +} + +-(void)drawRect:(NSRect)dirtyRect { + [super drawRect:dirtyRect]; + + // Draw the bookmark-button-dragging drop indicator if necessary. + if (dropIndicatorShown_) { + const CGFloat kBarWidth = 1; + const CGFloat kBarHalfWidth = kBarWidth / 2.0; + const CGFloat kBarVertPad = 4; + const CGFloat kBarOpacity = 0.85; + + // Prevent the indicator from being clipped on the left. + CGFloat xLeft = MAX(dropIndicatorPosition_ - kBarHalfWidth, 0); + + NSRect uglyBlackBar = + NSMakeRect(xLeft, kBarVertPad, + kBarWidth, NSHeight([self bounds]) - 2 * kBarVertPad); + NSColor* uglyBlackBarColor = [[self window] themeProvider]-> + GetNSColor(BrowserThemeProvider::COLOR_BOOKMARK_TEXT, true); + [[uglyBlackBarColor colorWithAlphaComponent:kBarOpacity] setFill]; + [[NSBezierPath bezierPathWithRect:uglyBlackBar] fill]; + } +} + +// Shim function to assist in unit testing. +- (BOOL)dragClipboardContainsBookmarks { + return bookmark_pasteboard_helper_mac::DragClipboardContainsBookmarks(); +} + +// NSDraggingDestination methods + +- (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)info { + if ([[info draggingPasteboard] dataForType:kBookmarkButtonDragType] || + [self dragClipboardContainsBookmarks] || + [[info draggingPasteboard] containsURLData]) { + // We only show the drop indicator if we're not in a position to + // perform a hover-open since it doesn't make sense to do both. + BOOL showIt = [controller_ shouldShowIndicatorShownForPoint: + [info draggingLocation]]; + if (!showIt) { + if (dropIndicatorShown_) { + dropIndicatorShown_ = NO; + [self setNeedsDisplay:YES]; + } + } else { + CGFloat x = + [controller_ indicatorPosForDragToPoint:[info draggingLocation]]; + // Need an update if the indicator wasn't previously shown or if it has + // moved. + if (!dropIndicatorShown_ || dropIndicatorPosition_ != x) { + dropIndicatorShown_ = YES; + dropIndicatorPosition_ = x; + [self setNeedsDisplay:YES]; + } + } + + [controller_ draggingEntered:info]; // allow hover-open to work. + return [info draggingSource] ? NSDragOperationMove : NSDragOperationCopy; + } + return NSDragOperationNone; +} + +- (void)draggingExited:(id<NSDraggingInfo>)info { + // Regardless of the type of dragging which ended, we need to get rid of the + // drop indicator if one was shown. + if (dropIndicatorShown_) { + dropIndicatorShown_ = NO; + [self setNeedsDisplay:YES]; + } +} + +- (void)draggingEnded:(id<NSDraggingInfo>)info { + // For now, we just call |-draggingExited:|. + [self draggingExited:info]; +} + +- (BOOL)wantsPeriodicDraggingUpdates { + // TODO(port): This should probably return |YES| and the controller should + // slide the existing bookmark buttons interactively to the side to make + // room for the about-to-be-dropped bookmark. + return NO; +} + +- (NSDragOperation)draggingUpdated:(id<NSDraggingInfo>)info { + // For now it's the same as draggingEntered:. + // TODO(jrg): once we return YES for wantsPeriodicDraggingUpdates, + // this should ping the controller_ to perform animations. + return [self draggingEntered:info]; +} + +- (BOOL)prepareForDragOperation:(id<NSDraggingInfo>)info { + return YES; +} + +// Implement NSDraggingDestination protocol method +// performDragOperation: for URLs. +- (BOOL)performDragOperationForURL:(id<NSDraggingInfo>)info { + NSPasteboard* pboard = [info draggingPasteboard]; + DCHECK([pboard containsURLData]); + + NSArray* urls = nil; + NSArray* titles = nil; + [pboard getURLs:&urls andTitles:&titles convertingFilenames:YES]; + + return [controller_ addURLs:urls + withTitles:titles + at:[info draggingLocation]]; +} + +// Implement NSDraggingDestination protocol method +// performDragOperation: for bookmark buttons. +- (BOOL)performDragOperationForBookmarkButton:(id<NSDraggingInfo>)info { + BOOL rtn = NO; + NSData* data = [[info draggingPasteboard] + dataForType:kBookmarkButtonDragType]; + // [info draggingSource] is nil if not the same application. + if (data && [info draggingSource]) { + BookmarkButton* button = nil; + [data getBytes:&button length:sizeof(button)]; + BOOL copy = !([info draggingSourceOperationMask] & NSDragOperationMove); + rtn = [controller_ dragButton:button + to:[info draggingLocation] + copy:copy]; + UserMetrics::RecordAction(UserMetricsAction("BookmarkBar_DragEnd")); + } + return rtn; +} + +- (BOOL)performDragOperation:(id<NSDraggingInfo>)info { + if ([controller_ dragBookmarkData:info]) + return YES; + NSPasteboard* pboard = [info draggingPasteboard]; + if ([pboard dataForType:kBookmarkButtonDragType]) { + if ([self performDragOperationForBookmarkButton:info]) + return YES; + // Fall through.... + } + if ([pboard containsURLData]) { + if ([self performDragOperationForURL:info]) + return YES; + } + return NO; +} + +- (void)setController:(id)controller { + controller_ = controller; +} + +- (ViewID)viewID { + return VIEW_ID_BOOKMARK_BAR; +} + +@end // @implementation BookmarkBarView diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_view_unittest.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_view_unittest.mm new file mode 100644 index 0000000..c847a61 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_bar_view_unittest.mm @@ -0,0 +1,215 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_controller.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_view.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_button.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_folder_target.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/url_drop_target.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" +#import "third_party/mozilla/NSPasteboard+Utils.h" + +namespace { + const CGFloat kFakeIndicatorPos = 7.0; +}; + +// Fake DraggingInfo, fake BookmarkBarController, fake NSPasteboard... +@interface FakeBookmarkDraggingInfo : NSObject { + @public + BOOL dragButtonToPong_; + BOOL dragURLsPong_; + BOOL dragBookmarkDataPong_; + BOOL dropIndicatorShown_; + BOOL draggingEnteredCalled_; + // Only mock one type of drag data at a time. + NSString* dragDataType_; +} +@property (nonatomic) BOOL dropIndicatorShown; +@property (nonatomic) BOOL draggingEnteredCalled; +@property (nonatomic, copy) NSString* dragDataType; +@end + +@implementation FakeBookmarkDraggingInfo + +@synthesize dropIndicatorShown = dropIndicatorShown_; +@synthesize draggingEnteredCalled = draggingEnteredCalled_; +@synthesize dragDataType = dragDataType_; + +- (id)init { + if ((self = [super init])) { + dropIndicatorShown_ = YES; + } + return self; +} + +- (void)dealloc { + [dragDataType_ release]; + [super dealloc]; +} + +- (void)reset { + [dragDataType_ release]; + dragDataType_ = nil; + dragButtonToPong_ = NO; + dragURLsPong_ = NO; + dragBookmarkDataPong_ = NO; + dropIndicatorShown_ = YES; + draggingEnteredCalled_ = NO; +} + +// NSDragInfo mocking functions. + +- (id)draggingPasteboard { + return self; +} + +// So we can look local. +- (id)draggingSource { + return self; +} + +- (NSDragOperation)draggingSourceOperationMask { + return NSDragOperationCopy | NSDragOperationMove; +} + +- (NSPoint)draggingLocation { + return NSMakePoint(10, 10); +} + +// NSPasteboard mocking functions. + +- (BOOL)containsURLData { + NSArray* urlTypes = [URLDropTargetHandler handledDragTypes]; + if (dragDataType_) + return [urlTypes containsObject:dragDataType_]; + return NO; +} + +- (NSData*)dataForType:(NSString*)type { + if (dragDataType_ && [dragDataType_ isEqualToString:type]) + return [NSData data]; // Return something, anything. + return nil; +} + +// Fake a controller for callback ponging + +- (BOOL)dragButton:(BookmarkButton*)button to:(NSPoint)point copy:(BOOL)copy { + dragButtonToPong_ = YES; + return YES; +} + +- (BOOL)addURLs:(NSArray*)urls withTitles:(NSArray*)titles at:(NSPoint)point { + dragURLsPong_ = YES; + return YES; +} + +- (void)getURLs:(NSArray**)outUrls + andTitles:(NSArray**)outTitles + convertingFilenames:(BOOL)convertFilenames { +} + +- (BOOL)dragBookmarkData:(id<NSDraggingInfo>)info { + dragBookmarkDataPong_ = YES; + return NO; +} + +// Confirm the pongs. + +- (BOOL)dragButtonToPong { + return dragButtonToPong_; +} + +- (BOOL)dragURLsPong { + return dragURLsPong_; +} + +- (BOOL)dragBookmarkDataPong { + return dragBookmarkDataPong_; +} + +- (CGFloat)indicatorPosForDragToPoint:(NSPoint)point { + return kFakeIndicatorPos; +} + +- (BOOL)shouldShowIndicatorShownForPoint:(NSPoint)point { + return dropIndicatorShown_; +} + +- (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)info { + draggingEnteredCalled_ = YES; + return NSDragOperationNone; +} + +@end + +namespace { + +class BookmarkBarViewTest : public CocoaTest { + public: + virtual void SetUp() { + CocoaTest::SetUp(); + view_.reset([[BookmarkBarView alloc] init]); + } + + scoped_nsobject<BookmarkBarView> view_; +}; + +TEST_F(BookmarkBarViewTest, CanDragWindow) { + EXPECT_FALSE([view_ mouseDownCanMoveWindow]); +} + +TEST_F(BookmarkBarViewTest, BookmarkButtonDragAndDrop) { + scoped_nsobject<FakeBookmarkDraggingInfo> + info([[FakeBookmarkDraggingInfo alloc] init]); + [view_ setController:info.get()]; + [info reset]; + + [info setDragDataType:kBookmarkButtonDragType]; + EXPECT_EQ([view_ draggingEntered:(id)info.get()], NSDragOperationMove); + EXPECT_TRUE([view_ performDragOperation:(id)info.get()]); + EXPECT_TRUE([info dragButtonToPong]); + EXPECT_FALSE([info dragURLsPong]); + EXPECT_TRUE([info dragBookmarkDataPong]); +} + +TEST_F(BookmarkBarViewTest, URLDragAndDrop) { + scoped_nsobject<FakeBookmarkDraggingInfo> + info([[FakeBookmarkDraggingInfo alloc] init]); + [view_ setController:info.get()]; + [info reset]; + + NSArray* dragTypes = [URLDropTargetHandler handledDragTypes]; + for (NSString* type in dragTypes) { + [info setDragDataType:type]; + EXPECT_EQ([view_ draggingEntered:(id)info.get()], NSDragOperationMove); + EXPECT_TRUE([view_ performDragOperation:(id)info.get()]); + EXPECT_FALSE([info dragButtonToPong]); + EXPECT_TRUE([info dragURLsPong]); + EXPECT_TRUE([info dragBookmarkDataPong]); + [info reset]; + } +} + +TEST_F(BookmarkBarViewTest, BookmarkButtonDropIndicator) { + scoped_nsobject<FakeBookmarkDraggingInfo> + info([[FakeBookmarkDraggingInfo alloc] init]); + [view_ setController:info.get()]; + + [info reset]; + [info setDragDataType:kBookmarkButtonDragType]; + EXPECT_FALSE([info draggingEnteredCalled]); + EXPECT_EQ([view_ draggingEntered:(id)info.get()], NSDragOperationMove); + EXPECT_TRUE([info draggingEnteredCalled]); // Ensure controller pinged. + EXPECT_TRUE([view_ dropIndicatorShown]); + EXPECT_EQ([view_ dropIndicatorPosition], kFakeIndicatorPos); + + [info setDropIndicatorShown:NO]; + EXPECT_EQ([view_ draggingEntered:(id)info.get()], NSDragOperationMove); + EXPECT_FALSE([view_ dropIndicatorShown]); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_bubble_controller.h b/chrome/browser/ui/cocoa/bookmarks/bookmark_bubble_controller.h new file mode 100644 index 0000000..fc2840d --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_bubble_controller.h @@ -0,0 +1,81 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#import "base/cocoa_protocols_mac.h" +#include "base/scoped_ptr.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_model_observer_for_cocoa.h" + +class BookmarkBubbleNotificationBridge; +class BookmarkModel; +class BookmarkNode; +@class BookmarkBubbleController; +@class InfoBubbleView; + + +// Controller for the bookmark bubble. The bookmark bubble is a +// bubble that pops up when clicking on the STAR next to the URL to +// add or remove it as a bookmark. This bubble allows for editing of +// the bookmark in various ways (name, folder, etc.) +@interface BookmarkBubbleController : NSWindowController<NSWindowDelegate> { + @private + NSWindow* parentWindow_; // weak + + // Both weak; owned by the current browser's profile + BookmarkModel* model_; // weak + const BookmarkNode* node_; // weak + + // The bookmark node whose button we asked to pulse. + const BookmarkNode* pulsingBookmarkNode_; // weak + + BOOL alreadyBookmarked_; + + // Ping me when the bookmark model changes out from under us. + scoped_ptr<BookmarkModelObserverForCocoa> bookmark_observer_; + + // Ping me when other Chrome things change out from under us. + scoped_ptr<BookmarkBubbleNotificationBridge> chrome_observer_; + + IBOutlet NSTextField* bigTitle_; // "Bookmark" or "Bookmark Added!" + IBOutlet NSTextField* nameTextField_; + IBOutlet NSPopUpButton* folderPopUpButton_; + IBOutlet InfoBubbleView* bubble_; // to set arrow position +} + +@property (readonly, nonatomic) const BookmarkNode* node; + +// |node| is the bookmark node we edit in this bubble. +// |alreadyBookmarked| tells us if the node was bookmarked before the +// user clicked on the star. (if NO, this is a brand new bookmark). +// The owner of this object is responsible for showing the bubble if +// it desires it to be visible on the screen. It is not shown by the +// init routine. Closing of the window happens implicitly on dealloc. +- (id)initWithParentWindow:(NSWindow*)parentWindow + model:(BookmarkModel*)model + node:(const BookmarkNode*)node + alreadyBookmarked:(BOOL)alreadyBookmarked; + +// Actions for buttons in the dialog. +- (IBAction)ok:(id)sender; +- (IBAction)remove:(id)sender; +- (IBAction)cancel:(id)sender; + +// These actions send a -editBookmarkNode: action up the responder chain. +- (IBAction)edit:(id)sender; +- (IBAction)folderChanged:(id)sender; + +@end + + +// Exposed only for unit testing. +@interface BookmarkBubbleController(ExposedForUnitTesting) +- (void)addFolderNodes:(const BookmarkNode*)parent + toPopUpButton:(NSPopUpButton*)button + indentation:(int)indentation; +- (void)setTitle:(NSString*)title parentFolder:(const BookmarkNode*)parent; +- (void)setParentFolderSelection:(const BookmarkNode*)parent; ++ (NSString*)chooseAnotherFolderString; +- (NSPopUpButton*)folderPopUpButton; +@end diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_bubble_controller.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_bubble_controller.mm new file mode 100644 index 0000000..ae66081 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_bubble_controller.mm @@ -0,0 +1,428 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bubble_controller.h" + +#include "app/l10n_util_mac.h" +#include "base/mac_util.h" +#include "base/sys_string_conversions.h" +#include "base/utf_string_conversions.h" // TODO(viettrungluu): remove +#include "chrome/browser/bookmarks/bookmark_model.h" +#include "chrome/browser/metrics/user_metrics.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_button.h" +#import "chrome/browser/ui/cocoa/browser_window_controller.h" +#import "chrome/browser/ui/cocoa/info_bubble_view.h" +#include "chrome/common/notification_observer.h" +#include "chrome/common/notification_registrar.h" +#include "chrome/common/notification_service.h" +#include "grit/generated_resources.h" + + +// Simple class to watch for tab creation/destruction and close the bubble. +// Bridge between Chrome-style notifications and ObjC-style notifications. +class BookmarkBubbleNotificationBridge : public NotificationObserver { + public: + BookmarkBubbleNotificationBridge(BookmarkBubbleController* controller, + SEL selector); + virtual ~BookmarkBubbleNotificationBridge() {} + void Observe(NotificationType type, + const NotificationSource& source, + const NotificationDetails& details); + private: + NotificationRegistrar registrar_; + BookmarkBubbleController* controller_; // weak; owns us. + SEL selector_; // SEL sent to controller_ on notification. +}; + +BookmarkBubbleNotificationBridge::BookmarkBubbleNotificationBridge( + BookmarkBubbleController* controller, SEL selector) + : controller_(controller), selector_(selector) { + // registrar_ will automatically RemoveAll() when destroyed so we + // don't need to do so explicitly. + registrar_.Add(this, NotificationType::TAB_CONTENTS_CONNECTED, + NotificationService::AllSources()); + registrar_.Add(this, NotificationType::TAB_CLOSED, + NotificationService::AllSources()); +} + +// At this time all notifications instigate the same behavior (go +// away) so we don't bother checking which notification came in. +void BookmarkBubbleNotificationBridge::Observe( + NotificationType type, + const NotificationSource& source, + const NotificationDetails& details) { + [controller_ performSelector:selector_ withObject:controller_]; +} + + +// An object to represent the ChooseAnotherFolder item in the pop up. +@interface ChooseAnotherFolder : NSObject +@end + +@implementation ChooseAnotherFolder +@end + +@interface BookmarkBubbleController (PrivateAPI) +- (void)updateBookmarkNode; +- (void)fillInFolderList; +- (void)parentWindowWillClose:(NSNotification*)notification; +@end + +@implementation BookmarkBubbleController + +@synthesize node = node_; + ++ (id)chooseAnotherFolderObject { + // Singleton object to act as a representedObject for the "choose another + // folder" item in the pop up. + static ChooseAnotherFolder* object = nil; + if (!object) { + object = [[ChooseAnotherFolder alloc] init]; + } + return object; +} + +- (id)initWithParentWindow:(NSWindow*)parentWindow + model:(BookmarkModel*)model + node:(const BookmarkNode*)node + alreadyBookmarked:(BOOL)alreadyBookmarked { + NSString* nibPath = + [mac_util::MainAppBundle() pathForResource:@"BookmarkBubble" + ofType:@"nib"]; + if ((self = [super initWithWindowNibPath:nibPath owner:self])) { + parentWindow_ = parentWindow; + model_ = model; + node_ = node; + alreadyBookmarked_ = alreadyBookmarked; + + // Watch to see if the parent window closes, and if so, close this one. + NSNotificationCenter* center = [NSNotificationCenter defaultCenter]; + [center addObserver:self + selector:@selector(parentWindowWillClose:) + name:NSWindowWillCloseNotification + object:parentWindow_]; + } + return self; +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [super dealloc]; +} + +// If this is a new bookmark somewhere visible (e.g. on the bookmark +// bar), pulse it. Else, call ourself recursively with our parent +// until we find something visible to pulse. +- (void)startPulsingBookmarkButton:(const BookmarkNode*)node { + while (node) { + if ((node->GetParent() == model_->GetBookmarkBarNode()) || + (node == model_->other_node())) { + pulsingBookmarkNode_ = node; + NSValue *value = [NSValue valueWithPointer:node]; + NSDictionary *dict = [NSDictionary + dictionaryWithObjectsAndKeys:value, + bookmark_button::kBookmarkKey, + [NSNumber numberWithBool:YES], + bookmark_button::kBookmarkPulseFlagKey, + nil]; + [[NSNotificationCenter defaultCenter] + postNotificationName:bookmark_button::kPulseBookmarkButtonNotification + object:self + userInfo:dict]; + return; + } + node = node->GetParent(); + } +} + +- (void)stopPulsingBookmarkButton { + if (!pulsingBookmarkNode_) + return; + NSValue *value = [NSValue valueWithPointer:pulsingBookmarkNode_]; + pulsingBookmarkNode_ = NULL; + NSDictionary *dict = [NSDictionary + dictionaryWithObjectsAndKeys:value, + bookmark_button::kBookmarkKey, + [NSNumber numberWithBool:NO], + bookmark_button::kBookmarkPulseFlagKey, + nil]; + [[NSNotificationCenter defaultCenter] + postNotificationName:bookmark_button::kPulseBookmarkButtonNotification + object:self + userInfo:dict]; +} + +// Close the bookmark bubble without changing anything. Unlike a +// typical dialog's OK/Cancel, where Cancel is "do nothing", all +// buttons on the bubble have the capacity to change the bookmark +// model. This is an IBOutlet-looking entry point to remove the +// dialog without touching the model. +- (void)dismissWithoutEditing:(id)sender { + [self close]; +} + +- (void)parentWindowWillClose:(NSNotification*)notification { + [self close]; +} + +- (void)windowWillClose:(NSNotification*)notification { + // We caught a close so we don't need to watch for the parent closing. + [[NSNotificationCenter defaultCenter] removeObserver:self]; + bookmark_observer_.reset(NULL); + chrome_observer_.reset(NULL); + [self stopPulsingBookmarkButton]; + [self autorelease]; +} + +// We want this to be a child of a browser window. addChildWindow: +// (called from this function) will bring the window on-screen; +// unfortunately, [NSWindowController showWindow:] will also bring it +// on-screen (but will cause unexpected changes to the window's +// position). We cannot have an addChildWindow: and a subsequent +// showWindow:. Thus, we have our own version. +- (void)showWindow:(id)sender { + BrowserWindowController* bwc = + [BrowserWindowController browserWindowControllerForWindow:parentWindow_]; + [bwc lockBarVisibilityForOwner:self withAnimation:NO delay:NO]; + NSWindow* window = [self window]; // completes nib load + [bubble_ setArrowLocation:info_bubble::kTopRight]; + // Insure decent positioning even in the absence of a browser controller, + // which will occur for some unit tests. + NSPoint arrowtip = bwc ? [bwc bookmarkBubblePoint] : + NSMakePoint([window frame].size.width, [window frame].size.height); + NSPoint origin = [parentWindow_ convertBaseToScreen:arrowtip]; + NSPoint bubbleArrowtip = [bubble_ arrowTip]; + bubbleArrowtip = [bubble_ convertPoint:bubbleArrowtip toView:nil]; + origin.y -= bubbleArrowtip.y; + origin.x -= bubbleArrowtip.x; + [window setFrameOrigin:origin]; + [parentWindow_ addChildWindow:window ordered:NSWindowAbove]; + // Default is IDS_BOOMARK_BUBBLE_PAGE_BOOKMARK; "Bookmark". + // If adding for the 1st time the string becomes "Bookmark Added!" + if (!alreadyBookmarked_) { + NSString* title = + l10n_util::GetNSString(IDS_BOOMARK_BUBBLE_PAGE_BOOKMARKED); + [bigTitle_ setStringValue:title]; + } + + [self fillInFolderList]; + + // Ping me when things change out from under us. Unlike a normal + // dialog, the bookmark bubble's cancel: means "don't add this as a + // bookmark", not "cancel editing". We must take extra care to not + // touch the bookmark in this selector. + bookmark_observer_.reset(new BookmarkModelObserverForCocoa( + node_, model_, + self, + @selector(dismissWithoutEditing:))); + chrome_observer_.reset(new BookmarkBubbleNotificationBridge( + self, @selector(dismissWithoutEditing:))); + + // Pulse something interesting on the bookmark bar. + [self startPulsingBookmarkButton:node_]; + + [window makeKeyAndOrderFront:self]; +} + +- (void)close { + [[BrowserWindowController browserWindowControllerForWindow:parentWindow_] + releaseBarVisibilityForOwner:self withAnimation:YES delay:NO]; + [parentWindow_ removeChildWindow:[self window]]; + + // If you quit while the bubble is open, sometimes we get a + // DidResignKey before we get our parent's WindowWillClose and + // sometimes not. We protect against a multiple close (or reference + // to parentWindow_ at a bad time) by clearing it out once we're + // done, and by removing ourself from future notifications. + [[NSNotificationCenter defaultCenter] + removeObserver:self + name:NSWindowWillCloseNotification + object:parentWindow_]; + parentWindow_ = nil; + + [super close]; +} + +// Shows the bookmark editor sheet for more advanced editing. +- (void)showEditor { + [self ok:self]; + // Send the action up through the responder chain. + [NSApp sendAction:@selector(editBookmarkNode:) to:nil from:self]; +} + +- (IBAction)edit:(id)sender { + UserMetrics::RecordAction(UserMetricsAction("BookmarkBubble_Edit"), + model_->profile()); + [self showEditor]; +} + +- (IBAction)ok:(id)sender { + [self stopPulsingBookmarkButton]; // before parent changes + [self updateBookmarkNode]; + [self close]; +} + +// By implementing this, ESC causes the window to go away. If clicking the +// star was what prompted this bubble to appear (i.e., not already bookmarked), +// remove the bookmark. +- (IBAction)cancel:(id)sender { + if (!alreadyBookmarked_) { + // |-remove:| calls |-close| so don't do it. + [self remove:sender]; + } else { + [self ok:sender]; + } +} + +- (IBAction)remove:(id)sender { + [self stopPulsingBookmarkButton]; + // TODO(viettrungluu): get rid of conversion and utf_string_conversions.h. + model_->SetURLStarred(node_->GetURL(), node_->GetTitle(), false); + UserMetrics::RecordAction(UserMetricsAction("BookmarkBubble_Unstar"), + model_->profile()); + node_ = NULL; // no longer valid + [self ok:sender]; +} + +// The controller is the target of the pop up button box action so it can +// handle when "choose another folder" was picked. +- (IBAction)folderChanged:(id)sender { + DCHECK([sender isEqual:folderPopUpButton_]); + // It is possible that due to model change our parent window has been closed + // but the popup is still showing and able to notify the controller of a + // folder change. We ignore the sender in this case. + if (!parentWindow_) + return; + NSMenuItem* selected = [folderPopUpButton_ selectedItem]; + ChooseAnotherFolder* chooseItem = [[self class] chooseAnotherFolderObject]; + if ([[selected representedObject] isEqual:chooseItem]) { + UserMetrics::RecordAction( + UserMetricsAction("BookmarkBubble_EditFromCombobox"), + model_->profile()); + [self showEditor]; + } +} + +// The controller is the delegate of the window so it receives did resign key +// notifications. When key is resigned mirror Windows behavior and close the +// window. +- (void)windowDidResignKey:(NSNotification*)notification { + NSWindow* window = [self window]; + DCHECK_EQ([notification object], window); + if ([window isVisible]) { + // If the window isn't visible, it is already closed, and this notification + // has been sent as part of the closing operation, so no need to close. + [self ok:self]; + } +} + +// Look at the dialog; if the user has changed anything, update the +// bookmark node to reflect this. +- (void)updateBookmarkNode { + if (!node_) return; + + // First the title... + NSString* oldTitle = base::SysUTF16ToNSString(node_->GetTitle()); + NSString* newTitle = [nameTextField_ stringValue]; + if (![oldTitle isEqual:newTitle]) { + model_->SetTitle(node_, base::SysNSStringToUTF16(newTitle)); + UserMetrics::RecordAction( + UserMetricsAction("BookmarkBubble_ChangeTitleInBubble"), + model_->profile()); + } + // Then the parent folder. + const BookmarkNode* oldParent = node_->GetParent(); + NSMenuItem* selectedItem = [folderPopUpButton_ selectedItem]; + id representedObject = [selectedItem representedObject]; + if ([representedObject isEqual:[[self class] chooseAnotherFolderObject]]) { + // "Choose another folder..." + return; + } + const BookmarkNode* newParent = + static_cast<const BookmarkNode*>([representedObject pointerValue]); + DCHECK(newParent); + if (oldParent != newParent) { + int index = newParent->GetChildCount(); + model_->Move(node_, newParent, index); + UserMetrics::RecordAction(UserMetricsAction("BookmarkBubble_ChangeParent"), + model_->profile()); + } +} + +// Fill in all information related to the folder pop up button. +- (void)fillInFolderList { + [nameTextField_ setStringValue:base::SysUTF16ToNSString(node_->GetTitle())]; + DCHECK([folderPopUpButton_ numberOfItems] == 0); + [self addFolderNodes:model_->root_node() + toPopUpButton:folderPopUpButton_ + indentation:0]; + NSMenu* menu = [folderPopUpButton_ menu]; + NSString* title = [[self class] chooseAnotherFolderString]; + NSMenuItem *item = [menu addItemWithTitle:title + action:NULL + keyEquivalent:@""]; + ChooseAnotherFolder* obj = [[self class] chooseAnotherFolderObject]; + [item setRepresentedObject:obj]; + // Finally, select the current parent. + NSValue* parentValue = [NSValue valueWithPointer:node_->GetParent()]; + NSInteger idx = [menu indexOfItemWithRepresentedObject:parentValue]; + [folderPopUpButton_ selectItemAtIndex:idx]; +} + +@end // BookmarkBubbleController + + +@implementation BookmarkBubbleController(ExposedForUnitTesting) + ++ (NSString*)chooseAnotherFolderString { + return l10n_util::GetNSStringWithFixup( + IDS_BOOMARK_BUBBLE_CHOOSER_ANOTHER_FOLDER); +} + +// For the given folder node, walk the tree and add folder names to +// the given pop up button. +- (void)addFolderNodes:(const BookmarkNode*)parent + toPopUpButton:(NSPopUpButton*)button + indentation:(int)indentation { + if (!model_->is_root(parent)) { + NSString* title = base::SysUTF16ToNSString(parent->GetTitle()); + NSMenu* menu = [button menu]; + NSMenuItem* item = [menu addItemWithTitle:title + action:NULL + keyEquivalent:@""]; + [item setRepresentedObject:[NSValue valueWithPointer:parent]]; + [item setIndentationLevel:indentation]; + ++indentation; + } + for (int i = 0; i < parent->GetChildCount(); i++) { + const BookmarkNode* child = parent->GetChild(i); + if (child->is_folder()) + [self addFolderNodes:child + toPopUpButton:button + indentation:indentation]; + } +} + +- (void)setTitle:(NSString*)title parentFolder:(const BookmarkNode*)parent { + [nameTextField_ setStringValue:title]; + [self setParentFolderSelection:parent]; +} + +// Pick a specific parent node in the selection by finding the right +// pop up button index. +- (void)setParentFolderSelection:(const BookmarkNode*)parent { + // Expectation: There is a parent mapping for all items in the + // folderPopUpButton except the last one ("Choose another folder..."). + NSMenu* menu = [folderPopUpButton_ menu]; + NSValue* parentValue = [NSValue valueWithPointer:parent]; + NSInteger idx = [menu indexOfItemWithRepresentedObject:parentValue]; + DCHECK(idx != -1); + [folderPopUpButton_ selectItemAtIndex:idx]; +} + +- (NSPopUpButton*)folderPopUpButton { + return folderPopUpButton_; +} + +@end // implementation BookmarkBubbleController(ExposedForUnitTesting) diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_bubble_controller_unittest.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_bubble_controller_unittest.mm new file mode 100644 index 0000000..ef3a47a --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_bubble_controller_unittest.mm @@ -0,0 +1,490 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/basictypes.h" +#include "base/scoped_nsobject.h" +#include "base/utf_string_conversions.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bubble_controller.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#include "chrome/browser/ui/cocoa/browser_window_controller.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/info_bubble_window.h" +#include "chrome/common/notification_service.h" +#include "testing/gtest/include/gtest/gtest.h" +#import "testing/gtest_mac.h" +#include "testing/platform_test.h" + +// Watch for bookmark pulse notifications so we can confirm they were sent. +@interface BookmarkPulseObserver : NSObject { + int notifications_; +} +@property (assign, nonatomic) int notifications; +@end + + +@implementation BookmarkPulseObserver + +@synthesize notifications = notifications_; + +- (id)init { + if ((self = [super init])) { + [[NSNotificationCenter defaultCenter] + addObserver:self + selector:@selector(pulseBookmarkNotification:) + name:bookmark_button::kPulseBookmarkButtonNotification + object:nil]; + } + return self; +} + +- (void)pulseBookmarkNotification:(NSNotificationCenter *)notification { + notifications_++; +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [super dealloc]; +} + +@end + + +namespace { + +class BookmarkBubbleControllerTest : public CocoaTest { + public: + static int edits_; + BrowserTestHelper helper_; + BookmarkBubbleController* controller_; + + BookmarkBubbleControllerTest() : controller_(nil) { + edits_ = 0; + } + + virtual void TearDown() { + [controller_ close]; + CocoaTest::TearDown(); + } + + // Returns a controller but ownership not transferred. + // Only one of these will be valid at a time. + BookmarkBubbleController* ControllerForNode(const BookmarkNode* node) { + if (controller_ && !IsWindowClosing()) { + [controller_ close]; + controller_ = nil; + } + controller_ = [[BookmarkBubbleController alloc] + initWithParentWindow:test_window() + model:helper_.profile()->GetBookmarkModel() + node:node + alreadyBookmarked:YES]; + EXPECT_TRUE([controller_ window]); + // The window must be gone or we'll fail a unit test with windows left open. + [static_cast<InfoBubbleWindow*>([controller_ window]) setDelayOnClose:NO]; + [controller_ showWindow:nil]; + return controller_; + } + + BookmarkModel* GetBookmarkModel() { + return helper_.profile()->GetBookmarkModel(); + } + + bool IsWindowClosing() { + return [static_cast<InfoBubbleWindow*>([controller_ window]) isClosing]; + } +}; + +// static +int BookmarkBubbleControllerTest::edits_; + +// Confirm basics about the bubble window (e.g. that it is inside the +// parent window) +TEST_F(BookmarkBubbleControllerTest, TestBubbleWindow) { + BookmarkModel* model = GetBookmarkModel(); + const BookmarkNode* node = model->AddURL(model->GetBookmarkBarNode(), + 0, + ASCIIToUTF16("Bookie markie title"), + GURL("http://www.google.com")); + BookmarkBubbleController* controller = ControllerForNode(node); + EXPECT_TRUE(controller); + NSWindow* window = [controller window]; + EXPECT_TRUE(window); + EXPECT_TRUE(NSContainsRect([test_window() frame], + [window frame])); +} + +// Test that we can handle closing the parent window +TEST_F(BookmarkBubbleControllerTest, TestClosingParentWindow) { + BookmarkModel* model = GetBookmarkModel(); + const BookmarkNode* node = model->AddURL(model->GetBookmarkBarNode(), + 0, + ASCIIToUTF16("Bookie markie title"), + GURL("http://www.google.com")); + BookmarkBubbleController* controller = ControllerForNode(node); + EXPECT_TRUE(controller); + NSWindow* window = [controller window]; + EXPECT_TRUE(window); + base::mac::ScopedNSAutoreleasePool pool; + [test_window() performClose:NSApp]; +} + + +// Confirm population of folder list +TEST_F(BookmarkBubbleControllerTest, TestFillInFolder) { + // Create some folders, including a nested folder + BookmarkModel* model = GetBookmarkModel(); + EXPECT_TRUE(model); + const BookmarkNode* bookmarkBarNode = model->GetBookmarkBarNode(); + EXPECT_TRUE(bookmarkBarNode); + const BookmarkNode* node1 = model->AddGroup(bookmarkBarNode, 0, + ASCIIToUTF16("one")); + EXPECT_TRUE(node1); + const BookmarkNode* node2 = model->AddGroup(bookmarkBarNode, 1, + ASCIIToUTF16("two")); + EXPECT_TRUE(node2); + const BookmarkNode* node3 = model->AddGroup(bookmarkBarNode, 2, + ASCIIToUTF16("three")); + EXPECT_TRUE(node3); + const BookmarkNode* node4 = model->AddGroup(node2, 0, ASCIIToUTF16("sub")); + EXPECT_TRUE(node4); + const BookmarkNode* node5 = model->AddURL(node1, 0, ASCIIToUTF16("title1"), + GURL("http://www.google.com")); + EXPECT_TRUE(node5); + const BookmarkNode* node6 = model->AddURL(node3, 0, ASCIIToUTF16("title2"), + GURL("http://www.google.com")); + EXPECT_TRUE(node6); + const BookmarkNode* node7 = model->AddURL(node4, 0, ASCIIToUTF16("title3"), + GURL("http://www.google.com/reader")); + EXPECT_TRUE(node7); + + BookmarkBubbleController* controller = ControllerForNode(node4); + EXPECT_TRUE(controller); + + NSArray* titles = + [[[controller folderPopUpButton] itemArray] valueForKey:@"title"]; + EXPECT_TRUE([titles containsObject:@"one"]); + EXPECT_TRUE([titles containsObject:@"two"]); + EXPECT_TRUE([titles containsObject:@"three"]); + EXPECT_TRUE([titles containsObject:@"sub"]); + EXPECT_FALSE([titles containsObject:@"title1"]); + EXPECT_FALSE([titles containsObject:@"title2"]); +} + +// Confirm ability to handle folders with blank name. +TEST_F(BookmarkBubbleControllerTest, TestFolderWithBlankName) { + // Create some folders, including a nested folder + BookmarkModel* model = GetBookmarkModel(); + EXPECT_TRUE(model); + const BookmarkNode* bookmarkBarNode = model->GetBookmarkBarNode(); + EXPECT_TRUE(bookmarkBarNode); + const BookmarkNode* node1 = model->AddGroup(bookmarkBarNode, 0, + ASCIIToUTF16("one")); + EXPECT_TRUE(node1); + const BookmarkNode* node2 = model->AddGroup(bookmarkBarNode, 1, + ASCIIToUTF16("")); + EXPECT_TRUE(node2); + const BookmarkNode* node3 = model->AddGroup(bookmarkBarNode, 2, + ASCIIToUTF16("three")); + EXPECT_TRUE(node3); + const BookmarkNode* node2_1 = model->AddURL(node2, 0, ASCIIToUTF16("title1"), + GURL("http://www.google.com")); + EXPECT_TRUE(node2_1); + + BookmarkBubbleController* controller = ControllerForNode(node1); + EXPECT_TRUE(controller); + + // One of the items should be blank and its node should be node2. + NSArray* items = [[controller folderPopUpButton] itemArray]; + EXPECT_GT([items count], 4U); + BOOL blankFolderFound = NO; + for (NSMenuItem* item in [[controller folderPopUpButton] itemArray]) { + if ([[item title] length] == 0 && + static_cast<const BookmarkNode*>([[item representedObject] + pointerValue]) == node2) { + blankFolderFound = YES; + break; + } + } + EXPECT_TRUE(blankFolderFound); +} + + +// Click on edit; bubble gets closed. +TEST_F(BookmarkBubbleControllerTest, TestEdit) { + BookmarkModel* model = GetBookmarkModel(); + const BookmarkNode* node = model->AddURL(model->GetBookmarkBarNode(), + 0, + ASCIIToUTF16("Bookie markie title"), + GURL("http://www.google.com")); + BookmarkBubbleController* controller = ControllerForNode(node); + EXPECT_TRUE(controller); + + EXPECT_EQ(edits_, 0); + EXPECT_FALSE(IsWindowClosing()); + [controller edit:controller]; + EXPECT_EQ(edits_, 1); + EXPECT_TRUE(IsWindowClosing()); +} + +// CallClose; bubble gets closed. +// Also confirm pulse notifications get sent. +TEST_F(BookmarkBubbleControllerTest, TestClose) { + BookmarkModel* model = GetBookmarkModel(); + const BookmarkNode* node = model->AddURL( + model->GetBookmarkBarNode(), 0, ASCIIToUTF16("Bookie markie title"), + GURL("http://www.google.com")); + EXPECT_EQ(edits_, 0); + + scoped_nsobject<BookmarkPulseObserver> observer([[BookmarkPulseObserver alloc] + init]); + EXPECT_EQ([observer notifications], 0); + BookmarkBubbleController* controller = ControllerForNode(node); + EXPECT_TRUE(controller); + EXPECT_FALSE(IsWindowClosing()); + EXPECT_EQ([observer notifications], 1); + [controller ok:controller]; + EXPECT_EQ(edits_, 0); + EXPECT_TRUE(IsWindowClosing()); + EXPECT_EQ([observer notifications], 2); +} + +// User changes title and parent folder in the UI +TEST_F(BookmarkBubbleControllerTest, TestUserEdit) { + BookmarkModel* model = GetBookmarkModel(); + EXPECT_TRUE(model); + const BookmarkNode* bookmarkBarNode = model->GetBookmarkBarNode(); + EXPECT_TRUE(bookmarkBarNode); + const BookmarkNode* node = model->AddURL(bookmarkBarNode, + 0, + ASCIIToUTF16("short-title"), + GURL("http://www.google.com")); + const BookmarkNode* grandma = model->AddGroup(bookmarkBarNode, 0, + ASCIIToUTF16("grandma")); + EXPECT_TRUE(grandma); + const BookmarkNode* grandpa = model->AddGroup(bookmarkBarNode, 0, + ASCIIToUTF16("grandpa")); + EXPECT_TRUE(grandpa); + + BookmarkBubbleController* controller = ControllerForNode(node); + EXPECT_TRUE(controller); + + // simulate a user edit + [controller setTitle:@"oops" parentFolder:grandma]; + [controller edit:controller]; + + // Make sure bookmark has changed + EXPECT_EQ(node->GetTitle(), ASCIIToUTF16("oops")); + EXPECT_EQ(node->GetParent()->GetTitle(), ASCIIToUTF16("grandma")); +} + +// Confirm happiness with parent nodes that have the same name. +TEST_F(BookmarkBubbleControllerTest, TestNewParentSameName) { + BookmarkModel* model = GetBookmarkModel(); + EXPECT_TRUE(model); + const BookmarkNode* bookmarkBarNode = model->GetBookmarkBarNode(); + EXPECT_TRUE(bookmarkBarNode); + for (int i=0; i<2; i++) { + const BookmarkNode* node = model->AddURL(bookmarkBarNode, + 0, + ASCIIToUTF16("short-title"), + GURL("http://www.google.com")); + EXPECT_TRUE(node); + const BookmarkNode* group = model->AddGroup(bookmarkBarNode, 0, + ASCIIToUTF16("NAME")); + EXPECT_TRUE(group); + group = model->AddGroup(bookmarkBarNode, 0, ASCIIToUTF16("NAME")); + EXPECT_TRUE(group); + group = model->AddGroup(bookmarkBarNode, 0, ASCIIToUTF16("NAME")); + EXPECT_TRUE(group); + BookmarkBubbleController* controller = ControllerForNode(node); + EXPECT_TRUE(controller); + + // simulate a user edit + [controller setParentFolderSelection:bookmarkBarNode->GetChild(i)]; + [controller edit:controller]; + + // Make sure bookmark has changed, and that the parent is what we + // expect. This proves nobody did searching based on name. + EXPECT_EQ(node->GetParent(), bookmarkBarNode->GetChild(i)); + } +} + +// Confirm happiness with nodes with the same Name +TEST_F(BookmarkBubbleControllerTest, TestDuplicateNodeNames) { + BookmarkModel* model = GetBookmarkModel(); + const BookmarkNode* bookmarkBarNode = model->GetBookmarkBarNode(); + EXPECT_TRUE(bookmarkBarNode); + const BookmarkNode* node1 = model->AddGroup(bookmarkBarNode, 0, + ASCIIToUTF16("NAME")); + EXPECT_TRUE(node1); + const BookmarkNode* node2 = model->AddGroup(bookmarkBarNode, 0, + ASCIIToUTF16("NAME")); + EXPECT_TRUE(node2); + BookmarkBubbleController* controller = ControllerForNode(bookmarkBarNode); + EXPECT_TRUE(controller); + + NSPopUpButton* button = [controller folderPopUpButton]; + [controller setParentFolderSelection:node1]; + NSMenuItem* item = [button selectedItem]; + id itemObject = [item representedObject]; + EXPECT_NSEQ([NSValue valueWithPointer:node1], itemObject); + [controller setParentFolderSelection:node2]; + item = [button selectedItem]; + itemObject = [item representedObject]; + EXPECT_NSEQ([NSValue valueWithPointer:node2], itemObject); +} + +// Click the "remove" button +TEST_F(BookmarkBubbleControllerTest, TestRemove) { + BookmarkModel* model = GetBookmarkModel(); + GURL gurl("http://www.google.com"); + const BookmarkNode* node = model->AddURL(model->GetBookmarkBarNode(), + 0, + ASCIIToUTF16("Bookie markie title"), + gurl); + BookmarkBubbleController* controller = ControllerForNode(node); + EXPECT_TRUE(controller); + EXPECT_TRUE(model->IsBookmarked(gurl)); + + [controller remove:controller]; + EXPECT_FALSE(model->IsBookmarked(gurl)); + EXPECT_TRUE(IsWindowClosing()); +} + +// Confirm picking "choose another folder" caused edit: to be called. +TEST_F(BookmarkBubbleControllerTest, PopUpSelectionChanged) { + BookmarkModel* model = GetBookmarkModel(); + GURL gurl("http://www.google.com"); + const BookmarkNode* node = model->AddURL(model->GetBookmarkBarNode(), + 0, ASCIIToUTF16("super-title"), + gurl); + BookmarkBubbleController* controller = ControllerForNode(node); + EXPECT_TRUE(controller); + + NSPopUpButton* button = [controller folderPopUpButton]; + [button selectItemWithTitle:[[controller class] chooseAnotherFolderString]]; + EXPECT_EQ(edits_, 0); + [button sendAction:[button action] to:[button target]]; + EXPECT_EQ(edits_, 1); +} + +// Create a controller that simulates the bookmark just now being created by +// the user clicking the star, then sending the "cancel" command to represent +// them pressing escape. The bookmark should not be there. +TEST_F(BookmarkBubbleControllerTest, EscapeRemovesNewBookmark) { + BookmarkModel* model = GetBookmarkModel(); + GURL gurl("http://www.google.com"); + const BookmarkNode* node = model->AddURL(model->GetBookmarkBarNode(), + 0, + ASCIIToUTF16("Bookie markie title"), + gurl); + BookmarkBubbleController* controller = + [[BookmarkBubbleController alloc] + initWithParentWindow:test_window() + model:helper_.profile()->GetBookmarkModel() + node:node + alreadyBookmarked:NO]; // The last param is the key difference. + EXPECT_TRUE([controller window]); + // Calls release on controller. + [controller cancel:nil]; + EXPECT_FALSE(model->IsBookmarked(gurl)); +} + +// Create a controller where the bookmark already existed prior to clicking +// the star and test that sending a cancel command doesn't change the state +// of the bookmark. +TEST_F(BookmarkBubbleControllerTest, EscapeDoesntTouchExistingBookmark) { + BookmarkModel* model = GetBookmarkModel(); + GURL gurl("http://www.google.com"); + const BookmarkNode* node = model->AddURL(model->GetBookmarkBarNode(), + 0, + ASCIIToUTF16("Bookie markie title"), + gurl); + BookmarkBubbleController* controller = ControllerForNode(node); + EXPECT_TRUE(controller); + + [(id)controller cancel:nil]; + EXPECT_TRUE(model->IsBookmarked(gurl)); +} + +// Confirm indentation of items in pop-up menu +TEST_F(BookmarkBubbleControllerTest, TestMenuIndentation) { + // Create some folders, including a nested folder + BookmarkModel* model = GetBookmarkModel(); + EXPECT_TRUE(model); + const BookmarkNode* bookmarkBarNode = model->GetBookmarkBarNode(); + EXPECT_TRUE(bookmarkBarNode); + const BookmarkNode* node1 = model->AddGroup(bookmarkBarNode, 0, + ASCIIToUTF16("one")); + EXPECT_TRUE(node1); + const BookmarkNode* node2 = model->AddGroup(bookmarkBarNode, 1, + ASCIIToUTF16("two")); + EXPECT_TRUE(node2); + const BookmarkNode* node2_1 = model->AddGroup(node2, 0, + ASCIIToUTF16("two dot one")); + EXPECT_TRUE(node2_1); + const BookmarkNode* node3 = model->AddGroup(bookmarkBarNode, 2, + ASCIIToUTF16("three")); + EXPECT_TRUE(node3); + + BookmarkBubbleController* controller = ControllerForNode(node1); + EXPECT_TRUE(controller); + + // Compare the menu item indents against expectations. + static const int kExpectedIndent[] = {0, 1, 1, 2, 1, 0}; + NSArray* items = [[controller folderPopUpButton] itemArray]; + ASSERT_GE([items count], 6U); + for(int itemNo = 0; itemNo < 6; itemNo++) { + NSMenuItem* item = [items objectAtIndex:itemNo]; + EXPECT_EQ(kExpectedIndent[itemNo], [item indentationLevel]) + << "Unexpected indent for menu item #" << itemNo; + } +} + +// Confirm bubble goes away when a new tab is created. +TEST_F(BookmarkBubbleControllerTest, BubbleGoesAwayOnNewTab) { + + BookmarkModel* model = GetBookmarkModel(); + const BookmarkNode* node = model->AddURL(model->GetBookmarkBarNode(), + 0, + ASCIIToUTF16("Bookie markie title"), + GURL("http://www.google.com")); + EXPECT_EQ(edits_, 0); + + BookmarkBubbleController* controller = ControllerForNode(node); + EXPECT_TRUE(controller); + EXPECT_FALSE(IsWindowClosing()); + + // We can't actually create a new tab here, e.g. + // helper_.browser()->AddTabWithURL(...); + // Many of our browser objects (Browser, Profile, RequestContext) + // are "just enough" to run tests without being complete. Instead + // we fake the notification that would be triggered by a tab + // creation. + NotificationService::current()->Notify( + NotificationType::TAB_CONTENTS_CONNECTED, + Source<TabContentsDelegate>(NULL), + Details<TabContents>(NULL)); + + // Confirm bubble going bye-bye. + EXPECT_TRUE(IsWindowClosing()); +} + + +} // namespace + +@implementation NSApplication (BookmarkBubbleUnitTest) +// Add handler for the editBookmarkNode: action to NSApp for testing purposes. +// Normally this would be sent up the responder tree correctly, but since +// tests run in the background, key window and main window are never set on +// NSApplication. Adding it to NSApplication directly removes the need for +// worrying about what the current window with focus is. +- (void)editBookmarkNode:(id)sender { + EXPECT_TRUE([sender respondsToSelector:@selector(node)]); + BookmarkBubbleControllerTest::edits_++; +} + +@end diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_button.h b/chrome/browser/ui/cocoa/bookmarks/bookmark_button.h new file mode 100644 index 0000000..0bea5a5e --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_button.h @@ -0,0 +1,243 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> +#include <vector> +#import "chrome/browser/ui/cocoa/draggable_button.h" +#include "webkit/glue/window_open_disposition.h" + +@class BookmarkBarFolderController; +@class BookmarkButton; +struct BookmarkNodeData; +class BookmarkModel; +class BookmarkNode; +@class BrowserWindowController; +class ThemeProvider; + +// Protocol for a BookmarkButton's delegate, responsible for doing +// things on behalf of a bookmark button. +@protocol BookmarkButtonDelegate + +// Fill the given pasteboard with appropriate data when the given button is +// dragged. Since the delegate has no way of providing pasteboard data later, +// all data must actually be put into the pasteboard and not merely promised. +- (void)fillPasteboard:(NSPasteboard*)pboard + forDragOfButton:(BookmarkButton*)button; + +// Bookmark buttons pass mouseEntered: and mouseExited: events to +// their delegate. This allows the delegate to decide (for example) +// which one, if any, should perform a hover-open. +- (void)mouseEnteredButton:(id)button event:(NSEvent*)event; +- (void)mouseExitedButton:(id)button event:(NSEvent*)event; + +// Returns YES if a drag operation should lock the fullscreen overlay bar +// visibility before starting. For example, dragging a bookmark button should +// not lock the overlay if the bookmark bar is currently showing in detached +// mode on the NTP. +- (BOOL)dragShouldLockBarVisibility; + +// Returns the top-level window for this button. +- (NSWindow*)browserWindow; + +// Returns YES if the bookmark button can be dragged to the trash, NO otherwise. +- (BOOL)canDragBookmarkButtonToTrash:(BookmarkButton*)button; + +// This is called after the user has dropped the bookmark button on the trash. +// The delegate can use this event to delete the bookmark. +- (void)didDragBookmarkToTrash:(BookmarkButton*)button; + +@end + + +// Protocol to be implemented by controllers that logically own +// bookmark buttons. The controller may be either an NSViewController +// or NSWindowController. The BookmarkButton doesn't use this +// protocol directly; it is used when BookmarkButton controllers talk +// to each other. +// +// Other than the top level owner (the bookmark bar), all bookmark +// button controllers have a parent controller. +@protocol BookmarkButtonControllerProtocol + +// Close all bookmark folders, walking up the ownership chain. +- (void)closeAllBookmarkFolders; + +// Close just my bookmark folder. +- (void)closeBookmarkFolder:(id)sender; + +// Return the bookmark model for this controller. +- (BookmarkModel*)bookmarkModel; + +// Perform drag enter/exit operations, such as hover-open and hover-close. +- (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)info; +- (void)draggingExited:(id<NSDraggingInfo>)info; + +// Returns YES if a drag operation should lock the fullscreen overlay bar +// visibility before starting. For example, dragging a bookmark button should +// not lock the overlay if the bookmark bar is currently showing in detached +// mode on the NTP. +- (BOOL)dragShouldLockBarVisibility; + +// Perform the actual DnD of a bookmark or bookmark button. + +// |point| is in the base coordinate system of the destination window; +// |it comes from an id<NSDraggingInfo>. |copy| is YES if a copy is to be +// made and inserted into the new location while leaving the bookmark in +// the old location, otherwise move the bookmark by removing from its old +// location and inserting into the new location. +- (BOOL)dragButton:(BookmarkButton*)sourceButton + to:(NSPoint)point + copy:(BOOL)copy; + +// Determine if the pasteboard from |info| has dragging data containing +// bookmark(s) and perform the drag and return YES, otherwise return NO. +- (BOOL)dragBookmarkData:(id<NSDraggingInfo>)info; + +// Determine if the drag pasteboard has any drag data of type +// kBookmarkDictionaryListPboardType and, if so, return those elements +// otherwise return an empty vector. +- (std::vector<const BookmarkNode*>)retrieveBookmarkNodeData; + +// Return YES if we should show the drop indicator, else NO. In some +// cases (e.g. hover open) we don't want to show the drop indicator. +// |point| is in the base coordinate system of the destination window; +// |it comes from an id<NSDraggingInfo>. +- (BOOL)shouldShowIndicatorShownForPoint:(NSPoint)point; + +// The x or y coordinate of (the middle of) the indicator to draw for +// a drag of the source button to the given point (given in window +// coordinates). +// |point| is in the base coordinate system of the destination window; +// |it comes from an id<NSDraggingInfo>. +// TODO(viettrungluu,jrg): instead of this, make buttons move around. +// http://crbug.com/35968 +- (CGFloat)indicatorPosForDragToPoint:(NSPoint)point; + +// Return the theme provider associated with this browser window. +- (ThemeProvider*)themeProvider; + +// Called just before a child folder puts itself on screen. +- (void)childFolderWillShow:(id<BookmarkButtonControllerProtocol>)child; + +// Called just before a child folder closes. +- (void)childFolderWillClose:(id<BookmarkButtonControllerProtocol>)child; + +// Return a controller's folder controller for a subfolder, or nil. +- (BookmarkBarFolderController*)folderController; + +// Add a new folder controller as triggered by the given folder button. +// If there is a current folder controller, close it. +- (void)addNewFolderControllerWithParentButton:(BookmarkButton*)parentButton; + +// Open all of the nodes for the given node with disposition. +- (void)openAll:(const BookmarkNode*)node + disposition:(WindowOpenDisposition)disposition; + +// There are several operations which may affect the contents of a bookmark +// button controller after it has been created, primary of which are +// cut/paste/delete and drag/drop. Such changes may involve coordinating +// the bookmark button contents of two controllers (such as when a bookmark is +// dragged from one folder to another). The bookmark bar controller +// coordinates in response to notifications propogated by the bookmark model +// through BookmarkBarBridge calls. The following three functions are +// implemented by the controllers and are dispatched by the bookmark bar +// controller in response to notifications coming in from the BookmarkBarBridge. + +// Add a button for the given node to the bar or folder menu. This is safe +// to call when a folder menu window is open as that window will be updated. +// And index of -1 means to append to the end (bottom). +- (void)addButtonForNode:(const BookmarkNode*)node + atIndex:(NSInteger)buttonIndex; + +// Given a list or |urls| and |titles|, create new bookmark nodes and add +// them to the bookmark model such that they will be 1) added to the folder +// represented by the button at |point| if it is a folder, or 2) inserted +// into the parent of the non-folder bookmark at |point| in front of that +// button. Returns YES if at least one bookmark was added. +// TODO(mrossetti): Change function to use a pair-like structure for +// URLs and titles. http://crbug.com/44411 +- (BOOL)addURLs:(NSArray*)urls withTitles:(NSArray*)titles at:(NSPoint)point; + +// Move a button from one place in the menu to another. This is safe +// to call when a folder menu window is open as that window will be updated. +- (void)moveButtonFromIndex:(NSInteger)fromIndex toIndex:(NSInteger)toIndex; + +// Remove the bookmark button at the given index. Show the poof animation +// if |animate:| is YES. It may be obvious, but this is safe +// to call when a folder menu window is open as that window will be updated. +- (void)removeButton:(NSInteger)buttonIndex animate:(BOOL)poof; + +// Determine the controller containing the button representing |node|, if any. +- (id<BookmarkButtonControllerProtocol>)controllerForNode: + (const BookmarkNode*)node; + +@end // @protocol BookmarkButtonControllerProtocol + + +// Class for bookmark bar buttons that can be drag sources. +@interface BookmarkButton : DraggableButton { + @private + IBOutlet NSObject<BookmarkButtonDelegate>* delegate_; // Weak. + + // Saved pointer to the BWC for the browser window that contains this button. + // Used to lock and release bar visibility during a drag. The pointer is + // saved because the bookmark button is no longer a part of a window at the + // end of a drag operation (or, in fact, can be dragged to a completely + // different window), so there is no way to retrieve the same BWC object after + // a drag. + BrowserWindowController* visibilityDelegate_; // weak + + NSPoint dragMouseOffset_; + NSPoint dragEndScreenLocation_; + BOOL dragPending_; +} + +@property(assign, nonatomic) NSObject<BookmarkButtonDelegate>* delegate; + +// Return the bookmark node associated with this button, or NULL. +- (const BookmarkNode*)bookmarkNode; + +// Return YES if this is a folder button (the node has subnodes). +- (BOOL)isFolder; + +// At this time we represent an empty folder (e.g. the string +// '(empty)') as a disabled button with no associated node. +// +// TODO(jrg): improve; things work but are slightly ugly since "empty" +// and "one disabled button" are not the same thing. +// http://crbug.com/35967 +- (BOOL)isEmpty; + +// Turn on or off pulsing of a bookmark button. +// Triggered by the bookmark bubble. +- (void)setIsContinuousPulsing:(BOOL)flag; + +// Return continuous pulse state. +- (BOOL)isContinuousPulsing; + +// Return the location in screen coordinates where the remove animation should +// be displayed. +- (NSPoint)screenLocationForRemoveAnimation; + +@end // @interface BookmarkButton + + +@interface BookmarkButton(TestingAPI) +- (void)beginDrag:(NSEvent*)event; +@end + +namespace bookmark_button { + +// Notifications for pulsing of bookmarks. +extern NSString* const kPulseBookmarkButtonNotification; + +// Key for userInfo dict of a kPulseBookmarkButtonNotification. +// Value is a [NSValue valueWithPointer:]; pointer is a (const BookmarkNode*). +extern NSString* const kBookmarkKey; + +// Key for userInfo dict of a kPulseBookmarkButtonNotification. +// Value is a [NSNumber numberWithBool:] to turn pulsing on or off. +extern NSString* const kBookmarkPulseFlagKey; + +}; diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_button.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_button.mm new file mode 100644 index 0000000..885e5c8 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_button.mm @@ -0,0 +1,238 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_button.h" + +#include "base/logging.h" +#import "base/scoped_nsobject.h" +#include "chrome/browser/bookmarks/bookmark_model.h" +#include "chrome/browser/metrics/user_metrics.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_button_cell.h" +#import "chrome/browser/ui/cocoa/browser_window_controller.h" +#import "chrome/browser/ui/cocoa/view_id_util.h" + +// The opacity of the bookmark button drag image. +static const CGFloat kDragImageOpacity = 0.7; + + +namespace bookmark_button { + +NSString* const kPulseBookmarkButtonNotification = + @"PulseBookmarkButtonNotification"; +NSString* const kBookmarkKey = @"BookmarkKey"; +NSString* const kBookmarkPulseFlagKey = @"BookmarkPulseFlagKey"; + +}; + +@interface BookmarkButton(Private) + +// Make a drag image for the button. +- (NSImage*)dragImage; + +@end // @interface BookmarkButton(Private) + + +@implementation BookmarkButton + +@synthesize delegate = delegate_; + +- (id)initWithFrame:(NSRect)frameRect { + // BookmarkButton's ViewID may be changed to VIEW_ID_OTHER_BOOKMARKS in + // BookmarkBarController, so we can't just override -viewID method to return + // it. + if ((self = [super initWithFrame:frameRect])) + view_id_util::SetID(self, VIEW_ID_BOOKMARK_BAR_ELEMENT); + return self; +} + +- (void)dealloc { + if ([[self cell] respondsToSelector:@selector(safelyStopPulsing)]) + [[self cell] safelyStopPulsing]; + view_id_util::UnsetID(self); + [super dealloc]; +} + +- (const BookmarkNode*)bookmarkNode { + return [[self cell] bookmarkNode]; +} + +- (BOOL)isFolder { + const BookmarkNode* node = [self bookmarkNode]; + return (node && node->is_folder()); +} + +- (BOOL)isEmpty { + return [self bookmarkNode] ? NO : YES; +} + +- (void)setIsContinuousPulsing:(BOOL)flag { + [[self cell] setIsContinuousPulsing:flag]; +} + +- (BOOL)isContinuousPulsing { + return [[self cell] isContinuousPulsing]; +} + +- (NSPoint)screenLocationForRemoveAnimation { + NSPoint point; + + if (dragPending_) { + // Use the position of the mouse in the drag image as the location. + point = dragEndScreenLocation_; + point.x += dragMouseOffset_.x; + if ([self isFlipped]) { + point.y += [self bounds].size.height - dragMouseOffset_.y; + } else { + point.y += dragMouseOffset_.y; + } + } else { + // Use the middle of this button as the location. + NSRect bounds = [self bounds]; + point = NSMakePoint(NSMidX(bounds), NSMidY(bounds)); + point = [self convertPoint:point toView:nil]; + point = [[self window] convertBaseToScreen:point]; + } + + return point; +} + +// By default, NSButton ignores middle-clicks. +// But we want them. +- (void)otherMouseUp:(NSEvent*)event { + [self performClick:self]; +} + +// Overridden from DraggableButton. +- (void)beginDrag:(NSEvent*)event { + // Don't allow a drag of the empty node. + // The empty node is a placeholder for "(empty)", to be revisited. + if ([self isEmpty]) + return; + + if (![self delegate]) { + NOTREACHED(); + return; + } + // Ask our delegate to fill the pasteboard for us. + NSPasteboard* pboard = [NSPasteboard pasteboardWithName:NSDragPboard]; + [[self delegate] fillPasteboard:pboard forDragOfButton:self]; + + // At the moment, moving bookmarks causes their buttons (like me!) + // to be destroyed and rebuilt. Make sure we don't go away while on + // the stack. + [self retain]; + + // Lock bar visibility, forcing the overlay to stay visible if we are in + // fullscreen mode. + if ([[self delegate] dragShouldLockBarVisibility]) { + DCHECK(!visibilityDelegate_); + NSWindow* window = [[self delegate] browserWindow]; + visibilityDelegate_ = + [BrowserWindowController browserWindowControllerForWindow:window]; + [visibilityDelegate_ lockBarVisibilityForOwner:self + withAnimation:NO + delay:NO]; + } + const BookmarkNode* node = [self bookmarkNode]; + const BookmarkNode* parent = node ? node->GetParent() : NULL; + if (parent && parent->type() == BookmarkNode::FOLDER) { + UserMetrics::RecordAction(UserMetricsAction("BookmarkBarFolder_DragStart")); + } else { + UserMetrics::RecordAction(UserMetricsAction("BookmarkBar_DragStart")); + } + + dragMouseOffset_ = [self convertPointFromBase:[event locationInWindow]]; + dragPending_ = YES; + + CGFloat yAt = [self bounds].size.height; + NSSize dragOffset = NSMakeSize(0.0, 0.0); + [self dragImage:[self dragImage] at:NSMakePoint(0, yAt) offset:dragOffset + event:event pasteboard:pboard source:self slideBack:YES]; + + // And we're done. + dragPending_ = NO; + [self autorelease]; +} + +// Overridden to release bar visibility. +- (void)endDrag { + // visibilityDelegate_ can be nil if we're detached, and that's fine. + [visibilityDelegate_ releaseBarVisibilityForOwner:self + withAnimation:YES + delay:YES]; + visibilityDelegate_ = nil; + [super endDrag]; +} + +- (NSDragOperation)draggingSourceOperationMaskForLocal:(BOOL)isLocal { + NSDragOperation operation = NSDragOperationCopy; + if (isLocal) { + operation |= NSDragOperationMove; + } + if ([delegate_ canDragBookmarkButtonToTrash:self]) { + operation |= NSDragOperationDelete; + } + return operation; +} + +- (void)draggedImage:(NSImage *)anImage + endedAt:(NSPoint)aPoint + operation:(NSDragOperation)operation { + if (operation & NSDragOperationDelete) { + dragEndScreenLocation_ = aPoint; + [delegate_ didDragBookmarkToTrash:self]; + } +} + +// mouseEntered: and mouseExited: are called from our +// BookmarkButtonCell. We redirect this information to our delegate. +// The controller can then perform menu-like actions (e.g. "hover over +// to open menu"). +- (void)mouseEntered:(NSEvent*)event { + [delegate_ mouseEnteredButton:self event:event]; +} + +// See comments above mouseEntered:. +- (void)mouseExited:(NSEvent*)event { + [delegate_ mouseExitedButton:self event:event]; +} + +@end + +@implementation BookmarkButton(Private) + +- (NSImage*)dragImage { + NSRect bounds = [self bounds]; + + // Grab the image from the screen and put it in an |NSImage|. We can't use + // this directly since we need to clip it and set its opacity. This won't work + // if the source view is clipped. Fortunately, we don't display clipped + // bookmark buttons. + [self lockFocus]; + scoped_nsobject<NSBitmapImageRep> + bitmap([[NSBitmapImageRep alloc] initWithFocusedViewRect:bounds]); + [self unlockFocus]; + scoped_nsobject<NSImage> image([[NSImage alloc] initWithSize:[bitmap size]]); + [image addRepresentation:bitmap]; + + // Make an autoreleased |NSImage|, which will be returned, and draw into it. + // By default, the |NSImage| will be completely transparent. + NSImage* dragImage = + [[[NSImage alloc] initWithSize:[bitmap size]] autorelease]; + [dragImage lockFocus]; + + // Draw the image with the appropriate opacity, clipping it tightly. + GradientButtonCell* cell = static_cast<GradientButtonCell*>([self cell]); + DCHECK([cell isKindOfClass:[GradientButtonCell class]]); + [[cell clipPathForFrame:bounds inView:self] setClip]; + [image drawAtPoint:NSMakePoint(0, 0) + fromRect:NSMakeRect(0, 0, NSWidth(bounds), NSHeight(bounds)) + operation:NSCompositeSourceOver + fraction:kDragImageOpacity]; + + [dragImage unlockFocus]; + return dragImage; +} + +@end // @implementation BookmarkButton(Private) diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_button_cell.h b/chrome/browser/ui/cocoa/bookmarks/bookmark_button_cell.h new file mode 100644 index 0000000..e126ac3 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_button_cell.h @@ -0,0 +1,65 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_BUTTON_CELL_H_ +#define CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_BUTTON_CELL_H_ +#pragma once + +#import "base/cocoa_protocols_mac.h" +#import "chrome/browser/ui/cocoa/gradient_button_cell.h" + +class BookmarkNode; + +// A button cell that handles drawing/highlighting of buttons in the +// bookmark bar. This cell forwards mouseEntered/mouseExited events +// to its control view so that pseudo-menu operations +// (e.g. hover-over to open) can be implemented. +@interface BookmarkButtonCell : GradientButtonCell<NSMenuDelegate> { + @private + BOOL empty_; // is this an "empty" button placeholder button cell? + + // Starting index of bookmarkFolder children that we care to use. + int startingChildIndex_; + + // Should we draw the folder arrow as needed? Not used for the bar + // itself but used on the folder windows. + BOOL drawFolderArrow_; + + // Arrow for folders + scoped_nsobject<NSImage> arrowImage_; +} + +@property (nonatomic, readwrite, assign) const BookmarkNode* bookmarkNode; +@property (nonatomic, readwrite, assign) int startingChildIndex; +@property (nonatomic, readwrite, assign) BOOL drawFolderArrow; + +// Create a button cell which draws with a theme. ++ (id)buttonCellForNode:(const BookmarkNode*)node + contextMenu:(NSMenu*)contextMenu + cellText:(NSString*)cellText + cellImage:(NSImage*)cellImage; + +// Initialize a button cell which draws with a theme. +// Designated initializer. +- (id)initForNode:(const BookmarkNode*)node + contextMenu:(NSMenu*)contextMenu + cellText:(NSString*)cellText + cellImage:(NSImage*)cellImage; + +- (BOOL)empty; // returns YES if empty. +- (void)setEmpty:(BOOL)empty; + +// |-setBookmarkCellText:image:| is used to set the text and image of +// a BookmarkButtonCell, and align the image to the left (NSImageLeft) +// if there is text in the title, and centered (NSImageCenter) if +// there is not. If |title| is nil, do not reset the title. +- (void)setBookmarkCellText:(NSString*)title + image:(NSImage*)image; + +// Set the color of text in this cell. +- (void)setTextColor:(NSColor*)color; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_BUTTON_CELL_H_ diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_button_cell.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_button_cell.mm new file mode 100644 index 0000000..969a829 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_button_cell.mm @@ -0,0 +1,246 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_button_cell.h" + +#include "app/l10n_util_mac.h" +#include "base/logging.h" +#include "base/nsimage_cache_mac.h" +#include "base/sys_string_conversions.h" +#import "chrome/browser/bookmarks/bookmark_model.h" +#include "chrome/browser/metrics/user_metrics.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_menu.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_button.h" +#import "chrome/browser/ui/cocoa/image_utils.h" +#include "grit/generated_resources.h" + + +@interface BookmarkButtonCell(Private) +- (void)configureBookmarkButtonCell; +@end + + +@implementation BookmarkButtonCell + +@synthesize startingChildIndex = startingChildIndex_; +@synthesize drawFolderArrow = drawFolderArrow_; + ++ (id)buttonCellForNode:(const BookmarkNode*)node + contextMenu:(NSMenu*)contextMenu + cellText:(NSString*)cellText + cellImage:(NSImage*)cellImage { + id buttonCell = + [[[BookmarkButtonCell alloc] initForNode:node + contextMenu:contextMenu + cellText:cellText + cellImage:cellImage] + autorelease]; + return buttonCell; +} + +- (id)initForNode:(const BookmarkNode*)node + contextMenu:(NSMenu*)contextMenu + cellText:(NSString*)cellText + cellImage:(NSImage*)cellImage { + if ((self = [super initTextCell:cellText])) { + [self configureBookmarkButtonCell]; + + [self setBookmarkNode:node]; + + if (node) { + NSString* title = base::SysUTF16ToNSString(node->GetTitle()); + [self setBookmarkCellText:title image:cellImage]; + [self setMenu:contextMenu]; + } else { + [self setEmpty:YES]; + [self setBookmarkCellText:l10n_util::GetNSString(IDS_MENU_EMPTY_SUBMENU) + image:nil]; + } + } + + return self; +} + +- (id)initTextCell:(NSString*)string { + return [self initForNode:nil contextMenu:nil cellText:string cellImage:nil]; +} + +// Used by the off-the-side menu, the only case where a +// BookmarkButtonCell is loaded from a nib. +- (void)awakeFromNib { + [self configureBookmarkButtonCell]; +} + +// Perform all normal init routines specific to the BookmarkButtonCell. +- (void)configureBookmarkButtonCell { + [self setButtonType:NSMomentaryPushInButton]; + [self setBezelStyle:NSShadowlessSquareBezelStyle]; + [self setShowsBorderOnlyWhileMouseInside:YES]; + [self setControlSize:NSSmallControlSize]; + [self setAlignment:NSLeftTextAlignment]; + [self setFont:[NSFont systemFontOfSize:[NSFont smallSystemFontSize]]]; + [self setWraps:NO]; + // NSLineBreakByTruncatingMiddle seems more common on OSX but let's + // try to match Windows for a bit to see what happens. + [self setLineBreakMode:NSLineBreakByTruncatingTail]; + + // Theming doesn't work for bookmark buttons yet (cell text is chucked). + [super setShouldTheme:NO]; +} + +- (BOOL)empty { + return empty_; +} + +- (void)setEmpty:(BOOL)empty { + empty_ = empty; + [self setShowsBorderOnlyWhileMouseInside:!empty]; +} + +- (NSSize)cellSizeForBounds:(NSRect)aRect { + NSSize size = [super cellSizeForBounds:aRect]; + // Cocoa seems to slightly underestimate how much space we need, so we + // compensate here to avoid a clipped rendering. + size.width += 2; + size.height += 4; + return size; +} + +- (void)setBookmarkCellText:(NSString*)title + image:(NSImage*)image { + title = [title stringByReplacingOccurrencesOfString:@"\n" + withString:@" "]; + title = [title stringByReplacingOccurrencesOfString:@"\r" + withString:@" "]; + // If there is no title, squeeze things tight by displaying only the image; by + // default, Cocoa leaves extra space in an attempt to display an empty title. + if ([title length]) { + [self setImagePosition:NSImageLeft]; + [self setTitle:title]; + } else { + [self setImagePosition:NSImageOnly]; + } + + if (image) + [self setImage:image]; +} + +- (void)setBookmarkNode:(const BookmarkNode*)node { + [self setRepresentedObject:[NSValue valueWithPointer:node]]; +} + +- (const BookmarkNode*)bookmarkNode { + return static_cast<const BookmarkNode*>([[self representedObject] + pointerValue]); +} + +// We share the context menu among all bookmark buttons. To allow us +// to disambiguate when needed (e.g. "open bookmark"), we set the +// menu's associated bookmark node ID to be our represented object. +- (NSMenu*)menu { + if (empty_) + return nil; + BookmarkMenu* menu = (BookmarkMenu*)[super menu]; + const BookmarkNode* node = + static_cast<const BookmarkNode*>([[self representedObject] pointerValue]); + + if (node->GetParent() && node->GetParent()->type() == BookmarkNode::FOLDER) { + UserMetrics::RecordAction(UserMetricsAction("BookmarkBarFolder_CtxMenu")); + } else { + UserMetrics::RecordAction(UserMetricsAction("BookmarkBar_CtxMenu")); + } + + [menu setRepresentedObject:[NSNumber numberWithLongLong:node->id()]]; + + return menu; +} + +// Unfortunately, NSCell doesn't already have something like this. +// TODO(jrg): consider placing in GTM. +- (void)setTextColor:(NSColor*)color { + + // We can't properly set the cell's text color without a control. + // In theory we could just save the next for later and wait until + // the cell is moved to a control, but there is no obvious way to + // accomplish that (e.g. no "cellDidMoveToControl" notification.) + DCHECK([self controlView]); + + scoped_nsobject<NSMutableParagraphStyle> style([NSMutableParagraphStyle new]); + [style setAlignment:NSLeftTextAlignment]; + NSDictionary* dict = [NSDictionary + dictionaryWithObjectsAndKeys:color, + NSForegroundColorAttributeName, + [self font], NSFontAttributeName, + style.get(), NSParagraphStyleAttributeName, + nil]; + scoped_nsobject<NSAttributedString> ats([[NSAttributedString alloc] + initWithString:[self title] + attributes:dict]); + NSButton* button = static_cast<NSButton*>([self controlView]); + if (button) { + DCHECK([button isKindOfClass:[NSButton class]]); + [button setAttributedTitle:ats.get()]; + } +} + +// To implement "hover open a bookmark button to open the folder" +// which feels like menus, we override NSButtonCell's mouseEntered: +// and mouseExited:, then and pass them along to our owning control. +// Note: as verified in a debugger, mouseEntered: does NOT increase +// the retainCount of the cell or its owning control. +- (void)mouseEntered:(NSEvent*)event { + [super mouseEntered:event]; + [[self controlView] mouseEntered:event]; +} + +// See comment above mouseEntered:, above. +- (void)mouseExited:(NSEvent*)event { + [[self controlView] mouseExited:event]; + [super mouseExited:event]; +} + +- (void)setDrawFolderArrow:(BOOL)draw { + drawFolderArrow_ = draw; + if (draw && !arrowImage_) { + arrowImage_.reset([nsimage_cache::ImageNamed(@"menu_hierarchy_arrow.pdf") + retain]); + } +} + +// Add extra size for the arrow so it doesn't overlap the text. +// Does not sanity check to be sure this is actually a folder node. +- (NSSize)cellSize { + NSSize cellSize = [super cellSize]; + if (drawFolderArrow_) { + cellSize.width += [arrowImage_ size].width; // plus margin? + } + return cellSize; +} + +// Override cell drawing to add a submenu arrow like a real menu. +- (void)drawInteriorWithFrame:(NSRect)cellFrame inView:(NSView*)controlView { + // First draw "everything else". + [super drawInteriorWithFrame:cellFrame inView:controlView]; + + // If asked to do so, and if a folder, draw the arrow. + if (!drawFolderArrow_) + return; + BookmarkButton* button = static_cast<BookmarkButton*>([self controlView]); + DCHECK([button respondsToSelector:@selector(isFolder)]); + if ([button isFolder]) { + NSRect imageRect = NSZeroRect; + imageRect.size = [arrowImage_ size]; + NSRect drawRect = NSOffsetRect(imageRect, + NSWidth(cellFrame) - NSWidth(imageRect), + (NSHeight(cellFrame) / 2.0) - + (NSHeight(imageRect) / 2.0)); + [arrowImage_ drawInRect:drawRect + fromRect:imageRect + operation:NSCompositeSourceOver + fraction:[self isEnabled] ? 1.0 : 0.5 + neverFlipped:YES]; + } +} + +@end diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_button_cell_unittest.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_button_cell_unittest.mm new file mode 100644 index 0000000..ff26512 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_button_cell_unittest.mm @@ -0,0 +1,183 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "app/resource_bundle.h" +#include "base/scoped_nsobject.h" +#include "base/utf_string_conversions.h" +#include "chrome/browser/bookmarks/bookmark_model.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_button_cell.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_menu.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "grit/app_resources.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +// Simple class to remember how many mouseEntered: and mouseExited: +// calls it gets. Only used by BookmarkMouseForwarding but placed +// at the top of the file to keep it outside the anon namespace. +@interface ButtonRemembersMouseEnterExit : NSButton { + @public + int enters_; + int exits_; +} +@end + +@implementation ButtonRemembersMouseEnterExit +- (void)mouseEntered:(NSEvent*)event { + enters_++; +} +- (void)mouseExited:(NSEvent*)event { + exits_++; +} +@end + + +namespace { + +class BookmarkButtonCellTest : public CocoaTest { + public: + BrowserTestHelper helper_; +}; + +// Make sure it's not totally bogus +TEST_F(BookmarkButtonCellTest, SizeForBounds) { + NSRect frame = NSMakeRect(0, 0, 50, 30); + scoped_nsobject<NSButton> view([[NSButton alloc] initWithFrame:frame]); + scoped_nsobject<BookmarkButtonCell> cell( + [[BookmarkButtonCell alloc] initTextCell:@"Testing"]); + [view setCell:cell.get()]; + [[test_window() contentView] addSubview:view]; + + NSRect r = NSMakeRect(0, 0, 100, 100); + NSSize size = [cell.get() cellSizeForBounds:r]; + EXPECT_TRUE(size.width > 0 && size.height > 0); + EXPECT_TRUE(size.width < 200 && size.height < 200); +} + +// Make sure icon-only buttons are squeezed tightly. +TEST_F(BookmarkButtonCellTest, IconOnlySqueeze) { + NSRect frame = NSMakeRect(0, 0, 50, 30); + scoped_nsobject<NSButton> view([[NSButton alloc] initWithFrame:frame]); + scoped_nsobject<BookmarkButtonCell> cell( + [[BookmarkButtonCell alloc] initTextCell:@"Testing"]); + [view setCell:cell.get()]; + [[test_window() contentView] addSubview:view]; + + ResourceBundle& rb = ResourceBundle::GetSharedInstance(); + scoped_nsobject<NSImage> image([rb.GetNativeImageNamed(IDR_DEFAULT_FAVICON) + retain]); + EXPECT_TRUE(image.get()); + + NSRect r = NSMakeRect(0, 0, 100, 100); + [cell setBookmarkCellText:@" " image:image]; + CGFloat two_space_width = [cell.get() cellSizeForBounds:r].width; + [cell setBookmarkCellText:@" " image:image]; + CGFloat one_space_width = [cell.get() cellSizeForBounds:r].width; + [cell setBookmarkCellText:@"" image:image]; + CGFloat zero_space_width = [cell.get() cellSizeForBounds:r].width; + + // Make sure the switch to "no title" is more significant than we + // would otherwise see by decreasing the length of the title. + CGFloat delta1 = two_space_width - one_space_width; + CGFloat delta2 = one_space_width - zero_space_width; + EXPECT_GT(delta2, delta1); + +} + +// Make sure the default from the base class is overridden. +TEST_F(BookmarkButtonCellTest, MouseEnterStuff) { + scoped_nsobject<BookmarkButtonCell> cell( + [[BookmarkButtonCell alloc] initTextCell:@"Testing"]); + // Setting the menu should have no affect since we either share or + // dynamically compose the menu given a node. + [cell setMenu:[[[BookmarkMenu alloc] initWithTitle:@"foo"] autorelease]]; + EXPECT_FALSE([cell menu]); + + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + const BookmarkNode* node = model->GetBookmarkBarNode(); + [cell setEmpty:NO]; + [cell setBookmarkNode:node]; + EXPECT_TRUE([cell showsBorderOnlyWhileMouseInside]); + EXPECT_TRUE([cell menu]); + + [cell setEmpty:YES]; + EXPECT_FALSE([cell.get() showsBorderOnlyWhileMouseInside]); + EXPECT_FALSE([cell menu]); +} + +TEST_F(BookmarkButtonCellTest, BookmarkNode) { + BookmarkModel& model(*(helper_.profile()->GetBookmarkModel())); + scoped_nsobject<BookmarkButtonCell> cell( + [[BookmarkButtonCell alloc] initTextCell:@"Testing"]); + + const BookmarkNode* node = model.GetBookmarkBarNode(); + [cell setBookmarkNode:node]; + EXPECT_EQ(node, [cell bookmarkNode]); + + node = model.other_node(); + [cell setBookmarkNode:node]; + EXPECT_EQ(node, [cell bookmarkNode]); +} + +TEST_F(BookmarkButtonCellTest, BookmarkMouseForwarding) { + scoped_nsobject<BookmarkButtonCell> cell( + [[BookmarkButtonCell alloc] initTextCell:@"Testing"]); + scoped_nsobject<ButtonRemembersMouseEnterExit> + button([[ButtonRemembersMouseEnterExit alloc] + initWithFrame:NSMakeRect(0,0,50,50)]); + [button setCell:cell.get()]; + EXPECT_EQ(0, button.get()->enters_); + EXPECT_EQ(0, button.get()->exits_); + NSEvent* event = [NSEvent mouseEventWithType:NSMouseMoved + location:NSMakePoint(10,10) + modifierFlags:0 + timestamp:0 + windowNumber:0 + context:nil + eventNumber:0 + clickCount:0 + pressure:0]; + [cell mouseEntered:event]; + EXPECT_TRUE(button.get()->enters_ && !button.get()->exits_); + + for (int i = 0; i < 3; i++) + [cell mouseExited:event]; + EXPECT_EQ(button.get()->enters_, 1); + EXPECT_EQ(button.get()->exits_, 3); +} + +// Confirms a cell created in a nib is initialized properly +TEST_F(BookmarkButtonCellTest, Awake) { + scoped_nsobject<BookmarkButtonCell> cell([[BookmarkButtonCell alloc] init]); + [cell awakeFromNib]; + EXPECT_EQ(NSLeftTextAlignment, [cell alignment]); +} + +// Subfolder arrow details. +TEST_F(BookmarkButtonCellTest, FolderArrow) { + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + const BookmarkNode* bar = model->GetBookmarkBarNode(); + const BookmarkNode* node = model->AddURL(bar, bar->GetChildCount(), + ASCIIToUTF16("title"), + GURL("http://www.google.com")); + scoped_nsobject<BookmarkButtonCell> cell( + [[BookmarkButtonCell alloc] initForNode:node + contextMenu:nil + cellText:@"small" + cellImage:nil]); + EXPECT_TRUE(cell.get()); + + NSSize size = [cell cellSize]; + // sanity check + EXPECT_GE(size.width, 2); + EXPECT_GE(size.height, 2); + + // Once we turn on arrow drawing make sure there is now room for it. + [cell setDrawFolderArrow:YES]; + NSSize arrowSize = [cell cellSize]; + EXPECT_GT(arrowSize.width, size.width); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_button_unittest.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_button_unittest.mm new file mode 100644 index 0000000..93bd769 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_button_unittest.mm @@ -0,0 +1,174 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/scoped_nsobject.h" +#include "base/utf_string_conversions.h" +#include "chrome/browser/bookmarks/bookmark_model.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_button.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_button_cell.h" +#import "chrome/browser/ui/cocoa/browser_test_helper.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/test_event_utils.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +// Fake BookmarkButton delegate to get a pong on mouse entered/exited +@interface FakeButtonDelegate : NSObject<BookmarkButtonDelegate> { + @public + int entered_; + int exited_; + BOOL canDragToTrash_; + int didDragToTrashCount_; +} +@end + +@implementation FakeButtonDelegate + +- (void)fillPasteboard:(NSPasteboard*)pboard + forDragOfButton:(BookmarkButton*)button { +} + +- (void)mouseEnteredButton:(id)buton event:(NSEvent*)event { + entered_++; +} + +- (void)mouseExitedButton:(id)buton event:(NSEvent*)event { + exited_++; +} + +- (BOOL)dragShouldLockBarVisibility { + return NO; +} + +- (NSWindow*)browserWindow { + return nil; +} + +- (BOOL)canDragBookmarkButtonToTrash:(BookmarkButton*)button { + return canDragToTrash_; +} + +- (void)didDragBookmarkToTrash:(BookmarkButton*)button { + didDragToTrashCount_++; +} + +@end + +namespace { + +class BookmarkButtonTest : public CocoaTest { +}; + +// Make sure nothing leaks +TEST_F(BookmarkButtonTest, Create) { + scoped_nsobject<BookmarkButton> button; + button.reset([[BookmarkButton alloc] initWithFrame:NSMakeRect(0,0,500,500)]); +} + +// Test folder and empty node queries. +TEST_F(BookmarkButtonTest, FolderAndEmptyOrNot) { + BrowserTestHelper helper_; + scoped_nsobject<BookmarkButton> button; + scoped_nsobject<BookmarkButtonCell> cell; + + button.reset([[BookmarkButton alloc] initWithFrame:NSMakeRect(0,0,500,500)]); + cell.reset([[BookmarkButtonCell alloc] initTextCell:@"hi mom"]); + [button setCell:cell]; + + EXPECT_TRUE([button isEmpty]); + EXPECT_FALSE([button isFolder]); + EXPECT_FALSE([button bookmarkNode]); + + NSEvent* downEvent = + test_event_utils::LeftMouseDownAtPoint(NSMakePoint(10,10)); + // Since this returns (does not actually begin a modal drag), success! + [button beginDrag:downEvent]; + + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + const BookmarkNode* node = model->GetBookmarkBarNode(); + [cell setBookmarkNode:node]; + EXPECT_FALSE([button isEmpty]); + EXPECT_TRUE([button isFolder]); + EXPECT_EQ([button bookmarkNode], node); + + node = model->AddURL(node, 0, ASCIIToUTF16("hi mom"), + GURL("http://www.google.com")); + [cell setBookmarkNode:node]; + EXPECT_FALSE([button isEmpty]); + EXPECT_FALSE([button isFolder]); + EXPECT_EQ([button bookmarkNode], node); +} + +TEST_F(BookmarkButtonTest, MouseEnterExitRedirect) { + NSEvent* moveEvent = + test_event_utils::MouseEventAtPoint(NSMakePoint(10,10), NSMouseMoved, 0); + scoped_nsobject<BookmarkButton> button; + scoped_nsobject<BookmarkButtonCell> cell; + scoped_nsobject<FakeButtonDelegate> + delegate([[FakeButtonDelegate alloc] init]); + button.reset([[BookmarkButton alloc] initWithFrame:NSMakeRect(0,0,500,500)]); + cell.reset([[BookmarkButtonCell alloc] initTextCell:@"hi mom"]); + [button setCell:cell]; + [button setDelegate:delegate]; + + EXPECT_EQ(0, delegate.get()->entered_); + EXPECT_EQ(0, delegate.get()->exited_); + + [button mouseEntered:moveEvent]; + EXPECT_EQ(1, delegate.get()->entered_); + EXPECT_EQ(0, delegate.get()->exited_); + + [button mouseExited:moveEvent]; + [button mouseExited:moveEvent]; + EXPECT_EQ(1, delegate.get()->entered_); + EXPECT_EQ(2, delegate.get()->exited_); +} + +TEST_F(BookmarkButtonTest, DragToTrash) { + BrowserTestHelper helper_; + + scoped_nsobject<BookmarkButton> button; + scoped_nsobject<BookmarkButtonCell> cell; + scoped_nsobject<FakeButtonDelegate> + delegate([[FakeButtonDelegate alloc] init]); + button.reset([[BookmarkButton alloc] initWithFrame:NSMakeRect(0,0,500,500)]); + cell.reset([[BookmarkButtonCell alloc] initTextCell:@"hi mom"]); + [button setCell:cell]; + [button setDelegate:delegate]; + + // Add a deletable bookmark to the button. + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + const BookmarkNode* barNode = model->GetBookmarkBarNode(); + const BookmarkNode* node = model->AddURL(barNode, 0, ASCIIToUTF16("hi mom"), + GURL("http://www.google.com")); + [cell setBookmarkNode:node]; + + // Verify that if canDragBookmarkButtonToTrash is NO then the button can't + // be dragged to the trash. + delegate.get()->canDragToTrash_ = NO; + NSDragOperation operation = [button draggingSourceOperationMaskForLocal:NO]; + EXPECT_EQ(0u, operation & NSDragOperationDelete); + operation = [button draggingSourceOperationMaskForLocal:YES]; + EXPECT_EQ(0u, operation & NSDragOperationDelete); + + // Verify that if canDragBookmarkButtonToTrash is YES then the button can + // be dragged to the trash. + delegate.get()->canDragToTrash_ = YES; + operation = [button draggingSourceOperationMaskForLocal:NO]; + EXPECT_EQ(NSDragOperationDelete, operation & NSDragOperationDelete); + operation = [button draggingSourceOperationMaskForLocal:YES]; + EXPECT_EQ(NSDragOperationDelete, operation & NSDragOperationDelete); + + // Verify that canDragBookmarkButtonToTrash is called when expected. + delegate.get()->canDragToTrash_ = YES; + EXPECT_EQ(0, delegate.get()->didDragToTrashCount_); + [button draggedImage:nil endedAt:NSZeroPoint operation:NSDragOperationCopy]; + EXPECT_EQ(0, delegate.get()->didDragToTrashCount_); + [button draggedImage:nil endedAt:NSZeroPoint operation:NSDragOperationMove]; + EXPECT_EQ(0, delegate.get()->didDragToTrashCount_); + [button draggedImage:nil endedAt:NSZeroPoint operation:NSDragOperationDelete]; + EXPECT_EQ(1, delegate.get()->didDragToTrashCount_); +} + +} diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_drag_source.h b/chrome/browser/ui/cocoa/bookmarks/bookmark_drag_source.h new file mode 100644 index 0000000..53d4361 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_drag_source.h @@ -0,0 +1,30 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "chrome/browser/bookmarks/bookmark_node_data.h" +#include "chrome/browser/ui/cocoa/web_contents_drag_source.h" + +// A class that handles tracking and event processing for a drag and drop +// originating from the content area. +@interface BookmarkDragSource : WebContentsDragSource { + @private + // Our drop data. Should only be initialized once. + std::vector<BookmarkNodeData::Element> dropData_; + + Profile* profile_; +} + +// Initialize a DragDataSource object for a drag (originating on the given +// contentsView and with the given dropData and pboard). Fill the pasteboard +// with data types appropriate for dropData. +- (id)initWithContentsView:(TabContentsViewCocoa*)contentsView + dropData: + (const std::vector<BookmarkNodeData::Element>&)dropData + profile:(Profile*)profile + pasteboard:(NSPasteboard*)pboard + dragOperationMask:(NSDragOperation)dragOperationMask; + +@end diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_drag_source.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_drag_source.mm new file mode 100644 index 0000000..64ae1d9 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_drag_source.mm @@ -0,0 +1,43 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_drag_source.h" + +#include "chrome/browser/bookmarks/bookmark_pasteboard_helper_mac.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/tab_contents/tab_contents_view_mac.h" + +@implementation BookmarkDragSource + +- (id)initWithContentsView:(TabContentsViewCocoa*)contentsView + dropData: + (const std::vector<BookmarkNodeData::Element>&)dropData + profile:(Profile*)profile + pasteboard:(NSPasteboard*)pboard + dragOperationMask:(NSDragOperation)dragOperationMask { + self = [super initWithContentsView:contentsView + pasteboard:pboard + dragOperationMask:dragOperationMask]; + if (self) { + dropData_ = dropData; + profile_ = profile; + } + + return self; +} + +- (void)fillPasteboard { + bookmark_pasteboard_helper_mac::WriteToDragClipboard(dropData_, + profile_->GetPath().value()); +} + +- (NSImage*)dragImage { + // TODO(feldstein): Do something better than this. Should have badging + // and a single drag image. + // http://crbug.com/37264 + return [NSImage imageNamed:NSImageNameMultipleDocuments]; +} + +@end // @implementation BookmarkDragSource + diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_editor_base_controller.h b/chrome/browser/ui/cocoa/bookmarks/bookmark_editor_base_controller.h new file mode 100644 index 0000000..50e7413 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_editor_base_controller.h @@ -0,0 +1,171 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_EDITOR_BASE_CONTROLLER_H_ +#define CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_EDITOR_BASE_CONTROLLER_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#import "base/cocoa_protocols_mac.h" +#include "base/scoped_ptr.h" +#include "base/scoped_nsobject.h" +#include "chrome/browser/bookmarks/bookmark_editor.h" + +class BookmarkEditorBaseControllerBridge; +class BookmarkModel; +@class BookmarkTreeBrowserCell; + +// A base controller class for bookmark creation and editing dialogs which +// present the current bookmark folder structure in a tree view. Do not +// instantiate this controller directly -- use one of its derived classes. +// NOTE: If a derived class is intended to be dispatched via the +// BookmarkEditor::Show static function found in the accompanying +// implementation, that function will need to be update. +@interface BookmarkEditorBaseController : NSWindowController { + @private + IBOutlet NSButton* newFolderButton_; + IBOutlet NSButton* okButton_; // Used for unit testing only. + IBOutlet NSTreeController* folderTreeController_; + IBOutlet NSOutlineView* folderTreeView_; + + NSWindow* parentWindow_; // weak + Profile* profile_; // weak + const BookmarkNode* parentNode_; // weak; owned by the model + BookmarkEditor::Configuration configuration_; + NSString* initialName_; + NSString* displayName_; // Bound to a text field in the dialog. + BOOL okEnabled_; // Bound to the OK button. + // An array of BookmarkFolderInfo where each item describes a folder in the + // BookmarkNode structure. + scoped_nsobject<NSArray> folderTreeArray_; + // Bound to the table view giving a path to the current selections, of which + // there should only ever be one. + scoped_nsobject<NSArray> tableSelectionPaths_; + // C++ bridge object that observes the BookmarkModel for me. + scoped_ptr<BookmarkEditorBaseControllerBridge> observer_; +} + +@property (nonatomic, copy) NSString* initialName; +@property (nonatomic, copy) NSString* displayName; +@property (nonatomic, assign) BOOL okEnabled; +@property (nonatomic, retain, readonly) NSArray* folderTreeArray; +@property (nonatomic, copy) NSArray* tableSelectionPaths; + +// Designated initializer. Derived classes should call through to this init. +- (id)initWithParentWindow:(NSWindow*)parentWindow + nibName:(NSString*)nibName + profile:(Profile*)profile + parent:(const BookmarkNode*)parent + configuration:(BookmarkEditor::Configuration)configuration; + +// Run the bookmark editor as a modal sheet. Does not block. +- (void)runAsModalSheet; + +// Create a new folder at the end of the selected parent folder, give it +// an untitled name, and put it into editing mode. +- (IBAction)newFolder:(id)sender; + +// The cancel action will dismiss the dialog. Derived classes which +// override cancel:, must call this after accessing any dialog-related +// data. +- (IBAction)cancel:(id)sender; + +// The OK action will dismiss the dialog. This action is bound +// to the OK button of a dialog which presents a tree view of a profile's +// folder hierarchy and allows the creation of new folders within that tree. +// When the OK button is pressed, this function will: 1) call the derived +// class's -[willCommit] function, 2) create any new folders created by +// the user while the dialog is presented, 3) call the derived class's +// -[didCommit] function, and then 4) dismiss the dialog. At least one +// of -[willCommit] and -[didCommit] must be provided by the derived class +// and should return a NSNumber containing a BOOL or nil ('nil' means YES) +// indicating if the operation should be allowed to continue. +// Note: A derived class should not override the ok: action. +- (IBAction)ok:(id)sender; + +// Methods for use by derived classes only. + +// Determine and returns the rightmost selected/highlighted element (node) +// in the bookmark tree view if the tree view is showing, otherwise returns +// the original |parentNode_|. If the tree view is showing but nothing is +// selected then the root node is returned. +- (const BookmarkNode*)selectedNode; + +// Select/highlight the given node within the browser tree view. If the +// node is nil then select the bookmark bar node. Exposed for unit test. +- (void)selectNodeInBrowser:(const BookmarkNode*)node; + +// Notifications called when the BookmarkModel changes out from under me. +- (void)nodeRemoved:(const BookmarkNode*)node + fromParent:(const BookmarkNode*)parent; +- (void)modelChangedPreserveSelection:(BOOL)preserve; + +// Accessors +- (BookmarkModel*)bookmarkModel; +- (const BookmarkNode*)parentNode; + +@end + +// Describes the profile's bookmark folder structure: the folder name, the +// original BookmarkNode pointer (if the folder already exists), a BOOL +// indicating if the folder is new (meaning: created during this session +// but not yet committed to the bookmark structure), and an NSArray of +// child folder BookmarkFolderInfo's following this same structure. +@interface BookmarkFolderInfo : NSObject { + @private + NSString* folderName_; + const BookmarkNode* folderNode_; // weak + NSMutableArray* children_; + BOOL newFolder_; +} + +@property (nonatomic, copy) NSString* folderName; +@property (nonatomic, assign) const BookmarkNode* folderNode; +@property (nonatomic, retain) NSMutableArray* children; +@property (nonatomic, assign) BOOL newFolder; + +// Convenience creator for adding a new folder to the editor's bookmark +// structure. This folder will be added to the bookmark model when the +// user accepts the dialog. |folderName| must be provided. ++ (id)bookmarkFolderInfoWithFolderName:(NSString*)folderName; + +// Designated initializer. |folderName| must be provided. For folders which +// already exist in the bookmark model, |folderNode| and |children| (if any +// children are already attached to this folder) must be provided and +// |newFolder| should be NO. For folders which the user has added during +// this session and which have not been committed yet, |newFolder| should be +// YES and |folderNode| and |children| should be NULL/nil. +- (id)initWithFolderName:(NSString*)folderName + folderNode:(const BookmarkNode*)folderNode + children:(NSMutableArray*)children + newFolder:(BOOL)newFolder; + +// Convenience creator used during construction of the editor's bookmark +// structure. |folderName| and |folderNode| must be provided. |children| +// is optional. Private: exposed here for unit testing purposes. ++ (id)bookmarkFolderInfoWithFolderName:(NSString*)folderName + folderNode:(const BookmarkNode*)folderNode + children:(NSMutableArray*)children; + +@end + +@interface BookmarkEditorBaseController(TestingAPI) + +@property (nonatomic, readonly) BOOL okButtonEnabled; + +// Create any newly added folders. New folders are nodes in folderTreeArray +// which are marked as being new (i.e. their kFolderTreeNewFolderKey +// dictionary item is YES). This is called by -[ok:]. +- (void)createNewFolders; + +// Select the given bookmark node within the tree view. +- (void)selectTestNodeInBrowser:(const BookmarkNode*)node; + +// Return the dictionary for the folder selected in the tree. +- (BookmarkFolderInfo*)selectedFolder; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_EDITOR_BASE_CONTROLLER_H_ diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_editor_base_controller.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_editor_base_controller.mm new file mode 100644 index 0000000..14aa78e --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_editor_base_controller.mm @@ -0,0 +1,604 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include <stack> + +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_editor_base_controller.h" + +#include "app/l10n_util.h" +#include "app/l10n_util_mac.h" +#include "base/logging.h" +#include "base/mac_util.h" +#include "base/sys_string_conversions.h" +#include "chrome/browser/bookmarks/bookmark_model.h" +#include "chrome/browser/profile.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_all_tabs_controller.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_editor_controller.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_tree_browser_cell.h" +#import "chrome/browser/ui/cocoa/browser_window_controller.h" +#include "grit/generated_resources.h" + +@interface BookmarkEditorBaseController () + +// Return the folder tree object for the given path. +- (BookmarkFolderInfo*)folderForIndexPath:(NSIndexPath*)path; + +// (Re)build the folder tree from the BookmarkModel's current state. +- (void)buildFolderTree; + +// Notifies the controller that the bookmark model has changed. +// |selection| specifies if the current selection should be +// maintained (usually YES). +- (void)modelChangedPreserveSelection:(BOOL)preserve; + +// Notifies the controller that a node has been removed. +- (void)nodeRemoved:(const BookmarkNode*)node + fromParent:(const BookmarkNode*)parent; + +// Given a folder node, collect an array containing BookmarkFolderInfos +// describing its subchildren which are also folders. +- (NSMutableArray*)addChildFoldersFromNode:(const BookmarkNode*)node; + +// Scan the folder tree stemming from the given tree folder and create +// any newly added folders. Pass down info for the folder which was +// selected before we began creating folders. +- (void)createNewFoldersForFolder:(BookmarkFolderInfo*)treeFolder + selectedFolderInfo:(BookmarkFolderInfo*)selectedFolderInfo; + +// Scan the folder tree looking for the given bookmark node and return +// the selection path thereto. +- (NSIndexPath*)selectionPathForNode:(const BookmarkNode*)node; + +@end + +// static; implemented for each platform. Update this function for new +// classes derived from BookmarkEditorBaseController. +void BookmarkEditor::Show(gfx::NativeWindow parent_hwnd, + Profile* profile, + const BookmarkNode* parent, + const EditDetails& details, + Configuration configuration) { + BookmarkEditorBaseController* controller = nil; + if (details.type == EditDetails::NEW_FOLDER) { + controller = [[BookmarkAllTabsController alloc] + initWithParentWindow:parent_hwnd + profile:profile + parent:parent + configuration:configuration]; + } else { + controller = [[BookmarkEditorController alloc] + initWithParentWindow:parent_hwnd + profile:profile + parent:parent + node:details.existing_node + configuration:configuration]; + } + [controller runAsModalSheet]; +} + +// Adapter to tell BookmarkEditorBaseController when bookmarks change. +class BookmarkEditorBaseControllerBridge : public BookmarkModelObserver { + public: + BookmarkEditorBaseControllerBridge(BookmarkEditorBaseController* controller) + : controller_(controller), + importing_(false) + { } + + virtual void Loaded(BookmarkModel* model) { + [controller_ modelChangedPreserveSelection:YES]; + } + + virtual void BookmarkNodeMoved(BookmarkModel* model, + const BookmarkNode* old_parent, + int old_index, + const BookmarkNode* new_parent, + int new_index) { + if (!importing_ && new_parent->GetChild(new_index)->is_folder()) + [controller_ modelChangedPreserveSelection:YES]; + } + + virtual void BookmarkNodeAdded(BookmarkModel* model, + const BookmarkNode* parent, + int index) { + if (!importing_ && parent->GetChild(index)->is_folder()) + [controller_ modelChangedPreserveSelection:YES]; + } + + virtual void BookmarkNodeRemoved(BookmarkModel* model, + const BookmarkNode* parent, + int old_index, + const BookmarkNode* node) { + [controller_ nodeRemoved:node fromParent:parent]; + if (node->is_folder()) + [controller_ modelChangedPreserveSelection:NO]; + } + + virtual void BookmarkNodeChanged(BookmarkModel* model, + const BookmarkNode* node) { + if (!importing_ && node->is_folder()) + [controller_ modelChangedPreserveSelection:YES]; + } + + virtual void BookmarkNodeChildrenReordered(BookmarkModel* model, + const BookmarkNode* node) { + if (!importing_) + [controller_ modelChangedPreserveSelection:YES]; + } + + virtual void BookmarkNodeFavIconLoaded(BookmarkModel* model, + const BookmarkNode* node) { + // I care nothing for these 'favicons': I only show folders. + } + + virtual void BookmarkImportBeginning(BookmarkModel* model) { + importing_ = true; + } + + // Invoked after a batch import finishes. This tells observers to update + // themselves if they were waiting for the update to finish. + virtual void BookmarkImportEnding(BookmarkModel* model) { + importing_ = false; + [controller_ modelChangedPreserveSelection:YES]; + } + + private: + BookmarkEditorBaseController* controller_; // weak + bool importing_; +}; + + +#pragma mark - + +@implementation BookmarkEditorBaseController + +@synthesize initialName = initialName_; +@synthesize displayName = displayName_; +@synthesize okEnabled = okEnabled_; + +- (id)initWithParentWindow:(NSWindow*)parentWindow + nibName:(NSString*)nibName + profile:(Profile*)profile + parent:(const BookmarkNode*)parent + configuration:(BookmarkEditor::Configuration)configuration { + NSString* nibpath = [mac_util::MainAppBundle() + pathForResource:nibName + ofType:@"nib"]; + if ((self = [super initWithWindowNibPath:nibpath owner:self])) { + parentWindow_ = parentWindow; + profile_ = profile; + parentNode_ = parent; + configuration_ = configuration; + initialName_ = [@"" retain]; + observer_.reset(new BookmarkEditorBaseControllerBridge(self)); + [self bookmarkModel]->AddObserver(observer_.get()); + } + return self; +} + +- (void)dealloc { + [self bookmarkModel]->RemoveObserver(observer_.get()); + [initialName_ release]; + [displayName_ release]; + [super dealloc]; +} + +- (void)awakeFromNib { + [self setDisplayName:[self initialName]]; + + if (configuration_ != BookmarkEditor::SHOW_TREE) { + // Remember the tree view's height; we will shrink our frame by that much. + NSRect frame = [[self window] frame]; + CGFloat browserHeight = [folderTreeView_ frame].size.height; + frame.size.height -= browserHeight; + frame.origin.y += browserHeight; + // Remove the folder tree and "new folder" button. + [folderTreeView_ removeFromSuperview]; + [newFolderButton_ removeFromSuperview]; + // Finally, commit the size change. + [[self window] setFrame:frame display:YES]; + } + + // Build up a tree of the current folder configuration. + [self buildFolderTree]; +} + +- (void)windowDidLoad { + if (configuration_ == BookmarkEditor::SHOW_TREE) { + [self selectNodeInBrowser:parentNode_]; + } +} + +/* TODO(jrg): +// Implementing this informal protocol allows us to open the sheet +// somewhere other than at the top of the window. NOTE: this means +// that I, the controller, am also the window's delegate. +- (NSRect)window:(NSWindow*)window willPositionSheet:(NSWindow*)sheet + usingRect:(NSRect)rect { + // adjust rect.origin.y to be the bottom of the toolbar + return rect; +} +*/ + +// TODO(jrg): consider NSModalSession. +- (void)runAsModalSheet { + // Lock down floating bar when in full-screen mode. Don't animate + // otherwise the pane will be misplaced. + [[BrowserWindowController browserWindowControllerForWindow:parentWindow_] + lockBarVisibilityForOwner:self withAnimation:NO delay:NO]; + [NSApp beginSheet:[self window] + modalForWindow:parentWindow_ + modalDelegate:self + didEndSelector:@selector(didEndSheet:returnCode:contextInfo:) + contextInfo:nil]; +} + +- (BOOL)okEnabled { + return YES; +} + +- (IBAction)ok:(id)sender { + // At least one of these two functions should be provided by derived classes. + BOOL hasWillCommit = [self respondsToSelector:@selector(willCommit)]; + BOOL hasDidCommit = [self respondsToSelector:@selector(didCommit)]; + DCHECK(hasWillCommit || hasDidCommit); + BOOL shouldContinue = YES; + if (hasWillCommit) { + NSNumber* hasWillContinue = [self performSelector:@selector(willCommit)]; + if (hasWillContinue && [hasWillContinue isKindOfClass:[NSNumber class]]) + shouldContinue = [hasWillContinue boolValue]; + } + if (shouldContinue) + [self createNewFolders]; + if (hasDidCommit) { + NSNumber* hasDidContinue = [self performSelector:@selector(didCommit)]; + if (hasDidContinue && [hasDidContinue isKindOfClass:[NSNumber class]]) + shouldContinue = [hasDidContinue boolValue]; + } + if (shouldContinue) + [NSApp endSheet:[self window]]; +} + +- (IBAction)cancel:(id)sender { + [NSApp endSheet:[self window]]; +} + +- (void)didEndSheet:(NSWindow*)sheet + returnCode:(int)returnCode + contextInfo:(void*)contextInfo { + [sheet close]; + [[BrowserWindowController browserWindowControllerForWindow:parentWindow_] + releaseBarVisibilityForOwner:self withAnimation:YES delay:NO]; +} + +- (void)windowWillClose:(NSNotification*)notification { + [self autorelease]; +} + +#pragma mark Folder Tree Management + +- (BookmarkModel*)bookmarkModel { + return profile_->GetBookmarkModel(); +} + +- (const BookmarkNode*)parentNode { + return parentNode_; +} + +- (BookmarkFolderInfo*)folderForIndexPath:(NSIndexPath*)indexPath { + NSUInteger pathCount = [indexPath length]; + BookmarkFolderInfo* item = nil; + NSArray* treeNode = [self folderTreeArray]; + for (NSUInteger i = 0; i < pathCount; ++i) { + item = [treeNode objectAtIndex:[indexPath indexAtPosition:i]]; + treeNode = [item children]; + } + return item; +} + +- (NSIndexPath*)selectedIndexPath { + NSIndexPath* selectedIndexPath = nil; + NSArray* selections = [self tableSelectionPaths]; + if ([selections count]) { + DCHECK([selections count] == 1); // Should be exactly one selection. + selectedIndexPath = [selections objectAtIndex:0]; + } + return selectedIndexPath; +} + +- (BookmarkFolderInfo*)selectedFolder { + BookmarkFolderInfo* item = nil; + NSIndexPath* selectedIndexPath = [self selectedIndexPath]; + if (selectedIndexPath) { + item = [self folderForIndexPath:selectedIndexPath]; + } + return item; +} + +- (const BookmarkNode*)selectedNode { + const BookmarkNode* selectedNode = NULL; + // Determine a new parent node only if the browser is showing. + if (configuration_ == BookmarkEditor::SHOW_TREE) { + BookmarkFolderInfo* folderInfo = [self selectedFolder]; + if (folderInfo) + selectedNode = [folderInfo folderNode]; + } else { + // If the tree is not showing then we use the original parent. + selectedNode = parentNode_; + } + return selectedNode; +} + +- (NSArray*)folderTreeArray { + return folderTreeArray_.get(); +} + +- (NSArray*)tableSelectionPaths { + return tableSelectionPaths_.get(); +} + +- (void)setTableSelectionPath:(NSIndexPath*)tableSelectionPath { + [self setTableSelectionPaths:[NSArray arrayWithObject:tableSelectionPath]]; +} + +- (void)setTableSelectionPaths:(NSArray*)tableSelectionPaths { + tableSelectionPaths_.reset([tableSelectionPaths retain]); +} + +- (void)selectNodeInBrowser:(const BookmarkNode*)node { + DCHECK(configuration_ == BookmarkEditor::SHOW_TREE); + NSIndexPath* selectionPath = [self selectionPathForNode:node]; + [self willChangeValueForKey:@"okEnabled"]; + [self setTableSelectionPath:selectionPath]; + [self didChangeValueForKey:@"okEnabled"]; +} + +- (NSIndexPath*)selectionPathForNode:(const BookmarkNode*)desiredNode { + // Back up the parent chaing for desiredNode, building up a stack + // of ancestor nodes. Then crawl down the folderTreeArray looking + // for each ancestor in order while building up the selectionPath. + std::stack<const BookmarkNode*> nodeStack; + BookmarkModel* model = profile_->GetBookmarkModel(); + const BookmarkNode* rootNode = model->root_node(); + const BookmarkNode* node = desiredNode; + while (node != rootNode) { + DCHECK(node); + nodeStack.push(node); + node = node->GetParent(); + } + NSUInteger stackSize = nodeStack.size(); + + NSIndexPath* path = nil; + NSArray* folders = [self folderTreeArray]; + while (!nodeStack.empty()) { + node = nodeStack.top(); + nodeStack.pop(); + // Find node in the current folders array. + NSUInteger i = 0; + for (BookmarkFolderInfo *folderInfo in folders) { + const BookmarkNode* testNode = [folderInfo folderNode]; + if (testNode == node) { + path = path ? [path indexPathByAddingIndex:i] : + [NSIndexPath indexPathWithIndex:i]; + folders = [folderInfo children]; + break; + } + ++i; + } + } + DCHECK([path length] == stackSize); + return path; +} + +- (NSMutableArray*)addChildFoldersFromNode:(const BookmarkNode*)node { + NSMutableArray* childFolders = nil; + int childCount = node->GetChildCount(); + for (int i = 0; i < childCount; ++i) { + const BookmarkNode* childNode = node->GetChild(i); + if (childNode->type() != BookmarkNode::URL) { + NSString* childName = base::SysUTF16ToNSString(childNode->GetTitle()); + NSMutableArray* children = [self addChildFoldersFromNode:childNode]; + BookmarkFolderInfo* folderInfo = + [BookmarkFolderInfo bookmarkFolderInfoWithFolderName:childName + folderNode:childNode + children:children]; + if (!childFolders) + childFolders = [NSMutableArray arrayWithObject:folderInfo]; + else + [childFolders addObject:folderInfo]; + } + } + return childFolders; +} + +- (void)buildFolderTree { + // Build up a tree of the current folder configuration. + BookmarkModel* model = profile_->GetBookmarkModel(); + const BookmarkNode* rootNode = model->root_node(); + NSMutableArray* baseArray = [self addChildFoldersFromNode:rootNode]; + DCHECK(baseArray); + [self willChangeValueForKey:@"folderTreeArray"]; + folderTreeArray_.reset([baseArray retain]); + [self didChangeValueForKey:@"folderTreeArray"]; +} + +- (void)modelChangedPreserveSelection:(BOOL)preserve { + const BookmarkNode* selectedNode = [self selectedNode]; + [self buildFolderTree]; + if (preserve && + selectedNode && + configuration_ == BookmarkEditor::SHOW_TREE) + [self selectNodeInBrowser:selectedNode]; +} + +- (void)nodeRemoved:(const BookmarkNode*)node + fromParent:(const BookmarkNode*)parent { + if (node->is_folder()) { + if (parentNode_ == node || parentNode_->HasAncestor(node)) { + parentNode_ = [self bookmarkModel]->GetBookmarkBarNode(); + if (configuration_ != BookmarkEditor::SHOW_TREE) { + // The user can't select a different folder, so just close up shop. + [self cancel:self]; + return; + } + } + + if (configuration_ == BookmarkEditor::SHOW_TREE) { + // For safety's sake, in case deleted node was an ancestor of selection, + // go back to a known safe place. + [self selectNodeInBrowser:parentNode_]; + } + } +} + +#pragma mark New Folder Handler + +- (void)createNewFoldersForFolder:(BookmarkFolderInfo*)folderInfo + selectedFolderInfo:(BookmarkFolderInfo*)selectedFolderInfo { + NSArray* subfolders = [folderInfo children]; + const BookmarkNode* parentNode = [folderInfo folderNode]; + DCHECK(parentNode); + NSUInteger i = 0; + for (BookmarkFolderInfo* subFolderInfo in subfolders) { + if ([subFolderInfo newFolder]) { + BookmarkModel* model = [self bookmarkModel]; + const BookmarkNode* newFolder = + model->AddGroup(parentNode, i, + base::SysNSStringToUTF16([subFolderInfo folderName])); + // Update our dictionary with the actual folder node just created. + [subFolderInfo setFolderNode:newFolder]; + [subFolderInfo setNewFolder:NO]; + // If the newly created folder was selected, update the selection path. + if (subFolderInfo == selectedFolderInfo) { + NSIndexPath* selectionPath = [self selectionPathForNode:newFolder]; + [self setTableSelectionPath:selectionPath]; + } + } + [self createNewFoldersForFolder:subFolderInfo + selectedFolderInfo:selectedFolderInfo]; + ++i; + } +} + +- (IBAction)newFolder:(id)sender { + // Create a new folder off of the selected folder node. + BookmarkFolderInfo* parentInfo = [self selectedFolder]; + if (parentInfo) { + NSIndexPath* selection = [self selectedIndexPath]; + NSString* newFolderName = + l10n_util::GetNSStringWithFixup(IDS_BOOMARK_EDITOR_NEW_FOLDER_NAME); + BookmarkFolderInfo* folderInfo = + [BookmarkFolderInfo bookmarkFolderInfoWithFolderName:newFolderName]; + [self willChangeValueForKey:@"folderTreeArray"]; + NSMutableArray* children = [parentInfo children]; + if (children) { + [children addObject:folderInfo]; + } else { + children = [NSMutableArray arrayWithObject:folderInfo]; + [parentInfo setChildren:children]; + } + [self didChangeValueForKey:@"folderTreeArray"]; + + // Expose the parent folder children. + [folderTreeView_ expandItem:parentInfo]; + + // Select the new folder node and put the folder name into edit mode. + selection = [selection indexPathByAddingIndex:[children count] - 1]; + [self setTableSelectionPath:selection]; + NSInteger row = [folderTreeView_ selectedRow]; + DCHECK(row >= 0); + [folderTreeView_ editColumn:0 row:row withEvent:nil select:YES]; + } +} + +- (void)createNewFolders { + // Turn off notifications while "importing" folders (as created in the sheet). + observer_->BookmarkImportBeginning([self bookmarkModel]); + // Scan the tree looking for nodes marked 'newFolder' and create those nodes. + NSArray* folderTreeArray = [self folderTreeArray]; + for (BookmarkFolderInfo *folderInfo in folderTreeArray) { + [self createNewFoldersForFolder:folderInfo + selectedFolderInfo:[self selectedFolder]]; + } + // Notifications back on. + observer_->BookmarkImportEnding([self bookmarkModel]); +} + +#pragma mark For Unit Test Use Only + +- (BOOL)okButtonEnabled { + return [okButton_ isEnabled]; +} + +- (void)selectTestNodeInBrowser:(const BookmarkNode*)node { + [self selectNodeInBrowser:node]; +} + +@end // BookmarkEditorBaseController + +@implementation BookmarkFolderInfo + +@synthesize folderName = folderName_; +@synthesize folderNode = folderNode_; +@synthesize children = children_; +@synthesize newFolder = newFolder_; + ++ (id)bookmarkFolderInfoWithFolderName:(NSString*)folderName + folderNode:(const BookmarkNode*)folderNode + children:(NSMutableArray*)children { + return [[[BookmarkFolderInfo alloc] initWithFolderName:folderName + folderNode:folderNode + children:children + newFolder:NO] + autorelease]; +} + ++ (id)bookmarkFolderInfoWithFolderName:(NSString*)folderName { + return [[[BookmarkFolderInfo alloc] initWithFolderName:folderName + folderNode:NULL + children:nil + newFolder:YES] + autorelease]; +} + +- (id)initWithFolderName:(NSString*)folderName + folderNode:(const BookmarkNode*)folderNode + children:(NSMutableArray*)children + newFolder:(BOOL)newFolder { + if ((self = [super init])) { + // A folderName is always required, and if newFolder is NO then there + // should be a folderNode. Children is optional. + DCHECK(folderName && (newFolder || folderNode)); + if (folderName && (newFolder || folderNode)) { + folderName_ = [folderName copy]; + folderNode_ = folderNode; + children_ = [children retain]; + newFolder_ = newFolder; + } else { + NOTREACHED(); // Invalid init. + [self release]; + self = nil; + } + } + return self; +} + +- (id)init { + NOTREACHED(); // Should never be called. + return [self initWithFolderName:nil folderNode:nil children:nil newFolder:NO]; +} + +- (void)dealloc { + [folderName_ release]; + [children_ release]; + [super dealloc]; +} + +// Implementing isEqual: allows the NSTreeController to preserve the selection +// and open/shut state of outline items when the data changes. +- (BOOL)isEqual:(id)other { + return [other isKindOfClass:[BookmarkFolderInfo class]] && + folderNode_ == [(BookmarkFolderInfo*)other folderNode]; +} + +@end diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_editor_base_controller_unittest.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_editor_base_controller_unittest.mm new file mode 100644 index 0000000..6325346 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_editor_base_controller_unittest.mm @@ -0,0 +1,235 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "app/l10n_util_mac.h" +#include "base/scoped_nsobject.h" +#include "base/sys_string_conversions.h" +#include "base/utf_string_conversions.h" +#include "chrome/browser/bookmarks/bookmark_model.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_editor_controller.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "grit/generated_resources.h" +#include "testing/gtest/include/gtest/gtest.h" +#import "testing/gtest_mac.h" +#include "testing/platform_test.h" + +class BookmarkEditorBaseControllerTest : public CocoaTest { + public: + BrowserTestHelper browser_helper_; + BookmarkEditorBaseController* controller_; // weak + const BookmarkNode* group_a_; + const BookmarkNode* group_b_; + const BookmarkNode* group_b_0_; + const BookmarkNode* group_b_3_; + const BookmarkNode* group_c_; + + BookmarkEditorBaseControllerTest() { + // Set up a small bookmark hierarchy, which will look as follows: + // a b c d + // a-0 b-0 c-0 + // a-1 b-00 c-1 + // a-2 b-1 c-2 + // b-2 c-3 + // b-3 + // b-30 + // b-31 + // b-4 + BookmarkModel& model(*(browser_helper_.profile()->GetBookmarkModel())); + const BookmarkNode* root = model.GetBookmarkBarNode(); + group_a_ = model.AddGroup(root, 0, ASCIIToUTF16("a")); + model.AddURL(group_a_, 0, ASCIIToUTF16("a-0"), GURL("http://a-0.com")); + model.AddURL(group_a_, 1, ASCIIToUTF16("a-1"), GURL("http://a-1.com")); + model.AddURL(group_a_, 2, ASCIIToUTF16("a-2"), GURL("http://a-2.com")); + + group_b_ = model.AddGroup(root, 1, ASCIIToUTF16("b")); + group_b_0_ = model.AddGroup(group_b_, 0, ASCIIToUTF16("b-0")); + model.AddURL(group_b_0_, 0, ASCIIToUTF16("bb-0"), GURL("http://bb-0.com")); + model.AddURL(group_b_, 1, ASCIIToUTF16("b-1"), GURL("http://b-1.com")); + model.AddURL(group_b_, 2, ASCIIToUTF16("b-2"), GURL("http://b-2.com")); + group_b_3_ = model.AddGroup(group_b_, 3, ASCIIToUTF16("b-3")); + model.AddURL(group_b_3_, 0, ASCIIToUTF16("b-30"), GURL("http://b-30.com")); + model.AddURL(group_b_3_, 1, ASCIIToUTF16("b-31"), GURL("http://b-31.com")); + model.AddURL(group_b_, 4, ASCIIToUTF16("b-4"), GURL("http://b-4.com")); + + group_c_ = model.AddGroup(root, 2, ASCIIToUTF16("c")); + model.AddURL(group_c_, 0, ASCIIToUTF16("c-0"), GURL("http://c-0.com")); + model.AddURL(group_c_, 1, ASCIIToUTF16("c-1"), GURL("http://c-1.com")); + model.AddURL(group_c_, 2, ASCIIToUTF16("c-2"), GURL("http://c-2.com")); + model.AddURL(group_c_, 3, ASCIIToUTF16("c-3"), GURL("http://c-3.com")); + + model.AddURL(root, 3, ASCIIToUTF16("d"), GURL("http://d-0.com")); + } + + virtual BookmarkEditorBaseController* CreateController() { + return [[BookmarkEditorBaseController alloc] + initWithParentWindow:test_window() + nibName:@"BookmarkAllTabs" + profile:browser_helper_.profile() + parent:group_b_0_ + configuration:BookmarkEditor::SHOW_TREE]; + } + + virtual void SetUp() { + CocoaTest::SetUp(); + controller_ = CreateController(); + EXPECT_TRUE([controller_ window]); + [controller_ runAsModalSheet]; + } + + virtual void TearDown() { + controller_ = NULL; + CocoaTest::TearDown(); + } +}; + +TEST_F(BookmarkEditorBaseControllerTest, VerifyBookmarkTestModel) { + BookmarkModel& model(*(browser_helper_.profile()->GetBookmarkModel())); + const BookmarkNode& root(*model.GetBookmarkBarNode()); + EXPECT_EQ(4, root.GetChildCount()); + // a + const BookmarkNode* child = root.GetChild(0); + EXPECT_EQ(3, child->GetChildCount()); + const BookmarkNode* subchild = child->GetChild(0); + EXPECT_EQ(0, subchild->GetChildCount()); + subchild = child->GetChild(1); + EXPECT_EQ(0, subchild->GetChildCount()); + subchild = child->GetChild(2); + EXPECT_EQ(0, subchild->GetChildCount()); + // b + child = root.GetChild(1); + EXPECT_EQ(5, child->GetChildCount()); + subchild = child->GetChild(0); + EXPECT_EQ(1, subchild->GetChildCount()); + const BookmarkNode* subsubchild = subchild->GetChild(0); + EXPECT_EQ(0, subsubchild->GetChildCount()); + subchild = child->GetChild(1); + EXPECT_EQ(0, subchild->GetChildCount()); + subchild = child->GetChild(2); + EXPECT_EQ(0, subchild->GetChildCount()); + subchild = child->GetChild(3); + EXPECT_EQ(2, subchild->GetChildCount()); + subsubchild = subchild->GetChild(0); + EXPECT_EQ(0, subsubchild->GetChildCount()); + subsubchild = subchild->GetChild(1); + EXPECT_EQ(0, subsubchild->GetChildCount()); + subchild = child->GetChild(4); + EXPECT_EQ(0, subchild->GetChildCount()); + // c + child = root.GetChild(2); + EXPECT_EQ(4, child->GetChildCount()); + subchild = child->GetChild(0); + EXPECT_EQ(0, subchild->GetChildCount()); + subchild = child->GetChild(1); + EXPECT_EQ(0, subchild->GetChildCount()); + subchild = child->GetChild(2); + EXPECT_EQ(0, subchild->GetChildCount()); + subchild = child->GetChild(3); + EXPECT_EQ(0, subchild->GetChildCount()); + // d + child = root.GetChild(3); + EXPECT_EQ(0, child->GetChildCount()); + [controller_ cancel:nil]; +} + +TEST_F(BookmarkEditorBaseControllerTest, NodeSelection) { + EXPECT_TRUE([controller_ folderTreeArray]); + [controller_ selectTestNodeInBrowser:group_b_3_]; + const BookmarkNode* node = [controller_ selectedNode]; + EXPECT_EQ(node, group_b_3_); + [controller_ cancel:nil]; +} + +TEST_F(BookmarkEditorBaseControllerTest, CreateFolder) { + EXPECT_EQ(2, group_b_3_->GetChildCount()); + [controller_ selectTestNodeInBrowser:group_b_3_]; + NSString* expectedName = + l10n_util::GetNSStringWithFixup(IDS_BOOMARK_EDITOR_NEW_FOLDER_NAME); + [controller_ setDisplayName:expectedName]; + [controller_ newFolder:nil]; + NSArray* selectionPaths = [controller_ tableSelectionPaths]; + EXPECT_EQ(1U, [selectionPaths count]); + NSIndexPath* selectionPath = [selectionPaths objectAtIndex:0]; + EXPECT_EQ(4U, [selectionPath length]); + BookmarkFolderInfo* newFolderInfo = [controller_ selectedFolder]; + EXPECT_TRUE(newFolderInfo); + NSString* newFolderName = [newFolderInfo folderName]; + EXPECT_NSEQ(expectedName, newFolderName); + [controller_ createNewFolders]; + // Verify that the tab folder was added to the new folder. + EXPECT_EQ(3, group_b_3_->GetChildCount()); + [controller_ cancel:nil]; +} + +TEST_F(BookmarkEditorBaseControllerTest, CreateTwoFolders) { + BookmarkModel* model = browser_helper_.profile()->GetBookmarkModel(); + const BookmarkNode* bar = model->GetBookmarkBarNode(); + // Create 2 folders which are children of the bar. + [controller_ selectTestNodeInBrowser:bar]; + [controller_ newFolder:nil]; + [controller_ selectTestNodeInBrowser:bar]; + [controller_ newFolder:nil]; + // If we do NOT crash on createNewFolders, success! + // (e.g. http://crbug.com/47877 is fixed). + [controller_ createNewFolders]; + [controller_ cancel:nil]; +} + +TEST_F(BookmarkEditorBaseControllerTest, SelectedFolderDeleted) { + BookmarkModel& model(*(browser_helper_.profile()->GetBookmarkModel())); + [controller_ selectTestNodeInBrowser:group_b_3_]; + EXPECT_EQ(group_b_3_, [controller_ selectedNode]); + + // Delete the selected node, and verify it's no longer selected: + model.Remove(group_b_, 3); + EXPECT_NE(group_b_3_, [controller_ selectedNode]); + + [controller_ cancel:nil]; +} + +TEST_F(BookmarkEditorBaseControllerTest, SelectedFoldersParentDeleted) { + BookmarkModel& model(*(browser_helper_.profile()->GetBookmarkModel())); + const BookmarkNode* root = model.GetBookmarkBarNode(); + [controller_ selectTestNodeInBrowser:group_b_3_]; + EXPECT_EQ(group_b_3_, [controller_ selectedNode]); + + // Delete the selected node's parent, and verify it's no longer selected: + model.Remove(root, 1); + EXPECT_NE(group_b_3_, [controller_ selectedNode]); + + [controller_ cancel:nil]; +} + +TEST_F(BookmarkEditorBaseControllerTest, FolderAdded) { + BookmarkModel& model(*(browser_helper_.profile()->GetBookmarkModel())); + const BookmarkNode* root = model.GetBookmarkBarNode(); + + // Add a group node to the model, and verify it can be selected in the tree: + const BookmarkNode* group_added = model.AddGroup(root, 0, + ASCIIToUTF16("added")); + [controller_ selectTestNodeInBrowser:group_added]; + EXPECT_EQ(group_added, [controller_ selectedNode]); + + [controller_ cancel:nil]; +} + + +class BookmarkFolderInfoTest : public CocoaTest { }; + +TEST_F(BookmarkFolderInfoTest, Construction) { + NSMutableArray* children = [NSMutableArray arrayWithObject:@"child"]; + // We just need a pointer, and any pointer will do. + const BookmarkNode* fakeNode = + reinterpret_cast<const BookmarkNode*>(&children); + BookmarkFolderInfo* info = + [BookmarkFolderInfo bookmarkFolderInfoWithFolderName:@"name" + folderNode:fakeNode + children:children]; + EXPECT_TRUE(info); + EXPECT_EQ([info folderName], @"name"); + EXPECT_EQ([info children], children); + EXPECT_EQ([info folderNode], fakeNode); +} diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_editor_controller.h b/chrome/browser/ui/cocoa/bookmarks/bookmark_editor_controller.h new file mode 100644 index 0000000..30f1c75 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_editor_controller.h @@ -0,0 +1,36 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_EDITOR_CONTROLLER_H_ +#define CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_EDITOR_CONTROLLER_H_ +#pragma once + +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_editor_base_controller.h" + +// A controller for the bookmark editor, opened by 1) Edit... from the +// context menu of a bookmark button, and 2) Bookmark this Page...'s Edit +// button. +@interface BookmarkEditorController : BookmarkEditorBaseController { + @private + const BookmarkNode* node_; // weak; owned by the model + scoped_nsobject<NSString> initialUrl_; + NSString* displayURL_; // Bound to a text field in the dialog. + IBOutlet NSTextField* urlField_; +} + +@property (nonatomic, copy) NSString* displayURL; + +- (id)initWithParentWindow:(NSWindow*)parentWindow + profile:(Profile*)profile + parent:(const BookmarkNode*)parent + node:(const BookmarkNode*)node + configuration:(BookmarkEditor::Configuration)configuration; + +@end + +@interface BookmarkEditorController (UnitTesting) +- (NSColor *)urlFieldColor; +@end + +#endif // CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_EDITOR_CONTROLLER_H_ diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_editor_controller.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_editor_controller.mm new file mode 100644 index 0000000..88ed4bf --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_editor_controller.mm @@ -0,0 +1,143 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_editor_controller.h" + +#include "app/l10n_util.h" +#include "base/string16.h" +#include "base/sys_string_conversions.h" +#include "chrome/browser/bookmarks/bookmark_model.h" + +@interface BookmarkEditorController (Private) + +// Grab the url from the text field and convert. +- (GURL)GURLFromUrlField; + +@end + +@implementation BookmarkEditorController + +@synthesize displayURL = displayURL_; + ++ (NSSet*)keyPathsForValuesAffectingOkEnabled { + return [NSSet setWithObject:@"displayURL"]; +} + +- (id)initWithParentWindow:(NSWindow*)parentWindow + profile:(Profile*)profile + parent:(const BookmarkNode*)parent + node:(const BookmarkNode*)node + configuration:(BookmarkEditor::Configuration)configuration { + if ((self = [super initWithParentWindow:parentWindow + nibName:@"BookmarkEditor" + profile:profile + parent:parent + configuration:configuration])) { + // "Add Page..." has no "node" so this may be NULL. + node_ = node; + } + return self; +} + +- (void)dealloc { + [displayURL_ release]; + [super dealloc]; +} + +- (void)awakeFromNib { + // Set text fields to match our bookmark. If the node is NULL we + // arrived here from an "Add Page..." item in a context menu. + if (node_) { + [self setInitialName:base::SysUTF16ToNSString(node_->GetTitle())]; + std::string url_string = node_->GetURL().possibly_invalid_spec(); + initialUrl_.reset([[NSString stringWithUTF8String:url_string.c_str()] + retain]); + } else { + initialUrl_.reset([@"" retain]); + } + [self setDisplayURL:initialUrl_]; + [super awakeFromNib]; +} + +- (void)nodeRemoved:(const BookmarkNode*)node + fromParent:(const BookmarkNode*)parent +{ + // Be conservative; it is needed (e.g. "Add Page...") + node_ = NULL; + [self cancel:self]; +} + +#pragma mark Bookmark Editing + +// If possible, return a valid GURL from the URL text field. +- (GURL)GURLFromUrlField { + NSString* url = [self displayURL]; + GURL newURL = GURL([url UTF8String]); + if (!newURL.is_valid()) { + // Mimic observed friendliness from Windows + newURL = GURL([[NSString stringWithFormat:@"http://%@", url] UTF8String]); + } + return newURL; +} + +// Enable the OK button if there is a valid URL. +- (BOOL)okEnabled { + BOOL okEnabled = NO; + if ([[self displayURL] length]) { + GURL newURL = [self GURLFromUrlField]; + okEnabled = (newURL.is_valid()) ? YES : NO; + } + if (okEnabled) + [urlField_ setBackgroundColor:[NSColor whiteColor]]; + else + [urlField_ setBackgroundColor:[NSColor colorWithCalibratedRed:1.0 + green:0.67 + blue:0.67 + alpha:1.0]]; + return okEnabled; +} + +// The the bookmark's URL is assumed to be valid (otherwise the OK button +// should not be enabled). Previously existing bookmarks for which the +// parent has not changed are updated in-place. Those for which the parent +// has changed are removed with a new node created under the new parent. +// Called by -[BookmarkEditorBaseController ok:]. +- (NSNumber*)didCommit { + NSString* name = [[self displayName] stringByTrimmingCharactersInSet: + [NSCharacterSet newlineCharacterSet]]; + string16 newTitle = base::SysNSStringToUTF16(name); + const BookmarkNode* newParentNode = [self selectedNode]; + GURL newURL = [self GURLFromUrlField]; + if (!newURL.is_valid()) { + // Shouldn't be reached -- OK button should be disabled if not valid! + NOTREACHED(); + return [NSNumber numberWithBool:NO]; + } + + // Determine where the new/replacement bookmark is to go. + BookmarkModel* model = [self bookmarkModel]; + // If there was an old node then we update the node, and move it to its new + // parent if the parent has changed (rather than deleting it from the old + // parent and adding to the new -- which also prevents the 'poofing' that + // occurs when a node is deleted). + if (node_) { + model->SetURL(node_, newURL); + model->SetTitle(node_, newTitle); + const BookmarkNode* oldParentNode = [self parentNode]; + if (newParentNode != oldParentNode) + model->Move(node_, newParentNode, newParentNode->GetChildCount()); + } else { + // Otherwise, add a new bookmark at the end of the newly selected folder. + model->AddURL(newParentNode, newParentNode->GetChildCount(), newTitle, + newURL); + } + return [NSNumber numberWithBool:YES]; +} + +- (NSColor *)urlFieldColor { + return [urlField_ backgroundColor]; +} + +@end // BookmarkEditorController + diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_editor_controller_unittest.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_editor_controller_unittest.mm new file mode 100644 index 0000000..8f49c6d --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_editor_controller_unittest.mm @@ -0,0 +1,423 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/string16.h" +#include "base/sys_string_conversions.h" +#include "base/utf_string_conversions.h" +#include "chrome/browser/bookmarks/bookmark_model.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_editor_controller.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "testing/gtest/include/gtest/gtest.h" +#import "testing/gtest_mac.h" +#include "testing/platform_test.h" + +class BookmarkEditorControllerTest : public CocoaTest { + public: + BrowserTestHelper browser_helper_; + const BookmarkNode* default_node_; + const BookmarkNode* default_parent_; + const char* default_name_; + string16 default_title_; + BookmarkEditorController* controller_; + + virtual void SetUp() { + CocoaTest::SetUp(); + BookmarkModel* model = browser_helper_.profile()->GetBookmarkModel(); + default_parent_ = model->GetBookmarkBarNode(); + default_name_ = "http://www.zim-bop-a-dee.com/"; + default_title_ = ASCIIToUTF16("ooh title"); + const BookmarkNode* default_node = model->AddURL(default_parent_, 0, + default_title_, + GURL(default_name_)); + controller_ = [[BookmarkEditorController alloc] + initWithParentWindow:test_window() + profile:browser_helper_.profile() + parent:default_parent_ + node:default_node + configuration:BookmarkEditor::NO_TREE]; + [controller_ runAsModalSheet]; + } + + virtual void TearDown() { + controller_ = NULL; + CocoaTest::TearDown(); + } +}; + +TEST_F(BookmarkEditorControllerTest, NoEdit) { + [controller_ cancel:nil]; + ASSERT_EQ(default_parent_->GetChildCount(), 1); + const BookmarkNode* child = default_parent_->GetChild(0); + EXPECT_EQ(child->GetTitle(), default_title_); + EXPECT_EQ(child->GetURL(), GURL(default_name_)); +} + +TEST_F(BookmarkEditorControllerTest, EditTitle) { + [controller_ setDisplayName:@"whamma jamma bamma"]; + [controller_ ok:nil]; + ASSERT_EQ(default_parent_->GetChildCount(), 1); + const BookmarkNode* child = default_parent_->GetChild(0); + EXPECT_EQ(child->GetTitle(), ASCIIToUTF16("whamma jamma bamma")); + EXPECT_EQ(child->GetURL(), GURL(default_name_)); +} + +TEST_F(BookmarkEditorControllerTest, EditURL) { + EXPECT_TRUE([controller_ okButtonEnabled]); + [controller_ setDisplayURL:@"http://yellow-sneakers.com/"]; + EXPECT_TRUE([controller_ okButtonEnabled]); + [controller_ ok:nil]; + ASSERT_EQ(default_parent_->GetChildCount(), 1); + const BookmarkNode* child = default_parent_->GetChild(0); + EXPECT_EQ(child->GetTitle(), default_title_); + EXPECT_EQ(child->GetURL(), GURL("http://yellow-sneakers.com/")); +} + +TEST_F(BookmarkEditorControllerTest, EditAndFixPrefix) { + [controller_ setDisplayURL:@"x"]; + [controller_ ok:nil]; + ASSERT_EQ(default_parent_->GetChildCount(), 1); + const BookmarkNode* child = default_parent_->GetChild(0); + EXPECT_TRUE(child->GetURL().is_valid()); +} + +TEST_F(BookmarkEditorControllerTest, NodeDeleted) { + // Delete the bookmark being edited and verify the sheet cancels itself: + ASSERT_TRUE([test_window() attachedSheet]); + BookmarkModel* model = browser_helper_.profile()->GetBookmarkModel(); + model->Remove(default_parent_, 0); + ASSERT_FALSE([test_window() attachedSheet]); +} + +TEST_F(BookmarkEditorControllerTest, EditAndConfirmOKButton) { + // Confirm OK button enabled/disabled as appropriate: + // First test the URL. + EXPECT_TRUE([controller_ okButtonEnabled]); + [controller_ setDisplayURL:@""]; + EXPECT_FALSE([controller_ okButtonEnabled]); + [controller_ setDisplayURL:@"http://www.cnn.com"]; + EXPECT_TRUE([controller_ okButtonEnabled]); + // Then test the name. + [controller_ setDisplayName:@""]; + EXPECT_TRUE([controller_ okButtonEnabled]); + [controller_ setDisplayName:@" "]; + EXPECT_TRUE([controller_ okButtonEnabled]); + // Then little mix of both. + [controller_ setDisplayName:@"name"]; + EXPECT_TRUE([controller_ okButtonEnabled]); + [controller_ setDisplayURL:@""]; + EXPECT_FALSE([controller_ okButtonEnabled]); + [controller_ cancel:nil]; +} + +TEST_F(BookmarkEditorControllerTest, GoodAndBadURLsChangeColor) { + // Confirm that the background color of the URL edit field changes + // based on whether it contains a valid or invalid URL. + [controller_ setDisplayURL:@"http://www.cnn.com"]; + NSColor *urlColorA = [controller_ urlFieldColor]; + EXPECT_TRUE(urlColorA); + [controller_ setDisplayURL:@""]; + NSColor *urlColorB = [controller_ urlFieldColor]; + EXPECT_TRUE(urlColorB); + EXPECT_NSNE(urlColorA, urlColorB); + [controller_ setDisplayURL:@"http://www.google.com"]; + [controller_ cancel:nil]; + urlColorB = [controller_ urlFieldColor]; + EXPECT_TRUE(urlColorB); + EXPECT_NSEQ(urlColorA, urlColorB); +} + +class BookmarkEditorControllerNoNodeTest : public CocoaTest { + public: + BrowserTestHelper browser_helper_; + BookmarkEditorController* controller_; + + virtual void SetUp() { + CocoaTest::SetUp(); + BookmarkModel* model = browser_helper_.profile()->GetBookmarkModel(); + const BookmarkNode* parent = model->GetBookmarkBarNode(); + controller_ = [[BookmarkEditorController alloc] + initWithParentWindow:test_window() + profile:browser_helper_.profile() + parent:parent + node:NULL + configuration:BookmarkEditor::NO_TREE]; + + [controller_ runAsModalSheet]; + } + + virtual void TearDown() { + controller_ = NULL; + CocoaTest::TearDown(); + } +}; + +TEST_F(BookmarkEditorControllerNoNodeTest, NoNodeNoTree) { + EXPECT_EQ(@"", [controller_ displayName]); + EXPECT_EQ(@"", [controller_ displayURL]); + EXPECT_FALSE([controller_ okButtonEnabled]); + [controller_ cancel:nil]; +} + +class BookmarkEditorControllerYesNodeTest : public CocoaTest { + public: + BrowserTestHelper browser_helper_; + string16 default_title_; + const char* url_name_; + BookmarkEditorController* controller_; + + virtual void SetUp() { + CocoaTest::SetUp(); + BookmarkModel* model = browser_helper_.profile()->GetBookmarkModel(); + const BookmarkNode* parent = model->GetBookmarkBarNode(); + default_title_ = ASCIIToUTF16("wooh title"); + url_name_ = "http://www.zoom-baby-doo-da.com/"; + const BookmarkNode* node = model->AddURL(parent, 0, default_title_, + GURL(url_name_)); + controller_ = [[BookmarkEditorController alloc] + initWithParentWindow:test_window() + profile:browser_helper_.profile() + parent:parent + node:node + configuration:BookmarkEditor::NO_TREE]; + + [controller_ runAsModalSheet]; + } + + virtual void TearDown() { + controller_ = NULL; + CocoaTest::TearDown(); + } +}; + +TEST_F(BookmarkEditorControllerYesNodeTest, YesNodeShowTree) { + EXPECT_NSEQ(base::SysUTF16ToNSString(default_title_), + [controller_ displayName]); + EXPECT_NSEQ([NSString stringWithCString:url_name_ + encoding:NSUTF8StringEncoding], + [controller_ displayURL]); + [controller_ cancel:nil]; +} + +class BookmarkEditorControllerTreeTest : public CocoaTest { + + public: + BrowserTestHelper browser_helper_; + BookmarkEditorController* controller_; + const BookmarkNode* group_a_; + const BookmarkNode* group_b_; + const BookmarkNode* group_bb_; + const BookmarkNode* group_c_; + const BookmarkNode* bookmark_bb_3_; + GURL bb3_url_1_; + GURL bb3_url_2_; + + BookmarkEditorControllerTreeTest() { + // Set up a small bookmark hierarchy, which will look as follows: + // a b c d + // a-0 b-0 c-0 + // a-1 bb-0 c-1 + // a-2 bb-1 c-2 + // bb-2 + // bb-3 + // bb-4 + // b-1 + // b-2 + BookmarkModel& model(*(browser_helper_.profile()->GetBookmarkModel())); + const BookmarkNode* root = model.GetBookmarkBarNode(); + group_a_ = model.AddGroup(root, 0, ASCIIToUTF16("a")); + model.AddURL(group_a_, 0, ASCIIToUTF16("a-0"), GURL("http://a-0.com")); + model.AddURL(group_a_, 1, ASCIIToUTF16("a-1"), GURL("http://a-1.com")); + model.AddURL(group_a_, 2, ASCIIToUTF16("a-2"), GURL("http://a-2.com")); + + group_b_ = model.AddGroup(root, 1, ASCIIToUTF16("b")); + model.AddURL(group_b_, 0, ASCIIToUTF16("b-0"), GURL("http://b-0.com")); + group_bb_ = model.AddGroup(group_b_, 1, ASCIIToUTF16("bb")); + model.AddURL(group_bb_, 0, ASCIIToUTF16("bb-0"), GURL("http://bb-0.com")); + model.AddURL(group_bb_, 1, ASCIIToUTF16("bb-1"), GURL("http://bb-1.com")); + model.AddURL(group_bb_, 2, ASCIIToUTF16("bb-2"), GURL("http://bb-2.com")); + + // To find it later, this bookmark name must always have a URL + // of http://bb-3.com or https://bb-3.com + bb3_url_1_ = GURL("http://bb-3.com"); + bb3_url_2_ = GURL("https://bb-3.com"); + bookmark_bb_3_ = model.AddURL(group_bb_, 3, ASCIIToUTF16("bb-3"), + bb3_url_1_); + + model.AddURL(group_bb_, 4, ASCIIToUTF16("bb-4"), GURL("http://bb-4.com")); + model.AddURL(group_b_, 2, ASCIIToUTF16("b-1"), GURL("http://b-2.com")); + model.AddURL(group_b_, 3, ASCIIToUTF16("b-2"), GURL("http://b-3.com")); + + group_c_ = model.AddGroup(root, 2, ASCIIToUTF16("c")); + model.AddURL(group_c_, 0, ASCIIToUTF16("c-0"), GURL("http://c-0.com")); + model.AddURL(group_c_, 1, ASCIIToUTF16("c-1"), GURL("http://c-1.com")); + model.AddURL(group_c_, 2, ASCIIToUTF16("c-2"), GURL("http://c-2.com")); + model.AddURL(group_c_, 3, ASCIIToUTF16("c-3"), GURL("http://c-3.com")); + + model.AddURL(root, 3, ASCIIToUTF16("d"), GURL("http://d-0.com")); + } + + virtual BookmarkEditorController* CreateController() { + return [[BookmarkEditorController alloc] + initWithParentWindow:test_window() + profile:browser_helper_.profile() + parent:group_bb_ + node:bookmark_bb_3_ + configuration:BookmarkEditor::SHOW_TREE]; + } + + virtual void SetUp() { + controller_ = CreateController(); + [controller_ runAsModalSheet]; + } + + virtual void TearDown() { + controller_ = NULL; + CocoaTest::TearDown(); + } + + // After changing a node, pointers to the node may be invalid. This + // is because the node itself may not be updated; it may removed and + // a new one is added in that location. (Implementation detail of + // BookmarkEditorController). This method updates the class's + // bookmark_bb_3_ so that it points to the new node for testing. + void UpdateBB3() { + std::vector<const BookmarkNode*> nodes; + BookmarkModel* model = browser_helper_.profile()->GetBookmarkModel(); + model->GetNodesByURL(bb3_url_1_, &nodes); + if (nodes.size() == 0) + model->GetNodesByURL(bb3_url_2_, &nodes); + DCHECK(nodes.size()); + bookmark_bb_3_ = nodes[0]; + } + +}; + +TEST_F(BookmarkEditorControllerTreeTest, VerifyBookmarkTestModel) { + BookmarkModel& model(*(browser_helper_.profile()->GetBookmarkModel())); + model.root_node(); + const BookmarkNode& root(*model.GetBookmarkBarNode()); + EXPECT_EQ(4, root.GetChildCount()); + const BookmarkNode* child = root.GetChild(0); + EXPECT_EQ(3, child->GetChildCount()); + const BookmarkNode* subchild = child->GetChild(0); + EXPECT_EQ(0, subchild->GetChildCount()); + subchild = child->GetChild(1); + EXPECT_EQ(0, subchild->GetChildCount()); + subchild = child->GetChild(2); + EXPECT_EQ(0, subchild->GetChildCount()); + + child = root.GetChild(1); + EXPECT_EQ(4, child->GetChildCount()); + subchild = child->GetChild(0); + EXPECT_EQ(0, subchild->GetChildCount()); + subchild = child->GetChild(1); + EXPECT_EQ(5, subchild->GetChildCount()); + const BookmarkNode* subsubchild = subchild->GetChild(0); + EXPECT_EQ(0, subsubchild->GetChildCount()); + subsubchild = subchild->GetChild(1); + EXPECT_EQ(0, subsubchild->GetChildCount()); + subsubchild = subchild->GetChild(2); + EXPECT_EQ(0, subsubchild->GetChildCount()); + subsubchild = subchild->GetChild(3); + EXPECT_EQ(0, subsubchild->GetChildCount()); + subsubchild = subchild->GetChild(4); + EXPECT_EQ(0, subsubchild->GetChildCount()); + subchild = child->GetChild(2); + EXPECT_EQ(0, subchild->GetChildCount()); + subchild = child->GetChild(3); + EXPECT_EQ(0, subchild->GetChildCount()); + + child = root.GetChild(2); + EXPECT_EQ(4, child->GetChildCount()); + subchild = child->GetChild(0); + EXPECT_EQ(0, subchild->GetChildCount()); + subchild = child->GetChild(1); + EXPECT_EQ(0, subchild->GetChildCount()); + subchild = child->GetChild(2); + EXPECT_EQ(0, subchild->GetChildCount()); + subchild = child->GetChild(3); + EXPECT_EQ(0, subchild->GetChildCount()); + + child = root.GetChild(3); + EXPECT_EQ(0, child->GetChildCount()); + [controller_ cancel:nil]; +} + +TEST_F(BookmarkEditorControllerTreeTest, RenameBookmarkInPlace) { + const BookmarkNode* oldParent = bookmark_bb_3_->GetParent(); + [controller_ setDisplayName:@"NEW NAME"]; + [controller_ ok:nil]; + UpdateBB3(); + const BookmarkNode* newParent = bookmark_bb_3_->GetParent(); + ASSERT_EQ(newParent, oldParent); + int childIndex = newParent->IndexOfChild(bookmark_bb_3_); + ASSERT_EQ(3, childIndex); +} + +TEST_F(BookmarkEditorControllerTreeTest, ChangeBookmarkURLInPlace) { + const BookmarkNode* oldParent = bookmark_bb_3_->GetParent(); + [controller_ setDisplayURL:@"https://bb-3.com"]; + [controller_ ok:nil]; + UpdateBB3(); + const BookmarkNode* newParent = bookmark_bb_3_->GetParent(); + ASSERT_EQ(newParent, oldParent); + int childIndex = newParent->IndexOfChild(bookmark_bb_3_); + ASSERT_EQ(3, childIndex); +} + +TEST_F(BookmarkEditorControllerTreeTest, ChangeBookmarkGroup) { + [controller_ selectTestNodeInBrowser:group_c_]; + [controller_ ok:nil]; + UpdateBB3(); + const BookmarkNode* parent = bookmark_bb_3_->GetParent(); + ASSERT_EQ(parent, group_c_); + int childIndex = parent->IndexOfChild(bookmark_bb_3_); + ASSERT_EQ(4, childIndex); +} + +TEST_F(BookmarkEditorControllerTreeTest, ChangeNameAndBookmarkGroup) { + [controller_ setDisplayName:@"NEW NAME"]; + [controller_ selectTestNodeInBrowser:group_c_]; + [controller_ ok:nil]; + UpdateBB3(); + const BookmarkNode* parent = bookmark_bb_3_->GetParent(); + ASSERT_EQ(parent, group_c_); + int childIndex = parent->IndexOfChild(bookmark_bb_3_); + ASSERT_EQ(4, childIndex); + EXPECT_EQ(bookmark_bb_3_->GetTitle(), ASCIIToUTF16("NEW NAME")); +} + +TEST_F(BookmarkEditorControllerTreeTest, AddFolderWithGroupSelected) { + // Folders are NOT added unless the OK button is pressed. + [controller_ newFolder:nil]; + [controller_ cancel:nil]; + EXPECT_EQ(5, group_bb_->GetChildCount()); +} + +class BookmarkEditorControllerTreeNoNodeTest : + public BookmarkEditorControllerTreeTest { + public: + virtual BookmarkEditorController* CreateController() { + return [[BookmarkEditorController alloc] + initWithParentWindow:test_window() + profile:browser_helper_.profile() + parent:group_bb_ + node:nil + configuration:BookmarkEditor::SHOW_TREE]; + } + +}; + +TEST_F(BookmarkEditorControllerTreeNoNodeTest, NewBookmarkNoNode) { + [controller_ setDisplayName:@"NEW BOOKMARK"]; + [controller_ setDisplayURL:@"http://NEWURL.com"]; + [controller_ ok:nil]; + const BookmarkNode* new_node = group_bb_->GetChild(5); + ASSERT_EQ(0, new_node->GetChildCount()); + EXPECT_EQ(new_node->GetTitle(), ASCIIToUTF16("NEW BOOKMARK")); + EXPECT_EQ(new_node->GetURL(), GURL("http://NEWURL.com")); +} diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_folder_target.h b/chrome/browser/ui/cocoa/bookmarks/bookmark_folder_target.h new file mode 100644 index 0000000..e2af266 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_folder_target.h @@ -0,0 +1,50 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_FOLDER_TARGET_CONTROLLER_H_ +#define CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_FOLDER_TARGET_CONTROLLER_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +@class BookmarkButton; +@protocol BookmarkButtonControllerProtocol; +class BookmarkNode; + +// Target (in the target/action sense) of a bookmark folder button. +// Since ObjC doesn't have multiple inheritance we use has-a instead +// of is-a to share behavior between the BookmarkBarFolderController +// (NSWindowController) and the BookmarkBarController +// (NSViewController). +// +// This class is unit tested in the context of a BookmarkBarController. +@interface BookmarkFolderTarget : NSObject { + // The owner of the bookmark folder button + id<BookmarkButtonControllerProtocol> controller_; // weak +} + +- (id)initWithController:(id<BookmarkButtonControllerProtocol>)controller; + +// Main IBAction for a button click. +- (IBAction)openBookmarkFolderFromButton:(id)sender; + +// Copies the given bookmark node to the given pasteboard, declaring appropriate +// types (to paste a URL with a title). +- (void)copyBookmarkNode:(const BookmarkNode*)node + toPasteboard:(NSPasteboard*)pboard; + +// Fill the given pasteboard with appropriate data when the given button is +// dragged. Since the delegate has no way of providing pasteboard data later, +// all data must actually be put into the pasteboard and not merely promised. +- (void)fillPasteboard:(NSPasteboard*)pboard + forDragOfButton:(BookmarkButton*)button; + +@end + +// The (internal) |NSPasteboard| type string for bookmark button drags, used for +// dragging buttons around the bookmark bar. The data for this type is just a +// pointer to the |BookmarkButton| being dragged. +extern NSString* kBookmarkButtonDragType; + +#endif // CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_FOLDER_TARGET_CONTROLLER_H_ diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_folder_target.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_folder_target.mm new file mode 100644 index 0000000..95531b2 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_folder_target.mm @@ -0,0 +1,118 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_folder_target.h" + +#include "base/logging.h" +#include "base/sys_string_conversions.h" +#include "chrome/browser/bookmarks/bookmark_model.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_controller.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_button.h" +#import "chrome/browser/ui/cocoa/event_utils.h" +#import "third_party/mozilla/NSPasteboard+Utils.h" + +NSString* kBookmarkButtonDragType = @"ChromiumBookmarkButtonDragType"; + +@implementation BookmarkFolderTarget + +- (id)initWithController:(id<BookmarkButtonControllerProtocol>)controller { + if ((self = [super init])) { + controller_ = controller; + } + return self; +} + +// This IBAction is called when the user clicks (mouseUp, really) on a +// "folder" bookmark button. (In this context, "Click" does not +// include right-click to open a context menu which follows a +// different path). Scenarios when folder X is clicked: +// *Predicate* *Action* +// (nothing) Open Folder X +// Folder X open Close folder X +// Folder Y open Close Y, open X +// Cmd-click Open All with proper disposition +// +// Note complication in which a click-drag engages drag and drop, not +// a click-to-open. Thus the path to get here is a little twisted. +- (IBAction)openBookmarkFolderFromButton:(id)sender { + DCHECK(sender); + // Watch out for a modifier click. For example, command-click + // should open all. + // + // NOTE: we cannot use [[sender cell] mouseDownFlags] because we + // thwart the normal mouse click mechanism to make buttons + // draggable. Thus we must use [NSApp currentEvent]. + // + // Holding command while using the scroll wheel (or moving around + // over a bookmark folder) can confuse us. Unless we check the + // event type, we are not sure if this is an "open folder" due to a + // hover-open or "open folder" due to a click. It doesn't matter + // (both do the same thing) unless a modifier is held, since + // command-click should "open all" but command-move should not. + // WindowOpenDispositionFromNSEvent does not consider the event + // type; only the modifiers. Thus the need for an extra + // event-type-check here. + DCHECK([sender bookmarkNode]->is_folder()); + NSEvent* event = [NSApp currentEvent]; + WindowOpenDisposition disposition = + event_utils::WindowOpenDispositionFromNSEvent(event); + if (([event type] != NSMouseEntered) && + ([event type] != NSMouseMoved) && + ([event type] != NSScrollWheel) && + (disposition == NEW_BACKGROUND_TAB)) { + [controller_ closeAllBookmarkFolders]; + [controller_ openAll:[sender bookmarkNode] disposition:disposition]; + return; + } + + // If click on same folder, close it and be done. + // Else we clicked on a different folder so more work to do. + if ([[controller_ folderController] parentButton] == sender) { + [controller_ closeBookmarkFolder:controller_]; + return; + } + + [controller_ addNewFolderControllerWithParentButton:sender]; +} + +- (void)copyBookmarkNode:(const BookmarkNode*)node + toPasteboard:(NSPasteboard*)pboard { + if (!node) { + NOTREACHED(); + return; + } + + if (node->is_folder()) { + // TODO(viettrungluu): I'm not sure what we should do, so just declare the + // "additional" types we're given for now. Maybe we want to add a list of + // URLs? Would we then have to recurse if there were subfolders? + // In the meanwhile, we *must* set it to a known state. (If this survives to + // a 10.6-only release, it can be replaced with |-clearContents|.) + [pboard declareTypes:[NSArray array] owner:nil]; + } else { + const std::string spec = node->GetURL().spec(); + NSString* url = base::SysUTF8ToNSString(spec); + NSString* title = base::SysUTF16ToNSString(node->GetTitle()); + [pboard declareURLPasteboardWithAdditionalTypes:[NSArray array] + owner:nil]; + [pboard setDataForURL:url title:title]; + } +} + +- (void)fillPasteboard:(NSPasteboard*)pboard + forDragOfButton:(BookmarkButton*)button { + if (const BookmarkNode* node = [button bookmarkNode]) { + // Put the bookmark information into the pasteboard, and then write our own + // data for |kBookmarkButtonDragType|. + [self copyBookmarkNode:node toPasteboard:pboard]; + [pboard addTypes:[NSArray arrayWithObject:kBookmarkButtonDragType] + owner:nil]; + [pboard setData:[NSData dataWithBytes:&button length:sizeof(button)] + forType:kBookmarkButtonDragType]; + } else { + NOTREACHED(); + } +} + +@end diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_folder_target_unittest.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_folder_target_unittest.mm new file mode 100644 index 0000000..0142bfb --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_folder_target_unittest.mm @@ -0,0 +1,125 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/bookmarks/bookmark_model.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_controller.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_controller.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_folder_target.h" +#include "chrome/browser/ui/cocoa/bookmarks/bookmark_button.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#include "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" +#import "third_party/ocmock/OCMock/OCMock.h" + +@interface OCMockObject(PreventRetainCycle) +- (void)clearRecordersAndExpectations; +@end + +@implementation OCMockObject(PreventRetainCycle) + +// We need a mechanism to clear the invocation handlers to break a +// retain cycle (see below; search for "retain cycle"). +- (void)clearRecordersAndExpectations { + [recorders removeAllObjects]; + [expectations removeAllObjects]; +} + +@end + + +class BookmarkFolderTargetTest : public CocoaTest { + public: + virtual void SetUp() { + CocoaTest::SetUp(); + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + bmbNode_ = model->GetBookmarkBarNode(); + } + virtual void TearDown() { + pool_.Recycle(); + CocoaTest::TearDown(); + } + + BrowserTestHelper helper_; + const BookmarkNode* bmbNode_; + base::mac::ScopedNSAutoreleasePool pool_; +}; + +TEST_F(BookmarkFolderTargetTest, StartWithNothing) { + // Need a fake "button" which has a bookmark node. + id sender = [OCMockObject mockForClass:[BookmarkButton class]]; + [[[sender stub] andReturnValue:OCMOCK_VALUE(bmbNode_)] bookmarkNode]; + + // Fake controller + id controller = [OCMockObject mockForClass:[BookmarkBarFolderController + class]]; + // No current folder + [[[controller stub] andReturn:nil] folderController]; + + // Make sure we get an addNew + [[controller expect] addNewFolderControllerWithParentButton:sender]; + + scoped_nsobject<BookmarkFolderTarget> target( + [[BookmarkFolderTarget alloc] initWithController:controller]); + + [target openBookmarkFolderFromButton:sender]; + [controller verify]; +} + +TEST_F(BookmarkFolderTargetTest, ReopenSameFolder) { + // Need a fake "button" which has a bookmark node. + id sender = [OCMockObject mockForClass:[BookmarkButton class]]; + [[[sender stub] andReturnValue:OCMOCK_VALUE(bmbNode_)] bookmarkNode]; + + // Fake controller + id controller = [OCMockObject mockForClass:[BookmarkBarFolderController + class]]; + // YES a current folder. Self-mock that as well, so "same" will be + // true. Note this creates a retain cycle in OCMockObject; we + // accomodate at the end of this function. + [[[controller stub] andReturn:controller] folderController]; + [[[controller stub] andReturn:sender] parentButton]; + + // The folder is open, so a click should close just that folder (and + // any subfolders). + [[controller expect] closeBookmarkFolder:controller]; + + scoped_nsobject<BookmarkFolderTarget> target( + [[BookmarkFolderTarget alloc] initWithController:controller]); + + [target openBookmarkFolderFromButton:sender]; + [controller verify]; + + // Our use of OCMockObject means an object can return itself. This + // creates a retain cycle, since OCMock retains all objects used in + // mock creation. Clear out the invocation handlers of all + // OCMockRecorders we used to break the cycles. + [controller clearRecordersAndExpectations]; +} + +TEST_F(BookmarkFolderTargetTest, ReopenNotSame) { + // Need a fake "button" which has a bookmark node. + id sender = [OCMockObject mockForClass:[BookmarkButton class]]; + [[[sender stub] andReturnValue:OCMOCK_VALUE(bmbNode_)] bookmarkNode]; + + // Fake controller + id controller = [OCMockObject mockForClass:[BookmarkBarFolderController + class]]; + // YES a current folder but NOT same. + [[[controller stub] andReturn:controller] folderController]; + [[[controller stub] andReturn:nil] parentButton]; + + // Insure the controller gets a chance to decide which folders to + // close and open. + [[controller expect] addNewFolderControllerWithParentButton:sender]; + + scoped_nsobject<BookmarkFolderTarget> target( + [[BookmarkFolderTarget alloc] initWithController:controller]); + + [target openBookmarkFolderFromButton:sender]; + [controller verify]; + + // Break retain cycles. + [controller clearRecordersAndExpectations]; +} diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_menu.h b/chrome/browser/ui/cocoa/bookmarks/bookmark_menu.h new file mode 100644 index 0000000..d4ac001 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_menu.h @@ -0,0 +1,20 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/basictypes.h" + + +// The context menu for bookmark buttons needs to know which +// BookmarkNode it is talking about. For example, "Open All" is +// disabled if the bookmark node is a folder and has no children. +@interface BookmarkMenu : NSMenu { + @private + int64 id_; // id of the bookmark node we represent. +} +- (void)setRepresentedObject:(id)object; +@property (nonatomic) int64 id; +@end + diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_menu.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_menu.mm new file mode 100644 index 0000000..274edc7 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_menu.mm @@ -0,0 +1,22 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_menu.h" + + +@implementation BookmarkMenu + +@synthesize id = id_; + +// Convention in the bookmark bar controller: the bookmark button +// cells have a BookmarkNode as their represented object. This object +// is placed in a BookmarkMenu at the time a cell is asked for its +// menu. +- (void)setRepresentedObject:(id)object { + if ([object isKindOfClass:[NSNumber class]]) { + id_ = static_cast<int64>([object longLongValue]); + } +} + +@end diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_menu_bridge.h b/chrome/browser/ui/cocoa/bookmarks/bookmark_menu_bridge.h new file mode 100644 index 0000000..db64b2c --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_menu_bridge.h @@ -0,0 +1,123 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// C++ controller for the bookmark menu; one per AppController (which +// means there is only one). When bookmarks are changed, this class +// takes care of updating Cocoa bookmark menus. This is not named +// BookmarkMenuController to help avoid confusion between languages. +// This class needs to be C++, not ObjC, since it derives from +// BookmarkModelObserver. +// +// Most Chromium Cocoa menu items are static from a nib (e.g. New +// Tab), but may be enabled/disabled under certain circumstances +// (e.g. Cut and Paste). In addition, most Cocoa menu items have +// firstResponder: as a target. Unusually, bookmark menu items are +// created dynamically. They also have a target of +// BookmarkMenuCocoaController instead of firstResponder. +// See BookmarkMenuBridge::AddNodeToMenu()). + +#ifndef CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_MENU_BRIDGE_H_ +#define CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_MENU_BRIDGE_H_ +#pragma once + +#include <map> + +#include "base/scoped_nsobject.h" +#include "chrome/browser/bookmarks/bookmark_model_observer.h" + +class BookmarkNode; +class Profile; +@class NSImage; +@class NSMenu; +@class NSMenuItem; +@class BookmarkMenuCocoaController; + +class BookmarkMenuBridge : public BookmarkModelObserver { + public: + BookmarkMenuBridge(Profile* profile); + virtual ~BookmarkMenuBridge(); + + // Overridden from BookmarkModelObserver + virtual void Loaded(BookmarkModel* model); + virtual void BookmarkModelBeingDeleted(BookmarkModel* model); + virtual void BookmarkNodeMoved(BookmarkModel* model, + const BookmarkNode* old_parent, + int old_index, + const BookmarkNode* new_parent, + int new_index); + virtual void BookmarkNodeAdded(BookmarkModel* model, + const BookmarkNode* parent, + int index); + virtual void BookmarkNodeRemoved(BookmarkModel* model, + const BookmarkNode* parent, + int old_index, + const BookmarkNode* node); + virtual void BookmarkNodeChanged(BookmarkModel* model, + const BookmarkNode* node); + virtual void BookmarkNodeFavIconLoaded(BookmarkModel* model, + const BookmarkNode* node); + virtual void BookmarkNodeChildrenReordered(BookmarkModel* model, + const BookmarkNode* node); + + // Rebuilds the bookmark menu, if it has been marked invalid. + void UpdateMenu(NSMenu* bookmark_menu); + + // I wish I had a "friend @class" construct. + BookmarkModel* GetBookmarkModel(); + Profile* GetProfile(); + + protected: + // Clear all bookmarks from the given bookmark menu. + void ClearBookmarkMenu(NSMenu* menu); + + // Mark the bookmark menu as being invalid. + void InvalidateMenu() { menuIsValid_ = false; } + + // Helper for adding the node as a submenu to the menu with the + // given title. + void AddNodeAsSubmenu(NSMenu* menu, + const BookmarkNode* node, + NSString* title); + + // Helper for recursively adding items to our bookmark menu + // All children of |node| will be added to |menu|. + // TODO(jrg): add a counter to enforce maximum nodes added + void AddNodeToMenu(const BookmarkNode* node, NSMenu* menu); + + // This configures an NSMenuItem with all the data from a BookmarkNode. This + // is used to update existing menu items, as well as to configure newly + // created ones, like in AddNodeToMenu(). + // |set_title| is optional since it is only needed when we get a + // node changed notification. On initial build of the menu we set + // the title as part of alloc/init. + void ConfigureMenuItem(const BookmarkNode* node, NSMenuItem* item, + bool set_title); + + // Returns the NSMenuItem for a given BookmarkNode. + NSMenuItem* MenuItemForNode(const BookmarkNode* node); + + // Return the Bookmark menu. + virtual NSMenu* BookmarkMenu(); + + // Start watching the bookmarks for changes. + void ObserveBookmarkModel(); + + private: + friend class BookmarkMenuBridgeTest; + + // True iff the menu is up-to-date with the actual BookmarkModel. + bool menuIsValid_; + + Profile* profile_; // weak + BookmarkMenuCocoaController* controller_; // strong + + // The folder image so we can use one copy for all. + scoped_nsobject<NSImage> folder_image_; + + // In order to appropriately update items in the bookmark menu, without + // forcing a rebuild, map the model's nodes to menu items. + std::map<const BookmarkNode*, NSMenuItem*> bookmark_nodes_; +}; + +#endif // CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_MENU_BRIDGE_H_ diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_menu_bridge.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_menu_bridge.mm new file mode 100644 index 0000000..fbe5f10 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_menu_bridge.mm @@ -0,0 +1,253 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <AppKit/AppKit.h> + +#include "app/l10n_util.h" +#include "app/resource_bundle.h" +#include "base/nsimage_cache_mac.h" +#include "base/sys_string_conversions.h" +#import "chrome/browser/app_controller_mac.h" +#include "chrome/browser/bookmarks/bookmark_model.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/profile_manager.h" +#include "chrome/browser/ui/browser.h" +#include "chrome/browser/ui/browser_list.h" +#include "chrome/browser/ui/cocoa/bookmarks/bookmark_menu_bridge.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_menu_cocoa_controller.h" +#include "grit/generated_resources.h" +#include "grit/theme_resources.h" +#include "skia/ext/skia_utils_mac.h" + +BookmarkMenuBridge::BookmarkMenuBridge(Profile* profile) + : menuIsValid_(false), + profile_(profile), + controller_([[BookmarkMenuCocoaController alloc] initWithBridge:this]) { + if (GetBookmarkModel()) + ObserveBookmarkModel(); +} + +BookmarkMenuBridge::~BookmarkMenuBridge() { + BookmarkModel *model = GetBookmarkModel(); + if (model) + model->RemoveObserver(this); + [controller_ release]; +} + +NSMenu* BookmarkMenuBridge::BookmarkMenu() { + return [controller_ menu]; +} + +void BookmarkMenuBridge::Loaded(BookmarkModel* model) { + InvalidateMenu(); +} + +void BookmarkMenuBridge::UpdateMenu(NSMenu* bookmark_menu) { + DCHECK(bookmark_menu); + if (menuIsValid_) + return; + BookmarkModel* model = GetBookmarkModel(); + if (!model || !model->IsLoaded()) + return; + + if (!folder_image_) { + ResourceBundle& rb = ResourceBundle::GetSharedInstance(); + folder_image_.reset( + [rb.GetNativeImageNamed(IDR_BOOKMARK_BAR_FOLDER) retain]); + } + + ClearBookmarkMenu(bookmark_menu); + + // Add bookmark bar items, if any. + const BookmarkNode* barNode = model->GetBookmarkBarNode(); + CHECK(barNode); + if (barNode->GetChildCount()) { + [bookmark_menu addItem:[NSMenuItem separatorItem]]; + AddNodeToMenu(barNode, bookmark_menu); + } + + // Create a submenu for "other bookmarks", and fill it in. + NSString* other_items_title = + l10n_util::GetNSString(IDS_BOOMARK_BAR_OTHER_FOLDER_NAME); + [bookmark_menu addItem:[NSMenuItem separatorItem]]; + AddNodeAsSubmenu(bookmark_menu, + model->other_node(), + other_items_title); + + menuIsValid_ = true; +} + +void BookmarkMenuBridge::BookmarkModelBeingDeleted(BookmarkModel* model) { + NSMenu* bookmark_menu = BookmarkMenu(); + if (bookmark_menu == nil) + return; + + ClearBookmarkMenu(bookmark_menu); +} + +void BookmarkMenuBridge::BookmarkNodeMoved(BookmarkModel* model, + const BookmarkNode* old_parent, + int old_index, + const BookmarkNode* new_parent, + int new_index) { + InvalidateMenu(); +} + +void BookmarkMenuBridge::BookmarkNodeAdded(BookmarkModel* model, + const BookmarkNode* parent, + int index) { + InvalidateMenu(); +} + +void BookmarkMenuBridge::BookmarkNodeRemoved(BookmarkModel* model, + const BookmarkNode* parent, + int old_index, + const BookmarkNode* node) { + InvalidateMenu(); +} + +void BookmarkMenuBridge::BookmarkNodeChanged(BookmarkModel* model, + const BookmarkNode* node) { + NSMenuItem* item = MenuItemForNode(node); + if (item) + ConfigureMenuItem(node, item, true); +} + +void BookmarkMenuBridge::BookmarkNodeFavIconLoaded(BookmarkModel* model, + const BookmarkNode* node) { + NSMenuItem* item = MenuItemForNode(node); + if (item) + ConfigureMenuItem(node, item, false); +} + +void BookmarkMenuBridge::BookmarkNodeChildrenReordered( + BookmarkModel* model, const BookmarkNode* node) { + InvalidateMenu(); +} + +// Watch for changes. +void BookmarkMenuBridge::ObserveBookmarkModel() { + BookmarkModel* model = GetBookmarkModel(); + model->AddObserver(this); + if (model->IsLoaded()) + Loaded(model); +} + +BookmarkModel* BookmarkMenuBridge::GetBookmarkModel() { + if (!profile_) + return NULL; + return profile_->GetBookmarkModel(); +} + +Profile* BookmarkMenuBridge::GetProfile() { + return profile_; +} + +void BookmarkMenuBridge::ClearBookmarkMenu(NSMenu* menu) { + bookmark_nodes_.clear(); + // Recursively delete all menus that look like a bookmark. Assume + // all items with submenus contain only bookmarks. Also delete all + // separator items since we explicirly add them back in. This should + // deletes everything except the first item ("Add Bookmark..."). + NSArray* items = [menu itemArray]; + for (NSMenuItem* item in items) { + // Convention: items in the bookmark list which are bookmarks have + // an action of openBookmarkMenuItem:. Also, assume all items + // with submenus are submenus of bookmarks. + if (([item action] == @selector(openBookmarkMenuItem:)) || + [item hasSubmenu] || + [item isSeparatorItem]) { + // This will eventually [obj release] all its kids, if it has + // any. + [menu removeItem:item]; + } else { + // Leave it alone. + } + } +} + +void BookmarkMenuBridge::AddNodeAsSubmenu(NSMenu* menu, + const BookmarkNode* node, + NSString* title) { + NSMenuItem* items = [[[NSMenuItem alloc] + initWithTitle:title + action:nil + keyEquivalent:@""] autorelease]; + [items setImage:folder_image_]; + [menu addItem:items]; + NSMenu* other_submenu = [[[NSMenu alloc] initWithTitle:title] + autorelease]; + [menu setSubmenu:other_submenu forItem:items]; + AddNodeToMenu(node, other_submenu); +} + +// TODO(jrg): limit the number of bookmarks in the menubar? +void BookmarkMenuBridge::AddNodeToMenu(const BookmarkNode* node, NSMenu* menu) { + int child_count = node->GetChildCount(); + if (!child_count) { + NSString* empty_string = l10n_util::GetNSString(IDS_MENU_EMPTY_SUBMENU); + NSMenuItem* item = [[[NSMenuItem alloc] initWithTitle:empty_string + action:nil + keyEquivalent:@""] autorelease]; + [menu addItem:item]; + } else for (int i = 0; i < child_count; i++) { + const BookmarkNode* child = node->GetChild(i); + NSString* title = [BookmarkMenuCocoaController menuTitleForNode:child]; + NSMenuItem* item = [[[NSMenuItem alloc] initWithTitle:title + action:nil + keyEquivalent:@""] autorelease]; + [menu addItem:item]; + bookmark_nodes_[child] = item; + if (child->is_folder()) { + [item setImage:folder_image_]; + NSMenu* submenu = [[[NSMenu alloc] initWithTitle:title] autorelease]; + [menu setSubmenu:submenu forItem:item]; + AddNodeToMenu(child, submenu); // recursive call + } else { + ConfigureMenuItem(child, item, false); + } + } +} + +void BookmarkMenuBridge::ConfigureMenuItem(const BookmarkNode* node, + NSMenuItem* item, + bool set_title) { + if (set_title) { + NSString* title = [BookmarkMenuCocoaController menuTitleForNode:node]; + [item setTitle:title]; + } + [item setTarget:controller_]; + [item setAction:@selector(openBookmarkMenuItem:)]; + [item setTag:node->id()]; + // Add a tooltip + std::string url_string = node->GetURL().possibly_invalid_spec(); + NSString* tooltip = [NSString stringWithFormat:@"%@\n%s", + base::SysUTF16ToNSString(node->GetTitle()), + url_string.c_str()]; + [item setToolTip:tooltip]; + + // Check to see if we have a favicon. + NSImage* favicon = nil; + BookmarkModel* model = GetBookmarkModel(); + if (model) { + const SkBitmap& bitmap = model->GetFavIcon(node); + if (!bitmap.isNull()) + favicon = gfx::SkBitmapToNSImage(bitmap); + } + // Either we do not have a loaded favicon or the conversion from SkBitmap + // failed. Use the default site image instead. + if (!favicon) + favicon = nsimage_cache::ImageNamed(@"nav.pdf"); + [item setImage:favicon]; +} + +NSMenuItem* BookmarkMenuBridge::MenuItemForNode(const BookmarkNode* node) { + if (!node) + return nil; + std::map<const BookmarkNode*, NSMenuItem*>::iterator it = + bookmark_nodes_.find(node); + if (it == bookmark_nodes_.end()) + return nil; + return it->second; +} diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_menu_bridge_unittest.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_menu_bridge_unittest.mm new file mode 100644 index 0000000..cec14eb --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_menu_bridge_unittest.mm @@ -0,0 +1,317 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <AppKit/AppKit.h> + +#import "base/scoped_nsobject.h" +#include "base/string16.h" +#include "base/string_util.h" +#include "base/utf_string_conversions.h" +#include "chrome/app/chrome_command_ids.h" +#include "chrome/browser/bookmarks/bookmark_model.h" +#include "chrome/browser/ui/cocoa/bookmarks/bookmark_menu_bridge.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#include "chrome/browser/ui/browser.h" +#include "testing/gtest/include/gtest/gtest.h" +#import "testing/gtest_mac.h" +#include "testing/platform_test.h" + +class TestBookmarkMenuBridge : public BookmarkMenuBridge { + public: + TestBookmarkMenuBridge(Profile* profile) + : BookmarkMenuBridge(profile), + menu_([[NSMenu alloc] initWithTitle:@"test"]) { + } + virtual ~TestBookmarkMenuBridge() {} + + scoped_nsobject<NSMenu> menu_; + + protected: + // Overridden from BookmarkMenuBridge. + virtual NSMenu* BookmarkMenu() { + return menu_; + } +}; + +// TODO(jrg): see refactor comment in bookmark_bar_state_controller_unittest.mm +class BookmarkMenuBridgeTest : public PlatformTest { + public: + + void SetUp() { + bridge_.reset(new TestBookmarkMenuBridge(browser_test_helper_.profile())); + EXPECT_TRUE(bridge_.get()); + } + + // We are a friend of BookmarkMenuBridge (and have access to + // protected methods), but none of the classes generated by TEST_F() + // are. This (and AddNodeToMenu()) are simple wrappers to let + // derived test classes have access to protected methods. + void ClearBookmarkMenu(BookmarkMenuBridge* bridge, NSMenu* menu) { + bridge->ClearBookmarkMenu(menu); + } + + void InvalidateMenu() { bridge_->InvalidateMenu(); } + bool menu_is_valid() { return bridge_->menuIsValid_; } + + void AddNodeToMenu(BookmarkMenuBridge* bridge, const BookmarkNode* root, + NSMenu* menu) { + bridge->AddNodeToMenu(root, menu); + } + + NSMenuItem* MenuItemForNode(BookmarkMenuBridge* bridge, + const BookmarkNode* node) { + return bridge->MenuItemForNode(node); + } + + NSMenuItem* AddItemToMenu(NSMenu *menu, NSString *title, SEL selector) { + NSMenuItem *item = [[[NSMenuItem alloc] initWithTitle:title action:NULL + keyEquivalent:@""] autorelease]; + if (selector) + [item setAction:selector]; + [menu addItem:item]; + return item; + } + + BrowserTestHelper browser_test_helper_; + scoped_ptr<TestBookmarkMenuBridge> bridge_; +}; + +TEST_F(BookmarkMenuBridgeTest, TestBookmarkMenuAutoSeparator) { + BookmarkModel* model = bridge_->GetBookmarkModel(); + bridge_->Loaded(model); + NSMenu* menu = bridge_->menu_.get(); + bridge_->UpdateMenu(menu); + // The bare menu after loading has a separator and an "Other Bookmarks" + // submenu. + EXPECT_EQ(2, [menu numberOfItems]); + // Add a bookmark and reload and there should be 4 items: the previous + // menu contents plus a new separator and the new bookmark. + const BookmarkNode* parent = model->GetBookmarkBarNode(); + const char* url = "http://www.zim-bop-a-dee.com/"; + model->AddURL(parent, 0, ASCIIToUTF16("Bookmark"), GURL(url)); + bridge_->UpdateMenu(menu); + EXPECT_EQ(4, [menu numberOfItems]); + // Remove the new bookmark and reload and we should have 2 items again + // because the separator should have been removed as well. + model->Remove(parent, 0); + bridge_->UpdateMenu(menu); + EXPECT_EQ(2, [menu numberOfItems]); +} + +// Test that ClearBookmarkMenu() removes all bookmark menus. +TEST_F(BookmarkMenuBridgeTest, TestClearBookmarkMenu) { + NSMenu* menu = bridge_->menu_.get(); + + AddItemToMenu(menu, @"hi mom", nil); + AddItemToMenu(menu, @"not", @selector(openBookmarkMenuItem:)); + NSMenuItem* item = AddItemToMenu(menu, @"hi mom", nil); + [item setSubmenu:[[[NSMenu alloc] initWithTitle:@"bar"] autorelease]]; + AddItemToMenu(menu, @"not", @selector(openBookmarkMenuItem:)); + AddItemToMenu(menu, @"zippy", @selector(length)); + [menu addItem:[NSMenuItem separatorItem]]; + + ClearBookmarkMenu(bridge_.get(), menu); + + // Make sure all bookmark items are removed, all items with + // submenus removed, and all separator items are gone. + EXPECT_EQ(2, [menu numberOfItems]); + for (NSMenuItem *item in [menu itemArray]) { + EXPECT_NSNE(@"not", [item title]); + } +} + +// Test invalidation +TEST_F(BookmarkMenuBridgeTest, TestInvalidation) { + BookmarkModel* model = bridge_->GetBookmarkModel(); + bridge_->Loaded(model); + + EXPECT_FALSE(menu_is_valid()); + bridge_->UpdateMenu(bridge_->menu_); + EXPECT_TRUE(menu_is_valid()); + + InvalidateMenu(); + EXPECT_FALSE(menu_is_valid()); + InvalidateMenu(); + EXPECT_FALSE(menu_is_valid()); + bridge_->UpdateMenu(bridge_->menu_); + EXPECT_TRUE(menu_is_valid()); + bridge_->UpdateMenu(bridge_->menu_); + EXPECT_TRUE(menu_is_valid()); + + const BookmarkNode* parent = model->GetBookmarkBarNode(); + const char* url = "http://www.zim-bop-a-dee.com/"; + model->AddURL(parent, 0, ASCIIToUTF16("Bookmark"), GURL(url)); + + EXPECT_FALSE(menu_is_valid()); + bridge_->UpdateMenu(bridge_->menu_); + EXPECT_TRUE(menu_is_valid()); +} + +// Test that AddNodeToMenu() properly adds bookmark nodes as menus, +// including the recursive case. +TEST_F(BookmarkMenuBridgeTest, TestAddNodeToMenu) { + string16 empty; + NSMenu* menu = bridge_->menu_.get(); + + BookmarkModel* model = bridge_->GetBookmarkModel(); + const BookmarkNode* root = model->GetBookmarkBarNode(); + EXPECT_TRUE(model && root); + + const char* short_url = "http://foo/"; + const char* long_url = "http://super-duper-long-url--." + "that.cannot.possibly.fit.even-in-80-columns" + "or.be.reasonably-displayed-in-a-menu" + "without.looking-ridiculous.com/"; // 140 chars total + + // 3 nodes; middle one has a child, last one has a HUGE URL + // Set their titles to be the same as the URLs + const BookmarkNode* node = NULL; + model->AddURL(root, 0, ASCIIToUTF16(short_url), GURL(short_url)); + bridge_->UpdateMenu(menu); + int prev_count = [menu numberOfItems] - 1; // "extras" added at this point + node = model->AddGroup(root, 1, empty); + model->AddURL(root, 2, ASCIIToUTF16(long_url), GURL(long_url)); + + // And the submenu fo the middle one + model->AddURL(node, 0, empty, GURL("http://sub")); + bridge_->UpdateMenu(menu); + + EXPECT_EQ((NSInteger)(prev_count+3), [menu numberOfItems]); + + // Verify the 1st one is there with the right action. + NSMenuItem* item = [menu itemWithTitle:[NSString + stringWithUTF8String:short_url]]; + EXPECT_TRUE(item); + EXPECT_EQ(@selector(openBookmarkMenuItem:), [item action]); + EXPECT_EQ(NO, [item hasSubmenu]); + NSMenuItem* short_item = item; + NSMenuItem* long_item = nil; + + // Now confirm we have 2 submenus (the one we added, plus "other") + int subs = 0; + for (item in [menu itemArray]) { + if ([item hasSubmenu]) + subs++; + } + EXPECT_EQ(2, subs); + + for (item in [menu itemArray]) { + if ([[item title] hasPrefix:@"http://super-duper"]) { + long_item = item; + break; + } + } + EXPECT_TRUE(long_item); + + // Make sure a short title looks fine + NSString* s = [short_item title]; + EXPECT_NSEQ([NSString stringWithUTF8String:short_url], s); + + // Make sure a super-long title gets trimmed + s = [long_item title]; + EXPECT_TRUE([s length] < strlen(long_url)); + + // Confirm tooltips and confirm they are not trimmed (like the item + // name might be). Add tolerance for URL fixer-upping; + // e.g. http://foo becomes http://foo/) + EXPECT_GE([[short_item toolTip] length], (2*strlen(short_url) - 5)); + EXPECT_GE([[long_item toolTip] length], (2*strlen(long_url) - 5)); + + // Make sure the favicon is non-nil (should be either the default site + // icon or a favicon, if present). + EXPECT_TRUE([short_item image]); + EXPECT_TRUE([long_item image]); +} + +// Makes sure our internal map of BookmarkNode to NSMenuItem works. +TEST_F(BookmarkMenuBridgeTest, TestGetMenuItemForNode) { + string16 empty; + NSMenu* menu = bridge_->menu_.get(); + + BookmarkModel* model = bridge_->GetBookmarkModel(); + const BookmarkNode* bookmark_bar = model->GetBookmarkBarNode(); + const BookmarkNode* root = model->AddGroup(bookmark_bar, 0, empty); + EXPECT_TRUE(model && root); + + model->AddURL(root, 0, ASCIIToUTF16("Test Item"), GURL("http://test")); + AddNodeToMenu(bridge_.get(), root, menu); + EXPECT_TRUE(MenuItemForNode(bridge_.get(), root->GetChild(0))); + + model->AddURL(root, 1, ASCIIToUTF16("Test 2"), GURL("http://second-test")); + AddNodeToMenu(bridge_.get(), root, menu); + EXPECT_TRUE(MenuItemForNode(bridge_.get(), root->GetChild(0))); + EXPECT_TRUE(MenuItemForNode(bridge_.get(), root->GetChild(1))); + + const BookmarkNode* removed_node = root->GetChild(0); + EXPECT_EQ(2, root->GetChildCount()); + model->Remove(root, 0); + EXPECT_EQ(1, root->GetChildCount()); + bridge_->UpdateMenu(menu); + EXPECT_FALSE(MenuItemForNode(bridge_.get(), removed_node)); + EXPECT_TRUE(MenuItemForNode(bridge_.get(), root->GetChild(0))); + + const BookmarkNode empty_node(GURL("http://no-where/")); + EXPECT_FALSE(MenuItemForNode(bridge_.get(), &empty_node)); + EXPECT_FALSE(MenuItemForNode(bridge_.get(), NULL)); +} + +// Test that Loaded() adds both the bookmark bar nodes and the "other" nodes. +TEST_F(BookmarkMenuBridgeTest, TestAddNodeToOther) { + NSMenu* menu = bridge_->menu_.get(); + + BookmarkModel* model = bridge_->GetBookmarkModel(); + const BookmarkNode* root = model->other_node(); + EXPECT_TRUE(model && root); + + const char* short_url = "http://foo/"; + model->AddURL(root, 0, ASCIIToUTF16(short_url), GURL(short_url)); + + bridge_->UpdateMenu(menu); + ASSERT_GT([menu numberOfItems], 0); + NSMenuItem* other = [menu itemAtIndex:([menu numberOfItems]-1)]; + EXPECT_TRUE(other); + EXPECT_TRUE([other hasSubmenu]); + ASSERT_GT([[other submenu] numberOfItems], 0); + EXPECT_NSEQ(@"http://foo/", [[[other submenu] itemAtIndex:0] title]); +} + +TEST_F(BookmarkMenuBridgeTest, TestFavIconLoading) { + NSMenu* menu = bridge_->menu_; + + BookmarkModel* model = bridge_->GetBookmarkModel(); + const BookmarkNode* root = model->GetBookmarkBarNode(); + EXPECT_TRUE(model && root); + + const BookmarkNode* node = + model->AddURL(root, 0, ASCIIToUTF16("Test Item"), + GURL("http://favicon-test")); + bridge_->UpdateMenu(menu); + NSMenuItem* item = [menu itemWithTitle:@"Test Item"]; + EXPECT_TRUE([item image]); + [item setImage:nil]; + bridge_->BookmarkNodeFavIconLoaded(model, node); + EXPECT_TRUE([item image]); +} + +TEST_F(BookmarkMenuBridgeTest, TestChangeTitle) { + NSMenu* menu = bridge_->menu_; + BookmarkModel* model = bridge_->GetBookmarkModel(); + const BookmarkNode* root = model->GetBookmarkBarNode(); + EXPECT_TRUE(model && root); + + const BookmarkNode* node = + model->AddURL(root, 0, ASCIIToUTF16("Test Item"), + GURL("http://title-test")); + bridge_->UpdateMenu(menu); + NSMenuItem* item = [menu itemWithTitle:@"Test Item"]; + EXPECT_TRUE([item image]); + + model->SetTitle(node, ASCIIToUTF16("New Title")); + + item = [menu itemWithTitle:@"Test Item"]; + EXPECT_FALSE(item); + item = [menu itemWithTitle:@"New Title"]; + EXPECT_TRUE(item); +} + diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_menu_cocoa_controller.h b/chrome/browser/ui/cocoa/bookmarks/bookmark_menu_cocoa_controller.h new file mode 100644 index 0000000..66ecc45 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_menu_cocoa_controller.h @@ -0,0 +1,46 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// Controller (MVC) for the bookmark menu. +// All bookmark menu item commands get directed here. +// Unfortunately there is already a C++ class named BookmarkMenuController. + +#ifndef CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_MENU_COCOA_CONTROLLER_H_ +#define CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_MENU_COCOA_CONTROLLER_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#import "base/cocoa_protocols_mac.h" + +class BookmarkNode; +class BookmarkMenuBridge; + +@interface BookmarkMenuCocoaController : NSObject<NSMenuDelegate> { + @private + BookmarkMenuBridge* bridge_; // weak; owns me +} + +// The Bookmarks menu +@property (nonatomic, readonly) NSMenu* menu; + +// Return an autoreleased string to be used as a menu title for the +// given bookmark node. ++ (NSString*)menuTitleForNode:(const BookmarkNode*)node; + +- (id)initWithBridge:(BookmarkMenuBridge *)bridge; + +// Called by any Bookmark menu item. +// The menu item's tag is the bookmark ID. +- (IBAction)openBookmarkMenuItem:(id)sender; + +@end // BookmarkMenuCocoaController + + +@interface BookmarkMenuCocoaController (ExposedForUnitTests) +- (const BookmarkNode*)nodeForIdentifier:(int)identifier; +- (void)openURLForNode:(const BookmarkNode*)node; +@end // BookmarkMenuCocoaController (ExposedForUnitTests) + +#endif // CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_MENU_COCOA_CONTROLLER_H_ diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_menu_cocoa_controller.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_menu_cocoa_controller.mm new file mode 100644 index 0000000..4c36120 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_menu_cocoa_controller.mm @@ -0,0 +1,98 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_menu_cocoa_controller.h" + +#include "app/text_elider.h" +#include "base/sys_string_conversions.h" +#include "base/utf_string_conversions.h" +#include "chrome/app/chrome_command_ids.h" // IDC_BOOKMARK_MENU +#import "chrome/browser/app_controller_mac.h" +#include "chrome/browser/bookmarks/bookmark_model.h" +#include "chrome/browser/browser_process.h" +#include "chrome/browser/tab_contents/tab_contents.h" +#include "chrome/browser/ui/browser.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_menu_bridge.h" +#include "chrome/browser/ui/cocoa/event_utils.h" +#include "webkit/glue/window_open_disposition.h" + +namespace { + +// Menus more than this many pixels wide will get trimmed +// TODO(jrg): ask UI dudes what a good value is. +const NSUInteger kMaximumMenuPixelsWide = 300; + +} + +@implementation BookmarkMenuCocoaController + ++ (NSString*)menuTitleForNode:(const BookmarkNode*)node { + NSFont* nsfont = [NSFont menuBarFontOfSize:0]; // 0 means "default" + gfx::Font font(base::SysNSStringToWide([nsfont fontName]), + static_cast<int>([nsfont pointSize])); + string16 title = gfx::ElideText(node->GetTitle(), + font, + kMaximumMenuPixelsWide, + false); + return base::SysUTF16ToNSString(title); +} + +- (id)initWithBridge:(BookmarkMenuBridge *)bridge { + if ((self = [super init])) { + bridge_ = bridge; + DCHECK(bridge_); + [[self menu] setDelegate:self]; + } + return self; +} + +- (void)dealloc { + [[self menu] setDelegate:nil]; + [super dealloc]; +} + +- (NSMenu*)menu { + return [[[NSApp mainMenu] itemWithTag:IDC_BOOKMARK_MENU] submenu]; +} + +- (BOOL)validateMenuItem:(NSMenuItem*)menuItem { + AppController* controller = [NSApp delegate]; + return [controller keyWindowIsNotModal]; +} + +// NSMenu delegate method: called just before menu is displayed. +- (void)menuNeedsUpdate:(NSMenu*)menu { + bridge_->UpdateMenu(menu); +} + +// Return the a BookmarkNode that has the given id (called +// "identifier" here to avoid conflict with objc's concept of "id"). +- (const BookmarkNode*)nodeForIdentifier:(int)identifier { + return bridge_->GetBookmarkModel()->GetNodeByID(identifier); +} + +// Open the URL of the given BookmarkNode in the current tab. +- (void)openURLForNode:(const BookmarkNode*)node { + Browser* browser = Browser::GetTabbedBrowser(bridge_->GetProfile(), true); + if (!browser) + browser = Browser::Create(bridge_->GetProfile()); + WindowOpenDisposition disposition = + event_utils::WindowOpenDispositionFromNSEvent([NSApp currentEvent]); + browser->OpenURL(node->GetURL(), GURL(), disposition, + PageTransition::AUTO_BOOKMARK); +} + +- (IBAction)openBookmarkMenuItem:(id)sender { + NSInteger tag = [sender tag]; + int identifier = tag; + const BookmarkNode* node = [self nodeForIdentifier:identifier]; + DCHECK(node); + if (!node) + return; // shouldn't be reached + + [self openURLForNode:node]; +} + +@end // BookmarkMenuCocoaController + diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_menu_cocoa_controller_unittest.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_menu_cocoa_controller_unittest.mm new file mode 100644 index 0000000..22930bc --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_menu_cocoa_controller_unittest.mm @@ -0,0 +1,66 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/string16.h" +#include "chrome/browser/bookmarks/bookmark_model.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_menu_cocoa_controller.h" +#include "chrome/browser/ui/browser.h" +#include "testing/gtest/include/gtest/gtest.h" + +@interface FakeBookmarkMenuController : BookmarkMenuCocoaController { + @public + BrowserTestHelper* helper_; + const BookmarkNode* nodes_[2]; + BOOL opened_[2]; +} +@end + +@implementation FakeBookmarkMenuController + +- (id)init { + if ((self = [super init])) { + string16 empty; + helper_ = new BrowserTestHelper(); + BookmarkModel* model = helper_->browser()->profile()->GetBookmarkModel(); + const BookmarkNode* bookmark_bar = model->GetBookmarkBarNode(); + nodes_[0] = model->AddURL(bookmark_bar, 0, empty, GURL("http://0.com")); + nodes_[1] = model->AddURL(bookmark_bar, 1, empty, GURL("http://1.com")); + } + return self; +} + +- (void)dealloc { + delete helper_; + [super dealloc]; +} + +- (const BookmarkNode*)nodeForIdentifier:(int)identifier { + if ((identifier < 0) || (identifier >= 2)) + return NULL; + return nodes_[identifier]; +} + +- (void)openURLForNode:(const BookmarkNode*)node { + std::string url = node->GetURL().possibly_invalid_spec(); + if (url.find("http://0.com") != std::string::npos) + opened_[0] = YES; + if (url.find("http://1.com") != std::string::npos) + opened_[1] = YES; +} + +@end // FakeBookmarkMenuController + + +TEST(BookmarkMenuCocoaControllerTest, TestOpenItem) { + FakeBookmarkMenuController *c = [[FakeBookmarkMenuController alloc] init]; + NSMenuItem *item = [[[NSMenuItem alloc] init] autorelease]; + for (int i = 0; i < 2; i++) { + [item setTag:i]; + ASSERT_EQ(c->opened_[i], NO); + [c openBookmarkMenuItem:item]; + ASSERT_NE(c->opened_[i], NO); + } + [c release]; +} diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_menu_unittest.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_menu_unittest.mm new file mode 100644 index 0000000..ef251b0 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_menu_unittest.mm @@ -0,0 +1,29 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_menu.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +namespace { + +class BookmarkMenuTest : public CocoaTest { +}; + +TEST_F(BookmarkMenuTest, Basics) { + scoped_nsobject<BookmarkMenu> menu([[BookmarkMenu alloc] + initWithTitle:@"title"]); + scoped_nsobject<NSMenuItem> item([[NSMenuItem alloc] initWithTitle:@"item" + action:NULL + keyEquivalent:@""]); + [menu addItem:item]; + long long l = 103849459459598948LL; // arbitrary + NSNumber* number = [NSNumber numberWithLongLong:l]; + [menu setRepresentedObject:number]; + EXPECT_EQ(l, [menu id]); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_model_observer_for_cocoa.h b/chrome/browser/ui/cocoa/bookmarks/bookmark_model_observer_for_cocoa.h new file mode 100644 index 0000000..0a7da34 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_model_observer_for_cocoa.h @@ -0,0 +1,116 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// C++ bridge class to send a selector to a Cocoa object when the +// bookmark model changes. Some Cocoa objects edit the bookmark model +// and temporarily save a copy of the state (e.g. bookmark button +// editor). As a fail-safe, these objects want an easy cancel if the +// model changes out from under them. For example, if you have the +// bookmark button editor sheet open, then edit the bookmark in the +// bookmark manager, we'd want to simply cancel the editor. +// +// This class is conservative and may result in notifications which +// aren't strictly necessary. For example, node removal only needs to +// cancel an edit if the removed node is a folder (editors often have +// a list of "new parents"). But, just to be sure, notification +// happens on any removal. + +#ifndef CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_MODEL_OBSERVER_FOR_COCOA_H +#define CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_MODEL_OBSERVER_FOR_COCOA_H +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "base/basictypes.h" +#include "base/scoped_nsobject.h" +#include "chrome/browser/bookmarks/bookmark_model.h" +#include "chrome/browser/bookmarks/bookmark_model_observer.h" + +class BookmarkModelObserverForCocoa : public BookmarkModelObserver { + public: + // When |node| in |model| changes, send |selector| to |object|. + // Assumes |selector| is a selector that takes one arg, like an + // IBOutlet. The arg passed is nil. + // Many notifications happen independently of node + // (e.g. BeingDeleted), so |node| can be nil. + // + // |object| is NOT retained, since the expected use case is for + // ||object| to own the BookmarkModelObserverForCocoa and we don't + // want a retain cycle. + BookmarkModelObserverForCocoa(const BookmarkNode* node, + BookmarkModel* model, + NSObject* object, + SEL selector) { + DCHECK(model); + node_ = node; + model_ = model; + object_ = object; + selector_ = selector; + model_->AddObserver(this); + } + virtual ~BookmarkModelObserverForCocoa() { + model_->RemoveObserver(this); + } + + virtual void BookmarkModelBeingDeleted(BookmarkModel* model) { + Notify(); + } + virtual void BookmarkNodeMoved(BookmarkModel* model, + const BookmarkNode* old_parent, + int old_index, + const BookmarkNode* new_parent, + int new_index) { + // Editors often have a tree of parents, so movement of folders + // must cause a cancel. + Notify(); + } + virtual void BookmarkNodeRemoved(BookmarkModel* model, + const BookmarkNode* parent, + int old_index, + const BookmarkNode* node) { + // See comment in BookmarkNodeMoved. + Notify(); + } + virtual void BookmarkNodeChanged(BookmarkModel* model, + const BookmarkNode* node) { + if ((node_ == node) || (!node_)) + Notify(); + } + virtual void BookmarkImportBeginning(BookmarkModel* model) { + // Be conservative. + Notify(); + } + + // Some notifications we don't care about, but by being pure virtual + // in the base class we must implement them. + virtual void Loaded(BookmarkModel* model) { + } + virtual void BookmarkNodeAdded(BookmarkModel* model, + const BookmarkNode* parent, + int index) { + } + virtual void BookmarkNodeFavIconLoaded(BookmarkModel* model, + const BookmarkNode* node) { + } + virtual void BookmarkNodeChildrenReordered(BookmarkModel* model, + const BookmarkNode* node) { + } + + virtual void BookmarkImportEnding(BookmarkModel* model) { + } + + private: + const BookmarkNode* node_; // Weak; owned by a BookmarkModel. + BookmarkModel* model_; // Weak; it is owned by a Profile. + NSObject* object_; // Weak, like a delegate. + SEL selector_; + + void Notify() { + [object_ performSelector:selector_ withObject:nil]; + } + + DISALLOW_COPY_AND_ASSIGN(BookmarkModelObserverForCocoa); +}; + +#endif // CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_MODEL_OBSERVER_FOR_COCOA_H diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_model_observer_for_cocoa_unittest.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_model_observer_for_cocoa_unittest.mm new file mode 100644 index 0000000..5ff8687 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_model_observer_for_cocoa_unittest.mm @@ -0,0 +1,68 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_ptr.h" +#include "base/scoped_nsobject.h" +#include "base/utf_string_conversions.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_model_observer_for_cocoa.h" +#import "chrome/browser/ui/cocoa/browser_test_helper.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" + +// Keep track of bookmark pings. +@interface ObserverPingTracker : NSObject { + @public + int pings; +} +@end + +@implementation ObserverPingTracker +- (void)pingMe:(id)sender { + pings++; +} +@end + +namespace { + +class BookmarkModelObserverForCocoaTest : public CocoaTest { + public: + BrowserTestHelper helper_; + + BookmarkModelObserverForCocoaTest() {} + virtual ~BookmarkModelObserverForCocoaTest() {} + private: + DISALLOW_COPY_AND_ASSIGN(BookmarkModelObserverForCocoaTest); +}; + + +TEST_F(BookmarkModelObserverForCocoaTest, TestCallback) { + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + const BookmarkNode* node = model->AddURL(model->GetBookmarkBarNode(), + 0, ASCIIToUTF16("super"), + GURL("http://www.google.com")); + + scoped_nsobject<ObserverPingTracker> + pingCount([[ObserverPingTracker alloc] init]); + + scoped_ptr<BookmarkModelObserverForCocoa> + observer(new BookmarkModelObserverForCocoa(node, model, + pingCount, + @selector(pingMe:))); + + EXPECT_EQ(0, pingCount.get()->pings); + + model->SetTitle(node, ASCIIToUTF16("duper")); + EXPECT_EQ(1, pingCount.get()->pings); + model->SetURL(node, GURL("http://www.google.com/reader")); + EXPECT_EQ(2, pingCount.get()->pings); + + model->Move(node, model->other_node(), 0); + EXPECT_EQ(3, pingCount.get()->pings); + + model->Remove(node->GetParent(), 0); + EXPECT_EQ(4, pingCount.get()->pings); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_name_folder_controller.h b/chrome/browser/ui/cocoa/bookmarks/bookmark_name_folder_controller.h new file mode 100644 index 0000000..40f1cb1 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_name_folder_controller.h @@ -0,0 +1,64 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_NAME_FOLDER_CONTROLLER_H_ +#define CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_NAME_FOLDER_CONTROLLER_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#include "base/scoped_ptr.h" +#include "chrome/browser/bookmarks/bookmark_model.h" + +class BookmarkModelObserverForCocoa; + +// A controller for dialog to let the user create a new folder or +// rename an existing folder. Accessible from a context menu on a +// bookmark button or the bookmark bar. +@interface BookmarkNameFolderController : NSWindowController { + @private + IBOutlet NSTextField* nameField_; + IBOutlet NSButton* okButton_; + + NSWindow* parentWindow_; // weak + Profile* profile_; // weak + + // Weak; owned by the model. Can be NULL (see below). Either node_ + // is non-NULL (renaming a folder), or parent_ is non-NULL (adding a + // new one). + const BookmarkNode* node_; + const BookmarkNode* parent_; + int newIndex_; + + scoped_nsobject<NSString> initialName_; + + // Ping me when things change out from under us. + scoped_ptr<BookmarkModelObserverForCocoa> observer_; +} + +// Use the 1st initializer for a "rename existing folder" request. +// +// Use the 2nd initializer for an "add folder" request. If creating a +// new folder |parent| and |newIndex| specify where to put the new +// node. +- (id)initWithParentWindow:(NSWindow*)window + profile:(Profile*)profile + node:(const BookmarkNode*)node; +- (id)initWithParentWindow:(NSWindow*)window + profile:(Profile*)profile + parent:(const BookmarkNode*)parent + newIndex:(int)newIndex; +- (void)runAsModalSheet; +- (IBAction)cancel:(id)sender; +- (IBAction)ok:(id)sender; +@end + +@interface BookmarkNameFolderController(TestingAPI) +- (NSString*)folderName; +- (void)setFolderName:(NSString*)name; +- (NSButton*)okButton; +@end + +#endif // CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_NAME_FOLDER_CONTROLLER_H_ diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_name_folder_controller.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_name_folder_controller.mm new file mode 100644 index 0000000..8c34af2 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_name_folder_controller.mm @@ -0,0 +1,123 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_name_folder_controller.h" + +#include "app/l10n_util.h" +#include "app/l10n_util_mac.h" +#include "base/mac_util.h" +#include "base/sys_string_conversions.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/ui/cocoa/bookmarks/bookmark_model_observer_for_cocoa.h" +#include "grit/generated_resources.h" + +@implementation BookmarkNameFolderController + +// Common initializer (private). +- (id)initWithParentWindow:(NSWindow*)window + profile:(Profile*)profile + node:(const BookmarkNode*)node + parent:(const BookmarkNode*)parent + newIndex:(int)newIndex { + NSString* nibpath = [mac_util::MainAppBundle() + pathForResource:@"BookmarkNameFolder" + ofType:@"nib"]; + if ((self = [super initWithWindowNibPath:nibpath owner:self])) { + parentWindow_ = window; + profile_ = profile; + node_ = node; + parent_ = parent; + newIndex_ = newIndex; + if (parent) { + DCHECK_LE(newIndex, parent->GetChildCount()); + } + if (node_) { + initialName_.reset([base::SysUTF16ToNSString(node_->GetTitle()) retain]); + } else { + NSString* newString = + l10n_util::GetNSStringWithFixup(IDS_BOOMARK_EDITOR_NEW_FOLDER_NAME); + initialName_.reset([newString retain]); + } + } + return self; +} + +- (id)initWithParentWindow:(NSWindow*)window + profile:(Profile*)profile + node:(const BookmarkNode*)node { + DCHECK(node); + return [self initWithParentWindow:window + profile:profile + node:node + parent:nil + newIndex:0]; +} + +- (id)initWithParentWindow:(NSWindow*)window + profile:(Profile*)profile + parent:(const BookmarkNode*)parent + newIndex:(int)newIndex { + DCHECK(parent); + return [self initWithParentWindow:window + profile:profile + node:nil + parent:parent + newIndex:newIndex]; +} + +- (void)awakeFromNib { + [nameField_ setStringValue:initialName_.get()]; +} + +- (void)runAsModalSheet { + // Ping me when things change out from under us. + observer_.reset(new BookmarkModelObserverForCocoa( + node_, profile_->GetBookmarkModel(), + self, + @selector(cancel:))); + [NSApp beginSheet:[self window] + modalForWindow:parentWindow_ + modalDelegate:self + didEndSelector:@selector(didEndSheet:returnCode:contextInfo:) + contextInfo:nil]; +} + +- (IBAction)cancel:(id)sender { + [NSApp endSheet:[self window]]; +} + +- (IBAction)ok:(id)sender { + NSString* name = [nameField_ stringValue]; + BookmarkModel* model = profile_->GetBookmarkModel(); + if (node_) { + model->SetTitle(node_, base::SysNSStringToUTF16(name)); + } else { + model->AddGroup(parent_, + newIndex_, + base::SysNSStringToUTF16(name)); + } + [NSApp endSheet:[self window]]; +} + +- (void)didEndSheet:(NSWindow*)sheet + returnCode:(int)returnCode + contextInfo:(void*)contextInfo { + [[self window] orderOut:self]; + observer_.reset(NULL); + [self autorelease]; +} + +- (NSString*)folderName { + return [nameField_ stringValue]; +} + +- (void)setFolderName:(NSString*)name { + [nameField_ setStringValue:name]; +} + +- (NSButton*)okButton { + return okButton_; +} + +@end // BookmarkNameFolderController diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_name_folder_controller_unittest.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_name_folder_controller_unittest.mm new file mode 100644 index 0000000..69fb939 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_name_folder_controller_unittest.mm @@ -0,0 +1,172 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#include "base/utf_string_conversions.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_name_folder_controller.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "testing/gtest/include/gtest/gtest.h" +#import "testing/gtest_mac.h" +#include "testing/platform_test.h" + +class BookmarkNameFolderControllerTest : public CocoaTest { + public: + BrowserTestHelper helper_; +}; + + +// Simple add of a node (at the end). +TEST_F(BookmarkNameFolderControllerTest, AddNew) { + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + const BookmarkNode* parent = model->GetBookmarkBarNode(); + EXPECT_EQ(0, parent->GetChildCount()); + + scoped_nsobject<BookmarkNameFolderController> + controller([[BookmarkNameFolderController alloc] + initWithParentWindow:test_window() + profile:helper_.profile() + parent:parent + newIndex:0]); + [controller window]; // force nib load + + // Do nothing. + [controller cancel:nil]; + EXPECT_EQ(0, parent->GetChildCount()); + + // Change name then cancel. + [controller setFolderName:@"Bozo"]; + [controller cancel:nil]; + EXPECT_EQ(0, parent->GetChildCount()); + + // Add a new folder. + [controller ok:nil]; + EXPECT_EQ(1, parent->GetChildCount()); + EXPECT_TRUE(parent->GetChild(0)->is_folder()); + EXPECT_EQ(ASCIIToUTF16("Bozo"), parent->GetChild(0)->GetTitle()); +} + +// Add new but specify a sibling. +TEST_F(BookmarkNameFolderControllerTest, AddNewWithSibling) { + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + const BookmarkNode* parent = model->GetBookmarkBarNode(); + + // Add 2 nodes. We will place the new folder in the middle of these. + model->AddURL(parent, 0, ASCIIToUTF16("title 1"), + GURL("http://www.google.com")); + model->AddURL(parent, 1, ASCIIToUTF16("title 3"), + GURL("http://www.google.com")); + EXPECT_EQ(2, parent->GetChildCount()); + + scoped_nsobject<BookmarkNameFolderController> + controller([[BookmarkNameFolderController alloc] + initWithParentWindow:test_window() + profile:helper_.profile() + parent:parent + newIndex:1]); + [controller window]; // force nib load + + // Add a new folder. + [controller setFolderName:@"middle"]; + [controller ok:nil]; + + // Confirm we now have 3, and that the new one is in the middle. + EXPECT_EQ(3, parent->GetChildCount()); + EXPECT_TRUE(parent->GetChild(1)->is_folder()); + EXPECT_EQ(ASCIIToUTF16("middle"), parent->GetChild(1)->GetTitle()); +} + +// Make sure we are allowed to create a folder named "New Folder". +TEST_F(BookmarkNameFolderControllerTest, AddNewDefaultName) { + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + const BookmarkNode* parent = model->GetBookmarkBarNode(); + EXPECT_EQ(0, parent->GetChildCount()); + + scoped_nsobject<BookmarkNameFolderController> + controller([[BookmarkNameFolderController alloc] + initWithParentWindow:test_window() + profile:helper_.profile() + parent:parent + newIndex:0]); + + [controller window]; // force nib load + + // Click OK without changing the name + [controller ok:nil]; + EXPECT_EQ(1, parent->GetChildCount()); + EXPECT_TRUE(parent->GetChild(0)->is_folder()); +} + +// Make sure we are allowed to create a folder with an empty name. +TEST_F(BookmarkNameFolderControllerTest, AddNewBlankName) { + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + const BookmarkNode* parent = model->GetBookmarkBarNode(); + EXPECT_EQ(0, parent->GetChildCount()); + + scoped_nsobject<BookmarkNameFolderController> + controller([[BookmarkNameFolderController alloc] + initWithParentWindow:test_window() + profile:helper_.profile() + parent:parent + newIndex:0]); + [controller window]; // force nib load + + // Change the name to blank, click OK. + [controller setFolderName:@""]; + [controller ok:nil]; + EXPECT_EQ(1, parent->GetChildCount()); + EXPECT_TRUE(parent->GetChild(0)->is_folder()); +} + +TEST_F(BookmarkNameFolderControllerTest, Rename) { + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + const BookmarkNode* parent = model->GetBookmarkBarNode(); + const BookmarkNode* folder = model->AddGroup(parent, + parent->GetChildCount(), + ASCIIToUTF16("group")); + + // Rename the folder by creating a controller that originates from + // the node. + scoped_nsobject<BookmarkNameFolderController> + controller([[BookmarkNameFolderController alloc] + initWithParentWindow:test_window() + profile:helper_.profile() + node:folder]); + [controller window]; // force nib load + + EXPECT_NSEQ(@"group", [controller folderName]); + [controller setFolderName:@"Zobo"]; + [controller ok:nil]; + EXPECT_EQ(1, parent->GetChildCount()); + EXPECT_TRUE(parent->GetChild(0)->is_folder()); + EXPECT_EQ(ASCIIToUTF16("Zobo"), parent->GetChild(0)->GetTitle()); +} + +TEST_F(BookmarkNameFolderControllerTest, EditAndConfirmOKButton) { + BookmarkModel* model = helper_.profile()->GetBookmarkModel(); + const BookmarkNode* parent = model->GetBookmarkBarNode(); + EXPECT_EQ(0, parent->GetChildCount()); + + scoped_nsobject<BookmarkNameFolderController> + controller([[BookmarkNameFolderController alloc] + initWithParentWindow:test_window() + profile:helper_.profile() + parent:parent + newIndex:0]); + [controller window]; // force nib load + + // We start enabled since the default "New Folder" is added for us. + EXPECT_TRUE([[controller okButton] isEnabled]); + + [controller setFolderName:@"Bozo"]; + EXPECT_TRUE([[controller okButton] isEnabled]); + [controller setFolderName:@" "]; + EXPECT_TRUE([[controller okButton] isEnabled]); + + [controller setFolderName:@""]; + EXPECT_TRUE([[controller okButton] isEnabled]); +} + diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_tree_browser_cell.h b/chrome/browser/ui/cocoa/bookmarks/bookmark_tree_browser_cell.h new file mode 100644 index 0000000..08b195d --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_tree_browser_cell.h @@ -0,0 +1,35 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_TREE_BROWSER_CELL_H_ +#define CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_TREE_BROWSER_CELL_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +class BookmarkNode; + +// Provides a custom cell as used in the BookmarkEditor.xib's folder tree +// browser view. This cell customization adds target and action support +// not provided by the NSBrowserCell as well as contextual information +// identifying the bookmark node being edited and the column matrix +// control in which is contained the cell. +@interface BookmarkTreeBrowserCell : NSBrowserCell { + @private + const BookmarkNode* bookmarkNode_; // weak + NSMatrix* matrix_; // weak + id target_; // weak + SEL action_; +} + +@property (nonatomic, assign) NSMatrix* matrix; +@property (nonatomic, assign) id target; +@property (nonatomic, assign) SEL action; + +- (const BookmarkNode*)bookmarkNode; +- (void)setBookmarkNode:(const BookmarkNode*)bookmarkNode; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_BOOKMARKS_BOOKMARK_TREE_BROWSER_CELL_H_ diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_tree_browser_cell.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_tree_browser_cell.mm new file mode 100644 index 0000000..6fe7e6e --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_tree_browser_cell.mm @@ -0,0 +1,23 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_tree_browser_cell.h" + +#include "chrome/browser/bookmarks/bookmark_model.h" + +@implementation BookmarkTreeBrowserCell + +@synthesize matrix = matrix_; +@synthesize target = target_; +@synthesize action = action_; + +- (const BookmarkNode*)bookmarkNode { + return bookmarkNode_; +} + +- (void)setBookmarkNode:(const BookmarkNode*)bookmarkNode { + bookmarkNode_ = bookmarkNode; +} + +@end diff --git a/chrome/browser/ui/cocoa/bookmarks/bookmark_tree_browser_cell_unittest.mm b/chrome/browser/ui/cocoa/bookmarks/bookmark_tree_browser_cell_unittest.mm new file mode 100644 index 0000000..5171018 --- /dev/null +++ b/chrome/browser/ui/cocoa/bookmarks/bookmark_tree_browser_cell_unittest.mm @@ -0,0 +1,43 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "chrome/browser/bookmarks/bookmark_model.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_tree_browser_cell.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "testing/platform_test.h" + +class BookmarkTreeBrowserCellTest : public PlatformTest { + public: + BookmarkTreeBrowserCellTest() { + // Set up our mocks. + GURL gurl; + bookmarkNodeMock_.reset(new BookmarkNode(gurl)); + matrixMock_.reset([[NSMatrix alloc] init]); + targetMock_.reset([[NSObject alloc] init]); + } + + scoped_ptr<BookmarkNode> bookmarkNodeMock_; + scoped_nsobject<NSMatrix> matrixMock_; + scoped_nsobject<NSObject> targetMock_; +}; + +TEST_F(BookmarkTreeBrowserCellTest, BasicAllocDealloc) { + BookmarkTreeBrowserCell* cell = [[[BookmarkTreeBrowserCell alloc] + initTextCell:@"TEST STRING"] autorelease]; + [cell setMatrix:matrixMock_.get()]; + [cell setTarget:targetMock_.get()]; + [cell setAction:@selector(mockAction:)]; + [cell setBookmarkNode:bookmarkNodeMock_.get()]; + + NSMatrix* testMatrix = [cell matrix]; + EXPECT_EQ(testMatrix, matrixMock_.get()); + id testTarget = [cell target]; + EXPECT_EQ(testTarget, targetMock_.get()); + SEL testAction = [cell action]; + EXPECT_EQ(testAction, @selector(mockAction:)); + const BookmarkNode* testBookmarkNode = [cell bookmarkNode]; + EXPECT_EQ(testBookmarkNode, bookmarkNodeMock_.get()); +} diff --git a/chrome/browser/ui/cocoa/browser_command_executor.h b/chrome/browser/ui/cocoa/browser_command_executor.h new file mode 100644 index 0000000..e6e01cf --- /dev/null +++ b/chrome/browser/ui/cocoa/browser_command_executor.h @@ -0,0 +1,16 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_BROWSER_COMMAND_EXECUTOR_H_ +#define CHROME_BROWSER_BROWSER_COMMAND_EXECUTOR_H_ +#pragma once + +// Defines a protocol for any object that can execute commands in the +// context of some underlying browser object. +@protocol BrowserCommandExecutor +- (void)executeCommand:(int)command; +@end + +#endif // CHROME_BROWSER_BROWSER_COMMAND_EXECUTOR_H_ + diff --git a/chrome/browser/ui/cocoa/browser_frame_view.h b/chrome/browser/ui/cocoa/browser_frame_view.h new file mode 100644 index 0000000..42faa9b --- /dev/null +++ b/chrome/browser/ui/cocoa/browser_frame_view.h @@ -0,0 +1,65 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +// BrowserFrameView is a class whose methods we swizzle into NSGrayFrame +// (an AppKit framework class) so that we can support custom frame drawing, and +// have the ability to move our window widgets (close, zoom, miniaturize) where +// we want them. +// This class is never to be instantiated on its own. +// We explored a variety of ways to support custom frame drawing and custom +// window widgets. +// Our requirements were: +// a) that we could fall back on standard system drawing at any time for the +// "default theme" +// b) We needed to be able to draw both a background pattern, and an overlay +// graphic, and we need to be able to set the pattern phase of our background +// window. +// c) We had to be able to support "transparent" themes, so that you could see +// through to the underlying windows in places without the system theme +// getting in the way. +// d) We had to support having the custom window controls moved down by a couple +// of pixels and rollovers, accessibility, etc. all had to work. +// +// Since we want "A" we couldn't just do a transparent borderless window. At +// least I couldn't find the right combination of HITheme calls to make it draw +// nicely, and I don't trust that the HITheme calls are going to exist in future +// system versions. +// "C" precluded us from inserting a view between the system frame and the +// the content frame in Z order. To get the transparency we actually need to +// replace the drawing of the system frame. +// "D" required us to override _mouseInGroup to get our custom widget rollovers +// drawing correctly. The widgets call _mouseInGroup on their superview to +// decide whether they should draw in highlight mode or not. +// "B" precluded us from just setting a background color on the window. +// +// Originally we tried overriding the private API +frameViewForStyleMask: to +// add our own subclass of NSGrayView to our window. Turns out that if you +// subclass NSGrayView it does not draw correctly when you call NSGrayView's +// drawRect. It appears that NSGrayView's drawRect: method (and that of its +// superclasses) do lots of "isMemberOfClass/isKindOfClass" calls, and if your +// class is NOT an instance of NSGrayView (as opposed to a subclass of +// NSGrayView) then the system drawing will not work correctly. +// +// Given all of the above, we found swizzling drawRect, and adding an +// implementation of _mouseInGroup and updateTrackingAreas, in _load to be the +// easiest and safest method of achieving our goals. We do the best we can to +// check that everything is safe, and attempt to fallback gracefully if it is +// not. +@interface BrowserFrameView : NSView + +// Draws the window theme into the specified rect. Returns whether a theme was +// drawn (whether incognito or full pattern theme; an overlay image doesn't +// count). ++ (BOOL)drawWindowThemeInDirtyRect:(NSRect)dirtyRect + forView:(NSView*)view + bounds:(NSRect)bounds + offset:(NSPoint)offset + forceBlackBackground:(BOOL)forceBlackBackground; + +// Gets the color to draw title text. ++ (NSColor*)titleColorForThemeView:(NSView*)view; + +@end diff --git a/chrome/browser/ui/cocoa/browser_frame_view.mm b/chrome/browser/ui/cocoa/browser_frame_view.mm new file mode 100644 index 0000000..a966785 --- /dev/null +++ b/chrome/browser/ui/cocoa/browser_frame_view.mm @@ -0,0 +1,399 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/browser_frame_view.h" + +#import <objc/runtime.h> +#import <Carbon/Carbon.h> + +#include "base/logging.h" +#include "base/mac/scoped_nsautorelease_pool.h" +#import "chrome/browser/themes/browser_theme_provider.h" +#import "chrome/browser/ui/cocoa/framed_browser_window.h" +#import "chrome/browser/ui/cocoa/themed_window.h" +#include "grit/theme_resources.h" + +static const CGFloat kBrowserFrameViewPaintHeight = 60.0; +static const NSPoint kBrowserFrameViewPatternPhaseOffset = { -5, 3 }; + +static BOOL gCanDrawTitle = NO; +static BOOL gCanGetCornerRadius = NO; + +@interface NSView (Swizzles) +- (void)drawRectOriginal:(NSRect)rect; +- (BOOL)_mouseInGroup:(NSButton*)widget; +- (void)updateTrackingAreas; +- (NSUInteger)_shadowFlagsOriginal; +@end + +// Undocumented APIs. They are really on NSGrayFrame rather than +// BrowserFrameView, but we call them from methods swizzled onto NSGrayFrame. +@interface BrowserFrameView (UndocumentedAPI) + +- (float)roundedCornerRadius; +- (CGRect)_titlebarTitleRect; +- (void)_drawTitleStringIn:(struct CGRect)arg1 withColor:(id)color; +- (NSUInteger)_shadowFlags; + +@end + +@implementation BrowserFrameView + ++ (void)load { + // This is where we swizzle drawRect, and add in two methods that we + // need. If any of these fail it shouldn't affect the functionality of the + // others. If they all fail, we will lose window frame theming and + // roll overs for our close widgets, but things should still function + // correctly. + base::mac::ScopedNSAutoreleasePool pool; + Class grayFrameClass = NSClassFromString(@"NSGrayFrame"); + DCHECK(grayFrameClass); + if (!grayFrameClass) return; + + // Exchange draw rect. + Method m0 = class_getInstanceMethod([self class], @selector(drawRect:)); + DCHECK(m0); + if (m0) { + BOOL didAdd = class_addMethod(grayFrameClass, + @selector(drawRectOriginal:), + method_getImplementation(m0), + method_getTypeEncoding(m0)); + DCHECK(didAdd); + if (didAdd) { + Method m1 = class_getInstanceMethod(grayFrameClass, @selector(drawRect:)); + Method m2 = class_getInstanceMethod(grayFrameClass, + @selector(drawRectOriginal:)); + DCHECK(m1 && m2); + if (m1 && m2) { + method_exchangeImplementations(m1, m2); + } + } + } + + // Add _mouseInGroup. + m0 = class_getInstanceMethod([self class], @selector(_mouseInGroup:)); + DCHECK(m0); + if (m0) { + BOOL didAdd = class_addMethod(grayFrameClass, + @selector(_mouseInGroup:), + method_getImplementation(m0), + method_getTypeEncoding(m0)); + DCHECK(didAdd); + } + // Add updateTrackingArea. + m0 = class_getInstanceMethod([self class], @selector(updateTrackingAreas)); + DCHECK(m0); + if (m0) { + BOOL didAdd = class_addMethod(grayFrameClass, + @selector(updateTrackingAreas), + method_getImplementation(m0), + method_getTypeEncoding(m0)); + DCHECK(didAdd); + } + + gCanDrawTitle = + [grayFrameClass + instancesRespondToSelector:@selector(_titlebarTitleRect)] && + [grayFrameClass + instancesRespondToSelector:@selector(_drawTitleStringIn:withColor:)]; + gCanGetCornerRadius = + [grayFrameClass + instancesRespondToSelector:@selector(roundedCornerRadius)]; + + // Add _shadowFlags. This is a method on NSThemeFrame, not on NSGrayFrame. + // NSThemeFrame is NSGrayFrame's superclass. + Class themeFrameClass = NSClassFromString(@"NSThemeFrame"); + DCHECK(themeFrameClass); + if (!themeFrameClass) return; + m0 = class_getInstanceMethod([self class], @selector(_shadowFlags)); + DCHECK(m0); + if (m0) { + BOOL didAdd = class_addMethod(themeFrameClass, + @selector(_shadowFlagsOriginal), + method_getImplementation(m0), + method_getTypeEncoding(m0)); + DCHECK(didAdd); + if (didAdd) { + Method m1 = class_getInstanceMethod(themeFrameClass, + @selector(_shadowFlags)); + Method m2 = class_getInstanceMethod(themeFrameClass, + @selector(_shadowFlagsOriginal)); + DCHECK(m1 && m2); + if (m1 && m2) { + method_exchangeImplementations(m1, m2); + } + } + } +} + +- (id)initWithFrame:(NSRect)frame { + // This class is not for instantiating. + [self doesNotRecognizeSelector:_cmd]; + return nil; +} + +- (id)initWithCoder:(NSCoder*)coder { + // This class is not for instantiating. + [self doesNotRecognizeSelector:_cmd]; + return nil; +} + +// Here is our custom drawing for our frame. +- (void)drawRect:(NSRect)rect { + // If this isn't the window class we expect, then pass it on to the + // original implementation. + if (![[self window] isKindOfClass:[FramedBrowserWindow class]]) { + [self drawRectOriginal:rect]; + return; + } + + // WARNING: There is an obvious optimization opportunity here that you DO NOT + // want to take. To save painting cycles, you might think it would be a good + // idea to call out to -drawRectOriginal: only if no theme were drawn. In + // reality, however, if you fail to call -drawRectOriginal:, or if you call it + // after a clipping path is set, the rounded corners at the top of the window + // will not draw properly. Do not try to be smart here. + + // Only paint the top of the window. + NSWindow* window = [self window]; + NSRect windowRect = [self convertRect:[window frame] fromView:nil]; + windowRect.origin = NSMakePoint(0, 0); + + NSRect paintRect = windowRect; + paintRect.origin.y = NSMaxY(paintRect) - kBrowserFrameViewPaintHeight; + paintRect.size.height = kBrowserFrameViewPaintHeight; + rect = NSIntersectionRect(paintRect, rect); + [self drawRectOriginal:rect]; + + // Set up our clip. + float cornerRadius = 4.0; + if (gCanGetCornerRadius) + cornerRadius = [self roundedCornerRadius]; + [[NSBezierPath bezierPathWithRoundedRect:windowRect + xRadius:cornerRadius + yRadius:cornerRadius] addClip]; + [[NSBezierPath bezierPathWithRect:rect] addClip]; + + // Do the theming. + BOOL themed = [BrowserFrameView drawWindowThemeInDirtyRect:rect + forView:self + bounds:windowRect + offset:NSZeroPoint + forceBlackBackground:NO]; + + // If the window needs a title and we painted over the title as drawn by the + // default window paint, paint it ourselves. + if (themed && gCanDrawTitle && ![[self window] _isTitleHidden]) { + [self _drawTitleStringIn:[self _titlebarTitleRect] + withColor:[BrowserFrameView titleColorForThemeView:self]]; + } + + // Pinstripe the top. + if (themed) { + NSSize windowPixel = [self convertSizeFromBase:NSMakeSize(1, 1)]; + + windowRect = [self convertRect:[window frame] fromView:nil]; + windowRect.origin = NSMakePoint(0, 0); + windowRect.origin.y -= 0.5 * windowPixel.height; + windowRect.origin.x -= 0.5 * windowPixel.width; + windowRect.size.width += windowPixel.width; + [[NSColor colorWithCalibratedWhite:1.0 alpha:0.5] set]; + NSBezierPath* path = [NSBezierPath bezierPathWithRoundedRect:windowRect + xRadius:cornerRadius + yRadius:cornerRadius]; + [path setLineWidth:windowPixel.width]; + [path stroke]; + } +} + ++ (BOOL)drawWindowThemeInDirtyRect:(NSRect)dirtyRect + forView:(NSView*)view + bounds:(NSRect)bounds + offset:(NSPoint)offset + forceBlackBackground:(BOOL)forceBlackBackground { + ThemeProvider* themeProvider = [[view window] themeProvider]; + if (!themeProvider) + return NO; + + ThemedWindowStyle windowStyle = [[view window] themedWindowStyle]; + + // Devtools windows don't get themed. + if (windowStyle & THEMED_DEVTOOLS) + return NO; + + BOOL active = [[view window] isMainWindow]; + BOOL incognito = windowStyle & THEMED_INCOGNITO; + BOOL popup = windowStyle & THEMED_POPUP; + + // Find a theme image. + NSColor* themeImageColor = nil; + int themeImageID; + if (popup && active) + themeImageID = IDR_THEME_TOOLBAR; + else if (popup && !active) + themeImageID = IDR_THEME_TAB_BACKGROUND; + else if (!popup && active && incognito) + themeImageID = IDR_THEME_FRAME_INCOGNITO; + else if (!popup && active && !incognito) + themeImageID = IDR_THEME_FRAME; + else if (!popup && !active && incognito) + themeImageID = IDR_THEME_FRAME_INCOGNITO_INACTIVE; + else + themeImageID = IDR_THEME_FRAME_INACTIVE; + if (themeProvider->HasCustomImage(IDR_THEME_FRAME)) + themeImageColor = themeProvider->GetNSImageColorNamed(themeImageID, true); + + // If no theme image, use a gradient if incognito. + NSGradient* gradient = nil; + if (!themeImageColor && incognito) + gradient = themeProvider->GetNSGradient( + active ? BrowserThemeProvider::GRADIENT_FRAME_INCOGNITO : + BrowserThemeProvider::GRADIENT_FRAME_INCOGNITO_INACTIVE); + + BOOL themed = NO; + if (themeImageColor) { + // The titlebar/tabstrip header on the mac is slightly smaller than on + // Windows. To keep the window background lined up with the tab and toolbar + // patterns, we have to shift the pattern slightly, rather than simply + // drawing it from the top left corner. The offset below was empirically + // determined in order to line these patterns up. + // + // This will make the themes look slightly different than in Windows/Linux + // because of the differing heights between window top and tab top, but this + // has been approved by UI. + NSView* frameView = [[[view window] contentView] superview]; + NSPoint topLeft = NSMakePoint(NSMinX(bounds), NSMaxY(bounds)); + NSPoint topLeftInFrameCoordinates = + [view convertPoint:topLeft toView:frameView]; + + NSPoint phase = kBrowserFrameViewPatternPhaseOffset; + phase.x += (offset.x + topLeftInFrameCoordinates.x); + phase.y += (offset.y + topLeftInFrameCoordinates.y); + + // Align the phase to physical pixels so resizing the window under HiDPI + // doesn't cause wiggling of the theme. + phase = [frameView convertPointToBase:phase]; + phase.x = floor(phase.x); + phase.y = floor(phase.y); + phase = [frameView convertPointFromBase:phase]; + + // Default to replacing any existing pixels with the theme image, but if + // asked paint black first and blend the theme with black. + NSCompositingOperation operation = NSCompositeCopy; + if (forceBlackBackground) { + [[NSColor blackColor] set]; + NSRectFill(dirtyRect); + operation = NSCompositeSourceOver; + } + + [[NSGraphicsContext currentContext] setPatternPhase:phase]; + [themeImageColor set]; + NSRectFillUsingOperation(dirtyRect, operation); + themed = YES; + } else if (gradient) { + NSPoint startPoint = NSMakePoint(NSMinX(bounds), NSMaxY(bounds)); + NSPoint endPoint = startPoint; + endPoint.y -= kBrowserFrameViewPaintHeight; + [gradient drawFromPoint:startPoint toPoint:endPoint options:0]; + themed = YES; + } + + // Check to see if we have an overlay image. + NSImage* overlayImage = nil; + if (themeProvider->HasCustomImage(IDR_THEME_FRAME_OVERLAY)) { + overlayImage = themeProvider-> + GetNSImageNamed(active ? IDR_THEME_FRAME_OVERLAY : + IDR_THEME_FRAME_OVERLAY_INACTIVE, + true); + } + + if (overlayImage) { + // Anchor to top-left and don't scale. + NSSize overlaySize = [overlayImage size]; + NSRect imageFrame = NSMakeRect(0, 0, overlaySize.width, overlaySize.height); + [overlayImage drawAtPoint:NSMakePoint(offset.x, + NSHeight(bounds) + offset.y - + overlaySize.height) + fromRect:imageFrame + operation:NSCompositeSourceOver + fraction:1.0]; + } + + return themed; +} + ++ (NSColor*)titleColorForThemeView:(NSView*)view { + ThemeProvider* themeProvider = [[view window] themeProvider]; + if (!themeProvider) + return [NSColor windowFrameTextColor]; + + ThemedWindowStyle windowStyle = [[view window] themedWindowStyle]; + BOOL active = [[view window] isMainWindow]; + BOOL incognito = windowStyle & THEMED_INCOGNITO; + BOOL popup = windowStyle & THEMED_POPUP; + + NSColor* titleColor = nil; + if (popup && active) { + titleColor = themeProvider->GetNSColor( + BrowserThemeProvider::COLOR_TAB_TEXT, false); + } else if (popup && !active) { + titleColor = themeProvider->GetNSColor( + BrowserThemeProvider::COLOR_BACKGROUND_TAB_TEXT, false); + } + + if (titleColor) + return titleColor; + + if (incognito) + return [NSColor whiteColor]; + else + return [NSColor windowFrameTextColor]; +} + +// Check to see if the mouse is currently in one of our window widgets. +- (BOOL)_mouseInGroup:(NSButton*)widget { + BOOL mouseInGroup = NO; + if ([[self window] isKindOfClass:[FramedBrowserWindow class]]) { + FramedBrowserWindow* window = + static_cast<FramedBrowserWindow*>([self window]); + mouseInGroup = [window mouseInGroup:widget]; + } else if ([super respondsToSelector:@selector(_mouseInGroup:)]) { + mouseInGroup = [super _mouseInGroup:widget]; + } + return mouseInGroup; +} + +// Let our window handle updating the window widget tracking area. +- (void)updateTrackingAreas { + [super updateTrackingAreas]; + if ([[self window] isKindOfClass:[FramedBrowserWindow class]]) { + FramedBrowserWindow* window = + static_cast<FramedBrowserWindow*>([self window]); + [window updateTrackingAreas]; + } +} + +// When the compositor is active, the whole content area is transparent (with +// an OpenGL surface behind it), so Cocoa draws the shadow only around the +// toolbar area. +// Tell the window server that we want a shadow as if none of the content +// area is transparent. +- (NSUInteger)_shadowFlags { + // A slightly less intrusive hack would be to call + // _setContentHasShadow:NO on the window. That seems to be what Terminal.app + // is doing. However, it leads to this function returning 'code | 64', which + // doesn't do what we want. For some reason, it does the right thing in + // Terminal.app. + // TODO(thakis): Figure out why -_setContentHasShadow: works in Terminal.app + // and use that technique instead. http://crbug.com/53382 + + // If this isn't the window class we expect, then pass it on to the + // original implementation. + if (![[self window] isKindOfClass:[FramedBrowserWindow class]]) + return [self _shadowFlagsOriginal]; + + return [self _shadowFlagsOriginal] | 128; +} + +@end diff --git a/chrome/browser/ui/cocoa/browser_frame_view_unittest.mm b/chrome/browser/ui/cocoa/browser_frame_view_unittest.mm new file mode 100644 index 0000000..ca2de67 --- /dev/null +++ b/chrome/browser/ui/cocoa/browser_frame_view_unittest.mm @@ -0,0 +1,48 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> +#include <objc/runtime.h> + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/browser_frame_view.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +class BrowserFrameViewTest : public PlatformTest { + public: + BrowserFrameViewTest() { + NSRect frame = NSMakeRect(0, 0, 50, 50); + // We create NSGrayFrame instead of BrowserFrameView because + // we are swizzling into NSGrayFrame. + Class browserFrameClass = NSClassFromString(@"NSGrayFrame"); + view_.reset([[browserFrameClass alloc] initWithFrame:frame]); + } + + scoped_nsobject<NSView> view_; +}; + +// Test to make sure our class modifications were successful. +TEST_F(BrowserFrameViewTest, SuccessfulClassModifications) { + unsigned int count; + BOOL foundMouseInGroup = NO; + BOOL foundDrawRectOriginal = NO; + BOOL foundUpdateTrackingAreas = NO; + + Method* methods = class_copyMethodList([view_ class], &count); + for (unsigned int i = 0; i < count; ++i) { + SEL selector = method_getName(methods[i]); + if (selector == @selector(_mouseInGroup:)) { + foundMouseInGroup = YES; + } else if (selector == @selector(drawRectOriginal:)) { + foundDrawRectOriginal = YES; + } else if (selector == @selector(updateTrackingAreas)) { + foundUpdateTrackingAreas = YES; + } + } + EXPECT_TRUE(foundMouseInGroup); + EXPECT_TRUE(foundDrawRectOriginal); + EXPECT_TRUE(foundUpdateTrackingAreas); + free(methods); +} diff --git a/chrome/browser/ui/cocoa/browser_test_helper.h b/chrome/browser/ui/cocoa/browser_test_helper.h new file mode 100644 index 0000000..4b5b6a9 --- /dev/null +++ b/chrome/browser/ui/cocoa/browser_test_helper.h @@ -0,0 +1,92 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_BROWSER_TEST_HELPER_H_ +#define CHROME_BROWSER_UI_COCOA_BROWSER_TEST_HELPER_H_ +#pragma once + +#include "chrome/browser/browser_thread.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/ui/browser.h" +#include "chrome/test/testing_profile.h" + +// Base class which contains a valid Browser*. Lots of boilerplate to +// recycle between unit test classes. +// +// This class creates fake UI, file, and IO threads because many objects that +// are attached to the TestingProfile (and other objects) have traits that limit +// their destruction to certain threads. For example, the URLRequestContext can +// only be deleted on the IO thread; without this fake IO thread, the object +// would never be deleted and would report as a leak under Valgrind. Note that +// these are fake threads and they all share the same MessageLoop. +// +// TODO(jrg): move up a level (chrome/browser/ui/cocoa --> +// chrome/browser), and use in non-Mac unit tests such as +// back_forward_menu_model_unittest.cc, +// navigation_controller_unittest.cc, .. +class BrowserTestHelper { + public: + BrowserTestHelper() + : ui_thread_(BrowserThread::UI, &message_loop_), + file_thread_(new BrowserThread(BrowserThread::FILE, &message_loop_)), + io_thread_(new BrowserThread(BrowserThread::IO, &message_loop_)) { + profile_.reset(new TestingProfile()); + profile_->CreateBookmarkModel(true); + profile_->BlockUntilBookmarkModelLoaded(); + + // TODO(shess): These are needed in case someone creates a browser + // window off of browser_. pkasting indicates that other + // platforms use a stub |BrowserWindow| and thus don't need to do + // this. + // http://crbug.com/39725 + profile_->CreateAutocompleteClassifier(); + profile_->CreateTemplateURLModel(); + + browser_.reset(new Browser(Browser::TYPE_NORMAL, profile_.get())); + } + + virtual ~BrowserTestHelper() { + // Delete the testing profile on the UI thread. But first release the + // browser, since it may trigger accesses to the profile upon destruction. + browser_.reset(); + + // Drop any new tasks for the IO and FILE threads. + io_thread_.reset(); + file_thread_.reset(); + + message_loop_.DeleteSoon(FROM_HERE, profile_.release()); + message_loop_.RunAllPending(); + } + + virtual TestingProfile* profile() const { return profile_.get(); } + Browser* browser() const { return browser_.get(); } + + // Creates the browser window. To close this window call |CloseBrowserWindow|. + // Do NOT call close directly on the window. + BrowserWindow* CreateBrowserWindow() { + browser_->CreateBrowserWindow(); + return browser_->window(); + } + + // Closes the window for this browser. This must only be called after + // CreateBrowserWindow(). + void CloseBrowserWindow() { + // Check to make sure a window was actually created. + DCHECK(browser_->window()); + browser_->CloseAllTabs(); + browser_->CloseWindow(); + // |browser_| will be deleted by its BrowserWindowController. + ignore_result(browser_.release()); + } + + private: + scoped_ptr<TestingProfile> profile_; + scoped_ptr<Browser> browser_; + MessageLoopForUI message_loop_; + BrowserThread ui_thread_; + scoped_ptr<BrowserThread> file_thread_; + scoped_ptr<BrowserThread> io_thread_; +}; + +#endif // CHROME_BROWSER_UI_COCOA_BROWSER_TEST_HELPER_H_ diff --git a/chrome/browser/ui/cocoa/browser_window_cocoa.h b/chrome/browser/ui/cocoa/browser_window_cocoa.h new file mode 100644 index 0000000..316d062 --- /dev/null +++ b/chrome/browser/ui/cocoa/browser_window_cocoa.h @@ -0,0 +1,143 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_BROWSER_WINDOW_COCOA_H_ +#define CHROME_BROWSER_UI_COCOA_BROWSER_WINDOW_COCOA_H_ +#pragma once + +#include "base/scoped_nsobject.h" +#include "base/task.h" +#include "chrome/browser/browser_window.h" +#include "chrome/browser/bookmarks/bookmark_model.h" +#include "chrome/common/notification_registrar.h" + +class Browser; +@class BrowserWindowController; +@class FindBarCocoaController; +@class NSEvent; +@class NSMenu; +@class NSWindow; + +// An implementation of BrowserWindow for Cocoa. Bridges between C++ and +// the Cocoa NSWindow. Cross-platform code will interact with this object when +// it needs to manipulate the window. + +class BrowserWindowCocoa : public BrowserWindow, + public NotificationObserver { + public: + BrowserWindowCocoa(Browser* browser, + BrowserWindowController* controller, + NSWindow* window); + virtual ~BrowserWindowCocoa(); + + // Overridden from BrowserWindow + virtual void Show(); + virtual void SetBounds(const gfx::Rect& bounds); + virtual void Close(); + virtual void Activate(); + virtual void Deactivate(); + virtual bool IsActive() const; + virtual void FlashFrame(); + virtual gfx::NativeWindow GetNativeHandle(); + virtual BrowserWindowTesting* GetBrowserWindowTesting(); + virtual StatusBubble* GetStatusBubble(); + virtual void SelectedTabToolbarSizeChanged(bool is_animating); + virtual void UpdateTitleBar(); + virtual void ShelfVisibilityChanged(); + virtual void UpdateDevTools(); + virtual void UpdateLoadingAnimations(bool should_animate); + virtual void SetStarredState(bool is_starred); + virtual gfx::Rect GetRestoredBounds() const; + virtual bool IsMaximized() const; + virtual void SetFullscreen(bool fullscreen); + virtual bool IsFullscreen() const; + virtual bool IsFullscreenBubbleVisible() const; + virtual LocationBar* GetLocationBar() const; + virtual void SetFocusToLocationBar(bool select_all); + virtual void UpdateReloadStopState(bool is_loading, bool force); + virtual void UpdateToolbar(TabContentsWrapper* contents, + bool should_restore_state); + virtual void FocusToolbar(); + virtual void FocusAppMenu(); + virtual void FocusBookmarksToolbar(); + virtual void FocusChromeOSStatus(); + virtual void RotatePaneFocus(bool forwards); + virtual bool IsBookmarkBarVisible() const; + virtual bool IsBookmarkBarAnimating() const; + virtual bool IsToolbarVisible() const; + virtual void ConfirmAddSearchProvider(const TemplateURL* template_url, + Profile* profile); + virtual void ToggleBookmarkBar(); + virtual views::Window* ShowAboutChromeDialog(); + virtual void ShowUpdateChromeDialog(); + virtual void ShowTaskManager(); + virtual void ShowBookmarkBubble(const GURL& url, bool already_bookmarked); + virtual bool IsDownloadShelfVisible() const; + virtual DownloadShelf* GetDownloadShelf(); + virtual void ShowReportBugDialog(); + virtual void ShowClearBrowsingDataDialog(); + virtual void ShowImportDialog(); + virtual void ShowSearchEnginesDialog(); + virtual void ShowPasswordManager(); + virtual void ShowRepostFormWarningDialog(TabContents* tab_contents); + virtual void ShowContentSettingsWindow(ContentSettingsType content_type, + Profile* profile); + virtual void ShowCollectedCookiesDialog(TabContents* tab_contents); + virtual void ShowProfileErrorDialog(int message_id); + virtual void ShowThemeInstallBubble(); + virtual void ConfirmBrowserCloseWithPendingDownloads(); + virtual void ShowHTMLDialog(HtmlDialogUIDelegate* delegate, + gfx::NativeWindow parent_window); + virtual void UserChangedTheme(); + virtual int GetExtraRenderViewHeight() const; + virtual void TabContentsFocused(TabContents* tab_contents); + virtual void ShowPageInfo(Profile* profile, + const GURL& url, + const NavigationEntry::SSLStatus& ssl, + bool show_history); + virtual void ShowAppMenu(); + virtual bool PreHandleKeyboardEvent(const NativeWebKeyboardEvent& event, + bool* is_keyboard_shortcut); + virtual void HandleKeyboardEvent(const NativeWebKeyboardEvent& event); + virtual void ShowCreateWebAppShortcutsDialog(TabContents* tab_contents); + virtual void ShowCreateChromeAppShortcutsDialog(Profile* profile, + const Extension* app); + virtual void Cut(); + virtual void Copy(); + virtual void Paste(); + virtual void ToggleTabStripMode(); + virtual void OpenTabpose(); + virtual void PrepareForInstant(); + virtual void ShowInstant(TabContents* preview_contents); + virtual void HideInstant(); + virtual gfx::Rect GetInstantBounds(); + + // Overridden from NotificationObserver + virtual void Observe(NotificationType type, + const NotificationSource& source, + const NotificationDetails& details); + + // Adds the given FindBar cocoa controller to this browser window. + void AddFindBar(FindBarCocoaController* find_bar_cocoa_controller); + + // Returns the cocoa-world BrowserWindowController + BrowserWindowController* cocoa_controller() { return controller_; } + + protected: + virtual void DestroyBrowser(); + + private: + int GetCommandId(const NativeWebKeyboardEvent& event); + bool HandleKeyboardEventInternal(NSEvent* event); + NSWindow* window() const; // Accessor for the (current) |NSWindow|. + void UpdateSidebarForContents(TabContents* tab_contents); + + NotificationRegistrar registrar_; + Browser* browser_; // weak, owned by controller + BrowserWindowController* controller_; // weak, owns us + ScopedRunnableMethodFactory<Browser> confirm_close_factory_; + scoped_nsobject<NSString> pending_window_title_; +}; + +#endif // CHROME_BROWSER_UI_COCOA_BROWSER_WINDOW_COCOA_H_ diff --git a/chrome/browser/ui/cocoa/browser_window_cocoa.mm b/chrome/browser/ui/cocoa/browser_window_cocoa.mm new file mode 100644 index 0000000..003fec7 --- /dev/null +++ b/chrome/browser/ui/cocoa/browser_window_cocoa.mm @@ -0,0 +1,638 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/browser_window_cocoa.h" + +#include "app/l10n_util_mac.h" +#include "base/command_line.h" +#include "base/logging.h" +#include "base/message_loop.h" +#include "base/sys_string_conversions.h" +#include "chrome/app/chrome_command_ids.h" +#include "chrome/browser/bookmarks/bookmark_utils.h" +#include "chrome/browser/download/download_shelf.h" +#include "chrome/browser/global_keyboard_shortcuts_mac.h" +#include "chrome/browser/page_info_window.h" +#include "chrome/browser/prefs/pref_service.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/sidebar/sidebar_container.h" +#include "chrome/browser/sidebar/sidebar_manager.h" +#include "chrome/browser/tab_contents/tab_contents.h" +#include "chrome/browser/tab_contents_wrapper.h" +#include "chrome/browser/ui/browser.h" +#include "chrome/browser/ui/browser_list.h" +#import "chrome/browser/ui/cocoa/browser_window_controller.h" +#import "chrome/browser/ui/cocoa/bug_report_window_controller.h" +#import "chrome/browser/ui/cocoa/chrome_event_processing_window.h" +#import "chrome/browser/ui/cocoa/clear_browsing_data_controller.h" +#import "chrome/browser/ui/cocoa/collected_cookies_mac.h" +#import "chrome/browser/ui/cocoa/content_settings_dialog_controller.h" +#import "chrome/browser/ui/cocoa/download/download_shelf_controller.h" +#import "chrome/browser/ui/cocoa/edit_search_engine_cocoa_controller.h" +#import "chrome/browser/ui/cocoa/html_dialog_window_controller.h" +#import "chrome/browser/ui/cocoa/import_settings_dialog.h" +#import "chrome/browser/ui/cocoa/keyword_editor_cocoa_controller.h" +#import "chrome/browser/ui/cocoa/location_bar/location_bar_view_mac.h" +#import "chrome/browser/ui/cocoa/nsmenuitem_additions.h" +#include "chrome/browser/ui/cocoa/repost_form_warning_mac.h" +#include "chrome/browser/ui/cocoa/restart_browser.h" +#include "chrome/browser/ui/cocoa/status_bubble_mac.h" +#include "chrome/browser/ui/cocoa/task_manager_mac.h" +#import "chrome/browser/ui/cocoa/theme_install_bubble_view.h" +#import "chrome/browser/ui/cocoa/toolbar_controller.h" +#include "chrome/common/chrome_switches.h" +#include "chrome/common/native_web_keyboard_event.h" +#include "chrome/common/notification_service.h" +#include "chrome/common/pref_names.h" +#include "gfx/rect.h" +#include "grit/chromium_strings.h" +#include "grit/generated_resources.h" + +BrowserWindowCocoa::BrowserWindowCocoa(Browser* browser, + BrowserWindowController* controller, + NSWindow* window) + : browser_(browser), + controller_(controller), + confirm_close_factory_(browser) { + // This pref applies to all windows, so all must watch for it. + registrar_.Add(this, NotificationType::BOOKMARK_BAR_VISIBILITY_PREF_CHANGED, + NotificationService::AllSources()); + registrar_.Add(this, NotificationType::SIDEBAR_CHANGED, + NotificationService::AllSources()); +} + +BrowserWindowCocoa::~BrowserWindowCocoa() { +} + +void BrowserWindowCocoa::Show() { + // The Browser associated with this browser window must become the active + // browser at the time |Show()| is called. This is the natural behaviour under + // Windows, but |-makeKeyAndOrderFront:| won't send |-windowDidBecomeMain:| + // until we return to the runloop. Therefore any calls to + // |BrowserList::GetLastActive()| (for example, in bookmark_util), will return + // the previous browser instead if we don't explicitly set it here. + BrowserList::SetLastActive(browser_); + + [window() makeKeyAndOrderFront:controller_]; +} + +void BrowserWindowCocoa::SetBounds(const gfx::Rect& bounds) { + NSRect cocoa_bounds = NSMakeRect(bounds.x(), 0, bounds.width(), + bounds.height()); + // Flip coordinates based on the primary screen. + NSScreen* screen = [[NSScreen screens] objectAtIndex:0]; + cocoa_bounds.origin.y = + [screen frame].size.height - bounds.height() - bounds.y(); + + [window() setFrame:cocoa_bounds display:YES]; +} + +// Callers assume that this doesn't immediately delete the Browser object. +// The controller implementing the window delegate methods called from +// |-performClose:| must take precautions to ensure that. +void BrowserWindowCocoa::Close() { + // If there is an overlay window, we contain a tab being dragged between + // windows. Don't hide the window as it makes the UI extra confused. We can + // still close the window, as that will happen when the drag completes. + if ([controller_ overlayWindow]) { + [controller_ deferPerformClose]; + } else { + // Make sure we hide the window immediately. Even though performClose: + // calls orderOut: eventually, it leaves the window on-screen long enough + // that we start to see tabs shutting down. http://crbug.com/23959 + // TODO(viettrungluu): This is kind of bad, since |-performClose:| calls + // |-windowShouldClose:| (on its delegate, which is probably the + // controller) which may return |NO| causing the window to not be closed, + // thereby leaving a hidden window. In fact, our window-closing procedure + // involves a (indirect) recursion on |-performClose:|, which is also bad. + [window() orderOut:controller_]; + [window() performClose:controller_]; + } +} + +void BrowserWindowCocoa::Activate() { + [controller_ activate]; +} + +void BrowserWindowCocoa::Deactivate() { + // TODO(jcivelli): http://crbug.com/51364 Implement me. + NOTIMPLEMENTED(); +} + +void BrowserWindowCocoa::FlashFrame() { + [NSApp requestUserAttention:NSInformationalRequest]; +} + +bool BrowserWindowCocoa::IsActive() const { + return [window() isKeyWindow]; +} + +gfx::NativeWindow BrowserWindowCocoa::GetNativeHandle() { + return window(); +} + +BrowserWindowTesting* BrowserWindowCocoa::GetBrowserWindowTesting() { + return NULL; +} + +StatusBubble* BrowserWindowCocoa::GetStatusBubble() { + return [controller_ statusBubble]; +} + +void BrowserWindowCocoa::SelectedTabToolbarSizeChanged(bool is_animating) { + // According to beng, this is an ugly method that comes from the days when the + // download shelf was a ChromeView attached to the TabContents, and as its + // size changed via animation it notified through TCD/etc to the browser view + // to relayout for each tick of the animation. We don't need anything of the + // sort on Mac. +} + +void BrowserWindowCocoa::UpdateTitleBar() { + NSString* newTitle = + base::SysUTF16ToNSString(browser_->GetWindowTitleForCurrentTab()); + + // Work around Cocoa bug: if a window changes title during the tracking of the + // Window menu it doesn't display well and the constant re-sorting of the list + // makes it difficult for the user to pick the desired window. Delay window + // title updates until the default run-loop mode. + + if (pending_window_title_.get()) + [[NSRunLoop currentRunLoop] + cancelPerformSelector:@selector(setTitle:) + target:window() + argument:pending_window_title_.get()]; + + pending_window_title_.reset([newTitle copy]); + [[NSRunLoop currentRunLoop] + performSelector:@selector(setTitle:) + target:window() + argument:newTitle + order:0 + modes:[NSArray arrayWithObject:NSDefaultRunLoopMode]]; +} + +void BrowserWindowCocoa::ShelfVisibilityChanged() { + // Mac doesn't yet support showing the bookmark bar at a different size on + // the new tab page. When it does, this method should attempt to relayout the + // bookmark bar/extension shelf as their preferred height may have changed. + // http://crbug.com/43346 +} + +void BrowserWindowCocoa::UpdateDevTools() { + [controller_ updateDevToolsForContents: + browser_->GetSelectedTabContents()]; +} + +void BrowserWindowCocoa::UpdateLoadingAnimations(bool should_animate) { + // Do nothing on Mac. +} + +void BrowserWindowCocoa::SetStarredState(bool is_starred) { + [controller_ setStarredState:is_starred ? YES : NO]; +} + +gfx::Rect BrowserWindowCocoa::GetRestoredBounds() const { + // Flip coordinates based on the primary screen. + NSScreen* screen = [[NSScreen screens] objectAtIndex:0]; + NSRect frame = [controller_ regularWindowFrame]; + gfx::Rect bounds(frame.origin.x, 0, frame.size.width, frame.size.height); + bounds.set_y([screen frame].size.height - frame.origin.y - frame.size.height); + return bounds; +} + +bool BrowserWindowCocoa::IsMaximized() const { + return [window() isZoomed]; +} + +void BrowserWindowCocoa::SetFullscreen(bool fullscreen) { + [controller_ setFullscreen:fullscreen]; +} + +bool BrowserWindowCocoa::IsFullscreen() const { + return !![controller_ isFullscreen]; +} + +bool BrowserWindowCocoa::IsFullscreenBubbleVisible() const { + return false; +} + +void BrowserWindowCocoa::ConfirmAddSearchProvider( + const TemplateURL* template_url, + Profile* profile) { + // The controller will release itself when the window closes. + EditSearchEngineCocoaController* editor = + [[EditSearchEngineCocoaController alloc] initWithProfile:profile + delegate:NULL + templateURL:template_url]; + [NSApp beginSheet:[editor window] + modalForWindow:window() + modalDelegate:controller_ + didEndSelector:@selector(sheetDidEnd:returnCode:context:) + contextInfo:NULL]; +} + +LocationBar* BrowserWindowCocoa::GetLocationBar() const { + return [controller_ locationBarBridge]; +} + +void BrowserWindowCocoa::SetFocusToLocationBar(bool select_all) { + [controller_ focusLocationBar:select_all ? YES : NO]; +} + +void BrowserWindowCocoa::UpdateReloadStopState(bool is_loading, bool force) { + [controller_ setIsLoading:is_loading force:force]; +} + +void BrowserWindowCocoa::UpdateToolbar(TabContentsWrapper* contents, + bool should_restore_state) { + [controller_ updateToolbarWithContents:contents->tab_contents() + shouldRestoreState:should_restore_state ? YES : NO]; +} + +void BrowserWindowCocoa::FocusToolbar() { + // Not needed on the Mac. +} + +void BrowserWindowCocoa::FocusAppMenu() { + // Chrome uses the standard Mac OS X menu bar, so this isn't needed. +} + +void BrowserWindowCocoa::RotatePaneFocus(bool forwards) { + // Not needed on the Mac. +} + +void BrowserWindowCocoa::FocusBookmarksToolbar() { + // Not needed on the Mac. +} + +void BrowserWindowCocoa::FocusChromeOSStatus() { + // Not needed on the Mac. +} + +bool BrowserWindowCocoa::IsBookmarkBarVisible() const { + return browser_->profile()->GetPrefs()->GetBoolean(prefs::kShowBookmarkBar); +} + +bool BrowserWindowCocoa::IsBookmarkBarAnimating() const { + return [controller_ isBookmarkBarAnimating]; +} + +bool BrowserWindowCocoa::IsToolbarVisible() const { + return browser_->SupportsWindowFeature(Browser::FEATURE_TOOLBAR) || + browser_->SupportsWindowFeature(Browser::FEATURE_LOCATIONBAR); +} + +// This is called from Browser, which in turn is called directly from +// a menu option. All we do here is set a preference. The act of +// setting the preference sends notifications to all windows who then +// know what to do. +void BrowserWindowCocoa::ToggleBookmarkBar() { + bookmark_utils::ToggleWhenVisible(browser_->profile()); +} + +void BrowserWindowCocoa::AddFindBar( + FindBarCocoaController* find_bar_cocoa_controller) { + return [controller_ addFindBar:find_bar_cocoa_controller]; +} + +views::Window* BrowserWindowCocoa::ShowAboutChromeDialog() { + NOTIMPLEMENTED(); + return NULL; +} + +void BrowserWindowCocoa::ShowUpdateChromeDialog() { + restart_browser::RequestRestart(nil); +} + +void BrowserWindowCocoa::ShowTaskManager() { + TaskManagerMac::Show(); +} + +void BrowserWindowCocoa::ShowBookmarkBubble(const GURL& url, + bool already_bookmarked) { + [controller_ showBookmarkBubbleForURL:url + alreadyBookmarked:(already_bookmarked ? YES : NO)]; +} + +bool BrowserWindowCocoa::IsDownloadShelfVisible() const { + return [controller_ isDownloadShelfVisible] != NO; +} + +DownloadShelf* BrowserWindowCocoa::GetDownloadShelf() { + DownloadShelfController* shelfController = [controller_ downloadShelf]; + return [shelfController bridge]; +} + +void BrowserWindowCocoa::ShowReportBugDialog() { + TabContents* current_tab = browser_->GetSelectedTabContents(); + if (current_tab && current_tab->controller().GetActiveEntry()) { + browser_->ShowBrokenPageTab(current_tab); + } +} + +void BrowserWindowCocoa::ShowClearBrowsingDataDialog() { + [ClearBrowsingDataController + showClearBrowsingDialogForProfile:browser_->profile()]; +} + +void BrowserWindowCocoa::ShowImportDialog() { + [ImportSettingsDialogController + showImportSettingsDialogForProfile:browser_->profile()]; +} + +void BrowserWindowCocoa::ShowSearchEnginesDialog() { + [KeywordEditorCocoaController showKeywordEditor:browser_->profile()]; +} + +void BrowserWindowCocoa::ShowPasswordManager() { + NOTIMPLEMENTED(); +} + +void BrowserWindowCocoa::ShowRepostFormWarningDialog( + TabContents* tab_contents) { + RepostFormWarningMac::Create(GetNativeHandle(), tab_contents); +} + +void BrowserWindowCocoa::ShowContentSettingsWindow( + ContentSettingsType settings_type, + Profile* profile) { + [ContentSettingsDialogController showContentSettingsForType:settings_type + profile:profile]; +} + +void BrowserWindowCocoa::ShowCollectedCookiesDialog(TabContents* tab_contents) { + // Deletes itself on close. + new CollectedCookiesMac(GetNativeHandle(), tab_contents); +} + +void BrowserWindowCocoa::ShowProfileErrorDialog(int message_id) { + scoped_nsobject<NSAlert> alert([[NSAlert alloc] init]); + [alert addButtonWithTitle:l10n_util::GetNSStringWithFixup(IDS_OK)]; + [alert setMessageText:l10n_util::GetNSStringWithFixup(IDS_PRODUCT_NAME)]; + [alert setInformativeText:l10n_util::GetNSStringWithFixup(message_id)]; + [alert setAlertStyle:NSWarningAlertStyle]; + [alert runModal]; +} + +void BrowserWindowCocoa::ShowThemeInstallBubble() { + ThemeInstallBubbleView::Show(window()); +} + +// We allow closing the window here since the real quit decision on Mac is made +// in [AppController quit:]. +void BrowserWindowCocoa::ConfirmBrowserCloseWithPendingDownloads() { + // Call InProgressDownloadResponse asynchronously to avoid a crash when the + // browser window is closed here (http://crbug.com/44454). + MessageLoop::current()->PostTask( + FROM_HERE, + confirm_close_factory_.NewRunnableMethod( + &Browser::InProgressDownloadResponse, + true)); +} + +void BrowserWindowCocoa::ShowHTMLDialog(HtmlDialogUIDelegate* delegate, + gfx::NativeWindow parent_window) { + [HtmlDialogWindowController showHtmlDialog:delegate + profile:browser_->profile()]; +} + +void BrowserWindowCocoa::UserChangedTheme() { + [controller_ userChangedTheme]; +} + +int BrowserWindowCocoa::GetExtraRenderViewHeight() const { + // Currently this is only used on linux. + return 0; +} + +void BrowserWindowCocoa::TabContentsFocused(TabContents* tab_contents) { + NOTIMPLEMENTED(); +} + +void BrowserWindowCocoa::ShowPageInfo(Profile* profile, + const GURL& url, + const NavigationEntry::SSLStatus& ssl, + bool show_history) { + browser::ShowPageInfoBubble(window(), profile, url, ssl, show_history); +} + +void BrowserWindowCocoa::ShowAppMenu() { + // No-op. Mac doesn't support showing the menus via alt keys. +} + +bool BrowserWindowCocoa::PreHandleKeyboardEvent( + const NativeWebKeyboardEvent& event, bool* is_keyboard_shortcut) { + if (event.skip_in_browser || event.type == NativeWebKeyboardEvent::Char) + return false; + + DCHECK(event.os_event != NULL); + int id = GetCommandId(event); + if (id == -1) + return false; + + if (browser_->IsReservedCommand(id)) + return HandleKeyboardEventInternal(event.os_event); + + DCHECK(is_keyboard_shortcut != NULL); + *is_keyboard_shortcut = true; + + return false; +} + +void BrowserWindowCocoa::HandleKeyboardEvent( + const NativeWebKeyboardEvent& event) { + if (event.skip_in_browser || event.type == NativeWebKeyboardEvent::Char) + return; + + DCHECK(event.os_event != NULL); + HandleKeyboardEventInternal(event.os_event); +} + +@interface MenuWalker : NSObject ++ (NSMenuItem*)itemForKeyEquivalent:(NSEvent*)key + menu:(NSMenu*)menu; +@end + +@implementation MenuWalker ++ (NSMenuItem*)itemForKeyEquivalent:(NSEvent*)key + menu:(NSMenu*)menu { + NSMenuItem* result = nil; + + for (NSMenuItem *item in [menu itemArray]) { + NSMenu* submenu = [item submenu]; + if (submenu) { + if (submenu != [NSApp servicesMenu]) + result = [self itemForKeyEquivalent:key + menu:submenu]; + } else if ([item cr_firesForKeyEvent:key]) { + result = item; + } + + if (result) + break; + } + + return result; +} +@end + +int BrowserWindowCocoa::GetCommandId(const NativeWebKeyboardEvent& event) { + if ([event.os_event type] != NSKeyDown) + return -1; + + // Look in menu. + NSMenuItem* item = [MenuWalker itemForKeyEquivalent:event.os_event + menu:[NSApp mainMenu]]; + + if (item && [item action] == @selector(commandDispatch:) && [item tag] > 0) + return [item tag]; + + // "Close window" doesn't use the |commandDispatch:| mechanism. Menu items + // that do not correspond to IDC_ constants need no special treatment however, + // as they can't be blacklisted in |Browser::IsReservedCommand()| anyhow. + if (item && [item action] == @selector(performClose:)) + return IDC_CLOSE_WINDOW; + + // "Exit" doesn't use the |commandDispatch:| mechanism either. + if (item && [item action] == @selector(terminate:)) + return IDC_EXIT; + + // Look in secondary keyboard shortcuts. + NSUInteger modifiers = [event.os_event modifierFlags]; + const bool cmdKey = (modifiers & NSCommandKeyMask) != 0; + const bool shiftKey = (modifiers & NSShiftKeyMask) != 0; + const bool cntrlKey = (modifiers & NSControlKeyMask) != 0; + const bool optKey = (modifiers & NSAlternateKeyMask) != 0; + const int keyCode = [event.os_event keyCode]; + const unichar keyChar = KeyCharacterForEvent(event.os_event); + + int cmdNum = CommandForWindowKeyboardShortcut( + cmdKey, shiftKey, cntrlKey, optKey, keyCode, keyChar); + if (cmdNum != -1) + return cmdNum; + + cmdNum = CommandForBrowserKeyboardShortcut( + cmdKey, shiftKey, cntrlKey, optKey, keyCode, keyChar); + if (cmdNum != -1) + return cmdNum; + + return -1; +} + +bool BrowserWindowCocoa::HandleKeyboardEventInternal(NSEvent* event) { + ChromeEventProcessingWindow* event_window = + static_cast<ChromeEventProcessingWindow*>(window()); + DCHECK([event_window isKindOfClass:[ChromeEventProcessingWindow class]]); + + // Do not fire shortcuts on key up. + if ([event type] == NSKeyDown) { + // Send the event to the menu before sending it to the browser/window + // shortcut handling, so that if a user configures cmd-left to mean + // "previous tab", it takes precedence over the built-in "history back" + // binding. Other than that, the |-redispatchKeyEvent:| call would take care + // of invoking the original menu item shortcut as well. + + if ([[NSApp mainMenu] performKeyEquivalent:event]) + return true; + + if ([event_window handleExtraBrowserKeyboardShortcut:event]) + return true; + + if ([event_window handleExtraWindowKeyboardShortcut:event]) + return true; + + if ([event_window handleDelayedWindowKeyboardShortcut:event]) + return true; + } + + return [event_window redispatchKeyEvent:event]; +} + +void BrowserWindowCocoa::ShowCreateWebAppShortcutsDialog( + TabContents* tab_contents) { + NOTIMPLEMENTED(); +} + +void BrowserWindowCocoa::ShowCreateChromeAppShortcutsDialog( + Profile* profile, const Extension* app) { + NOTIMPLEMENTED(); +} + +void BrowserWindowCocoa::Cut() { + [NSApp sendAction:@selector(cut:) to:nil from:nil]; +} + +void BrowserWindowCocoa::Copy() { + [NSApp sendAction:@selector(copy:) to:nil from:nil]; +} + +void BrowserWindowCocoa::Paste() { + [NSApp sendAction:@selector(paste:) to:nil from:nil]; +} + +void BrowserWindowCocoa::ToggleTabStripMode() { + [controller_ toggleTabStripDisplayMode]; +} + +void BrowserWindowCocoa::OpenTabpose() { + [controller_ openTabpose]; +} + +void BrowserWindowCocoa::PrepareForInstant() { + // TODO: implement fade as done on windows. +} + +void BrowserWindowCocoa::ShowInstant(TabContents* preview_contents) { + [controller_ showInstant:preview_contents]; +} + +void BrowserWindowCocoa::HideInstant() { + [controller_ hideInstant]; +} + +gfx::Rect BrowserWindowCocoa::GetInstantBounds() { + // Flip coordinates based on the primary screen. + NSScreen* screen = [[NSScreen screens] objectAtIndex:0]; + NSRect monitorFrame = [screen frame]; + NSRect frame = [controller_ instantFrame]; + gfx::Rect bounds(NSRectToCGRect(frame)); + bounds.set_y(NSHeight(monitorFrame) - bounds.y() - bounds.height()); + return bounds; +} + +void BrowserWindowCocoa::Observe(NotificationType type, + const NotificationSource& source, + const NotificationDetails& details) { + switch (type.value) { + // Only the key window gets a direct toggle from the menu. + // Other windows hear about it from the notification. + case NotificationType::BOOKMARK_BAR_VISIBILITY_PREF_CHANGED: + [controller_ updateBookmarkBarVisibilityWithAnimation:YES]; + break; + case NotificationType::SIDEBAR_CHANGED: + UpdateSidebarForContents( + Details<SidebarContainer>(details)->tab_contents()); + break; + default: + NOTREACHED(); // we don't ask for anything else! + break; + } +} + +void BrowserWindowCocoa::DestroyBrowser() { + [controller_ destroyBrowser]; + + // at this point the controller is dead (autoreleased), so + // make sure we don't try to reference it any more. +} + +NSWindow* BrowserWindowCocoa::window() const { + return [controller_ window]; +} + +void BrowserWindowCocoa::UpdateSidebarForContents(TabContents* tab_contents) { + if (tab_contents == browser_->GetSelectedTabContents()) { + [controller_ updateSidebarForContents:tab_contents]; + } +} diff --git a/chrome/browser/ui/cocoa/browser_window_cocoa_unittest.mm b/chrome/browser/ui/cocoa/browser_window_cocoa_unittest.mm new file mode 100644 index 0000000..c76d3ee --- /dev/null +++ b/chrome/browser/ui/cocoa/browser_window_cocoa_unittest.mm @@ -0,0 +1,120 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/scoped_nsobject.h" +#include "base/scoped_ptr.h" +#include "base/string_util.h" +#include "chrome/browser/bookmarks/bookmark_utils.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#include "chrome/browser/ui/cocoa/browser_window_cocoa.h" +#include "chrome/browser/ui/cocoa/browser_window_controller.h" +#include "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "chrome/common/notification_type.h" +#include "testing/gtest/include/gtest/gtest.h" + +// A BrowserWindowCocoa that goes PONG when +// BOOKMARK_BAR_VISIBILITY_PREF_CHANGED is sent. This is so we can be +// sure we are observing it. +class BrowserWindowCocoaPong : public BrowserWindowCocoa { + public: + BrowserWindowCocoaPong(Browser* browser, + BrowserWindowController* controller) : + BrowserWindowCocoa(browser, controller, [controller window]) { + pong_ = false; + } + virtual ~BrowserWindowCocoaPong() { } + + void Observe(NotificationType type, + const NotificationSource& source, + const NotificationDetails& details) { + if (type.value == NotificationType::BOOKMARK_BAR_VISIBILITY_PREF_CHANGED) + pong_ = true; + BrowserWindowCocoa::Observe(type, source, details); + } + + bool pong_; +}; + +// Main test class. +class BrowserWindowCocoaTest : public CocoaTest { + virtual void SetUp() { + CocoaTest::SetUp(); + Browser* browser = browser_helper_.browser(); + controller_ = [[BrowserWindowController alloc] initWithBrowser:browser + takeOwnership:NO]; + } + + virtual void TearDown() { + [controller_ close]; + CocoaTest::TearDown(); + } + + public: + BrowserTestHelper browser_helper_; + BrowserWindowController* controller_; +}; + + +TEST_F(BrowserWindowCocoaTest, TestNotification) { + BrowserWindowCocoaPong *bwc = + new BrowserWindowCocoaPong(browser_helper_.browser(), controller_); + + EXPECT_FALSE(bwc->pong_); + bookmark_utils::ToggleWhenVisible(browser_helper_.profile()); + // Confirm we are listening + EXPECT_TRUE(bwc->pong_); + delete bwc; + // If this does NOT crash it confirms we stopped listening in the destructor. + bookmark_utils::ToggleWhenVisible(browser_helper_.profile()); +} + + +TEST_F(BrowserWindowCocoaTest, TestBookmarkBarVisible) { + BrowserWindowCocoaPong *bwc = new BrowserWindowCocoaPong( + browser_helper_.browser(), + controller_); + scoped_ptr<BrowserWindowCocoaPong> scoped_bwc(bwc); + + bool before = bwc->IsBookmarkBarVisible(); + bookmark_utils::ToggleWhenVisible(browser_helper_.profile()); + EXPECT_NE(before, bwc->IsBookmarkBarVisible()); + + bookmark_utils::ToggleWhenVisible(browser_helper_.profile()); + EXPECT_EQ(before, bwc->IsBookmarkBarVisible()); +} + +@interface FakeController : NSWindowController { + BOOL fullscreen_; +} +@end + +@implementation FakeController +- (void)setFullscreen:(BOOL)fullscreen { + fullscreen_ = fullscreen; +} +- (BOOL)isFullscreen { + return fullscreen_; +} +@end + +TEST_F(BrowserWindowCocoaTest, TestFullscreen) { + // Wrap the FakeController in a scoped_nsobject instead of autoreleasing in + // windowWillClose: because we never actually open a window in this test (so + // windowWillClose: never gets called). + scoped_nsobject<FakeController> fake_controller( + [[FakeController alloc] init]); + BrowserWindowCocoaPong *bwc = new BrowserWindowCocoaPong( + browser_helper_.browser(), + (BrowserWindowController*)fake_controller.get()); + scoped_ptr<BrowserWindowCocoaPong> scoped_bwc(bwc); + + EXPECT_FALSE(bwc->IsFullscreen()); + bwc->SetFullscreen(true); + EXPECT_TRUE(bwc->IsFullscreen()); + bwc->SetFullscreen(false); + EXPECT_FALSE(bwc->IsFullscreen()); + [fake_controller close]; +} + +// TODO(???): test other methods of BrowserWindowCocoa diff --git a/chrome/browser/ui/cocoa/browser_window_controller.h b/chrome/browser/ui/cocoa/browser_window_controller.h new file mode 100644 index 0000000..4ff053e --- /dev/null +++ b/chrome/browser/ui/cocoa/browser_window_controller.h @@ -0,0 +1,397 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_BROWSER_WINDOW_CONTROLLER_H_ +#define CHROME_BROWSER_UI_COCOA_BROWSER_WINDOW_CONTROLLER_H_ +#pragma once + +// A class acting as the Objective-C controller for the Browser +// object. Handles interactions between Cocoa and the cross-platform +// code. Each window has a single toolbar and, by virtue of being a +// TabWindowController, a tab strip along the top. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#include "base/scoped_ptr.h" +#include "chrome/browser/sync/sync_ui_util.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_controller.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bubble_controller.h" +#import "chrome/browser/ui/cocoa/browser_command_executor.h" +#import "chrome/browser/ui/cocoa/tab_contents_controller.h" +#import "chrome/browser/ui/cocoa/tab_strip_controller.h" +#import "chrome/browser/ui/cocoa/tab_window_controller.h" +#import "chrome/browser/ui/cocoa/themed_window.h" +#import "chrome/browser/ui/cocoa/url_drop_target.h" +#import "chrome/browser/ui/cocoa/view_resizer.h" + + +class Browser; +class BrowserWindow; +class BrowserWindowCocoa; +class ConstrainedWindowMac; +@class DevToolsController; +@class DownloadShelfController; +@class FindBarCocoaController; +@class FullscreenController; +@class GTMWindowSheetController; +@class IncognitoImageView; +@class InfoBarContainerController; +class LocationBarViewMac; +@class PreviewableContentsController; +@class SidebarController; +class StatusBubbleMac; +class TabContents; +@class TabStripController; +@class TabStripView; +@class ToolbarController; + + +@interface BrowserWindowController : + TabWindowController<NSUserInterfaceValidations, + BookmarkBarControllerDelegate, + BrowserCommandExecutor, + ViewResizer, + TabContentsControllerDelegate, + TabStripControllerDelegate> { + @private + // The ordering of these members is important as it determines the order in + // which they are destroyed. |browser_| needs to be destroyed last as most of + // the other objects hold weak references to it or things it owns + // (tab/toolbar/bookmark models, profiles, etc). + scoped_ptr<Browser> browser_; + NSWindow* savedRegularWindow_; + scoped_ptr<BrowserWindowCocoa> windowShim_; + scoped_nsobject<ToolbarController> toolbarController_; + scoped_nsobject<TabStripController> tabStripController_; + scoped_nsobject<FindBarCocoaController> findBarCocoaController_; + scoped_nsobject<InfoBarContainerController> infoBarContainerController_; + scoped_nsobject<DownloadShelfController> downloadShelfController_; + scoped_nsobject<BookmarkBarController> bookmarkBarController_; + scoped_nsobject<DevToolsController> devToolsController_; + scoped_nsobject<SidebarController> sidebarController_; + scoped_nsobject<PreviewableContentsController> previewableContentsController_; + scoped_nsobject<FullscreenController> fullscreenController_; + + // Strong. StatusBubble is a special case of a strong reference that + // we don't wrap in a scoped_ptr because it is acting the same + // as an NSWindowController in that it wraps a window that must + // be shut down before our destructors are called. + StatusBubbleMac* statusBubble_; + + BookmarkBubbleController* bookmarkBubbleController_; // Weak. + BOOL initializing_; // YES while we are currently in initWithBrowser: + BOOL ownsBrowser_; // Only ever NO when testing + + // The total amount by which we've grown the window up or down (to display a + // bookmark bar and/or download shelf), respectively; reset to 0 when moved + // away from the bottom/top or resized (or zoomed). + CGFloat windowTopGrowth_; + CGFloat windowBottomGrowth_; + + // YES only if we're shrinking the window from an apparent zoomed state (which + // we'll only do if we grew it to the zoomed state); needed since we'll then + // restrict the amount of shrinking by the amounts specified above. Reset to + // NO on growth. + BOOL isShrinkingFromZoomed_; + + // The raw accumulated zoom value and the actual zoom increments made for an + // an in-progress pinch gesture. + CGFloat totalMagnifyGestureAmount_; + NSInteger currentZoomStepDelta_; + + // The view which shows the incognito badge (NULL if not an incognito window). + // Needed to access the view to move it to/from the fullscreen window. + scoped_nsobject<IncognitoImageView> incognitoBadge_; + + // Lazily created view which draws the background for the floating set of bars + // in fullscreen mode (for window types having a floating bar; it remains nil + // for those which don't). + scoped_nsobject<NSView> floatingBarBackingView_; + + // Tracks whether the floating bar is above or below the bookmark bar, in + // terms of z-order. + BOOL floatingBarAboveBookmarkBar_; + + // The proportion of the floating bar which is shown (in fullscreen mode). + CGFloat floatingBarShownFraction_; + + // Various UI elements/events may want to ensure that the floating bar is + // visible (in fullscreen mode), e.g., because of where the mouse is or where + // keyboard focus is. Whenever an object requires bar visibility, it has + // itself added to |barVisibilityLocks_|. When it no longer requires bar + // visibility, it has itself removed. + scoped_nsobject<NSMutableSet> barVisibilityLocks_; + + // Bar visibility locks and releases only result (when appropriate) in changes + // in visible state when the following is |YES|. + BOOL barVisibilityUpdatesEnabled_; +} + +// A convenience class method which gets the |BrowserWindowController| for a +// given window. This method returns nil if no window in the chain has a BWC. ++ (BrowserWindowController*)browserWindowControllerForWindow:(NSWindow*)window; + +// A convenience class method which gets the |BrowserWindowController| for a +// given view. This is the controller for the window containing |view|, if it +// is a BWC, or the first controller in the parent-window chain that is a +// BWC. This method returns nil if no window in the chain has a BWC. ++ (BrowserWindowController*)browserWindowControllerForView:(NSView*)view; + +// Load the browser window nib and do any Cocoa-specific initialization. +// Takes ownership of |browser|. +- (id)initWithBrowser:(Browser*)browser; + +// Call to make the browser go away from other places in the cross-platform +// code. +- (void)destroyBrowser; + +// Access the C++ bridge between the NSWindow and the rest of Chromium. +- (BrowserWindow*)browserWindow; + +// Return a weak pointer to the toolbar controller. +- (ToolbarController*)toolbarController; + +// Return a weak pointer to the tab strip controller. +- (TabStripController*)tabStripController; + +// Access the C++ bridge object representing the status bubble for the window. +- (StatusBubbleMac*)statusBubble; + +// Access the C++ bridge object representing the location bar. +- (LocationBarViewMac*)locationBarBridge; + +// Updates the toolbar (and transitively the location bar) with the states of +// the specified |tab|. If |shouldRestore| is true, we're switching +// (back?) to this tab and should restore any previous location bar state +// (such as user editing) as well. +- (void)updateToolbarWithContents:(TabContents*)tab + shouldRestoreState:(BOOL)shouldRestore; + +// Sets whether or not the current page in the frontmost tab is bookmarked. +- (void)setStarredState:(BOOL)isStarred; + +// Called to tell the selected tab to update its loading state. +// |force| is set if the update is due to changing tabs, as opposed to +// the page-load finishing. See comment in reload_button.h. +- (void)setIsLoading:(BOOL)isLoading force:(BOOL)force; + +// Brings this controller's window to the front. +- (void)activate; + +// Make the location bar the first responder, if possible. +- (void)focusLocationBar:(BOOL)selectAll; + +// Make the (currently-selected) tab contents the first responder, if possible. +- (void)focusTabContents; + +// Returns the frame of the regular (non-fullscreened) window (even if the +// window is currently in fullscreen mode). The frame is returned in Cocoa +// coordinates (origin in bottom-left). +- (NSRect)regularWindowFrame; + +- (BOOL)isBookmarkBarVisible; + +// Returns YES if the bookmark bar is currently animating. +- (BOOL)isBookmarkBarAnimating; + +// Called after bookmark bar visibility changes (due to pref change or change in +// tab/tab contents). +- (void)updateBookmarkBarVisibilityWithAnimation:(BOOL)animate; + +- (BOOL)isDownloadShelfVisible; + +// Lazily creates the download shelf in visible state if it doesn't exist yet. +- (DownloadShelfController*)downloadShelf; + +// Retains the given FindBarCocoaController and adds its view to this +// browser window. Must only be called once per +// BrowserWindowController. +- (void)addFindBar:(FindBarCocoaController*)findBarCocoaController; + +// The user changed the theme. +- (void)userChangedTheme; + +// Executes the command in the context of the current browser. +// |command| is an integer value containing one of the constants defined in the +// "chrome/app/chrome_command_ids.h" file. +- (void)executeCommand:(int)command; + +// Delegate method for the status bubble to query its base frame. +- (NSRect)statusBubbleBaseFrame; + +// Show the bookmark bubble (e.g. user just clicked on the STAR) +- (void)showBookmarkBubbleForURL:(const GURL&)url + alreadyBookmarked:(BOOL)alreadyBookmarked; + +// Returns the (lazily created) window sheet controller of this window. Used +// for the per-tab sheets. +- (GTMWindowSheetController*)sheetController; + +// Requests that |window| is opened as a per-tab sheet to the current tab. +- (void)attachConstrainedWindow:(ConstrainedWindowMac*)window; +// Closes the tab sheet |window| and potentially shows the next sheet in the +// tab's sheet queue. +- (void)removeConstrainedWindow:(ConstrainedWindowMac*)window; +// Returns NO if constrained windows cannot be attached to this window. +- (BOOL)canAttachConstrainedWindow; + +// Shows or hides the docked web inspector depending on |contents|'s state. +- (void)updateDevToolsForContents:(TabContents*)contents; + +// Displays the active sidebar linked to the |contents| or hides sidebar UI, +// if there's no such sidebar. +- (void)updateSidebarForContents:(TabContents*)contents; + +// Gets the current theme provider. +- (ThemeProvider*)themeProvider; + +// Gets the window style. +- (ThemedWindowStyle)themedWindowStyle; + +// Gets the pattern phase for the window. +- (NSPoint)themePatternPhase; + +// Return the point to which a bubble window's arrow should point. +- (NSPoint)bookmarkBubblePoint; + +// Call when the user changes the tab strip display mode, enabling or +// disabling vertical tabs for this browser. Re-flows the contents of the +// browser. +- (void)toggleTabStripDisplayMode; + +// Shows or hides the Instant preview contents. +- (void)showInstant:(TabContents*)previewContents; +- (void)hideInstant; + +// Returns the frame, in Cocoa (unflipped) screen coordinates, of the area where +// Instant results are. If Instant is not showing, returns the frame of where +// it would be. +- (NSRect)instantFrame; + +// Called when the Add Search Engine dialog is closed. +- (void)sheetDidEnd:(NSWindow*)sheet + returnCode:(NSInteger)code + context:(void*)context; + +@end // @interface BrowserWindowController + + +// Methods having to do with the window type (normal/popup/app, and whether the +// window has various features; fullscreen methods are separate). +@interface BrowserWindowController(WindowType) + +// Determines whether this controller's window supports a given feature (i.e., +// whether a given feature is or can be shown in the window). +// TODO(viettrungluu): |feature| is really should be |Browser::Feature|, but I +// don't want to include browser.h (and you can't forward declare enums). +- (BOOL)supportsWindowFeature:(int)feature; + +// Called to check whether or not this window has a normal title bar (YES if it +// does, NO otherwise). (E.g., normal browser windows do not, pop-ups do.) +- (BOOL)hasTitleBar; + +// Called to check whether or not this window has a toolbar (YES if it does, NO +// otherwise). (E.g., normal browser windows do, pop-ups do not.) +- (BOOL)hasToolbar; + +// Called to check whether or not this window has a location bar (YES if it +// does, NO otherwise). (E.g., normal browser windows do, pop-ups may or may +// not.) +- (BOOL)hasLocationBar; + +// Called to check whether or not this window can have bookmark bar (YES if it +// does, NO otherwise). (E.g., normal browser windows may, pop-ups may not.) +- (BOOL)supportsBookmarkBar; + +// Called to check if this controller's window is a normal window (e.g., not a +// pop-up window). Returns YES if it is, NO otherwise. +// Note: The |-has...| methods are usually preferred, so this method is largely +// deprecated. +- (BOOL)isNormalWindow; + +@end // @interface BrowserWindowController(WindowType) + + +// Methods having to do with fullscreen mode. +@interface BrowserWindowController(Fullscreen) + +// Enters (or exits) fullscreen mode. +- (void)setFullscreen:(BOOL)fullscreen; + +// Returns fullscreen state. +- (BOOL)isFullscreen; + +// Resizes the fullscreen window to fit the screen it's currently on. Called by +// the FullscreenController when there is a change in monitor placement or +// resolution. +- (void)resizeFullscreenWindow; + +// Gets or sets the fraction of the floating bar (fullscreen overlay) that is +// shown. 0 is completely hidden, 1 is fully shown. +- (CGFloat)floatingBarShownFraction; +- (void)setFloatingBarShownFraction:(CGFloat)fraction; + +// Query/lock/release the requirement that the tab strip/toolbar/attached +// bookmark bar bar cluster is visible (e.g., when one of its elements has +// focus). This is required for the floating bar in fullscreen mode, but should +// also be called when not in fullscreen mode; see the comments for +// |barVisibilityLocks_| for more details. Double locks/releases by the same +// owner are ignored. If |animate:| is YES, then an animation may be performed, +// possibly after a small delay if |delay:| is YES. If |animate:| is NO, +// |delay:| will be ignored. In the case of multiple calls, later calls have +// precedence with the rule that |animate:NO| has precedence over |animate:YES|, +// and |delay:NO| has precedence over |delay:YES|. +- (BOOL)isBarVisibilityLockedForOwner:(id)owner; +- (void)lockBarVisibilityForOwner:(id)owner + withAnimation:(BOOL)animate + delay:(BOOL)delay; +- (void)releaseBarVisibilityForOwner:(id)owner + withAnimation:(BOOL)animate + delay:(BOOL)delay; + +// Returns YES if any of the views in the floating bar currently has focus. +- (BOOL)floatingBarHasFocus; + +// Opens the tabpose window. +- (void)openTabpose; + +@end // @interface BrowserWindowController(Fullscreen) + + +// Methods which are either only for testing, or only public for testing. +@interface BrowserWindowController(TestingAPI) + +// Put the incognito badge on the browser and adjust the tab strip +// accordingly. +- (void)installIncognitoBadge; + +// Allows us to initWithBrowser withOUT taking ownership of the browser. +- (id)initWithBrowser:(Browser*)browser takeOwnership:(BOOL)ownIt; + +// Adjusts the window height by the given amount. If the window spans from the +// top of the current workspace to the bottom of the current workspace, the +// height is not adjusted. If growing the window by the requested amount would +// size the window to be taller than the current workspace, the window height is +// capped to be equal to the height of the current workspace. If the window is +// partially offscreen, its height is not adjusted at all. This function +// prefers to grow the window down, but will grow up if needed. Calls to this +// function should be followed by a call to |layoutSubviews|. +- (void)adjustWindowHeightBy:(CGFloat)deltaH; + +// Return an autoreleased NSWindow suitable for fullscreen use. +- (NSWindow*)createFullscreenWindow; + +// Resets any saved state about window growth (due to showing the bookmark bar +// or the download shelf), so that future shrinking will occur from the bottom. +- (void)resetWindowGrowthState; + +// Computes by how far in each direction, horizontal and vertical, the +// |source| rect doesn't fit into |target|. +- (NSSize)overflowFrom:(NSRect)source + to:(NSRect)target; +@end // @interface BrowserWindowController(TestingAPI) + + +#endif // CHROME_BROWSER_UI_COCOA_BROWSER_WINDOW_CONTROLLER_H_ diff --git a/chrome/browser/ui/cocoa/browser_window_controller.mm b/chrome/browser/ui/cocoa/browser_window_controller.mm new file mode 100644 index 0000000..82151f0 --- /dev/null +++ b/chrome/browser/ui/cocoa/browser_window_controller.mm @@ -0,0 +1,2059 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/browser_window_controller.h" + +#include <Carbon/Carbon.h> + +#include "app/l10n_util.h" +#include "app/l10n_util_mac.h" +#include "base/mac_util.h" +#include "app/mac/scoped_nsdisable_screen_updates.h" +#include "base/nsimage_cache_mac.h" +#import "base/scoped_nsobject.h" +#include "base/sys_string_conversions.h" +#include "chrome/app/chrome_command_ids.h" // IDC_* +#include "chrome/browser/bookmarks/bookmark_editor.h" +#include "chrome/browser/dock_info.h" +#include "chrome/browser/encoding_menu_controller.h" +#include "chrome/browser/google/google_util.h" +#include "chrome/browser/location_bar.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/renderer_host/render_widget_host_view.h" +#include "chrome/browser/sync/profile_sync_service.h" +#include "chrome/browser/sync/sync_ui_util_mac.h" +#include "chrome/browser/tab_contents/tab_contents.h" +#include "chrome/browser/tab_contents/tab_contents_view_mac.h" +#include "chrome/browser/tab_contents_wrapper.h" +#include "chrome/browser/tabs/tab_strip_model.h" +#include "chrome/browser/themes/browser_theme_provider.h" +#include "chrome/browser/ui/browser.h" +#include "chrome/browser/ui/browser_list.h" +#import "chrome/browser/ui/cocoa/background_gradient_view.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_controller.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_editor_controller.h" +#import "chrome/browser/ui/cocoa/browser_window_cocoa.h" +#import "chrome/browser/ui/cocoa/browser_window_controller_private.h" +#import "chrome/browser/ui/cocoa/dev_tools_controller.h" +#import "chrome/browser/ui/cocoa/download/download_shelf_controller.h" +#import "chrome/browser/ui/cocoa/event_utils.h" +#import "chrome/browser/ui/cocoa/fast_resize_view.h" +#import "chrome/browser/ui/cocoa/find_bar_bridge.h" +#import "chrome/browser/ui/cocoa/find_bar_cocoa_controller.h" +#import "chrome/browser/ui/cocoa/focus_tracker.h" +#import "chrome/browser/ui/cocoa/fullscreen_controller.h" +#import "chrome/browser/ui/cocoa/fullscreen_window.h" +#import "chrome/browser/ui/cocoa/infobar_container_controller.h" +#import "chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_editor.h" +#import "chrome/browser/ui/cocoa/previewable_contents_controller.h" +#import "chrome/browser/ui/cocoa/nswindow_additions.h" +#import "chrome/browser/ui/cocoa/sad_tab_controller.h" +#import "chrome/browser/ui/cocoa/sidebar_controller.h" +#import "chrome/browser/ui/cocoa/status_bubble_mac.h" +#import "chrome/browser/ui/cocoa/tab_contents_controller.h" +#import "chrome/browser/ui/cocoa/tab_strip_controller.h" +#import "chrome/browser/ui/cocoa/tab_strip_view.h" +#import "chrome/browser/ui/cocoa/tab_view.h" +#import "chrome/browser/ui/cocoa/tabpose_window.h" +#import "chrome/browser/ui/cocoa/toolbar_controller.h" +#include "chrome/browser/window_sizer.h" +#include "chrome/common/url_constants.h" +#include "grit/generated_resources.h" +#include "grit/locale_settings.h" + +// ORGANIZATION: This is a big file. It is (in principle) organized as follows +// (in order): +// 1. Interfaces. Very short, one-time-use classes may include an implementation +// immediately after their interface. +// 2. The general implementation section, ordered as follows: +// i. Public methods and overrides. +// ii. Overrides/implementations of undocumented methods. +// iii. Delegate methods for various protocols, formal and informal, to which +// |BrowserWindowController| conforms. +// 3. (temporary) Implementation sections for various categories. +// +// Private methods are defined and implemented separately in +// browser_window_controller_private.{h,mm}. +// +// Not all of the above guidelines are followed and more (re-)organization is +// needed. BUT PLEASE TRY TO KEEP THIS FILE ORGANIZED. I'd rather re-organize as +// little as possible, since doing so messes up the file's history. +// +// TODO(viettrungluu): [crbug.com/35543] on-going re-organization, splitting +// things into multiple files -- the plan is as follows: +// - in general, everything stays in browser_window_controller.h, but is split +// off into categories (see below) +// - core stuff stays in browser_window_controller.mm +// - ... overrides also stay (without going into a category, in particular) +// - private stuff which everyone needs goes into +// browser_window_controller_private.{h,mm}; if no one else needs them, they +// can go in individual files (see below) +// - area/task-specific stuff go in browser_window_controller_<area>.mm +// - ... in categories called "(<Area>)" or "(<PrivateArea>)" +// Plan of action: +// - first re-organize into categories +// - then split into files + +// Notes on self-inflicted (not user-inflicted) window resizing and moving: +// +// When the bookmark bar goes from hidden to shown (on a non-NTP) page, or when +// the download shelf goes from hidden to shown, we grow the window downwards in +// order to maintain a constant content area size. When either goes from shown +// to hidden, we consequently shrink the window from the bottom, also to keep +// the content area size constant. To keep things simple, if the window is not +// entirely on-screen, we don't grow/shrink the window. +// +// The complications come in when there isn't enough room (on screen) below the +// window to accomodate the growth. In this case, we grow the window first +// downwards, and then upwards. So, when it comes to shrinking, we do the +// opposite: shrink from the top by the amount by which we grew at the top, and +// then from the bottom -- unless the user moved/resized/zoomed the window, in +// which case we "reset state" and just shrink from the bottom. +// +// A further complication arises due to the way in which "zoom" ("maximize") +// works on Mac OS X. Basically, for our purposes, a window is "zoomed" whenever +// it occupies the full available vertical space. (Note that the green zoom +// button does not track zoom/unzoomed state per se, but basically relies on +// this heuristic.) We don't, in general, want to shrink the window if the +// window is zoomed (scenario: window is zoomed, download shelf opens -- which +// doesn't cause window growth, download shelf closes -- shouldn't cause the +// window to become unzoomed!). However, if we grew the window +// (upwards/downwards) to become zoomed in the first place, we *should* shrink +// the window by the amounts by which we grew (scenario: window occupies *most* +// of vertical space, download shelf opens causing growth so that window +// occupies all of vertical space -- i.e., window is effectively zoomed, +// download shelf closes -- should return the window to its previous state). +// +// A major complication is caused by the way grows/shrinks are handled and +// animated. Basically, the BWC doesn't see the global picture, but it sees +// grows and shrinks in small increments (as dictated by the animation). Thus +// window growth/shrinkage (at the top/bottom) have to be tracked incrementally. +// Allowing shrinking from the zoomed state also requires tracking: We check on +// any shrink whether we're both zoomed and have previously grown -- if so, we +// set a flag, and constrain any resize by the allowed amounts. On further +// shrinks, we check the flag (since the size/position of the window will no +// longer indicate that the window is shrinking from an apparent zoomed state) +// and if it's set we continue to constrain the resize. + + +@interface NSWindow(NSPrivateApis) +// Note: These functions are private, use -[NSObject respondsToSelector:] +// before calling them. + +- (void)setBottomCornerRounded:(BOOL)rounded; + +- (NSRect)_growBoxRect; + +@end + + +// IncognitoImageView subclasses NSImageView to allow mouse events to pass +// through it so you can drag the window by dragging on the spy guy +@interface IncognitoImageView : NSImageView +@end + +@implementation IncognitoImageView +- (BOOL)mouseDownCanMoveWindow { + return YES; +} +@end + + +@implementation BrowserWindowController + ++ (BrowserWindowController*)browserWindowControllerForWindow:(NSWindow*)window { + while (window) { + id controller = [window windowController]; + if ([controller isKindOfClass:[BrowserWindowController class]]) + return (BrowserWindowController*)controller; + window = [window parentWindow]; + } + return nil; +} + ++ (BrowserWindowController*)browserWindowControllerForView:(NSView*)view { + NSWindow* window = [view window]; + return [BrowserWindowController browserWindowControllerForWindow:window]; +} + +// Load the browser window nib and do any Cocoa-specific initialization. +// Takes ownership of |browser|. Note that the nib also sets this controller +// up as the window's delegate. +- (id)initWithBrowser:(Browser*)browser { + return [self initWithBrowser:browser takeOwnership:YES]; +} + +// Private(TestingAPI) init routine with testing options. +- (id)initWithBrowser:(Browser*)browser takeOwnership:(BOOL)ownIt { + // Use initWithWindowNibPath:: instead of initWithWindowNibName: so we + // can override it in a unit test. + NSString* nibpath = [mac_util::MainAppBundle() + pathForResource:@"BrowserWindow" + ofType:@"nib"]; + if ((self = [super initWithWindowNibPath:nibpath owner:self])) { + DCHECK(browser); + initializing_ = YES; + browser_.reset(browser); + ownsBrowser_ = ownIt; + NSWindow* window = [self window]; + windowShim_.reset(new BrowserWindowCocoa(browser, self, window)); + + // Create the bar visibility lock set; 10 is arbitrary, but should hopefully + // be big enough to hold all locks that'll ever be needed. + barVisibilityLocks_.reset([[NSMutableSet setWithCapacity:10] retain]); + + // Sets the window to not have rounded corners, which prevents + // the resize control from being inset slightly and looking ugly. + if ([window respondsToSelector:@selector(setBottomCornerRounded:)]) + [window setBottomCornerRounded:NO]; + + // Get the most appropriate size for the window, then enforce the + // minimum width and height. The window shim will handle flipping + // the coordinates for us so we can use it to save some code. + // Note that this may leave a significant portion of the window + // offscreen, but there will always be enough window onscreen to + // drag the whole window back into view. + NSSize minSize = [[self window] minSize]; + gfx::Rect desiredContentRect = browser_->GetSavedWindowBounds(); + gfx::Rect windowRect = desiredContentRect; + if (windowRect.width() < minSize.width) + windowRect.set_width(minSize.width); + if (windowRect.height() < minSize.height) + windowRect.set_height(minSize.height); + + // When we are given x/y coordinates of 0 on a created popup window, assume + // none were given by the window.open() command. + if (browser_->type() & Browser::TYPE_POPUP && + windowRect.x() == 0 && windowRect.y() == 0) { + gfx::Size size = windowRect.size(); + windowRect.set_origin(WindowSizer::GetDefaultPopupOrigin(size)); + } + + // Size and position the window. Note that it is not yet onscreen. Popup + // windows may get resized later on in this function, once the actual size + // of the toolbar/tabstrip is known. + windowShim_->SetBounds(windowRect); + + // Puts the incognito badge on the window frame, if necessary. + [self installIncognitoBadge]; + + // Create a sub-controller for the docked devTools and add its view to the + // hierarchy. This must happen before the sidebar controller is + // instantiated. + devToolsController_.reset( + [[DevToolsController alloc] initWithDelegate:self]); + [[devToolsController_ view] setFrame:[[self tabContentArea] bounds]]; + [[self tabContentArea] addSubview:[devToolsController_ view]]; + + // Create a sub-controller for the docked sidebar and add its view to the + // hierarchy. This must happen before the previewable contents controller + // is instantiated. + sidebarController_.reset([[SidebarController alloc] initWithDelegate:self]); + [[sidebarController_ view] setFrame:[[devToolsController_ view] bounds]]; + [[devToolsController_ view] addSubview:[sidebarController_ view]]; + + // Create the previewable contents controller. This provides the switch + // view that TabStripController needs. + previewableContentsController_.reset( + [[PreviewableContentsController alloc] init]); + [[previewableContentsController_ view] + setFrame:[[sidebarController_ view] bounds]]; + [[sidebarController_ view] + addSubview:[previewableContentsController_ view]]; + + // Create a controller for the tab strip, giving it the model object for + // this window's Browser and the tab strip view. The controller will handle + // registering for the appropriate tab notifications from the back-end and + // managing the creation of new tabs. + [self createTabStripController]; + + // Create the infobar container view, so we can pass it to the + // ToolbarController. + infoBarContainerController_.reset( + [[InfoBarContainerController alloc] initWithResizeDelegate:self]); + [[[self window] contentView] addSubview:[infoBarContainerController_ view]]; + + // Create a controller for the toolbar, giving it the toolbar model object + // and the toolbar view from the nib. The controller will handle + // registering for the appropriate command state changes from the back-end. + // Adds the toolbar to the content area. + toolbarController_.reset([[ToolbarController alloc] + initWithModel:browser->toolbar_model() + commands:browser->command_updater() + profile:browser->profile() + browser:browser + resizeDelegate:self]); + [toolbarController_ setHasToolbar:[self hasToolbar] + hasLocationBar:[self hasLocationBar]]; + [[[self window] contentView] addSubview:[toolbarController_ view]]; + + // Create a sub-controller for the bookmark bar. + bookmarkBarController_.reset( + [[BookmarkBarController alloc] + initWithBrowser:browser_.get() + initialWidth:NSWidth([[[self window] contentView] frame]) + delegate:self + resizeDelegate:self]); + + // Add bookmark bar to the view hierarchy, which also triggers the nib load. + // The bookmark bar is defined (in the nib) to be bottom-aligned to its + // parent view (among other things), so position and resize properties don't + // need to be set. + [[[self window] contentView] addSubview:[bookmarkBarController_ view] + positioned:NSWindowBelow + relativeTo:[toolbarController_ view]]; + [bookmarkBarController_ setBookmarkBarEnabled:[self supportsBookmarkBar]]; + + // We don't want to try and show the bar before it gets placed in its parent + // view, so this step shoudn't be inside the bookmark bar controller's + // |-awakeFromNib|. + [self updateBookmarkBarVisibilityWithAnimation:NO]; + + // Allow bar visibility to be changed. + [self enableBarVisibilityUpdates]; + + // Force a relayout of all the various bars. + [self layoutSubviews]; + + // For a popup window, |desiredContentRect| contains the desired height of + // the content, not of the whole window. Now that all the views are laid + // out, measure the current content area size and grow if needed. The + // window has not been placed onscreen yet, so this extra resize will not + // cause visible jank. + if (browser_->type() & Browser::TYPE_POPUP) { + CGFloat deltaH = desiredContentRect.height() - + NSHeight([[self tabContentArea] frame]); + // Do not shrink the window, as that may break minimum size invariants. + if (deltaH > 0) { + // Convert from tabContentArea coordinates to window coordinates. + NSSize convertedSize = + [[self tabContentArea] convertSize:NSMakeSize(0, deltaH) + toView:nil]; + NSRect frame = [[self window] frame]; + frame.size.height += convertedSize.height; + frame.origin.y -= convertedSize.height; + [[self window] setFrame:frame display:NO]; + } + } + + // Create the bridge for the status bubble. + statusBubble_ = new StatusBubbleMac([self window], self); + + // Register for application hide/unhide notifications. + [[NSNotificationCenter defaultCenter] + addObserver:self + selector:@selector(applicationDidHide:) + name:NSApplicationDidHideNotification + object:nil]; + [[NSNotificationCenter defaultCenter] + addObserver:self + selector:@selector(applicationDidUnhide:) + name:NSApplicationDidUnhideNotification + object:nil]; + + // This must be done after the view is added to the window since it relies + // on the window bounds to determine whether to show buttons or not. + if ([self hasToolbar]) // Do not create the buttons in popups. + [toolbarController_ createBrowserActionButtons]; + + // We are done initializing now. + initializing_ = NO; + } + return self; +} + +- (void)dealloc { + browser_->CloseAllTabs(); + [downloadShelfController_ exiting]; + + // Explicitly release |fullscreenController_| here, as it may call back to + // this BWC in |-dealloc|. We are required to call |-exitFullscreen| before + // releasing the controller. + [fullscreenController_ exitFullscreen]; + fullscreenController_.reset(); + + // Under certain testing configurations we may not actually own the browser. + if (ownsBrowser_ == NO) + ignore_result(browser_.release()); + + [[NSNotificationCenter defaultCenter] removeObserver:self]; + + [super dealloc]; +} + +- (BrowserWindow*)browserWindow { + return windowShim_.get(); +} + +- (ToolbarController*)toolbarController { + return toolbarController_.get(); +} + +- (TabStripController*)tabStripController { + return tabStripController_.get(); +} + +- (StatusBubbleMac*)statusBubble { + return statusBubble_; +} + +- (LocationBarViewMac*)locationBarBridge { + return [toolbarController_ locationBarBridge]; +} + +- (void)destroyBrowser { + [NSApp removeWindowsItem:[self window]]; + + // We need the window to go away now. + // We can't actually use |-autorelease| here because there's an embedded + // run loop in the |-performClose:| which contains its own autorelease pool. + // Instead call it after a zero-length delay, which gets us back to the main + // event loop. + [self performSelector:@selector(autorelease) + withObject:nil + afterDelay:0]; +} + +// Called when the window meets the criteria to be closed (ie, +// |-windowShouldClose:| returns YES). We must be careful to preserve the +// semantics of BrowserWindow::Close() and not call the Browser's dtor directly +// from this method. +- (void)windowWillClose:(NSNotification*)notification { + DCHECK_EQ([notification object], [self window]); + DCHECK(browser_->tabstrip_model()->empty()); + [savedRegularWindow_ close]; + // We delete statusBubble here because we need to kill off the dependency + // that its window has on our window before our window goes away. + delete statusBubble_; + statusBubble_ = NULL; + // We can't actually use |-autorelease| here because there's an embedded + // run loop in the |-performClose:| which contains its own autorelease pool. + // Instead call it after a zero-length delay, which gets us back to the main + // event loop. + [self performSelector:@selector(autorelease) + withObject:nil + afterDelay:0]; +} + +- (void)attachConstrainedWindow:(ConstrainedWindowMac*)window { + [tabStripController_ attachConstrainedWindow:window]; +} + +- (void)removeConstrainedWindow:(ConstrainedWindowMac*)window { + [tabStripController_ removeConstrainedWindow:window]; +} + +- (BOOL)canAttachConstrainedWindow { + return ![previewableContentsController_ isShowingPreview]; +} + +- (void)updateDevToolsForContents:(TabContents*)contents { + [devToolsController_ updateDevToolsForTabContents:contents]; + [devToolsController_ ensureContentsVisible]; +} + +- (void)updateSidebarForContents:(TabContents*)contents { + [sidebarController_ updateSidebarForTabContents:contents]; + [sidebarController_ ensureContentsVisible]; +} + +// Called when the user wants to close a window or from the shutdown process. +// The Browser object is in control of whether or not we're allowed to close. It +// may defer closing due to several states, such as onUnload handlers needing to +// be fired. If closing is deferred, the Browser will handle the processing +// required to get us to the closing state and (by watching for all the tabs +// going away) will again call to close the window when it's finally ready. +- (BOOL)windowShouldClose:(id)sender { + // Disable updates while closing all tabs to avoid flickering. + app::mac::ScopedNSDisableScreenUpdates disabler; + // Give beforeunload handlers the chance to cancel the close before we hide + // the window below. + if (!browser_->ShouldCloseWindow()) + return NO; + + // saveWindowPositionIfNeeded: only works if we are the last active + // window, but orderOut: ends up activating another window, so we + // have to save the window position before we call orderOut:. + [self saveWindowPositionIfNeeded]; + + if (!browser_->tabstrip_model()->empty()) { + // Tab strip isn't empty. Hide the frame (so it appears to have closed + // immediately) and close all the tabs, allowing the renderers to shut + // down. When the tab strip is empty we'll be called back again. + [[self window] orderOut:self]; + browser_->OnWindowClosing(); + return NO; + } + + // the tab strip is empty, it's ok to close the window + return YES; +} + +// Called right after our window became the main window. +- (void)windowDidBecomeMain:(NSNotification*)notification { + BrowserList::SetLastActive(browser_.get()); + [self saveWindowPositionIfNeeded]; + + // TODO(dmaclach): Instead of redrawing the whole window, views that care + // about the active window state should be registering for notifications. + [[self window] setViewsNeedDisplay:YES]; + + // TODO(viettrungluu): For some reason, the above doesn't suffice. + if ([self isFullscreen]) + [floatingBarBackingView_ setNeedsDisplay:YES]; // Okay even if nil. +} + +- (void)windowDidResignMain:(NSNotification*)notification { + // TODO(dmaclach): Instead of redrawing the whole window, views that care + // about the active window state should be registering for notifications. + [[self window] setViewsNeedDisplay:YES]; + + // TODO(viettrungluu): For some reason, the above doesn't suffice. + if ([self isFullscreen]) + [floatingBarBackingView_ setNeedsDisplay:YES]; // Okay even if nil. +} + +// Called when we are activated (when we gain focus). +- (void)windowDidBecomeKey:(NSNotification*)notification { + // We need to activate the controls (in the "WebView"). To do this, get the + // selected TabContents's RenderWidgetHostViewMac and tell it to activate. + if (TabContents* contents = browser_->GetSelectedTabContents()) { + if (RenderWidgetHostView* rwhv = contents->GetRenderWidgetHostView()) + rwhv->SetActive(true); + } +} + +// Called when we are deactivated (when we lose focus). +- (void)windowDidResignKey:(NSNotification*)notification { + // If our app is still active and we're still the key window, ignore this + // message, since it just means that a menu extra (on the "system status bar") + // was activated; we'll get another |-windowDidResignKey| if we ever really + // lose key window status. + if ([NSApp isActive] && ([NSApp keyWindow] == [self window])) + return; + + // We need to deactivate the controls (in the "WebView"). To do this, get the + // selected TabContents's RenderWidgetHostView and tell it to deactivate. + if (TabContents* contents = browser_->GetSelectedTabContents()) { + if (RenderWidgetHostView* rwhv = contents->GetRenderWidgetHostView()) + rwhv->SetActive(false); + } +} + +// Called when we have been minimized. +- (void)windowDidMiniaturize:(NSNotification *)notification { + // Let the selected RenderWidgetHostView know, so that it can tell plugins. + if (TabContents* contents = browser_->GetSelectedTabContents()) { + if (RenderWidgetHostView* rwhv = contents->GetRenderWidgetHostView()) + rwhv->SetWindowVisibility(false); + } +} + +// Called when we have been unminimized. +- (void)windowDidDeminiaturize:(NSNotification *)notification { + // Let the selected RenderWidgetHostView know, so that it can tell plugins. + if (TabContents* contents = browser_->GetSelectedTabContents()) { + if (RenderWidgetHostView* rwhv = contents->GetRenderWidgetHostView()) + rwhv->SetWindowVisibility(true); + } +} + +// Called when the application has been hidden. +- (void)applicationDidHide:(NSNotification *)notification { + // Let the selected RenderWidgetHostView know, so that it can tell plugins + // (unless we are minimized, in which case nothing has really changed). + if (![[self window] isMiniaturized]) { + if (TabContents* contents = browser_->GetSelectedTabContents()) { + if (RenderWidgetHostView* rwhv = contents->GetRenderWidgetHostView()) + rwhv->SetWindowVisibility(false); + } + } +} + +// Called when the application has been unhidden. +- (void)applicationDidUnhide:(NSNotification *)notification { + // Let the selected RenderWidgetHostView know, so that it can tell plugins + // (unless we are minimized, in which case nothing has really changed). + if (![[self window] isMiniaturized]) { + if (TabContents* contents = browser_->GetSelectedTabContents()) { + if (RenderWidgetHostView* rwhv = contents->GetRenderWidgetHostView()) + rwhv->SetWindowVisibility(true); + } + } +} + +// Called when the user clicks the zoom button (or selects it from the Window +// menu) to determine the "standard size" of the window, based on the content +// and other factors. If the current size/location differs nontrivally from the +// standard size, Cocoa resizes the window to the standard size, and saves the +// current size as the "user size". If the current size/location is the same (up +// to a fudge factor) as the standard size, Cocoa resizes the window to the +// saved user size. (It is possible for the two to coincide.) In this way, the +// zoom button acts as a toggle. We determine the standard size based on the +// content, but enforce a minimum width (calculated using the dimensions of the +// screen) to ensure websites with small intrinsic width (such as google.com) +// don't end up with a wee window. Moreover, we always declare the standard +// width to be at least as big as the current width, i.e., we never want zooming +// to the standard width to shrink the window. This is consistent with other +// browsers' behaviour, and is desirable in multi-tab situations. Note, however, +// that the "toggle" behaviour means that the window can still be "unzoomed" to +// the user size. +- (NSRect)windowWillUseStandardFrame:(NSWindow*)window + defaultFrame:(NSRect)frame { + // Forget that we grew the window up (if we in fact did). + [self resetWindowGrowthState]; + + // |frame| already fills the current screen. Never touch y and height since we + // always want to fill vertically. + + // If the shift key is down, maximize. Hopefully this should make the + // "switchers" happy. + if ([[NSApp currentEvent] modifierFlags] & NSShiftKeyMask) { + return frame; + } + + // To prevent strange results on portrait displays, the basic minimum zoomed + // width is the larger of: 60% of available width, 60% of available height + // (bounded by available width). + const CGFloat kProportion = 0.6; + CGFloat zoomedWidth = + std::max(kProportion * frame.size.width, + std::min(kProportion * frame.size.height, frame.size.width)); + + TabContents* contents = browser_->GetSelectedTabContents(); + if (contents) { + // If the intrinsic width is bigger, then make it the zoomed width. + const int kScrollbarWidth = 16; // TODO(viettrungluu): ugh. + TabContentsViewMac* tab_contents_view = + static_cast<TabContentsViewMac*>(contents->view()); + CGFloat intrinsicWidth = static_cast<CGFloat>( + tab_contents_view->preferred_width() + kScrollbarWidth); + zoomedWidth = std::max(zoomedWidth, + std::min(intrinsicWidth, frame.size.width)); + } + + // Never shrink from the current size on zoom (see above). + NSRect currentFrame = [[self window] frame]; + zoomedWidth = std::max(zoomedWidth, currentFrame.size.width); + + // |frame| determines our maximum extents. We need to set the origin of the + // frame -- and only move it left if necessary. + if (currentFrame.origin.x + zoomedWidth > frame.origin.x + frame.size.width) + frame.origin.x = frame.origin.x + frame.size.width - zoomedWidth; + else + frame.origin.x = currentFrame.origin.x; + + // Set the width. Don't touch y or height. + frame.size.width = zoomedWidth; + + return frame; +} + +- (void)activate { + [[self window] makeKeyAndOrderFront:self]; +} + +// Determine whether we should let a window zoom/unzoom to the given |newFrame|. +// We avoid letting unzoom move windows between screens, because it's really +// strange and unintuitive. +- (BOOL)windowShouldZoom:(NSWindow*)window toFrame:(NSRect)newFrame { + // Figure out which screen |newFrame| is on. + NSScreen* newScreen = nil; + CGFloat newScreenOverlapArea = 0.0; + for (NSScreen* screen in [NSScreen screens]) { + NSRect overlap = NSIntersectionRect(newFrame, [screen frame]); + CGFloat overlapArea = overlap.size.width * overlap.size.height; + if (overlapArea > newScreenOverlapArea) { + newScreen = screen; + newScreenOverlapArea = overlapArea; + } + } + // If we're somehow not on any screen, allow the zoom. + if (!newScreen) + return YES; + + // If the new screen is the current screen, we can return a definitive YES. + // Note: This check is not strictly necessary, but just short-circuits in the + // "no-brainer" case. To test the complicated logic below, comment this out! + NSScreen* curScreen = [window screen]; + if (newScreen == curScreen) + return YES; + + // Worry a little: What happens when a window is on two (or more) screens? + // E.g., what happens in a 50-50 scenario? Cocoa may reasonably elect to zoom + // to the other screen rather than staying on the officially current one. So + // we compare overlaps with the current window frame, and see if Cocoa's + // choice was reasonable (allowing a small rounding error). This should + // hopefully avoid us ever erroneously denying a zoom when a window is on + // multiple screens. + NSRect curFrame = [window frame]; + NSRect newScrIntersectCurFr = NSIntersectionRect([newScreen frame], curFrame); + NSRect curScrIntersectCurFr = NSIntersectionRect([curScreen frame], curFrame); + if (newScrIntersectCurFr.size.width*newScrIntersectCurFr.size.height >= + (curScrIntersectCurFr.size.width*curScrIntersectCurFr.size.height - 1.0)) + return YES; + + // If it wasn't reasonable, return NO. + return NO; +} + +// Adjusts the window height by the given amount. +- (void)adjustWindowHeightBy:(CGFloat)deltaH { + // By not adjusting the window height when initializing, we can ensure that + // the window opens with the same size that was saved on close. + if (initializing_ || [self isFullscreen] || deltaH == 0) + return; + + NSWindow* window = [self window]; + NSRect windowFrame = [window frame]; + NSRect workarea = [[window screen] visibleFrame]; + + // If the window is not already fully in the workarea, do not adjust its frame + // at all. + if (!NSContainsRect(workarea, windowFrame)) + return; + + // Record the position of the top/bottom of the window, so we can easily check + // whether we grew the window upwards/downwards. + CGFloat oldWindowMaxY = NSMaxY(windowFrame); + CGFloat oldWindowMinY = NSMinY(windowFrame); + + // We are "zoomed" if we occupy the full vertical space. + bool isZoomed = (windowFrame.origin.y == workarea.origin.y && + windowFrame.size.height == workarea.size.height); + + // If we're shrinking the window.... + if (deltaH < 0) { + bool didChange = false; + + // Don't reset if not currently zoomed since shrinking can take several + // steps! + if (isZoomed) + isShrinkingFromZoomed_ = YES; + + // If we previously grew at the top, shrink as much as allowed at the top + // first. + if (windowTopGrowth_ > 0) { + CGFloat shrinkAtTopBy = MIN(-deltaH, windowTopGrowth_); + windowFrame.size.height -= shrinkAtTopBy; // Shrink the window. + deltaH += shrinkAtTopBy; // Update the amount left to shrink. + windowTopGrowth_ -= shrinkAtTopBy; // Update the growth state. + didChange = true; + } + + // Similarly for the bottom (not an "else if" since we may have to + // simultaneously shrink at both the top and at the bottom). Note that + // |deltaH| may no longer be nonzero due to the above. + if (deltaH < 0 && windowBottomGrowth_ > 0) { + CGFloat shrinkAtBottomBy = MIN(-deltaH, windowBottomGrowth_); + windowFrame.origin.y += shrinkAtBottomBy; // Move the window up. + windowFrame.size.height -= shrinkAtBottomBy; // Shrink the window. + deltaH += shrinkAtBottomBy; // Update the amount left.... + windowBottomGrowth_ -= shrinkAtBottomBy; // Update the growth state. + didChange = true; + } + + // If we're shrinking from zoomed but we didn't change the top or bottom + // (since we've reached the limits imposed by |window...Growth_|), then stop + // here. Don't reset |isShrinkingFromZoomed_| since we might get called + // again for the same shrink. + if (isShrinkingFromZoomed_ && !didChange) + return; + } else { + isShrinkingFromZoomed_ = NO; + + // Don't bother with anything else. + if (isZoomed) + return; + } + + // Shrinking from zoomed is handled above (and is constrained by + // |window...Growth_|). + if (!isShrinkingFromZoomed_) { + // Resize the window down until it hits the bottom of the workarea, then if + // needed continue resizing upwards. Do not resize the window to be taller + // than the current workarea. + // Resize the window as requested, keeping the top left corner fixed. + windowFrame.origin.y -= deltaH; + windowFrame.size.height += deltaH; + + // If the bottom left corner is now outside the visible frame, move the + // window up to make it fit, but make sure not to move the top left corner + // out of the visible frame. + if (windowFrame.origin.y < workarea.origin.y) { + windowFrame.origin.y = workarea.origin.y; + windowFrame.size.height = + std::min(windowFrame.size.height, workarea.size.height); + } + + // Record (if applicable) how much we grew the window in either direction. + // (N.B.: These only record growth, not shrinkage.) + if (NSMaxY(windowFrame) > oldWindowMaxY) + windowTopGrowth_ += NSMaxY(windowFrame) - oldWindowMaxY; + if (NSMinY(windowFrame) < oldWindowMinY) + windowBottomGrowth_ += oldWindowMinY - NSMinY(windowFrame); + } + + // Disable subview resizing while resizing the window, or else we will get + // unwanted renderer resizes. The calling code must call layoutSubviews to + // make things right again. + NSView* contentView = [window contentView]; + [contentView setAutoresizesSubviews:NO]; + [window setFrame:windowFrame display:NO]; + [contentView setAutoresizesSubviews:YES]; +} + +// Main method to resize browser window subviews. This method should be called +// when resizing any child of the content view, rather than resizing the views +// directly. If the view is already the correct height, does not force a +// relayout. +- (void)resizeView:(NSView*)view newHeight:(CGFloat)height { + // We should only ever be called for one of the following four views. + // |downloadShelfController_| may be nil. If we are asked to size the bookmark + // bar directly, its superview must be this controller's content view. + DCHECK(view); + DCHECK(view == [toolbarController_ view] || + view == [infoBarContainerController_ view] || + view == [downloadShelfController_ view] || + view == [bookmarkBarController_ view]); + + // Change the height of the view and call |-layoutSubViews|. We set the height + // here without regard to where the view is on the screen or whether it needs + // to "grow up" or "grow down." The below call to |-layoutSubviews| will + // position each view correctly. + NSRect frame = [view frame]; + if (NSHeight(frame) == height) + return; + + // Grow or shrink the window by the amount of the height change. We adjust + // the window height only in two cases: + // 1) We are adjusting the height of the bookmark bar and it is currently + // animating either open or closed. + // 2) We are adjusting the height of the download shelf. + // + // We do not adjust the window height for bookmark bar changes on the NTP. + BOOL shouldAdjustBookmarkHeight = + [bookmarkBarController_ isAnimatingBetweenState:bookmarks::kHiddenState + andState:bookmarks::kShowingState]; + if ((shouldAdjustBookmarkHeight && view == [bookmarkBarController_ view]) || + view == [downloadShelfController_ view]) { + [[self window] disableScreenUpdatesUntilFlush]; + CGFloat deltaH = height - frame.size.height; + [self adjustWindowHeightBy:deltaH]; + } + + frame.size.height = height; + // TODO(rohitrao): Determine if calling setFrame: twice is bad. + [view setFrame:frame]; + [self layoutSubviews]; +} + +- (void)setAnimationInProgress:(BOOL)inProgress { + [[self tabContentArea] setFastResizeMode:inProgress]; +} + +// Update a toggle state for an NSMenuItem if modified. +// Take care to ensure |item| looks like a NSMenuItem. +// Called by validateUserInterfaceItem:. +- (void)updateToggleStateWithTag:(NSInteger)tag forItem:(id)item { + if (![item respondsToSelector:@selector(state)] || + ![item respondsToSelector:@selector(setState:)]) + return; + + // On Windows this logic happens in bookmark_bar_view.cc. On the + // Mac we're a lot more MVC happy so we've moved it into a + // controller. To be clear, this simply updates the menu item; it + // does not display the bookmark bar itself. + if (tag == IDC_SHOW_BOOKMARK_BAR) { + bool toggled = windowShim_->IsBookmarkBarVisible(); + NSInteger oldState = [item state]; + NSInteger newState = toggled ? NSOnState : NSOffState; + if (oldState != newState) + [item setState:newState]; + } + + // Update the checked/Unchecked state of items in the encoding menu. + // On Windows, this logic is part of |EncodingMenuModel| in + // browser/views/toolbar_view.h. + EncodingMenuController encoding_controller; + if (encoding_controller.DoesCommandBelongToEncodingMenu(tag)) { + DCHECK(browser_.get()); + Profile* profile = browser_->profile(); + DCHECK(profile); + TabContents* current_tab = browser_->GetSelectedTabContents(); + if (!current_tab) { + return; + } + const std::string encoding = current_tab->encoding(); + + bool toggled = encoding_controller.IsItemChecked(profile, encoding, tag); + NSInteger oldState = [item state]; + NSInteger newState = toggled ? NSOnState : NSOffState; + if (oldState != newState) + [item setState:newState]; + } +} + +- (BOOL)supportsFullscreen { + // TODO(avi, thakis): GTMWindowSheetController has no api to move + // tabsheets between windows. Until then, we have to prevent having to + // move a tabsheet between windows, e.g. no fullscreen toggling + NSArray* a = [[tabStripController_ sheetController] viewsWithAttachedSheets]; + return [a count] == 0; +} + +// Called to validate menu and toolbar items when this window is key. All the +// items we care about have been set with the |-commandDispatch:| or +// |-commandDispatchUsingKeyModifiers:| actions and a target of FirstResponder +// in IB. If it's not one of those, let it continue up the responder chain to be +// handled elsewhere. We pull out the tag as the cross-platform constant to +// differentiate and dispatch the various commands. +// NOTE: we might have to handle state for app-wide menu items, +// although we could cheat and directly ask the app controller if our +// command_updater doesn't support the command. This may or may not be an issue, +// too early to tell. +- (BOOL)validateUserInterfaceItem:(id<NSValidatedUserInterfaceItem>)item { + SEL action = [item action]; + BOOL enable = NO; + if (action == @selector(commandDispatch:) || + action == @selector(commandDispatchUsingKeyModifiers:)) { + NSInteger tag = [item tag]; + if (browser_->command_updater()->SupportsCommand(tag)) { + // Generate return value (enabled state) + enable = browser_->command_updater()->IsCommandEnabled(tag); + switch (tag) { + case IDC_CLOSE_TAB: + // Disable "close tab" if we're not the key window or if there's only + // one tab. + enable &= [self numberOfTabs] > 1 && [[self window] isKeyWindow]; + break; + case IDC_FULLSCREEN: { + enable &= [self supportsFullscreen]; + if ([static_cast<NSObject*>(item) isKindOfClass:[NSMenuItem class]]) { + NSString* menuTitle = l10n_util::GetNSString( + [self isFullscreen] ? IDS_EXIT_FULLSCREEN_MAC : + IDS_ENTER_FULLSCREEN_MAC); + [static_cast<NSMenuItem*>(item) setTitle:menuTitle]; + } + break; + } + case IDC_SYNC_BOOKMARKS: + enable &= browser_->profile()->IsSyncAccessible(); + sync_ui_util::UpdateSyncItem(item, enable, browser_->profile()); + break; + default: + // Special handling for the contents of the Text Encoding submenu. On + // Mac OS, instead of enabling/disabling the top-level menu item, we + // enable/disable the submenu's contents (per Apple's HIG). + EncodingMenuController encoding_controller; + if (encoding_controller.DoesCommandBelongToEncodingMenu(tag)) { + enable &= browser_->command_updater()->IsCommandEnabled( + IDC_ENCODING_MENU) ? YES : NO; + } + } + + // If the item is toggleable, find its toggle state and + // try to update it. This is a little awkward, but the alternative is + // to check after a commandDispatch, which seems worse. + [self updateToggleStateWithTag:tag forItem:item]; + } + } + return enable; +} + +// Called when the user picks a menu or toolbar item when this window is key. +// Calls through to the browser object to execute the command. This assumes that +// the command is supported and doesn't check, otherwise it would have been +// disabled in the UI in validateUserInterfaceItem:. +- (void)commandDispatch:(id)sender { + DCHECK(sender); + // Identify the actual BWC to which the command should be dispatched. It might + // belong to a background window, yet this controller gets it because it is + // the foreground window's controller and thus in the responder chain. Some + // senders don't have this problem (for example, menus only operate on the + // foreground window), so this is only an issue for senders that are part of + // windows. + BrowserWindowController* targetController = self; + if ([sender respondsToSelector:@selector(window)]) + targetController = [[sender window] windowController]; + DCHECK([targetController isKindOfClass:[BrowserWindowController class]]); + DCHECK(targetController->browser_.get()); + targetController->browser_->ExecuteCommand([sender tag]); +} + +// Same as |-commandDispatch:|, but executes commands using a disposition +// determined by the key flags. If the window is in the background and the +// command key is down, ignore the command key, but process any other modifiers. +- (void)commandDispatchUsingKeyModifiers:(id)sender { + DCHECK(sender); + // See comment above for why we do this. + BrowserWindowController* targetController = self; + if ([sender respondsToSelector:@selector(window)]) + targetController = [[sender window] windowController]; + DCHECK([targetController isKindOfClass:[BrowserWindowController class]]); + NSInteger command = [sender tag]; + NSUInteger modifierFlags = [[NSApp currentEvent] modifierFlags]; + if ((command == IDC_RELOAD) && + (modifierFlags & (NSShiftKeyMask | NSControlKeyMask))) { + command = IDC_RELOAD_IGNORING_CACHE; + // Mask off Shift and Control so they don't affect the disposition below. + modifierFlags &= ~(NSShiftKeyMask | NSControlKeyMask); + } + if (![[sender window] isMainWindow]) { + // Remove the command key from the flags, it means "keep the window in + // the background" in this case. + modifierFlags &= ~NSCommandKeyMask; + } + WindowOpenDisposition disposition = + event_utils::WindowOpenDispositionFromNSEventWithFlags( + [NSApp currentEvent], modifierFlags); + switch (command) { + case IDC_BACK: + case IDC_FORWARD: + case IDC_RELOAD: + case IDC_RELOAD_IGNORING_CACHE: + if (disposition == CURRENT_TAB) { + // Forcibly reset the location bar, since otherwise it won't discard any + // ongoing user edits, since it doesn't realize this is a user-initiated + // action. + [targetController locationBarBridge]->Revert(); + } + } + DCHECK(targetController->browser_.get()); + targetController->browser_->ExecuteCommandWithDisposition(command, + disposition); +} + +// Called when another part of the internal codebase needs to execute a +// command. +- (void)executeCommand:(int)command { + if (browser_->command_updater()->IsCommandEnabled(command)) + browser_->ExecuteCommand(command); +} + +// StatusBubble delegate method: tell the status bubble the frame it should +// position itself in. +- (NSRect)statusBubbleBaseFrame { + NSView* view = [previewableContentsController_ view]; + return [view convertRect:[view bounds] toView:nil]; +} + +- (GTMWindowSheetController*)sheetController { + return [tabStripController_ sheetController]; +} + +- (void)updateToolbarWithContents:(TabContents*)tab + shouldRestoreState:(BOOL)shouldRestore { + [toolbarController_ updateToolbarWithContents:tab + shouldRestoreState:shouldRestore]; +} + +- (void)setStarredState:(BOOL)isStarred { + [toolbarController_ setStarredState:isStarred]; +} + +// Accept tabs from a BrowserWindowController with the same Profile. +- (BOOL)canReceiveFrom:(TabWindowController*)source { + if (![source isKindOfClass:[BrowserWindowController class]]) { + return NO; + } + + BrowserWindowController* realSource = + static_cast<BrowserWindowController*>(source); + if (browser_->profile() != realSource->browser_->profile()) { + return NO; + } + + // Can't drag a tab from a normal browser to a pop-up + if (browser_->type() != realSource->browser_->type()) { + return NO; + } + + return YES; +} + +// Move a given tab view to the location of the current placeholder. If there is +// no placeholder, it will go at the end. |controller| is the window controller +// of a tab being dropped from a different window. It will be nil if the drag is +// within the window, otherwise the tab is removed from that window before being +// placed into this one. The implementation will call |-removePlaceholder| since +// the drag is now complete. This also calls |-layoutTabs| internally so +// clients do not need to call it again. +- (void)moveTabView:(NSView*)view + fromController:(TabWindowController*)dragController { + if (dragController) { + // Moving between windows. Figure out the TabContents to drop into our tab + // model from the source window's model. + BOOL isBrowser = + [dragController isKindOfClass:[BrowserWindowController class]]; + DCHECK(isBrowser); + if (!isBrowser) return; + BrowserWindowController* dragBWC = (BrowserWindowController*)dragController; + int index = [dragBWC->tabStripController_ modelIndexForTabView:view]; + TabContentsWrapper* contents = + dragBWC->browser_->GetTabContentsWrapperAt(index); + // The tab contents may have gone away if given a window.close() while it + // is being dragged. If so, bail, we've got nothing to drop. + if (!contents) + return; + + // Convert |view|'s frame (which starts in the source tab strip's coordinate + // system) to the coordinate system of the destination tab strip. This needs + // to be done before being detached so the window transforms can be + // performed. + NSRect destinationFrame = [view frame]; + NSPoint tabOrigin = destinationFrame.origin; + tabOrigin = [[dragController tabStripView] convertPoint:tabOrigin + toView:nil]; + tabOrigin = [[view window] convertBaseToScreen:tabOrigin]; + tabOrigin = [[self window] convertScreenToBase:tabOrigin]; + tabOrigin = [[self tabStripView] convertPoint:tabOrigin fromView:nil]; + destinationFrame.origin = tabOrigin; + + // Before the tab is detached from its originating tab strip, store the + // pinned state so that it can be maintained between the windows. + bool isPinned = dragBWC->browser_->tabstrip_model()->IsTabPinned(index); + + // Now that we have enough information about the tab, we can remove it from + // the dragging window. We need to do this *before* we add it to the new + // window as this will remove the TabContents' delegate. + [dragController detachTabView:view]; + + // Deposit it into our model at the appropriate location (it already knows + // where it should go from tracking the drag). Doing this sets the tab's + // delegate to be the Browser. + [tabStripController_ dropTabContents:contents + withFrame:destinationFrame + asPinnedTab:isPinned]; + } else { + // Moving within a window. + int index = [tabStripController_ modelIndexForTabView:view]; + [tabStripController_ moveTabFromIndex:index]; + } + + // Remove the placeholder since the drag is now complete. + [self removePlaceholder]; +} + +// Tells the tab strip to forget about this tab in preparation for it being +// put into a different tab strip, such as during a drop on another window. +- (void)detachTabView:(NSView*)view { + int index = [tabStripController_ modelIndexForTabView:view]; + browser_->tabstrip_model()->DetachTabContentsAt(index); +} + +- (NSView*)selectedTabView { + return [tabStripController_ selectedTabView]; +} + +- (void)setIsLoading:(BOOL)isLoading force:(BOOL)force { + [toolbarController_ setIsLoading:isLoading force:force]; +} + +// Make the location bar the first responder, if possible. +- (void)focusLocationBar:(BOOL)selectAll { + [toolbarController_ focusLocationBar:selectAll]; +} + +- (void)focusTabContents { + [[self window] makeFirstResponder:[tabStripController_ selectedTabView]]; +} + +- (void)layoutTabs { + [tabStripController_ layoutTabs]; +} + +- (TabWindowController*)detachTabToNewWindow:(TabView*)tabView { + // Disable screen updates so that this appears as a single visual change. + app::mac::ScopedNSDisableScreenUpdates disabler; + + // Fetch the tab contents for the tab being dragged. + int index = [tabStripController_ modelIndexForTabView:tabView]; + TabContentsWrapper* contents = browser_->GetTabContentsWrapperAt(index); + + // Set the window size. Need to do this before we detach the tab so it's + // still in the window. We have to flip the coordinates as that's what + // is expected by the Browser code. + NSWindow* sourceWindow = [tabView window]; + NSRect windowRect = [sourceWindow frame]; + NSScreen* screen = [sourceWindow screen]; + windowRect.origin.y = + [screen frame].size.height - windowRect.size.height - + windowRect.origin.y; + gfx::Rect browserRect(windowRect.origin.x, windowRect.origin.y, + windowRect.size.width, windowRect.size.height); + + NSRect sourceTabRect = [tabView frame]; + NSView* tabStrip = [self tabStripView]; + + // Pushes tabView's frame back inside the tabstrip. + NSSize tabOverflow = + [self overflowFrom:[tabStrip convertRectToBase:sourceTabRect] + to:[tabStrip frame]]; + NSRect tabRect = NSOffsetRect(sourceTabRect, + -tabOverflow.width, -tabOverflow.height); + + // Before detaching the tab, store the pinned state. + bool isPinned = browser_->tabstrip_model()->IsTabPinned(index); + + // Detach it from the source window, which just updates the model without + // deleting the tab contents. This needs to come before creating the new + // Browser because it clears the TabContents' delegate, which gets hooked + // up during creation of the new window. + browser_->tabstrip_model()->DetachTabContentsAt(index); + + // Create the new window with a single tab in its model, the one being + // dragged. + DockInfo dockInfo; + Browser* newBrowser = browser_->tabstrip_model()->delegate()-> + CreateNewStripWithContents(contents, browserRect, dockInfo, false); + + // Propagate the tab pinned state of the new tab (which is the only tab in + // this new window). + newBrowser->tabstrip_model()->SetTabPinned(0, isPinned); + + // Get the new controller by asking the new window for its delegate. + BrowserWindowController* controller = + reinterpret_cast<BrowserWindowController*>( + [newBrowser->window()->GetNativeHandle() delegate]); + DCHECK(controller && [controller isKindOfClass:[TabWindowController class]]); + + // Force the added tab to the right size (remove stretching.) + tabRect.size.height = [TabStripController defaultTabHeight]; + + // And make sure we use the correct frame in the new view. + [[controller tabStripController] setFrameOfSelectedTab:tabRect]; + return controller; +} + +- (void)insertPlaceholderForTab:(TabView*)tab + frame:(NSRect)frame + yStretchiness:(CGFloat)yStretchiness { + [super insertPlaceholderForTab:tab frame:frame yStretchiness:yStretchiness]; + [tabStripController_ insertPlaceholderForTab:tab + frame:frame + yStretchiness:yStretchiness]; +} + +- (void)removePlaceholder { + [super removePlaceholder]; + [tabStripController_ insertPlaceholderForTab:nil + frame:NSZeroRect + yStretchiness:0]; +} + +- (BOOL)tabDraggingAllowed { + return [tabStripController_ tabDraggingAllowed]; +} + +- (BOOL)tabTearingAllowed { + return ![self isFullscreen]; +} + +- (BOOL)windowMovementAllowed { + return ![self isFullscreen]; +} + +- (BOOL)isTabFullyVisible:(TabView*)tab { + return [tabStripController_ isTabFullyVisible:tab]; +} + +- (void)showNewTabButton:(BOOL)show { + [tabStripController_ showNewTabButton:show]; +} + +- (BOOL)isBookmarkBarVisible { + return [bookmarkBarController_ isVisible]; +} + +- (BOOL)isBookmarkBarAnimating { + return [bookmarkBarController_ isAnimationRunning]; +} + +- (void)updateBookmarkBarVisibilityWithAnimation:(BOOL)animate { + [bookmarkBarController_ + updateAndShowNormalBar:[self shouldShowBookmarkBar] + showDetachedBar:[self shouldShowDetachedBookmarkBar] + withAnimation:animate]; +} + +- (BOOL)isDownloadShelfVisible { + return downloadShelfController_ != nil && + [downloadShelfController_ isVisible]; +} + +- (DownloadShelfController*)downloadShelf { + if (!downloadShelfController_.get()) { + downloadShelfController_.reset([[DownloadShelfController alloc] + initWithBrowser:browser_.get() resizeDelegate:self]); + [[[self window] contentView] addSubview:[downloadShelfController_ view]]; + [downloadShelfController_ show:nil]; + } + return downloadShelfController_; +} + +- (void)addFindBar:(FindBarCocoaController*)findBarCocoaController { + // Shouldn't call addFindBar twice. + DCHECK(!findBarCocoaController_.get()); + + // Create a controller for the findbar. + findBarCocoaController_.reset([findBarCocoaController retain]); + NSView *contentView = [[self window] contentView]; + [contentView addSubview:[findBarCocoaController_ view] + positioned:NSWindowAbove + relativeTo:[toolbarController_ view]]; + + // Place the find bar immediately below the toolbar/attached bookmark bar. In + // fullscreen mode, it hangs off the top of the screen when the bar is hidden. + CGFloat maxY = [self placeBookmarkBarBelowInfoBar] ? + NSMinY([[toolbarController_ view] frame]) : + NSMinY([[bookmarkBarController_ view] frame]); + CGFloat maxWidth = NSWidth([contentView frame]); + [findBarCocoaController_ positionFindBarViewAtMaxY:maxY maxWidth:maxWidth]; +} + +- (NSWindow*)createFullscreenWindow { + return [[[FullscreenWindow alloc] initForScreen:[[self window] screen]] + autorelease]; +} + +- (NSInteger)numberOfTabs { + // count() includes pinned tabs. + return browser_->tabstrip_model()->count(); +} + +- (BOOL)hasLiveTabs { + return !browser_->tabstrip_model()->empty(); +} + +- (NSString*)selectedTabTitle { + TabContents* contents = browser_->GetSelectedTabContents(); + return base::SysUTF16ToNSString(contents->GetTitle()); +} + +- (NSRect)regularWindowFrame { + return [self isFullscreen] ? [savedRegularWindow_ frame] : + [[self window] frame]; +} + +// (Override of |TabWindowController| method.) +- (BOOL)hasTabStrip { + return [self supportsWindowFeature:Browser::FEATURE_TABSTRIP]; +} + +// TabContentsControllerDelegate protocol. +- (void)tabContentsViewFrameWillChange:(TabContentsController*)source + frameRect:(NSRect)frameRect { + TabContents* contents = [source tabContents]; + RenderWidgetHostView* render_widget_host_view = contents ? + contents->GetRenderWidgetHostView() : NULL; + if (!render_widget_host_view) + return; + + gfx::Rect reserved_rect; + + NSWindow* window = [self window]; + if ([window respondsToSelector:@selector(_growBoxRect)]) { + NSView* view = [source view]; + if (view && [view superview]) { + NSRect windowGrowBoxRect = [window _growBoxRect]; + NSRect viewRect = [[view superview] convertRect:frameRect toView:nil]; + NSRect growBoxRect = NSIntersectionRect(windowGrowBoxRect, viewRect); + if (!NSIsEmptyRect(growBoxRect)) { + // Before we return a rect, we need to convert it from window + // coordinates to content area coordinates and flip the coordinate + // system. + // Superview is used here because, first, it's a frame rect, so it is + // specified in the parent's coordinates and, second, view is not + // positioned yet. + growBoxRect = [[view superview] convertRect:growBoxRect fromView:nil]; + growBoxRect.origin.y = + NSHeight(frameRect) - NSHeight(growBoxRect); + growBoxRect = + NSOffsetRect(growBoxRect, -frameRect.origin.x, -frameRect.origin.y); + + reserved_rect = + gfx::Rect(growBoxRect.origin.x, growBoxRect.origin.y, + growBoxRect.size.width, growBoxRect.size.height); + } + } + } + + render_widget_host_view->set_reserved_contents_rect(reserved_rect); +} + +// TabStripControllerDelegate protocol. +- (void)onSelectTabWithContents:(TabContents*)contents { + // Update various elements that are interested in knowing the current + // TabContents. + + // Update all the UI bits. + windowShim_->UpdateTitleBar(); + + [sidebarController_ updateSidebarForTabContents:contents]; + [devToolsController_ updateDevToolsForTabContents:contents]; + + // Update the bookmark bar. + // Must do it after sidebar and devtools update, otherwise bookmark bar might + // call resizeView -> layoutSubviews and cause unnecessary relayout. + // TODO(viettrungluu): perhaps update to not terminate running animations (if + // applicable)? + [self updateBookmarkBarVisibilityWithAnimation:NO]; + + [infoBarContainerController_ changeTabContents:contents]; + + // Update devTools and sidebar contents after size for all views is set. + [sidebarController_ ensureContentsVisible]; + [devToolsController_ ensureContentsVisible]; +} + +- (void)onReplaceTabWithContents:(TabContents*)contents { + // This is only called when instant results are committed. Simply remove the + // preview view; the tab strip controller will reinstall the view as the + // active view. + [previewableContentsController_ hidePreview]; + [self updateBookmarkBarVisibilityWithAnimation:NO]; +} + +- (void)onSelectedTabChange:(TabStripModelObserver::TabChangeType)change { + // Update titles if this is the currently selected tab and if it isn't just + // the loading state which changed. + if (change != TabStripModelObserver::LOADING_ONLY) + windowShim_->UpdateTitleBar(); + + // Update the bookmark bar if this is the currently selected tab and if it + // isn't just the title which changed. This for transitions between the NTP + // (showing its floating bookmark bar) and normal web pages (showing no + // bookmark bar). + // TODO(viettrungluu): perhaps update to not terminate running animations? + if (change != TabStripModelObserver::TITLE_NOT_LOADING) + [self updateBookmarkBarVisibilityWithAnimation:NO]; +} + +- (void)onTabDetachedWithContents:(TabContents*)contents { + [infoBarContainerController_ tabDetachedWithContents:contents]; +} + +- (void)userChangedTheme { + // TODO(dmaclach): Instead of redrawing the whole window, views that care + // about the active window state should be registering for notifications. + [[self window] setViewsNeedDisplay:YES]; +} + +- (ThemeProvider*)themeProvider { + return browser_->profile()->GetThemeProvider(); +} + +- (ThemedWindowStyle)themedWindowStyle { + ThemedWindowStyle style = 0; + if (browser_->profile()->IsOffTheRecord()) + style |= THEMED_INCOGNITO; + + Browser::Type type = browser_->type(); + if (type == Browser::TYPE_POPUP) + style |= THEMED_POPUP; + else if (type == Browser::TYPE_DEVTOOLS) + style |= THEMED_DEVTOOLS; + + return style; +} + +- (NSPoint)themePatternPhase { + // Our patterns want to be drawn from the upper left hand corner of the view. + // Cocoa wants to do it from the lower left of the window. + // + // Rephase our pattern to fit this view. Some other views (Tabs, Toolbar etc.) + // will phase their patterns relative to this so all the views look right. + // + // To line up the background pattern with the pattern in the browser window + // the background pattern for the tabs needs to be moved left by 5 pixels. + const CGFloat kPatternHorizontalOffset = -5; + NSView* tabStripView = [self tabStripView]; + NSRect tabStripViewWindowBounds = [tabStripView bounds]; + NSView* windowChromeView = [[[self window] contentView] superview]; + tabStripViewWindowBounds = + [tabStripView convertRect:tabStripViewWindowBounds + toView:windowChromeView]; + NSPoint phase = NSMakePoint(NSMinX(tabStripViewWindowBounds) + + kPatternHorizontalOffset, + NSMinY(tabStripViewWindowBounds) + + [TabStripController defaultTabHeight]); + return phase; +} + +- (NSPoint)bookmarkBubblePoint { + return [toolbarController_ bookmarkBubblePoint]; +} + +// Show the bookmark bubble (e.g. user just clicked on the STAR). +- (void)showBookmarkBubbleForURL:(const GURL&)url + alreadyBookmarked:(BOOL)alreadyMarked { + if (!bookmarkBubbleController_) { + BookmarkModel* model = browser_->profile()->GetBookmarkModel(); + const BookmarkNode* node = model->GetMostRecentlyAddedNodeForURL(url); + bookmarkBubbleController_ = + [[BookmarkBubbleController alloc] initWithParentWindow:[self window] + model:model + node:node + alreadyBookmarked:alreadyMarked]; + [bookmarkBubbleController_ showWindow:self]; + NSNotificationCenter* center = [NSNotificationCenter defaultCenter]; + [center addObserver:self + selector:@selector(bubbleWindowWillClose:) + name:NSWindowWillCloseNotification + object:[bookmarkBubbleController_ window]]; + } +} + +// Nil out the weak bookmark bubble controller reference. +- (void)bubbleWindowWillClose:(NSNotification*)notification { + DCHECK([notification object] == [bookmarkBubbleController_ window]); + NSNotificationCenter* center = [NSNotificationCenter defaultCenter]; + [center removeObserver:self + name:NSWindowWillCloseNotification + object:[bookmarkBubbleController_ window]]; + bookmarkBubbleController_ = nil; +} + +// Handle the editBookmarkNode: action sent from bookmark bubble controllers. +- (void)editBookmarkNode:(id)sender { + BOOL responds = [sender respondsToSelector:@selector(node)]; + DCHECK(responds); + if (responds) { + const BookmarkNode* node = [sender node]; + if (node) { + // A BookmarkEditorController is a sheet that owns itself, and + // deallocates itself when closed. + [[[BookmarkEditorController alloc] + initWithParentWindow:[self window] + profile:browser_->profile() + parent:node->GetParent() + node:node + configuration:BookmarkEditor::SHOW_TREE] + runAsModalSheet]; + } + } +} + +// If the browser is in incognito mode, install the image view to decorate +// the window at the upper right. Use the same base y coordinate as the +// tab strip. +- (void)installIncognitoBadge { + // Only install if this browser window is OTR and has a tab strip. + if (!browser_->profile()->IsOffTheRecord() || ![self hasTabStrip]) + return; + + // Install the image into the badge view and size the view appropriately. + // Hide it for now; positioning and showing will be done by the layout code. + NSImage* image = nsimage_cache::ImageNamed(@"otr_icon.pdf"); + incognitoBadge_.reset([[IncognitoImageView alloc] init]); + [incognitoBadge_ setImage:image]; + [incognitoBadge_ setFrameSize:[image size]]; + [incognitoBadge_ setAutoresizingMask:NSViewMinXMargin | NSViewMinYMargin]; + [incognitoBadge_ setHidden:YES]; + + // Give it a shadow. + scoped_nsobject<NSShadow> shadow([[NSShadow alloc] init]); + [shadow.get() setShadowColor:[NSColor colorWithCalibratedWhite:0.0 + alpha:0.5]]; + [shadow.get() setShadowOffset:NSMakeSize(0, -1)]; + [shadow setShadowBlurRadius:2.0]; + [incognitoBadge_ setShadow:shadow]; + + // Install the view. + [[[[self window] contentView] superview] addSubview:incognitoBadge_]; +} + +// Documented in 10.6+, but present starting in 10.5. Called when we get a +// three-finger swipe. +- (void)swipeWithEvent:(NSEvent*)event { + // Map forwards and backwards to history; left is positive, right is negative. + unsigned int command = 0; + if ([event deltaX] > 0.5) { + command = IDC_BACK; + } else if ([event deltaX] < -0.5) { + command = IDC_FORWARD; + } else if ([event deltaY] > 0.5) { + // TODO(pinkerton): figure out page-up, http://crbug.com/16305 + } else if ([event deltaY] < -0.5) { + // TODO(pinkerton): figure out page-down, http://crbug.com/16305 + browser_->ExecuteCommand(IDC_TABPOSE); + } + + // Ensure the command is valid first (ExecuteCommand() won't do that) and + // then make it so. + if (browser_->command_updater()->IsCommandEnabled(command)) + browser_->ExecuteCommandWithDisposition(command, + event_utils::WindowOpenDispositionFromNSEvent(event)); +} + +// Documented in 10.6+, but present starting in 10.5. Called repeatedly during +// a pinch gesture, with incremental change values. +- (void)magnifyWithEvent:(NSEvent*)event { + // The deltaZ difference necessary to trigger a zoom action. Derived from + // experimentation to find a value that feels reasonable. + const float kZoomStepValue = 150; + + // Find the (absolute) thresholds on either side of the current zoom factor, + // then convert those to actual numbers to trigger a zoom in or out. + // This logic deliberately makes the range around the starting zoom value for + // the gesture twice as large as the other ranges (i.e., the notches are at + // ..., -3*step, -2*step, -step, step, 2*step, 3*step, ... but not at 0) + // so that it's easier to get back to your starting point than it is to + // overshoot. + float nextStep = (abs(currentZoomStepDelta_) + 1) * kZoomStepValue; + float backStep = abs(currentZoomStepDelta_) * kZoomStepValue; + float zoomInThreshold = (currentZoomStepDelta_ >= 0) ? nextStep : -backStep; + float zoomOutThreshold = (currentZoomStepDelta_ <= 0) ? -nextStep : backStep; + + unsigned int command = 0; + totalMagnifyGestureAmount_ += [event deltaZ]; + if (totalMagnifyGestureAmount_ > zoomInThreshold) { + command = IDC_ZOOM_PLUS; + } else if (totalMagnifyGestureAmount_ < zoomOutThreshold) { + command = IDC_ZOOM_MINUS; + } + + if (command && browser_->command_updater()->IsCommandEnabled(command)) { + currentZoomStepDelta_ += (command == IDC_ZOOM_PLUS) ? 1 : -1; + browser_->ExecuteCommandWithDisposition(command, + event_utils::WindowOpenDispositionFromNSEvent(event)); + } +} + +// Documented in 10.6+, but present starting in 10.5. Called at the beginning +// of a gesture. +- (void)beginGestureWithEvent:(NSEvent*)event { + totalMagnifyGestureAmount_ = 0; + currentZoomStepDelta_ = 0; +} + +// Delegate method called when window is resized. +- (void)windowDidResize:(NSNotification*)notification { + // Resize (and possibly move) the status bubble. Note that we may get called + // when the status bubble does not exist. + if (statusBubble_) { + statusBubble_->UpdateSizeAndPosition(); + } + + // Let the selected RenderWidgetHostView know, so that it can tell plugins. + if (TabContents* contents = browser_->GetSelectedTabContents()) { + if (RenderWidgetHostView* rwhv = contents->GetRenderWidgetHostView()) + rwhv->WindowFrameChanged(); + } +} + +// Handle the openLearnMoreAboutCrashLink: action from SadTabController when +// "Learn more" link in "Aw snap" page (i.e. crash page or sad tab) is +// clicked. Decoupling the action from its target makes unitestting possible. +- (void)openLearnMoreAboutCrashLink:(id)sender { + if ([sender isKindOfClass:[SadTabController class]]) { + SadTabController* sad_tab = static_cast<SadTabController*>(sender); + TabContents* tab_contents = [sad_tab tabContents]; + if (tab_contents) { + GURL helpUrl = + google_util::AppendGoogleLocaleParam(GURL(chrome::kCrashReasonURL)); + tab_contents->OpenURL(helpUrl, GURL(), CURRENT_TAB, PageTransition::LINK); + } + } +} + +// Delegate method called when window did move. (See below for why we don't use +// |-windowWillMove:|, which is called less frequently than |-windowDidMove| +// instead.) +- (void)windowDidMove:(NSNotification*)notification { + NSWindow* window = [self window]; + NSRect windowFrame = [window frame]; + NSRect workarea = [[window screen] visibleFrame]; + + // We reset the window growth state whenever the window is moved out of the + // work area or away (up or down) from the bottom or top of the work area. + // Unfortunately, Cocoa sends |-windowWillMove:| too frequently (including + // when clicking on the title bar to activate), and of course + // |-windowWillMove| is called too early for us to apply our heuristic. (The + // heuristic we use for detecting window movement is that if |windowTopGrowth_ + // > 0|, then we should be at the bottom of the work area -- if we're not, + // we've moved. Similarly for the other side.) + if (!NSContainsRect(workarea, windowFrame) || + (windowTopGrowth_ > 0 && NSMinY(windowFrame) != NSMinY(workarea)) || + (windowBottomGrowth_ > 0 && NSMaxY(windowFrame) != NSMaxY(workarea))) + [self resetWindowGrowthState]; + + // Let the selected RenderWidgetHostView know, so that it can tell plugins. + if (TabContents* contents = browser_->GetSelectedTabContents()) { + if (RenderWidgetHostView* rwhv = contents->GetRenderWidgetHostView()) + rwhv->WindowFrameChanged(); + } +} + +// Delegate method called when window will be resized; not called for +// |-setFrame:display:|. +- (NSSize)windowWillResize:(NSWindow*)sender toSize:(NSSize)frameSize { + [self resetWindowGrowthState]; + return frameSize; +} + +// Delegate method: see |NSWindowDelegate| protocol. +- (id)windowWillReturnFieldEditor:(NSWindow*)sender toObject:(id)obj { + // Ask the toolbar controller if it wants to return a custom field editor + // for the specific object. + return [toolbarController_ customFieldEditorForObject:obj]; +} + +// (Needed for |BookmarkBarControllerDelegate| protocol.) +- (void)bookmarkBar:(BookmarkBarController*)controller + didChangeFromState:(bookmarks::VisualState)oldState + toState:(bookmarks::VisualState)newState { + [toolbarController_ + setDividerOpacity:[bookmarkBarController_ toolbarDividerOpacity]]; + [self adjustToolbarAndBookmarkBarForCompression: + [controller getDesiredToolbarHeightCompression]]; +} + +// (Needed for |BookmarkBarControllerDelegate| protocol.) +- (void)bookmarkBar:(BookmarkBarController*)controller +willAnimateFromState:(bookmarks::VisualState)oldState + toState:(bookmarks::VisualState)newState { + [toolbarController_ + setDividerOpacity:[bookmarkBarController_ toolbarDividerOpacity]]; + [self adjustToolbarAndBookmarkBarForCompression: + [controller getDesiredToolbarHeightCompression]]; +} + +// (Private/TestingAPI) +- (void)resetWindowGrowthState { + windowTopGrowth_ = 0; + windowBottomGrowth_ = 0; + isShrinkingFromZoomed_ = NO; +} + +- (NSSize)overflowFrom:(NSRect)source + to:(NSRect)target { + // If |source|'s boundary is outside of |target|'s, set its distance + // to |x|. Note that |source| can overflow to both side, but we + // have nothing to do for such case. + CGFloat x = 0; + if (NSMaxX(target) < NSMaxX(source)) // |source| overflows to right + x = NSMaxX(source) - NSMaxX(target); + else if (NSMinX(source) < NSMinX(target)) // |source| overflows to left + x = NSMinX(source) - NSMinX(target); + + // Same as |x| above. + CGFloat y = 0; + if (NSMaxY(target) < NSMaxY(source)) + y = NSMaxY(source) - NSMaxY(target); + else if (NSMinY(source) < NSMinY(target)) + y = NSMinY(source) - NSMinY(target); + + return NSMakeSize(x, y); +} + +// Override to swap in the correct tab strip controller based on the new +// tab strip mode. +- (void)toggleTabStripDisplayMode { + [super toggleTabStripDisplayMode]; + [self createTabStripController]; +} + +- (BOOL)useVerticalTabs { + return browser_->tabstrip_model()->delegate()->UseVerticalTabs(); +} + +- (void)showInstant:(TabContents*)previewContents { + [previewableContentsController_ showPreview:previewContents]; + [self updateBookmarkBarVisibilityWithAnimation:NO]; +} + +- (void)hideInstant { + // TODO(rohitrao): Revisit whether or not this method should be called when + // instant isn't showing. + if (![previewableContentsController_ isShowingPreview]) + return; + + [previewableContentsController_ hidePreview]; + [self updateBookmarkBarVisibilityWithAnimation:NO]; +} + +- (NSRect)instantFrame { + // The view's bounds are in its own coordinate system. Convert that to the + // window base coordinate system, then translate it into the screen's + // coordinate system. + NSView* view = [previewableContentsController_ view]; + if (!view) + return NSZeroRect; + + NSRect frame = [view convertRect:[view bounds] toView:nil]; + NSPoint originInScreenCoords = + [[view window] convertBaseToScreen:frame.origin]; + frame.origin = originInScreenCoords; + return frame; +} + +- (void)sheetDidEnd:(NSWindow*)sheet + returnCode:(NSInteger)code + context:(void*)context { + [sheet orderOut:self]; +} + +@end // @implementation BrowserWindowController + + +@implementation BrowserWindowController(Fullscreen) + +- (void)setFullscreen:(BOOL)fullscreen { + // The logic in this function is a bit complicated and very carefully + // arranged. See the below comments for more details. + + if (fullscreen == [self isFullscreen]) + return; + + if (![self supportsFullscreen]) + return; + + // Fade to black. + const CGDisplayReservationInterval kFadeDurationSeconds = 0.6; + Boolean didFadeOut = NO; + CGDisplayFadeReservationToken token; + if (CGAcquireDisplayFadeReservation(kFadeDurationSeconds, &token) + == kCGErrorSuccess) { + didFadeOut = YES; + CGDisplayFade(token, kFadeDurationSeconds / 2, kCGDisplayBlendNormal, + kCGDisplayBlendSolidColor, 0.0, 0.0, 0.0, /*synchronous=*/true); + } + + // Close the bookmark bubble, if it's open. We use |-ok:| instead of + // |-cancel:| or |-close| because that matches the behavior when the bubble + // loses key status. + [bookmarkBubbleController_ ok:self]; + + // Save the current first responder so we can restore after views are moved. + NSWindow* window = [self window]; + scoped_nsobject<FocusTracker> focusTracker( + [[FocusTracker alloc] initWithWindow:window]); + BOOL showDropdown = [self floatingBarHasFocus]; + + // While we move views (and focus) around, disable any bar visibility changes. + [self disableBarVisibilityUpdates]; + + // If we're entering fullscreen, create the fullscreen controller. If we're + // exiting fullscreen, kill the controller. + if (fullscreen) { + fullscreenController_.reset([[FullscreenController alloc] + initWithBrowserController:self]); + } else { + [fullscreenController_ exitFullscreen]; + fullscreenController_.reset(); + } + + // Destroy the tab strip's sheet controller. We will recreate it in the new + // window when needed. + [tabStripController_ destroySheetController]; + + // Retain the tab strip view while we remove it from its superview. + scoped_nsobject<NSView> tabStripView; + if ([self hasTabStrip] && ![self useVerticalTabs]) { + tabStripView.reset([[self tabStripView] retain]); + [tabStripView removeFromSuperview]; + } + + // Ditto for the content view. + scoped_nsobject<NSView> contentView([[window contentView] retain]); + // Disable autoresizing of subviews while we move views around. This prevents + // spurious renderer resizes. + [contentView setAutoresizesSubviews:NO]; + [contentView removeFromSuperview]; + + NSWindow* destWindow = nil; + if (fullscreen) { + DCHECK(!savedRegularWindow_); + savedRegularWindow_ = [window retain]; + destWindow = [self createFullscreenWindow]; + } else { + DCHECK(savedRegularWindow_); + destWindow = [savedRegularWindow_ autorelease]; + savedRegularWindow_ = nil; + + CGSWorkspaceID workspace; + if ([window cr_workspace:&workspace]) { + [destWindow cr_moveToWorkspace:workspace]; + } + } + DCHECK(destWindow); + + // Have to do this here, otherwise later calls can crash because the window + // has no delegate. + [window setDelegate:nil]; + [destWindow setDelegate:self]; + + // With this call, valgrind complains that a "Conditional jump or move depends + // on uninitialised value(s)". The error happens in -[NSThemeFrame + // drawOverlayRect:]. I'm pretty convinced this is an Apple bug, but there is + // no visual impact. I have been unable to tickle it away with other window + // or view manipulation Cocoa calls. Stack added to suppressions_mac.txt. + [contentView setAutoresizesSubviews:YES]; + [destWindow setContentView:contentView]; + + // Move the incognito badge if present. + if (incognitoBadge_.get()) { + [incognitoBadge_ removeFromSuperview]; + [incognitoBadge_ setHidden:YES]; // Will be shown in layout. + [[[destWindow contentView] superview] addSubview:incognitoBadge_]; + } + + // Add the tab strip after setting the content view and moving the incognito + // badge (if any), so that the tab strip will be on top (in the z-order). + if ([self hasTabStrip] && ![self useVerticalTabs]) + [[[destWindow contentView] superview] addSubview:tabStripView]; + + [window setWindowController:nil]; + [self setWindow:destWindow]; + [destWindow setWindowController:self]; + [self adjustUIForFullscreen:fullscreen]; + + // When entering fullscreen mode, the controller forces a layout for us. When + // exiting, we need to call layoutSubviews manually. + if (fullscreen) { + [fullscreenController_ enterFullscreenForContentView:contentView + showDropdown:showDropdown]; + } else { + [self layoutSubviews]; + } + + // Move the status bubble over, if we have one. + if (statusBubble_) + statusBubble_->SwitchParentWindow(destWindow); + + // Move the title over. + [destWindow setTitle:[window title]]; + + // The window needs to be onscreen before we can set its first responder. + [destWindow makeKeyAndOrderFront:self]; + [focusTracker restoreFocusInWindow:destWindow]; + [window orderOut:self]; + + // We're done moving focus, so re-enable bar visibility changes. + [self enableBarVisibilityUpdates]; + + // Fade back in. + if (didFadeOut) { + CGDisplayFade(token, kFadeDurationSeconds / 2, kCGDisplayBlendSolidColor, + kCGDisplayBlendNormal, 0.0, 0.0, 0.0, /*synchronous=*/false); + CGReleaseDisplayFadeReservation(token); + } +} + +- (BOOL)isFullscreen { + return fullscreenController_.get() && [fullscreenController_ isFullscreen]; +} + +- (void)resizeFullscreenWindow { + DCHECK([self isFullscreen]); + if (![self isFullscreen]) + return; + + NSWindow* window = [self window]; + [window setFrame:[[window screen] frame] display:YES]; + [self layoutSubviews]; +} + +- (CGFloat)floatingBarShownFraction { + return floatingBarShownFraction_; +} + +- (void)setFloatingBarShownFraction:(CGFloat)fraction { + floatingBarShownFraction_ = fraction; + [self layoutSubviews]; +} + +- (BOOL)isBarVisibilityLockedForOwner:(id)owner { + DCHECK(owner); + DCHECK(barVisibilityLocks_); + return [barVisibilityLocks_ containsObject:owner]; +} + +- (void)lockBarVisibilityForOwner:(id)owner + withAnimation:(BOOL)animate + delay:(BOOL)delay { + if (![self isBarVisibilityLockedForOwner:owner]) { + [barVisibilityLocks_ addObject:owner]; + + // If enabled, show the overlay if necessary (and if in fullscreen mode). + if (barVisibilityUpdatesEnabled_) { + [fullscreenController_ ensureOverlayShownWithAnimation:animate + delay:delay]; + } + } +} + +- (void)releaseBarVisibilityForOwner:(id)owner + withAnimation:(BOOL)animate + delay:(BOOL)delay { + if ([self isBarVisibilityLockedForOwner:owner]) { + [barVisibilityLocks_ removeObject:owner]; + + // If enabled, hide the overlay if necessary (and if in fullscreen mode). + if (barVisibilityUpdatesEnabled_ && + ![barVisibilityLocks_ count]) { + [fullscreenController_ ensureOverlayHiddenWithAnimation:animate + delay:delay]; + } + } +} + +- (BOOL)floatingBarHasFocus { + NSResponder* focused = [[self window] firstResponder]; + return [focused isKindOfClass:[AutocompleteTextFieldEditor class]]; +} + +- (void)openTabpose { + NSUInteger modifierFlags = [[NSApp currentEvent] modifierFlags]; + BOOL slomo = (modifierFlags & NSShiftKeyMask) != 0; + + // Cover info bars, inspector window, and detached bookmark bar on NTP. + // Do not cover download shelf. + NSRect activeArea = [[self tabContentArea] frame]; + activeArea.size.height += + NSHeight([[infoBarContainerController_ view] frame]); + if ([self isBookmarkBarVisible] && [self placeBookmarkBarBelowInfoBar]) { + NSView* bookmarkBarView = [bookmarkBarController_ view]; + activeArea.size.height += NSHeight([bookmarkBarView frame]); + } + + [TabposeWindow openTabposeFor:[self window] + rect:activeArea + slomo:slomo + tabStripModel:browser_->tabstrip_model()]; +} + +@end // @implementation BrowserWindowController(Fullscreen) + + +@implementation BrowserWindowController(WindowType) + +- (BOOL)supportsWindowFeature:(int)feature { + return browser_->SupportsWindowFeature( + static_cast<Browser::WindowFeature>(feature)); +} + +- (BOOL)hasTitleBar { + return [self supportsWindowFeature:Browser::FEATURE_TITLEBAR]; +} + +- (BOOL)hasToolbar { + return [self supportsWindowFeature:Browser::FEATURE_TOOLBAR]; +} + +- (BOOL)hasLocationBar { + return [self supportsWindowFeature:Browser::FEATURE_LOCATIONBAR]; +} + +- (BOOL)supportsBookmarkBar { + return [self supportsWindowFeature:Browser::FEATURE_BOOKMARKBAR]; +} + +- (BOOL)isNormalWindow { + return browser_->type() == Browser::TYPE_NORMAL; +} + +@end // @implementation BrowserWindowController(WindowType) diff --git a/chrome/browser/ui/cocoa/browser_window_controller_private.h b/chrome/browser/ui/cocoa/browser_window_controller_private.h new file mode 100644 index 0000000..87571262 --- /dev/null +++ b/chrome/browser/ui/cocoa/browser_window_controller_private.h @@ -0,0 +1,119 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_BROWSER_WINDOW_CONTROLLER_PRIVATE_H_ +#define CHROME_BROWSER_UI_COCOA_BROWSER_WINDOW_CONTROLLER_PRIVATE_H_ +#pragma once + +#import "chrome/browser/ui/cocoa/browser_window_controller.h" + + +// Private methods for the |BrowserWindowController|. This category should +// contain the private methods used by different parts of the BWC; private +// methods used only by single parts should be declared in their own file. +// TODO(viettrungluu): [crbug.com/35543] work on splitting out stuff from the +// BWC, and figuring out which methods belong here (need to unravel +// "dependencies"). +@interface BrowserWindowController(Private) + +// Create the appropriate tab strip controller based on whether or not side +// tabs are enabled. Replaces the current controller. +- (void)createTabStripController; + +// Saves the window's position in the local state preferences. +- (void)saveWindowPositionIfNeeded; + +// Saves the window's position to the given pref service. +- (void)saveWindowPositionToPrefs:(PrefService*)prefs; + +// We need to adjust where sheets come out of the window, as by default they +// erupt from the omnibox, which is rather weird. +- (NSRect)window:(NSWindow*)window + willPositionSheet:(NSWindow*)sheet + usingRect:(NSRect)defaultSheetRect; + +// Repositions the window's subviews. From the top down: toolbar, normal +// bookmark bar (if shown), infobar, NTP detached bookmark bar (if shown), +// content area, download shelf (if any). +- (void)layoutSubviews; + +// Find the total height of the floating bar (in fullscreen mode). Safe to call +// even when not in fullscreen mode. +- (CGFloat)floatingBarHeight; + +// Lays out the tab strip at the given maximum y-coordinate, with the given +// width, possibly for fullscreen mode; returns the new maximum y (below the tab +// strip). This is safe to call even when there is no tab strip. +- (CGFloat)layoutTabStripAtMaxY:(CGFloat)maxY + width:(CGFloat)width + fullscreen:(BOOL)fullscreen; + +// Lays out the toolbar (or just location bar for popups) at the given maximum +// y-coordinate, with the given width; returns the new maximum y (below the +// toolbar). +- (CGFloat)layoutToolbarAtMinX:(CGFloat)minX + maxY:(CGFloat)maxY + width:(CGFloat)width; + +// Returns YES if the bookmark bar should be placed below the infobar, NO +// otherwise. +- (BOOL)placeBookmarkBarBelowInfoBar; + +// Lays out the bookmark bar at the given maximum y-coordinate, with the given +// width; returns the new maximum y (below the bookmark bar). Note that one must +// call it with the appropriate |maxY| which depends on whether or not the +// bookmark bar is shown as the NTP bubble or not (use +// |-placeBookmarkBarBelowInfoBar|). +- (CGFloat)layoutBookmarkBarAtMinX:(CGFloat)minX + maxY:(CGFloat)maxY + width:(CGFloat)width; + +// Lay out the view which draws the background for the floating bar when in +// fullscreen mode, with the given frame and fullscreen-mode-status. Should be +// called even when not in fullscreen mode to hide the backing view. +- (void)layoutFloatingBarBackingView:(NSRect)frame + fullscreen:(BOOL)fullscreen; + +// Lays out the infobar at the given maximum y-coordinate, with the given width; +// returns the new maximum y (below the infobar). +- (CGFloat)layoutInfoBarAtMinX:(CGFloat)minX + maxY:(CGFloat)maxY + width:(CGFloat)width; + +// Lays out the download shelf, if there is one, at the given minimum +// y-coordinate, with the given width; returns the new minimum y (above the +// download shelf). This is safe to call even if there is no download shelf. +- (CGFloat)layoutDownloadShelfAtMinX:(CGFloat)minX + minY:(CGFloat)minY + width:(CGFloat)width; + +// Lays out the tab content area in the given frame. If the height changes, +// sends a message to the renderer to resize. +- (void)layoutTabContentArea:(NSRect)frame; + +// Should we show the normal bookmark bar? +- (BOOL)shouldShowBookmarkBar; + +// Is the current page one for which the bookmark should be shown detached *if* +// the normal bookmark bar is not shown? +- (BOOL)shouldShowDetachedBookmarkBar; + +// Sets the toolbar's height to a value appropriate for the given compression. +// Also adjusts the bookmark bar's height by the opposite amount in order to +// keep the total height of the two views constant. +- (void)adjustToolbarAndBookmarkBarForCompression:(CGFloat)compression; + +// Adjust the UI when entering or leaving fullscreen mode. +- (void)adjustUIForFullscreen:(BOOL)fullscreen; + +// Allows/prevents bar visibility locks and releases from updating the visual +// state. Enabling makes changes instantaneously; disabling cancels any +// timers/animation. +- (void)enableBarVisibilityUpdates; +- (void)disableBarVisibilityUpdates; + +@end // @interface BrowserWindowController(Private) + + +#endif // CHROME_BROWSER_UI_COCOA_BROWSER_WINDOW_CONTROLLER_PRIVATE_H_ diff --git a/chrome/browser/ui/cocoa/browser_window_controller_private.mm b/chrome/browser/ui/cocoa/browser_window_controller_private.mm new file mode 100644 index 0000000..62d0a4f --- /dev/null +++ b/chrome/browser/ui/cocoa/browser_window_controller_private.mm @@ -0,0 +1,509 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/browser_window_controller_private.h" + +#include "base/mac_util.h" +#import "base/scoped_nsobject.h" +#include "chrome/browser/browser_process.h" +#include "chrome/browser/prefs/pref_service.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/renderer_host/render_widget_host_view.h" +#include "chrome/browser/tab_contents/tab_contents.h" +#include "chrome/browser/tab_contents/tab_contents_view.h" +#include "chrome/browser/themes/browser_theme_provider.h" +#include "chrome/browser/ui/browser.h" +#include "chrome/browser/ui/browser_list.h" +#import "chrome/browser/ui/cocoa/fast_resize_view.h" +#import "chrome/browser/ui/cocoa/find_bar_cocoa_controller.h" +#import "chrome/browser/ui/cocoa/floating_bar_backing_view.h" +#import "chrome/browser/ui/cocoa/framed_browser_window.h" +#import "chrome/browser/ui/cocoa/fullscreen_controller.h" +#import "chrome/browser/ui/cocoa/previewable_contents_controller.h" +#import "chrome/browser/ui/cocoa/side_tab_strip_controller.h" +#import "chrome/browser/ui/cocoa/tab_strip_controller.h" +#import "chrome/browser/ui/cocoa/tab_strip_view.h" +#import "chrome/browser/ui/cocoa/toolbar_controller.h" +#include "chrome/common/pref_names.h" + +namespace { + +// Space between the incognito badge and the right edge of the window. +const CGFloat kIncognitoBadgeOffset = 4; + +// Insets for the location bar, used when the full toolbar is hidden. +// TODO(viettrungluu): We can argue about the "correct" insetting; I like the +// following best, though arguably 0 inset is better/more correct. +const CGFloat kLocBarLeftRightInset = 1; +const CGFloat kLocBarTopInset = 0; +const CGFloat kLocBarBottomInset = 1; + +} // end namespace + + +@implementation BrowserWindowController(Private) + +// Create the appropriate tab strip controller based on whether or not side +// tabs are enabled. +- (void)createTabStripController { + Class factory = [TabStripController class]; + if ([self useVerticalTabs]) + factory = [SideTabStripController class]; + + DCHECK([previewableContentsController_ activeContainer]); + DCHECK([[previewableContentsController_ activeContainer] window]); + tabStripController_.reset([[factory alloc] + initWithView:[self tabStripView] + switchView:[previewableContentsController_ activeContainer] + browser:browser_.get() + delegate:self]); +} + +- (void)saveWindowPositionIfNeeded { + if (browser_ != BrowserList::GetLastActive()) + return; + + if (!g_browser_process || !g_browser_process->local_state() || + !browser_->ShouldSaveWindowPlacement()) + return; + + [self saveWindowPositionToPrefs:g_browser_process->local_state()]; +} + +- (void)saveWindowPositionToPrefs:(PrefService*)prefs { + // If we're in fullscreen mode, save the position of the regular window + // instead. + NSWindow* window = [self isFullscreen] ? savedRegularWindow_ : [self window]; + + // Window positions are stored relative to the origin of the primary monitor. + NSRect monitorFrame = [[[NSScreen screens] objectAtIndex:0] frame]; + NSScreen* windowScreen = [window screen]; + + // |windowScreen| can be nil (for example, if the monitor arrangement was + // changed while in fullscreen mode). If we see a nil screen, return without + // saving. + // TODO(rohitrao): We should just not save anything for fullscreen windows. + // http://crbug.com/36479. + if (!windowScreen) + return; + + // Start with the window's frame, which is in virtual coordinates. + // Do some y twiddling to flip the coordinate system. + gfx::Rect bounds(NSRectToCGRect([window frame])); + bounds.set_y(monitorFrame.size.height - bounds.y() - bounds.height()); + + // We also need to save the current work area, in flipped coordinates. + gfx::Rect workArea(NSRectToCGRect([windowScreen visibleFrame])); + workArea.set_y(monitorFrame.size.height - workArea.y() - workArea.height()); + + // Browser::SaveWindowPlacement is used for session restore. + if (browser_->ShouldSaveWindowPlacement()) + browser_->SaveWindowPlacement(bounds, /*maximized=*/ false); + + DictionaryValue* windowPreferences = prefs->GetMutableDictionary( + browser_->GetWindowPlacementKey().c_str()); + windowPreferences->SetInteger("left", bounds.x()); + windowPreferences->SetInteger("top", bounds.y()); + windowPreferences->SetInteger("right", bounds.right()); + windowPreferences->SetInteger("bottom", bounds.bottom()); + windowPreferences->SetBoolean("maximized", false); + windowPreferences->SetBoolean("always_on_top", false); + windowPreferences->SetInteger("work_area_left", workArea.x()); + windowPreferences->SetInteger("work_area_top", workArea.y()); + windowPreferences->SetInteger("work_area_right", workArea.right()); + windowPreferences->SetInteger("work_area_bottom", workArea.bottom()); +} + +- (NSRect)window:(NSWindow*)window +willPositionSheet:(NSWindow*)sheet + usingRect:(NSRect)defaultSheetRect { + // Position the sheet as follows: + // - If the bookmark bar is hidden or shown as a bubble (on the NTP when the + // bookmark bar is disabled), position the sheet immediately below the + // normal toolbar. + // - If the bookmark bar is shown (attached to the normal toolbar), position + // the sheet below the bookmark bar. + // - If the bookmark bar is currently animating, position the sheet according + // to where the bar will be when the animation ends. + switch ([bookmarkBarController_ visualState]) { + case bookmarks::kShowingState: { + NSRect bookmarkBarFrame = [[bookmarkBarController_ view] frame]; + defaultSheetRect.origin.y = bookmarkBarFrame.origin.y; + break; + } + case bookmarks::kHiddenState: + case bookmarks::kDetachedState: { + NSRect toolbarFrame = [[toolbarController_ view] frame]; + defaultSheetRect.origin.y = toolbarFrame.origin.y; + break; + } + case bookmarks::kInvalidState: + default: + NOTREACHED(); + } + return defaultSheetRect; +} + +- (void)layoutSubviews { + // With the exception of the top tab strip, the subviews which we lay out are + // subviews of the content view, so we mainly work in the content view's + // coordinate system. Note, however, that the content view's coordinate system + // and the window's base coordinate system should coincide. + NSWindow* window = [self window]; + NSView* contentView = [window contentView]; + NSRect contentBounds = [contentView bounds]; + CGFloat minX = NSMinX(contentBounds); + CGFloat minY = NSMinY(contentBounds); + CGFloat width = NSWidth(contentBounds); + + // Suppress title drawing if necessary. + if ([window respondsToSelector:@selector(setShouldHideTitle:)]) + [(id)window setShouldHideTitle:![self hasTitleBar]]; + + BOOL isFullscreen = [self isFullscreen]; + CGFloat floatingBarHeight = [self floatingBarHeight]; + // In fullscreen mode, |yOffset| accounts for the sliding position of the + // floating bar and the extra offset needed to dodge the menu bar. + CGFloat yOffset = isFullscreen ? + (floor((1 - floatingBarShownFraction_) * floatingBarHeight) - + [fullscreenController_ floatingBarVerticalOffset]) : 0; + CGFloat maxY = NSMaxY(contentBounds) + yOffset; + CGFloat startMaxY = maxY; + + if ([self hasTabStrip] && ![self useVerticalTabs]) { + // If we need to lay out the top tab strip, replace |maxY| and |startMaxY| + // with higher values, and then lay out the tab strip. + NSRect windowFrame = [contentView convertRect:[window frame] fromView:nil]; + startMaxY = maxY = NSHeight(windowFrame) + yOffset; + maxY = [self layoutTabStripAtMaxY:maxY width:width fullscreen:isFullscreen]; + } + + // Sanity-check |maxY|. + DCHECK_GE(maxY, minY); + DCHECK_LE(maxY, NSMaxY(contentBounds) + yOffset); + + // The base class already positions the side tab strip on the left side + // of the window's content area and sizes it to take the entire vertical + // height. All that's needed here is to push everything over to the right, + // if necessary. + if ([self useVerticalTabs]) { + const CGFloat sideTabWidth = [[self tabStripView] bounds].size.width; + minX += sideTabWidth; + width -= sideTabWidth; + } + + // Place the toolbar at the top of the reserved area. + maxY = [self layoutToolbarAtMinX:minX maxY:maxY width:width]; + + // If we're not displaying the bookmark bar below the infobar, then it goes + // immediately below the toolbar. + BOOL placeBookmarkBarBelowInfoBar = [self placeBookmarkBarBelowInfoBar]; + if (!placeBookmarkBarBelowInfoBar) + maxY = [self layoutBookmarkBarAtMinX:minX maxY:maxY width:width]; + + // The floating bar backing view doesn't actually add any height. + NSRect floatingBarBackingRect = + NSMakeRect(minX, maxY, width, floatingBarHeight); + [self layoutFloatingBarBackingView:floatingBarBackingRect + fullscreen:isFullscreen]; + + // Place the find bar immediately below the toolbar/attached bookmark bar. In + // fullscreen mode, it hangs off the top of the screen when the bar is hidden. + // The find bar is unaffected by the side tab positioning. + [findBarCocoaController_ positionFindBarViewAtMaxY:maxY maxWidth:width]; + + // If in fullscreen mode, reset |maxY| to top of screen, so that the floating + // bar slides over the things which appear to be in the content area. + if (isFullscreen) + maxY = NSMaxY(contentBounds); + + // Also place the infobar container immediate below the toolbar, except in + // fullscreen mode in which case it's at the top of the visual content area. + maxY = [self layoutInfoBarAtMinX:minX maxY:maxY width:width]; + + // If the bookmark bar is detached, place it next in the visual content area. + if (placeBookmarkBarBelowInfoBar) + maxY = [self layoutBookmarkBarAtMinX:minX maxY:maxY width:width]; + + // Place the download shelf, if any, at the bottom of the view. + minY = [self layoutDownloadShelfAtMinX:minX minY:minY width:width]; + + // Finally, the content area takes up all of the remaining space. + NSRect contentAreaRect = NSMakeRect(minX, minY, width, maxY - minY); + [self layoutTabContentArea:contentAreaRect]; + + // Normally, we don't need to tell the toolbar whether or not to show the + // divider, but things break down during animation. + [toolbarController_ + setDividerOpacity:[bookmarkBarController_ toolbarDividerOpacity]]; +} + +- (CGFloat)floatingBarHeight { + if (![self isFullscreen]) + return 0; + + CGFloat totalHeight = [fullscreenController_ floatingBarVerticalOffset]; + + if ([self hasTabStrip]) + totalHeight += NSHeight([[self tabStripView] frame]); + + if ([self hasToolbar]) { + totalHeight += NSHeight([[toolbarController_ view] frame]); + } else if ([self hasLocationBar]) { + totalHeight += NSHeight([[toolbarController_ view] frame]) + + kLocBarTopInset + kLocBarBottomInset; + } + + if (![self placeBookmarkBarBelowInfoBar]) + totalHeight += NSHeight([[bookmarkBarController_ view] frame]); + + return totalHeight; +} + +- (CGFloat)layoutTabStripAtMaxY:(CGFloat)maxY + width:(CGFloat)width + fullscreen:(BOOL)fullscreen { + // Nothing to do if no tab strip. + if (![self hasTabStrip]) + return maxY; + + NSView* tabStripView = [self tabStripView]; + CGFloat tabStripHeight = NSHeight([tabStripView frame]); + maxY -= tabStripHeight; + [tabStripView setFrame:NSMakeRect(0, maxY, width, tabStripHeight)]; + + // Set indentation. + [tabStripController_ setIndentForControls:(fullscreen ? 0 : + [[tabStripController_ class] defaultIndentForControls])]; + + // TODO(viettrungluu): Seems kind of bad -- shouldn't |-layoutSubviews| do + // this? Moreover, |-layoutTabs| will try to animate.... + [tabStripController_ layoutTabs]; + + // Now lay out incognito badge together with the tab strip. + if (incognitoBadge_.get()) { + // Actually place the badge *above* |maxY|. + NSPoint origin = NSMakePoint(width - NSWidth([incognitoBadge_ frame]) - + kIncognitoBadgeOffset, maxY); + [incognitoBadge_ setFrameOrigin:origin]; + [incognitoBadge_ setHidden:NO]; // Make sure it's shown. + } + + return maxY; +} + +- (CGFloat)layoutToolbarAtMinX:(CGFloat)minX + maxY:(CGFloat)maxY + width:(CGFloat)width { + NSView* toolbarView = [toolbarController_ view]; + NSRect toolbarFrame = [toolbarView frame]; + if ([self hasToolbar]) { + // The toolbar is present in the window, so we make room for it. + DCHECK(![toolbarView isHidden]); + toolbarFrame.origin.x = minX; + toolbarFrame.origin.y = maxY - NSHeight(toolbarFrame); + toolbarFrame.size.width = width; + maxY -= NSHeight(toolbarFrame); + } else { + if ([self hasLocationBar]) { + // Location bar is present with no toolbar. Put a border of + // |kLocBar...Inset| pixels around the location bar. + // TODO(viettrungluu): This is moderately ridiculous. The toolbar should + // really be aware of what its height should be (the way the toolbar + // compression stuff is currently set up messes things up). + DCHECK(![toolbarView isHidden]); + toolbarFrame.origin.x = kLocBarLeftRightInset; + toolbarFrame.origin.y = maxY - NSHeight(toolbarFrame) - kLocBarTopInset; + toolbarFrame.size.width = width - 2 * kLocBarLeftRightInset; + maxY -= kLocBarTopInset + NSHeight(toolbarFrame) + kLocBarBottomInset; + } else { + DCHECK([toolbarView isHidden]); + } + } + [toolbarView setFrame:toolbarFrame]; + return maxY; +} + +- (BOOL)placeBookmarkBarBelowInfoBar { + // If we are currently displaying the NTP detached bookmark bar or animating + // to/from it (from/to anything else), we display the bookmark bar below the + // infobar. + return [bookmarkBarController_ isInState:bookmarks::kDetachedState] || + [bookmarkBarController_ isAnimatingToState:bookmarks::kDetachedState] || + [bookmarkBarController_ isAnimatingFromState:bookmarks::kDetachedState]; +} + +- (CGFloat)layoutBookmarkBarAtMinX:(CGFloat)minX + maxY:(CGFloat)maxY + width:(CGFloat)width { + NSView* bookmarkBarView = [bookmarkBarController_ view]; + NSRect bookmarkBarFrame = [bookmarkBarView frame]; + BOOL oldHidden = [bookmarkBarView isHidden]; + BOOL newHidden = ![self isBookmarkBarVisible]; + if (oldHidden != newHidden) + [bookmarkBarView setHidden:newHidden]; + bookmarkBarFrame.origin.x = minX; + bookmarkBarFrame.origin.y = maxY - NSHeight(bookmarkBarFrame); + bookmarkBarFrame.size.width = width; + [bookmarkBarView setFrame:bookmarkBarFrame]; + maxY -= NSHeight(bookmarkBarFrame); + + // TODO(viettrungluu): Does this really belong here? Calling it shouldn't be + // necessary in the non-NTP case. + [bookmarkBarController_ layoutSubviews]; + + return maxY; +} + +- (void)layoutFloatingBarBackingView:(NSRect)frame + fullscreen:(BOOL)fullscreen { + // Only display when in fullscreen mode. + if (fullscreen) { + // For certain window types such as app windows (e.g., the dev tools + // window), there's no actual overlay. (Displaying one would result in an + // overly sliding in only under the menu, which gives an ugly effect.) + if (floatingBarBackingView_.get()) { + BOOL aboveBookmarkBar = [self placeBookmarkBarBelowInfoBar]; + + // Insert it into the view hierarchy if necessary. + if (![floatingBarBackingView_ superview] || + aboveBookmarkBar != floatingBarAboveBookmarkBar_) { + NSView* contentView = [[self window] contentView]; + // z-order gets messed up unless we explicitly remove the floatingbar + // view and re-add it. + [floatingBarBackingView_ removeFromSuperview]; + [contentView addSubview:floatingBarBackingView_ + positioned:(aboveBookmarkBar ? + NSWindowAbove : NSWindowBelow) + relativeTo:[bookmarkBarController_ view]]; + floatingBarAboveBookmarkBar_ = aboveBookmarkBar; + } + + // Set its frame. + [floatingBarBackingView_ setFrame:frame]; + } + + // But we want the logic to work as usual (for show/hide/etc. purposes). + [fullscreenController_ overlayFrameChanged:frame]; + } else { + // Okay to call even if |floatingBarBackingView_| is nil. + if ([floatingBarBackingView_ superview]) + [floatingBarBackingView_ removeFromSuperview]; + } +} + +- (CGFloat)layoutInfoBarAtMinX:(CGFloat)minX + maxY:(CGFloat)maxY + width:(CGFloat)width { + NSView* infoBarView = [infoBarContainerController_ view]; + NSRect infoBarFrame = [infoBarView frame]; + infoBarFrame.origin.x = minX; + infoBarFrame.origin.y = maxY - NSHeight(infoBarFrame); + infoBarFrame.size.width = width; + [infoBarView setFrame:infoBarFrame]; + maxY -= NSHeight(infoBarFrame); + return maxY; +} + +- (CGFloat)layoutDownloadShelfAtMinX:(CGFloat)minX + minY:(CGFloat)minY + width:(CGFloat)width { + if (downloadShelfController_.get()) { + NSView* downloadView = [downloadShelfController_ view]; + NSRect downloadFrame = [downloadView frame]; + downloadFrame.origin.x = minX; + downloadFrame.origin.y = minY; + downloadFrame.size.width = width; + [downloadView setFrame:downloadFrame]; + minY += NSHeight(downloadFrame); + } + return minY; +} + +- (void)layoutTabContentArea:(NSRect)newFrame { + NSView* tabContentView = [self tabContentArea]; + NSRect tabContentFrame = [tabContentView frame]; + + bool contentShifted = + NSMaxY(tabContentFrame) != NSMaxY(newFrame) || + NSMinX(tabContentFrame) != NSMinX(newFrame); + + tabContentFrame = newFrame; + [tabContentView setFrame:tabContentFrame]; + + // If the relayout shifts the content area up or down, let the renderer know. + if (contentShifted) { + if (TabContents* contents = browser_->GetSelectedTabContents()) { + if (RenderWidgetHostView* rwhv = contents->GetRenderWidgetHostView()) + rwhv->WindowFrameChanged(); + } + } +} + +- (BOOL)shouldShowBookmarkBar { + DCHECK(browser_.get()); + return browser_->profile()->GetPrefs()->GetBoolean(prefs::kShowBookmarkBar) ? + YES : NO; +} + +- (BOOL)shouldShowDetachedBookmarkBar { + DCHECK(browser_.get()); + TabContents* contents = browser_->GetSelectedTabContents(); + return (contents && + contents->ShouldShowBookmarkBar() && + ![previewableContentsController_ isShowingPreview]); +} + +- (void)adjustToolbarAndBookmarkBarForCompression:(CGFloat)compression { + CGFloat newHeight = + [toolbarController_ desiredHeightForCompression:compression]; + NSRect toolbarFrame = [[toolbarController_ view] frame]; + CGFloat deltaH = newHeight - toolbarFrame.size.height; + + if (deltaH == 0) + return; + + toolbarFrame.size.height = newHeight; + NSRect bookmarkFrame = [[bookmarkBarController_ view] frame]; + bookmarkFrame.size.height = bookmarkFrame.size.height - deltaH; + [[toolbarController_ view] setFrame:toolbarFrame]; + [[bookmarkBarController_ view] setFrame:bookmarkFrame]; + [self layoutSubviews]; +} + +// TODO(rohitrao): This function has shrunk into uselessness, and +// |-setFullscreen:| has grown rather large. Find a good way to break up +// |-setFullscreen:| into smaller pieces. http://crbug.com/36449 +- (void)adjustUIForFullscreen:(BOOL)fullscreen { + // Create the floating bar backing view if necessary. + if (fullscreen && !floatingBarBackingView_.get() && + ([self hasTabStrip] || [self hasToolbar] || [self hasLocationBar])) { + floatingBarBackingView_.reset( + [[FloatingBarBackingView alloc] initWithFrame:NSZeroRect]); + } +} + +- (void)enableBarVisibilityUpdates { + // Early escape if there's nothing to do. + if (barVisibilityUpdatesEnabled_) + return; + + barVisibilityUpdatesEnabled_ = YES; + + if ([barVisibilityLocks_ count]) + [fullscreenController_ ensureOverlayShownWithAnimation:NO delay:NO]; + else + [fullscreenController_ ensureOverlayHiddenWithAnimation:NO delay:NO]; +} + +- (void)disableBarVisibilityUpdates { + // Early escape if there's nothing to do. + if (!barVisibilityUpdatesEnabled_) + return; + + barVisibilityUpdatesEnabled_ = NO; + [fullscreenController_ cancelAnimationAndTimers]; +} + +@end // @implementation BrowserWindowController(Private) diff --git a/chrome/browser/ui/cocoa/browser_window_controller_unittest.mm b/chrome/browser/ui/cocoa/browser_window_controller_unittest.mm new file mode 100644 index 0000000..742b7f4 --- /dev/null +++ b/chrome/browser/ui/cocoa/browser_window_controller_unittest.mm @@ -0,0 +1,670 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "app/l10n_util_mac.h" +#include "base/scoped_nsobject.h" +#include "base/scoped_ptr.h" +#include "chrome/app/chrome_command_ids.h" +#include "chrome/browser/browser_process.h" +#include "chrome/browser/browser_window.h" +#include "chrome/browser/prefs/pref_service.h" +#include "chrome/browser/sync/sync_ui_util.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#include "chrome/browser/ui/cocoa/browser_window_controller.h" +#include "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "chrome/browser/ui/cocoa/find_bar_bridge.h" +#include "chrome/common/pref_names.h" +#include "chrome/test/testing_browser_process.h" +#include "chrome/test/testing_profile.h" +#include "grit/generated_resources.h" + +@interface BrowserWindowController (JustForTesting) +// Already defined in BWC. +- (void)saveWindowPositionToPrefs:(PrefService*)prefs; +- (void)layoutSubviews; +@end + +@interface BrowserWindowController (ExposedForTesting) +// Implementations are below. +- (NSView*)infoBarContainerView; +- (NSView*)toolbarView; +- (NSView*)bookmarkView; +- (BOOL)bookmarkBarVisible; +@end + +@implementation BrowserWindowController (ExposedForTesting) +- (NSView*)infoBarContainerView { + return [infoBarContainerController_ view]; +} + +- (NSView*)toolbarView { + return [toolbarController_ view]; +} + +- (NSView*)bookmarkView { + return [bookmarkBarController_ view]; +} + +- (NSView*)findBarView { + return [findBarCocoaController_ view]; +} + +- (NSSplitView*)devToolsView { + return static_cast<NSSplitView*>([devToolsController_ view]); +} + +- (NSView*)sidebarView { + return [sidebarController_ view]; +} + +- (BOOL)bookmarkBarVisible { + return [bookmarkBarController_ isVisible]; +} +@end + +class BrowserWindowControllerTest : public CocoaTest { + public: + virtual void SetUp() { + CocoaTest::SetUp(); + Browser* browser = browser_helper_.browser(); + controller_ = [[BrowserWindowController alloc] initWithBrowser:browser + takeOwnership:NO]; + } + + virtual void TearDown() { + [controller_ close]; + CocoaTest::TearDown(); + } + + public: + BrowserTestHelper browser_helper_; + BrowserWindowController* controller_; +}; + +TEST_F(BrowserWindowControllerTest, TestSaveWindowPosition) { + PrefService* prefs = browser_helper_.profile()->GetPrefs(); + ASSERT_TRUE(prefs != NULL); + + // Check to make sure there is no existing pref for window placement. + ASSERT_TRUE(prefs->GetDictionary(prefs::kBrowserWindowPlacement) == NULL); + + // Ask the window to save its position, then check that a preference + // exists. We're technically passing in a pointer to the user prefs + // and not the local state prefs, but a PrefService* is a + // PrefService*, and this is a unittest. + [controller_ saveWindowPositionToPrefs:prefs]; + EXPECT_TRUE(prefs->GetDictionary(prefs::kBrowserWindowPlacement) != NULL); +} + +TEST_F(BrowserWindowControllerTest, TestFullScreenWindow) { + // Confirm that |-createFullscreenWindow| doesn't return nil. + // See BrowserWindowFullScreenControllerTest for more fullscreen tests. + EXPECT_TRUE([controller_ createFullscreenWindow]); +} + +TEST_F(BrowserWindowControllerTest, TestNormal) { + // Force the bookmark bar to be shown. + browser_helper_.profile()->GetPrefs()-> + SetBoolean(prefs::kShowBookmarkBar, true); + [controller_ updateBookmarkBarVisibilityWithAnimation:NO]; + + // Make sure a normal BrowserWindowController is, uh, normal. + EXPECT_TRUE([controller_ isNormalWindow]); + EXPECT_TRUE([controller_ hasTabStrip]); + EXPECT_FALSE([controller_ hasTitleBar]); + EXPECT_TRUE([controller_ isBookmarkBarVisible]); + + // And make sure a controller for a pop-up window is not normal. + // popup_browser will be owned by its window. + Browser *popup_browser(Browser::CreateForType(Browser::TYPE_POPUP, + browser_helper_.profile())); + NSWindow *cocoaWindow = popup_browser->window()->GetNativeHandle(); + BrowserWindowController* controller = + static_cast<BrowserWindowController*>([cocoaWindow windowController]); + ASSERT_TRUE([controller isKindOfClass:[BrowserWindowController class]]); + EXPECT_FALSE([controller isNormalWindow]); + EXPECT_FALSE([controller hasTabStrip]); + EXPECT_TRUE([controller hasTitleBar]); + EXPECT_FALSE([controller isBookmarkBarVisible]); + [controller close]; +} + +TEST_F(BrowserWindowControllerTest, TestTheme) { + [controller_ userChangedTheme]; +} + +TEST_F(BrowserWindowControllerTest, BookmarkBarControllerIndirection) { + EXPECT_FALSE([controller_ isBookmarkBarVisible]); + + // Explicitly show the bar. Can't use bookmark_utils::ToggleWhenVisible() + // because of the notification issues. + browser_helper_.profile()->GetPrefs()-> + SetBoolean(prefs::kShowBookmarkBar, true); + + [controller_ updateBookmarkBarVisibilityWithAnimation:NO]; + EXPECT_TRUE([controller_ isBookmarkBarVisible]); +} + +#if 0 +// TODO(jrg): This crashes trying to create the BookmarkBarController, adding +// an observer to the BookmarkModel. +TEST_F(BrowserWindowControllerTest, TestIncognitoWidthSpace) { + scoped_ptr<TestingProfile> incognito_profile(new TestingProfile()); + incognito_profile->set_off_the_record(true); + scoped_ptr<Browser> browser(new Browser(Browser::TYPE_NORMAL, + incognito_profile.get())); + controller_.reset([[BrowserWindowController alloc] + initWithBrowser:browser.get() + takeOwnership:NO]); + + NSRect tabFrame = [[controller_ tabStripView] frame]; + [controller_ installIncognitoBadge]; + NSRect newTabFrame = [[controller_ tabStripView] frame]; + EXPECT_GT(tabFrame.size.width, newTabFrame.size.width); + + controller_.release(); +} +#endif + +namespace { +// Verifies that the toolbar, infobar, tab content area, and download shelf +// completely fill the area under the tabstrip. +void CheckViewPositions(BrowserWindowController* controller) { + NSRect contentView = [[[controller window] contentView] bounds]; + NSRect tabstrip = [[controller tabStripView] frame]; + NSRect toolbar = [[controller toolbarView] frame]; + NSRect infobar = [[controller infoBarContainerView] frame]; + NSRect contentArea = [[controller tabContentArea] frame]; + NSRect download = [[[controller downloadShelf] view] frame]; + + EXPECT_EQ(NSMinY(contentView), NSMinY(download)); + EXPECT_EQ(NSMaxY(download), NSMinY(contentArea)); + EXPECT_EQ(NSMaxY(contentArea), NSMinY(infobar)); + + // Bookmark bar frame is random memory when hidden. + if ([controller bookmarkBarVisible]) { + NSRect bookmark = [[controller bookmarkView] frame]; + EXPECT_EQ(NSMaxY(infobar), NSMinY(bookmark)); + EXPECT_EQ(NSMaxY(bookmark), NSMinY(toolbar)); + EXPECT_FALSE([[controller bookmarkView] isHidden]); + } else { + EXPECT_EQ(NSMaxY(infobar), NSMinY(toolbar)); + EXPECT_TRUE([[controller bookmarkView] isHidden]); + } + + // Toolbar should start immediately under the tabstrip, but the tabstrip is + // not necessarily fixed with respect to the content view. + EXPECT_EQ(NSMinY(tabstrip), NSMaxY(toolbar)); +} +} // end namespace + +TEST_F(BrowserWindowControllerTest, TestAdjustWindowHeight) { + NSWindow* window = [controller_ window]; + NSRect workarea = [[window screen] visibleFrame]; + + // Place the window well above the bottom of the screen and try to adjust its + // height. It should change appropriately (and only downwards). Then get it to + // shrink by the same amount; it should return to its original state. + NSRect initialFrame = NSMakeRect(workarea.origin.x, workarea.origin.y + 100, + 200, 200); + [window setFrame:initialFrame display:YES]; + [controller_ resetWindowGrowthState]; + [controller_ adjustWindowHeightBy:40]; + NSRect finalFrame = [window frame]; + EXPECT_FALSE(NSEqualRects(finalFrame, initialFrame)); + EXPECT_FLOAT_EQ(NSMaxY(finalFrame), NSMaxY(initialFrame)); + EXPECT_FLOAT_EQ(NSHeight(finalFrame), NSHeight(initialFrame) + 40); + [controller_ adjustWindowHeightBy:-40]; + finalFrame = [window frame]; + EXPECT_FLOAT_EQ(NSMaxY(finalFrame), NSMaxY(initialFrame)); + EXPECT_FLOAT_EQ(NSHeight(finalFrame), NSHeight(initialFrame)); + + // Place the window at the bottom of the screen and try again. Its height + // should still change, but it should not grow down below the work area; it + // should instead move upwards. Then shrink it and make sure it goes back to + // the way it was. + initialFrame = NSMakeRect(workarea.origin.x, workarea.origin.y, 200, 200); + [window setFrame:initialFrame display:YES]; + [controller_ resetWindowGrowthState]; + [controller_ adjustWindowHeightBy:40]; + finalFrame = [window frame]; + EXPECT_FALSE(NSEqualRects(finalFrame, initialFrame)); + EXPECT_FLOAT_EQ(NSMinY(finalFrame), NSMinY(initialFrame)); + EXPECT_FLOAT_EQ(NSHeight(finalFrame), NSHeight(initialFrame) + 40); + [controller_ adjustWindowHeightBy:-40]; + finalFrame = [window frame]; + EXPECT_FLOAT_EQ(NSMinY(finalFrame), NSMinY(initialFrame)); + EXPECT_FLOAT_EQ(NSHeight(finalFrame), NSHeight(initialFrame)); + + // Put the window slightly offscreen and try again. The height should not + // change this time. + initialFrame = NSMakeRect(workarea.origin.x - 10, 0, 200, 200); + [window setFrame:initialFrame display:YES]; + [controller_ resetWindowGrowthState]; + [controller_ adjustWindowHeightBy:40]; + EXPECT_TRUE(NSEqualRects([window frame], initialFrame)); + [controller_ adjustWindowHeightBy:-40]; + EXPECT_TRUE(NSEqualRects([window frame], initialFrame)); + + // Make the window the same size as the workarea. Resizing both larger and + // smaller should have no effect. + [window setFrame:workarea display:YES]; + [controller_ resetWindowGrowthState]; + [controller_ adjustWindowHeightBy:40]; + EXPECT_TRUE(NSEqualRects([window frame], workarea)); + [controller_ adjustWindowHeightBy:-40]; + EXPECT_TRUE(NSEqualRects([window frame], workarea)); + + // Make the window smaller than the workarea and place it near the bottom of + // the workarea. The window should grow down until it hits the bottom and + // then continue to grow up. Then shrink it, and it should return to where it + // was. + initialFrame = NSMakeRect(workarea.origin.x, workarea.origin.y + 5, + 200, 200); + [window setFrame:initialFrame display:YES]; + [controller_ resetWindowGrowthState]; + [controller_ adjustWindowHeightBy:40]; + finalFrame = [window frame]; + EXPECT_FLOAT_EQ(NSMinY(workarea), NSMinY(finalFrame)); + EXPECT_FLOAT_EQ(NSHeight(finalFrame), NSHeight(initialFrame) + 40); + [controller_ adjustWindowHeightBy:-40]; + finalFrame = [window frame]; + EXPECT_FLOAT_EQ(NSMinY(initialFrame), NSMinY(finalFrame)); + EXPECT_FLOAT_EQ(NSHeight(initialFrame), NSHeight(finalFrame)); + + // Inset the window slightly from the workarea. It should not grow to be + // larger than the workarea. Shrink it; it should return to where it started. + initialFrame = NSInsetRect(workarea, 0, 5); + [window setFrame:initialFrame display:YES]; + [controller_ resetWindowGrowthState]; + [controller_ adjustWindowHeightBy:40]; + finalFrame = [window frame]; + EXPECT_FLOAT_EQ(NSMinY(workarea), NSMinY(finalFrame)); + EXPECT_FLOAT_EQ(NSHeight(workarea), NSHeight(finalFrame)); + [controller_ adjustWindowHeightBy:-40]; + finalFrame = [window frame]; + EXPECT_FLOAT_EQ(NSMinY(initialFrame), NSMinY(finalFrame)); + EXPECT_FLOAT_EQ(NSHeight(initialFrame), NSHeight(finalFrame)); + + // Place the window at the bottom of the screen and grow; it should grow + // upwards. Move the window off the bottom, then shrink. It should then shrink + // from the bottom. + initialFrame = NSMakeRect(workarea.origin.x, workarea.origin.y, 200, 200); + [window setFrame:initialFrame display:YES]; + [controller_ resetWindowGrowthState]; + [controller_ adjustWindowHeightBy:40]; + finalFrame = [window frame]; + EXPECT_FALSE(NSEqualRects(finalFrame, initialFrame)); + EXPECT_FLOAT_EQ(NSMinY(finalFrame), NSMinY(initialFrame)); + EXPECT_FLOAT_EQ(NSHeight(finalFrame), NSHeight(initialFrame) + 40); + NSPoint oldOrigin = initialFrame.origin; + NSPoint newOrigin = NSMakePoint(oldOrigin.x, oldOrigin.y + 10); + [window setFrameOrigin:newOrigin]; + initialFrame = [window frame]; + EXPECT_FLOAT_EQ(NSMinY(initialFrame), oldOrigin.y + 10); + [controller_ adjustWindowHeightBy:-40]; + finalFrame = [window frame]; + EXPECT_FLOAT_EQ(NSMinY(finalFrame), NSMinY(initialFrame) + 40); + EXPECT_FLOAT_EQ(NSHeight(finalFrame), NSHeight(initialFrame) - 40); + + // Do the "inset" test above, but using multiple calls to + // |-adjustWindowHeightBy|; the result should be the same. + initialFrame = NSInsetRect(workarea, 0, 5); + [window setFrame:initialFrame display:YES]; + [controller_ resetWindowGrowthState]; + for (int i = 0; i < 8; i++) + [controller_ adjustWindowHeightBy:5]; + finalFrame = [window frame]; + EXPECT_FLOAT_EQ(NSMinY(workarea), NSMinY(finalFrame)); + EXPECT_FLOAT_EQ(NSHeight(workarea), NSHeight(finalFrame)); + for (int i = 0; i < 8; i++) + [controller_ adjustWindowHeightBy:-5]; + finalFrame = [window frame]; + EXPECT_FLOAT_EQ(NSMinY(initialFrame), NSMinY(finalFrame)); + EXPECT_FLOAT_EQ(NSHeight(initialFrame), NSHeight(finalFrame)); +} + +// Test to make sure resizing and relaying-out subviews works correctly. +TEST_F(BrowserWindowControllerTest, TestResizeViews) { + TabStripView* tabstrip = [controller_ tabStripView]; + NSView* contentView = [[tabstrip window] contentView]; + NSView* toolbar = [controller_ toolbarView]; + NSView* infobar = [controller_ infoBarContainerView]; + + // We need to muck with the views a bit to put us in a consistent state before + // we start resizing. In particular, we need to move the tab strip to be + // immediately above the content area, since we layout views to be directly + // under the tab strip. + NSRect tabstripFrame = [tabstrip frame]; + tabstripFrame.origin.y = NSMaxY([contentView frame]); + [tabstrip setFrame:tabstripFrame]; + + // The download shelf is created lazily. Force-create it and set its initial + // height to 0. + NSView* download = [[controller_ downloadShelf] view]; + NSRect downloadFrame = [download frame]; + downloadFrame.size.height = 0; + [download setFrame:downloadFrame]; + + // Force a layout and check each view's frame. + [controller_ layoutSubviews]; + CheckViewPositions(controller_); + + // Expand the infobar to 60px and recheck + [controller_ resizeView:infobar newHeight:60]; + CheckViewPositions(controller_); + + // Expand the toolbar to 64px and recheck + [controller_ resizeView:toolbar newHeight:64]; + CheckViewPositions(controller_); + + // Add a 30px download shelf and recheck + [controller_ resizeView:download newHeight:30]; + CheckViewPositions(controller_); + + // Shrink the infobar to 0px and toolbar to 39px and recheck + [controller_ resizeView:infobar newHeight:0]; + [controller_ resizeView:toolbar newHeight:39]; + CheckViewPositions(controller_); +} + +TEST_F(BrowserWindowControllerTest, TestResizeViewsWithBookmarkBar) { + // Force a display of the bookmark bar. + browser_helper_.profile()->GetPrefs()-> + SetBoolean(prefs::kShowBookmarkBar, true); + [controller_ updateBookmarkBarVisibilityWithAnimation:NO]; + + TabStripView* tabstrip = [controller_ tabStripView]; + NSView* contentView = [[tabstrip window] contentView]; + NSView* toolbar = [controller_ toolbarView]; + NSView* bookmark = [controller_ bookmarkView]; + NSView* infobar = [controller_ infoBarContainerView]; + + // We need to muck with the views a bit to put us in a consistent state before + // we start resizing. In particular, we need to move the tab strip to be + // immediately above the content area, since we layout views to be directly + // under the tab strip. + NSRect tabstripFrame = [tabstrip frame]; + tabstripFrame.origin.y = NSMaxY([contentView frame]); + [tabstrip setFrame:tabstripFrame]; + + // The download shelf is created lazily. Force-create it and set its initial + // height to 0. + NSView* download = [[controller_ downloadShelf] view]; + NSRect downloadFrame = [download frame]; + downloadFrame.size.height = 0; + [download setFrame:downloadFrame]; + + // Force a layout and check each view's frame. + [controller_ layoutSubviews]; + CheckViewPositions(controller_); + + // Add the bookmark bar and recheck. + [controller_ resizeView:bookmark newHeight:40]; + CheckViewPositions(controller_); + + // Expand the infobar to 60px and recheck + [controller_ resizeView:infobar newHeight:60]; + CheckViewPositions(controller_); + + // Expand the toolbar to 64px and recheck + [controller_ resizeView:toolbar newHeight:64]; + CheckViewPositions(controller_); + + // Add a 30px download shelf and recheck + [controller_ resizeView:download newHeight:30]; + CheckViewPositions(controller_); + + // Remove the bookmark bar and recheck + browser_helper_.profile()->GetPrefs()-> + SetBoolean(prefs::kShowBookmarkBar, false); + [controller_ resizeView:bookmark newHeight:0]; + CheckViewPositions(controller_); + + // Shrink the infobar to 0px and toolbar to 39px and recheck + [controller_ resizeView:infobar newHeight:0]; + [controller_ resizeView:toolbar newHeight:39]; + CheckViewPositions(controller_); +} + +// Make sure, by default, the bookmark bar and the toolbar are the same width. +TEST_F(BrowserWindowControllerTest, BookmarkBarIsSameWidth) { + // Set the pref to the bookmark bar is visible when the toolbar is + // first created. + browser_helper_.profile()->GetPrefs()->SetBoolean( + prefs::kShowBookmarkBar, true); + + // Make sure the bookmark bar is the same width as the toolbar + NSView* bookmarkBarView = [controller_ bookmarkView]; + NSView* toolbarView = [controller_ toolbarView]; + EXPECT_EQ([toolbarView frame].size.width, + [bookmarkBarView frame].size.width); +} + +TEST_F(BrowserWindowControllerTest, TestTopRightForBubble) { + NSPoint p = [controller_ bookmarkBubblePoint]; + NSRect all = [[controller_ window] frame]; + + // As a sanity check make sure the point is vaguely in the top right + // of the window. + EXPECT_GT(p.y, all.origin.y + (all.size.height/2)); + EXPECT_GT(p.x, all.origin.x + (all.size.width/2)); +} + +// By the "zoom frame", we mean what Apple calls the "standard frame". +TEST_F(BrowserWindowControllerTest, TestZoomFrame) { + NSWindow* window = [controller_ window]; + ASSERT_TRUE(window); + NSRect screenFrame = [[window screen] visibleFrame]; + ASSERT_FALSE(NSIsEmptyRect(screenFrame)); + + // Minimum zoomed width is the larger of 60% of available horizontal space or + // 60% of available vertical space, subject to available horizontal space. + CGFloat minZoomWidth = + std::min(std::max((CGFloat)0.6 * screenFrame.size.width, + (CGFloat)0.6 * screenFrame.size.height), + screenFrame.size.width); + + // |testFrame| is the size of the window we start out with, and |zoomFrame| is + // the one returned by |-windowWillUseStandardFrame:defaultFrame:|. + NSRect testFrame; + NSRect zoomFrame; + + // 1. Test a case where it zooms the window both horizontally and vertically, + // and only moves it vertically. "+ 32", etc. are just arbitrary constants + // used to check that the window is moved properly and not just to the origin; + // they should be small enough to not shove windows off the screen. + testFrame.size.width = 0.5 * minZoomWidth; + testFrame.size.height = 0.5 * screenFrame.size.height; + testFrame.origin.x = screenFrame.origin.x + 32; // See above. + testFrame.origin.y = screenFrame.origin.y + 23; + [window setFrame:testFrame display:NO]; + zoomFrame = [controller_ windowWillUseStandardFrame:window + defaultFrame:screenFrame]; + EXPECT_LE(minZoomWidth, zoomFrame.size.width); + EXPECT_EQ(screenFrame.size.height, zoomFrame.size.height); + EXPECT_EQ(testFrame.origin.x, zoomFrame.origin.x); + EXPECT_EQ(screenFrame.origin.y, zoomFrame.origin.y); + + // 2. Test a case where it zooms the window only horizontally, and only moves + // it horizontally. + testFrame.size.width = 0.5 * minZoomWidth; + testFrame.size.height = screenFrame.size.height; + testFrame.origin.x = screenFrame.origin.x + screenFrame.size.width - + testFrame.size.width; + testFrame.origin.y = screenFrame.origin.y; + [window setFrame:testFrame display:NO]; + zoomFrame = [controller_ windowWillUseStandardFrame:window + defaultFrame:screenFrame]; + EXPECT_LE(minZoomWidth, zoomFrame.size.width); + EXPECT_EQ(screenFrame.size.height, zoomFrame.size.height); + EXPECT_EQ(screenFrame.origin.x + screenFrame.size.width - + zoomFrame.size.width, zoomFrame.origin.x); + EXPECT_EQ(screenFrame.origin.y, zoomFrame.origin.y); + + // 3. Test a case where it zooms the window only vertically, and only moves it + // vertically. + testFrame.size.width = std::min((CGFloat)1.1 * minZoomWidth, + screenFrame.size.width); + testFrame.size.height = 0.3 * screenFrame.size.height; + testFrame.origin.x = screenFrame.origin.x + 32; // See above (in 1.). + testFrame.origin.y = screenFrame.origin.y + 123; + [window setFrame:testFrame display:NO]; + zoomFrame = [controller_ windowWillUseStandardFrame:window + defaultFrame:screenFrame]; + // Use the actual width of the window frame, since it's subject to rounding. + EXPECT_EQ([window frame].size.width, zoomFrame.size.width); + EXPECT_EQ(screenFrame.size.height, zoomFrame.size.height); + EXPECT_EQ(testFrame.origin.x, zoomFrame.origin.x); + EXPECT_EQ(screenFrame.origin.y, zoomFrame.origin.y); + + // 4. Test a case where zooming should do nothing (i.e., we're already at a + // zoomed frame). + testFrame.size.width = std::min((CGFloat)1.1 * minZoomWidth, + screenFrame.size.width); + testFrame.size.height = screenFrame.size.height; + testFrame.origin.x = screenFrame.origin.x + 32; // See above (in 1.). + testFrame.origin.y = screenFrame.origin.y; + [window setFrame:testFrame display:NO]; + zoomFrame = [controller_ windowWillUseStandardFrame:window + defaultFrame:screenFrame]; + // Use the actual width of the window frame, since it's subject to rounding. + EXPECT_EQ([window frame].size.width, zoomFrame.size.width); + EXPECT_EQ(screenFrame.size.height, zoomFrame.size.height); + EXPECT_EQ(testFrame.origin.x, zoomFrame.origin.x); + EXPECT_EQ(screenFrame.origin.y, zoomFrame.origin.y); +} + +TEST_F(BrowserWindowControllerTest, TestFindBarOnTop) { + FindBarBridge bridge; + [controller_ addFindBar:bridge.find_bar_cocoa_controller()]; + + // Test that the Z-order of the find bar is on top of everything. + NSArray* subviews = [[[controller_ window] contentView] subviews]; + NSUInteger findBar_index = + [subviews indexOfObject:[controller_ findBarView]]; + EXPECT_NE(NSNotFound, findBar_index); + NSUInteger toolbar_index = + [subviews indexOfObject:[controller_ toolbarView]]; + EXPECT_NE(NSNotFound, toolbar_index); + NSUInteger bookmark_index = + [subviews indexOfObject:[controller_ bookmarkView]]; + EXPECT_NE(NSNotFound, bookmark_index); + + EXPECT_GT(findBar_index, toolbar_index); + EXPECT_GT(findBar_index, bookmark_index); +} + +// Tests that the sidebar view and devtools view are both non-opaque. +TEST_F(BrowserWindowControllerTest, TestSplitViewsAreNotOpaque) { + // Add a subview to the sidebar view to mimic what happens when a tab is added + // to the window. NSSplitView only marks itself as non-opaque when one of its + // subviews is non-opaque, so the test will not pass without this subview. + scoped_nsobject<NSView> view( + [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 10, 10)]); + [[controller_ sidebarView] addSubview:view]; + + EXPECT_FALSE([[controller_ tabContentArea] isOpaque]); + EXPECT_FALSE([[controller_ devToolsView] isOpaque]); + EXPECT_FALSE([[controller_ sidebarView] isOpaque]); +} + +// Tests that status bubble's base frame does move when devTools are docked. +TEST_F(BrowserWindowControllerTest, TestStatusBubblePositioning) { + ASSERT_EQ(1U, [[[controller_ devToolsView] subviews] count]); + + NSPoint bubbleOrigin = [controller_ statusBubbleBaseFrame].origin; + + // Add a fake subview to devToolsView to emulate docked devTools. + scoped_nsobject<NSView> view( + [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 10, 10)]); + [[controller_ devToolsView] addSubview:view]; + [[controller_ devToolsView] adjustSubviews]; + + NSPoint bubbleOriginWithDevTools = [controller_ statusBubbleBaseFrame].origin; + + // Make sure that status bubble frame is moved. + EXPECT_FALSE(NSEqualPoints(bubbleOrigin, bubbleOriginWithDevTools)); +} + +@interface BrowserWindowControllerFakeFullscreen : BrowserWindowController { + @private + // We release the window ourselves, so we don't have to rely on the unittest + // doing it for us. + scoped_nsobject<NSWindow> fullscreenWindow_; +} +@end + +class BrowserWindowFullScreenControllerTest : public CocoaTest { + public: + virtual void SetUp() { + CocoaTest::SetUp(); + Browser* browser = browser_helper_.browser(); + controller_ = + [[BrowserWindowControllerFakeFullscreen alloc] initWithBrowser:browser + takeOwnership:NO]; + } + + virtual void TearDown() { + [controller_ close]; + CocoaTest::TearDown(); + } + + public: + BrowserTestHelper browser_helper_; + BrowserWindowController* controller_; +}; + +@interface BrowserWindowController (PrivateAPI) +- (BOOL)supportsFullscreen; +@end + +TEST_F(BrowserWindowFullScreenControllerTest, TestFullscreen) { + EXPECT_FALSE([controller_ isFullscreen]); + [controller_ setFullscreen:YES]; + EXPECT_TRUE([controller_ isFullscreen]); + [controller_ setFullscreen:NO]; + EXPECT_FALSE([controller_ isFullscreen]); +} + +// If this test fails, it is usually a sign that the bots have some sort of +// problem (such as a modal dialog up). This tests is a very useful canary, so +// please do not mark it as flaky without first verifying that there are no bot +// problems. +TEST_F(BrowserWindowFullScreenControllerTest, TestActivate) { + EXPECT_FALSE([controller_ isFullscreen]); + + [controller_ activate]; + NSWindow* frontmostWindow = [[NSApp orderedWindows] objectAtIndex:0]; + EXPECT_EQ(frontmostWindow, [controller_ window]); + + [controller_ setFullscreen:YES]; + [controller_ activate]; + frontmostWindow = [[NSApp orderedWindows] objectAtIndex:0]; + EXPECT_EQ(frontmostWindow, [controller_ createFullscreenWindow]); + + // We have to cleanup after ourselves by unfullscreening. + [controller_ setFullscreen:NO]; +} + +@implementation BrowserWindowControllerFakeFullscreen +// Override |-createFullscreenWindow| to return a dummy window. This isn't +// needed to pass the test, but because the dummy window is only 100x100, it +// prevents the real fullscreen window from flashing up and taking over the +// whole screen. We have to return an actual window because |-layoutSubviews| +// looks at the window's frame. +- (NSWindow*)createFullscreenWindow { + if (fullscreenWindow_.get()) + return fullscreenWindow_.get(); + + fullscreenWindow_.reset( + [[NSWindow alloc] initWithContentRect:NSMakeRect(0,0,400,400) + styleMask:NSBorderlessWindowMask + backing:NSBackingStoreBuffered + defer:NO]); + return fullscreenWindow_.get(); +} +@end + +/* TODO(???): test other methods of BrowserWindowController */ diff --git a/chrome/browser/ui/cocoa/browser_window_factory.mm b/chrome/browser/ui/cocoa/browser_window_factory.mm new file mode 100644 index 0000000..e7222e7 --- /dev/null +++ b/chrome/browser/ui/cocoa/browser_window_factory.mm @@ -0,0 +1,32 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/basictypes.h" +#include "chrome/browser/ui/cocoa/browser_window_cocoa.h" +#include "chrome/browser/ui/cocoa/browser_window_controller.h" +#include "chrome/browser/ui/cocoa/find_bar_bridge.h" +#include "chrome/browser/ui/browser.h" +#include "chrome/browser/ui/browser_window.h" + +// Create the controller for the Browser, which handles loading the browser +// window from the nib. The controller takes ownership of |browser|. +// static +BrowserWindow* BrowserWindow::CreateBrowserWindow(Browser* browser) { + BrowserWindowController* controller = + [[BrowserWindowController alloc] initWithBrowser:browser]; + return [controller browserWindow]; +} + +// static +FindBar* BrowserWindow::CreateFindBar(Browser* browser) { + // We could push the AddFindBar() call into the FindBarBridge + // constructor or the FindBarCocoaController init, but that makes + // unit testing difficult, since we would also require a + // BrowserWindow object. + BrowserWindowCocoa* window = + static_cast<BrowserWindowCocoa*>(browser->window()); + FindBarBridge* bridge = new FindBarBridge(); + window->AddFindBar(bridge->find_bar_cocoa_controller()); + return bridge; +} diff --git a/chrome/browser/ui/cocoa/bubble_view.h b/chrome/browser/ui/cocoa/bubble_view.h new file mode 100644 index 0000000..755c8a4 --- /dev/null +++ b/chrome/browser/ui/cocoa/bubble_view.h @@ -0,0 +1,66 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" + +// A view class that looks like a "bubble" with rounded corners and displays +// text inside. Can be themed. To put flush against the sides of a window, the +// corner flags can be adjusted. + +// Constants that define where the bubble will have a rounded corner. If +// not set, the corner will be square. +enum { + kRoundedTopLeftCorner = 1, + kRoundedTopRightCorner = 1 << 1, + kRoundedBottomLeftCorner = 1 << 2, + kRoundedBottomRightCorner = 1 << 3, + kRoundedAllCorners = kRoundedTopLeftCorner & kRoundedTopRightCorner & + kRoundedBottomLeftCorner & kRoundedBottomRightCorner +}; + +// Constants that affect where the text is positioned within the view. They +// are exposed in case anyone needs to use the padding to set the content string +// length appropriately based on available space (such as eliding a URL). +enum { + kBubbleViewTextPositionX = 4, + kBubbleViewTextPositionY = 2 +}; + +@interface BubbleView : NSView { + @private + scoped_nsobject<NSString> content_; + unsigned long cornerFlags_; + // The window from which we get the theme used to draw. In some cases, + // it might not be the window we're in. As a result, this may or may not + // directly own us, so it needs to be weak to prevent a cycle. + NSWindow* themeProvider_; +} + +// Designated initializer. |provider| is the window from which we get the +// current theme to draw text and backgrounds. If nil, the current window will +// be checked. The caller needs to ensure |provider| can't go away as it will +// not be retained. Defaults to all corners being rounded. +- (id)initWithFrame:(NSRect)frame themeProvider:(NSWindow*)provider; + +// Sets the string displayed in the bubble. A copy of the string is made. +- (void)setContent:(NSString*)content; + +// Sets which corners will be rounded. +- (void)setCornerFlags:(unsigned long)flags; + +// Sets the window whose theme is used to draw. +- (void)setThemeProvider:(NSWindow*)provider; + +// The font used to display the content string. +- (NSFont*)font; + +@end + +// APIs exposed only for testing. +@interface BubbleView(TestingOnly) +- (NSString*)content; +- (unsigned long)cornerFlags; +@end diff --git a/chrome/browser/ui/cocoa/bubble_view.mm b/chrome/browser/ui/cocoa/bubble_view.mm new file mode 100644 index 0000000..a888ebc --- /dev/null +++ b/chrome/browser/ui/cocoa/bubble_view.mm @@ -0,0 +1,120 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/bubble_view.h" + +#include "chrome/browser/themes/browser_theme_provider.h" +#import "chrome/browser/ui/cocoa/themed_window.h" +#import "third_party/GTM/AppKit/GTMNSBezierPath+RoundRect.h" +#import "third_party/GTM/AppKit/GTMNSColor+Luminance.h" + +// The roundedness of the edges of our bubble. +const int kBubbleCornerRadius = 4.0f; +const float kWindowEdge = 0.7f; + +@implementation BubbleView + +// Designated initializer. |provider| is the window from which we get the +// current theme to draw text and backgrounds. If nil, the current window will +// be checked. The caller needs to ensure |provider| can't go away as it will +// not be retained. Defaults to all corners being rounded. +- (id)initWithFrame:(NSRect)frame themeProvider:(NSWindow*)provider { + if ((self = [super initWithFrame:frame])) { + cornerFlags_ = kRoundedAllCorners; + themeProvider_ = provider; + } + return self; +} + +// Sets the string displayed in the bubble. A copy of the string is made. +- (void)setContent:(NSString*)content { + if ([content_ isEqualToString:content]) + return; + content_.reset([content copy]); + [self setNeedsDisplay:YES]; +} + +// Sets which corners will be rounded. +- (void)setCornerFlags:(unsigned long)flags { + if (cornerFlags_ == flags) + return; + cornerFlags_ = flags; + [self setNeedsDisplay:YES]; +} + +- (void)setThemeProvider:(NSWindow*)provider { + if (themeProvider_ == provider) + return; + themeProvider_ = provider; + [self setNeedsDisplay:YES]; +} + +- (NSString*)content { + return content_.get(); +} + +- (unsigned long)cornerFlags { + return cornerFlags_; +} + +// The font used to display the content string. +- (NSFont*)font { + return [NSFont systemFontOfSize:[NSFont smallSystemFontSize]]; +} + +// Draws the themed background and the text. Will draw a gray bg if no theme. +- (void)drawRect:(NSRect)rect { + float topLeftRadius = + cornerFlags_ & kRoundedTopLeftCorner ? kBubbleCornerRadius : 0; + float topRightRadius = + cornerFlags_ & kRoundedTopRightCorner ? kBubbleCornerRadius : 0; + float bottomLeftRadius = + cornerFlags_ & kRoundedBottomLeftCorner ? kBubbleCornerRadius : 0; + float bottomRightRadius = + cornerFlags_ & kRoundedBottomRightCorner ? kBubbleCornerRadius : 0; + + ThemeProvider* themeProvider = + themeProvider_ ? [themeProvider_ themeProvider] : + [[self window] themeProvider]; + + // Background / Edge + + NSRect bounds = [self bounds]; + bounds = NSInsetRect(bounds, 0.5, 0.5); + NSBezierPath* border = + [NSBezierPath gtm_bezierPathWithRoundRect:bounds + topLeftCornerRadius:topLeftRadius + topRightCornerRadius:topRightRadius + bottomLeftCornerRadius:bottomLeftRadius + bottomRightCornerRadius:bottomRightRadius]; + + if (themeProvider) + [themeProvider->GetNSColor(BrowserThemeProvider::COLOR_TOOLBAR, true) set]; + [border fill]; + + [[NSColor colorWithDeviceWhite:kWindowEdge alpha:1.0f] set]; + [border stroke]; + + // Text + NSColor* textColor = [NSColor blackColor]; + if (themeProvider) + textColor = themeProvider->GetNSColor(BrowserThemeProvider::COLOR_TAB_TEXT, + true); + NSFont* textFont = [self font]; + scoped_nsobject<NSShadow> textShadow([[NSShadow alloc] init]); + [textShadow setShadowBlurRadius:0.0f]; + [textShadow.get() setShadowColor:[textColor gtm_legibleTextColor]]; + [textShadow.get() setShadowOffset:NSMakeSize(0.0f, -1.0f)]; + + NSDictionary* textDict = [NSDictionary dictionaryWithObjectsAndKeys: + textColor, NSForegroundColorAttributeName, + textFont, NSFontAttributeName, + textShadow.get(), NSShadowAttributeName, + nil]; + [content_ drawAtPoint:NSMakePoint(kBubbleViewTextPositionX, + kBubbleViewTextPositionY) + withAttributes:textDict]; +} + +@end diff --git a/chrome/browser/ui/cocoa/bubble_view_unittest.mm b/chrome/browser/ui/cocoa/bubble_view_unittest.mm new file mode 100644 index 0000000..5d788ea --- /dev/null +++ b/chrome/browser/ui/cocoa/bubble_view_unittest.mm @@ -0,0 +1,58 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/bubble_view.h" +#include "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "testing/gtest_mac.h" + +class BubbleViewTest : public CocoaTest { + public: + BubbleViewTest() { + NSRect frame = NSMakeRect(0, 0, 50, 50); + scoped_nsobject<BubbleView> view( + [[BubbleView alloc] initWithFrame:frame themeProvider:test_window()]); + view_ = view.get(); + [[test_window() contentView] addSubview:view_]; + [view_ setContent:@"Hi there, I'm a bubble view"]; + } + + BubbleView* view_; +}; + +TEST_VIEW(BubbleViewTest, view_); + +// Test a nil themeProvider in init. +TEST_F(BubbleViewTest, NilThemeProvider) { + NSRect frame = NSMakeRect(0, 0, 50, 50); + scoped_nsobject<BubbleView> view( + [[BubbleView alloc] initWithFrame:frame themeProvider:nil]); + [[test_window() contentView] addSubview:view.get()]; + [view display]; +} + +// Make sure things don't go haywire when given invalid or long strings. +TEST_F(BubbleViewTest, SetContent) { + [view_ setContent:nil]; + EXPECT_TRUE([view_ content] == nil); + [view_ setContent:@""]; + EXPECT_NSEQ(@"", [view_ content]); + NSString* str = @"This is a really really long string that's just too long"; + [view_ setContent:str]; + EXPECT_NSEQ(str, [view_ content]); +} + +TEST_F(BubbleViewTest, CornerFlags) { + // Set some random flags just to check. + [view_ setCornerFlags:kRoundedTopRightCorner | kRoundedTopLeftCorner]; + EXPECT_EQ([view_ cornerFlags], + (unsigned long)kRoundedTopRightCorner | kRoundedTopLeftCorner); + // Set no flags (all 4 draw corners are square). + [view_ setCornerFlags:0]; + EXPECT_EQ([view_ cornerFlags], 0UL); + // Set all bits. Meaningless past the first 4, but harmless to set too many. + [view_ setCornerFlags:0xFFFFFFFF]; + EXPECT_EQ([view_ cornerFlags], 0xFFFFFFFF); +} diff --git a/chrome/browser/ui/cocoa/bug_report_window_controller.h b/chrome/browser/ui/cocoa/bug_report_window_controller.h new file mode 100644 index 0000000..15aa707 --- /dev/null +++ b/chrome/browser/ui/cocoa/bug_report_window_controller.h @@ -0,0 +1,112 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_BUG_REPORT_WINDOW_CONTROLLER_H_ +#define CHROME_BROWSER_UI_COCOA_BUG_REPORT_WINDOW_CONTROLLER_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include <vector> + +#include "base/scoped_nsobject.h" + +class Profile; +class TabContents; + +// A window controller for managing the "Report Bug" feature. Modally +// presents a dialog that allows the user to either file a bug report on +// a broken page, or go directly to Google's "Report Phishing" page and +// file a report there. +@interface BugReportWindowController : NSWindowController { + @private + TabContents* currentTab_; // Weak, owned by browser. + Profile* profile_; // Weak, owned by browser. + + // Holds screenshot of current tab. + std::vector<unsigned char> pngData_; + // Width and height of the current tab's screenshot. + int pngWidth_; + int pngHeight_; + + // Values bound to data in the dialog box. These values cannot be boxed in + // scoped_nsobjects because we use them for bindings. + NSString* bugDescription_; // Strong. + NSUInteger bugTypeIndex_; + NSString* pageTitle_; // Strong. + NSString* pageURL_; // Strong. + + // We keep a pointer to this button so we can change its title. + IBOutlet NSButton* sendReportButton_; + + // This button must be moved when the send report button changes title. + IBOutlet NSButton* cancelButton_; + + // The popup button that allows choice of bug type. + IBOutlet NSPopUpButton* bugTypePopUpButton_; + + // YES sends a screenshot along with the bug report. + BOOL sendScreenshot_; + + // Disable screenshot if no browser window is open. + BOOL disableScreenshotCheckbox_; + + // Menu for the bug type popup button. We create it here instead of in + // IB so that we can nicely check whether the phishing page is selected, + // and so that we can create a menu without "page" options when no browser + // window is open. + NSMutableArray* bugTypeList_; // Strong. + + // When dialog switches from regular bug reports to phishing page, "save + // screenshot" and "description" are disabled. Save the state of this value + // to restore if the user switches back to a regular bug report before + // sending. + BOOL saveSendScreenshot_; + scoped_nsobject<NSString> saveBugDescription_; // Strong + + // Maps bug type menu item title strings to BugReportUtil::BugType ints. + NSDictionary* bugTypeDictionary_; // Strong +} + +// Initialize with the contents of the tab to be reported as buggy / wrong. +// If dialog is called without an open window, currentTab may be null; in +// that case, a dialog is opened with options for reporting a bugs not +// related to a specific page. Profile is passed to BugReportUtil, who +// will not send a report if the value is null. +- (id)initWithTabContents:(TabContents*)currentTab profile:(Profile*)profile; + +// Run the dialog with an application-modal event loop. If the user accepts, +// send the report of the bug or broken web site. +- (void)runModalDialog; + +// IBActions for the dialog buttons. +- (IBAction)sendReport:(id)sender; +- (IBAction)cancel:(id)sender; + +// YES if the user has selected the phishing report option. +- (BOOL)isPhishingReport; + +// Converts the bug type from the menu into the correct value for the bug type +// from BugReportUtil::BugType. +- (int)bugTypeFromIndex; + +// Force the description text field to allow "return" to go to the next line +// within the description field. Without this delegate method, "return" falls +// back to the "Send Report" action, because this button has been bound to +// the return key in IB. +- (BOOL)control:(NSControl*)control textView:(NSTextView*)textView + doCommandBySelector:(SEL)commandSelector; + +// Properties for bindings. +@property (nonatomic, copy) NSString* bugDescription; +@property (nonatomic) NSUInteger bugTypeIndex; +@property (nonatomic, copy) NSString* pageTitle; +@property (nonatomic, copy) NSString* pageURL; +@property (nonatomic) BOOL sendScreenshot; +@property (nonatomic) BOOL disableScreenshotCheckbox; +@property (nonatomic, readonly) NSArray* bugTypeList; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_BUG_REPORT_WINDOW_CONTROLLER_H_ diff --git a/chrome/browser/ui/cocoa/bug_report_window_controller.mm b/chrome/browser/ui/cocoa/bug_report_window_controller.mm new file mode 100644 index 0000000..82b3a36 --- /dev/null +++ b/chrome/browser/ui/cocoa/bug_report_window_controller.mm @@ -0,0 +1,231 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/bug_report_window_controller.h" + +#include "app/l10n_util_mac.h" +#include "base/mac_util.h" +#include "base/sys_string_conversions.h" +#include "chrome/browser/bug_report_util.h" +#include "chrome/browser/tab_contents/tab_contents.h" +#include "chrome/browser/tab_contents/tab_contents_view.h" +#include "grit/chromium_strings.h" +#include "grit/generated_resources.h" +#include "third_party/GTM/AppKit/GTMUILocalizerAndLayoutTweaker.h" + +@implementation BugReportWindowController + +@synthesize bugDescription = bugDescription_; +@synthesize bugTypeIndex = bugTypeIndex_; +@synthesize pageURL = pageURL_; +@synthesize pageTitle = pageTitle_; +@synthesize sendScreenshot = sendScreenshot_; +@synthesize disableScreenshotCheckbox = disableScreenshotCheckbox_; +@synthesize bugTypeList = bugTypeList_; + +- (id)initWithTabContents:(TabContents*)currentTab + profile:(Profile*)profile { + NSString* nibpath = [mac_util::MainAppBundle() pathForResource:@"ReportBug" + ofType:@"nib"]; + if ((self = [super initWithWindowNibPath:nibpath owner:self])) { + currentTab_ = currentTab; + profile_ = profile; + + // The order of strings in this array must match the order of the bug types + // declared below in the bugTypeFromIndex function. + bugTypeList_ = [[NSMutableArray alloc] initWithObjects: + l10n_util::GetNSStringWithFixup(IDS_BUGREPORT_CHROME_MISBEHAVES), + l10n_util::GetNSStringWithFixup(IDS_BUGREPORT_SOMETHING_MISSING), + l10n_util::GetNSStringWithFixup(IDS_BUGREPORT_BROWSER_CRASH), + l10n_util::GetNSStringWithFixup(IDS_BUGREPORT_OTHER_PROBLEM), + nil]; + + if (currentTab_ != NULL) { + // Get data from current tab, if one exists. This dialog could be called + // from the main menu with no tab contents, so currentTab_ is not + // guaranteed to be non-NULL. + // TODO(mirandac): This dialog should be a tab-modal sheet if a browser + // window exists. + [self setSendScreenshot:YES]; + [self setDisableScreenshotCheckbox:NO]; + // Insert menu items about bugs related to specific pages. + [bugTypeList_ insertObjects: + [NSArray arrayWithObjects: + l10n_util::GetNSStringWithFixup(IDS_BUGREPORT_PAGE_WONT_LOAD), + l10n_util::GetNSStringWithFixup(IDS_BUGREPORT_PAGE_LOOKS_ODD), + l10n_util::GetNSStringWithFixup(IDS_BUGREPORT_PHISHING_PAGE), + l10n_util::GetNSStringWithFixup(IDS_BUGREPORT_CANT_SIGN_IN), + nil] + atIndexes:[NSIndexSet indexSetWithIndexesInRange:NSMakeRange(0, 4)]]; + + [self setPageURL:base::SysUTF8ToNSString( + currentTab_->controller().GetActiveEntry()->url().spec())]; + [self setPageTitle:base::SysUTF16ToNSString(currentTab_->GetTitle())]; + mac_util::GrabWindowSnapshot( + currentTab_->view()->GetTopLevelNativeWindow(), &pngData_, + &pngWidth_, &pngHeight_); + } else { + // If no current tab exists, create a menu without the "broken page" + // options, with page URL and title empty, and screenshot disabled. + [self setSendScreenshot:NO]; + [self setDisableScreenshotCheckbox:YES]; + } + + pngHeight_ = 0; + pngWidth_ = 0; + } + return self; +} + +- (void)dealloc { + [pageURL_ release]; + [pageTitle_ release]; + [bugDescription_ release]; + [bugTypeList_ release]; + [bugTypeDictionary_ release]; + [super dealloc]; +} + +// Delegate callback so that closing the window deletes the controller. +- (void)windowWillClose:(NSNotification*)notification { + [self autorelease]; +} + +- (void)closeDialog { + [NSApp stopModal]; + [[self window] close]; +} + +- (void)runModalDialog { + NSWindow* bugReportWindow = [self window]; + [bugReportWindow center]; + [NSApp runModalForWindow:bugReportWindow]; +} + +- (IBAction)sendReport:(id)sender { + if ([self isPhishingReport]) { + BugReportUtil::ReportPhishing(currentTab_, + pageURL_ ? base::SysNSStringToUTF8(pageURL_) : ""); + } else { + BugReportUtil::SendReport( + profile_, + base::SysNSStringToUTF8(pageTitle_), + [self bugTypeFromIndex], + base::SysNSStringToUTF8(pageURL_), + base::SysNSStringToUTF8(bugDescription_), + sendScreenshot_ && !pngData_.empty() ? + reinterpret_cast<const char *>(&(pngData_[0])) : NULL, + pngData_.size(), pngWidth_, pngHeight_); + } + [self closeDialog]; +} + +- (IBAction)cancel:(id)sender { + [self closeDialog]; +} + +- (BOOL)isPhishingReport { + return [self bugTypeFromIndex] == BugReportUtil::PHISHING_PAGE; +} + +- (int)bugTypeFromIndex { + // The order of these bugs must match the ordering in the bugTypeList_, + // and thereby the menu in the popup button in the dialog box. + const BugReportUtil::BugType typesForMenuIndices[] = { + BugReportUtil::PAGE_WONT_LOAD, + BugReportUtil::PAGE_LOOKS_ODD, + BugReportUtil::PHISHING_PAGE, + BugReportUtil::CANT_SIGN_IN, + BugReportUtil::CHROME_MISBEHAVES, + BugReportUtil::SOMETHING_MISSING, + BugReportUtil::BROWSER_CRASH, + BugReportUtil::OTHER_PROBLEM + }; + // The bugs for the shorter menu start at index 4. + NSUInteger adjustedBugTypeIndex_ = [bugTypeList_ count] == 8 ? bugTypeIndex_ : + bugTypeIndex_ + 4; + DCHECK_LT(adjustedBugTypeIndex_, arraysize(typesForMenuIndices)); + return typesForMenuIndices[adjustedBugTypeIndex_]; +} + +// Custom setter to update the UI for different bug types. +- (void)setBugTypeIndex:(NSUInteger)bugTypeIndex { + bugTypeIndex_ = bugTypeIndex; + + // The "send" button's title is based on the type of report. + NSString* buttonTitle = [self isPhishingReport] ? + l10n_util::GetNSStringWithFixup(IDS_BUGREPORT_SEND_PHISHING_REPORT) : + l10n_util::GetNSStringWithFixup(IDS_BUGREPORT_SEND_REPORT); + if (![buttonTitle isEqualTo:[sendReportButton_ title]]) { + NSRect sendFrame1 = [sendReportButton_ frame]; + NSRect cancelFrame1 = [cancelButton_ frame]; + + [sendReportButton_ setTitle:buttonTitle]; + CGFloat deltaWidth = + [GTMUILocalizerAndLayoutTweaker sizeToFitView:sendReportButton_].width; + + NSRect sendFrame2 = [sendReportButton_ frame]; + sendFrame2.origin.x -= deltaWidth; + NSRect cancelFrame2 = cancelFrame1; + cancelFrame2.origin.x -= deltaWidth; + + // Since the buttons get updated/resize, use a quick animation so it is + // a little less jarring in the UI. + NSDictionary* sendReportButtonResize = + [NSDictionary dictionaryWithObjectsAndKeys: + sendReportButton_, NSViewAnimationTargetKey, + [NSValue valueWithRect:sendFrame1], NSViewAnimationStartFrameKey, + [NSValue valueWithRect:sendFrame2], NSViewAnimationEndFrameKey, + nil]; + NSDictionary* cancelButtonResize = + [NSDictionary dictionaryWithObjectsAndKeys: + cancelButton_, NSViewAnimationTargetKey, + [NSValue valueWithRect:cancelFrame1], NSViewAnimationStartFrameKey, + [NSValue valueWithRect:cancelFrame2], NSViewAnimationEndFrameKey, + nil]; + NSAnimation* animation = + [[[NSViewAnimation alloc] initWithViewAnimations: + [NSArray arrayWithObjects:sendReportButtonResize, cancelButtonResize, + nil]] autorelease]; + const NSTimeInterval kQuickTransitionInterval = 0.1; + [animation setDuration:kQuickTransitionInterval]; + [animation startAnimation]; + + // Save or reload description when moving between phishing page and other + // bug report types. + if ([self isPhishingReport]) { + saveBugDescription_.reset([[self bugDescription] retain]); + [self setBugDescription:nil]; + saveSendScreenshot_ = sendScreenshot_; + [self setSendScreenshot:NO]; + } else { + [self setBugDescription:saveBugDescription_.get()]; + saveBugDescription_.reset(); + [self setSendScreenshot:saveSendScreenshot_]; + } + } +} + +- (BOOL)control:(NSControl*)control textView:(NSTextView*)textView + doCommandBySelector:(SEL)commandSelector { + if (commandSelector == @selector(insertNewline:)) { + [textView insertNewlineIgnoringFieldEditor:self]; + return YES; + } + return NO; +} + +// BugReportWindowController needs to change the title of the Send Report +// button when the user chooses the phishing bug type, so we need to bind +// the function that changes the button title to the bug type key. ++ (NSSet*)keyPathsForValuesAffectingValueForKey:(NSString*)key { + NSSet* paths = [super keyPathsForValuesAffectingValueForKey:key]; + if ([key isEqualToString:@"isPhishingReport"]) { + paths = [paths setByAddingObject:@"bugTypeIndex"]; + } + return paths; +} + +@end + diff --git a/chrome/browser/ui/cocoa/bug_report_window_controller_unittest.mm b/chrome/browser/ui/cocoa/bug_report_window_controller_unittest.mm new file mode 100644 index 0000000..b95f251 --- /dev/null +++ b/chrome/browser/ui/cocoa/bug_report_window_controller_unittest.mm @@ -0,0 +1,78 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/ref_counted.h" +#include "chrome/browser/browser_thread.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/renderer_host/site_instance.h" +#include "chrome/browser/renderer_host/test/test_render_view_host.h" +#include "chrome/browser/tab_contents/test_tab_contents.h" +#import "chrome/browser/ui/cocoa/bug_report_window_controller.h" +#include "chrome/test/testing_profile.h" +#import "testing/gtest_mac.h" + +namespace { + +class BugReportWindowControllerUnittest : public RenderViewHostTestHarness { +}; + +// See http://crbug.com/29019 for why it's disabled. +TEST_F(BugReportWindowControllerUnittest, DISABLED_ReportBugWithNewTabPageOpen) { + BrowserThread ui_thread(BrowserThread::UI, MessageLoop::current()); + // Create a "chrome://newtab" test tab. SiteInstance will be deleted when + // tabContents is deleted. + SiteInstance* instance = + SiteInstance::CreateSiteInstance(profile_.get()); + TestTabContents* tabContents = new TestTabContents(profile_.get(), + instance); + tabContents->controller().LoadURL(GURL("chrome://newtab"), + GURL(), PageTransition::START_PAGE); + + BugReportWindowController* controller = [[BugReportWindowController alloc] + initWithTabContents:tabContents + profile:profile_.get()]; + + // The phishing report bug is stored at index 2 in the Report Bug dialog. + [controller setBugTypeIndex:2]; + EXPECT_TRUE([controller isPhishingReport]); + [controller setBugTypeIndex:1]; + EXPECT_FALSE([controller isPhishingReport]); + + // Make sure that the tab was correctly recorded. + EXPECT_NSEQ(@"chrome://newtab/", [controller pageURL]); + EXPECT_NSEQ(@"New Tab", [controller pageTitle]); + + // When we call "report bug" with non-empty tab contents, all menu options + // should be available, and we should send screenshot by default. + EXPECT_EQ([[controller bugTypeList] count], 8U); + EXPECT_TRUE([controller sendScreenshot]); + + delete tabContents; + [controller release]; +} + +// See http://crbug.com/29019 for why it's disabled. +TEST_F(BugReportWindowControllerUnittest, DISABLED_ReportBugWithNoWindowOpen) { + BugReportWindowController* controller = [[BugReportWindowController alloc] + initWithTabContents:NULL + profile:profile_.get()]; + + // Make sure that no page title or URL are recorded. Note that IB reports + // empty textfields as NULL values. + EXPECT_FALSE([controller pageURL]); + EXPECT_FALSE([controller pageTitle]); + + // When we call "report bug" with empty tab contents, only menu options + // that don't refer to a specific page should be available, and the send + // screenshot option should be turned off. + EXPECT_EQ([[controller bugTypeList] count], 4U); + EXPECT_FALSE([controller sendScreenshot]); + + [controller release]; +} + +} // namespace + diff --git a/chrome/browser/ui/cocoa/certificate_viewer.mm b/chrome/browser/ui/cocoa/certificate_viewer.mm new file mode 100644 index 0000000..8c5a954 --- /dev/null +++ b/chrome/browser/ui/cocoa/certificate_viewer.mm @@ -0,0 +1,45 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/certificate_viewer.h" + +#include <Security/Security.h> +#include <SecurityInterface/SFCertificatePanel.h> + +#include <vector> + +#include "base/logging.h" +#include "base/mac/scoped_cftyperef.h" +#include "net/base/x509_certificate.h" + +void ShowCertificateViewer(gfx::NativeWindow parent, + net::X509Certificate* cert) { + SecCertificateRef cert_mac = cert->os_cert_handle(); + if (!cert_mac) + return; + + base::mac::ScopedCFTypeRef<CFMutableArrayRef> certificates( + CFArrayCreateMutable(kCFAllocatorDefault, 0, &kCFTypeArrayCallBacks)); + if (!certificates.get()) { + NOTREACHED(); + return; + } + CFArrayAppendValue(certificates, cert_mac); + + // Server certificate must be first in the array; subsequent certificates + // in the chain can be in any order. + const std::vector<SecCertificateRef>& ca_certs = + cert->GetIntermediateCertificates(); + for (size_t i = 0; i < ca_certs.size(); ++i) + CFArrayAppendValue(certificates, ca_certs[i]); + + [[[SFCertificatePanel alloc] init] + beginSheetForWindow:parent + modalDelegate:nil + didEndSelector:NULL + contextInfo:NULL + certificates:reinterpret_cast<NSArray*>(certificates.get()) + showGroup:YES]; + // The SFCertificatePanel releases itself when the sheet is dismissed. +} diff --git a/chrome/browser/ui/cocoa/chrome_browser_window.h b/chrome/browser/ui/cocoa/chrome_browser_window.h new file mode 100644 index 0000000..64c0123 --- /dev/null +++ b/chrome/browser/ui/cocoa/chrome_browser_window.h @@ -0,0 +1,28 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_CHROME_BROWSER_WINDOW_H_ +#define CHROME_BROWSER_UI_COCOA_CHROME_BROWSER_WINDOW_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "chrome/browser/ui/cocoa/chrome_event_processing_window.h" + +// Common base class for chrome browser windows. Contains methods relating to +// theming and hole punching that are shared between framed and fullscreen +// windows. +@interface ChromeBrowserWindow : ChromeEventProcessingWindow { + @private + int underlaySurfaceCount_; +} + +// Informs the window that an underlay surface has been added/removed. The +// window is non-opaque while underlay surfaces are present. +- (void)underlaySurfaceAdded; +- (void)underlaySurfaceRemoved; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_CHROME_BROWSER_WINDOW_H_ diff --git a/chrome/browser/ui/cocoa/chrome_browser_window.mm b/chrome/browser/ui/cocoa/chrome_browser_window.mm new file mode 100644 index 0000000..abac221 --- /dev/null +++ b/chrome/browser/ui/cocoa/chrome_browser_window.mm @@ -0,0 +1,52 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/chrome_browser_window.h" + +#include "base/logging.h" +#include "chrome/browser/themes/browser_theme_provider.h" +#import "chrome/browser/ui/cocoa/themed_window.h" + +@implementation ChromeBrowserWindow + +- (void)underlaySurfaceAdded { + DCHECK_GE(underlaySurfaceCount_, 0); + ++underlaySurfaceCount_; + + // We're having the OpenGL surface render under the window, so the window + // needs to be not opaque. + if (underlaySurfaceCount_ == 1) + [self setOpaque:NO]; +} + +- (void)underlaySurfaceRemoved { + --underlaySurfaceCount_; + DCHECK_GE(underlaySurfaceCount_, 0); + + if (underlaySurfaceCount_ == 0) + [self setOpaque:YES]; +} + +- (ThemeProvider*)themeProvider { + id delegate = [self delegate]; + if (![delegate respondsToSelector:@selector(themeProvider)]) + return NULL; + return [delegate themeProvider]; +} + +- (ThemedWindowStyle)themedWindowStyle { + id delegate = [self delegate]; + if (![delegate respondsToSelector:@selector(themedWindowStyle)]) + return THEMED_NORMAL; + return [delegate themedWindowStyle]; +} + +- (NSPoint)themePatternPhase { + id delegate = [self delegate]; + if (![delegate respondsToSelector:@selector(themePatternPhase)]) + return NSMakePoint(0, 0); + return [delegate themePatternPhase]; +} + +@end diff --git a/chrome/browser/ui/cocoa/chrome_browser_window_unittest.mm b/chrome/browser/ui/cocoa/chrome_browser_window_unittest.mm new file mode 100644 index 0000000..196dc74 --- /dev/null +++ b/chrome/browser/ui/cocoa/chrome_browser_window_unittest.mm @@ -0,0 +1,45 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/debug/debugger.h" +#import "chrome/browser/ui/cocoa/chrome_browser_window.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "testing/gtest/include/gtest/gtest.h" +#import "testing/gtest_mac.h" +#include "testing/platform_test.h" + +class ChromeBrowserWindowTest : public CocoaTest { + public: + virtual void SetUp() { + CocoaTest::SetUp(); + // Create a window. + const NSUInteger mask = NSTitledWindowMask | NSClosableWindowMask | + NSMiniaturizableWindowMask | NSResizableWindowMask; + window_ = [[ChromeBrowserWindow alloc] + initWithContentRect:NSMakeRect(0, 0, 800, 600) + styleMask:mask + backing:NSBackingStoreBuffered + defer:NO]; + if (base::debug::BeingDebugged()) { + [window_ orderFront:nil]; + } else { + [window_ orderBack:nil]; + } + } + + virtual void TearDown() { + [window_ close]; + CocoaTest::TearDown(); + } + + ChromeBrowserWindow* window_; +}; + +// Baseline test that the window creates, displays, closes, and +// releases. +TEST_F(ChromeBrowserWindowTest, ShowAndClose) { + [window_ display]; +} diff --git a/chrome/browser/ui/cocoa/chrome_event_processing_window.h b/chrome/browser/ui/cocoa/chrome_event_processing_window.h new file mode 100644 index 0000000..3524d6f1a --- /dev/null +++ b/chrome/browser/ui/cocoa/chrome_event_processing_window.h @@ -0,0 +1,49 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_CHROME_EVENT_PROCESSING_WINDOW_H_ +#define CHROME_BROWSER_UI_COCOA_CHROME_EVENT_PROCESSING_WINDOW_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" + +// Override NSWindow to access unhandled keyboard events (for command +// processing); subclassing NSWindow is the only method to do +// this. +@interface ChromeEventProcessingWindow : NSWindow { + @private + BOOL redispatchingEvent_; + BOOL eventHandled_; +} + +// Sends a key event to |NSApp sendEvent:|, but also makes sure that it's not +// short-circuited to the RWHV. This is used to send keyboard events to the menu +// and the cmd-` handler if a keyboard event comes back unhandled from the +// renderer. The event must be of type |NSKeyDown|, |NSKeyUp|, or +// |NSFlagsChanged|. +// Returns |YES| if |event| has been handled. +- (BOOL)redispatchKeyEvent:(NSEvent*)event; + +// See global_keyboard_shortcuts_mac.h for details on the next two functions. + +// Checks if |event| is a window keyboard shortcut. If so, dispatches it to the +// window controller's |executeCommand:| and returns |YES|. +- (BOOL)handleExtraWindowKeyboardShortcut:(NSEvent*)event; + +// Checks if |event| is a delayed window keyboard shortcut. If so, dispatches +// it to the window controller's |executeCommand:| and returns |YES|. +- (BOOL)handleDelayedWindowKeyboardShortcut:(NSEvent*)event; + +// Checks if |event| is a browser keyboard shortcut. If so, dispatches it to the +// window controller's |executeCommand:| and returns |YES|. +- (BOOL)handleExtraBrowserKeyboardShortcut:(NSEvent*)event; + +// Override, so we can handle global keyboard events. +- (BOOL)performKeyEquivalent:(NSEvent*)theEvent; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_CHROME_EVENT_PROCESSING_WINDOW_H_ diff --git a/chrome/browser/ui/cocoa/chrome_event_processing_window.mm b/chrome/browser/ui/cocoa/chrome_event_processing_window.mm new file mode 100644 index 0000000..be6591b --- /dev/null +++ b/chrome/browser/ui/cocoa/chrome_event_processing_window.mm @@ -0,0 +1,164 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/chrome_event_processing_window.h" + +#include "base/logging.h" +#import "chrome/browser/renderer_host/render_widget_host_view_mac.h" +#import "chrome/browser/ui/cocoa/browser_command_executor.h" +#import "chrome/browser/ui/cocoa/browser_frame_view.h" +#import "chrome/browser/ui/cocoa/tab_strip_controller.h" +#include "chrome/browser/global_keyboard_shortcuts_mac.h" + +typedef int (*KeyToCommandMapper)(bool, bool, bool, bool, int, unichar); + +@interface ChromeEventProcessingWindow () +// Duplicate the given key event, but changing the associated window. +- (NSEvent*)keyEventForWindow:(NSWindow*)window fromKeyEvent:(NSEvent*)event; +@end + +@implementation ChromeEventProcessingWindow + +- (BOOL)handleExtraKeyboardShortcut:(NSEvent*)event fromTable: + (KeyToCommandMapper)commandForKeyboardShortcut { + // Extract info from |event|. + NSUInteger modifers = [event modifierFlags]; + const bool cmdKey = modifers & NSCommandKeyMask; + const bool shiftKey = modifers & NSShiftKeyMask; + const bool cntrlKey = modifers & NSControlKeyMask; + const bool optKey = modifers & NSAlternateKeyMask; + const unichar keyCode = [event keyCode]; + const unichar keyChar = KeyCharacterForEvent(event); + + int cmdNum = commandForKeyboardShortcut(cmdKey, shiftKey, cntrlKey, optKey, + keyCode, keyChar); + + if (cmdNum != -1) { + id executor = [self delegate]; + // A bit of sanity. + DCHECK([executor conformsToProtocol:@protocol(BrowserCommandExecutor)]); + DCHECK([executor respondsToSelector:@selector(executeCommand:)]); + [executor executeCommand:cmdNum]; + return YES; + } + return NO; +} + +- (BOOL)handleExtraWindowKeyboardShortcut:(NSEvent*)event { + return [self handleExtraKeyboardShortcut:event + fromTable:CommandForWindowKeyboardShortcut]; +} + +- (BOOL)handleDelayedWindowKeyboardShortcut:(NSEvent*)event { + return [self handleExtraKeyboardShortcut:event + fromTable:CommandForDelayedWindowKeyboardShortcut]; +} + +- (BOOL)handleExtraBrowserKeyboardShortcut:(NSEvent*)event { + return [self handleExtraKeyboardShortcut:event + fromTable:CommandForBrowserKeyboardShortcut]; +} + +- (BOOL)performKeyEquivalent:(NSEvent*)event { + if (redispatchingEvent_) + return NO; + + // Give the web site a chance to handle the event. If it doesn't want to + // handle it, it will call us back with one of the |handle*| methods above. + NSResponder* r = [self firstResponder]; + if ([r isKindOfClass:[RenderWidgetHostViewCocoa class]]) + return [r performKeyEquivalent:event]; + + // If the delegate does not implement the BrowserCommandExecutor protocol, + // then we don't need to handle browser specific shortcut keys. + if (![[self delegate] conformsToProtocol:@protocol(BrowserCommandExecutor)]) + return [super performKeyEquivalent:event]; + + // Handle per-window shortcuts like cmd-1, but do not handle browser-level + // shortcuts like cmd-left (else, cmd-left would do history navigation even + // if e.g. the Omnibox has focus). + if ([self handleExtraWindowKeyboardShortcut:event]) + return YES; + + if ([super performKeyEquivalent:event]) + return YES; + + // Handle per-window shortcuts like Esc after giving everybody else a chance + // to handle them + return [self handleDelayedWindowKeyboardShortcut:event]; +} + +- (BOOL)redispatchKeyEvent:(NSEvent*)event { + DCHECK(event); + NSEventType eventType = [event type]; + if (eventType != NSKeyDown && + eventType != NSKeyUp && + eventType != NSFlagsChanged) { + NOTREACHED(); + return YES; // Pretend it's been handled in an effort to limit damage. + } + + // Ordinarily, the event's window should be this window. However, when + // switching between normal and fullscreen mode, we switch out the window, and + // the event's window might be the previous window (or even an earlier one if + // the renderer is running slowly and several mode switches occur). In this + // rare case, we synthesize a new key event so that its associate window + // (number) is our own. + if ([event window] != self) + event = [self keyEventForWindow:self fromKeyEvent:event]; + + // Redispatch the event. + eventHandled_ = YES; + redispatchingEvent_ = YES; + [NSApp sendEvent:event]; + redispatchingEvent_ = NO; + + // If the event was not handled by [NSApp sendEvent:], the sendEvent: + // method below will be called, and because |redispatchingEvent_| is YES, + // |eventHandled_| will be set to NO. + return eventHandled_; +} + +- (void)sendEvent:(NSEvent*)event { + if (!redispatchingEvent_) + [super sendEvent:event]; + else + eventHandled_ = NO; +} + +- (NSEvent*)keyEventForWindow:(NSWindow*)window fromKeyEvent:(NSEvent*)event { + NSEventType eventType = [event type]; + + // Convert the event's location from the original window's coordinates into + // our own. + NSPoint eventLoc = [event locationInWindow]; + eventLoc = [[event window] convertBaseToScreen:eventLoc]; + eventLoc = [self convertScreenToBase:eventLoc]; + + // Various things *only* apply to key down/up. + BOOL eventIsARepeat = NO; + NSString* eventCharacters = nil; + NSString* eventUnmodCharacters = nil; + if (eventType == NSKeyDown || eventType == NSKeyUp) { + eventIsARepeat = [event isARepeat]; + eventCharacters = [event characters]; + eventUnmodCharacters = [event charactersIgnoringModifiers]; + } + + // This synthesis may be slightly imperfect: we provide nil for the context, + // since I (viettrungluu) am sceptical that putting in the original context + // (if one is given) is valid. + return [NSEvent keyEventWithType:eventType + location:eventLoc + modifierFlags:[event modifierFlags] + timestamp:[event timestamp] + windowNumber:[window windowNumber] + context:nil + characters:eventCharacters + charactersIgnoringModifiers:eventUnmodCharacters + isARepeat:eventIsARepeat + keyCode:[event keyCode]]; +} + +@end // ChromeEventProcessingWindow diff --git a/chrome/browser/ui/cocoa/chrome_event_processing_window_unittest.mm b/chrome/browser/ui/cocoa/chrome_event_processing_window_unittest.mm new file mode 100644 index 0000000..9cbb8e0 --- /dev/null +++ b/chrome/browser/ui/cocoa/chrome_event_processing_window_unittest.mm @@ -0,0 +1,104 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/debug/debugger.h" +#include "base/scoped_nsobject.h" +#include "chrome/app/chrome_command_ids.h" +#import "chrome/browser/ui/cocoa/chrome_event_processing_window.h" +#import "chrome/browser/ui/cocoa/browser_window_controller.h" +#import "chrome/browser/ui/cocoa/browser_frame_view.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "third_party/ocmock/OCMock/OCMock.h" + +namespace { + +NSEvent* KeyEvent(const NSUInteger flags, const NSUInteger keyCode) { + return [NSEvent keyEventWithType:NSKeyDown + location:NSZeroPoint + modifierFlags:flags + timestamp:0.0 + windowNumber:0 + context:nil + characters:@"" + charactersIgnoringModifiers:@"" + isARepeat:NO + keyCode:keyCode]; +} + +class ChromeEventProcessingWindowTest : public CocoaTest { + public: + virtual void SetUp() { + CocoaTest::SetUp(); + // Create a window. + const NSUInteger mask = NSTitledWindowMask | NSClosableWindowMask | + NSMiniaturizableWindowMask | NSResizableWindowMask; + window_ = [[ChromeEventProcessingWindow alloc] + initWithContentRect:NSMakeRect(0, 0, 800, 600) + styleMask:mask + backing:NSBackingStoreBuffered + defer:NO]; + if (base::debug::BeingDebugged()) { + [window_ orderFront:nil]; + } else { + [window_ orderBack:nil]; + } + } + + virtual void TearDown() { + [window_ close]; + CocoaTest::TearDown(); + } + + ChromeEventProcessingWindow* window_; +}; + +id CreateBrowserWindowControllerMock() { + id delegate = [OCMockObject mockForClass:[BrowserWindowController class]]; + // Make conformsToProtocol return YES for @protocol(BrowserCommandExecutor) + // to satisfy the DCHECK() in handleExtraKeyboardShortcut. + // + // TODO(akalin): Figure out how to replace OCMOCK_ANY below with + // @protocol(BrowserCommandExecutor) and have it work. + BOOL yes = YES; + [[[delegate stub] andReturnValue:OCMOCK_VALUE(yes)] + conformsToProtocol:OCMOCK_ANY]; + return delegate; +} + +// Verify that the window intercepts a particular key event and +// forwards it to [delegate executeCommand:]. Assume that other +// CommandForKeyboardShortcut() will work the same for the rest. +TEST_F(ChromeEventProcessingWindowTest, + PerformKeyEquivalentForwardToExecuteCommand) { + NSEvent* event = KeyEvent(NSCommandKeyMask, kVK_ANSI_1); + + id delegate = CreateBrowserWindowControllerMock(); + [[delegate expect] executeCommand:IDC_SELECT_TAB_0]; + + [window_ setDelegate:delegate]; + [window_ performKeyEquivalent:event]; + + // Don't wish to mock all the way down... + [window_ setDelegate:nil]; + [delegate verify]; +} + +// Verify that an unhandled shortcut does not get forwarded via +// -executeCommand:. +// TODO(shess) Think of a way to test that it is sent to the +// superclass. +TEST_F(ChromeEventProcessingWindowTest, PerformKeyEquivalentNoForward) { + NSEvent* event = KeyEvent(0, 0); + + id delegate = CreateBrowserWindowControllerMock(); + + [window_ setDelegate:delegate]; + [window_ performKeyEquivalent:event]; + + // Don't wish to mock all the way down... + [window_ setDelegate:nil]; + [delegate verify]; +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/clear_browsing_data_controller.h b/chrome/browser/ui/cocoa/clear_browsing_data_controller.h new file mode 100644 index 0000000..776841d --- /dev/null +++ b/chrome/browser/ui/cocoa/clear_browsing_data_controller.h @@ -0,0 +1,87 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_CLEAR_BROWSING_DATA_CONTROLLER_ +#define CHROME_BROWSER_UI_COCOA_CLEAR_BROWSING_DATA_CONTROLLER_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_ptr.h" + +class BrowsingDataRemover; +class ClearBrowsingObserver; +class Profile; +@class ThrobberView; + +// Name of notification that is called when data is cleared. +extern NSString* const kClearBrowsingDataControllerDidDelete; +// A key in the above notification's userInfo. Contains a NSNumber with the +// logically-ored constants defined in BrowsingDataRemover for the removal. +extern NSString* const kClearBrowsingDataControllerRemoveMask; + +// A window controller for managing the "Clear Browsing Data" feature. Modally +// presents a dialog offering the user a set of choices of what browsing data +// to delete and does so if the user chooses. + +@interface ClearBrowsingDataController : NSWindowController { + @private + Profile* profile_; // Weak, owned by browser. + // If non-null means there is a removal in progress. Member used mainly for + // automated tests. The remove deletes itself when it's done, so this is a + // weak reference. + BrowsingDataRemover* remover_; + scoped_ptr<ClearBrowsingObserver> observer_; + BOOL isClearing_; // YES while clearing data is ongoing. + + // Values for checkboxes, kept in sync with bindings. These values get + // persisted into prefs if the user accepts the dialog. + BOOL clearBrowsingHistory_; + BOOL clearDownloadHistory_; + BOOL emptyCache_; + BOOL deleteCookies_; + BOOL clearSavedPasswords_; + BOOL clearFormData_; + NSInteger timePeriod_; +} + +// Show the clear browsing data window. Do not use |-initWithProfile:|, +// go through this instead so we don't end up with multiple instances. +// This function does not block, so it can be used from DOMUI calls. ++ (void)showClearBrowsingDialogForProfile:(Profile*)profile; ++ (ClearBrowsingDataController*)controllerForProfile:(Profile*)profile; + +// Run the dialog with an application-modal event loop. If the user accepts, +// performs the deletion of the selected browsing data. The values of the +// checkboxes will be persisted into prefs for next time. +- (void)runModalDialog; + +// IBActions for the dialog buttons +- (IBAction)clearData:(id)sender; +- (IBAction)cancel:(id)sender; +- (IBAction)openFlashPlayerSettings:(id)sender; + +// Properties for bindings +@property (nonatomic) BOOL clearBrowsingHistory; +@property (nonatomic) BOOL clearDownloadHistory; +@property (nonatomic) BOOL emptyCache; +@property (nonatomic) BOOL deleteCookies; +@property (nonatomic) BOOL clearSavedPasswords; +@property (nonatomic) BOOL clearFormData; +@property (nonatomic) NSInteger timePeriod; +@property (nonatomic) BOOL isClearing; + +@end + + +@interface ClearBrowsingDataController (ExposedForUnitTests) +// Create the controller with the given profile (which must not be NULL). +- (id)initWithProfile:(Profile*)profile; +@property (readonly) int removeMask; +- (void)persistToPrefs; +- (void)closeDialog; +- (void)dataRemoverDidFinish; +@end + +#endif // CHROME_BROWSER_UI_COCOA_CLEAR_BROWSING_DATA_CONTROLLER_ diff --git a/chrome/browser/ui/cocoa/clear_browsing_data_controller.mm b/chrome/browser/ui/cocoa/clear_browsing_data_controller.mm new file mode 100644 index 0000000..927c2e4 --- /dev/null +++ b/chrome/browser/ui/cocoa/clear_browsing_data_controller.mm @@ -0,0 +1,264 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/clear_browsing_data_controller.h" + +#include "app/l10n_util.h" +#include "base/mac_util.h" +#include "base/scoped_nsobject.h" +#include "base/singleton.h" +#include "chrome/browser/browsing_data_remover.h" +#include "chrome/browser/prefs/pref_service.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/ui/browser.h" +#include "chrome/browser/ui/browser_window.h" +#include "chrome/common/pref_names.h" +#include "grit/locale_settings.h" +#import "third_party/GTM/AppKit/GTMUILocalizerAndLayoutTweaker.h" + +NSString* const kClearBrowsingDataControllerDidDelete = + @"kClearBrowsingDataControllerDidDelete"; +NSString* const kClearBrowsingDataControllerRemoveMask = + @"kClearBrowsingDataControllerRemoveMask"; + +@interface ClearBrowsingDataController(Private) +- (void)initFromPrefs; +- (void)persistToPrefs; +- (void)dataRemoverDidFinish; +@end + +class ClearBrowsingObserver : public BrowsingDataRemover::Observer { + public: + ClearBrowsingObserver(ClearBrowsingDataController* controller) + : controller_(controller) { } + void OnBrowsingDataRemoverDone() { [controller_ dataRemoverDidFinish]; } + private: + ClearBrowsingDataController* controller_; +}; + +namespace { + +typedef std::map<Profile*, ClearBrowsingDataController*> ProfileControllerMap; + +} // namespace + +@implementation ClearBrowsingDataController + +@synthesize clearBrowsingHistory = clearBrowsingHistory_; +@synthesize clearDownloadHistory = clearDownloadHistory_; +@synthesize emptyCache = emptyCache_; +@synthesize deleteCookies = deleteCookies_; +@synthesize clearSavedPasswords = clearSavedPasswords_; +@synthesize clearFormData = clearFormData_; +@synthesize timePeriod = timePeriod_; +@synthesize isClearing = isClearing_; + ++ (void)showClearBrowsingDialogForProfile:(Profile*)profile { + ClearBrowsingDataController* controller = + [ClearBrowsingDataController controllerForProfile:profile]; + if (![controller isWindowLoaded]) { + // This function needs to return instead of blocking, to match the windows + // api call. It caused problems when launching the dialog from the + // DomUI history page. See bug and code review for more details. + // http://crbug.com/37976 + [controller performSelector:@selector(runModalDialog) + withObject:nil + afterDelay:0]; + } +} + ++ (ClearBrowsingDataController *)controllerForProfile:(Profile*)profile { + // Get the original profile in case we get here from an incognito window + // |GetOriginalProfile()| will return the same profile if it is the original + // profile. + profile = profile->GetOriginalProfile(); + + ProfileControllerMap* map = Singleton<ProfileControllerMap>::get(); + DCHECK(map != NULL); + ProfileControllerMap::iterator it = map->find(profile); + if (it == map->end()) { + // Since we don't currently support multiple profiles, this class + // has not been tested against this case. + if (map->size() != 0) { + return nil; + } + + ClearBrowsingDataController* controller = + [[self alloc] initWithProfile:profile]; + it = map->insert(std::make_pair(profile, controller)).first; + } + return it->second; +} + +- (id)initWithProfile:(Profile*)profile { + DCHECK(profile); + // Use initWithWindowNibPath:: instead of initWithWindowNibName: so we + // can override it in a unit test. + NSString *nibpath = [mac_util::MainAppBundle() + pathForResource:@"ClearBrowsingData" + ofType:@"nib"]; + if ((self = [super initWithWindowNibPath:nibpath owner:self])) { + profile_ = profile; + observer_.reset(new ClearBrowsingObserver(self)); + [self initFromPrefs]; + } + return self; +} + +- (void)dealloc { + if (remover_) { + // We were destroyed while clearing history was in progress. This can only + // occur during automated tests (normally the user can't close the dialog + // while clearing is in progress as the dialog is modal and not closeable). + remover_->RemoveObserver(observer_.get()); + } + + [super dealloc]; +} + +// Run application modal. +- (void)runModalDialog { + // Check again to make sure there is only one window. Since we use + // |performSelector:afterDelay:| it is possible for this to somehow be + // triggered twice. + DCHECK([NSThread isMainThread]); + if (![self isWindowLoaded]) { + // The Window size in the nib is a min size, loop over the views collecting + // the max they grew by, that is how much the window needs to be widened by. + CGFloat maxWidthGrowth = 0.0; + NSWindow* window = [self window]; + NSView* contentView = [window contentView]; + Class widthBasedTweakerClass = [GTMWidthBasedTweaker class]; + for (id subView in [contentView subviews]) { + if ([subView isKindOfClass:widthBasedTweakerClass]) { + GTMWidthBasedTweaker* tweaker = subView; + CGFloat delta = [tweaker changedWidth]; + maxWidthGrowth = std::max(maxWidthGrowth, delta); + } + } + if (maxWidthGrowth > 0.0) { + NSRect rect = [contentView convertRect:[window frame] fromView:nil]; + rect.size.width += maxWidthGrowth; + rect = [contentView convertRect:rect toView:nil]; + [window setFrame:rect display:NO]; + // For some reason the content view is resizing, but some times not + // adjusting its origin, so correct it manually. + [contentView setFrameOrigin:NSZeroPoint]; + } + // Now start the modal loop. + [NSApp runModalForWindow:window]; + } +} + +- (int)removeMask { + int removeMask = 0L; + if (clearBrowsingHistory_) + removeMask |= BrowsingDataRemover::REMOVE_HISTORY; + if (clearDownloadHistory_) + removeMask |= BrowsingDataRemover::REMOVE_DOWNLOADS; + if (emptyCache_) + removeMask |= BrowsingDataRemover::REMOVE_CACHE; + if (deleteCookies_) + removeMask |= BrowsingDataRemover::REMOVE_COOKIES; + if (clearSavedPasswords_) + removeMask |= BrowsingDataRemover::REMOVE_PASSWORDS; + if (clearFormData_) + removeMask |= BrowsingDataRemover::REMOVE_FORM_DATA; + return removeMask; +} + +// Called when the user clicks the "clear" button. Do the work and persist +// the prefs for next time. We don't stop the modal session until we get +// the callback from the BrowsingDataRemover so the window stays on the screen. +// While we're working, dim the buttons so the user can't click them. +- (IBAction)clearData:(id)sender { + // Set that we're working so that the buttons disable. + [self setIsClearing:YES]; + + [self persistToPrefs]; + + // BrowsingDataRemover deletes itself when done. + remover_ = new BrowsingDataRemover(profile_, + static_cast<BrowsingDataRemover::TimePeriod>(timePeriod_), + base::Time()); + remover_->AddObserver(observer_.get()); + remover_->Remove([self removeMask]); +} + +// Called when the user clicks the cancel button. All we need to do is stop +// the modal session. +- (IBAction)cancel:(id)sender { + [self closeDialog]; +} + +// Called when the user clicks the "Flash Player storage settings" button. +- (IBAction)openFlashPlayerSettings:(id)sender { + // The "Clear Data" dialog is app-modal on OS X. Hence, close it before + // opening a tab with flash settings. + [self closeDialog]; + + Browser* browser = Browser::Create(profile_); + browser->OpenURL(GURL(l10n_util::GetStringUTF8(IDS_FLASH_STORAGE_URL)), + GURL(), NEW_FOREGROUND_TAB, PageTransition::LINK); + browser->window()->Show(); +} + +- (void)closeDialog { + ProfileControllerMap* map = Singleton<ProfileControllerMap>::get(); + ProfileControllerMap::iterator it = map->find(profile_); + if (it != map->end()) { + map->erase(it); + } + [self autorelease]; + [[self window] orderOut:self]; + [NSApp stopModal]; +} + +// Initialize the bools from prefs using the setters to be KVO-compliant. +- (void)initFromPrefs { + PrefService* prefs = profile_->GetPrefs(); + [self setClearBrowsingHistory: + prefs->GetBoolean(prefs::kDeleteBrowsingHistory)]; + [self setClearDownloadHistory: + prefs->GetBoolean(prefs::kDeleteDownloadHistory)]; + [self setEmptyCache:prefs->GetBoolean(prefs::kDeleteCache)]; + [self setDeleteCookies:prefs->GetBoolean(prefs::kDeleteCookies)]; + [self setClearSavedPasswords:prefs->GetBoolean(prefs::kDeletePasswords)]; + [self setClearFormData:prefs->GetBoolean(prefs::kDeleteFormData)]; + [self setTimePeriod:prefs->GetInteger(prefs::kDeleteTimePeriod)]; +} + +// Save the checkbox values to the preferences. +- (void)persistToPrefs { + PrefService* prefs = profile_->GetPrefs(); + prefs->SetBoolean(prefs::kDeleteBrowsingHistory, + [self clearBrowsingHistory]); + prefs->SetBoolean(prefs::kDeleteDownloadHistory, + [self clearDownloadHistory]); + prefs->SetBoolean(prefs::kDeleteCache, [self emptyCache]); + prefs->SetBoolean(prefs::kDeleteCookies, [self deleteCookies]); + prefs->SetBoolean(prefs::kDeletePasswords, [self clearSavedPasswords]); + prefs->SetBoolean(prefs::kDeleteFormData, [self clearFormData]); + prefs->SetInteger(prefs::kDeleteTimePeriod, [self timePeriod]); +} + +// Called when the data remover object is done with its work. Close the window. +// The remover will delete itself. End the modal session at this point. +- (void)dataRemoverDidFinish { + NSNotificationCenter* center = [NSNotificationCenter defaultCenter]; + int removeMask = [self removeMask]; + NSDictionary* userInfo = + [NSDictionary dictionaryWithObject:[NSNumber numberWithInt:removeMask] + forKey:kClearBrowsingDataControllerRemoveMask]; + [center postNotificationName:kClearBrowsingDataControllerDidDelete + object:self + userInfo:userInfo]; + + [self closeDialog]; + [[self window] orderOut:self]; + [self setIsClearing:NO]; + remover_ = NULL; +} + +@end diff --git a/chrome/browser/ui/cocoa/clear_browsing_data_controller_unittest.mm b/chrome/browser/ui/cocoa/clear_browsing_data_controller_unittest.mm new file mode 100644 index 0000000..208cfa4 --- /dev/null +++ b/chrome/browser/ui/cocoa/clear_browsing_data_controller_unittest.mm @@ -0,0 +1,149 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#include "chrome/browser/browsing_data_remover.h" +#include "chrome/browser/prefs/pref_service.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#import "chrome/browser/ui/cocoa/clear_browsing_data_controller.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "chrome/common/pref_names.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" +#import "third_party/ocmock/OCMock/OCMock.h" + +namespace { + +class ClearBrowsingDataControllerTest : public CocoaTest { + public: + virtual void SetUp() { + CocoaTest::SetUp(); + // Set up some interesting prefs: + PrefService* prefs = helper_.profile()->GetPrefs(); + prefs->SetBoolean(prefs::kDeleteBrowsingHistory, true); + prefs->SetBoolean(prefs::kDeleteDownloadHistory, false); + prefs->SetBoolean(prefs::kDeleteCache, true); + prefs->SetBoolean(prefs::kDeleteCookies, false); + prefs->SetBoolean(prefs::kDeletePasswords, true); + prefs->SetBoolean(prefs::kDeleteFormData, false); + prefs->SetInteger(prefs::kDeleteTimePeriod, + BrowsingDataRemover::FOUR_WEEKS); + controller_ = + [ClearBrowsingDataController controllerForProfile:helper_.profile()]; + } + + virtual void TearDown() { + [controller_ closeDialog]; + CocoaTest::TearDown(); + } + + BrowserTestHelper helper_; + ClearBrowsingDataController* controller_; +}; + +TEST_F(ClearBrowsingDataControllerTest, InitialState) { + // Check properties match the prefs set above: + EXPECT_TRUE([controller_ clearBrowsingHistory]); + EXPECT_FALSE([controller_ clearDownloadHistory]); + EXPECT_TRUE([controller_ emptyCache]); + EXPECT_FALSE([controller_ deleteCookies]); + EXPECT_TRUE([controller_ clearSavedPasswords]); + EXPECT_FALSE([controller_ clearFormData]); + EXPECT_EQ(BrowsingDataRemover::FOUR_WEEKS, + [controller_ timePeriod]); +} + +TEST_F(ClearBrowsingDataControllerTest, InitialRemoveMask) { + // Check that the remove-mask matches the initial properties: + EXPECT_EQ(BrowsingDataRemover::REMOVE_HISTORY | + BrowsingDataRemover::REMOVE_CACHE | + BrowsingDataRemover::REMOVE_PASSWORDS, + [controller_ removeMask]); +} + +TEST_F(ClearBrowsingDataControllerTest, ModifiedRemoveMask) { + // Invert all properties and check that the remove-mask is still correct: + [controller_ setClearBrowsingHistory:false]; + [controller_ setClearDownloadHistory:true]; + [controller_ setEmptyCache:false]; + [controller_ setDeleteCookies:true]; + [controller_ setClearSavedPasswords:false]; + [controller_ setClearFormData:true]; + + EXPECT_EQ(BrowsingDataRemover::REMOVE_DOWNLOADS | + BrowsingDataRemover::REMOVE_COOKIES | + BrowsingDataRemover::REMOVE_FORM_DATA, + [controller_ removeMask]); +} + +TEST_F(ClearBrowsingDataControllerTest, EmptyRemoveMask) { + // Clear all properties and check that the remove-mask is zero: + [controller_ setClearBrowsingHistory:false]; + [controller_ setClearDownloadHistory:false]; + [controller_ setEmptyCache:false]; + [controller_ setDeleteCookies:false]; + [controller_ setClearSavedPasswords:false]; + [controller_ setClearFormData:false]; + + EXPECT_EQ(0, + [controller_ removeMask]); +} + +TEST_F(ClearBrowsingDataControllerTest, PersistToPrefs) { + // Change some settings and store to prefs: + [controller_ setClearBrowsingHistory:false]; + [controller_ setClearDownloadHistory:true]; + [controller_ persistToPrefs]; + + // Test that the modified settings were stored to prefs: + PrefService* prefs = helper_.profile()->GetPrefs(); + EXPECT_FALSE(prefs->GetBoolean(prefs::kDeleteBrowsingHistory)); + EXPECT_TRUE(prefs->GetBoolean(prefs::kDeleteDownloadHistory)); + + // Make sure the rest of the prefs didn't change: + EXPECT_TRUE(prefs->GetBoolean(prefs::kDeleteCache)); + EXPECT_FALSE(prefs->GetBoolean(prefs::kDeleteCookies)); + EXPECT_TRUE(prefs->GetBoolean(prefs::kDeletePasswords)); + EXPECT_FALSE(prefs->GetBoolean(prefs::kDeleteFormData)); + EXPECT_EQ(BrowsingDataRemover::FOUR_WEEKS, + prefs->GetInteger(prefs::kDeleteTimePeriod)); +} + +TEST_F(ClearBrowsingDataControllerTest, SameControllerForProfile) { + ClearBrowsingDataController* controller = + [ClearBrowsingDataController controllerForProfile:helper_.profile()]; + EXPECT_EQ(controller_, controller); +} + +TEST_F(ClearBrowsingDataControllerTest, DataRemoverDidFinish) { + id observer = [OCMockObject observerMock]; + // Don't use |controller_| as the object because it will free itself twice + // because both |-dataRemoverDidFinish| and TearDown() call |-closeDialog|. + ClearBrowsingDataController* controller = + [[ClearBrowsingDataController alloc] initWithProfile:helper_.profile()]; + + NSNotificationCenter* center = [NSNotificationCenter defaultCenter]; + [center addMockObserver:observer + name:kClearBrowsingDataControllerDidDelete + object:controller]; + + int mask = [controller removeMask]; + NSDictionary* expectedInfo = + [NSDictionary dictionaryWithObject:[NSNumber numberWithInt:mask] + forKey:kClearBrowsingDataControllerRemoveMask]; + [[observer expect] + notificationWithName:kClearBrowsingDataControllerDidDelete + object:controller + userInfo:expectedInfo]; + + // This calls |-closeDialog| and cleans the controller up. + [controller dataRemoverDidFinish]; + + [observer verify]; +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/clickhold_button_cell.h b/chrome/browser/ui/cocoa/clickhold_button_cell.h new file mode 100644 index 0000000..89218cf --- /dev/null +++ b/chrome/browser/ui/cocoa/clickhold_button_cell.h @@ -0,0 +1,48 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_CLICKHOLD_BUTTON_CELL_H_ +#define CHROME_BROWSER_UI_COCOA_CLICKHOLD_BUTTON_CELL_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/gradient_button_cell.h" + +// A button cell that implements "click hold" behavior after a specified delay +// or after dragging. If click-hold is never enabled (e.g., if +// |-setEnableClickHold:| is never called), this behaves like a normal button. +@interface ClickHoldButtonCell : GradientButtonCell { + @private + BOOL enableClickHold_; + NSTimeInterval clickHoldTimeout_; + id clickHoldTarget_; // Weak. + SEL clickHoldAction_; + BOOL trackOnlyInRect_; + BOOL activateOnDrag_; +} + +// Enable click-hold? Default: NO. +@property(assign, nonatomic) BOOL enableClickHold; + +// Timeout is in seconds (at least 0.0, at most 5; 0.0 means that the button +// will always have its click-hold action activated immediately on press). +// Default: 0.25 (a guess at a Cocoa-ish value). +@property(assign, nonatomic) NSTimeInterval clickHoldTimeout; + +// Track only in the frame rectangle? Default: NO. +@property(assign, nonatomic) BOOL trackOnlyInRect; + +// Activate (click-hold) immediately on a sufficiently-large drag (if not, +// always wait for timeout)? Default: YES. +@property(assign, nonatomic) BOOL activateOnDrag; + +// Defines what to do when click-held (as per usual action/target). +@property(assign, nonatomic) id clickHoldTarget; +@property(assign, nonatomic) SEL clickHoldAction; + +@end // @interface ClickHoldButtonCell + +#endif // CHROME_BROWSER_UI_COCOA_CLICKHOLD_BUTTON_CELL_H_ diff --git a/chrome/browser/ui/cocoa/clickhold_button_cell.mm b/chrome/browser/ui/cocoa/clickhold_button_cell.mm new file mode 100644 index 0000000..9b4424d --- /dev/null +++ b/chrome/browser/ui/cocoa/clickhold_button_cell.mm @@ -0,0 +1,190 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/clickhold_button_cell.h" + +#include "base/logging.h" + +// Minimum and maximum click-hold timeout. +static const NSTimeInterval kMinTimeout = 0.0; +static const NSTimeInterval kMaxTimeout = 5.0; + +// Drag distance threshold to activate click-hold; should be >= 0. +static const CGFloat kDragDistThreshold = 2.5; + +// See |-resetToDefaults| (and header file) for other default values. + +@interface ClickHoldButtonCell (Private) +- (void)resetToDefaults; +@end // @interface ClickHoldButtonCell (Private) + +@implementation ClickHoldButtonCell + +// Overrides: + ++ (BOOL)prefersTrackingUntilMouseUp { + return NO; +} + +- (id)init { + if ((self = [super init])) + [self resetToDefaults]; + return self; +} + +- (id)initWithCoder:(NSCoder*)decoder { + if ((self = [super initWithCoder:decoder])) + [self resetToDefaults]; + return self; +} + +- (id)initImageCell:(NSImage*)image { + if ((self = [super initImageCell:image])) + [self resetToDefaults]; + return self; +} + +- (id)initTextCell:(NSString*)string { + if ((self = [super initTextCell:string])) + [self resetToDefaults]; + return self; +} + +- (BOOL)startTrackingAt:(NSPoint)startPoint + inView:(NSView*)controlView { + return enableClickHold_ ? YES : + [super startTrackingAt:startPoint + inView:controlView]; +} + +- (BOOL)continueTracking:(NSPoint)lastPoint + at:(NSPoint)currentPoint + inView:(NSView*)controlView { + return enableClickHold_ ? YES : + [super continueTracking:lastPoint + at:currentPoint + inView:controlView]; +} + +- (BOOL)trackMouse:(NSEvent*)originalEvent + inRect:(NSRect)cellFrame + ofView:(NSView*)controlView + untilMouseUp:(BOOL)untilMouseUp { + if (!enableClickHold_) { + return [super trackMouse:originalEvent + inRect:cellFrame + ofView:controlView + untilMouseUp:untilMouseUp]; + } + + // If doing click-hold, track the mouse ourselves. + NSPoint currPoint = [controlView convertPoint:[originalEvent locationInWindow] + fromView:nil]; + NSPoint lastPoint = currPoint; + NSPoint firstPoint = currPoint; + NSTimeInterval timeout = + MAX(MIN(clickHoldTimeout_, kMaxTimeout), kMinTimeout); + NSDate* clickHoldBailTime = [NSDate dateWithTimeIntervalSinceNow:timeout]; + + if (![self startTrackingAt:currPoint inView:controlView]) + return NO; + + enum { + kContinueTrack, kStopClickHold, kStopMouseUp, kStopLeftRect, kStopNoContinue + } state = kContinueTrack; + do { + NSEvent* event = [NSApp nextEventMatchingMask:(NSLeftMouseDraggedMask | + NSLeftMouseUpMask) + untilDate:clickHoldBailTime + inMode:NSEventTrackingRunLoopMode + dequeue:YES]; + currPoint = [controlView convertPoint:[event locationInWindow] + fromView:nil]; + + // Time-out. + if (!event) { + state = kStopClickHold; + + // Drag? (If distance meets threshold.) + } else if (activateOnDrag_ && ([event type] == NSLeftMouseDragged)) { + CGFloat dx = currPoint.x - firstPoint.x; + CGFloat dy = currPoint.y - firstPoint.y; + if ((dx*dx + dy*dy) >= (kDragDistThreshold*kDragDistThreshold)) + state = kStopClickHold; + + // Mouse up. + } else if ([event type] == NSLeftMouseUp) { + state = kStopMouseUp; + + // Stop tracking if mouse left frame rectangle (if requested to do so). + } else if (trackOnlyInRect_ && ![controlView mouse:currPoint + inRect:cellFrame]) { + state = kStopLeftRect; + + // Stop tracking if instructed to. + } else if (![self continueTracking:lastPoint + at:currPoint + inView:controlView]) { + state = kStopNoContinue; + } + + lastPoint = currPoint; + } while (state == kContinueTrack); + + [self stopTracking:lastPoint + at:lastPoint + inView:controlView + mouseIsUp:NO]; + + switch (state) { + case kStopClickHold: + if (clickHoldAction_) { + [static_cast<NSControl*>(controlView) sendAction:clickHoldAction_ + to:clickHoldTarget_]; + } + return YES; + + case kStopMouseUp: + if ([self action]) { + [static_cast<NSControl*>(controlView) sendAction:[self action] + to:[self target]]; + } + return YES; + + case kStopLeftRect: + case kStopNoContinue: + return NO; + + default: + NOTREACHED() << "Unknown terminating state!"; + } + + return NO; +} + +// Accessors and mutators: + +@synthesize enableClickHold = enableClickHold_; +@synthesize clickHoldTimeout = clickHoldTimeout_; +@synthesize trackOnlyInRect = trackOnlyInRect_; +@synthesize activateOnDrag = activateOnDrag_; +@synthesize clickHoldTarget = clickHoldTarget_; +@synthesize clickHoldAction = clickHoldAction_; + +@end // @implementation ClickHoldButtonCell + +@implementation ClickHoldButtonCell (Private) + +// Resets various members to defaults indicated in the header file. (Those +// without indicated defaults are *not* touched.) Please keep the values below +// in sync with the header file, and please be aware of side-effects on code +// which relies on the "published" defaults. +- (void)resetToDefaults { + [self setEnableClickHold:NO]; + [self setClickHoldTimeout:0.25]; + [self setTrackOnlyInRect:NO]; + [self setActivateOnDrag:YES]; +} + +@end // @implementation ClickHoldButtonCell (Private) diff --git a/chrome/browser/ui/cocoa/clickhold_button_cell_unittest.mm b/chrome/browser/ui/cocoa/clickhold_button_cell_unittest.mm new file mode 100644 index 0000000..7ccc773 --- /dev/null +++ b/chrome/browser/ui/cocoa/clickhold_button_cell_unittest.mm @@ -0,0 +1,51 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/clickhold_button_cell.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +namespace { + +class ClickHoldButtonCellTest : public CocoaTest { + public: + ClickHoldButtonCellTest() { + NSRect frame = NSMakeRect(0, 0, 50, 30); + scoped_nsobject<NSButton> view([[NSButton alloc] initWithFrame:frame]); + view_ = view.get(); + scoped_nsobject<ClickHoldButtonCell> cell( + [[ClickHoldButtonCell alloc] initTextCell:@"Testing"]); + [view_ setCell:cell.get()]; + [[test_window() contentView] addSubview:view_]; + } + + NSButton* view_; +}; + +TEST_VIEW(ClickHoldButtonCellTest, view_) + +// Test default values; make sure they are what they should be. +TEST_F(ClickHoldButtonCellTest, Defaults) { + ClickHoldButtonCell* cell = static_cast<ClickHoldButtonCell*>([view_ cell]); + ASSERT_TRUE([cell isKindOfClass:[ClickHoldButtonCell class]]); + + EXPECT_FALSE([cell enableClickHold]); + + NSTimeInterval clickHoldTimeout = [cell clickHoldTimeout]; + EXPECT_GE(clickHoldTimeout, 0.15); // Check for a "Cocoa-ish" value. + EXPECT_LE(clickHoldTimeout, 0.35); + + EXPECT_FALSE([cell trackOnlyInRect]); + EXPECT_TRUE([cell activateOnDrag]); +} + +// TODO(viettrungluu): (1) Enable click-hold and figure out how to test the +// tracking loop (i.e., |-trackMouse:...|), which is the nontrivial part. +// (2) Test various initialization code paths (in particular, loading from nib). + +} // namespace diff --git a/chrome/browser/ui/cocoa/cocoa_test_helper.h b/chrome/browser/ui/cocoa/cocoa_test_helper.h new file mode 100644 index 0000000..3431925 --- /dev/null +++ b/chrome/browser/ui/cocoa/cocoa_test_helper.h @@ -0,0 +1,153 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_COCOA_TEST_HELPER_H_ +#define CHROME_BROWSER_UI_COCOA_COCOA_TEST_HELPER_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#import "base/chrome_application_mac.h" +#include "base/debug_util.h" +#include "base/mac_util.h" +#include "base/path_service.h" +#import "base/mac/scoped_nsautorelease_pool.h" +#import "base/scoped_nsobject.h" +#include "chrome/common/chrome_constants.h" +#include "testing/platform_test.h" + +// Background windows normally will not display things such as focus +// rings. This class allows -isKeyWindow to be manipulated to test +// such things. +@interface CocoaTestHelperWindow : NSWindow { + @private + BOOL pretendIsKeyWindow_; +} + +// Init a borderless non-deferred window with a backing store. +- (id)initWithContentRect:(NSRect)contentRect; + +// Init with a default frame. +- (id)init; + +// Sets the responder passed in as first responder, and sets the window +// so that it will return "YES" if asked if it key window. It does not actually +// make the window key. +- (void)makePretendKeyWindowAndSetFirstResponder:(NSResponder*)responder; + +// Clears the first responder duty for the window and returns the window +// to being non-key. +- (void)clearPretendKeyWindowAndFirstResponder; + +// Set value to return for -isKeyWindow. +- (void)setPretendIsKeyWindow:(BOOL)isKeyWindow; + +- (BOOL)isKeyWindow; + +@end + +// A test class that all tests that depend on AppKit should inherit from. +// Sets up NSApplication and paths correctly, and makes sure that any windows +// created in the test are closed down properly by the test. If you need to +// inherit from a different test class, but need to set up the AppKit runtime +// environment, you can call BootstrapCocoa directly from your test class. You +// will have to deal with windows on your own though. +class CocoaTest : public PlatformTest { + public: + // Sets up AppKit and paths correctly for unit tests. If you can't inherit + // from CocoaTest but are going to be using any AppKit features directly, + // or indirectly, you should be calling this from the c'tor or SetUp methods + // of your test class. + static void BootstrapCocoa(); + + CocoaTest(); + virtual ~CocoaTest(); + + // Must be called by subclasses that override TearDown. We verify that it + // is called in our destructor. Takes care of making sure that all windows + // are closed off correctly. If your tests open windows, they must be sure + // to close them before CocoaTest::TearDown is called. A standard way of doing + // this would be to create them in SetUp (after calling CocoaTest::Setup) and + // then close them in TearDown before calling CocoaTest::TearDown. + virtual void TearDown(); + + // Retuns a test window that can be used by views and other UI objects + // as part of their tests. Is created lazily, and will be closed correctly + // in CocoaTest::TearDown. Note that it is a CocoaTestHelperWindow which + // has special handling for being Key. + CocoaTestHelperWindow* test_window(); + + private: + // Return a set of currently open windows. Avoiding NSArray so + // contents aren't retained, the pointer values can only be used for + // comparison purposes. Using std::set to make progress-checking + // convenient. + static std::set<NSWindow*> ApplicationWindows(); + + // Return a set of windows which are in |ApplicationWindows()| but + // not |initial_windows_|. + std::set<NSWindow*> WindowsLeft(); + + bool called_tear_down_; + base::mac::ScopedNSAutoreleasePool pool_; + + // Windows which existed at the beginning of the test. + std::set<NSWindow*> initial_windows_; + + // Strong. Lazily created. This isn't wrapped in a scoped_nsobject because + // we want to call [close] to destroy it rather than calling [release]. We + // want to verify that [close] is actually removing our window and that it's + // not hanging around because releaseWhenClosed was set to "no" on the window. + // It isn't wrapped in a different wrapper class to close it because we + // need to close it at a very specific time; just before we enter our clean + // up loop in TearDown. + CocoaTestHelperWindow* test_window_; +}; + +// A macro defining a standard set of tests to run on a view. Since we can't +// inherit tests, this macro saves us a lot of duplicate code. Handles simply +// displaying the view to make sure it won't crash, as well as removing it +// from a window. All tests that work with NSView subclasses and/or +// NSViewController subclasses should use it. +#define TEST_VIEW(test_fixture, test_view) \ + TEST_F(test_fixture, AddRemove##test_fixture) { \ + scoped_nsobject<NSView> view([test_view retain]); \ + EXPECT_EQ([test_window() contentView], [view superview]); \ + [view removeFromSuperview]; \ + EXPECT_FALSE([view superview]); \ + } \ + TEST_F(test_fixture, Display##test_fixture) { \ + [test_view display]; \ + } + +// A macro which determines the proper float epsilon for a CGFloat. +#if CGFLOAT_IS_DOUBLE +#define CGFLOAT_EPSILON DBL_EPSILON +#else +#define CGFLOAT_EPSILON FLT_EPSILON +#endif + +// A macro which which determines if two CGFloats are equal taking a +// proper epsilon into consideration. +#define CGFLOAT_EQ(expected, actual) \ + (actual >= (expected - CGFLOAT_EPSILON) && \ + actual <= (expected + CGFLOAT_EPSILON)) + +// A test support macro which ascertains if two CGFloats are equal. +#define EXPECT_CGFLOAT_EQ(expected, actual) \ + EXPECT_TRUE(CGFLOAT_EQ(expected, actual)) << \ + expected << " != " << actual + +// A test support macro which compares two NSRects for equality taking +// the float epsilon into consideration. +#define EXPECT_NSRECT_EQ(expected, actual) \ + EXPECT_TRUE(CGFLOAT_EQ(expected.origin.x, actual.origin.x) && \ + CGFLOAT_EQ(expected.origin.y, actual.origin.y) && \ + CGFLOAT_EQ(expected.size.width, actual.size.width) && \ + CGFLOAT_EQ(expected.size.height, actual.size.height)) << \ + "Rects do not match: " << \ + [NSStringFromRect(expected) UTF8String] << \ + " != " << [NSStringFromRect(actual) UTF8String] + +#endif // CHROME_BROWSER_UI_COCOA_COCOA_TEST_HELPER_H_ diff --git a/chrome/browser/ui/cocoa/cocoa_test_helper.mm b/chrome/browser/ui/cocoa/cocoa_test_helper.mm new file mode 100644 index 0000000..2cd2cc0 --- /dev/null +++ b/chrome/browser/ui/cocoa/cocoa_test_helper.mm @@ -0,0 +1,205 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" + +#include "base/debug/debugger.h" +#include "base/logging.h" +#include "base/test/test_timeouts.h" +#import "chrome/browser/chrome_browser_application_mac.h" + +@implementation CocoaTestHelperWindow + +- (id)initWithContentRect:(NSRect)contentRect { + return [self initWithContentRect:contentRect + styleMask:NSBorderlessWindowMask + backing:NSBackingStoreBuffered + defer:NO]; +} + +- (id)init { + return [self initWithContentRect:NSMakeRect(0, 0, 800, 600)]; +} + +- (void)dealloc { + // Just a good place to put breakpoints when having problems with + // unittests and CocoaTestHelperWindow. + [super dealloc]; +} + +- (void)makePretendKeyWindowAndSetFirstResponder:(NSResponder*)responder { + EXPECT_TRUE([self makeFirstResponder:responder]); + [self setPretendIsKeyWindow:YES]; +} + +- (void)clearPretendKeyWindowAndFirstResponder { + [self setPretendIsKeyWindow:NO]; + EXPECT_TRUE([self makeFirstResponder:NSApp]); +} + +- (void)setPretendIsKeyWindow:(BOOL)flag { + pretendIsKeyWindow_ = flag; +} + +- (BOOL)isKeyWindow { + return pretendIsKeyWindow_; +} + +@end + +CocoaTest::CocoaTest() : called_tear_down_(false), test_window_(nil) { + BootstrapCocoa(); + + // Set the duration of AppKit-evaluated animations (such as frame changes) + // to zero for testing purposes. That way they take effect immediately. + [[NSAnimationContext currentContext] setDuration:0.0]; + + // The above does not affect window-resize time, such as for an + // attached sheet dropping in. Set that duration for the current + // process (this is not persisted). Empirically, the value of 0.0 + // is ignored. + NSDictionary* dict = + [NSDictionary dictionaryWithObject:@"0.01" forKey:@"NSWindowResizeTime"]; + [[NSUserDefaults standardUserDefaults] registerDefaults:dict]; + + // Collect the list of windows that were open when the test started so + // that we don't wait for them to close in TearDown. Has to be done + // after BootstrapCocoa is called. + initial_windows_ = ApplicationWindows(); +} + +CocoaTest::~CocoaTest() { + // Must call CocoaTest's teardown from your overrides. + DCHECK(called_tear_down_); +} + +void CocoaTest::BootstrapCocoa() { + // Look in the framework bundle for resources. + FilePath path; + PathService::Get(base::DIR_EXE, &path); + path = path.Append(chrome::kFrameworkName); + mac_util::SetOverrideAppBundlePath(path); + + // Bootstrap Cocoa. It's very unhappy without this. + [CrApplication sharedApplication]; +} + +void CocoaTest::TearDown() { + called_tear_down_ = true; + // Call close on our test_window to clean it up if one was opened. + [test_window_ close]; + test_window_ = nil; + + // Recycle the pool to clean up any stuff that was put on the + // autorelease pool due to window or windowcontroller closures. + pool_.Recycle(); + + // Some controls (NSTextFields, NSComboboxes etc) use + // performSelector:withDelay: to clean up drag handlers and other + // things (Radar 5851458 "Closing a window with a NSTextView in it + // should get rid of it immediately"). The event loop must be spun + // to get everything cleaned up correctly. It normally only takes + // one to two spins through the event loop to see a change. + + // NOTE(shess): Under valgrind, -nextEventMatchingMask:* in one test + // needed to run twice, once taking .2 seconds, the next time .6 + // seconds. The loop exit condition attempts to be scalable. + + // Get the set of windows which weren't present when the test + // started. + std::set<NSWindow*> windows_left(WindowsLeft()); + + while (windows_left.size() > 0) { + // Cover delayed actions by spinning the loop at least once after + // this timeout. + const NSTimeInterval kCloseTimeoutSeconds = + TestTimeouts::action_timeout_ms() / 1000.0; + + // Cover chains of delayed actions by spinning the loop at least + // this many times. + const int kCloseSpins = 3; + + // Track the set of remaining windows so that everything can be + // reset if progress is made. + std::set<NSWindow*> still_left = windows_left; + + NSDate* start_date = [NSDate date]; + bool one_more_time = true; + int spins = 0; + while (still_left.size() == windows_left.size() && + (spins < kCloseSpins || one_more_time)) { + // Check the timeout before pumping events, so that we'll spin + // the loop once after the timeout. + one_more_time = ([start_date timeIntervalSinceNow] > -kCloseTimeoutSeconds); + + // Autorelease anything thrown up by the event loop. + { + base::mac::ScopedNSAutoreleasePool pool; + ++spins; + NSEvent *next_event = [NSApp nextEventMatchingMask:NSAnyEventMask + untilDate:nil + inMode:NSDefaultRunLoopMode + dequeue:YES]; + [NSApp sendEvent:next_event]; + [NSApp updateWindows]; + } + + // Refresh the outstanding windows. + still_left = WindowsLeft(); + } + + // If no progress is being made, log a failure and continue. + if (still_left.size() == windows_left.size()) { + // NOTE(shess): Failing this expectation means that the test + // opened windows which have not been fully released. Either + // there is a leak, or perhaps one of |kCloseTimeoutSeconds| or + // |kCloseSpins| needs adjustment. + EXPECT_EQ(0U, windows_left.size()); + for (std::set<NSWindow*>::iterator iter = windows_left.begin(); + iter != windows_left.end(); ++iter) { + const char* desc = [[*iter description] UTF8String]; + LOG(WARNING) << "Didn't close window " << desc; + } + break; + } + + windows_left = still_left; + } + PlatformTest::TearDown(); +} + +std::set<NSWindow*> CocoaTest::ApplicationWindows() { + // This must NOT retain the windows it is returning. + std::set<NSWindow*> windows; + + // Must create a pool here because [NSApp windows] has created an array + // with retains on all the windows in it. + base::mac::ScopedNSAutoreleasePool pool; + NSArray *appWindows = [NSApp windows]; + for (NSWindow *window in appWindows) { + windows.insert(window); + } + return windows; +} + +std::set<NSWindow*> CocoaTest::WindowsLeft() { + const std::set<NSWindow*> windows(ApplicationWindows()); + std::set<NSWindow*> windows_left; + std::set_difference(windows.begin(), windows.end(), + initial_windows_.begin(), initial_windows_.end(), + std::inserter(windows_left, windows_left.begin())); + return windows_left; +} + +CocoaTestHelperWindow* CocoaTest::test_window() { + if (!test_window_) { + test_window_ = [[CocoaTestHelperWindow alloc] init]; + if (base::debug::BeingDebugged()) { + [test_window_ orderFront:nil]; + } else { + [test_window_ orderBack:nil]; + } + } + return test_window_; +} diff --git a/chrome/browser/ui/cocoa/collected_cookies_mac.h b/chrome/browser/ui/cocoa/collected_cookies_mac.h new file mode 100644 index 0000000..23647ee --- /dev/null +++ b/chrome/browser/ui/cocoa/collected_cookies_mac.h @@ -0,0 +1,123 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/cocoa_protocols_mac.h" +#include "base/scoped_nsobject.h" +#include "base/scoped_ptr.h" +#include "chrome/browser/cookies_tree_model.h" +#include "chrome/browser/ui/cocoa/constrained_window_mac.h" +#import "chrome/browser/ui/cocoa/cookie_tree_node.h" +#include "chrome/common/notification_registrar.h" + +@class CollectedCookiesWindowController; +@class VerticalGradientView; +class TabContents; + +// The constrained window delegate reponsible for managing the collected +// cookies dialog. +class CollectedCookiesMac : public ConstrainedWindowMacDelegateCustomSheet, + public NotificationObserver { + public: + CollectedCookiesMac(NSWindow* parent, TabContents* tab_contents); + + void OnSheetDidEnd(NSWindow* sheet); + + // ConstrainedWindowMacDelegateCustomSheet implementation. + virtual void DeleteDelegate(); + + private: + virtual ~CollectedCookiesMac(); + + // NotificationObserver implementation. + void Observe(NotificationType type, + const NotificationSource& source, + const NotificationDetails& details); + + NotificationRegistrar registrar_; + + ConstrainedWindow* window_; + + TabContents* tab_contents_; + + CollectedCookiesWindowController* sheet_controller_; + + DISALLOW_COPY_AND_ASSIGN(CollectedCookiesMac); +}; + +// Controller for the collected cookies dialog. This class stores an internal +// copy of the CookiesTreeModel but with Cocoa-converted values (NSStrings and +// NSImages instead of std::strings and SkBitmaps). Doing this allows us to use +// bindings for the interface. Changes are pushed to this internal model via a +// very thin bridge (see cookies_window_controller.h). +@interface CollectedCookiesWindowController : NSWindowController + <NSOutlineViewDelegate, + NSWindowDelegate> { + @private + // Platform-independent model. + scoped_ptr<CookiesTreeModel> allowedTreeModel_; + scoped_ptr<CookiesTreeModel> blockedTreeModel_; + + // Cached array of icons. + scoped_nsobject<NSMutableArray> icons_; + + // Our Cocoa copy of the model. + scoped_nsobject<CocoaCookieTreeNode> cocoaAllowedTreeModel_; + scoped_nsobject<CocoaCookieTreeNode> cocoaBlockedTreeModel_; + + BOOL allowedCookiesButtonsEnabled_; + BOOL blockedCookiesButtonsEnabled_; + + IBOutlet NSTreeController* allowedTreeController_; + IBOutlet NSTreeController* blockedTreeController_; + IBOutlet NSOutlineView* allowedOutlineView_; + IBOutlet NSOutlineView* blockedOutlineView_; + IBOutlet VerticalGradientView* infoBar_; + IBOutlet NSImageView* infoBarIcon_; + IBOutlet NSTextField* infoBarText_; + IBOutlet NSSplitView* splitView_; + IBOutlet NSScrollView* lowerScrollView_; + IBOutlet NSTextField* blockedCookiesText_; + + scoped_nsobject<NSViewAnimation> animation_; + + TabContents* tabContents_; // weak + + BOOL infoBarVisible_; +} +@property (readonly, nonatomic) NSTreeController* allowedTreeController; +@property (readonly, nonatomic) NSTreeController* blockedTreeController; + +@property (assign, nonatomic) BOOL allowedCookiesButtonsEnabled; +@property (assign, nonatomic) BOOL blockedCookiesButtonsEnabled; + +// Designated initializer. TabContents cannot be NULL. +- (id)initWithTabContents:(TabContents*)tabContents; + +// Closes the sheet and ends the modal loop. This will also cleanup the memory. +- (IBAction)closeSheet:(id)sender; + +- (IBAction)allowOrigin:(id)sender; +- (IBAction)allowForSessionFromOrigin:(id)sender; +- (IBAction)blockOrigin:(id)sender; + +// NSSplitView delegate methods: +- (CGFloat) splitView:(NSSplitView *)sender + constrainMinCoordinate:(CGFloat)proposedMin + ofSubviewAt:(NSInteger)offset; +- (BOOL)splitView:(NSSplitView *)sender canCollapseSubview:(NSView *)subview; + +// Returns the cocoaAllowedTreeModel_ and cocoaBlockedTreeModel_. +- (CocoaCookieTreeNode*)cocoaAllowedTreeModel; +- (CocoaCookieTreeNode*)cocoaBlockedTreeModel; +- (void)setCocoaAllowedTreeModel:(CocoaCookieTreeNode*)model; +- (void)setCocoaBlockedTreeModel:(CocoaCookieTreeNode*)model; + +// Returns the allowedTreeModel_ and blockedTreeModel_. +- (CookiesTreeModel*)allowedTreeModel; +- (CookiesTreeModel*)blockedTreeModel; + +- (void)loadTreeModelFromTabContents; +@end diff --git a/chrome/browser/ui/cocoa/collected_cookies_mac.mm b/chrome/browser/ui/cocoa/collected_cookies_mac.mm new file mode 100644 index 0000000..7a79680 --- /dev/null +++ b/chrome/browser/ui/cocoa/collected_cookies_mac.mm @@ -0,0 +1,499 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/collected_cookies_mac.h" + +#include <vector> + +#include "app/l10n_util_mac.h" +#include "app/resource_bundle.h" +#import "base/mac_util.h" +#include "base/sys_string_conversions.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/tab_contents/tab_contents.h" +#import "chrome/browser/ui/cocoa/vertical_gradient_view.h" +#include "chrome/common/notification_service.h" +#include "grit/generated_resources.h" +#include "grit/theme_resources.h" +#include "skia/ext/skia_utils_mac.h" +#include "third_party/apple/ImageAndTextCell.h" +#include "third_party/skia/include/core/SkBitmap.h" +#import "third_party/GTM/AppKit/GTMNSAnimation+Duration.h" +#import "third_party/GTM/AppKit/GTMUILocalizerAndLayoutTweaker.h" + +namespace { +// Colors for the infobar. +const double kBannerGradientColorTop[3] = + {255.0 / 255.0, 242.0 / 255.0, 183.0 / 255.0}; +const double kBannerGradientColorBottom[3] = + {250.0 / 255.0, 230.0 / 255.0, 145.0 / 255.0}; +const double kBannerStrokeColor = 135.0 / 255.0; + +// Minimal height for the collected cookies dialog. +const CGFloat kMinCollectedCookiesViewHeight = 116; +} // namespace + +#pragma mark Bridge between the constrained window delegate and the sheet + +// The delegate used to forward the events from the sheet to the constrained +// window delegate. +@interface CollectedCookiesSheetBridge : NSObject { + CollectedCookiesMac* collectedCookies_; // weak +} +- (id)initWithCollectedCookiesMac:(CollectedCookiesMac*)collectedCookies; +- (void)sheetDidEnd:(NSWindow*)sheet + returnCode:(int)returnCode + contextInfo:(void*)contextInfo; +@end + +@implementation CollectedCookiesSheetBridge +- (id)initWithCollectedCookiesMac:(CollectedCookiesMac*)collectedCookies { + if ((self = [super init])) { + collectedCookies_ = collectedCookies; + } + return self; +} + +- (void)sheetDidEnd:(NSWindow*)sheet + returnCode:(int)returnCode + contextInfo:(void*)contextInfo { + collectedCookies_->OnSheetDidEnd(sheet); +} +@end + +#pragma mark Constrained window delegate + +CollectedCookiesMac::CollectedCookiesMac(NSWindow* parent, + TabContents* tab_contents) + : ConstrainedWindowMacDelegateCustomSheet( + [[[CollectedCookiesSheetBridge alloc] + initWithCollectedCookiesMac:this] autorelease], + @selector(sheetDidEnd:returnCode:contextInfo:)), + tab_contents_(tab_contents) { + TabSpecificContentSettings* content_settings = + tab_contents->GetTabSpecificContentSettings(); + registrar_.Add(this, NotificationType::COLLECTED_COOKIES_SHOWN, + Source<TabSpecificContentSettings>(content_settings)); + + sheet_controller_ = [[CollectedCookiesWindowController alloc] + initWithTabContents:tab_contents]; + + set_sheet([sheet_controller_ window]); + + window_ = tab_contents->CreateConstrainedDialog(this); +} + +CollectedCookiesMac::~CollectedCookiesMac() { + NSWindow* window = [sheet_controller_ window]; + if (window_ && window && is_sheet_open()) { + window_ = NULL; + [NSApp endSheet:window]; + } +} + +void CollectedCookiesMac::DeleteDelegate() { + delete this; +} + +void CollectedCookiesMac::Observe(NotificationType type, + const NotificationSource& source, + const NotificationDetails& details) { + DCHECK(type == NotificationType::COLLECTED_COOKIES_SHOWN); + DCHECK_EQ(Source<TabSpecificContentSettings>(source).ptr(), + tab_contents_->GetTabSpecificContentSettings()); + window_->CloseConstrainedWindow(); +} + +void CollectedCookiesMac::OnSheetDidEnd(NSWindow* sheet) { + [sheet orderOut:sheet_controller_]; + if (window_) + window_->CloseConstrainedWindow(); +} + +#pragma mark Window Controller + +@interface CollectedCookiesWindowController(Private) +-(void)showInfoBarForDomain:(const string16&)domain + setting:(ContentSetting)setting; +-(void)showInfoBarForMultipleDomainsAndSetting:(ContentSetting)setting; +-(void)animateInfoBar; +@end + +@implementation CollectedCookiesWindowController + +@synthesize allowedCookiesButtonsEnabled = + allowedCookiesButtonsEnabled_; +@synthesize blockedCookiesButtonsEnabled = + blockedCookiesButtonsEnabled_; + +@synthesize allowedTreeController = allowedTreeController_; +@synthesize blockedTreeController = blockedTreeController_; + +- (id)initWithTabContents:(TabContents*)tabContents { + DCHECK(tabContents); + infoBarVisible_ = NO; + tabContents_ = tabContents; + + NSString* nibpath = + [mac_util::MainAppBundle() pathForResource:@"CollectedCookies" + ofType:@"nib"]; + if ((self = [super initWithWindowNibPath:nibpath owner:self])) { + [self loadTreeModelFromTabContents]; + + animation_.reset([[NSViewAnimation alloc] init]); + [animation_ setAnimationBlockingMode:NSAnimationNonblocking]; + } + return self; +} + +- (void)awakeFromNib { + ResourceBundle& rb = ResourceBundle::GetSharedInstance(); + NSImage* infoIcon = rb.GetNativeImageNamed(IDR_INFO); + DCHECK(infoIcon); + [infoBarIcon_ setImage:infoIcon]; + + // Initialize the banner gradient and stroke color. + NSColor* bannerStartingColor = + [NSColor colorWithCalibratedRed:kBannerGradientColorTop[0] + green:kBannerGradientColorTop[1] + blue:kBannerGradientColorTop[2] + alpha:1.0]; + NSColor* bannerEndingColor = + [NSColor colorWithCalibratedRed:kBannerGradientColorBottom[0] + green:kBannerGradientColorBottom[1] + blue:kBannerGradientColorBottom[2] + alpha:1.0]; + scoped_nsobject<NSGradient> bannerGradient( + [[NSGradient alloc] initWithStartingColor:bannerStartingColor + endingColor:bannerEndingColor]); + [infoBar_ setGradient:bannerGradient]; + + NSColor* bannerStrokeColor = + [NSColor colorWithCalibratedWhite:kBannerStrokeColor + alpha:1.0]; + [infoBar_ setStrokeColor:bannerStrokeColor]; + + // Change the label of the blocked cookies part if necessary. + if (tabContents_->profile()->GetHostContentSettingsMap()-> + BlockThirdPartyCookies()) { + [blockedCookiesText_ setStringValue:l10n_util::GetNSString( + IDS_COLLECTED_COOKIES_BLOCKED_THIRD_PARTY_BLOCKING_ENABLED)]; + CGFloat textDeltaY = [GTMUILocalizerAndLayoutTweaker + sizeToFitFixedWidthTextField:blockedCookiesText_]; + + // Shrink the upper custom view. + NSView* upperContentView = [[splitView_ subviews] objectAtIndex:0]; + NSRect frame = [upperContentView frame]; + [splitView_ setPosition:(frame.size.height - textDeltaY/2.0) + ofDividerAtIndex:0]; + + // Shrink the lower outline view. + frame = [lowerScrollView_ frame]; + frame.size.height -= textDeltaY; + [lowerScrollView_ setFrame:frame]; + + // Move the label down so it actually fits. + frame = [blockedCookiesText_ frame]; + frame.origin.y -= textDeltaY; + [blockedCookiesText_ setFrame:frame]; + } +} + +- (void)windowWillClose:(NSNotification*)notif { + [allowedOutlineView_ setDelegate:nil]; + [blockedOutlineView_ setDelegate:nil]; + [animation_ stopAnimation]; + [self autorelease]; +} + +- (IBAction)closeSheet:(id)sender { + [NSApp endSheet:[self window]]; +} + +- (void)addException:(ContentSetting)setting + forTreeController:(NSTreeController*)controller { + NSArray* nodes = [controller selectedNodes]; + BOOL multipleDomainsChanged = NO; + string16 lastDomain; + for (NSTreeNode* treeNode in nodes) { + CocoaCookieTreeNode* node = [treeNode representedObject]; + CookieTreeNode* cookie = static_cast<CookieTreeNode*>([node treeNode]); + if (cookie->GetDetailedInfo().node_type != + CookieTreeNode::DetailedInfo::TYPE_ORIGIN) { + continue; + } + CookieTreeOriginNode* origin_node = + static_cast<CookieTreeOriginNode*>(cookie); + origin_node->CreateContentException( + tabContents_->profile()->GetHostContentSettingsMap(), + setting); + if (!lastDomain.empty()) + multipleDomainsChanged = YES; + lastDomain = origin_node->GetTitle(); + } + if (multipleDomainsChanged) + [self showInfoBarForMultipleDomainsAndSetting:setting]; + else + [self showInfoBarForDomain:lastDomain setting:setting]; +} + +- (IBAction)allowOrigin:(id)sender { + [self addException:CONTENT_SETTING_ALLOW + forTreeController:blockedTreeController_]; +} + +- (IBAction)allowForSessionFromOrigin:(id)sender { + [self addException:CONTENT_SETTING_SESSION_ONLY + forTreeController:blockedTreeController_]; +} + +- (IBAction)blockOrigin:(id)sender { + [self addException:CONTENT_SETTING_BLOCK + forTreeController:allowedTreeController_]; +} + +- (CGFloat) splitView:(NSSplitView *)sender + constrainMinCoordinate:(CGFloat)proposedMin + ofSubviewAt:(NSInteger)offset { + return proposedMin + kMinCollectedCookiesViewHeight; +} +- (CGFloat) splitView:(NSSplitView *)sender + constrainMaxCoordinate:(CGFloat)proposedMax + ofSubviewAt:(NSInteger)offset { + return proposedMax - kMinCollectedCookiesViewHeight; +} +- (BOOL)splitView:(NSSplitView *)sender canCollapseSubview:(NSView *)subview { + return YES; +} + +- (CocoaCookieTreeNode*)cocoaAllowedTreeModel { + return cocoaAllowedTreeModel_.get(); +} +- (void)setCocoaAllowedTreeModel:(CocoaCookieTreeNode*)model { + cocoaAllowedTreeModel_.reset([model retain]); +} + +- (CookiesTreeModel*)allowedTreeModel { + return allowedTreeModel_.get(); +} + +- (CocoaCookieTreeNode*)cocoaBlockedTreeModel { + return cocoaBlockedTreeModel_.get(); +} +- (void)setCocoaBlockedTreeModel:(CocoaCookieTreeNode*)model { + cocoaBlockedTreeModel_.reset([model retain]); +} + +- (CookiesTreeModel*)blockedTreeModel { + return blockedTreeModel_.get(); +} + +- (void)outlineView:(NSOutlineView*)outlineView + willDisplayCell:(id)cell + forTableColumn:(NSTableColumn*)tableColumn + item:(id)item { + CocoaCookieTreeNode* node = [item representedObject]; + int index; + if (outlineView == allowedOutlineView_) + index = allowedTreeModel_->GetIconIndex([node treeNode]); + else + index = blockedTreeModel_->GetIconIndex([node treeNode]); + NSImage* icon = nil; + if (index >= 0) + icon = [icons_ objectAtIndex:index]; + else + icon = [icons_ lastObject]; + DCHECK([cell isKindOfClass:[ImageAndTextCell class]]); + [static_cast<ImageAndTextCell*>(cell) setImage:icon]; +} + +- (void)outlineViewSelectionDidChange:(NSNotification*)notif { + BOOL isAllowedOutlineView; + if ([notif object] == allowedOutlineView_) { + isAllowedOutlineView = YES; + } else if ([notif object] == blockedOutlineView_) { + isAllowedOutlineView = NO; + } else { + NOTREACHED(); + return; + } + NSTreeController* controller = + isAllowedOutlineView ? allowedTreeController_ : blockedTreeController_; + + NSArray* nodes = [controller selectedNodes]; + for (NSTreeNode* treeNode in nodes) { + CocoaCookieTreeNode* node = [treeNode representedObject]; + CookieTreeNode* cookie = static_cast<CookieTreeNode*>([node treeNode]); + if (cookie->GetDetailedInfo().node_type != + CookieTreeNode::DetailedInfo::TYPE_ORIGIN) { + continue; + } + CookieTreeOriginNode* origin_node = + static_cast<CookieTreeOriginNode*>(cookie); + if (origin_node->CanCreateContentException()) { + if (isAllowedOutlineView) { + [self setAllowedCookiesButtonsEnabled:YES]; + } else { + [self setBlockedCookiesButtonsEnabled:YES]; + } + return; + } + } + if (isAllowedOutlineView) { + [self setAllowedCookiesButtonsEnabled:NO]; + } else { + [self setBlockedCookiesButtonsEnabled:NO]; + } +} + +// Initializes the |allowedTreeModel_| and |blockedTreeModel_|, and builds +// the |cocoaAllowedTreeModel_| and |cocoaBlockedTreeModel_|. +- (void)loadTreeModelFromTabContents { + TabSpecificContentSettings* content_settings = + tabContents_->GetTabSpecificContentSettings(); + allowedTreeModel_.reset(content_settings->GetAllowedCookiesTreeModel()); + blockedTreeModel_.reset(content_settings->GetBlockedCookiesTreeModel()); + + // Convert the model's icons from Skia to Cocoa. + std::vector<SkBitmap> skiaIcons; + allowedTreeModel_->GetIcons(&skiaIcons); + icons_.reset([[NSMutableArray alloc] init]); + for (std::vector<SkBitmap>::iterator it = skiaIcons.begin(); + it != skiaIcons.end(); ++it) { + [icons_ addObject:gfx::SkBitmapToNSImage(*it)]; + } + + // Default icon will be the last item in the array. + ResourceBundle& rb = ResourceBundle::GetSharedInstance(); + // TODO(rsesek): Rename this resource now that it's in multiple places. + [icons_ addObject:rb.GetNativeImageNamed(IDR_BOOKMARK_BAR_FOLDER)]; + + // Create the Cocoa model. + CookieTreeNode* root = + static_cast<CookieTreeNode*>(allowedTreeModel_->GetRoot()); + scoped_nsobject<CocoaCookieTreeNode> model( + [[CocoaCookieTreeNode alloc] initWithNode:root]); + [self setCocoaAllowedTreeModel:model.get()]; // Takes ownership. + root = static_cast<CookieTreeNode*>(blockedTreeModel_->GetRoot()); + model.reset( + [[CocoaCookieTreeNode alloc] initWithNode:root]); + [self setCocoaBlockedTreeModel:model.get()]; // Takes ownership. +} + +-(void)showInfoBarForMultipleDomainsAndSetting:(ContentSetting)setting { + NSString* label; + switch (setting) { + case CONTENT_SETTING_BLOCK: + label = l10n_util::GetNSString( + IDS_COLLECTED_COOKIES_MULTIPLE_BLOCK_RULES_CREATED); + break; + + case CONTENT_SETTING_ALLOW: + label = l10n_util::GetNSString( + IDS_COLLECTED_COOKIES_MULTIPLE_ALLOW_RULES_CREATED); + break; + + case CONTENT_SETTING_SESSION_ONLY: + label = l10n_util::GetNSString( + IDS_COLLECTED_COOKIES_MULTIPLE_SESSION_RULES_CREATED); + break; + + default: + NOTREACHED(); + label = [[[NSString alloc] init] autorelease]; + } + [infoBarText_ setStringValue:label]; + [self animateInfoBar]; +} + +-(void)showInfoBarForDomain:(const string16&)domain + setting:(ContentSetting)setting { + NSString* label; + switch (setting) { + case CONTENT_SETTING_BLOCK: + label = l10n_util::GetNSStringF( + IDS_COLLECTED_COOKIES_BLOCK_RULE_CREATED, + domain); + break; + + case CONTENT_SETTING_ALLOW: + label = l10n_util::GetNSStringF( + IDS_COLLECTED_COOKIES_ALLOW_RULE_CREATED, + domain); + break; + + case CONTENT_SETTING_SESSION_ONLY: + label = l10n_util::GetNSStringF( + IDS_COLLECTED_COOKIES_SESSION_RULE_CREATED, + domain); + break; + + default: + NOTREACHED(); + label = [[[NSString alloc] init] autorelease]; + } + [infoBarText_ setStringValue:label]; + [self animateInfoBar]; +} + +-(void)animateInfoBar { + if (infoBarVisible_) + return; + + infoBarVisible_ = YES; + + NSMutableArray* animations = [NSMutableArray arrayWithCapacity:3]; + + NSWindow* sheet = [self window]; + NSRect sheetFrame = [sheet frame]; + NSRect infoBarFrame = [infoBar_ frame]; + NSRect splitViewFrame = [splitView_ frame]; + + // Calculate the end position of the info bar and set it to its start + // position. + infoBarFrame.origin.y = NSHeight(sheetFrame); + infoBarFrame.size.width = NSWidth(sheetFrame); + NSRect infoBarStartFrame = infoBarFrame; + infoBarStartFrame.origin.y += NSHeight(infoBarFrame); + infoBarStartFrame.size.height = 0.0; + [infoBar_ setFrame:infoBarStartFrame]; + [[[self window] contentView] addSubview:infoBar_]; + + // Calculate the new position of the sheet. + sheetFrame.origin.y -= NSHeight(infoBarFrame); + sheetFrame.size.height += NSHeight(infoBarFrame); + + // Slide the infobar in. + [animations addObject: + [NSDictionary dictionaryWithObjectsAndKeys: + infoBar_, NSViewAnimationTargetKey, + [NSValue valueWithRect:infoBarFrame], + NSViewAnimationEndFrameKey, + [NSValue valueWithRect:infoBarStartFrame], + NSViewAnimationStartFrameKey, + nil]]; + // Make sure the split view ends up in the right position. + [animations addObject: + [NSDictionary dictionaryWithObjectsAndKeys: + splitView_, NSViewAnimationTargetKey, + [NSValue valueWithRect:splitViewFrame], + NSViewAnimationEndFrameKey, + nil]]; + + // Grow the sheet. + [animations addObject: + [NSDictionary dictionaryWithObjectsAndKeys: + sheet, NSViewAnimationTargetKey, + [NSValue valueWithRect:sheetFrame], + NSViewAnimationEndFrameKey, + nil]]; + [animation_ setViewAnimations:animations]; + // The default duration is 0.5s, which actually feels slow in here, so speed + // it up a bit. + [animation_ gtm_setDuration:0.2 + eventMask:NSLeftMouseUpMask]; + [animation_ startAnimation]; +} + +@end diff --git a/chrome/browser/ui/cocoa/collected_cookies_mac_unittest.mm b/chrome/browser/ui/cocoa/collected_cookies_mac_unittest.mm new file mode 100644 index 0000000..f04c134 --- /dev/null +++ b/chrome/browser/ui/cocoa/collected_cookies_mac_unittest.mm @@ -0,0 +1,38 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/ref_counted.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/renderer_host/site_instance.h" +#include "chrome/browser/renderer_host/test/test_render_view_host.h" +#include "chrome/browser/tab_contents/test_tab_contents.h" +#import "chrome/browser/ui/cocoa/collected_cookies_mac.h" +#include "chrome/test/testing_profile.h" + +namespace { + +class CollectedCookiesWindowControllerTest : public RenderViewHostTestHarness { +}; + +TEST_F(CollectedCookiesWindowControllerTest, Construction) { + BrowserThread ui_thread(BrowserThread::UI, MessageLoop::current()); + // Create a test tab. SiteInstance will be deleted when tabContents is + // deleted. + SiteInstance* instance = + SiteInstance::CreateSiteInstance(profile_.get()); + TestTabContents* tabContents = new TestTabContents(profile_.get(), + instance); + CollectedCookiesWindowController* controller = + [[CollectedCookiesWindowController alloc] + initWithTabContents:tabContents]; + + [controller release]; + + delete tabContents; +} + +} // namespace + diff --git a/chrome/browser/ui/cocoa/command_observer_bridge.h b/chrome/browser/ui/cocoa/command_observer_bridge.h new file mode 100644 index 0000000..74179dd --- /dev/null +++ b/chrome/browser/ui/cocoa/command_observer_bridge.h @@ -0,0 +1,47 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_COMMAND_OBSERVER_BRIDGE +#define CHROME_BROWSER_UI_COCOA_COMMAND_OBSERVER_BRIDGE +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "chrome/browser/command_updater.h" + +@protocol CommandObserverProtocol; + +// A C++ bridge class that handles listening for updates to commands and +// passing them back to an object that supports the protocol delcared below. +// The observer will create one of these bridges, call ObserveCommand() on the +// command ids it cares about, and then wait for update notifications, +// delivered via -enabledStateChangedForCommand:enabled:. Destroying this +// bridge will handle automatically unregistering for updates, so there's no +// need to do that manually. + +class CommandObserverBridge : public CommandUpdater::CommandObserver { + public: + CommandObserverBridge(id<CommandObserverProtocol> observer, + CommandUpdater* commands); + virtual ~CommandObserverBridge(); + + // Register for updates about |command|. + void ObserveCommand(int command); + + protected: + // Overridden from CommandUpdater::CommandObserver + virtual void EnabledStateChangedForCommand(int command, bool enabled); + + private: + id<CommandObserverProtocol> observer_; // weak, owns me + CommandUpdater* commands_; // weak +}; + +// Implemented by the observing Objective-C object, called when there is a +// state change for the given command. +@protocol CommandObserverProtocol +- (void)enabledStateChangedForCommand:(NSInteger)command enabled:(BOOL)enabled; +@end + +#endif // CHROME_BROWSER_UI_COCOA_COMMAND_OBSERVER_BRIDGE diff --git a/chrome/browser/ui/cocoa/command_observer_bridge.mm b/chrome/browser/ui/cocoa/command_observer_bridge.mm new file mode 100644 index 0000000..0ffea97 --- /dev/null +++ b/chrome/browser/ui/cocoa/command_observer_bridge.mm @@ -0,0 +1,28 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/command_observer_bridge.h" + +#include "base/logging.h" + +CommandObserverBridge::CommandObserverBridge( + id<CommandObserverProtocol> observer, CommandUpdater* commands) + : observer_(observer), commands_(commands) { + DCHECK(observer_ && commands_); +} + +CommandObserverBridge::~CommandObserverBridge() { + // Unregister the notifications + commands_->RemoveCommandObserver(this); +} + +void CommandObserverBridge::ObserveCommand(int command) { + commands_->AddCommandObserver(command, this); +} + +void CommandObserverBridge::EnabledStateChangedForCommand(int command, + bool enabled) { + [observer_ enabledStateChangedForCommand:command + enabled:enabled ? YES : NO]; +} diff --git a/chrome/browser/ui/cocoa/command_observer_bridge_unittest.mm b/chrome/browser/ui/cocoa/command_observer_bridge_unittest.mm new file mode 100644 index 0000000..371bb2c --- /dev/null +++ b/chrome/browser/ui/cocoa/command_observer_bridge_unittest.mm @@ -0,0 +1,89 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_ptr.h" +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/command_observer_bridge.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +// Implements the callback interface. Records the last command id and +// enabled state it has received so it can be queried by the tests to see +// if we got a notification or not. +@interface CommandTestObserver : NSObject<CommandObserverProtocol> { + @private + int lastCommand_; // id of last received state change + bool lastState_; // state of last received state change +} +- (int)lastCommand; +- (bool)lastState; +@end + +@implementation CommandTestObserver +- (void)enabledStateChangedForCommand:(NSInteger)command enabled:(BOOL)enabled { + lastCommand_ = command; + lastState_ = enabled; +} +- (int)lastCommand { + return lastCommand_; +} +- (bool)lastState { + return lastState_; +} +@end + +namespace { + +class CommandObserverBridgeTest : public PlatformTest { + public: + CommandObserverBridgeTest() + : updater_(new CommandUpdater(NULL)), + observer_([[CommandTestObserver alloc] init]) { + } + scoped_ptr<CommandUpdater> updater_; + scoped_nsobject<CommandTestObserver> observer_; +}; + +// Tests creation and deletion. NULL arguments aren't allowed. +TEST_F(CommandObserverBridgeTest, Create) { + CommandObserverBridge bridge(observer_.get(), updater_.get()); +} + +// Observes state changes on command ids 1 and 2. Ensure we don't get +// a notification of a state change on a command we're not observing (3). +// Commands start off enabled in CommandUpdater. +TEST_F(CommandObserverBridgeTest, Observe) { + CommandObserverBridge bridge(observer_.get(), updater_.get()); + bridge.ObserveCommand(1); + bridge.ObserveCommand(2); + + // Validate initial state assumptions. + EXPECT_EQ([observer_ lastCommand], 0); + EXPECT_EQ([observer_ lastState], false); + EXPECT_EQ(updater_->IsCommandEnabled(1), true); + EXPECT_EQ(updater_->IsCommandEnabled(2), true); + + updater_->UpdateCommandEnabled(1, false); + EXPECT_EQ([observer_ lastCommand], 1); + EXPECT_EQ([observer_ lastState], false); + + updater_->UpdateCommandEnabled(2, false); + EXPECT_EQ([observer_ lastCommand], 2); + EXPECT_EQ([observer_ lastState], false); + + updater_->UpdateCommandEnabled(1, true); + EXPECT_EQ([observer_ lastCommand], 1); + EXPECT_EQ([observer_ lastState], true); + + // Change something we're not watching and make sure the last state hasn't + // changed. + updater_->UpdateCommandEnabled(3, false); + EXPECT_EQ([observer_ lastCommand], 1); + EXPECT_NE([observer_ lastCommand], 3); + EXPECT_EQ([observer_ lastState], true); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/confirm_quit_panel_controller.h b/chrome/browser/ui/cocoa/confirm_quit_panel_controller.h new file mode 100644 index 0000000..fccafa9f --- /dev/null +++ b/chrome/browser/ui/cocoa/confirm_quit_panel_controller.h @@ -0,0 +1,25 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/cocoa_protocols_mac.h" + +// The ConfirmQuitPanelController manages the black HUD window that tells users +// to "Hold Cmd+Q to Quit". +@interface ConfirmQuitPanelController : NSWindowController<NSWindowDelegate> { +} + +// Returns a singleton instance of the Controller. This will create one if it +// does not currently exist. ++ (ConfirmQuitPanelController*)sharedController; + +// Shows the window. +- (void)showWindow:(id)sender; + +// If the user did not confirm quit, send this message to give the user +// instructions on how to quit. +- (void)dismissPanel; + +@end diff --git a/chrome/browser/ui/cocoa/confirm_quit_panel_controller.mm b/chrome/browser/ui/cocoa/confirm_quit_panel_controller.mm new file mode 100644 index 0000000..548b83a --- /dev/null +++ b/chrome/browser/ui/cocoa/confirm_quit_panel_controller.mm @@ -0,0 +1,85 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> +#import <QuartzCore/QuartzCore.h> + +#include "base/logging.h" +#include "base/mac_util.h" +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/confirm_quit_panel_controller.h" + +@interface ConfirmQuitPanelController (Private) +- (id)initInternal; +- (void)animateFadeOut; +@end + +ConfirmQuitPanelController* g_confirmQuitPanelController = nil; + +@implementation ConfirmQuitPanelController + ++ (ConfirmQuitPanelController*)sharedController { + if (!g_confirmQuitPanelController) { + g_confirmQuitPanelController = + [[ConfirmQuitPanelController alloc] initInternal]; + } + return g_confirmQuitPanelController; +} + +- (id)initInternal { + NSString* nibPath = + [mac_util::MainAppBundle() pathForResource:@"ConfirmQuitPanel" + ofType:@"nib"]; + if ((self = [super initWithWindowNibPath:nibPath owner:self])) { + } + return self; +} + +- (void)awakeFromNib { + DCHECK([self window]); + DCHECK_EQ(self, [[self window] delegate]); +} + +- (void)windowWillClose:(NSNotification*)notif { + // Release all animations because CAAnimation retains its delegate (self), + // which will cause a retain cycle. Break it! + [[self window] setAnimations:[NSDictionary dictionary]]; + g_confirmQuitPanelController = nil; + [self autorelease]; +} + +- (void)showWindow:(id)sender { + // If a panel that is fading out is going to be reused here, make sure it + // does not get released when the animation finishes. + scoped_nsobject<ConfirmQuitPanelController> stayAlive([self retain]); + [[self window] setAnimations:[NSDictionary dictionary]]; + [[self window] center]; + [[self window] setAlphaValue:1.0]; + [super showWindow:sender]; +} + +- (void)dismissPanel { + [self performSelector:@selector(animateFadeOut) + withObject:nil + afterDelay:1.0]; +} + +- (void)animateFadeOut { + NSWindow* window = [self window]; + scoped_nsobject<CAAnimation> animation( + [[window animationForKey:@"alphaValue"] copy]); + [animation setDelegate:self]; + [animation setDuration:0.2]; + NSMutableDictionary* dictionary = + [NSMutableDictionary dictionaryWithDictionary:[window animations]]; + [dictionary setObject:animation forKey:@"alphaValue"]; + [window setAnimations:dictionary]; + [[window animator] setAlphaValue:0.0]; +} + +- (void)animationDidStop:(CAAnimation*)theAnimation finished:(BOOL)finished { + [self close]; +} + +@end diff --git a/chrome/browser/ui/cocoa/confirm_quit_panel_controller_unittest.mm b/chrome/browser/ui/cocoa/confirm_quit_panel_controller_unittest.mm new file mode 100644 index 0000000..0426149 --- /dev/null +++ b/chrome/browser/ui/cocoa/confirm_quit_panel_controller_unittest.mm @@ -0,0 +1,27 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "chrome/browser/ui/cocoa/confirm_quit_panel_controller.h" + +namespace { + +class ConfirmQuitPanelControllerTest : public CocoaTest { +}; + + +TEST_F(ConfirmQuitPanelControllerTest, ShowAndDismiss) { + ConfirmQuitPanelController* controller = + [ConfirmQuitPanelController sharedController]; + // Test singleton. + EXPECT_EQ(controller, [ConfirmQuitPanelController sharedController]); + [controller showWindow:nil]; + [controller dismissPanel]; // Releases self. + // The controller should still be the singleton instance until after the + // animation runs and the window closes. That will happen after this test body + // finishes executing. + EXPECT_EQ(controller, [ConfirmQuitPanelController sharedController]); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/constrained_html_delegate_mac.mm b/chrome/browser/ui/cocoa/constrained_html_delegate_mac.mm new file mode 100644 index 0000000..8d5b024 --- /dev/null +++ b/chrome/browser/ui/cocoa/constrained_html_delegate_mac.mm @@ -0,0 +1,153 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/dom_ui/constrained_html_ui.h" + +#include "base/scoped_nsobject.h" +#include "chrome/browser/dom_ui/html_dialog_ui.h" +#include "chrome/browser/dom_ui/html_dialog_tab_contents_delegate.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/tab_contents/tab_contents.h" +#include "chrome/browser/ui/cocoa/constrained_window_mac.h" +#import <Cocoa/Cocoa.h> +#include "ipc/ipc_message.h" + +class ConstrainedHtmlDelegateMac : + public ConstrainedWindowMacDelegateCustomSheet, + public HtmlDialogTabContentsDelegate, + public ConstrainedHtmlUIDelegate { + + public: + ConstrainedHtmlDelegateMac(Profile* profile, + HtmlDialogUIDelegate* delegate); + ~ConstrainedHtmlDelegateMac() {} + + // ConstrainedWindowMacDelegateCustomSheet ----------------------------------- + virtual void DeleteDelegate() { + // From ConstrainedWindowMacDelegate: "you MUST close the sheet belonging to + // your delegate in this method." + if (is_sheet_open()) + [NSApp endSheet:sheet()]; + html_delegate_->OnDialogClosed(""); + delete this; + } + + // ConstrainedHtmlDelegate --------------------------------------------------- + virtual HtmlDialogUIDelegate* GetHtmlDialogUIDelegate(); + virtual void OnDialogClose(); + + // HtmlDialogTabContentsDelegate --------------------------------------------- + void MoveContents(TabContents* source, const gfx::Rect& pos) {} + void ToolbarSizeChanged(TabContents* source, bool is_animating) {} + void HandleKeyboardEvent(const NativeWebKeyboardEvent& event) {} + + void set_window(ConstrainedWindow* window) { + constrained_window_ = window; + } + + private: + TabContents tab_contents_; // Holds the HTML to be displayed in the sheet. + HtmlDialogUIDelegate* html_delegate_; // weak. + + // The constrained window that owns |this|. Saved here because it needs to be + // closed in response to the DOMUI OnDialogClose callback. + ConstrainedWindow* constrained_window_; + + DISALLOW_COPY_AND_ASSIGN(ConstrainedHtmlDelegateMac); +}; + +// The delegate used to forward events from the sheet to the constrained +// window delegate. This bridge needs to be passed into the customsheet +// to allow the HtmlDialog to know when the sheet closes. +@interface ConstrainedHtmlDialogSheetCocoa : NSObject { + ConstrainedHtmlDelegateMac* constrainedHtmlDelegate_; // weak +} +- (id)initWithConstrainedHtmlDelegateMac: + (ConstrainedHtmlDelegateMac*)ConstrainedHtmlDelegateMac; +- (void)sheetDidEnd:(NSWindow*)sheet + returnCode:(int)returnCode + contextInfo:(void*)contextInfo; +@end + +ConstrainedHtmlDelegateMac::ConstrainedHtmlDelegateMac( + Profile* profile, + HtmlDialogUIDelegate* delegate) + : HtmlDialogTabContentsDelegate(profile), + tab_contents_(profile, NULL, MSG_ROUTING_NONE, NULL, NULL), + html_delegate_(delegate), + constrained_window_(NULL) { + tab_contents_.set_delegate(this); + + // Set |this| as a property on the tab contents so that the ConstrainedHtmlUI + // can get a reference to |this|. + ConstrainedHtmlUI::GetPropertyAccessor().SetProperty( + tab_contents_.property_bag(), this); + + tab_contents_.controller().LoadURL(delegate->GetDialogContentURL(), + GURL(), PageTransition::START_PAGE); + + // Create NSWindow to hold tab_contents in the constrained sheet: + gfx::Size size; + delegate->GetDialogSize(&size); + NSRect frame = NSMakeRect(0, 0, size.width(), size.height()); + + // |window| is retained by the ConstrainedWindowMacDelegateCustomSheet when + // the sheet is initialized. + scoped_nsobject<NSWindow> window; + window.reset( + [[NSWindow alloc] initWithContentRect:frame + styleMask:NSTitledWindowMask + backing:NSBackingStoreBuffered + defer:YES]); + + [window.get() setContentView:tab_contents_.GetNativeView()]; + + // Set the custom sheet to point to the new window. + ConstrainedWindowMacDelegateCustomSheet::init( + window.get(), + [[[ConstrainedHtmlDialogSheetCocoa alloc] + initWithConstrainedHtmlDelegateMac:this] autorelease], + @selector(sheetDidEnd:returnCode:contextInfo:)); +} + +HtmlDialogUIDelegate* ConstrainedHtmlDelegateMac::GetHtmlDialogUIDelegate() { + return html_delegate_; +} + +void ConstrainedHtmlDelegateMac::OnDialogClose() { + DCHECK(constrained_window_); + if (constrained_window_) + constrained_window_->CloseConstrainedWindow(); +} + +// static +void ConstrainedHtmlUI::CreateConstrainedHtmlDialog( + Profile* profile, + HtmlDialogUIDelegate* delegate, + TabContents* overshadowed) { + // Deleted when ConstrainedHtmlDelegateMac::DeleteDelegate() runs. + ConstrainedHtmlDelegateMac* constrained_delegate = + new ConstrainedHtmlDelegateMac(profile, delegate); + // Deleted when ConstrainedHtmlDelegateMac::OnDialogClose() runs. + ConstrainedWindow* constrained_window = + overshadowed->CreateConstrainedDialog(constrained_delegate); + constrained_delegate->set_window(constrained_window); +} + +@implementation ConstrainedHtmlDialogSheetCocoa + +- (id)initWithConstrainedHtmlDelegateMac: + (ConstrainedHtmlDelegateMac*)ConstrainedHtmlDelegateMac { + if ((self = [super init])) + constrainedHtmlDelegate_ = ConstrainedHtmlDelegateMac; + return self; +} + +- (void)sheetDidEnd:(NSWindow*)sheet + returnCode:(int)returnCode + contextInfo:(void *)contextInfo { + [sheet orderOut:self]; +} + +@end diff --git a/chrome/browser/ui/cocoa/constrained_window_mac.h b/chrome/browser/ui/cocoa/constrained_window_mac.h new file mode 100644 index 0000000..7c056ed --- /dev/null +++ b/chrome/browser/ui/cocoa/constrained_window_mac.h @@ -0,0 +1,165 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_CONSTRAINED_WINDOW_MAC_H_ +#define CHROME_BROWSER_UI_COCOA_CONSTRAINED_WINDOW_MAC_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "chrome/browser/tab_contents/constrained_window.h" + +#include "base/basictypes.h" +#include "base/logging.h" +#include "base/scoped_nsobject.h" + +@class BrowserWindowController; +@class GTMWindowSheetController; +@class NSView; +@class NSWindow; +class TabContents; + +// Base class for constrained dialog delegates. Never inherit from this +// directly. +class ConstrainedWindowMacDelegate { + public: + ConstrainedWindowMacDelegate() : is_sheet_open_(false) { } + virtual ~ConstrainedWindowMacDelegate(); + + // Tells the delegate to either delete itself or set up a task to delete + // itself later. Note that you MUST close the sheet belonging to your delegate + // in this method. + virtual void DeleteDelegate() = 0; + + // Called by the tab controller, you do not need to do anything yourself + // with this method. + virtual void RunSheet(GTMWindowSheetController* sheetController, + NSView* view) = 0; + protected: + // Returns true if this delegate's sheet is currently showing. + bool is_sheet_open() { return is_sheet_open_; } + + private: + bool is_sheet_open_; + void set_sheet_open(bool is_open) { is_sheet_open_ = is_open; } + friend class ConstrainedWindowMac; +}; + +// Subclass this for a dialog delegate that displays a system sheet such as +// an NSAlert, an open or save file panel, etc. +class ConstrainedWindowMacDelegateSystemSheet + : public ConstrainedWindowMacDelegate { + public: + ConstrainedWindowMacDelegateSystemSheet(id delegate, SEL didEndSelector) + : systemSheet_(nil), + delegate_([delegate retain]), + didEndSelector_(didEndSelector) { } + + protected: + void set_sheet(id sheet) { systemSheet_.reset([sheet retain]); } + id sheet() { return systemSheet_; } + + // Returns an NSArray to be passed as parameters to GTMWindowSheetController. + // Array's contents should be the arguments passed to the system sheet's + // beginSheetForWindow:... method. The window argument must be [NSNull null]. + // + // The default implementation returns + // [null window, delegate, didEndSelector, null contextInfo] + // Subclasses may override this if they show a system sheet which takes + // different parameters. + virtual NSArray* GetSheetParameters(id delegate, SEL didEndSelector); + + private: + virtual void RunSheet(GTMWindowSheetController* sheetController, + NSView* view); + scoped_nsobject<id> systemSheet_; + scoped_nsobject<id> delegate_; + SEL didEndSelector_; +}; + +// Subclass this for a dialog delegate that displays a custom sheet, e.g. loaded +// from a nib file. +class ConstrainedWindowMacDelegateCustomSheet + : public ConstrainedWindowMacDelegate { + public: + ConstrainedWindowMacDelegateCustomSheet() + : customSheet_(nil), + delegate_(nil), + didEndSelector_(NULL) { } + + ConstrainedWindowMacDelegateCustomSheet(id delegate, SEL didEndSelector) + : customSheet_(nil), + delegate_([delegate retain]), + didEndSelector_(didEndSelector) { } + + protected: + // For when you need to delay initalization after the constructor call. + void init(NSWindow* sheet, id delegate, SEL didEndSelector) { + DCHECK(!delegate_.get()); + DCHECK(!didEndSelector_); + customSheet_.reset([sheet retain]); + delegate_.reset([delegate retain]); + didEndSelector_ = didEndSelector; + DCHECK(delegate_.get()); + DCHECK(didEndSelector_); + } + void set_sheet(NSWindow* sheet) { customSheet_.reset([sheet retain]); } + NSWindow* sheet() { return customSheet_; } + + private: + virtual void RunSheet(GTMWindowSheetController* sheetController, + NSView* view); + scoped_nsobject<NSWindow> customSheet_; + scoped_nsobject<id> delegate_; + SEL didEndSelector_; +}; + +// Constrained window implementation for the Mac port. A constrained window +// is a per-tab sheet on OS X. +// +// Constrained windows work slightly differently on OS X than on the other +// platforms: +// 1. A constrained window is bound to both a tab and window on OS X. +// 2. The delegate is responsible for closing the sheet again when it is +// deleted. +class ConstrainedWindowMac : public ConstrainedWindow { + public: + virtual ~ConstrainedWindowMac(); + + // Overridden from ConstrainedWindow: + virtual void ShowConstrainedWindow(); + virtual void CloseConstrainedWindow(); + + // Returns the TabContents that constrains this Constrained Window. + TabContents* owner() const { return owner_; } + + // Returns the window's delegate. + ConstrainedWindowMacDelegate* delegate() { return delegate_; } + + // Makes the constrained window visible, if it is not yet visible. + void Realize(BrowserWindowController* controller); + + private: + friend class ConstrainedWindow; + + ConstrainedWindowMac(TabContents* owner, + ConstrainedWindowMacDelegate* delegate); + + // The TabContents that owns and constrains this ConstrainedWindow. + TabContents* owner_; + + // Delegate that provides the contents of this constrained window. + ConstrainedWindowMacDelegate* delegate_; + + // Controller of the window that contains this sheet. + BrowserWindowController* controller_; + + // Stores if |ShowConstrainedWindow()| was called. + bool should_be_visible_; + + DISALLOW_COPY_AND_ASSIGN(ConstrainedWindowMac); +}; + +#endif // CHROME_BROWSER_UI_COCOA_CONSTRAINED_WINDOW_MAC_H_ + diff --git a/chrome/browser/ui/cocoa/constrained_window_mac.mm b/chrome/browser/ui/cocoa/constrained_window_mac.mm new file mode 100644 index 0000000..9c408a0 --- /dev/null +++ b/chrome/browser/ui/cocoa/constrained_window_mac.mm @@ -0,0 +1,104 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/constrained_window_mac.h" + +#include "chrome/browser/tab_contents/tab_contents.h" +#include "chrome/browser/tab_contents/tab_contents_view.h" +#import "chrome/browser/ui/cocoa/browser_window_controller.h" +#import "third_party/GTM/AppKit/GTMWindowSheetController.h" + +ConstrainedWindowMacDelegate::~ConstrainedWindowMacDelegate() {} + +NSArray* ConstrainedWindowMacDelegateSystemSheet::GetSheetParameters( + id delegate, + SEL didEndSelector) { + return [NSArray arrayWithObjects: + [NSNull null], // window, must be [NSNull null] + delegate, + [NSValue valueWithPointer:didEndSelector], + [NSValue valueWithPointer:NULL], // context info for didEndSelector_. + nil]; +} + +void ConstrainedWindowMacDelegateSystemSheet::RunSheet( + GTMWindowSheetController* sheetController, + NSView* view) { + NSArray* params = GetSheetParameters(delegate_.get(), didEndSelector_); + [sheetController beginSystemSheet:systemSheet_ + modalForView:view + withParameters:params]; +} + +void ConstrainedWindowMacDelegateCustomSheet::RunSheet( + GTMWindowSheetController* sheetController, + NSView* view) { + [sheetController beginSheet:customSheet_.get() + modalForView:view + modalDelegate:delegate_.get() + didEndSelector:didEndSelector_ + contextInfo:NULL]; +} + +// static +ConstrainedWindow* ConstrainedWindow::CreateConstrainedDialog( + TabContents* parent, + ConstrainedWindowMacDelegate* delegate) { + return new ConstrainedWindowMac(parent, delegate); +} + +ConstrainedWindowMac::ConstrainedWindowMac( + TabContents* owner, ConstrainedWindowMacDelegate* delegate) + : owner_(owner), + delegate_(delegate), + controller_(nil), + should_be_visible_(false) { + DCHECK(owner); + DCHECK(delegate); +} + +ConstrainedWindowMac::~ConstrainedWindowMac() {} + +void ConstrainedWindowMac::ShowConstrainedWindow() { + should_be_visible_ = true; + // The TabContents only has a native window if it is currently visible. In + // this case, open the sheet now. Else, Realize() will be called later, when + // our tab becomes visible. + NSWindow* browserWindow = owner_->view()->GetTopLevelNativeWindow(); + NSWindowController* controller = [browserWindow windowController]; + if (controller != nil) { + DCHECK([controller isKindOfClass:[BrowserWindowController class]]); + BrowserWindowController* browser_controller = + static_cast<BrowserWindowController*>(controller); + if ([browser_controller canAttachConstrainedWindow]) + Realize(browser_controller); + } +} + +void ConstrainedWindowMac::CloseConstrainedWindow() { + // Note: controller_ can be `nil` here if the sheet was never realized. That's + // ok. + [controller_ removeConstrainedWindow:this]; + delegate_->DeleteDelegate(); + owner_->WillClose(this); + + delete this; +} + +void ConstrainedWindowMac::Realize(BrowserWindowController* controller) { + if (!should_be_visible_) + return; + + if (controller_ != nil) { + DCHECK(controller_ == controller); + return; + } + DCHECK(controller != nil); + + // Remember the controller we're adding ourselves to, so that we can later + // remove us from it. + controller_ = controller; + [controller_ attachConstrainedWindow:this]; + delegate_->set_sheet_open(true); +} diff --git a/chrome/browser/ui/cocoa/content_exceptions_window_controller.h b/chrome/browser/ui/cocoa/content_exceptions_window_controller.h new file mode 100644 index 0000000..23d1a98 --- /dev/null +++ b/chrome/browser/ui/cocoa/content_exceptions_window_controller.h @@ -0,0 +1,74 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/cocoa_protocols_mac.h" +#include "base/scoped_ptr.h" +#include "chrome/browser/content_settings/host_content_settings_map.h" +#include "chrome/common/content_settings_types.h" + +class ContentExceptionsTableModel; +class ContentSettingComboModel; +class UpdatingContentSettingsObserver; + +// Controller for the content exception dialogs. +@interface ContentExceptionsWindowController : NSWindowController + <NSWindowDelegate, + NSTableViewDataSource, + NSTableViewDelegate> { + @private + IBOutlet NSTableView* tableView_; + IBOutlet NSButton* addButton_; + IBOutlet NSButton* removeButton_; + IBOutlet NSButton* removeAllButton_; + IBOutlet NSButton* doneButton_; + + ContentSettingsType settingsType_; + HostContentSettingsMap* settingsMap_; // weak + HostContentSettingsMap* otrSettingsMap_; // weak + scoped_ptr<ContentExceptionsTableModel> model_; + scoped_ptr<ContentSettingComboModel> popup_model_; + + // Is set if adding and editing exceptions for the current OTR session should + // be allowed. + BOOL otrAllowed_; + + // Listens for changes to the content settings and reloads the data when they + // change. See comment in -modelDidChange in the mm file for details. + scoped_ptr<UpdatingContentSettingsObserver> tableObserver_; + + // If this is set to NO, notifications by |tableObserver_| are ignored. This + // is used to suppress updates at bad times. + BOOL updatesEnabled_; + + // This is non-NULL only while a new element is being added and its pattern + // is being edited. + scoped_ptr<HostContentSettingsMap::PatternSettingPair> newException_; +} + +// Returns the content exceptions window controller for |settingsType|. +// Changes made by the user in the window are persisted in |settingsMap|. ++ (id)controllerForType:(ContentSettingsType)settingsType + settingsMap:(HostContentSettingsMap*)settingsMap + otrSettingsMap:(HostContentSettingsMap*)otrSettingsMap; + +// Shows the exceptions dialog as a modal sheet attached to |window|. +- (void)attachSheetTo:(NSWindow*)window; + +// Sets the minimum width of the sheet and resizes it if necessary. +- (void)setMinWidth:(CGFloat)minWidth; + +- (IBAction)addException:(id)sender; +- (IBAction)removeException:(id)sender; +- (IBAction)removeAllExceptions:(id)sender; +// Closes the sheet and ends the modal loop. +- (IBAction)closeSheet:(id)sender; + +@end + +@interface ContentExceptionsWindowController(VisibleForTesting) +- (void)cancel:(id)sender; +- (BOOL)editingNewException; +@end diff --git a/chrome/browser/ui/cocoa/content_exceptions_window_controller.mm b/chrome/browser/ui/cocoa/content_exceptions_window_controller.mm new file mode 100644 index 0000000..1a6aa74 --- /dev/null +++ b/chrome/browser/ui/cocoa/content_exceptions_window_controller.mm @@ -0,0 +1,490 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/content_exceptions_window_controller.h" + +#include "app/l10n_util.h" +#include "app/l10n_util_mac.h" +#include "app/table_model_observer.h" +#include "base/command_line.h" +#import "base/mac_util.h" +#import "base/scoped_nsobject.h" +#include "base/sys_string_conversions.h" +#include "chrome/browser/content_exceptions_table_model.h" +#include "chrome/browser/content_setting_combo_model.h" +#include "chrome/common/chrome_switches.h" +#include "chrome/common/notification_registrar.h" +#include "chrome/common/notification_service.h" +#include "grit/generated_resources.h" +#include "third_party/GTM/AppKit/GTMUILocalizerAndLayoutTweaker.h" + +@interface ContentExceptionsWindowController (Private) +- (id)initWithType:(ContentSettingsType)settingsType + settingsMap:(HostContentSettingsMap*)settingsMap + otrSettingsMap:(HostContentSettingsMap*)otrSettingsMap; +- (void)updateRow:(NSInteger)row + withEntry:(const HostContentSettingsMap::PatternSettingPair&)entry + forOtr:(BOOL)isOtr; +- (void)adjustEditingButtons; +- (void)modelDidChange; +- (NSString*)titleForIndex:(size_t)index; +@end + +//////////////////////////////////////////////////////////////////////////////// +// PatternFormatter + +// A simple formatter that accepts text that vaguely looks like a pattern. +@interface PatternFormatter : NSFormatter +@end + +@implementation PatternFormatter +- (NSString*)stringForObjectValue:(id)object { + if (![object isKindOfClass:[NSString class]]) + return nil; + return object; +} + +- (BOOL)getObjectValue:(id*)object + forString:(NSString*)string + errorDescription:(NSString**)error { + if ([string length]) { + if (HostContentSettingsMap::Pattern( + base::SysNSStringToUTF8(string)).IsValid()) { + *object = string; + return YES; + } + } + if (error) + *error = @"Invalid pattern"; + return NO; +} + +- (NSAttributedString*)attributedStringForObjectValue:(id)object + withDefaultAttributes:(NSDictionary*)attribs { + return nil; +} +@end + +//////////////////////////////////////////////////////////////////////////////// +// UpdatingContentSettingsObserver + +// UpdatingContentSettingsObserver is a notification observer that tells a +// window controller to update its data on every notification. +class UpdatingContentSettingsObserver : public NotificationObserver { + public: + UpdatingContentSettingsObserver(ContentExceptionsWindowController* controller) + : controller_(controller) { + // One would think one could register a TableModelObserver to be notified of + // changes to ContentExceptionsTableModel. One would be wrong: The table + // model only sends out changes that are made through the model, not for + // changes made directly to its backing HostContentSettings object (that + // happens e.g. if the user uses the cookie confirmation dialog). Hence, + // observe the CONTENT_SETTINGS_CHANGED notification directly. + registrar_.Add(this, NotificationType::CONTENT_SETTINGS_CHANGED, + NotificationService::AllSources()); + } + virtual void Observe(NotificationType type, + const NotificationSource& source, + const NotificationDetails& details); + private: + NotificationRegistrar registrar_; + ContentExceptionsWindowController* controller_; +}; + +void UpdatingContentSettingsObserver::Observe( + NotificationType type, + const NotificationSource& source, + const NotificationDetails& details) { + [controller_ modelDidChange]; +} + +//////////////////////////////////////////////////////////////////////////////// +// Static functions + +namespace { + +NSString* GetWindowTitle(ContentSettingsType settingsType) { + switch (settingsType) { + case CONTENT_SETTINGS_TYPE_COOKIES: + return l10n_util::GetNSStringWithFixup(IDS_COOKIE_EXCEPTION_TITLE); + case CONTENT_SETTINGS_TYPE_IMAGES: + return l10n_util::GetNSStringWithFixup(IDS_IMAGES_EXCEPTION_TITLE); + case CONTENT_SETTINGS_TYPE_JAVASCRIPT: + return l10n_util::GetNSStringWithFixup(IDS_JS_EXCEPTION_TITLE); + case CONTENT_SETTINGS_TYPE_PLUGINS: + return l10n_util::GetNSStringWithFixup(IDS_PLUGINS_EXCEPTION_TITLE); + case CONTENT_SETTINGS_TYPE_POPUPS: + return l10n_util::GetNSStringWithFixup(IDS_POPUP_EXCEPTION_TITLE); + default: + NOTREACHED(); + } + return @""; +} + +const CGFloat kButtonBarHeight = 35.0; + +} // namespace + +//////////////////////////////////////////////////////////////////////////////// +// ContentExceptionsWindowController implementation + +static ContentExceptionsWindowController* + g_exceptionWindows[CONTENT_SETTINGS_NUM_TYPES] = { nil }; + +@implementation ContentExceptionsWindowController + ++ (id)controllerForType:(ContentSettingsType)settingsType + settingsMap:(HostContentSettingsMap*)settingsMap + otrSettingsMap:(HostContentSettingsMap*)otrSettingsMap { + if (!g_exceptionWindows[settingsType]) { + g_exceptionWindows[settingsType] = + [[ContentExceptionsWindowController alloc] + initWithType:settingsType + settingsMap:settingsMap + otrSettingsMap:otrSettingsMap]; + } + return g_exceptionWindows[settingsType]; +} + +- (id)initWithType:(ContentSettingsType)settingsType + settingsMap:(HostContentSettingsMap*)settingsMap + otrSettingsMap:(HostContentSettingsMap*)otrSettingsMap { + NSString* nibpath = + [mac_util::MainAppBundle() pathForResource:@"ContentExceptionsWindow" + ofType:@"nib"]; + if ((self = [super initWithWindowNibPath:nibpath owner:self])) { + settingsType_ = settingsType; + settingsMap_ = settingsMap; + otrSettingsMap_ = otrSettingsMap; + model_.reset(new ContentExceptionsTableModel( + settingsMap_, otrSettingsMap_, settingsType_)); + popup_model_.reset(new ContentSettingComboModel(settingsType_)); + otrAllowed_ = otrSettingsMap != NULL; + tableObserver_.reset(new UpdatingContentSettingsObserver(self)); + updatesEnabled_ = YES; + + // TODO(thakis): autoremember window rect. + // TODO(thakis): sorting support. + } + return self; +} + +- (void)awakeFromNib { + DCHECK([self window]); + DCHECK_EQ(self, [[self window] delegate]); + DCHECK(tableView_); + DCHECK_EQ(self, [tableView_ dataSource]); + DCHECK_EQ(self, [tableView_ delegate]); + + [[self window] setTitle:GetWindowTitle(settingsType_)]; + + CGFloat minWidth = [[addButton_ superview] bounds].size.width + + [[doneButton_ superview] bounds].size.width; + [self setMinWidth:minWidth]; + + [self adjustEditingButtons]; + + // Initialize menu for the data cell in the "action" column. + scoped_nsobject<NSMenu> menu([[NSMenu alloc] initWithTitle:@"exceptionMenu"]); + for (int i = 0; i < popup_model_->GetItemCount(); ++i) { + NSString* title = + l10n_util::FixUpWindowsStyleLabel(popup_model_->GetItemAt(i)); + scoped_nsobject<NSMenuItem> allowItem( + [[NSMenuItem alloc] initWithTitle:title action:NULL keyEquivalent:@""]); + [allowItem.get() setTag:popup_model_->SettingForIndex(i)]; + [menu.get() addItem:allowItem.get()]; + } + NSCell* menuCell = + [[tableView_ tableColumnWithIdentifier:@"action"] dataCell]; + [menuCell setMenu:menu.get()]; + + NSCell* patternCell = + [[tableView_ tableColumnWithIdentifier:@"pattern"] dataCell]; + [patternCell setFormatter:[[[PatternFormatter alloc] init] autorelease]]; + + if (!otrAllowed_) { + [tableView_ + removeTableColumn:[tableView_ tableColumnWithIdentifier:@"otr"]]; + } +} + +- (void)setMinWidth:(CGFloat)minWidth { + NSWindow* window = [self window]; + [window setMinSize:NSMakeSize(minWidth, [window minSize].height)]; + if ([window frame].size.width < minWidth) { + NSRect frame = [window frame]; + frame.size.width = minWidth; + [window setFrame:frame display:NO]; + } +} + +- (void)windowWillClose:(NSNotification*)notification { + // Without this, some of the unit tests fail on 10.6: + [tableView_ setDataSource:nil]; + + g_exceptionWindows[settingsType_] = nil; + [self autorelease]; +} + +- (BOOL)editingNewException { + return newException_.get() != NULL; +} + +// Let esc cancel editing if the user is currently editing a pattern. Else, let +// esc close the window. +- (void)cancel:(id)sender { + if ([tableView_ currentEditor] != nil) { + [tableView_ abortEditing]; + [[self window] makeFirstResponder:tableView_]; // Re-gain focus. + + if ([tableView_ selectedRow] == model_->RowCount()) { + // Cancel addition of new row. + [self removeException:self]; + } + } else { + [self closeSheet:self]; + } +} + +- (void)keyDown:(NSEvent*)event { + NSString* chars = [event charactersIgnoringModifiers]; + if ([chars length] == 1) { + switch ([chars characterAtIndex:0]) { + case NSDeleteCharacter: + case NSDeleteFunctionKey: + // Delete deletes. + if ([[tableView_ selectedRowIndexes] count] > 0) + [self removeException:self]; + return; + case NSCarriageReturnCharacter: + case NSEnterCharacter: + // Return enters rename mode. + if ([[tableView_ selectedRowIndexes] count] == 1) { + [tableView_ editColumn:0 + row:[[tableView_ selectedRowIndexes] lastIndex] + withEvent:nil + select:YES]; + } + return; + } + } + [super keyDown:event]; +} + +- (void)attachSheetTo:(NSWindow*)window { + [NSApp beginSheet:[self window] + modalForWindow:window + modalDelegate:self + didEndSelector:@selector(sheetDidEnd:returnCode:contextInfo:) + contextInfo:nil]; +} + +- (void)sheetDidEnd:(NSWindow*)sheet + returnCode:(NSInteger)returnCode + contextInfo:(void*)context { + [sheet close]; + [sheet orderOut:self]; +} + +- (IBAction)addException:(id)sender { + if (newException_.get()) { + // The invariant is that |newException_| is non-NULL exactly if the pattern + // of a new exception is currently being edited - so there's nothing to do + // in that case. + return; + } + newException_.reset(new HostContentSettingsMap::PatternSettingPair); + newException_->first = HostContentSettingsMap::Pattern( + l10n_util::GetStringUTF8(IDS_EXCEPTIONS_SAMPLE_PATTERN)); + newException_->second = CONTENT_SETTING_BLOCK; + [tableView_ reloadData]; + + [self adjustEditingButtons]; + int index = model_->RowCount(); + NSIndexSet* selectedSet = [NSIndexSet indexSetWithIndex:index]; + [tableView_ selectRowIndexes:selectedSet byExtendingSelection:NO]; + [tableView_ editColumn:0 row:index withEvent:nil select:YES]; +} + +- (IBAction)removeException:(id)sender { + updatesEnabled_ = NO; + NSIndexSet* selection = [tableView_ selectedRowIndexes]; + [tableView_ deselectAll:self]; // Else we'll get a -setObjectValue: later. + DCHECK_GT([selection count], 0U); + NSUInteger index = [selection lastIndex]; + while (index != NSNotFound) { + if (index == static_cast<NSUInteger>(model_->RowCount())) + newException_.reset(); + else + model_->RemoveException(index); + index = [selection indexLessThanIndex:index]; + } + updatesEnabled_ = YES; + [self modelDidChange]; +} + +- (IBAction)removeAllExceptions:(id)sender { + updatesEnabled_ = NO; + [tableView_ deselectAll:self]; // Else we'll get a -setObjectValue: later. + newException_.reset(); + model_->RemoveAll(); + updatesEnabled_ = YES; + [self modelDidChange]; +} + +- (IBAction)closeSheet:(id)sender { + [NSApp endSheet:[self window]]; +} + +// Table View Data Source ----------------------------------------------------- + +- (NSInteger)numberOfRowsInTableView:(NSTableView*)table { + return model_->RowCount() + (newException_.get() ? 1 : 0); +} + +- (id)tableView:(NSTableView*)tv + objectValueForTableColumn:(NSTableColumn*)tableColumn + row:(NSInteger)row { + const HostContentSettingsMap::PatternSettingPair* entry; + int isOtr; + if (newException_.get() && row >= model_->RowCount()) { + entry = newException_.get(); + isOtr = 0; + } else { + entry = &model_->entry_at(row); + isOtr = model_->entry_is_off_the_record(row) ? 1 : 0; + } + + NSObject* result = nil; + NSString* identifier = [tableColumn identifier]; + if ([identifier isEqualToString:@"pattern"]) { + result = base::SysUTF8ToNSString(entry->first.AsString()); + } else if ([identifier isEqualToString:@"action"]) { + result = + [NSNumber numberWithInt:popup_model_->IndexForSetting(entry->second)]; + } else if ([identifier isEqualToString:@"otr"]) { + result = [NSNumber numberWithInt:isOtr]; + } else { + NOTREACHED(); + } + return result; +} + +// Updates exception at |row| to contain the data in |entry|. +- (void)updateRow:(NSInteger)row + withEntry:(const HostContentSettingsMap::PatternSettingPair&)entry + forOtr:(BOOL)isOtr { + // TODO(thakis): This apparently moves an edited row to the back of the list. + // It's what windows and linux do, but it's kinda sucky. Fix. + // http://crbug.com/36904 + updatesEnabled_ = NO; + if (row < model_->RowCount()) + model_->RemoveException(row); + model_->AddException(entry.first, entry.second, isOtr); + updatesEnabled_ = YES; + [self modelDidChange]; + + // For now, at least re-select the edited element. + int newIndex = model_->IndexOfExceptionByPattern(entry.first, isOtr); + DCHECK(newIndex != -1); + [tableView_ selectRowIndexes:[NSIndexSet indexSetWithIndex:newIndex] + byExtendingSelection:NO]; +} + +- (void) tableView:(NSTableView*)tv + setObjectValue:(id)object + forTableColumn:(NSTableColumn*)tableColumn + row:(NSInteger)row { + // -remove: and -removeAll: both call |tableView_|'s -deselectAll:, which + // calls this method if a cell is currently being edited. Do not commit edits + // of rows that are about to be deleted. + if (!updatesEnabled_) { + // If this method gets called, the pattern filed of the new exception can no + // longer be being edited. Reset |newException_| to keep the invariant true. + newException_.reset(); + return; + } + + // Get model object. + bool isNewRow = newException_.get() && row >= model_->RowCount(); + HostContentSettingsMap::PatternSettingPair originalEntry = + isNewRow ? *newException_ : model_->entry_at(row); + HostContentSettingsMap::PatternSettingPair entry = originalEntry; + bool isOtr = + isNewRow ? 0 : model_->entry_is_off_the_record(row); + bool wasOtr = isOtr; + + // Modify it. + NSString* identifier = [tableColumn identifier]; + if ([identifier isEqualToString:@"pattern"]) { + entry.first = HostContentSettingsMap::Pattern( + base::SysNSStringToUTF8(object)); + } + if ([identifier isEqualToString:@"action"]) { + int index = [object intValue]; + entry.second = popup_model_->SettingForIndex(index); + } + if ([identifier isEqualToString:@"otr"]) { + isOtr = [object intValue] != 0; + } + + // Commit modification, if any. + if (isNewRow) { + newException_.reset(); + if (![identifier isEqualToString:@"pattern"]) { + [tableView_ reloadData]; + [self adjustEditingButtons]; + return; // Commit new rows only when the pattern has been set. + } + int newIndex = model_->IndexOfExceptionByPattern(entry.first, false); + if (newIndex != -1) { + // The new pattern was already in the table. Focus existing row instead of + // overwriting it with a new one. + [tableView_ selectRowIndexes:[NSIndexSet indexSetWithIndex:newIndex] + byExtendingSelection:NO]; + [tableView_ reloadData]; + [self adjustEditingButtons]; + return; + } + } + if (entry != originalEntry || wasOtr != isOtr || isNewRow) + [self updateRow:row withEntry:entry forOtr:isOtr]; +} + + +// Table View Delegate -------------------------------------------------------- + +// When the selection in the table view changes, we need to adjust buttons. +- (void)tableViewSelectionDidChange:(NSNotification*)notification { + [self adjustEditingButtons]; +} + +// Private -------------------------------------------------------------------- + +// This method appropriately sets the enabled states on the table's editing +// buttons. +- (void)adjustEditingButtons { + NSIndexSet* selection = [tableView_ selectedRowIndexes]; + [removeButton_ setEnabled:([selection count] > 0)]; + [removeAllButton_ setEnabled:([tableView_ numberOfRows] > 0)]; +} + +- (void)modelDidChange { + // Some calls on |model_|, e.g. RemoveException(), change something on the + // backing content settings map object (which sends a notification) and then + // change more stuff in |model_|. If |model_| is deleted when the notification + // is sent, this second access causes a segmentation violation. Hence, disable + // resetting |model_| while updates can be in progress. + if (!updatesEnabled_) + return; + + // The model caches its data, meaning we need to recreate it on every change. + model_.reset(new ContentExceptionsTableModel( + settingsMap_, otrSettingsMap_, settingsType_)); + + [tableView_ reloadData]; + [self adjustEditingButtons]; +} + +@end diff --git a/chrome/browser/ui/cocoa/content_exceptions_window_controller_unittest.mm b/chrome/browser/ui/cocoa/content_exceptions_window_controller_unittest.mm new file mode 100644 index 0000000..99dfb9d --- /dev/null +++ b/chrome/browser/ui/cocoa/content_exceptions_window_controller_unittest.mm @@ -0,0 +1,252 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/content_exceptions_window_controller.h" + +#import <Cocoa/Cocoa.h> + +#import "base/scoped_nsobject.h" +#include "base/ref_counted.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#include "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +namespace { + +void ProcessEvents() { + for (;;) { + base::mac::ScopedNSAutoreleasePool pool; + NSEvent* next_event = [NSApp nextEventMatchingMask:NSAnyEventMask + untilDate:nil + inMode:NSDefaultRunLoopMode + dequeue:YES]; + if (!next_event) + break; + [NSApp sendEvent:next_event]; + } +} + +void SendKeyEvents(NSString* characters) { + for (NSUInteger i = 0; i < [characters length]; ++i) { + unichar character = [characters characterAtIndex:i]; + NSString* charString = [NSString stringWithCharacters:&character length:1]; + NSEvent* event = [NSEvent keyEventWithType:NSKeyDown + location:NSZeroPoint + modifierFlags:0 + timestamp:0.0 + windowNumber:0 + context:nil + characters:charString + charactersIgnoringModifiers:charString + isARepeat:NO + keyCode:0]; + [NSApp sendEvent:event]; + } +} + +class ContentExceptionsWindowControllerTest : public CocoaTest { + public: + virtual void SetUp() { + CocoaTest::SetUp(); + TestingProfile* profile = browser_helper_.profile(); + settingsMap_ = new HostContentSettingsMap(profile); + } + + ContentExceptionsWindowController* GetController(ContentSettingsType type) { + id controller = [ContentExceptionsWindowController + controllerForType:type + settingsMap:settingsMap_.get() + otrSettingsMap:NULL]; + [controller showWindow:nil]; + return controller; + } + + void ClickAdd(ContentExceptionsWindowController* controller) { + [controller addException:nil]; + ProcessEvents(); + } + + void ClickRemove(ContentExceptionsWindowController* controller) { + [controller removeException:nil]; + ProcessEvents(); + } + + void ClickRemoveAll(ContentExceptionsWindowController* controller) { + [controller removeAllExceptions:nil]; + ProcessEvents(); + } + + void EnterText(NSString* str) { + SendKeyEvents(str); + ProcessEvents(); + } + + void HitEscape(ContentExceptionsWindowController* controller) { + [controller cancel:nil]; + ProcessEvents(); + } + + protected: + BrowserTestHelper browser_helper_; + scoped_refptr<HostContentSettingsMap> settingsMap_; +}; + +TEST_F(ContentExceptionsWindowControllerTest, Construction) { + ContentExceptionsWindowController* controller = + [ContentExceptionsWindowController + controllerForType:CONTENT_SETTINGS_TYPE_PLUGINS + settingsMap:settingsMap_.get() + otrSettingsMap:NULL]; + [controller showWindow:nil]; + [controller close]; // Should autorelease. +} + +// Regression test for http://crbug.com/37137 +TEST_F(ContentExceptionsWindowControllerTest, AddRemove) { + ContentExceptionsWindowController* controller = + GetController(CONTENT_SETTINGS_TYPE_PLUGINS); + + HostContentSettingsMap::SettingsForOneType settings; + + ClickAdd(controller); + settingsMap_->GetSettingsForOneType(CONTENT_SETTINGS_TYPE_PLUGINS, + "", + &settings); + EXPECT_EQ(0u, settings.size()); + + ClickRemove(controller); + + EXPECT_FALSE([controller editingNewException]); + [controller close]; + + settingsMap_->GetSettingsForOneType(CONTENT_SETTINGS_TYPE_PLUGINS, + "", + &settings); + EXPECT_EQ(0u, settings.size()); +} + +// Regression test for http://crbug.com/37137 +TEST_F(ContentExceptionsWindowControllerTest, AddRemoveAll) { + ContentExceptionsWindowController* controller = + GetController(CONTENT_SETTINGS_TYPE_PLUGINS); + + ClickAdd(controller); + ClickRemoveAll(controller); + + EXPECT_FALSE([controller editingNewException]); + [controller close]; + + HostContentSettingsMap::SettingsForOneType settings; + settingsMap_->GetSettingsForOneType(CONTENT_SETTINGS_TYPE_PLUGINS, + "", + &settings); + EXPECT_EQ(0u, settings.size()); +} + +TEST_F(ContentExceptionsWindowControllerTest, Add) { + ContentExceptionsWindowController* controller = + GetController(CONTENT_SETTINGS_TYPE_PLUGINS); + + ClickAdd(controller); + EnterText(@"addedhost\n"); + + EXPECT_FALSE([controller editingNewException]); + [controller close]; + + HostContentSettingsMap::SettingsForOneType settings; + settingsMap_->GetSettingsForOneType(CONTENT_SETTINGS_TYPE_PLUGINS, + "", + &settings); + EXPECT_EQ(1u, settings.size()); + EXPECT_EQ(HostContentSettingsMap::Pattern("addedhost"), settings[0].first); +} + +TEST_F(ContentExceptionsWindowControllerTest, AddEscDoesNotAdd) { + ContentExceptionsWindowController* controller = + GetController(CONTENT_SETTINGS_TYPE_PLUGINS); + + ClickAdd(controller); + EnterText(@"addedhost"); // but do not press enter + HitEscape(controller); + + HostContentSettingsMap::SettingsForOneType settings; + settingsMap_->GetSettingsForOneType(CONTENT_SETTINGS_TYPE_PLUGINS, + "", + &settings); + EXPECT_EQ(0u, settings.size()); + EXPECT_FALSE([controller editingNewException]); + + [controller close]; +} + +// Regression test for http://crbug.com/37208 +TEST_F(ContentExceptionsWindowControllerTest, AddEditAddAdd) { + ContentExceptionsWindowController* controller = + GetController(CONTENT_SETTINGS_TYPE_PLUGINS); + + ClickAdd(controller); + EnterText(@"testtesttest"); // but do not press enter + ClickAdd(controller); + ClickAdd(controller); + + EXPECT_TRUE([controller editingNewException]); + [controller close]; + + HostContentSettingsMap::SettingsForOneType settings; + settingsMap_->GetSettingsForOneType(CONTENT_SETTINGS_TYPE_PLUGINS, + "", + &settings); + EXPECT_EQ(0u, settings.size()); +} + +TEST_F(ContentExceptionsWindowControllerTest, AddExistingEditAdd) { + settingsMap_->SetContentSetting(HostContentSettingsMap::Pattern("myhost"), + CONTENT_SETTINGS_TYPE_PLUGINS, + "", + CONTENT_SETTING_BLOCK); + + ContentExceptionsWindowController* controller = + GetController(CONTENT_SETTINGS_TYPE_PLUGINS); + + ClickAdd(controller); + EnterText(@"myhost"); // but do not press enter + ClickAdd(controller); + + EXPECT_TRUE([controller editingNewException]); + [controller close]; + + + HostContentSettingsMap::SettingsForOneType settings; + settingsMap_->GetSettingsForOneType(CONTENT_SETTINGS_TYPE_PLUGINS, + "", + &settings); + EXPECT_EQ(1u, settings.size()); +} + +TEST_F(ContentExceptionsWindowControllerTest, AddExistingDoesNotOverwrite) { + settingsMap_->SetContentSetting(HostContentSettingsMap::Pattern("myhost"), + CONTENT_SETTINGS_TYPE_COOKIES, + "", + CONTENT_SETTING_SESSION_ONLY); + + ContentExceptionsWindowController* controller = + GetController(CONTENT_SETTINGS_TYPE_COOKIES); + + ClickAdd(controller); + EnterText(@"myhost\n"); + + EXPECT_FALSE([controller editingNewException]); + [controller close]; + + HostContentSettingsMap::SettingsForOneType settings; + settingsMap_->GetSettingsForOneType(CONTENT_SETTINGS_TYPE_COOKIES, + "", + &settings); + EXPECT_EQ(1u, settings.size()); + EXPECT_EQ(CONTENT_SETTING_SESSION_ONLY, settings[0].second); +} + + +} // namespace diff --git a/chrome/browser/ui/cocoa/content_setting_bubble_cocoa.h b/chrome/browser/ui/cocoa/content_setting_bubble_cocoa.h new file mode 100644 index 0000000..201ea12 --- /dev/null +++ b/chrome/browser/ui/cocoa/content_setting_bubble_cocoa.h @@ -0,0 +1,67 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include <map> + +#import <Cocoa/Cocoa.h> + +#import "base/cocoa_protocols_mac.h" +#include "base/scoped_nsobject.h" +#include "base/scoped_ptr.h" +#import "chrome/browser/ui/cocoa/base_bubble_controller.h" + +class ContentSettingBubbleModel; +@class InfoBubbleView; + +namespace content_setting_bubble { +// For every "show popup" button, remember the index of the popup tab contents +// it should open when clicked. +typedef std::map<NSButton*, int> PopupLinks; +} + +// Manages a "content blocked" bubble. +@interface ContentSettingBubbleController : BaseBubbleController { + @private + IBOutlet NSTextField* titleLabel_; + IBOutlet NSMatrix* allowBlockRadioGroup_; + + IBOutlet NSButton* manageButton_; + IBOutlet NSButton* doneButton_; + IBOutlet NSButton* loadAllPluginsButton_; + + // The container for the bubble contents of the geolocation bubble. + IBOutlet NSView* contentsContainer_; + + // The info button of the cookies bubble. + IBOutlet NSButton* infoButton_; + + IBOutlet NSTextField* blockedResourcesField_; + + scoped_ptr<ContentSettingBubbleModel> contentSettingBubbleModel_; + content_setting_bubble::PopupLinks popupLinks_; +} + +// Creates and shows a content blocked bubble. Takes ownership of +// |contentSettingBubbleModel| but not of the other objects. ++ (ContentSettingBubbleController*) + showForModel:(ContentSettingBubbleModel*)contentSettingBubbleModel + parentWindow:(NSWindow*)parentWindow + anchoredAt:(NSPoint)anchoredAt; + +// Callback for the "don't block / continue blocking" radio group. +- (IBAction)allowBlockToggled:(id)sender; + +// Callback for "close" button. +- (IBAction)closeBubble:(id)sender; + +// Callback for "manage" button. +- (IBAction)manageBlocking:(id)sender; + +// Callback for "info" link. +- (IBAction)showMoreInfo:(id)sender; + +// Callback for "load all plugins" button. +- (IBAction)loadAllPlugins:(id)sender; + +@end diff --git a/chrome/browser/ui/cocoa/content_setting_bubble_cocoa.mm b/chrome/browser/ui/cocoa/content_setting_bubble_cocoa.mm new file mode 100644 index 0000000..9e36292 --- /dev/null +++ b/chrome/browser/ui/cocoa/content_setting_bubble_cocoa.mm @@ -0,0 +1,487 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/content_setting_bubble_cocoa.h" + +#include "app/l10n_util.h" +#include "base/command_line.h" +#include "base/logging.h" +#include "base/sys_string_conversions.h" +#include "base/utf_string_conversions.h" +#include "chrome/browser/blocked_content_container.h" +#include "chrome/browser/content_setting_bubble_model.h" +#include "chrome/browser/content_settings/host_content_settings_map.h" +#include "chrome/browser/plugin_updater.h" +#import "chrome/browser/ui/cocoa/content_settings_dialog_controller.h" +#import "chrome/browser/ui/cocoa/hyperlink_button_cell.h" +#import "chrome/browser/ui/cocoa/info_bubble_view.h" +#import "chrome/browser/ui/cocoa/l10n_util.h" +#include "chrome/common/chrome_switches.h" +#include "grit/generated_resources.h" +#include "skia/ext/skia_utils_mac.h" +#import "third_party/GTM/AppKit/GTMUILocalizerAndLayoutTweaker.h" +#include "webkit/glue/plugins/plugin_list.h" + +namespace { + +// Must match the tag of the unblock radio button in the xib files. +const int kAllowTag = 1; + +// Must match the tag of the block radio button in the xib files. +const int kBlockTag = 2; + +// Height of one link in the popup list. +const int kLinkHeight = 16; + +// Space between two popup links. +const int kLinkPadding = 4; + +// Space taken in total by one popup link. +const int kLinkLineHeight = kLinkHeight + kLinkPadding; + +// Space between popup list and surrounding UI elements. +const int kLinkOuterPadding = 8; + +// Height of each of the labels in the geolocation bubble. +const int kGeoLabelHeight = 14; + +// Height of the "Clear" button in the geolocation bubble. +const int kGeoClearButtonHeight = 17; + +// Padding between radio buttons and "Load all plugins" button +// in the plugin bubble. +const int kLoadAllPluginsButtonVerticalPadding = 8; + +// General padding between elements in the geolocation bubble. +const int kGeoPadding = 8; + +// Padding between host names in the geolocation bubble. +const int kGeoHostPadding = 4; + +// Minimal padding between "Manage" and "Done" buttons. +const int kManageDonePadding = 8; + +void SetControlSize(NSControl* control, NSControlSize controlSize) { + CGFloat fontSize = [NSFont systemFontSizeForControlSize:controlSize]; + NSCell* cell = [control cell]; + NSFont* font = [NSFont fontWithName:[[cell font] fontName] size:fontSize]; + [cell setFont:font]; + [cell setControlSize:controlSize]; +} + +// Returns an autoreleased NSTextField that is configured to look like a Label +// looks in Interface Builder. +NSTextField* LabelWithFrame(NSString* text, const NSRect& frame) { + NSTextField* label = [[NSTextField alloc] initWithFrame:frame]; + [label setStringValue:text]; + [label setSelectable:NO]; + [label setBezeled:NO]; + return [label autorelease]; +} + +} // namespace + +@interface ContentSettingBubbleController(Private) +- (id)initWithModel:(ContentSettingBubbleModel*)settingsBubbleModel + parentWindow:(NSWindow*)parentWindow + anchoredAt:(NSPoint)anchoredAt; +- (NSButton*)hyperlinkButtonWithFrame:(NSRect)frame + title:(NSString*)title + icon:(NSImage*)icon + referenceFrame:(NSRect)referenceFrame; +- (void)initializeBlockedPluginsList; +- (void)initializeTitle; +- (void)initializeRadioGroup; +- (void)initializePopupList; +- (void)initializeGeoLists; +- (void)sizeToFitLoadPluginsButton; +- (void)sizeToFitManageDoneButtons; +- (void)removeInfoButton; +- (void)popupLinkClicked:(id)sender; +- (void)clearGeolocationForCurrentHost:(id)sender; +@end + +@implementation ContentSettingBubbleController + ++ (ContentSettingBubbleController*) + showForModel:(ContentSettingBubbleModel*)contentSettingBubbleModel + parentWindow:(NSWindow*)parentWindow + anchoredAt:(NSPoint)anchor { + // Autoreleases itself on bubble close. + return [[ContentSettingBubbleController alloc] + initWithModel:contentSettingBubbleModel + parentWindow:parentWindow + anchoredAt:anchor]; +} + +- (id)initWithModel:(ContentSettingBubbleModel*)contentSettingBubbleModel + parentWindow:(NSWindow*)parentWindow + anchoredAt:(NSPoint)anchoredAt { + // This method takes ownership of |contentSettingBubbleModel| in all cases. + scoped_ptr<ContentSettingBubbleModel> model(contentSettingBubbleModel); + DCHECK(model.get()); + + NSString* const nibPaths[] = { + @"ContentBlockedCookies", + @"ContentBlockedImages", + @"ContentBlockedJavaScript", + @"ContentBlockedPlugins", + @"ContentBlockedPopups", + @"ContentBubbleGeolocation", + @"", // Notifications do not have a bubble. + }; + COMPILE_ASSERT(arraysize(nibPaths) == CONTENT_SETTINGS_NUM_TYPES, + nibPaths_requires_an_entry_for_every_setting_type); + const int settingsType = model->content_type(); + // Nofifications do not have a bubble. + CHECK_NE(settingsType, CONTENT_SETTINGS_TYPE_NOTIFICATIONS); + DCHECK_LT(settingsType, CONTENT_SETTINGS_NUM_TYPES); + if ((self = [super initWithWindowNibPath:nibPaths[settingsType] + parentWindow:parentWindow + anchoredAt:anchoredAt])) { + contentSettingBubbleModel_.reset(model.release()); + [self showWindow:nil]; + } + return self; +} + +- (void)initializeTitle { + if (!titleLabel_) + return; + + NSString* label = base::SysUTF8ToNSString( + contentSettingBubbleModel_->bubble_content().title); + [titleLabel_ setStringValue:label]; + + // Layout title post-localization. + CGFloat deltaY = [GTMUILocalizerAndLayoutTweaker + sizeToFitFixedWidthTextField:titleLabel_]; + NSRect windowFrame = [[self window] frame]; + windowFrame.size.height += deltaY; + [[self window] setFrame:windowFrame display:NO]; + NSRect titleFrame = [titleLabel_ frame]; + titleFrame.origin.y -= deltaY; + [titleLabel_ setFrame:titleFrame]; +} + +- (void)initializeRadioGroup { + // Configure the radio group. For now, only deal with the + // strictly needed case of group containing 2 radio buttons. + const ContentSettingBubbleModel::RadioGroup& radio_group = + contentSettingBubbleModel_->bubble_content().radio_group; + + // Select appropriate radio button. + [allowBlockRadioGroup_ selectCellWithTag: + radio_group.default_item == 0 ? kAllowTag : kBlockTag]; + + const ContentSettingBubbleModel::RadioItems& radio_items = + radio_group.radio_items; + DCHECK_EQ(2u, radio_items.size()) << "Only 2 radio items per group supported"; + // Set radio group labels from model. + NSCell* radioCell = [allowBlockRadioGroup_ cellWithTag:kAllowTag]; + [radioCell setTitle:base::SysUTF8ToNSString(radio_items[0])]; + + radioCell = [allowBlockRadioGroup_ cellWithTag:kBlockTag]; + [radioCell setTitle:base::SysUTF8ToNSString(radio_items[1])]; + + // Layout radio group labels post-localization. + [GTMUILocalizerAndLayoutTweaker + wrapRadioGroupForWidth:allowBlockRadioGroup_]; + CGFloat radioDeltaY = [GTMUILocalizerAndLayoutTweaker + sizeToFitView:allowBlockRadioGroup_].height; + NSRect windowFrame = [[self window] frame]; + windowFrame.size.height += radioDeltaY; + [[self window] setFrame:windowFrame display:NO]; +} + +- (NSButton*)hyperlinkButtonWithFrame:(NSRect)frame + title:(NSString*)title + icon:(NSImage*)icon + referenceFrame:(NSRect)referenceFrame { + scoped_nsobject<HyperlinkButtonCell> cell([[HyperlinkButtonCell alloc] + initTextCell:title]); + [cell.get() setAlignment:NSNaturalTextAlignment]; + if (icon) { + [cell.get() setImagePosition:NSImageLeft]; + [cell.get() setImage:icon]; + } else { + [cell.get() setImagePosition:NSNoImage]; + } + [cell.get() setControlSize:NSSmallControlSize]; + + NSButton* button = [[[NSButton alloc] initWithFrame:frame] autorelease]; + // Cell must be set immediately after construction. + [button setCell:cell.get()]; + + // If the link text is too long, clamp it. + [button sizeToFit]; + int maxWidth = NSWidth([[self bubble] frame]) - 2 * NSMinX(referenceFrame); + NSRect buttonFrame = [button frame]; + if (NSWidth(buttonFrame) > maxWidth) { + buttonFrame.size.width = maxWidth; + [button setFrame:buttonFrame]; + } + + [button setTarget:self]; + [button setAction:@selector(popupLinkClicked:)]; + return button; +} + +- (void)initializeBlockedPluginsList { + NSMutableArray* pluginArray = [NSMutableArray array]; + const std::set<std::string>& plugins = + contentSettingBubbleModel_->bubble_content().resource_identifiers; + if (plugins.empty()) { + int delta = NSMinY([titleLabel_ frame]) - + NSMinY([blockedResourcesField_ frame]); + [blockedResourcesField_ removeFromSuperview]; + NSRect frame = [[self window] frame]; + frame.size.height -= delta; + [[self window] setFrame:frame display:NO]; + } else { + for (std::set<std::string>::iterator it = plugins.begin(); + it != plugins.end(); ++it) { + NSString* name; + NPAPI::PluginList::PluginMap groups; + NPAPI::PluginList::Singleton()->GetPluginGroups(false, &groups); + if (groups.find(*it) != groups.end()) + name = base::SysUTF16ToNSString(groups[*it]->GetGroupName()); + else + name = base::SysUTF8ToNSString(*it); + [pluginArray addObject:name]; + } + [blockedResourcesField_ + setStringValue:[pluginArray componentsJoinedByString:@"\n"]]; + [GTMUILocalizerAndLayoutTweaker + sizeToFitFixedWidthTextField:blockedResourcesField_]; + } +} + +- (void)initializePopupList { + // I didn't put the buttons into a NSMatrix because then they are only one + // entity in the key view loop. This way, one can tab through all of them. + const ContentSettingBubbleModel::PopupItems& popupItems = + contentSettingBubbleModel_->bubble_content().popup_items; + + // Get the pre-resize frame of the radio group. Its origin is where the + // popup list should go. + NSRect radioFrame = [allowBlockRadioGroup_ frame]; + + // Make room for the popup list. The bubble view and its subviews autosize + // themselves when the window is enlarged. + // Heading and radio box are already 1 * kLinkOuterPadding apart in the nib, + // so only 1 * kLinkOuterPadding more is needed. + int delta = popupItems.size() * kLinkLineHeight - kLinkPadding + + kLinkOuterPadding; + NSSize deltaSize = NSMakeSize(0, delta); + deltaSize = [[[self window] contentView] convertSize:deltaSize toView:nil]; + NSRect windowFrame = [[self window] frame]; + windowFrame.size.height += deltaSize.height; + [[self window] setFrame:windowFrame display:NO]; + + // Create popup list. + int topLinkY = NSMaxY(radioFrame) + delta - kLinkHeight; + int row = 0; + for (std::vector<ContentSettingBubbleModel::PopupItem>::const_iterator + it(popupItems.begin()); it != popupItems.end(); ++it, ++row) { + const SkBitmap& icon = it->bitmap; + NSImage* image = nil; + if (!icon.empty()) + image = gfx::SkBitmapToNSImage(icon); + + std::string title(it->title); + // The popup may not have committed a load yet, in which case it won't + // have a URL or title. + if (title.empty()) + title = l10n_util::GetStringUTF8(IDS_TAB_LOADING_TITLE); + + NSRect linkFrame = + NSMakeRect(NSMinX(radioFrame), topLinkY - kLinkLineHeight * row, + 200, kLinkHeight); + NSButton* button = [self + hyperlinkButtonWithFrame:linkFrame + title:base::SysUTF8ToNSString(title) + icon:image + referenceFrame:radioFrame]; + [[self bubble] addSubview:button]; + popupLinks_[button] = row; + } +} + +- (void)initializeGeoLists { + // Cocoa has its origin in the lower left corner. This means elements are + // added from bottom to top, which explains why loops run backwards and the + // order of operations is the other way than on Linux/Windows. + const ContentSettingBubbleModel::BubbleContent& content = + contentSettingBubbleModel_->bubble_content(); + NSRect containerFrame = [contentsContainer_ frame]; + NSRect frame = NSMakeRect(0, 0, NSWidth(containerFrame), kGeoLabelHeight); + + // "Clear" button. + if (!content.clear_link.empty()) { + NSRect buttonFrame = NSMakeRect(0, 0, + NSWidth(containerFrame), + kGeoClearButtonHeight); + scoped_nsobject<NSButton> button([[NSButton alloc] + initWithFrame:buttonFrame]); + [button setTitle:base::SysUTF8ToNSString(content.clear_link)]; + [button setTarget:self]; + [button setAction:@selector(clearGeolocationForCurrentHost:)]; + [button setBezelStyle:NSRoundRectBezelStyle]; + SetControlSize(button, NSSmallControlSize); + [button sizeToFit]; + + // If the button is wider than the container, widen the window. + CGFloat buttonWidth = NSWidth([button frame]); + if (buttonWidth > NSWidth(containerFrame)) { + NSRect windowFrame = [[self window] frame]; + windowFrame.size.width += buttonWidth - NSWidth(containerFrame); + [[self window] setFrame:windowFrame display:NO]; + // Fetch the updated sizes. + containerFrame = [contentsContainer_ frame]; + frame = NSMakeRect(0, 0, NSWidth(containerFrame), kGeoLabelHeight); + } + + // Add the button. + [contentsContainer_ addSubview:button]; + + frame.origin.y = NSMaxY([button frame]) + kGeoPadding; + } + + typedef + std::vector<ContentSettingBubbleModel::DomainList>::const_reverse_iterator + GeolocationGroupIterator; + for (GeolocationGroupIterator i = content.domain_lists.rbegin(); + i != content.domain_lists.rend(); ++i) { + // Add all hosts in the current domain list. + for (std::set<std::string>::const_reverse_iterator j = i->hosts.rbegin(); + j != i->hosts.rend(); ++j) { + NSTextField* title = LabelWithFrame(base::SysUTF8ToNSString(*j), frame); + SetControlSize(title, NSSmallControlSize); + [contentsContainer_ addSubview:title]; + + frame.origin.y = NSMaxY(frame) + kGeoHostPadding + + [GTMUILocalizerAndLayoutTweaker sizeToFitFixedWidthTextField:title]; + } + if (!i->hosts.empty()) + frame.origin.y += kGeoPadding - kGeoHostPadding; + + // Add the domain list's title. + NSTextField* title = + LabelWithFrame(base::SysUTF8ToNSString(i->title), frame); + SetControlSize(title, NSSmallControlSize); + [contentsContainer_ addSubview:title]; + + frame.origin.y = NSMaxY(frame) + kGeoPadding + + [GTMUILocalizerAndLayoutTweaker sizeToFitFixedWidthTextField:title]; + } + + CGFloat containerHeight = frame.origin.y; + // Undo last padding. + if (!content.domain_lists.empty()) + containerHeight -= kGeoPadding; + + // Resize container to fit its subviews, and window to fit the container. + NSRect windowFrame = [[self window] frame]; + windowFrame.size.height += containerHeight - NSHeight(containerFrame); + [[self window] setFrame:windowFrame display:NO]; + containerFrame.size.height = containerHeight; + [contentsContainer_ setFrame:containerFrame]; +} + +- (void)sizeToFitLoadPluginsButton { + const ContentSettingBubbleModel::BubbleContent& content = + contentSettingBubbleModel_->bubble_content(); + [loadAllPluginsButton_ setEnabled:content.load_plugins_link_enabled]; + + // Resize horizontally to fit button if necessary. + NSRect windowFrame = [[self window] frame]; + int widthNeeded = NSWidth([loadAllPluginsButton_ frame]) + + 2 * NSMinX([loadAllPluginsButton_ frame]); + if (NSWidth(windowFrame) < widthNeeded) { + windowFrame.size.width = widthNeeded; + [[self window] setFrame:windowFrame display:NO]; + } +} + +- (void)sizeToFitManageDoneButtons { + CGFloat actualWidth = NSWidth([[[self window] contentView] frame]); + CGFloat requiredWidth = NSMaxX([manageButton_ frame]) + kManageDonePadding + + NSWidth([[doneButton_ superview] frame]) - NSMinX([doneButton_ frame]); + if (requiredWidth <= actualWidth || !doneButton_ || !manageButton_) + return; + + // Resize window, autoresizing takes care of the rest. + NSSize size = NSMakeSize(requiredWidth - actualWidth, 0); + size = [[[self window] contentView] convertSize:size toView:nil]; + NSRect frame = [[self window] frame]; + frame.origin.x -= size.width; + frame.size.width += size.width; + [[self window] setFrame:frame display:NO]; +} + +- (void)awakeFromNib { + [super awakeFromNib]; + + [[self bubble] setBubbleType:info_bubble::kWhiteInfoBubble]; + [[self bubble] setArrowLocation:info_bubble::kTopRight]; + + // Adapt window size to bottom buttons. Do this before all other layouting. + [self sizeToFitManageDoneButtons]; + + [self initializeTitle]; + + ContentSettingsType type = contentSettingBubbleModel_->content_type(); + if (type == CONTENT_SETTINGS_TYPE_PLUGINS) { + [self sizeToFitLoadPluginsButton]; + [self initializeBlockedPluginsList]; + } + if (allowBlockRadioGroup_) // not bound in cookie bubble xib + [self initializeRadioGroup]; + + if (type == CONTENT_SETTINGS_TYPE_POPUPS) + [self initializePopupList]; + if (type == CONTENT_SETTINGS_TYPE_GEOLOCATION) + [self initializeGeoLists]; +} + +/////////////////////////////////////////////////////////////////////////////// +// Actual application logic + +- (IBAction)allowBlockToggled:(id)sender { + NSButtonCell *selectedCell = [sender selectedCell]; + contentSettingBubbleModel_->OnRadioClicked( + [selectedCell tag] == kAllowTag ? 0 : 1); +} + +- (IBAction)closeBubble:(id)sender { + [self close]; +} + +- (IBAction)manageBlocking:(id)sender { + contentSettingBubbleModel_->OnManageLinkClicked(); +} + +- (IBAction)showMoreInfo:(id)sender { + contentSettingBubbleModel_->OnInfoLinkClicked(); + [self close]; +} + +- (IBAction)loadAllPlugins:(id)sender { + contentSettingBubbleModel_->OnLoadPluginsLinkClicked(); + [self close]; +} + +- (void)popupLinkClicked:(id)sender { + content_setting_bubble::PopupLinks::iterator i(popupLinks_.find(sender)); + DCHECK(i != popupLinks_.end()); + contentSettingBubbleModel_->OnPopupClicked(i->second); +} + +- (void)clearGeolocationForCurrentHost:(id)sender { + contentSettingBubbleModel_->OnClearLinkClicked(); + [self close]; +} + +@end // ContentSettingBubbleController diff --git a/chrome/browser/ui/cocoa/content_setting_bubble_cocoa_unittest.mm b/chrome/browser/ui/cocoa/content_setting_bubble_cocoa_unittest.mm new file mode 100644 index 0000000..e67b0aa --- /dev/null +++ b/chrome/browser/ui/cocoa/content_setting_bubble_cocoa_unittest.mm @@ -0,0 +1,63 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/content_setting_bubble_cocoa.h" + +#import <Cocoa/Cocoa.h> + +#include "base/debug/debugger.h" +#include "base/scoped_nsobject.h" +#include "chrome/browser/content_setting_bubble_model.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "chrome/common/content_settings_types.h" +#include "testing/gtest/include/gtest/gtest.h" + +namespace { + +class DummyContentSettingBubbleModel : public ContentSettingBubbleModel { + public: + DummyContentSettingBubbleModel(ContentSettingsType content_type) + : ContentSettingBubbleModel(NULL, NULL, content_type) { + RadioGroup radio_group; + radio_group.default_item = 0; + radio_group.radio_items.resize(2); + set_radio_group(radio_group); + } +}; + +class ContentSettingBubbleControllerTest : public CocoaTest { +}; + +// Check that the bubble doesn't crash or leak for any settings type +TEST_F(ContentSettingBubbleControllerTest, Init) { + for (int i = 0; i < CONTENT_SETTINGS_NUM_TYPES; ++i) { + if (i == CONTENT_SETTINGS_TYPE_NOTIFICATIONS) + continue; // Notifications have no bubble. + + ContentSettingsType settingsType = static_cast<ContentSettingsType>(i); + + scoped_nsobject<NSWindow> parent([[NSWindow alloc] + initWithContentRect:NSMakeRect(0, 0, 800, 600) + styleMask:NSBorderlessWindowMask + backing:NSBackingStoreBuffered + defer:NO]); + [parent setReleasedWhenClosed:NO]; + if (base::debug::BeingDebugged()) + [parent.get() orderFront:nil]; + else + [parent.get() orderBack:nil]; + + ContentSettingBubbleController* controller = [ContentSettingBubbleController + showForModel:new DummyContentSettingBubbleModel(settingsType) + parentWindow:parent + anchoredAt:NSMakePoint(50, 20)]; + EXPECT_TRUE(controller != nil); + EXPECT_TRUE([[controller window] isVisible]); + [parent.get() close]; + } +} + +} // namespace + + diff --git a/chrome/browser/ui/cocoa/content_settings_dialog_controller.h b/chrome/browser/ui/cocoa/content_settings_dialog_controller.h new file mode 100644 index 0000000..432a3dc --- /dev/null +++ b/chrome/browser/ui/cocoa/content_settings_dialog_controller.h @@ -0,0 +1,102 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#import "base/cocoa_protocols_mac.h" +#include "base/scoped_ptr.h" +#include "chrome/common/content_settings_types.h" +#include "chrome/browser/prefs/pref_change_registrar.h" +#include "chrome/browser/prefs/pref_member.h" + +// Index of the "enabled" and "disabled" radio group settings in all tabs except +// the ones below. +const NSInteger kContentSettingsEnabledIndex = 0; +const NSInteger kContentSettingsDisabledIndex = 1; + +// Indices of the various cookie settings in the cookie radio group. +const NSInteger kCookieEnabledIndex = 0; +const NSInteger kCookieDisabledIndex = 1; + +// Indices of the various plugin settings in the plugins radio group. +const NSInteger kPluginsAllowIndex = 0; +const NSInteger kPluginsAskIndex = 1; +const NSInteger kPluginsBlockIndex = 2; + +// Indices of the various geolocation settings in the geolocation radio group. +const NSInteger kGeolocationEnabledIndex = 0; +const NSInteger kGeolocationAskIndex = 1; +const NSInteger kGeolocationDisabledIndex = 2; + +// Indices of the various notifications settings in the geolocation radio group. +const NSInteger kNotificationsEnabledIndex = 0; +const NSInteger kNotificationsAskIndex = 1; +const NSInteger kNotificationsDisabledIndex = 2; + +namespace ContentSettingsDialogControllerInternal { +class PrefObserverBridge; +} + +class Profile; +@class TabViewPickerTable; + +// This controller manages a dialog that lets the user manage the content +// settings for several content setting types. +@interface ContentSettingsDialogController + : NSWindowController<NSWindowDelegate, NSTabViewDelegate> { + @private + IBOutlet NSTabView* tabView_; + IBOutlet TabViewPickerTable* tabViewPicker_; + IBOutlet NSMatrix* pluginDefaultSettingMatrix_; + Profile* profile_; // weak + IntegerPrefMember lastSelectedTab_; + BooleanPrefMember clearSiteDataOnExit_; + PrefChangeRegistrar registrar_; + scoped_ptr<ContentSettingsDialogControllerInternal::PrefObserverBridge> + observer_; // Watches for pref changes. +} + +// Show the content settings dialog associated with the given profile (or the +// original profile if this is an incognito profile). If no content settings +// dialog exists for this profile, create one and show it. Any resulting +// editor releases itself when closed. ++(id)showContentSettingsForType:(ContentSettingsType)settingsType + profile:(Profile*)profile; + +// Closes an exceptions sheet, if one is attached. +- (void)closeExceptionsSheet; + +- (IBAction)showCookies:(id)sender; +- (IBAction)openFlashPlayerSettings:(id)sender; +- (IBAction)openPluginsPage:(id)sender; + +- (IBAction)showCookieExceptions:(id)sender; +- (IBAction)showImagesExceptions:(id)sender; +- (IBAction)showJavaScriptExceptions:(id)sender; +- (IBAction)showPluginsExceptions:(id)sender; +- (IBAction)showPopupsExceptions:(id)sender; +- (IBAction)showGeolocationExceptions:(id)sender; +- (IBAction)showNotificationsExceptions:(id)sender; + +@end + +@interface ContentSettingsDialogController (TestingAPI) +// Properties that the radio groups and checkboxes are bound to. +@property(nonatomic) NSInteger cookieSettingIndex; +@property(nonatomic) BOOL blockThirdPartyCookies; +@property(nonatomic) BOOL clearSiteDataOnExit; +@property(nonatomic) NSInteger imagesEnabledIndex; +@property(nonatomic) NSInteger javaScriptEnabledIndex; +@property(nonatomic) NSInteger popupsEnabledIndex; +@property(nonatomic) NSInteger pluginsEnabledIndex; +@property(nonatomic) NSInteger geolocationSettingIndex; +@property(nonatomic) NSInteger notificationsSettingIndex; + +@property(nonatomic, readonly) BOOL blockThirdPartyCookiesManaged; +@property(nonatomic, readonly) BOOL cookieSettingsManaged; +@property(nonatomic, readonly) BOOL imagesSettingsManaged; +@property(nonatomic, readonly) BOOL javaScriptSettingsManaged; +@property(nonatomic, readonly) BOOL pluginsSettingsManaged; +@property(nonatomic, readonly) BOOL popupsSettingsManaged; +@end diff --git a/chrome/browser/ui/cocoa/content_settings_dialog_controller.mm b/chrome/browser/ui/cocoa/content_settings_dialog_controller.mm new file mode 100644 index 0000000..7a574d0 --- /dev/null +++ b/chrome/browser/ui/cocoa/content_settings_dialog_controller.mm @@ -0,0 +1,647 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/content_settings_dialog_controller.h" + +#import <Cocoa/Cocoa.h> + +#include "app/l10n_util.h" +#include "base/command_line.h" +#include "base/mac_util.h" +#import "chrome/browser/content_settings/host_content_settings_map.h" +#import "chrome/browser/geolocation/geolocation_content_settings_map.h" +#import "chrome/browser/geolocation/geolocation_exceptions_table_model.h" +#import "chrome/browser/notifications/desktop_notification_service.h" +#import "chrome/browser/notifications/notification_exceptions_table_model.h" +#include "chrome/browser/plugin_exceptions_table_model.h" +#include "chrome/browser/prefs/pref_service.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/ui/browser.h" +#include "chrome/browser/ui/browser_window.h" +#import "chrome/browser/ui/cocoa/content_exceptions_window_controller.h" +#import "chrome/browser/ui/cocoa/cookies_window_controller.h" +#import "chrome/browser/ui/cocoa/l10n_util.h" +#import "chrome/browser/ui/cocoa/simple_content_exceptions_window_controller.h" +#import "chrome/browser/ui/cocoa/tab_view_picker_table.h" +#include "chrome/common/chrome_switches.h" +#include "chrome/common/notification_service.h" +#include "chrome/common/pref_names.h" +#include "chrome/common/url_constants.h" +#include "grit/locale_settings.h" +#include "grit/generated_resources.h" + +namespace { + +// Stores the currently visible content settings dialog, if any. +ContentSettingsDialogController* g_instance = nil; + +} // namespace + + +@interface ContentSettingsDialogController(Private) +- (id)initWithProfile:(Profile*)profile; +- (void)selectTab:(ContentSettingsType)settingsType; +- (void)showExceptionsForType:(ContentSettingsType)settingsType; + +// Callback when preferences are changed. |prefName| is the name of the +// pref that has changed. +- (void)prefChanged:(const std::string&)prefName; + +// Callback when content settings are changed. +- (void)contentSettingsChanged: + (HostContentSettingsMap::ContentSettingsDetails*)details; + +@end + +namespace ContentSettingsDialogControllerInternal { + +// A C++ class registered for changes in preferences. +class PrefObserverBridge : public NotificationObserver { + public: + PrefObserverBridge(ContentSettingsDialogController* controller) + : controller_(controller), disabled_(false) {} + + virtual ~PrefObserverBridge() {} + + virtual void Observe(NotificationType type, + const NotificationSource& source, + const NotificationDetails& details) { + if (disabled_) + return; + + // This is currently used by most notifications. + if (type == NotificationType::PREF_CHANGED) { + std::string* detail = Details<std::string>(details).ptr(); + if (detail) + [controller_ prefChanged:*detail]; + } + + // This is sent when the "is managed" state changes. + // TODO(markusheintz): Move all content settings to this notification. + if (type == NotificationType::CONTENT_SETTINGS_CHANGED) { + HostContentSettingsMap::ContentSettingsDetails* settings_details = + Details<HostContentSettingsMap::ContentSettingsDetails>(details).ptr(); + [controller_ contentSettingsChanged:settings_details]; + } + } + + void SetDisabled(bool disabled) { + disabled_ = disabled; + } + + private: + ContentSettingsDialogController* controller_; // weak, owns us + bool disabled_; // true if notifications should be ignored. +}; + +// A C++ utility class to disable notifications for PrefsObserverBridge. +// The intended usage is to create this on the stack. +class PrefObserverDisabler { + public: + PrefObserverDisabler(PrefObserverBridge *bridge) : bridge_(bridge) { + bridge_->SetDisabled(true); + } + + ~PrefObserverDisabler() { + bridge_->SetDisabled(false); + } + + private: + PrefObserverBridge *bridge_; +}; + +} // ContentSettingsDialogControllerInternal + +@implementation ContentSettingsDialogController + ++ (id)showContentSettingsForType:(ContentSettingsType)settingsType + profile:(Profile*)profile { + profile = profile->GetOriginalProfile(); + if (!g_instance) + g_instance = [[self alloc] initWithProfile:profile]; + + // The code doesn't expect multiple profiles. Check that support for that + // hasn't been added. + DCHECK(g_instance->profile_ == profile); + + // Select desired tab. + if (settingsType == CONTENT_SETTINGS_TYPE_DEFAULT) { + // Remember the last visited page from local state. + int value = g_instance->lastSelectedTab_.GetValue(); + if (value >= 0 && value < CONTENT_SETTINGS_NUM_TYPES) + settingsType = static_cast<ContentSettingsType>(value); + if (settingsType == CONTENT_SETTINGS_TYPE_DEFAULT) + settingsType = CONTENT_SETTINGS_TYPE_COOKIES; + } + // TODO(thakis): Autosave window pos. + + [g_instance selectTab:settingsType]; + [g_instance showWindow:nil]; + [g_instance closeExceptionsSheet]; + return g_instance; +} + +- (id)initWithProfile:(Profile*)profile { + DCHECK(profile); + NSString* nibpath = + [mac_util::MainAppBundle() pathForResource:@"ContentSettings" + ofType:@"nib"]; + if ((self = [super initWithWindowNibPath:nibpath owner:self])) { + profile_ = profile; + + observer_.reset( + new ContentSettingsDialogControllerInternal::PrefObserverBridge(self)); + clearSiteDataOnExit_.Init(prefs::kClearSiteDataOnExit, + profile_->GetPrefs(), observer_.get()); + + // Manually observe notifications for preferences that are grouped in + // the HostContentSettingsMap or GeolocationContentSettingsMap. + PrefService* prefs = profile_->GetPrefs(); + registrar_.Init(prefs); + registrar_.Add(prefs::kBlockThirdPartyCookies, observer_.get()); + registrar_.Add(prefs::kBlockNonsandboxedPlugins, observer_.get()); + registrar_.Add(prefs::kDefaultContentSettings, observer_.get()); + registrar_.Add(prefs::kGeolocationDefaultContentSetting, observer_.get()); + + // We don't need to observe changes in this value. + lastSelectedTab_.Init(prefs::kContentSettingsWindowLastTabIndex, + profile_->GetPrefs(), NULL); + } + return self; +} + +- (void)closeExceptionsSheet { + NSWindow* attachedSheet = [[self window] attachedSheet]; + if (attachedSheet) { + [NSApp endSheet:attachedSheet]; + } +} + +- (void)awakeFromNib { + DCHECK([self window]); + DCHECK(tabView_); + DCHECK(tabViewPicker_); + DCHECK_EQ(self, [[self window] delegate]); + + // Adapt views to potentially long localized strings. + CGFloat windowDelta = 0; + for (NSTabViewItem* tab in [tabView_ tabViewItems]) { + NSArray* subviews = [[tab view] subviews]; + windowDelta = MAX(windowDelta, + cocoa_l10n_util::VerticallyReflowGroup(subviews)); + + for (NSView* view in subviews) { + // Since the tab pane is in a horizontal resizer in IB, it's convenient + // to give all the subviews flexible width so that their sizes are + // autoupdated in IB. However, in chrome, the subviews shouldn't have + // flexible widths as this looks weird. + [view setAutoresizingMask:NSViewMaxXMargin | NSViewMinYMargin]; + } + } + + NSString* label = + l10n_util::GetNSStringWithFixup(IDS_CONTENT_SETTINGS_FEATURES_LABEL); + label = [label stringByReplacingOccurrencesOfString:@":" withString:@""]; + [tabViewPicker_ setHeading:label]; + + if (!CommandLine::ForCurrentProcess()->HasSwitch( + switches::kEnableClickToPlay)) { + // The |pluginsEnabledIndex| property is bound to the selected *tag*, + // so we don't have to worry about index shifts when removing a row + // from the matrix. + [pluginDefaultSettingMatrix_ removeRow:kPluginsAskIndex]; + NSArray* siblingViews = [[pluginDefaultSettingMatrix_ superview] subviews]; + for (NSView* view in siblingViews) { + NSRect frame = [view frame]; + if (frame.origin.y < [pluginDefaultSettingMatrix_ frame].origin.y) { + frame.origin.y += + ([pluginDefaultSettingMatrix_ cellSize].height + + [pluginDefaultSettingMatrix_ intercellSpacing].height); + [view setFrame:frame]; + } + } + } + + NSRect frame = [[self window] frame]; + frame.origin.y -= windowDelta; + frame.size.height += windowDelta; + [[self window] setFrame:frame display:NO]; +} + +// NSWindowDelegate method. +- (void)windowWillClose:(NSNotification*)notification { + [self autorelease]; + g_instance = nil; +} + +- (void)selectTab:(ContentSettingsType)settingsType { + [self window]; // Make sure the nib file is loaded. + DCHECK(tabView_); + [tabView_ selectTabViewItemAtIndex:settingsType]; +} + +// NSTabViewDelegate method. +- (void) tabView:(NSTabView*)tabView + didSelectTabViewItem:(NSTabViewItem*)tabViewItem { + DCHECK_EQ(tabView_, tabView); + NSInteger index = [tabView indexOfTabViewItem:tabViewItem]; + DCHECK_GT(index, CONTENT_SETTINGS_TYPE_DEFAULT); + DCHECK_LT(index, CONTENT_SETTINGS_NUM_TYPES); + if (index > CONTENT_SETTINGS_TYPE_DEFAULT && + index < CONTENT_SETTINGS_NUM_TYPES) + lastSelectedTab_.SetValue(index); +} + +// Let esc close the window. +- (void)cancel:(id)sender { + [self close]; +} + +- (void)setCookieSettingIndex:(NSInteger)value { + ContentSetting setting = CONTENT_SETTING_DEFAULT; + switch (value) { + case kCookieEnabledIndex: setting = CONTENT_SETTING_ALLOW; break; + case kCookieDisabledIndex: setting = CONTENT_SETTING_BLOCK; break; + default: + NOTREACHED(); + } + ContentSettingsDialogControllerInternal::PrefObserverDisabler + disabler(observer_.get()); + profile_->GetHostContentSettingsMap()->SetDefaultContentSetting( + CONTENT_SETTINGS_TYPE_COOKIES, + setting); +} + +- (NSInteger)cookieSettingIndex { + switch (profile_->GetHostContentSettingsMap()->GetDefaultContentSetting( + CONTENT_SETTINGS_TYPE_COOKIES)) { + case CONTENT_SETTING_ALLOW: return kCookieEnabledIndex; + case CONTENT_SETTING_BLOCK: return kCookieDisabledIndex; + default: + NOTREACHED(); + return kCookieEnabledIndex; + } +} + +- (BOOL)cookieSettingsManaged { + return profile_->GetHostContentSettingsMap()->IsDefaultContentSettingManaged( + CONTENT_SETTINGS_TYPE_COOKIES); +} + +- (BOOL)blockThirdPartyCookies { + HostContentSettingsMap* settingsMap = profile_->GetHostContentSettingsMap(); + return settingsMap->BlockThirdPartyCookies(); +} + +- (void)setBlockThirdPartyCookies:(BOOL)value { + HostContentSettingsMap* settingsMap = profile_->GetHostContentSettingsMap(); + ContentSettingsDialogControllerInternal::PrefObserverDisabler + disabler(observer_.get()); + settingsMap->SetBlockThirdPartyCookies(value); +} + +- (BOOL)blockThirdPartyCookiesManaged { + HostContentSettingsMap* settingsMap = profile_->GetHostContentSettingsMap(); + return settingsMap->IsBlockThirdPartyCookiesManaged(); +} + +- (BOOL)clearSiteDataOnExit { + return clearSiteDataOnExit_.GetValue(); +} + +- (void)setClearSiteDataOnExit:(BOOL)value { + ContentSettingsDialogControllerInternal::PrefObserverDisabler + disabler(observer_.get()); + clearSiteDataOnExit_.SetValue(value); +} + +// Shows the cookies controller. +- (IBAction)showCookies:(id)sender { + // The cookie controller will autorelease itself when it's closed. + BrowsingDataDatabaseHelper* databaseHelper = + new BrowsingDataDatabaseHelper(profile_); + BrowsingDataLocalStorageHelper* storageHelper = + new BrowsingDataLocalStorageHelper(profile_); + BrowsingDataAppCacheHelper* appcacheHelper = + new BrowsingDataAppCacheHelper(profile_); + BrowsingDataIndexedDBHelper* indexedDBHelper = + BrowsingDataIndexedDBHelper::Create(profile_); + CookiesWindowController* controller = + [[CookiesWindowController alloc] initWithProfile:profile_ + databaseHelper:databaseHelper + storageHelper:storageHelper + appcacheHelper:appcacheHelper + indexedDBHelper:indexedDBHelper]; + [controller attachSheetTo:[self window]]; +} + +// Called when the user clicks the "Flash Player storage settings" button. +- (IBAction)openFlashPlayerSettings:(id)sender { + Browser* browser = Browser::Create(profile_); + browser->OpenURL(GURL(l10n_util::GetStringUTF8(IDS_FLASH_STORAGE_URL)), + GURL(), NEW_FOREGROUND_TAB, PageTransition::LINK); + browser->window()->Show(); +} + +// Called when the user clicks the "Disable individual plug-ins..." button. +- (IBAction)openPluginsPage:(id)sender { + Browser* browser = Browser::Create(profile_); + browser->OpenURL(GURL(chrome::kChromeUIPluginsURL), + GURL(), NEW_FOREGROUND_TAB, PageTransition::LINK); + browser->window()->Show(); +} + +- (IBAction)showCookieExceptions:(id)sender { + [self showExceptionsForType:CONTENT_SETTINGS_TYPE_COOKIES]; +} + +- (IBAction)showImagesExceptions:(id)sender { + [self showExceptionsForType:CONTENT_SETTINGS_TYPE_IMAGES]; +} + +- (IBAction)showJavaScriptExceptions:(id)sender { + [self showExceptionsForType:CONTENT_SETTINGS_TYPE_JAVASCRIPT]; +} + +- (IBAction)showPluginsExceptions:(id)sender { + if (CommandLine::ForCurrentProcess()->HasSwitch( + switches::kEnableResourceContentSettings)) { + HostContentSettingsMap* settingsMap = profile_->GetHostContentSettingsMap(); + HostContentSettingsMap* offTheRecordSettingsMap = + profile_->HasOffTheRecordProfile() ? + profile_->GetOffTheRecordProfile()->GetHostContentSettingsMap() : + NULL; + PluginExceptionsTableModel* model = + new PluginExceptionsTableModel(settingsMap, offTheRecordSettingsMap); + model->LoadSettings(); + [[SimpleContentExceptionsWindowController controllerWithTableModel:model] + attachSheetTo:[self window]]; + } else { + [self showExceptionsForType:CONTENT_SETTINGS_TYPE_PLUGINS]; + } +} + +- (IBAction)showPopupsExceptions:(id)sender { + [self showExceptionsForType:CONTENT_SETTINGS_TYPE_POPUPS]; +} + +- (IBAction)showGeolocationExceptions:(id)sender { + GeolocationContentSettingsMap* settingsMap = + profile_->GetGeolocationContentSettingsMap(); + GeolocationExceptionsTableModel* model = // Freed by window controller. + new GeolocationExceptionsTableModel(settingsMap); + [[SimpleContentExceptionsWindowController controllerWithTableModel:model] + attachSheetTo:[self window]]; +} + +- (IBAction)showNotificationsExceptions:(id)sender { + DesktopNotificationService* service = + profile_->GetDesktopNotificationService(); + NotificationExceptionsTableModel* model = // Freed by window controller. + new NotificationExceptionsTableModel(service); + [[SimpleContentExceptionsWindowController controllerWithTableModel:model] + attachSheetTo:[self window]]; +} + +- (void)showExceptionsForType:(ContentSettingsType)settingsType { + HostContentSettingsMap* settingsMap = profile_->GetHostContentSettingsMap(); + HostContentSettingsMap* offTheRecordSettingsMap = + profile_->HasOffTheRecordProfile() ? + profile_->GetOffTheRecordProfile()->GetHostContentSettingsMap() : + NULL; + [[ContentExceptionsWindowController controllerForType:settingsType + settingsMap:settingsMap + otrSettingsMap:offTheRecordSettingsMap] + attachSheetTo:[self window]]; +} + +- (void)setImagesEnabledIndex:(NSInteger)value { + ContentSetting setting = value == kContentSettingsEnabledIndex ? + CONTENT_SETTING_ALLOW : CONTENT_SETTING_BLOCK; + ContentSettingsDialogControllerInternal::PrefObserverDisabler + disabler(observer_.get()); + profile_->GetHostContentSettingsMap()->SetDefaultContentSetting( + CONTENT_SETTINGS_TYPE_IMAGES, setting); +} + +- (NSInteger)imagesEnabledIndex { + HostContentSettingsMap* settingsMap = profile_->GetHostContentSettingsMap(); + bool enabled = + settingsMap->GetDefaultContentSetting(CONTENT_SETTINGS_TYPE_IMAGES) == + CONTENT_SETTING_ALLOW; + return enabled ? kContentSettingsEnabledIndex : kContentSettingsDisabledIndex; +} + +- (BOOL)imagesSettingsManaged { + return profile_->GetHostContentSettingsMap()->IsDefaultContentSettingManaged( + CONTENT_SETTINGS_TYPE_IMAGES); +} + +- (void)setJavaScriptEnabledIndex:(NSInteger)value { + ContentSetting setting = value == kContentSettingsEnabledIndex ? + CONTENT_SETTING_ALLOW : CONTENT_SETTING_BLOCK; + ContentSettingsDialogControllerInternal::PrefObserverDisabler + disabler(observer_.get()); + profile_->GetHostContentSettingsMap()->SetDefaultContentSetting( + CONTENT_SETTINGS_TYPE_JAVASCRIPT, setting); +} + +- (NSInteger)javaScriptEnabledIndex { + HostContentSettingsMap* settingsMap = profile_->GetHostContentSettingsMap(); + bool enabled = + settingsMap->GetDefaultContentSetting(CONTENT_SETTINGS_TYPE_JAVASCRIPT) == + CONTENT_SETTING_ALLOW; + return enabled ? kContentSettingsEnabledIndex : kContentSettingsDisabledIndex; +} + +- (BOOL)javaScriptSettingsManaged { + return profile_->GetHostContentSettingsMap()->IsDefaultContentSettingManaged( + CONTENT_SETTINGS_TYPE_JAVASCRIPT); +} + +- (void)setPluginsEnabledIndex:(NSInteger)value { + ContentSetting setting = CONTENT_SETTING_DEFAULT; + switch (value) { + case kPluginsAllowIndex: + setting = CONTENT_SETTING_ALLOW; + break; + case kPluginsAskIndex: + setting = CONTENT_SETTING_ASK; + break; + case kPluginsBlockIndex: + setting = CONTENT_SETTING_BLOCK; + break; + default: + NOTREACHED(); + } + ContentSettingsDialogControllerInternal::PrefObserverDisabler + disabler(observer_.get()); + profile_->GetHostContentSettingsMap()->SetDefaultContentSetting( + CONTENT_SETTINGS_TYPE_PLUGINS, setting); +} + +- (NSInteger)pluginsEnabledIndex { + HostContentSettingsMap* map = profile_->GetHostContentSettingsMap(); + ContentSetting setting = + map->GetDefaultContentSetting(CONTENT_SETTINGS_TYPE_PLUGINS); + switch (setting) { + case CONTENT_SETTING_ALLOW: + return kPluginsAllowIndex; + case CONTENT_SETTING_ASK: + if (CommandLine::ForCurrentProcess()->HasSwitch( + switches::kEnableClickToPlay)) + return kPluginsAskIndex; + // Fall through to the next case. + case CONTENT_SETTING_BLOCK: + return kPluginsBlockIndex; + default: + NOTREACHED(); + return kPluginsAllowIndex; + } +} + +- (BOOL)pluginsSettingsManaged { + return profile_->GetHostContentSettingsMap()->IsDefaultContentSettingManaged( + CONTENT_SETTINGS_TYPE_PLUGINS); +} + +- (void)setPopupsEnabledIndex:(NSInteger)value { + ContentSetting setting = value == kContentSettingsEnabledIndex ? + CONTENT_SETTING_ALLOW : CONTENT_SETTING_BLOCK; + ContentSettingsDialogControllerInternal::PrefObserverDisabler + disabler(observer_.get()); + profile_->GetHostContentSettingsMap()->SetDefaultContentSetting( + CONTENT_SETTINGS_TYPE_POPUPS, setting); +} + +- (NSInteger)popupsEnabledIndex { + HostContentSettingsMap* settingsMap = profile_->GetHostContentSettingsMap(); + bool enabled = + settingsMap->GetDefaultContentSetting(CONTENT_SETTINGS_TYPE_POPUPS) == + CONTENT_SETTING_ALLOW; + return enabled ? kContentSettingsEnabledIndex : kContentSettingsDisabledIndex; +} + +- (BOOL)popupsSettingsManaged { + return profile_->GetHostContentSettingsMap()->IsDefaultContentSettingManaged( + CONTENT_SETTINGS_TYPE_POPUPS); +} + +- (void)setGeolocationSettingIndex:(NSInteger)value { + ContentSetting setting = CONTENT_SETTING_DEFAULT; + switch (value) { + case kGeolocationEnabledIndex: setting = CONTENT_SETTING_ALLOW; break; + case kGeolocationAskIndex: setting = CONTENT_SETTING_ASK; break; + case kGeolocationDisabledIndex: setting = CONTENT_SETTING_BLOCK; break; + default: + NOTREACHED(); + } + ContentSettingsDialogControllerInternal::PrefObserverDisabler + disabler(observer_.get()); + profile_->GetGeolocationContentSettingsMap()->SetDefaultContentSetting( + setting); +} + +- (NSInteger)geolocationSettingIndex { + ContentSetting setting = + profile_->GetGeolocationContentSettingsMap()->GetDefaultContentSetting(); + switch (setting) { + case CONTENT_SETTING_ALLOW: return kGeolocationEnabledIndex; + case CONTENT_SETTING_ASK: return kGeolocationAskIndex; + case CONTENT_SETTING_BLOCK: return kGeolocationDisabledIndex; + default: + NOTREACHED(); + return kGeolocationAskIndex; + } +} + +- (void)setNotificationsSettingIndex:(NSInteger)value { + ContentSetting setting = CONTENT_SETTING_DEFAULT; + switch (value) { + case kNotificationsEnabledIndex: setting = CONTENT_SETTING_ALLOW; break; + case kNotificationsAskIndex: setting = CONTENT_SETTING_ASK; break; + case kNotificationsDisabledIndex: setting = CONTENT_SETTING_BLOCK; break; + default: + NOTREACHED(); + } + ContentSettingsDialogControllerInternal::PrefObserverDisabler + disabler(observer_.get()); + profile_->GetDesktopNotificationService()->SetDefaultContentSetting( + setting); +} + +- (NSInteger)notificationsSettingIndex { + ContentSetting setting = + profile_->GetDesktopNotificationService()->GetDefaultContentSetting(); + switch (setting) { + case CONTENT_SETTING_ALLOW: return kNotificationsEnabledIndex; + case CONTENT_SETTING_ASK: return kNotificationsAskIndex; + case CONTENT_SETTING_BLOCK: return kNotificationsDisabledIndex; + default: + NOTREACHED(); + return kGeolocationAskIndex; + } +} + +// Callback when preferences are changed. |prefName| is the name of the +// pref that has changed and should not be NULL. +- (void)prefChanged:(const std::string&)prefName { + if (prefName == prefs::kClearSiteDataOnExit) { + [self willChangeValueForKey:@"clearSiteDataOnExit"]; + [self didChangeValueForKey:@"clearSiteDataOnExit"]; + } + if (prefName == prefs::kBlockThirdPartyCookies) { + [self willChangeValueForKey:@"blockThirdPartyCookies"]; + [self didChangeValueForKey:@"blockThirdPartyCookies"]; + [self willChangeValueForKey:@"blockThirdPartyCookiesManaged"]; + [self didChangeValueForKey:@"blockThirdPartyCookiesManaged"]; + } + if (prefName == prefs::kBlockNonsandboxedPlugins) { + [self willChangeValueForKey:@"pluginsEnabledIndex"]; + [self didChangeValueForKey:@"pluginsEnabledIndex"]; + } + if (prefName == prefs::kDefaultContentSettings) { + // We don't know exactly which setting has changed, so we'll tickle all + // of the properties that apply to kDefaultContentSettings. This will + // keep the UI up-to-date. + [self willChangeValueForKey:@"cookieSettingIndex"]; + [self didChangeValueForKey:@"cookieSettingIndex"]; + [self willChangeValueForKey:@"imagesEnabledIndex"]; + [self didChangeValueForKey:@"imagesEnabledIndex"]; + [self willChangeValueForKey:@"javaScriptEnabledIndex"]; + [self didChangeValueForKey:@"javaScriptEnabledIndex"]; + [self willChangeValueForKey:@"pluginsEnabledIndex"]; + [self didChangeValueForKey:@"pluginsEnabledIndex"]; + [self willChangeValueForKey:@"popupsEnabledIndex"]; + [self didChangeValueForKey:@"popupsEnabledIndex"]; + + // Updates the "Enable" state of the radio groups and the exception buttons. + [self willChangeValueForKey:@"cookieSettingsManaged"]; + [self didChangeValueForKey:@"cookieSettingsManaged"]; + [self willChangeValueForKey:@"imagesSettingsManaged"]; + [self didChangeValueForKey:@"imagesSettingsManaged"]; + [self willChangeValueForKey:@"javaScriptSettingsManaged"]; + [self didChangeValueForKey:@"javaScriptSettingsManaged"]; + [self willChangeValueForKey:@"pluginsSettingsManaged"]; + [self didChangeValueForKey:@"pluginsSettingsManaged"]; + [self willChangeValueForKey:@"popupsSettingsManaged"]; + [self didChangeValueForKey:@"popupsSettingsManaged"]; + } + if (prefName == prefs::kGeolocationDefaultContentSetting) { + [self willChangeValueForKey:@"geolocationSettingIndex"]; + [self didChangeValueForKey:@"geolocationSettingIndex"]; + } + if (prefName == prefs::kDesktopNotificationDefaultContentSetting) { + [self willChangeValueForKey:@"notificationsSettingIndex"]; + [self didChangeValueForKey:@"notificationsSettingIndex"]; + } +} + +- (void)contentSettingsChanged: + (HostContentSettingsMap::ContentSettingsDetails*)details { + [self prefChanged:prefs::kBlockNonsandboxedPlugins]; + [self prefChanged:prefs::kDefaultContentSettings]; +} + +@end diff --git a/chrome/browser/ui/cocoa/content_settings_dialog_controller_unittest.mm b/chrome/browser/ui/cocoa/content_settings_dialog_controller_unittest.mm new file mode 100644 index 0000000..1d48509 --- /dev/null +++ b/chrome/browser/ui/cocoa/content_settings_dialog_controller_unittest.mm @@ -0,0 +1,289 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/content_settings_dialog_controller.h" + +#include "base/auto_reset.h" +#include "base/command_line.h" +#import "base/scoped_nsobject.h" +#include "base/ref_counted.h" +#include "chrome/browser/content_settings/host_content_settings_map.h" +#include "chrome/browser/geolocation/geolocation_content_settings_map.h" +#include "chrome/browser/notifications/desktop_notification_service.h" +#include "chrome/browser/prefs/pref_service.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#include "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "chrome/common/pref_names.h" +#include "chrome/common/chrome_switches.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +namespace { + +class ContentSettingsDialogControllerTest : public CocoaTest { + public: + virtual void SetUp() { + CocoaTest::SetUp(); + TestingProfile* profile = browser_helper_.profile(); + settingsMap_ = new HostContentSettingsMap(profile); + geoSettingsMap_ = new GeolocationContentSettingsMap(profile); + notificationsService_.reset(new DesktopNotificationService(profile, NULL)); + controller_ = [ContentSettingsDialogController + showContentSettingsForType:CONTENT_SETTINGS_TYPE_DEFAULT + profile:browser_helper_.profile()]; + } + + virtual void TearDown() { + [controller_ close]; + CocoaTest::TearDown(); + } + + protected: + ContentSettingsDialogController* controller_; + BrowserTestHelper browser_helper_; + scoped_refptr<HostContentSettingsMap> settingsMap_; + scoped_refptr<GeolocationContentSettingsMap> geoSettingsMap_; + scoped_ptr<DesktopNotificationService> notificationsService_; +}; + +// Test that +showContentSettingsDialogForProfile brings up the existing editor +// and doesn't leak or crash. +TEST_F(ContentSettingsDialogControllerTest, CreateDialog) { + EXPECT_TRUE(controller_); +} + +TEST_F(ContentSettingsDialogControllerTest, CookieSetting) { + // Change setting, check dialog property. + settingsMap_->SetDefaultContentSetting(CONTENT_SETTINGS_TYPE_COOKIES, + CONTENT_SETTING_ALLOW); + EXPECT_EQ([controller_ cookieSettingIndex], kCookieEnabledIndex); + + settingsMap_->SetDefaultContentSetting(CONTENT_SETTINGS_TYPE_COOKIES, + CONTENT_SETTING_BLOCK); + EXPECT_EQ([controller_ cookieSettingIndex], kCookieDisabledIndex); + + // Change dialog property, check setting. + NSInteger setting; + [controller_ setCookieSettingIndex:kCookieEnabledIndex]; + setting = + settingsMap_->GetDefaultContentSetting(CONTENT_SETTINGS_TYPE_COOKIES); + EXPECT_EQ(setting, CONTENT_SETTING_ALLOW); + + [controller_ setCookieSettingIndex:kCookieDisabledIndex]; + setting = + settingsMap_->GetDefaultContentSetting(CONTENT_SETTINGS_TYPE_COOKIES); + EXPECT_EQ(setting, CONTENT_SETTING_BLOCK); +} + +TEST_F(ContentSettingsDialogControllerTest, BlockThirdPartyCookiesSetting) { + // Change setting, check dialog property. + settingsMap_->SetBlockThirdPartyCookies(YES); + EXPECT_TRUE([controller_ blockThirdPartyCookies]); + + settingsMap_->SetBlockThirdPartyCookies(NO); + EXPECT_FALSE([controller_ blockThirdPartyCookies]); + + // Change dialog property, check setting. + [controller_ setBlockThirdPartyCookies:YES]; + EXPECT_TRUE(settingsMap_->BlockThirdPartyCookies()); + + [controller_ setBlockThirdPartyCookies:NO]; + EXPECT_FALSE(settingsMap_->BlockThirdPartyCookies()); +} + +TEST_F(ContentSettingsDialogControllerTest, ClearSiteDataOnExitSetting) { + TestingProfile* profile = browser_helper_.profile(); + + // Change setting, check dialog property. + profile->GetPrefs()->SetBoolean(prefs::kClearSiteDataOnExit, true); + EXPECT_TRUE([controller_ clearSiteDataOnExit]); + + profile->GetPrefs()->SetBoolean(prefs::kClearSiteDataOnExit, false); + EXPECT_FALSE([controller_ clearSiteDataOnExit]); + + // Change dialog property, check setting. + [controller_ setClearSiteDataOnExit:YES]; + EXPECT_TRUE(profile->GetPrefs()->GetBoolean(prefs::kClearSiteDataOnExit)); + + [controller_ setClearSiteDataOnExit:NO]; + EXPECT_FALSE(profile->GetPrefs()->GetBoolean(prefs::kClearSiteDataOnExit)); +} + +TEST_F(ContentSettingsDialogControllerTest, ImagesSetting) { + // Change setting, check dialog property. + settingsMap_->SetDefaultContentSetting(CONTENT_SETTINGS_TYPE_IMAGES, + CONTENT_SETTING_ALLOW); + EXPECT_EQ([controller_ imagesEnabledIndex], kContentSettingsEnabledIndex); + + settingsMap_->SetDefaultContentSetting(CONTENT_SETTINGS_TYPE_IMAGES, + CONTENT_SETTING_BLOCK); + EXPECT_EQ([controller_ imagesEnabledIndex], kContentSettingsDisabledIndex); + + // Change dialog property, check setting. + NSInteger setting; + [controller_ setImagesEnabledIndex:kContentSettingsEnabledIndex]; + setting = + settingsMap_->GetDefaultContentSetting(CONTENT_SETTINGS_TYPE_IMAGES); + EXPECT_EQ(setting, CONTENT_SETTING_ALLOW); + + [controller_ setImagesEnabledIndex:kContentSettingsDisabledIndex]; + setting = + settingsMap_->GetDefaultContentSetting(CONTENT_SETTINGS_TYPE_IMAGES); + EXPECT_EQ(setting, CONTENT_SETTING_BLOCK); +} + +TEST_F(ContentSettingsDialogControllerTest, JavaScriptSetting) { + // Change setting, check dialog property. + settingsMap_->SetDefaultContentSetting(CONTENT_SETTINGS_TYPE_JAVASCRIPT, + CONTENT_SETTING_ALLOW); + EXPECT_EQ([controller_ javaScriptEnabledIndex], kContentSettingsEnabledIndex); + + settingsMap_->SetDefaultContentSetting(CONTENT_SETTINGS_TYPE_JAVASCRIPT, + CONTENT_SETTING_BLOCK); + EXPECT_EQ([controller_ javaScriptEnabledIndex], + kContentSettingsDisabledIndex); + + // Change dialog property, check setting. + NSInteger setting; + [controller_ setJavaScriptEnabledIndex:kContentSettingsEnabledIndex]; + setting = + settingsMap_->GetDefaultContentSetting(CONTENT_SETTINGS_TYPE_JAVASCRIPT); + EXPECT_EQ(setting, CONTENT_SETTING_ALLOW); + + [controller_ setJavaScriptEnabledIndex:kContentSettingsDisabledIndex]; + setting = + settingsMap_->GetDefaultContentSetting(CONTENT_SETTINGS_TYPE_JAVASCRIPT); + EXPECT_EQ(setting, CONTENT_SETTING_BLOCK); +} + +TEST_F(ContentSettingsDialogControllerTest, PluginsSetting) { + // Change setting, check dialog property. + settingsMap_->SetDefaultContentSetting(CONTENT_SETTINGS_TYPE_PLUGINS, + CONTENT_SETTING_ALLOW); + EXPECT_EQ(kPluginsAllowIndex, [controller_ pluginsEnabledIndex]); + + settingsMap_->SetDefaultContentSetting(CONTENT_SETTINGS_TYPE_PLUGINS, + CONTENT_SETTING_BLOCK); + EXPECT_EQ(kPluginsBlockIndex, [controller_ pluginsEnabledIndex]); + + { + // Click-to-play needs to be enabled to set the content setting to ASK. + CommandLine* cmd = CommandLine::ForCurrentProcess(); + AutoReset<CommandLine> auto_reset(cmd, *cmd); + cmd->AppendSwitch(switches::kEnableClickToPlay); + + settingsMap_->SetDefaultContentSetting(CONTENT_SETTINGS_TYPE_PLUGINS, + CONTENT_SETTING_ASK); + EXPECT_EQ(kPluginsAskIndex, [controller_ pluginsEnabledIndex]); + } + + // Change dialog property, check setting. + NSInteger setting; + [controller_ setPluginsEnabledIndex:kPluginsAllowIndex]; + setting = + settingsMap_->GetDefaultContentSetting(CONTENT_SETTINGS_TYPE_PLUGINS); + EXPECT_EQ(CONTENT_SETTING_ALLOW, setting); + + [controller_ setPluginsEnabledIndex:kPluginsBlockIndex]; + setting = + settingsMap_->GetDefaultContentSetting(CONTENT_SETTINGS_TYPE_PLUGINS); + EXPECT_EQ(CONTENT_SETTING_BLOCK, setting); + + { + CommandLine* cmd = CommandLine::ForCurrentProcess(); + AutoReset<CommandLine> auto_reset(cmd, *cmd); + cmd->AppendSwitch(switches::kEnableClickToPlay); + + [controller_ setPluginsEnabledIndex:kPluginsAskIndex]; + setting = + settingsMap_->GetDefaultContentSetting(CONTENT_SETTINGS_TYPE_PLUGINS); + EXPECT_EQ(CONTENT_SETTING_ASK, setting); + } +} + +TEST_F(ContentSettingsDialogControllerTest, PopupsSetting) { + // Change setting, check dialog property. + settingsMap_->SetDefaultContentSetting(CONTENT_SETTINGS_TYPE_POPUPS, + CONTENT_SETTING_ALLOW); + EXPECT_EQ([controller_ popupsEnabledIndex], kContentSettingsEnabledIndex); + + settingsMap_->SetDefaultContentSetting(CONTENT_SETTINGS_TYPE_POPUPS, + CONTENT_SETTING_BLOCK); + EXPECT_EQ([controller_ popupsEnabledIndex], kContentSettingsDisabledIndex); + + // Change dialog property, check setting. + NSInteger setting; + [controller_ setPopupsEnabledIndex:kContentSettingsEnabledIndex]; + setting = + settingsMap_->GetDefaultContentSetting(CONTENT_SETTINGS_TYPE_POPUPS); + EXPECT_EQ(setting, CONTENT_SETTING_ALLOW); + + [controller_ setPopupsEnabledIndex:kContentSettingsDisabledIndex]; + setting = + settingsMap_->GetDefaultContentSetting(CONTENT_SETTINGS_TYPE_POPUPS); + EXPECT_EQ(setting, CONTENT_SETTING_BLOCK); +} + +TEST_F(ContentSettingsDialogControllerTest, GeolocationSetting) { + // Change setting, check dialog property. + geoSettingsMap_->SetDefaultContentSetting(CONTENT_SETTING_ALLOW); + EXPECT_EQ([controller_ geolocationSettingIndex], kGeolocationEnabledIndex); + + geoSettingsMap_->SetDefaultContentSetting(CONTENT_SETTING_ASK); + EXPECT_EQ([controller_ geolocationSettingIndex], kGeolocationAskIndex); + + geoSettingsMap_->SetDefaultContentSetting(CONTENT_SETTING_BLOCK); + EXPECT_EQ([controller_ geolocationSettingIndex], kGeolocationDisabledIndex); + + // Change dialog property, check setting. + NSInteger setting; + [controller_ setGeolocationSettingIndex:kGeolocationEnabledIndex]; + setting = + geoSettingsMap_->GetDefaultContentSetting(); + EXPECT_EQ(setting, CONTENT_SETTING_ALLOW); + + [controller_ setGeolocationSettingIndex:kGeolocationAskIndex]; + setting = + geoSettingsMap_->GetDefaultContentSetting(); + EXPECT_EQ(setting, CONTENT_SETTING_ASK); + + [controller_ setGeolocationSettingIndex:kGeolocationDisabledIndex]; + setting = + geoSettingsMap_->GetDefaultContentSetting(); + EXPECT_EQ(setting, CONTENT_SETTING_BLOCK); +} + +TEST_F(ContentSettingsDialogControllerTest, NotificationsSetting) { + // Change setting, check dialog property. + notificationsService_->SetDefaultContentSetting(CONTENT_SETTING_ALLOW); + EXPECT_EQ([controller_ notificationsSettingIndex], + kNotificationsEnabledIndex); + + notificationsService_->SetDefaultContentSetting(CONTENT_SETTING_ASK); + EXPECT_EQ([controller_ notificationsSettingIndex], kNotificationsAskIndex); + + notificationsService_->SetDefaultContentSetting(CONTENT_SETTING_BLOCK); + EXPECT_EQ([controller_ notificationsSettingIndex], + kNotificationsDisabledIndex); + + // Change dialog property, check setting. + NSInteger setting; + [controller_ setNotificationsSettingIndex:kNotificationsEnabledIndex]; + setting = + notificationsService_->GetDefaultContentSetting(); + EXPECT_EQ(setting, CONTENT_SETTING_ALLOW); + + [controller_ setNotificationsSettingIndex:kNotificationsAskIndex]; + setting = + notificationsService_->GetDefaultContentSetting(); + EXPECT_EQ(setting, CONTENT_SETTING_ASK); + + [controller_ setNotificationsSettingIndex:kNotificationsDisabledIndex]; + setting = + notificationsService_->GetDefaultContentSetting(); + EXPECT_EQ(setting, CONTENT_SETTING_BLOCK); +} + +} // namespace + diff --git a/chrome/browser/ui/cocoa/cookie_details.h b/chrome/browser/ui/cocoa/cookie_details.h new file mode 100644 index 0000000..614c87c --- /dev/null +++ b/chrome/browser/ui/cocoa/cookie_details.h @@ -0,0 +1,224 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "chrome/browser/browsing_data_database_helper.h" +#include "chrome/browser/browsing_data_indexed_db_helper.h" +#include "chrome/browser/browsing_data_local_storage_helper.h" +#include "base/scoped_nsobject.h" +#include "net/base/cookie_monster.h" +#include "webkit/appcache/appcache_service.h" + +class CookieTreeNode; +class CookiePromptModalDialog; + +// This enum specifies the type of information contained in the +// cookie details. +enum CocoaCookieDetailsType { + // Represents grouping of cookie data, used in the cookie tree. + kCocoaCookieDetailsTypeFolder = 0, + + // Detailed information about a cookie, used both in the cookie + // tree and the cookie prompt. + kCocoaCookieDetailsTypeCookie, + + // Detailed information about a web database used for + // display in the cookie tree. + kCocoaCookieDetailsTypeTreeDatabase, + + // Detailed information about local storage used for + // display in the cookie tree. + kCocoaCookieDetailsTypeTreeLocalStorage, + + // Detailed information about an appcache used for display in the + // cookie tree. + kCocoaCookieDetailsTypeTreeAppCache, + + // Detailed information about an IndexedDB used for display in the + // cookie tree. + kCocoaCookieDetailsTypeTreeIndexedDB, + + // Detailed information about a web database used for display + // in the cookie prompt dialog. + kCocoaCookieDetailsTypePromptDatabase, + + // Detailed information about local storage used for display + // in the cookie prompt dialog. + kCocoaCookieDetailsTypePromptLocalStorage, + + // Detailed information about app caches used for display + // in the cookie prompt dialog. + kCocoaCookieDetailsTypePromptAppCache +}; + +// This class contains all of the information that can be displayed in +// a cookie details view. Because the view uses bindings to display +// the cookie information, the methods that provide that information +// for display must be implemented directly on this class and not on any +// of its subclasses. +// If this system is rewritten to not use bindings, this class should be +// subclassed and specialized, rather than using an enum to determine type. +@interface CocoaCookieDetails : NSObject { + @private + CocoaCookieDetailsType type_; + + // Used for type kCocoaCookieDetailsTypeCookie to indicate whether + // it should be possible to edit the expiration. + BOOL canEditExpiration_; + + // Indicates whether a cookie has an explcit expiration. If not + // it will expire with the session. + BOOL hasExpiration_; + + // Only set for type kCocoaCookieDetailsTypeCookie. + scoped_nsobject<NSString> content_; + scoped_nsobject<NSString> path_; + scoped_nsobject<NSString> sendFor_; + // Stringifed dates. + scoped_nsobject<NSString> expires_; + + // Only set for type kCocoaCookieDetailsTypeCookie and + // kCocoaCookieDetailsTypeTreeAppCache nodes. + scoped_nsobject<NSString> created_; + + // Only set for types kCocoaCookieDetailsTypeCookie, and + // kCocoaCookieDetailsTypePromptDatabase nodes. + scoped_nsobject<NSString> name_; + + // Only set for type kCocoaCookieDetailsTypeTreeLocalStorage, + // kCocoaCookieDetailsTypeTreeDatabase, + // kCocoaCookieDetailsTypePromptDatabase, + // kCocoaCookieDetailsTypeTreeIndexedDB, and + // kCocoaCookieDetailsTypeTreeAppCache nodes. + scoped_nsobject<NSString> fileSize_; + + // Only set for types kCocoaCookieDetailsTypeTreeLocalStorage, + // kCocoaCookieDetailsTypeTreeDatabase, and + // kCocoaCookieDetailsTypeTreeIndexedDB nodes. + scoped_nsobject<NSString> lastModified_; + + // Only set for type kCocoaCookieDetailsTypeTreeAppCache nodes. + scoped_nsobject<NSString> lastAccessed_; + + // Only set for type kCocoaCookieDetailsTypeCookie, + // kCocoaCookieDetailsTypePromptDatabase, + // kCocoaCookieDetailsTypePromptLocalStorage, and + // kCocoaCookieDetailsTypeTreeIndexedDB nodes. + scoped_nsobject<NSString> domain_; + + // Only set for type kCocoaCookieTreeNodeTypeDatabaseStorage and + // kCocoaCookieDetailsTypePromptDatabase nodes. + scoped_nsobject<NSString> databaseDescription_; + + // Only set for type kCocoaCookieDetailsTypePromptLocalStorage. + scoped_nsobject<NSString> localStorageKey_; + scoped_nsobject<NSString> localStorageValue_; + + // Only set for type kCocoaCookieDetailsTypeTreeAppCache and + // kCocoaCookieDetailsTypePromptAppCache. + scoped_nsobject<NSString> manifestURL_; +} + +@property (nonatomic, readonly) BOOL canEditExpiration; +@property (nonatomic) BOOL hasExpiration; +@property (nonatomic, readonly) CocoaCookieDetailsType type; + +// The following methods are used in the bindings of subviews inside +// the cookie detail view. Note that the method that tests the +// visibility of the subview for cookie-specific information has a different +// polarity than the other visibility testing methods. This ensures that +// this subview is shown when there is no selection in the cookie tree, +// because a hidden value of |false| is generated when the key value binding +// is evaluated through a nil object. The other methods are bound using a +// |NSNegateBoolean| transformer, so that when there is a empty selection the +// hidden value is |true|. +- (BOOL)shouldHideCookieDetailsView; +- (BOOL)shouldShowLocalStorageTreeDetailsView; +- (BOOL)shouldShowLocalStoragePromptDetailsView; +- (BOOL)shouldShowDatabaseTreeDetailsView; +- (BOOL)shouldShowDatabasePromptDetailsView; +- (BOOL)shouldShowAppCachePromptDetailsView; +- (BOOL)shouldShowAppCacheTreeDetailsView; +- (BOOL)shouldShowIndexedDBTreeDetailsView; + +- (NSString*)name; +- (NSString*)content; +- (NSString*)domain; +- (NSString*)path; +- (NSString*)sendFor; +- (NSString*)created; +- (NSString*)expires; +- (NSString*)fileSize; +- (NSString*)lastModified; +- (NSString*)lastAccessed; +- (NSString*)databaseDescription; +- (NSString*)localStorageKey; +- (NSString*)localStorageValue; +- (NSString*)manifestURL; + +// Used for folders in the cookie tree. +- (id)initAsFolder; + +// Used for cookie details in both the cookie tree and the cookie prompt dialog. +- (id)initWithCookie:(const net::CookieMonster::CanonicalCookie*)treeNode + origin:(NSString*)origin + canEditExpiration:(BOOL)canEditExpiration; + +// Used for database details in the cookie tree. +- (id)initWithDatabase: + (const BrowsingDataDatabaseHelper::DatabaseInfo*)databaseInfo; + +// Used for local storage details in the cookie tree. +- (id)initWithLocalStorage: + (const BrowsingDataLocalStorageHelper::LocalStorageInfo*)localStorageInfo; + +// Used for database details in the cookie prompt dialog. +- (id)initWithDatabase:(const std::string&)domain + databaseName:(const string16&)databaseName + databaseDescription:(const string16&)databaseDescription + fileSize:(unsigned long)fileSize; + +// -initWithAppCacheInfo: creates a cookie details with the manifest URL plus +// all of this additional information that is available after an appcache is +// actually created, including it's creation date, size and last accessed time. +- (id)initWithAppCacheInfo:(const appcache::AppCacheInfo*)appcacheInfo; + +// Used for local storage details in the cookie prompt dialog. +- (id)initWithLocalStorage:(const std::string&)domain + key:(const string16&)key + value:(const string16&)value; + +// -initWithAppCacheManifestURL: is called when the cookie prompt is displayed +// for an appcache, at that time only the manifest URL of the appcache is known. +- (id)initWithAppCacheManifestURL:(const std::string&)manifestURL; + +// Used for IndexedDB details in the cookie tree. +- (id)initWithIndexedDBInfo: + (const BrowsingDataIndexedDBHelper::IndexedDBInfo*)indexedDB; + +// A factory method to create a configured instance given a node from +// the cookie tree in |treeNode|. ++ (CocoaCookieDetails*)createFromCookieTreeNode:(CookieTreeNode*)treeNode; + +@end + +// The subpanes of the cookie details view expect to be able to bind to methods +// through a key path in the form |content.details.xxxx|. This class serves as +// an adapter that simply wraps a |CocoaCookieDetails| object. An instance of +// this class is set as the content object for cookie details view's object +// controller so that key paths are properly resolved through to the +// |CocoaCookieDetails| object for the cookie prompt. +@interface CookiePromptContentDetailsAdapter : NSObject { + @private + scoped_nsobject<CocoaCookieDetails> details_; +} + +- (CocoaCookieDetails*)details; + +// The adapter assumes ownership of the details object +// in its initializer. +- (id)initWithDetails:(CocoaCookieDetails*)details; +@end + diff --git a/chrome/browser/ui/cocoa/cookie_details.mm b/chrome/browser/ui/cocoa/cookie_details.mm new file mode 100644 index 0000000..c6f5cec --- /dev/null +++ b/chrome/browser/ui/cocoa/cookie_details.mm @@ -0,0 +1,299 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/cookie_details.h" + +#include "app/l10n_util_mac.h" +#import "base/i18n/time_formatting.h" +#include "base/sys_string_conversions.h" +#include "grit/generated_resources.h" +#include "chrome/browser/cookies_tree_model.h" +#include "webkit/appcache/appcache_service.h" + +#pragma mark Cocoa Cookie Details + +@implementation CocoaCookieDetails + +@synthesize canEditExpiration = canEditExpiration_; +@synthesize hasExpiration = hasExpiration_; +@synthesize type = type_; + +- (BOOL)shouldHideCookieDetailsView { + return type_ != kCocoaCookieDetailsTypeFolder && + type_ != kCocoaCookieDetailsTypeCookie; +} + +- (BOOL)shouldShowLocalStorageTreeDetailsView { + return type_ == kCocoaCookieDetailsTypeTreeLocalStorage; +} + +- (BOOL)shouldShowLocalStoragePromptDetailsView { + return type_ == kCocoaCookieDetailsTypePromptLocalStorage; +} + +- (BOOL)shouldShowDatabaseTreeDetailsView { + return type_ == kCocoaCookieDetailsTypeTreeDatabase; +} + +- (BOOL)shouldShowAppCacheTreeDetailsView { + return type_ == kCocoaCookieDetailsTypeTreeAppCache; +} + +- (BOOL)shouldShowDatabasePromptDetailsView { + return type_ == kCocoaCookieDetailsTypePromptDatabase; +} + +- (BOOL)shouldShowAppCachePromptDetailsView { + return type_ == kCocoaCookieDetailsTypePromptAppCache; +} + +- (BOOL)shouldShowIndexedDBTreeDetailsView { + return type_ == kCocoaCookieDetailsTypeTreeIndexedDB; +} + +- (NSString*)name { + return name_.get(); +} + +- (NSString*)content { + return content_.get(); +} + +- (NSString*)domain { + return domain_.get(); +} + +- (NSString*)path { + return path_.get(); +} + +- (NSString*)sendFor { + return sendFor_.get(); +} + +- (NSString*)created { + return created_.get(); +} + +- (NSString*)expires { + return expires_.get(); +} + +- (NSString*)fileSize { + return fileSize_.get(); +} + +- (NSString*)lastModified { + return lastModified_.get(); +} + +- (NSString*)lastAccessed { + return lastAccessed_.get(); +} + +- (NSString*)databaseDescription { + return databaseDescription_.get(); +} + +- (NSString*)localStorageKey { + return localStorageKey_.get(); +} + +- (NSString*)localStorageValue { + return localStorageValue_.get(); +} + +- (NSString*)manifestURL { + return manifestURL_.get(); +} + +- (id)initAsFolder { + if ((self = [super init])) { + type_ = kCocoaCookieDetailsTypeFolder; + } + return self; +} + +- (id)initWithCookie:(const net::CookieMonster::CanonicalCookie*)cookie + origin:(NSString*)origin + canEditExpiration:(BOOL)canEditExpiration { + if ((self = [super init])) { + type_ = kCocoaCookieDetailsTypeCookie; + hasExpiration_ = cookie->DoesExpire(); + canEditExpiration_ = canEditExpiration && hasExpiration_; + name_.reset([base::SysUTF8ToNSString(cookie->Name()) retain]); + content_.reset([base::SysUTF8ToNSString(cookie->Value()) retain]); + path_.reset([base::SysUTF8ToNSString(cookie->Path()) retain]); + domain_.reset([origin retain]); + + if (cookie->DoesExpire()) { + expires_.reset([base::SysWideToNSString( + base::TimeFormatFriendlyDateAndTime(cookie->ExpiryDate())) retain]); + } else { + expires_.reset([l10n_util::GetNSStringWithFixup( + IDS_COOKIES_COOKIE_EXPIRES_SESSION) retain]); + } + + created_.reset([base::SysWideToNSString( + base::TimeFormatFriendlyDateAndTime(cookie->CreationDate())) retain]); + + if (cookie->IsSecure()) { + sendFor_.reset([l10n_util::GetNSStringWithFixup( + IDS_COOKIES_COOKIE_SENDFOR_SECURE) retain]); + } else { + sendFor_.reset([l10n_util::GetNSStringWithFixup( + IDS_COOKIES_COOKIE_SENDFOR_ANY) retain]); + } + } + return self; +} + +- (id)initWithDatabase:(const BrowsingDataDatabaseHelper::DatabaseInfo*) + databaseInfo { + if ((self = [super init])) { + type_ = kCocoaCookieDetailsTypeTreeDatabase; + canEditExpiration_ = NO; + databaseDescription_.reset([base::SysUTF8ToNSString( + databaseInfo->description) retain]); + fileSize_.reset([base::SysUTF16ToNSString(FormatBytes(databaseInfo->size, + GetByteDisplayUnits(databaseInfo->size), true)) retain]); + lastModified_.reset([base::SysWideToNSString( + base::TimeFormatFriendlyDateAndTime( + databaseInfo->last_modified)) retain]); + } + return self; +} + +- (id)initWithLocalStorage:( + const BrowsingDataLocalStorageHelper::LocalStorageInfo*)storageInfo { + if ((self = [super init])) { + type_ = kCocoaCookieDetailsTypeTreeLocalStorage; + canEditExpiration_ = NO; + domain_.reset([base::SysUTF8ToNSString(storageInfo->origin) retain]); + fileSize_.reset([base::SysUTF16ToNSString(FormatBytes(storageInfo->size, + GetByteDisplayUnits(storageInfo->size), true)) retain]); + lastModified_.reset([base::SysWideToNSString( + base::TimeFormatFriendlyDateAndTime( + storageInfo->last_modified)) retain]); + } + return self; +} + +- (id)initWithAppCacheInfo:(const appcache::AppCacheInfo*)appcacheInfo { + if ((self = [super init])) { + type_ = kCocoaCookieDetailsTypeTreeAppCache; + canEditExpiration_ = NO; + manifestURL_.reset([base::SysUTF8ToNSString( + appcacheInfo->manifest_url.spec()) retain]); + fileSize_.reset([base::SysUTF16ToNSString(FormatBytes(appcacheInfo->size, + GetByteDisplayUnits(appcacheInfo->size), true)) retain]); + created_.reset([base::SysWideToNSString( + base::TimeFormatFriendlyDateAndTime( + appcacheInfo->creation_time)) retain]); + lastAccessed_.reset([base::SysWideToNSString( + base::TimeFormatFriendlyDateAndTime( + appcacheInfo->last_access_time)) retain]); + } + return self; +} + +- (id)initWithDatabase:(const std::string&)domain + databaseName:(const string16&)databaseName + databaseDescription:(const string16&)databaseDescription + fileSize:(unsigned long)fileSize { + if ((self = [super init])) { + type_ = kCocoaCookieDetailsTypePromptDatabase; + canEditExpiration_ = NO; + name_.reset([base::SysUTF16ToNSString(databaseName) retain]); + domain_.reset([base::SysUTF8ToNSString(domain) retain]); + databaseDescription_.reset( + [base::SysUTF16ToNSString(databaseDescription) retain]); + fileSize_.reset([base::SysUTF16ToNSString(FormatBytes(fileSize, + GetByteDisplayUnits(fileSize), true)) retain]); + } + return self; +} + +- (id)initWithLocalStorage:(const std::string&)domain + key:(const string16&)key + value:(const string16&)value { + if ((self = [super init])) { + type_ = kCocoaCookieDetailsTypePromptLocalStorage; + canEditExpiration_ = NO; + domain_.reset([base::SysUTF8ToNSString(domain) retain]); + localStorageKey_.reset([base::SysUTF16ToNSString(key) retain]); + localStorageValue_.reset([base::SysUTF16ToNSString(value) retain]); + } + return self; +} + +- (id)initWithAppCacheManifestURL:(const std::string&)manifestURL { + if ((self = [super init])) { + type_ = kCocoaCookieDetailsTypePromptAppCache; + canEditExpiration_ = NO; + manifestURL_.reset([base::SysUTF8ToNSString(manifestURL) retain]); + } + return self; +} + +- (id)initWithIndexedDBInfo: + (const BrowsingDataIndexedDBHelper::IndexedDBInfo*)indexedDBInfo { + if ((self = [super init])) { + type_ = kCocoaCookieDetailsTypeTreeIndexedDB; + canEditExpiration_ = NO; + domain_.reset([base::SysUTF8ToNSString(indexedDBInfo->origin) retain]); + fileSize_.reset([base::SysUTF16ToNSString(FormatBytes(indexedDBInfo->size, + GetByteDisplayUnits(indexedDBInfo->size), true)) retain]); + lastModified_.reset([base::SysWideToNSString( + base::TimeFormatFriendlyDateAndTime( + indexedDBInfo->last_modified)) retain]); + } + return self; +} + ++ (CocoaCookieDetails*)createFromCookieTreeNode:(CookieTreeNode*)treeNode { + CookieTreeNode::DetailedInfo info = treeNode->GetDetailedInfo(); + CookieTreeNode::DetailedInfo::NodeType nodeType = info.node_type; + NSString* origin; + switch (nodeType) { + case CookieTreeNode::DetailedInfo::TYPE_COOKIE: + origin = base::SysWideToNSString(info.origin.c_str()); + return [[[CocoaCookieDetails alloc] initWithCookie:info.cookie + origin:origin + canEditExpiration:NO] autorelease]; + case CookieTreeNode::DetailedInfo::TYPE_DATABASE: + return [[[CocoaCookieDetails alloc] + initWithDatabase:info.database_info] autorelease]; + case CookieTreeNode::DetailedInfo::TYPE_LOCAL_STORAGE: + return [[[CocoaCookieDetails alloc] + initWithLocalStorage:info.local_storage_info] autorelease]; + case CookieTreeNode::DetailedInfo::TYPE_APPCACHE: + return [[[CocoaCookieDetails alloc] + initWithAppCacheInfo:info.appcache_info] autorelease]; + case CookieTreeNode::DetailedInfo::TYPE_INDEXED_DB: + return [[[CocoaCookieDetails alloc] + initWithIndexedDBInfo:info.indexed_db_info] autorelease]; + default: + return [[[CocoaCookieDetails alloc] initAsFolder] autorelease]; + } +} + +@end + +#pragma mark Content Object Adapter + +@implementation CookiePromptContentDetailsAdapter + +- (id)initWithDetails:(CocoaCookieDetails*)details { + if ((self = [super init])) { + details_.reset([details retain]); + } + return self; +} + +- (CocoaCookieDetails*)details { + return details_.get(); +} + +@end diff --git a/chrome/browser/ui/cocoa/cookie_details_unittest.mm b/chrome/browser/ui/cocoa/cookie_details_unittest.mm new file mode 100644 index 0000000..0f7d711 --- /dev/null +++ b/chrome/browser/ui/cocoa/cookie_details_unittest.mm @@ -0,0 +1,247 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/sys_string_conversions.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "chrome/browser/ui/cocoa/cookie_details.h" +#include "googleurl/src/gurl.h" +#import "testing/gtest_mac.h" + +namespace { + +class CookiesDetailsTest : public CocoaTest { +}; + +TEST_F(CookiesDetailsTest, CreateForFolder) { + scoped_nsobject<CocoaCookieDetails> details; + details.reset([[CocoaCookieDetails alloc] initAsFolder]); + + EXPECT_EQ([details.get() type], kCocoaCookieDetailsTypeFolder); +} + +TEST_F(CookiesDetailsTest, CreateForCookie) { + scoped_nsobject<CocoaCookieDetails> details; + GURL url("http://chromium.org"); + std::string cookieLine( + "PHPSESSID=0123456789abcdef0123456789abcdef; path=/"); + net::CookieMonster::ParsedCookie pc(cookieLine); + net::CookieMonster::CanonicalCookie cookie(url, pc); + NSString* origin = base::SysUTF8ToNSString("http://chromium.org"); + details.reset([[CocoaCookieDetails alloc] initWithCookie:&cookie + origin:origin + canEditExpiration:NO]); + + EXPECT_EQ([details.get() type], kCocoaCookieDetailsTypeCookie); + EXPECT_NSEQ(@"PHPSESSID", [details.get() name]); + EXPECT_NSEQ(@"0123456789abcdef0123456789abcdef", + [details.get() content]); + EXPECT_NSEQ(@"http://chromium.org", [details.get() domain]); + EXPECT_NSEQ(@"/", [details.get() path]); + EXPECT_NSNE(@"", [details.get() lastModified]); + EXPECT_NSNE(@"", [details.get() created]); + EXPECT_NSNE(@"", [details.get() sendFor]); + + EXPECT_FALSE([details.get() shouldHideCookieDetailsView]); + EXPECT_FALSE([details.get() shouldShowLocalStorageTreeDetailsView]); + EXPECT_FALSE([details.get() shouldShowDatabaseTreeDetailsView]); + EXPECT_FALSE([details.get() shouldShowAppCacheTreeDetailsView]); + EXPECT_FALSE([details.get() shouldShowIndexedDBTreeDetailsView]); + EXPECT_FALSE([details.get() shouldShowLocalStoragePromptDetailsView]); + EXPECT_FALSE([details.get() shouldShowDatabasePromptDetailsView]); + EXPECT_FALSE([details.get() shouldShowAppCachePromptDetailsView]); +} + +TEST_F(CookiesDetailsTest, CreateForTreeDatabase) { + scoped_nsobject<CocoaCookieDetails> details; + std::string host("http://chromium.org"); + std::string database_name("sassolungo"); + std::string origin_identifier("dolomites"); + std::string description("a great place to climb"); + int64 size = 1234; + base::Time last_modified = base::Time::Now(); + BrowsingDataDatabaseHelper::DatabaseInfo info(host, database_name, + origin_identifier, description, host, size, last_modified); + details.reset([[CocoaCookieDetails alloc] initWithDatabase:&info]); + + EXPECT_EQ([details.get() type], kCocoaCookieDetailsTypeTreeDatabase); + EXPECT_NSEQ(@"a great place to climb", [details.get() databaseDescription]); + EXPECT_NSEQ(@"1234 B", [details.get() fileSize]); + EXPECT_NSNE(@"", [details.get() lastModified]); + + EXPECT_TRUE([details.get() shouldHideCookieDetailsView]); + EXPECT_FALSE([details.get() shouldShowLocalStorageTreeDetailsView]); + EXPECT_TRUE([details.get() shouldShowDatabaseTreeDetailsView]); + EXPECT_FALSE([details.get() shouldShowAppCacheTreeDetailsView]); + EXPECT_FALSE([details.get() shouldShowIndexedDBTreeDetailsView]); + EXPECT_FALSE([details.get() shouldShowLocalStoragePromptDetailsView]); + EXPECT_FALSE([details.get() shouldShowDatabasePromptDetailsView]); + EXPECT_FALSE([details.get() shouldShowAppCachePromptDetailsView]); +} + +TEST_F(CookiesDetailsTest, CreateForTreeLocalStorage) { + scoped_nsobject<CocoaCookieDetails> details; + std::string protocol("http"); + std::string host("chromium.org"); + unsigned short port = 80; + std::string database_identifier("id"); + std::string origin("chromium.org"); + FilePath file_path(FILE_PATH_LITERAL("/")); + int64 size = 1234; + base::Time last_modified = base::Time::Now(); + BrowsingDataLocalStorageHelper::LocalStorageInfo info(protocol, host, port, + database_identifier, origin, file_path, size, last_modified); + details.reset([[CocoaCookieDetails alloc] initWithLocalStorage:&info]); + + EXPECT_EQ([details.get() type], kCocoaCookieDetailsTypeTreeLocalStorage); + EXPECT_NSEQ(@"chromium.org", [details.get() domain]); + EXPECT_NSEQ(@"1234 B", [details.get() fileSize]); + EXPECT_NSNE(@"", [details.get() lastModified]); + + EXPECT_TRUE([details.get() shouldHideCookieDetailsView]); + EXPECT_TRUE([details.get() shouldShowLocalStorageTreeDetailsView]); + EXPECT_FALSE([details.get() shouldShowDatabaseTreeDetailsView]); + EXPECT_FALSE([details.get() shouldShowAppCacheTreeDetailsView]); + EXPECT_FALSE([details.get() shouldShowIndexedDBTreeDetailsView]); + EXPECT_FALSE([details.get() shouldShowLocalStoragePromptDetailsView]); + EXPECT_FALSE([details.get() shouldShowDatabasePromptDetailsView]); + EXPECT_FALSE([details.get() shouldShowAppCachePromptDetailsView]); +} + +TEST_F(CookiesDetailsTest, CreateForTreeAppCache) { + scoped_nsobject<CocoaCookieDetails> details; + + GURL url("http://chromium.org/stuff.manifest"); + appcache::AppCacheInfo info; + info.creation_time = base::Time::Now(); + info.last_update_time = base::Time::Now(); + info.last_access_time = base::Time::Now(); + info.size=2678; + info.manifest_url = url; + details.reset([[CocoaCookieDetails alloc] initWithAppCacheInfo:&info]); + + EXPECT_EQ([details.get() type], kCocoaCookieDetailsTypeTreeAppCache); + EXPECT_NSEQ(@"http://chromium.org/stuff.manifest", + [details.get() manifestURL]); + EXPECT_NSEQ(@"2678 B", [details.get() fileSize]); + EXPECT_NSNE(@"", [details.get() lastAccessed]); + EXPECT_NSNE(@"", [details.get() created]); + + EXPECT_TRUE([details.get() shouldHideCookieDetailsView]); + EXPECT_FALSE([details.get() shouldShowLocalStorageTreeDetailsView]); + EXPECT_FALSE([details.get() shouldShowDatabaseTreeDetailsView]); + EXPECT_TRUE([details.get() shouldShowAppCacheTreeDetailsView]); + EXPECT_FALSE([details.get() shouldShowIndexedDBTreeDetailsView]); + EXPECT_FALSE([details.get() shouldShowLocalStoragePromptDetailsView]); + EXPECT_FALSE([details.get() shouldShowDatabasePromptDetailsView]); + EXPECT_FALSE([details.get() shouldShowAppCachePromptDetailsView]); +} + +TEST_F(CookiesDetailsTest, CreateForTreeIndexedDB) { + scoped_nsobject<CocoaCookieDetails> details; + + std::string protocol("http"); + std::string host("moose.org"); + unsigned short port = 80; + std::string database_identifier("id"); + std::string origin("moose.org"); + FilePath file_path(FILE_PATH_LITERAL("/")); + int64 size = 1234; + base::Time last_modified = base::Time::Now(); + BrowsingDataIndexedDBHelper::IndexedDBInfo info(protocol, + host, + port, + database_identifier, + origin, + file_path, + size, + last_modified); + + details.reset([[CocoaCookieDetails alloc] initWithIndexedDBInfo:&info]); + + EXPECT_EQ([details.get() type], kCocoaCookieDetailsTypeTreeIndexedDB); + EXPECT_NSEQ(@"moose.org", [details.get() domain]); + EXPECT_NSEQ(@"1234 B", [details.get() fileSize]); + EXPECT_NSNE(@"", [details.get() lastModified]); + + EXPECT_TRUE([details.get() shouldHideCookieDetailsView]); + EXPECT_FALSE([details.get() shouldShowLocalStorageTreeDetailsView]); + EXPECT_FALSE([details.get() shouldShowDatabaseTreeDetailsView]); + EXPECT_FALSE([details.get() shouldShowAppCacheTreeDetailsView]); + EXPECT_TRUE([details.get() shouldShowIndexedDBTreeDetailsView]); + EXPECT_FALSE([details.get() shouldShowLocalStoragePromptDetailsView]); + EXPECT_FALSE([details.get() shouldShowDatabasePromptDetailsView]); + EXPECT_FALSE([details.get() shouldShowAppCachePromptDetailsView]); +} + +TEST_F(CookiesDetailsTest, CreateForPromptDatabase) { + scoped_nsobject<CocoaCookieDetails> details; + std::string domain("chromium.org"); + string16 name(base::SysNSStringToUTF16(@"wicked_name")); + string16 desc(base::SysNSStringToUTF16(@"desc")); + details.reset([[CocoaCookieDetails alloc] initWithDatabase:domain + databaseName:name + databaseDescription:desc + fileSize:94]); + + EXPECT_EQ([details.get() type], kCocoaCookieDetailsTypePromptDatabase); + EXPECT_NSEQ(@"chromium.org", [details.get() domain]); + EXPECT_NSEQ(@"wicked_name", [details.get() name]); + EXPECT_NSEQ(@"desc", [details.get() databaseDescription]); + EXPECT_NSEQ(@"94 B", [details.get() fileSize]); + + EXPECT_TRUE([details.get() shouldHideCookieDetailsView]); + EXPECT_FALSE([details.get() shouldShowLocalStorageTreeDetailsView]); + EXPECT_FALSE([details.get() shouldShowDatabaseTreeDetailsView]); + EXPECT_FALSE([details.get() shouldShowAppCacheTreeDetailsView]); + EXPECT_FALSE([details.get() shouldShowIndexedDBTreeDetailsView]); + EXPECT_FALSE([details.get() shouldShowLocalStoragePromptDetailsView]); + EXPECT_TRUE([details.get() shouldShowDatabasePromptDetailsView]); + EXPECT_FALSE([details.get() shouldShowAppCachePromptDetailsView]); +} + +TEST_F(CookiesDetailsTest, CreateForPromptLocalStorage) { + scoped_nsobject<CocoaCookieDetails> details; + std::string domain("chromium.org"); + string16 key(base::SysNSStringToUTF16(@"testKey")); + string16 value(base::SysNSStringToUTF16(@"testValue")); + details.reset([[CocoaCookieDetails alloc] initWithLocalStorage:domain + key:key + value:value]); + + EXPECT_EQ([details.get() type], kCocoaCookieDetailsTypePromptLocalStorage); + EXPECT_NSEQ(@"chromium.org", [details.get() domain]); + EXPECT_NSEQ(@"testKey", [details.get() localStorageKey]); + EXPECT_NSEQ(@"testValue", [details.get() localStorageValue]); + + EXPECT_TRUE([details.get() shouldHideCookieDetailsView]); + EXPECT_FALSE([details.get() shouldShowLocalStorageTreeDetailsView]); + EXPECT_FALSE([details.get() shouldShowDatabaseTreeDetailsView]); + EXPECT_FALSE([details.get() shouldShowAppCacheTreeDetailsView]); + EXPECT_FALSE([details.get() shouldShowIndexedDBTreeDetailsView]); + EXPECT_TRUE([details.get() shouldShowLocalStoragePromptDetailsView]); + EXPECT_FALSE([details.get() shouldShowDatabasePromptDetailsView]); + EXPECT_FALSE([details.get() shouldShowAppCachePromptDetailsView]); +} + +TEST_F(CookiesDetailsTest, CreateForPromptAppCache) { + scoped_nsobject<CocoaCookieDetails> details; + std::string manifestURL("http://html5demos.com/html5demo.manifest"); + details.reset([[CocoaCookieDetails alloc] + initWithAppCacheManifestURL:manifestURL.c_str()]); + + EXPECT_EQ([details.get() type], kCocoaCookieDetailsTypePromptAppCache); + EXPECT_NSEQ(@"http://html5demos.com/html5demo.manifest", + [details.get() manifestURL]); + + EXPECT_TRUE([details.get() shouldHideCookieDetailsView]); + EXPECT_FALSE([details.get() shouldShowLocalStorageTreeDetailsView]); + EXPECT_FALSE([details.get() shouldShowDatabaseTreeDetailsView]); + EXPECT_FALSE([details.get() shouldShowAppCacheTreeDetailsView]); + EXPECT_FALSE([details.get() shouldShowIndexedDBTreeDetailsView]); + EXPECT_FALSE([details.get() shouldShowLocalStoragePromptDetailsView]); + EXPECT_FALSE([details.get() shouldShowDatabasePromptDetailsView]); + EXPECT_TRUE([details.get() shouldShowAppCachePromptDetailsView]); +} + +} diff --git a/chrome/browser/ui/cocoa/cookie_details_view_controller.h b/chrome/browser/ui/cocoa/cookie_details_view_controller.h new file mode 100644 index 0000000..cad42f4 --- /dev/null +++ b/chrome/browser/ui/cocoa/cookie_details_view_controller.h @@ -0,0 +1,56 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/cocoa_protocols_mac.h" +#include "net/base/cookie_monster.h" + +@class CocoaCookieTreeNode; +@class GTMUILocalizerAndLayoutTweaker; + +// Controller for the view that displays the details of a cookie, +// used both in the cookie prompt dialog as well as the +// show cookies preference sheet of content settings preferences. +@interface CookieDetailsViewController : NSViewController { + @private + // Allows direct access to the object controller for + // the displayed cookie information. + IBOutlet NSObjectController* objectController_; + + // This explicit reference to the layout tweaker is + // required because it's necessary to reformat the view when + // the content object changes, since the content object may + // alter the widths of some of the fields displayed in the view. + IBOutlet GTMUILocalizerAndLayoutTweaker* tweaker_; +} + +@property (nonatomic, readonly) BOOL hasExpiration; + +- (id)init; + +// Configures the cookie detail view that is managed by the controller +// to display the information about a single cookie, the information +// for which is explicitly passed in the parameter |content|. +- (void)setContentObject:(id)content; + +// Adjust the size of the view to exactly fix the information text fields +// that are visible inside it. +- (void)shrinkViewToFit; + +// Called by the cookie tree dialog to establish a binding between +// the the detail view's object controller and the tree controller. +// This binding allows the cookie tree to use the detail view unmodified. +- (void)configureBindingsForTreeController:(NSTreeController*)controller; + +// Action sent by the expiration date popup when the user +// selects the menu item "When I close my browser". +- (IBAction)setCookieDoesntHaveExplicitExpiration:(id)sender; + +// Action sent by the expiration date popup when the user +// selects the menu item with an explicit date/time of expiration. +- (IBAction)setCookieHasExplicitExpiration:(id)sender; + +@end + diff --git a/chrome/browser/ui/cocoa/cookie_details_view_controller.mm b/chrome/browser/ui/cocoa/cookie_details_view_controller.mm new file mode 100644 index 0000000..9f47a54 --- /dev/null +++ b/chrome/browser/ui/cocoa/cookie_details_view_controller.mm @@ -0,0 +1,110 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/cookie_details_view_controller.h" + +#include "app/l10n_util_mac.h" +#include "app/resource_bundle.h" +#import "base/mac_util.h" +#include "base/sys_string_conversions.h" +#import "chrome/browser/ui/cocoa/cookie_tree_node.h" +#import "third_party/GTM/AppKit/GTMUILocalizerAndLayoutTweaker.h" + +namespace { +static const int kExtraMarginBelowWhenExpirationEditable = 5; +} + +#pragma mark View Controller + +@implementation CookieDetailsViewController +@dynamic hasExpiration; + +- (id)init { + return [super initWithNibName:@"CookieDetailsView" + bundle:mac_util::MainAppBundle()]; +} + +- (void)awakeFromNib { + DCHECK(objectController_); +} + +// Finds and returns the y offset of the lowest-most non-hidden +// text field in the view. This is used to shrink the view +// appropriately so that it just fits its visible content. +- (void)getLowestLabelVerticalPosition:(NSView*)view + lowestLabelPosition:(float&)lowestLabelPosition { + if (![view isHidden]) { + if ([view isKindOfClass:[NSTextField class]]) { + NSRect frame = [view frame]; + if (frame.origin.y < lowestLabelPosition) { + lowestLabelPosition = frame.origin.y; + } + } + for (NSView* subview in [view subviews]) { + [self getLowestLabelVerticalPosition:subview + lowestLabelPosition:lowestLabelPosition]; + } + } +} + +- (void)setContentObject:(id)content { + // Make sure the view is loaded before we set the content object, + // otherwise, the KVO notifications to update the content don't + // reach the view and all of the detail values are default + // strings. + NSView* view = [self view]; + + [objectController_ setValue:content forKey:@"content"]; + + // View needs to be re-tweaked after setting the content object, + // since the expiration date may have changed, changing the + // size of the expiration popup. + [tweaker_ tweakUI:view]; +} + +- (void)shrinkViewToFit { + // Adjust the information pane to be exactly the right size + // to hold the visible text information fields. + NSView* view = [self view]; + NSRect frame = [view frame]; + float lowestLabelPosition = frame.origin.y + frame.size.height; + [self getLowestLabelVerticalPosition:view + lowestLabelPosition:lowestLabelPosition]; + float verticalDelta = lowestLabelPosition - frame.origin.y; + + // Popup menu for the expiration is taller than the plain + // text, give it some more room. + if ([[[objectController_ content] details] canEditExpiration]) { + verticalDelta -= kExtraMarginBelowWhenExpirationEditable; + } + + frame.origin.y += verticalDelta; + frame.size.height -= verticalDelta; + [[self view] setFrame:frame]; +} + +- (void)configureBindingsForTreeController:(NSTreeController*)treeController { + // There seems to be a bug in the binding logic that it's not possible + // to bind to the selection of the tree controller, the bind seems to + // require an additional path segment in the key, thus the use of + // selection.self rather than just selection below. + [objectController_ bind:@"contentObject" + toObject:treeController + withKeyPath:@"selection.self" + options:nil]; +} + +- (IBAction)setCookieDoesntHaveExplicitExpiration:(id)sender { + [[[objectController_ content] details] setHasExpiration:NO]; +} + +- (IBAction)setCookieHasExplicitExpiration:(id)sender { + [[[objectController_ content] details] setHasExpiration:YES]; +} + +- (BOOL)hasExpiration { + return [[[objectController_ content] details] hasExpiration]; +} + +@end diff --git a/chrome/browser/ui/cocoa/cookie_details_view_controller_unittest.mm b/chrome/browser/ui/cocoa/cookie_details_view_controller_unittest.mm new file mode 100644 index 0000000..4a7e5da --- /dev/null +++ b/chrome/browser/ui/cocoa/cookie_details_view_controller_unittest.mm @@ -0,0 +1,88 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/sys_string_conversions.h" +#include "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "chrome/browser/ui/cocoa/cookie_details.h" +#include "chrome/browser/ui/cocoa/cookie_details_view_controller.h" + +namespace { + +class CookieDetailsViewControllerTest : public CocoaTest { +}; + +static CocoaCookieDetails* CreateTestCookieDetails(BOOL canEditExpiration) { + GURL url("http://chromium.org"); + std::string cookieLine( + "PHPSESSID=0123456789abcdef0123456789abcdef; path=/"); + net::CookieMonster::ParsedCookie pc(cookieLine); + net::CookieMonster::CanonicalCookie cookie(url, pc); + NSString* origin = base::SysUTF8ToNSString("http://chromium.org"); + CocoaCookieDetails* details = [CocoaCookieDetails alloc]; + [details initWithCookie:&cookie + origin:origin + canEditExpiration:canEditExpiration]; + return [details autorelease]; +} + +static CookiePromptContentDetailsAdapter* CreateCookieTestContent( + BOOL canEditExpiration) { + CocoaCookieDetails* details = CreateTestCookieDetails(canEditExpiration); + return [[[CookiePromptContentDetailsAdapter alloc] initWithDetails:details] + autorelease]; +} + +static CocoaCookieDetails* CreateTestDatabaseDetails() { + std::string domain("chromium.org"); + string16 name(base::SysNSStringToUTF16(@"wicked_name")); + string16 desc(base::SysNSStringToUTF16(@"wicked_desc")); + CocoaCookieDetails* details = [CocoaCookieDetails alloc]; + [details initWithDatabase:domain + databaseName:name + databaseDescription:desc + fileSize:2222]; + return [details autorelease]; +} + +static CookiePromptContentDetailsAdapter* CreateDatabaseTestContent() { + CocoaCookieDetails* details = CreateTestDatabaseDetails(); + return [[[CookiePromptContentDetailsAdapter alloc] initWithDetails:details] + autorelease]; +} + +TEST_F(CookieDetailsViewControllerTest, Create) { + scoped_nsobject<CookieDetailsViewController> detailsViewController( + [[CookieDetailsViewController alloc] init]); +} + +TEST_F(CookieDetailsViewControllerTest, ShrinkToFit) { + scoped_nsobject<CookieDetailsViewController> detailsViewController( + [[CookieDetailsViewController alloc] init]); + scoped_nsobject<CookiePromptContentDetailsAdapter> adapter( + [CreateDatabaseTestContent() retain]); + [detailsViewController.get() setContentObject:adapter.get()]; + NSRect beforeFrame = [[detailsViewController.get() view] frame]; + [detailsViewController.get() shrinkViewToFit]; + NSRect afterFrame = [[detailsViewController.get() view] frame]; + + EXPECT_TRUE(afterFrame.size.height < beforeFrame.size.width); +} + +TEST_F(CookieDetailsViewControllerTest, ExpirationEditability) { + scoped_nsobject<CookieDetailsViewController> detailsViewController( + [[CookieDetailsViewController alloc] init]); + [detailsViewController view]; + scoped_nsobject<CookiePromptContentDetailsAdapter> adapter( + [CreateCookieTestContent(YES) retain]); + [detailsViewController.get() setContentObject:adapter.get()]; + + EXPECT_FALSE([detailsViewController.get() hasExpiration]); + [detailsViewController.get() setCookieHasExplicitExpiration:adapter.get()]; + EXPECT_TRUE([detailsViewController.get() hasExpiration]); + [detailsViewController.get() + setCookieDoesntHaveExplicitExpiration:adapter.get()]; + EXPECT_FALSE([detailsViewController.get() hasExpiration]); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/cookie_tree_node.h b/chrome/browser/ui/cocoa/cookie_tree_node.h new file mode 100644 index 0000000..ec1b2d2 --- /dev/null +++ b/chrome/browser/ui/cocoa/cookie_tree_node.h @@ -0,0 +1,37 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#include "chrome/browser/cookies_tree_model.h" +#include "chrome/browser/ui/cocoa/cookie_details.h" + +@interface CocoaCookieTreeNode : NSObject { + scoped_nsobject<NSString> title_; + scoped_nsobject<NSMutableArray> children_; + scoped_nsobject<CocoaCookieDetails> details_; + CookieTreeNode* treeNode_; // weak +} + +// Designated initializer. +- (id)initWithNode:(CookieTreeNode*)node; + +// Re-sets all the members of the node based on |treeNode_|. +- (void)rebuild; + +// Common getters.. +- (NSString*)title; +- (CocoaCookieDetailsType)nodeType; +- (TreeModelNode*)treeNode; + +// |-mutableChildren| exists so that the CookiesTreeModelObserverBridge can +// operate on the children. Note that this lazily creates children. +- (NSMutableArray*)mutableChildren; +- (NSArray*)children; +- (BOOL)isLeaf; + +- (CocoaCookieDetails*)details; + +@end diff --git a/chrome/browser/ui/cocoa/cookie_tree_node.mm b/chrome/browser/ui/cocoa/cookie_tree_node.mm new file mode 100644 index 0000000..fa4da84 --- /dev/null +++ b/chrome/browser/ui/cocoa/cookie_tree_node.mm @@ -0,0 +1,73 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/cookie_tree_node.h" + +#include "base/sys_string_conversions.h" + +@implementation CocoaCookieTreeNode + +- (id)initWithNode:(CookieTreeNode*)node { + if ((self = [super init])) { + DCHECK(node); + treeNode_ = node; + [self rebuild]; + } + return self; +} + +- (void)rebuild { + title_.reset([base::SysUTF16ToNSString(treeNode_->GetTitle()) retain]); + children_.reset(); + // The tree node assumes ownership of the cookie details object + details_.reset([[CocoaCookieDetails createFromCookieTreeNode:(treeNode_)] + retain]); +} + +- (NSString*)title { + return title_.get(); +} + +- (CocoaCookieDetailsType)nodeType { + return [details_.get() type]; +} + +- (TreeModelNode*)treeNode { + return treeNode_; +} + +- (NSMutableArray*)mutableChildren { + if (!children_.get()) { + const int childCount = treeNode_->GetChildCount(); + children_.reset([[NSMutableArray alloc] initWithCapacity:childCount]); + for (int i = 0; i < childCount; ++i) { + CookieTreeNode* child = treeNode_->GetChild(i); + scoped_nsobject<CocoaCookieTreeNode> childNode( + [[CocoaCookieTreeNode alloc] initWithNode:child]); + [children_ addObject:childNode.get()]; + } + } + return children_.get(); +} + +- (NSArray*)children { + return [self mutableChildren]; +} + +- (BOOL)isLeaf { + return [self nodeType] != kCocoaCookieDetailsTypeFolder; +}; + +- (NSString*)description { + NSString* format = + @"<CocoaCookieTreeNode @ %p (title=%@, nodeType=%d, childCount=%u)"; + return [NSString stringWithFormat:format, self, [self title], + [self nodeType], [[self children] count]]; +} + +- (CocoaCookieDetails*)details { + return details_; +} + +@end diff --git a/chrome/browser/ui/cocoa/cookies_window_controller.h b/chrome/browser/ui/cocoa/cookies_window_controller.h new file mode 100644 index 0000000..0dd8004 --- /dev/null +++ b/chrome/browser/ui/cocoa/cookies_window_controller.h @@ -0,0 +1,146 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/cocoa_protocols_mac.h" +#include "base/scoped_nsobject.h" +#include "base/scoped_ptr.h" +#include "chrome/browser/cookies_tree_model.h" +#import "chrome/browser/ui/cocoa/cookie_tree_node.h" +#include "net/base/cookie_monster.h" + +@class CookiesWindowController; +@class CookieDetailsViewController; +class Profile; +class TreeModel; +class TreeModelNode; + +namespace { +class CookiesWindowControllerTest; +} + +// Thin bridge to the window controller that performs model update actions +// directly on the treeController_. +class CookiesTreeModelObserverBridge : public CookiesTreeModel::Observer { + public: + explicit CookiesTreeModelObserverBridge(CookiesWindowController* controller); + + // Begin TreeModelObserver implementation. + virtual void TreeNodesAdded(TreeModel* model, + TreeModelNode* parent, + int start, + int count); + virtual void TreeNodesRemoved(TreeModel* model, + TreeModelNode* parent, + int start, + int count); + virtual void TreeNodeChanged(TreeModel* model, TreeModelNode* node); + // End TreeModelObserver implementation. + + virtual void TreeModelBeginBatch(CookiesTreeModel* model); + virtual void TreeModelEndBatch(CookiesTreeModel* model); + + // Invalidates the Cocoa model. This is used to tear down the Cocoa model + // when we're about to entirely rebuild it. + void InvalidateCocoaModel(); + + private: + friend class ::CookiesWindowControllerTest; + + // Creates a CocoaCookieTreeNode from a platform-independent one. + // Return value is autoreleased. This creates child nodes recusively. + CocoaCookieTreeNode* CocoaNodeFromTreeNode(TreeModelNode* node); + + // Finds the Cocoa model node based on a platform-independent one. This is + // done by comparing the treeNode pointers. |start| is the node to start + // searching at. If |start| is nil, the root is used. + CocoaCookieTreeNode* FindCocoaNode(TreeModelNode* node, + CocoaCookieTreeNode* start); + + // Returns whether or not the Cocoa tree model is built. + bool HasCocoaModel(); + + CookiesWindowController* window_controller_; // weak, owns us. + + // If this is true, then the Model has informed us that it is batching + // updates. Rather than updating the Cocoa side of the model, we ignore those + // small changes and rebuild once at the end. + bool batch_update_; +}; + +// Controller for the cookies manager. This class stores an internal copy of +// the CookiesTreeModel but with Cocoa-converted values (NSStrings and NSImages +// instead of std::strings and SkBitmaps). Doing this allows us to use bindings +// for the interface. Changes are pushed to this internal model via a very thin +// bridge (see above). +@interface CookiesWindowController : NSWindowController + <NSOutlineViewDelegate, + NSWindowDelegate> { + @private + // Platform-independent model and C++/Obj-C bridge components. + scoped_ptr<CookiesTreeModel> treeModel_; + scoped_ptr<CookiesTreeModelObserverBridge> modelObserver_; + + // Cached array of icons. + scoped_nsobject<NSMutableArray> icons_; + + // Our Cocoa copy of the model. + scoped_nsobject<CocoaCookieTreeNode> cocoaTreeModel_; + + // A flag indicating whether or not the "Remove" button should be enabled. + BOOL removeButtonEnabled_; + + IBOutlet NSTreeController* treeController_; + IBOutlet NSOutlineView* outlineView_; + IBOutlet NSSearchField* searchField_; + IBOutlet NSView* cookieDetailsViewPlaceholder_; + IBOutlet NSButton* removeButton_; + + scoped_nsobject<CookieDetailsViewController> detailsViewController_; + Profile* profile_; // weak + BrowsingDataDatabaseHelper* databaseHelper_; // weak + BrowsingDataLocalStorageHelper* storageHelper_; // weak + BrowsingDataAppCacheHelper* appcacheHelper_; // weak + BrowsingDataIndexedDBHelper* indexedDBHelper_; // weak +} +@property (assign, nonatomic) BOOL removeButtonEnabled; +@property (readonly, nonatomic) NSTreeController* treeController; + +// Designated initializer. Profile cannot be NULL. +- (id)initWithProfile:(Profile*)profile + databaseHelper:(BrowsingDataDatabaseHelper*)databaseHelper + storageHelper:(BrowsingDataLocalStorageHelper*)storageHelper + appcacheHelper:(BrowsingDataAppCacheHelper*)appcacheHelper + indexedDBHelper:(BrowsingDataIndexedDBHelper*)indexedDBHelper; + +// Shows the cookies window as a modal sheet attached to |window|. +- (void)attachSheetTo:(NSWindow*)window; + +// Updates the filter from the search field. +- (IBAction)updateFilter:(id)sender; + +// Delete cookie actions. +- (IBAction)deleteCookie:(id)sender; +- (IBAction)deleteAllCookies:(id)sender; + +// Closes the sheet and ends the modal loop. This will also cleanup the memory. +- (IBAction)closeSheet:(id)sender; + +// Returns the cocoaTreeModel_. +- (CocoaCookieTreeNode*)cocoaTreeModel; +- (void)setCocoaTreeModel:(CocoaCookieTreeNode*)model; + +// Returns the treeModel_. +- (CookiesTreeModel*)treeModel; + +@end + +@interface CookiesWindowController (UnitTesting) +- (void)deleteNodeAtIndexPath:(NSIndexPath*)path; +- (void)clearBrowsingDataNotification:(NSNotification*)notif; +- (CookiesTreeModelObserverBridge*)modelObserver; +- (NSArray*)icons; +- (void)loadTreeModelFromProfile; +@end diff --git a/chrome/browser/ui/cocoa/cookies_window_controller.mm b/chrome/browser/ui/cocoa/cookies_window_controller.mm new file mode 100644 index 0000000..ac95301 --- /dev/null +++ b/chrome/browser/ui/cocoa/cookies_window_controller.mm @@ -0,0 +1,448 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/cookies_window_controller.h" + +#include <queue> +#include <vector> + +#include "app/l10n_util_mac.h" +#include "app/resource_bundle.h" +#import "base/mac_util.h" +#include "base/sys_string_conversions.h" +#include "chrome/browser/browsing_data_remover.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/ui/cocoa/clear_browsing_data_controller.h" +#include "chrome/browser/ui/cocoa/cookie_details_view_controller.h" +#include "grit/generated_resources.h" +#include "grit/theme_resources.h" +#include "skia/ext/skia_utils_mac.h" +#include "third_party/apple/ImageAndTextCell.h" +#include "third_party/skia/include/core/SkBitmap.h" + +// Key path used for notifying KVO. +static NSString* const kCocoaTreeModel = @"cocoaTreeModel"; + +CookiesTreeModelObserverBridge::CookiesTreeModelObserverBridge( + CookiesWindowController* controller) + : window_controller_(controller), + batch_update_(false) { +} + +// Notification that nodes were added to the specified parent. +void CookiesTreeModelObserverBridge::TreeNodesAdded(TreeModel* model, + TreeModelNode* parent, + int start, + int count) { + // We're in for a major rebuild. Ignore this request. + if (batch_update_ || !HasCocoaModel()) + return; + + CocoaCookieTreeNode* cocoa_parent = FindCocoaNode(parent, nil); + NSMutableArray* cocoa_children = [cocoa_parent mutableChildren]; + + [window_controller_ willChangeValueForKey:kCocoaTreeModel]; + CookieTreeNode* cookie_parent = static_cast<CookieTreeNode*>(parent); + for (int i = 0; i < count; ++i) { + CookieTreeNode* cookie_child = cookie_parent->GetChild(start + i); + CocoaCookieTreeNode* new_child = CocoaNodeFromTreeNode(cookie_child); + [cocoa_children addObject:new_child]; + } + [window_controller_ didChangeValueForKey:kCocoaTreeModel]; +} + +// Notification that nodes were removed from the specified parent. +void CookiesTreeModelObserverBridge::TreeNodesRemoved(TreeModel* model, + TreeModelNode* parent, + int start, + int count) { + // We're in for a major rebuild. Ignore this request. + if (batch_update_ || !HasCocoaModel()) + return; + + CocoaCookieTreeNode* cocoa_parent = FindCocoaNode(parent, nil); + [window_controller_ willChangeValueForKey:kCocoaTreeModel]; + NSMutableArray* cocoa_children = [cocoa_parent mutableChildren]; + for (int i = start + count - 1; i >= start; --i) { + [cocoa_children removeObjectAtIndex:i]; + } + [window_controller_ didChangeValueForKey:kCocoaTreeModel]; +} + +// Notification that the contents of a node has changed. +void CookiesTreeModelObserverBridge::TreeNodeChanged(TreeModel* model, + TreeModelNode* node) { + // If we don't have a Cocoa model, only let the root node change. + if (batch_update_ || (!HasCocoaModel() && model->GetRoot() != node)) + return; + + if (HasCocoaModel()) { + // We still have a Cocoa model, so just rebuild the node. + [window_controller_ willChangeValueForKey:kCocoaTreeModel]; + CocoaCookieTreeNode* changed_node = FindCocoaNode(node, nil); + [changed_node rebuild]; + [window_controller_ didChangeValueForKey:kCocoaTreeModel]; + } else { + // Full rebuild. + [window_controller_ setCocoaTreeModel:CocoaNodeFromTreeNode(node)]; + } +} + +void CookiesTreeModelObserverBridge::TreeModelBeginBatch( + CookiesTreeModel* model) { + batch_update_ = true; +} + +void CookiesTreeModelObserverBridge::TreeModelEndBatch( + CookiesTreeModel* model) { + DCHECK(batch_update_); + CocoaCookieTreeNode* root = CocoaNodeFromTreeNode(model->GetRoot()); + [window_controller_ setCocoaTreeModel:root]; + batch_update_ = false; +} + +void CookiesTreeModelObserverBridge::InvalidateCocoaModel() { + [[[window_controller_ cocoaTreeModel] mutableChildren] removeAllObjects]; +} + +CocoaCookieTreeNode* CookiesTreeModelObserverBridge::CocoaNodeFromTreeNode( + TreeModelNode* node) { + CookieTreeNode* cookie_node = static_cast<CookieTreeNode*>(node); + return [[[CocoaCookieTreeNode alloc] initWithNode:cookie_node] autorelease]; +} + +// Does breadth-first search on the tree to find |node|. This method is most +// commonly used to find origin/folder nodes, which are at the first level off +// the root (hence breadth-first search). +CocoaCookieTreeNode* CookiesTreeModelObserverBridge::FindCocoaNode( + TreeModelNode* target, CocoaCookieTreeNode* start) { + if (!start) { + start = [window_controller_ cocoaTreeModel]; + } + if ([start treeNode] == target) { + return start; + } + + // Enqueue the root node of the search (sub-)tree. + std::queue<CocoaCookieTreeNode*> horizon; + horizon.push(start); + + // Loop until we've looked at every node or we found the target. + while (!horizon.empty()) { + // Dequeue the item at the front. + CocoaCookieTreeNode* node = horizon.front(); + horizon.pop(); + + // If this is the droid we're looking for, report it. + if ([node treeNode] == target) + return node; + + // "Move along, move along." by adding all child nodes to the queue. + if (![node isLeaf]) { + NSArray* children = [node children]; + for (CocoaCookieTreeNode* child in children) { + horizon.push(child); + } + } + } + + return nil; // We couldn't find the node. +} + +// Returns whether or not the Cocoa tree model is built. +bool CookiesTreeModelObserverBridge::HasCocoaModel() { + return ([[[window_controller_ cocoaTreeModel] children] count] > 0U); +} + +#pragma mark Window Controller + +@implementation CookiesWindowController + +@synthesize removeButtonEnabled = removeButtonEnabled_; +@synthesize treeController = treeController_; + +- (id)initWithProfile:(Profile*)profile + databaseHelper:(BrowsingDataDatabaseHelper*)databaseHelper + storageHelper:(BrowsingDataLocalStorageHelper*)storageHelper + appcacheHelper:(BrowsingDataAppCacheHelper*)appcacheHelper + indexedDBHelper:(BrowsingDataIndexedDBHelper*)indexedDBHelper { + DCHECK(profile); + NSString* nibpath = [mac_util::MainAppBundle() pathForResource:@"Cookies" + ofType:@"nib"]; + if ((self = [super initWithWindowNibPath:nibpath owner:self])) { + profile_ = profile; + databaseHelper_ = databaseHelper; + storageHelper_ = storageHelper; + appcacheHelper_ = appcacheHelper; + indexedDBHelper_ = indexedDBHelper; + + [self loadTreeModelFromProfile]; + + // Register for Clear Browsing Data controller so we update appropriately. + ClearBrowsingDataController* clearingController = + [ClearBrowsingDataController controllerForProfile:profile_]; + if (clearingController) { + NSNotificationCenter* center = [NSNotificationCenter defaultCenter]; + [center addObserver:self + selector:@selector(clearBrowsingDataNotification:) + name:kClearBrowsingDataControllerDidDelete + object:clearingController]; + } + } + return self; +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [super dealloc]; +} + +- (void)awakeFromNib { + DCHECK([self window]); + DCHECK_EQ(self, [[self window] delegate]); + + detailsViewController_.reset([[CookieDetailsViewController alloc] init]); + + NSView* detailView = [detailsViewController_.get() view]; + NSRect viewFrameRect = [cookieDetailsViewPlaceholder_ frame]; + [[detailsViewController_.get() view] setFrame:viewFrameRect]; + [[cookieDetailsViewPlaceholder_ superview] + replaceSubview:cookieDetailsViewPlaceholder_ + with:detailView]; + + [detailsViewController_ configureBindingsForTreeController:treeController_]; +} + +- (void)windowWillClose:(NSNotification*)notif { + [searchField_ setTarget:nil]; + [outlineView_ setDelegate:nil]; + [self autorelease]; +} + +- (void)attachSheetTo:(NSWindow*)window { + [NSApp beginSheet:[self window] + modalForWindow:window + modalDelegate:self + didEndSelector:@selector(sheetEndSheet:returnCode:contextInfo:) + contextInfo:nil]; +} + +- (void)sheetEndSheet:(NSWindow*)sheet + returnCode:(NSInteger)returnCode + contextInfo:(void*)context { + [sheet close]; + [sheet orderOut:self]; +} + +- (IBAction)updateFilter:(id)sender { + DCHECK([sender isKindOfClass:[NSSearchField class]]); + NSString* string = [sender stringValue]; + // Invalidate the model here because all the nodes are going to be removed + // in UpdateSearchResults(). This could lead to there temporarily being + // invalid pointers in the Cocoa model. + modelObserver_->InvalidateCocoaModel(); + treeModel_->UpdateSearchResults(base::SysNSStringToWide(string)); +} + +- (IBAction)deleteCookie:(id)sender { + DCHECK_EQ(1U, [[treeController_ selectedObjects] count]); + [self deleteNodeAtIndexPath:[treeController_ selectionIndexPath]]; +} + +// This will delete the Cocoa model node as well as the backing model object at +// the specified index path in the Cocoa model. If the node that was deleted +// was the sole child of the parent node, this will be called recursively to +// delete empty parents. +- (void)deleteNodeAtIndexPath:(NSIndexPath*)path { + NSTreeNode* treeNode = + [[treeController_ arrangedObjects] descendantNodeAtIndexPath:path]; + if (!treeNode) + return; + + CocoaCookieTreeNode* node = [treeNode representedObject]; + CookieTreeNode* cookie = static_cast<CookieTreeNode*>([node treeNode]); + treeModel_->DeleteCookieNode(cookie); + // If there is a next cookie, this will select it because items will slide + // up. If there is no next cookie, this is a no-op. + [treeController_ setSelectionIndexPath:path]; + // If the above setting of the selection was in fact a no-op, find the next + // node to select. + if (![[treeController_ selectedObjects] count]) { + NSUInteger lastIndex = [path indexAtPosition:[path length] - 1]; + if (lastIndex != 0) { + // If there any nodes remaining, select the node that is in the list + // before this one. + path = [path indexPathByRemovingLastIndex]; + path = [path indexPathByAddingIndex:lastIndex - 1]; + [treeController_ setSelectionIndexPath:path]; + } + } +} + +- (IBAction)deleteAllCookies:(id)sender { + // Preemptively delete all cookies in the Cocoa model. + modelObserver_->InvalidateCocoaModel(); + treeModel_->DeleteAllStoredObjects(); +} + +- (IBAction)closeSheet:(id)sender { + [NSApp endSheet:[self window]]; +} + +- (void)clearBrowsingDataNotification:(NSNotification*)notif { + NSNumber* removeMask = + [[notif userInfo] objectForKey:kClearBrowsingDataControllerRemoveMask]; + if ([removeMask intValue] & BrowsingDataRemover::REMOVE_COOKIES) { + [self loadTreeModelFromProfile]; + } +} + +// Override keyDown on the controller (which is the first responder) to allow +// both backspace and delete to be captured by the Remove button. +- (void)keyDown:(NSEvent*)theEvent { + NSString* keys = [theEvent characters]; + if ([keys length]) { + unichar key = [keys characterAtIndex:0]; + // The button has a key equivalent of backspace, so examine this event for + // forward delete. + if ((key == NSDeleteCharacter || key == NSDeleteFunctionKey) && + [self removeButtonEnabled]) { + [removeButton_ performClick:self]; + return; + } + } + [super keyDown:theEvent]; +} + +#pragma mark Getters and Setters + +- (CocoaCookieTreeNode*)cocoaTreeModel { + return cocoaTreeModel_.get(); +} +- (void)setCocoaTreeModel:(CocoaCookieTreeNode*)model { + cocoaTreeModel_.reset([model retain]); +} + +- (CookiesTreeModel*)treeModel { + return treeModel_.get(); +} + +#pragma mark Outline View Delegate + +- (void)outlineView:(NSOutlineView*)outlineView + willDisplayCell:(id)cell + forTableColumn:(NSTableColumn*)tableColumn + item:(id)item { + CocoaCookieTreeNode* node = [item representedObject]; + int index = treeModel_->GetIconIndex([node treeNode]); + NSImage* icon = nil; + if (index >= 0) + icon = [icons_ objectAtIndex:index]; + else + icon = [icons_ lastObject]; + [(ImageAndTextCell*)cell setImage:icon]; +} + +- (void)outlineViewItemDidExpand:(NSNotification*)notif { + NSTreeNode* item = [[notif userInfo] objectForKey:@"NSObject"]; + CocoaCookieTreeNode* node = [item representedObject]; + NSArray* children = [node children]; + if ([children count] == 1U) { + // The node that will expand has one child. Do the user a favor and expand + // that node (saving her a click) if it is non-leaf. + CocoaCookieTreeNode* child = [children lastObject]; + if (![child isLeaf]) { + NSOutlineView* outlineView = [notif object]; + // Tell the OutlineView to expand the NSTreeNode, not the model object. + children = [item childNodes]; + DCHECK_EQ([children count], 1U); + [outlineView expandItem:[children lastObject]]; + // Select the first node in that child set. + NSTreeNode* folderChild = [children lastObject]; + if ([[folderChild childNodes] count] > 0) { + NSTreeNode* firstCookieChild = + [[folderChild childNodes] objectAtIndex:0]; + [treeController_ setSelectionIndexPath:[firstCookieChild indexPath]]; + } + } + } +} + +- (void)outlineViewSelectionDidChange:(NSNotification*)notif { + // Multi-selection should be disabled in the UI, but for sanity, double-check + // that they can't do it here. + NSArray* selectedObjects = [treeController_ selectedObjects]; + NSUInteger count = [selectedObjects count]; + if (count != 1U) { + DCHECK_LT(count, 1U) << "User was able to select more than 1 cookie node!"; + [self setRemoveButtonEnabled:NO]; + return; + } + + // Go through the selection's indexPath and make sure that the node that is + // being referenced actually exists in the Cocoa model. + NSIndexPath* selection = [treeController_ selectionIndexPath]; + NSUInteger length = [selection length]; + CocoaCookieTreeNode* node = [self cocoaTreeModel]; + for (NSUInteger i = 0; i < length; ++i) { + NSUInteger childIndex = [selection indexAtPosition:i]; + if (childIndex >= [[node children] count]) { + [self setRemoveButtonEnabled:NO]; + return; + } + node = [[node children] objectAtIndex:childIndex]; + } + + // If there is a valid selection, make sure that the remove + // button is enabled. + [self setRemoveButtonEnabled:YES]; +} + +#pragma mark Unit Testing + +- (CookiesTreeModelObserverBridge*)modelObserver { + return modelObserver_.get(); +} + +- (NSArray*)icons { + return icons_.get(); +} + +// Re-initializes the |treeModel_|, creates a new observer for it, and re- +// builds the |cocoaTreeModel_|. We use this to initialize the controller and +// to rebuild after the user clears browsing data. Because the models get +// clobbered, we rebuild the icon cache for safety (though they do not change). +- (void)loadTreeModelFromProfile { + treeModel_.reset(new CookiesTreeModel( + profile_->GetRequestContext()->GetCookieStore()->GetCookieMonster(), + databaseHelper_, + storageHelper_, + NULL, + appcacheHelper_, + indexedDBHelper_)); + modelObserver_.reset(new CookiesTreeModelObserverBridge(self)); + treeModel_->AddObserver(modelObserver_.get()); + + // Convert the model's icons from Skia to Cocoa. + std::vector<SkBitmap> skiaIcons; + treeModel_->GetIcons(&skiaIcons); + icons_.reset([[NSMutableArray alloc] init]); + for (std::vector<SkBitmap>::iterator it = skiaIcons.begin(); + it != skiaIcons.end(); ++it) { + [icons_ addObject:gfx::SkBitmapToNSImage(*it)]; + } + + // Default icon will be the last item in the array. + ResourceBundle& rb = ResourceBundle::GetSharedInstance(); + // TODO(rsesek): Rename this resource now that it's in multiple places. + [icons_ addObject:rb.GetNativeImageNamed(IDR_BOOKMARK_BAR_FOLDER)]; + + // Create the Cocoa model. + CookieTreeNode* root = static_cast<CookieTreeNode*>(treeModel_->GetRoot()); + scoped_nsobject<CocoaCookieTreeNode> model( + [[CocoaCookieTreeNode alloc] initWithNode:root]); + [self setCocoaTreeModel:model.get()]; // Takes ownership. +} + +@end diff --git a/chrome/browser/ui/cocoa/cookies_window_controller_unittest.mm b/chrome/browser/ui/cocoa/cookies_window_controller_unittest.mm new file mode 100644 index 0000000..9f3f410 --- /dev/null +++ b/chrome/browser/ui/cocoa/cookies_window_controller_unittest.mm @@ -0,0 +1,687 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "app/l10n_util_mac.h" +#include "app/tree_model.h" +#import "base/scoped_nsobject.h" +#include "base/scoped_ptr.h" +#include "base/utf_string_conversions.h" +#include "chrome/browser/browsing_data_remover.h" +#include "chrome/browser/cookies_tree_model.h" +#include "chrome/browser/mock_browsing_data_database_helper.h" +#include "chrome/browser/mock_browsing_data_local_storage_helper.h" +#include "chrome/browser/mock_browsing_data_appcache_helper.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#include "chrome/browser/ui/cocoa/clear_browsing_data_controller.h" +#include "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/cookies_window_controller.h" +#include "chrome/common/net/url_request_context_getter.h" +#include "chrome/test/testing_profile.h" +#include "googleurl/src/gurl.h" +#include "grit/generated_resources.h" +#include "net/url_request/url_request_context.h" +#include "testing/gtest/include/gtest/gtest.h" +#import "testing/gtest_mac.h" +#include "testing/platform_test.h" +#import "third_party/ocmock/OCMock/OCMock.h" + +// Used to test FindCocoaNode. This only sets the title and node, without +// initializing any other members. +@interface FakeCocoaCookieTreeNode : CocoaCookieTreeNode { + TreeModelNode* testNode_; +} +- (id)initWithTreeNode:(TreeModelNode*)node; +@end +@implementation FakeCocoaCookieTreeNode +- (id)initWithTreeNode:(TreeModelNode*)node { + if ((self = [super init])) { + testNode_ = node; + children_.reset([[NSMutableArray alloc] init]); + } + return self; +} +- (TreeModelNode*)treeNode { + return testNode_; +} +@end + +namespace { + +class CookiesWindowControllerTest : public CocoaTest { + public: + + virtual void SetUp() { + CocoaTest::SetUp(); + TestingProfile* profile = browser_helper_.profile(); + profile->CreateRequestContext(); + database_helper_ = new MockBrowsingDataDatabaseHelper(profile); + local_storage_helper_ = new MockBrowsingDataLocalStorageHelper(profile); + appcache_helper_ = new MockBrowsingDataAppCacheHelper(profile); + controller_.reset( + [[CookiesWindowController alloc] initWithProfile:profile + databaseHelper:database_helper_ + storageHelper:local_storage_helper_ + appcacheHelper:appcache_helper_] + ); + } + + virtual void TearDown() { + CocoaTest::TearDown(); + } + + CocoaCookieTreeNode* CocoaNodeFromTreeNode(TreeModelNode* node) { + return [controller_ modelObserver]->CocoaNodeFromTreeNode(node); + } + + CocoaCookieTreeNode* FindCocoaNode(TreeModelNode* node, + CocoaCookieTreeNode* start) { + return [controller_ modelObserver]->FindCocoaNode(node, start); + } + + protected: + BrowserTestHelper browser_helper_; + scoped_nsobject<CookiesWindowController> controller_; + MockBrowsingDataDatabaseHelper* database_helper_; + MockBrowsingDataLocalStorageHelper* local_storage_helper_; + MockBrowsingDataAppCacheHelper* appcache_helper_; +}; + +TEST_F(CookiesWindowControllerTest, Construction) { + std::vector<SkBitmap> skia_icons; + [controller_ treeModel]->GetIcons(&skia_icons); + + EXPECT_EQ([[controller_ icons] count], skia_icons.size() + 1U); +} + +TEST_F(CookiesWindowControllerTest, FindCocoaNodeRoot) { + scoped_ptr< TreeNodeWithValue<int> > search(new TreeNodeWithValue<int>(42)); + scoped_nsobject<FakeCocoaCookieTreeNode> node( + [[FakeCocoaCookieTreeNode alloc] initWithTreeNode:search.get()]); + EXPECT_EQ(node.get(), FindCocoaNode(search.get(), node.get())); +} + +TEST_F(CookiesWindowControllerTest, FindCocoaNodeImmediateChild) { + scoped_ptr< TreeNodeWithValue<int> > parent(new TreeNodeWithValue<int>(100)); + scoped_ptr< TreeNodeWithValue<int> > child1(new TreeNodeWithValue<int>(10)); + scoped_ptr< TreeNodeWithValue<int> > child2(new TreeNodeWithValue<int>(20)); + scoped_nsobject<FakeCocoaCookieTreeNode> cocoaParent( + [[FakeCocoaCookieTreeNode alloc] initWithTreeNode:parent.get()]); + scoped_nsobject<FakeCocoaCookieTreeNode> cocoaChild1( + [[FakeCocoaCookieTreeNode alloc] initWithTreeNode:child1.get()]); + scoped_nsobject<FakeCocoaCookieTreeNode> cocoaChild2( + [[FakeCocoaCookieTreeNode alloc] initWithTreeNode:child2.get()]); + [[cocoaParent mutableChildren] addObject:cocoaChild1.get()]; + [[cocoaParent mutableChildren] addObject:cocoaChild2.get()]; + + EXPECT_EQ(cocoaChild2.get(), FindCocoaNode(child2.get(), cocoaParent.get())); +} + +TEST_F(CookiesWindowControllerTest, FindCocoaNodeRecursive) { + scoped_ptr< TreeNodeWithValue<int> > parent(new TreeNodeWithValue<int>(100)); + scoped_ptr< TreeNodeWithValue<int> > child1(new TreeNodeWithValue<int>(10)); + scoped_ptr< TreeNodeWithValue<int> > child2(new TreeNodeWithValue<int>(20)); + scoped_nsobject<FakeCocoaCookieTreeNode> cocoaParent( + [[FakeCocoaCookieTreeNode alloc] initWithTreeNode:parent.get()]); + scoped_nsobject<FakeCocoaCookieTreeNode> cocoaChild1( + [[FakeCocoaCookieTreeNode alloc] initWithTreeNode:child1.get()]); + scoped_nsobject<FakeCocoaCookieTreeNode> cocoaChild2( + [[FakeCocoaCookieTreeNode alloc] initWithTreeNode:child2.get()]); + [[cocoaParent mutableChildren] addObject:cocoaChild1.get()]; + [[cocoaChild1 mutableChildren] addObject:cocoaChild2.get()]; + + EXPECT_EQ(cocoaChild2.get(), FindCocoaNode(child2.get(), cocoaParent.get())); +} + +TEST_F(CookiesWindowControllerTest, CocoaNodeFromTreeNodeCookie) { + net::CookieMonster* cm = browser_helper_.profile()->GetCookieMonster(); + cm->SetCookie(GURL("http://foo.com"), "A=B"); + CookiesTreeModel model(cm, database_helper_, local_storage_helper_, nil, nil); + + // Root --> foo.com --> Cookies --> A. Create node for 'A'. + TreeModelNode* node = model.GetRoot()->GetChild(0)->GetChild(0)->GetChild(0); + CocoaCookieTreeNode* cookie = CocoaNodeFromTreeNode(node); + + CocoaCookieDetails* details = [cookie details]; + EXPECT_NSEQ(@"B", [details content]); + EXPECT_NSEQ(l10n_util::GetNSString(IDS_COOKIES_COOKIE_EXPIRES_SESSION), + [details expires]); + EXPECT_NSEQ(l10n_util::GetNSString(IDS_COOKIES_COOKIE_SENDFOR_ANY), + [details sendFor]); + EXPECT_NSEQ(@"A", [cookie title]); + EXPECT_NSEQ(@"A", [details name]); + EXPECT_NSEQ(@"/", [details path]); + EXPECT_EQ(0U, [[cookie children] count]); + EXPECT_TRUE([details created]); + EXPECT_TRUE([cookie isLeaf]); + EXPECT_EQ(node, [cookie treeNode]); +} + +TEST_F(CookiesWindowControllerTest, CocoaNodeFromTreeNodeRecursive) { + net::CookieMonster* cm = browser_helper_.profile()->GetCookieMonster(); + cm->SetCookie(GURL("http://foo.com"), "A=B"); + CookiesTreeModel model(cm, database_helper_, local_storage_helper_, nil, nil); + + // Root --> foo.com --> Cookies --> A. Create node for 'foo.com'. + CookieTreeNode* node = model.GetRoot()->GetChild(0); + CocoaCookieTreeNode* domain = CocoaNodeFromTreeNode(node); + CocoaCookieTreeNode* cookies = [[domain children] objectAtIndex:0]; + CocoaCookieTreeNode* cookie = [[cookies children] objectAtIndex:0]; + + // Test domain-level node. + EXPECT_NSEQ(@"foo.com", [domain title]); + + EXPECT_FALSE([domain isLeaf]); + EXPECT_EQ(1U, [[domain children] count]); + EXPECT_EQ(node, [domain treeNode]); + + // Test "Cookies" folder node. + EXPECT_NSEQ(l10n_util::GetNSString(IDS_COOKIES_COOKIES), [cookies title]); + EXPECT_FALSE([cookies isLeaf]); + EXPECT_EQ(1U, [[cookies children] count]); + EXPECT_EQ(node->GetChild(0), [cookies treeNode]); + + // Test cookie node. This is the same as CocoaNodeFromTreeNodeCookie. + CocoaCookieDetails* details = [cookie details]; + EXPECT_NSEQ(@"B", [details content]); + EXPECT_NSEQ(l10n_util::GetNSString(IDS_COOKIES_COOKIE_EXPIRES_SESSION), + [details expires]); + EXPECT_NSEQ(l10n_util::GetNSString(IDS_COOKIES_COOKIE_SENDFOR_ANY), + [details sendFor]); + EXPECT_NSEQ(@"A", [cookie title]); + EXPECT_NSEQ(@"A", [details name]); + EXPECT_NSEQ(@"/", [details path]); + EXPECT_NSEQ(@"foo.com", [details domain]); + EXPECT_EQ(0U, [[cookie children] count]); + EXPECT_TRUE([details created]); + EXPECT_TRUE([cookie isLeaf]); + EXPECT_EQ(node->GetChild(0)->GetChild(0), [cookie treeNode]); +} + +TEST_F(CookiesWindowControllerTest, TreeNodesAdded) { + const GURL url = GURL("http://foo.com"); + TestingProfile* profile = browser_helper_.profile(); + net::CookieMonster* cm = profile->GetCookieMonster(); + cm->SetCookie(url, "A=B"); + + controller_.reset( + [[CookiesWindowController alloc] initWithProfile:profile + databaseHelper:database_helper_ + storageHelper:local_storage_helper_ + appcacheHelper:appcache_helper_]); + + // Root --> foo.com --> Cookies. + NSMutableArray* cocoa_children = + [[[[[[controller_ cocoaTreeModel] children] objectAtIndex:0] + children] objectAtIndex:0] mutableChildren]; + EXPECT_EQ(1U, [cocoa_children count]); + + // Create some cookies. + cm->SetCookie(url, "C=D"); + cm->SetCookie(url, "E=F"); + + net::CookieMonster::CookieList list = cm->GetAllCookies(); + CookiesTreeModel* model = [controller_ treeModel]; + // Root --> foo.com --> Cookies. + CookieTreeNode* parent = model->GetRoot()->GetChild(0)->GetChild(0); + + ASSERT_EQ(3U, list.size()); + + // Add the cookie nodes. + CookieTreeCookieNode* cnode = new CookieTreeCookieNode(&list[1]); + parent->Add(1, cnode); // |parent| takes ownership. + cnode = new CookieTreeCookieNode(&list[2]); + parent->Add(2, cnode); + + // Manually notify the observer. + [controller_ modelObserver]->TreeNodesAdded(model, parent, 1, 2); + + // Check that we have created 2 more Cocoa nodes. + EXPECT_EQ(3U, [cocoa_children count]); +} + +TEST_F(CookiesWindowControllerTest, TreeNodesRemoved) { + const GURL url = GURL("http://foo.com"); + TestingProfile* profile = browser_helper_.profile(); + net::CookieMonster* cm = profile->GetCookieMonster(); + cm->SetCookie(url, "A=B"); + cm->SetCookie(url, "C=D"); + cm->SetCookie(url, "E=F"); + + controller_.reset( + [[CookiesWindowController alloc] initWithProfile:profile + databaseHelper:database_helper_ + storageHelper:local_storage_helper_ + appcacheHelper:appcache_helper_]); + + // Root --> foo.com --> Cookies. + NSMutableArray* cocoa_children = + [[[[[[controller_ cocoaTreeModel] children] objectAtIndex:0] + children] objectAtIndex:0] mutableChildren]; + EXPECT_EQ(3U, [cocoa_children count]); + + CookiesTreeModel* model = [controller_ treeModel]; + // Root --> foo.com --> Cookies. + CookieTreeNode* parent = model->GetRoot()->GetChild(0)->GetChild(0); + + // Pretend to remove the nodes. + [controller_ modelObserver]->TreeNodesRemoved(model, parent, 1, 2); + + EXPECT_EQ(1U, [cocoa_children count]); + + NSString* title = [[[cocoa_children objectAtIndex:0] details] name]; + EXPECT_NSEQ(@"A", title); +} + +TEST_F(CookiesWindowControllerTest, TreeNodeChanged) { + const GURL url = GURL("http://foo.com"); + TestingProfile* profile = browser_helper_.profile(); + net::CookieMonster* cm = profile->GetCookieMonster(); + cm->SetCookie(url, "A=B"); + + controller_.reset( + [[CookiesWindowController alloc] initWithProfile:profile + databaseHelper:database_helper_ + storageHelper:local_storage_helper_ + appcacheHelper:appcache_helper_]); + + CookiesTreeModel* model = [controller_ treeModel]; + // Root --> foo.com --> Cookies. + CookieTreeNode* node = model->GetRoot()->GetChild(0)->GetChild(0); + + // Root --> foo.com --> Cookies. + CocoaCookieTreeNode* cocoa_node = + [[[[[controller_ cocoaTreeModel] children] objectAtIndex:0] + children] objectAtIndex:0]; + + EXPECT_NSEQ(l10n_util::GetNSString(IDS_COOKIES_COOKIES), + [cocoa_node title]); + + // Fake update the cookie folder's title. This would never happen in reality, + // but it tests the code path that ultimately calls CocoaNodeFromTreeNode, + // which is tested elsewhere. + node->SetTitle(ASCIIToUTF16("Silly Change")); + [controller_ modelObserver]->TreeNodeChanged(model, node); + + EXPECT_NSEQ(@"Silly Change", [cocoa_node title]); +} + +TEST_F(CookiesWindowControllerTest, DeleteCookie) { + const GURL url = GURL("http://foo.com"); + TestingProfile* profile = browser_helper_.profile(); + net::CookieMonster* cm = profile->GetCookieMonster(); + cm->SetCookie(url, "A=B"); + cm->SetCookie(url, "C=D"); + cm->SetCookie(GURL("http://google.com"), "E=F"); + + // This will clean itself up when we call |-closeSheet:|. If we reset the + // scoper, we'd get a double-free. + CookiesWindowController* controller = + [[CookiesWindowController alloc] initWithProfile:profile + databaseHelper:database_helper_ + storageHelper:local_storage_helper_ + appcacheHelper:appcache_helper_]; + [controller attachSheetTo:test_window()]; + NSTreeController* treeController = [controller treeController]; + + // Select cookie A. + NSUInteger pathA[3] = {0, 0, 0}; + NSIndexPath* indexPath = [NSIndexPath indexPathWithIndexes:pathA length:3]; + [treeController setSelectionIndexPath:indexPath]; + + // Press the "Delete" button. + [controller deleteCookie:nil]; + + // Root --> foo.com --> Cookies. + NSArray* cookies = [[[[[[controller cocoaTreeModel] children] + objectAtIndex:0] children] objectAtIndex:0] children]; + EXPECT_EQ(1U, [cookies count]); + EXPECT_NSEQ(@"C", [[cookies lastObject] title]); + EXPECT_NSEQ(indexPath, [treeController selectionIndexPath]); + + // Select cookie E. + NSUInteger pathE[3] = {1, 0, 0}; + indexPath = [NSIndexPath indexPathWithIndexes:pathE length:3]; + [treeController setSelectionIndexPath:indexPath]; + + // Perform delete. + [controller deleteCookie:nil]; + + // Make sure that both the domain level node and the Cookies folder node got + // deleted because there was only one leaf node. + EXPECT_EQ(1U, [[[controller cocoaTreeModel] children] count]); + + // Select cookie C. + NSUInteger pathC[3] = {0, 0, 0}; + indexPath = [NSIndexPath indexPathWithIndexes:pathC length:3]; + [treeController setSelectionIndexPath:indexPath]; + + // Perform delete. + [controller deleteCookie:nil]; + + // Make sure the world didn't explode and that there's nothing in the tree. + EXPECT_EQ(0U, [[[controller cocoaTreeModel] children] count]); + + [controller closeSheet:nil]; +} + +TEST_F(CookiesWindowControllerTest, DidExpandItem) { + const GURL url = GURL("http://foo.com"); + TestingProfile* profile = browser_helper_.profile(); + net::CookieMonster* cm = profile->GetCookieMonster(); + cm->SetCookie(url, "A=B"); + cm->SetCookie(url, "C=D"); + + controller_.reset( + [[CookiesWindowController alloc] initWithProfile:profile + databaseHelper:database_helper_ + storageHelper:local_storage_helper_ + appcacheHelper:appcache_helper_]); + + // Root --> foo.com. + CocoaCookieTreeNode* foo = + [[[controller_ cocoaTreeModel] children] objectAtIndex:0]; + + // Create the objects we are going to be testing with. + id outlineView = [OCMockObject mockForClass:[NSOutlineView class]]; + id treeNode = [OCMockObject mockForClass:[NSTreeNode class]]; + NSTreeNode* childTreeNode = + [NSTreeNode treeNodeWithRepresentedObject:[[foo children] lastObject]]; + NSArray* fakeChildren = [NSArray arrayWithObject:childTreeNode]; + + // Set up the mock object. + [[[treeNode stub] andReturn:foo] representedObject]; + [[[treeNode stub] andReturn:fakeChildren] childNodes]; + + // Create a fake "ItemDidExpand" notification. + NSDictionary* userInfo = [NSDictionary dictionaryWithObject:treeNode + forKey:@"NSObject"]; + NSNotification* notif = + [NSNotification notificationWithName:@"ItemDidExpandNotification" + object:outlineView + userInfo:userInfo]; + + // Make sure we work correctly. + [[outlineView expect] expandItem:childTreeNode]; + [controller_ outlineViewItemDidExpand:notif]; + [outlineView verify]; +} + +TEST_F(CookiesWindowControllerTest, ClearBrowsingData) { + const GURL url = GURL("http://foo.com"); + TestingProfile* profile = browser_helper_.profile(); + net::CookieMonster* cm = profile->GetCookieMonster(); + cm->SetCookie(url, "A=B"); + cm->SetCookie(url, "C=D"); + cm->SetCookie(url, "E=F"); + + id mock = [OCMockObject partialMockForObject:controller_.get()]; + [[mock expect] loadTreeModelFromProfile]; + + NSNumber* mask = + [NSNumber numberWithInt:BrowsingDataRemover::REMOVE_COOKIES]; + NSDictionary* userInfo = + [NSDictionary dictionaryWithObject:mask + forKey:kClearBrowsingDataControllerRemoveMask]; + NSNotification* notif = + [NSNotification notificationWithName:kClearBrowsingDataControllerDidDelete + object:nil + userInfo:userInfo]; + [controller_ clearBrowsingDataNotification:notif]; + + [mock verify]; +} + +// This test has been flaky under Valgrind and turns the bot red since r38504. +// Under Mac Tests 10.5, it occasionally reports: +// malloc: *** error for object 0x31e0468: Non-aligned pointer being freed +// *** set a breakpoint in malloc_error_break to debug +// Attempts to reproduce locally were not successful. This code is likely +// changing in the future, so it's marked flaky for now. http://crbug.com/35327 +TEST_F(CookiesWindowControllerTest, FLAKY_RemoveButtonEnabled) { + const GURL url = GURL("http://foo.com"); + TestingProfile* profile = browser_helper_.profile(); + net::CookieMonster* cm = profile->GetCookieMonster(); + cm->SetCookie(url, "A=B"); + cm->SetCookie(url, "C=D"); + + // This will clean itself up when we call |-closeSheet:|. If we reset the + // scoper, we'd get a double-free. + database_helper_ = new MockBrowsingDataDatabaseHelper(profile); + local_storage_helper_ = new MockBrowsingDataLocalStorageHelper(profile); + local_storage_helper_->AddLocalStorageSamples(); + CookiesWindowController* controller = + [[CookiesWindowController alloc] initWithProfile:profile + databaseHelper:database_helper_ + storageHelper:local_storage_helper_ + appcacheHelper:appcache_helper_]; + local_storage_helper_->Notify(); + [controller attachSheetTo:test_window()]; + + // Nothing should be selected right now. + EXPECT_FALSE([controller removeButtonEnabled]); + + { + // Pretend to select cookie A. + NSUInteger path[3] = {0, 0, 0}; + NSIndexPath* indexPath = [NSIndexPath indexPathWithIndexes:path length:3]; + [[controller treeController] setSelectionIndexPath:indexPath]; + [controller outlineViewSelectionDidChange:nil]; + EXPECT_TRUE([controller removeButtonEnabled]); + } + + { + // Pretend to select cookie C. + NSUInteger path[3] = {0, 0, 1}; + NSIndexPath* indexPath = [NSIndexPath indexPathWithIndexes:path length:3]; + [[controller treeController] setSelectionIndexPath:indexPath]; + [controller outlineViewSelectionDidChange:nil]; + EXPECT_TRUE([controller removeButtonEnabled]); + } + + { + // Select a local storage node. + NSUInteger path[3] = {2, 0, 0}; + NSIndexPath* indexPath = [NSIndexPath indexPathWithIndexes:path length:3]; + [[controller treeController] setSelectionIndexPath:indexPath]; + [controller outlineViewSelectionDidChange:nil]; + EXPECT_TRUE([controller removeButtonEnabled]); + } + + { + // Pretend to select something that isn't there! + NSUInteger path[3] = {0, 0, 2}; + NSIndexPath* indexPath = [NSIndexPath indexPathWithIndexes:path length:3]; + [[controller treeController] setSelectionIndexPath:indexPath]; + [controller outlineViewSelectionDidChange:nil]; + EXPECT_FALSE([controller removeButtonEnabled]); + } + + { + // Try selecting something that doesn't exist again. + NSUInteger path[3] = {7, 1, 4}; + NSIndexPath* indexPath = [NSIndexPath indexPathWithIndexes:path length:3]; + [[controller treeController] setSelectionIndexPath:indexPath]; + [controller outlineViewSelectionDidChange:nil]; + EXPECT_FALSE([controller removeButtonEnabled]); + } + + [controller closeSheet:nil]; +} + +TEST_F(CookiesWindowControllerTest, UpdateFilter) { + const GURL url = GURL("http://foo.com"); + TestingProfile* profile = browser_helper_.profile(); + net::CookieMonster* cm = profile->GetCookieMonster(); + cm->SetCookie(GURL("http://a.com"), "A=B"); + cm->SetCookie(GURL("http://aa.com"), "C=D"); + cm->SetCookie(GURL("http://b.com"), "E=F"); + cm->SetCookie(GURL("http://d.com"), "G=H"); + cm->SetCookie(GURL("http://dd.com"), "I=J"); + + controller_.reset( + [[CookiesWindowController alloc] initWithProfile:profile + databaseHelper:database_helper_ + storageHelper:local_storage_helper_ + appcacheHelper:appcache_helper_]); + + // Make sure we registered all five cookies. + EXPECT_EQ(5U, [[[controller_ cocoaTreeModel] children] count]); + + NSSearchField* field = + [[NSSearchField alloc] initWithFrame:NSMakeRect(0, 0, 100, 100)]; + + // Make sure we still have five cookies. + [field setStringValue:@""]; + [controller_ updateFilter:field]; + EXPECT_EQ(5U, [[[controller_ cocoaTreeModel] children] count]); + + // Search for "a". + [field setStringValue:@"a"]; + [controller_ updateFilter:field]; + EXPECT_EQ(2U, [[[controller_ cocoaTreeModel] children] count]); + + // Search for "b". + [field setStringValue:@"b"]; + [controller_ updateFilter:field]; + EXPECT_EQ(1U, [[[controller_ cocoaTreeModel] children] count]); + + // Search for "d". + [field setStringValue:@"d"]; + [controller_ updateFilter:field]; + EXPECT_EQ(2U, [[[controller_ cocoaTreeModel] children] count]); + + // Search for "e". + [field setStringValue:@"e"]; + [controller_ updateFilter:field]; + EXPECT_EQ(0U, [[[controller_ cocoaTreeModel] children] count]); + + // Search for "aa". + [field setStringValue:@"aa"]; + [controller_ updateFilter:field]; + EXPECT_EQ(1U, [[[controller_ cocoaTreeModel] children] count]); +} + +TEST_F(CookiesWindowControllerTest, CreateDatabaseStorageNodes) { + TestingProfile* profile = browser_helper_.profile(); + database_helper_ = new MockBrowsingDataDatabaseHelper(profile); + local_storage_helper_ = new MockBrowsingDataLocalStorageHelper(profile); + database_helper_->AddDatabaseSamples(); + controller_.reset( + [[CookiesWindowController alloc] initWithProfile:profile + databaseHelper:database_helper_ + storageHelper:local_storage_helper_ + appcacheHelper:appcache_helper_]); + database_helper_->Notify(); + + ASSERT_EQ(2U, [[[controller_ cocoaTreeModel] children] count]); + + // Root --> gdbhost1. + CocoaCookieTreeNode* node = + [[[controller_ cocoaTreeModel] children] objectAtIndex:0]; + EXPECT_NSEQ(@"gdbhost1", [node title]); + EXPECT_EQ(kCocoaCookieDetailsTypeFolder, [node nodeType]); + EXPECT_EQ(1U, [[node children] count]); + + // host1 --> Web Databases. + node = [[node children] lastObject]; + EXPECT_NSEQ(l10n_util::GetNSString(IDS_COOKIES_WEB_DATABASES), [node title]); + EXPECT_EQ(kCocoaCookieDetailsTypeFolder, [node nodeType]); + EXPECT_EQ(1U, [[node children] count]); + + // Database Storage --> db1. + node = [[node children] lastObject]; + EXPECT_NSEQ(@"db1", [node title]); + EXPECT_EQ(kCocoaCookieDetailsTypeTreeDatabase, [node nodeType]); + CocoaCookieDetails* details = [node details]; + EXPECT_NSEQ(@"description 1", [details databaseDescription]); + EXPECT_TRUE([details lastModified]); + EXPECT_TRUE([details fileSize]); + + // Root --> gdbhost2. + node = + [[[controller_ cocoaTreeModel] children] objectAtIndex:1]; + EXPECT_NSEQ(@"gdbhost2", [node title]); + EXPECT_EQ(kCocoaCookieDetailsTypeFolder, [node nodeType]); + EXPECT_EQ(1U, [[node children] count]); + + // host1 --> Web Databases. + node = [[node children] lastObject]; + EXPECT_NSEQ(l10n_util::GetNSString(IDS_COOKIES_WEB_DATABASES), [node title]); + EXPECT_EQ(kCocoaCookieDetailsTypeFolder, [node nodeType]); + EXPECT_EQ(1U, [[node children] count]); + + // Database Storage --> db2. + node = [[node children] lastObject]; + EXPECT_NSEQ(@"db2", [node title]); + EXPECT_EQ(kCocoaCookieDetailsTypeTreeDatabase, [node nodeType]); + details = [node details]; + EXPECT_NSEQ(@"description 2", [details databaseDescription]); + EXPECT_TRUE([details lastModified]); + EXPECT_TRUE([details fileSize]); +} + +TEST_F(CookiesWindowControllerTest, CreateLocalStorageNodes) { + TestingProfile* profile = browser_helper_.profile(); + net::CookieMonster* cm = profile->GetCookieMonster(); + cm->SetCookie(GURL("http://google.com"), "A=B"); + cm->SetCookie(GURL("http://dev.chromium.org"), "C=D"); + database_helper_ = new MockBrowsingDataDatabaseHelper(profile); + local_storage_helper_ = new MockBrowsingDataLocalStorageHelper(profile); + local_storage_helper_->AddLocalStorageSamples(); + controller_.reset( + [[CookiesWindowController alloc] initWithProfile:profile + databaseHelper:database_helper_ + storageHelper:local_storage_helper_ + appcacheHelper:appcache_helper_]); + local_storage_helper_->Notify(); + + ASSERT_EQ(4U, [[[controller_ cocoaTreeModel] children] count]); + + // Root --> host1. + CocoaCookieTreeNode* node = + [[[controller_ cocoaTreeModel] children] objectAtIndex:2]; + EXPECT_NSEQ(@"host1", [node title]); + EXPECT_EQ(kCocoaCookieDetailsTypeFolder, [node nodeType]); + EXPECT_EQ(1U, [[node children] count]); + + // host1 --> Local Storage. + node = [[node children] lastObject]; + EXPECT_NSEQ(l10n_util::GetNSString(IDS_COOKIES_LOCAL_STORAGE), [node title]); + EXPECT_EQ(kCocoaCookieDetailsTypeFolder, [node nodeType]); + EXPECT_EQ(1U, [[node children] count]); + + // Local Storage --> http://host1:1/. + node = [[node children] lastObject]; + EXPECT_NSEQ(@"http://host1:1/", [node title]); + EXPECT_EQ(kCocoaCookieDetailsTypeTreeLocalStorage, [node nodeType]); + EXPECT_NSEQ(@"http://host1:1/", [[node details] domain]); + EXPECT_TRUE([[node details] lastModified]); + EXPECT_TRUE([[node details] fileSize]); + + // Root --> host2. + node = + [[[controller_ cocoaTreeModel] children] objectAtIndex:3]; + EXPECT_NSEQ(@"host2", [node title]); + EXPECT_EQ(kCocoaCookieDetailsTypeFolder, [node nodeType]); + EXPECT_EQ(1U, [[node children] count]); + + // host2 --> Local Storage. + node = [[node children] lastObject]; + EXPECT_NSEQ(l10n_util::GetNSString(IDS_COOKIES_LOCAL_STORAGE), [node title]); + EXPECT_EQ(kCocoaCookieDetailsTypeFolder, [node nodeType]); + EXPECT_EQ(1U, [[node children] count]); + + // Local Storage --> http://host2:2/. + node = [[node children] lastObject]; + EXPECT_NSEQ(@"http://host2:2/", [node title]); + EXPECT_EQ(kCocoaCookieDetailsTypeTreeLocalStorage, [node nodeType]); + EXPECT_NSEQ(@"http://host2:2/", [[node details] domain]); + EXPECT_TRUE([[node details] lastModified]); + EXPECT_TRUE([[node details] fileSize]); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/custom_home_pages_model.h b/chrome/browser/ui/cocoa/custom_home_pages_model.h new file mode 100644 index 0000000..2bb94d8 --- /dev/null +++ b/chrome/browser/ui/cocoa/custom_home_pages_model.h @@ -0,0 +1,91 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_CUSTOM_HOME_PAGES_MODEL_H_ +#define CHROME_BROWSER_UI_COCOA_CUSTOM_HOME_PAGES_MODEL_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include <vector> +#include "base/scoped_nsobject.h" +#include "chrome/browser/history/history.h" +#include "googleurl/src/gurl.h" + +class Profile; + +// The model for the "custom home pages" table in preferences. Contains a list +// of CustomHomePageEntry objects. This is intended to be used with Cocoa +// bindings. +// +// The supported binding is |customHomePages|, a to-many relationship which +// can be observed with an array controller. + +@interface CustomHomePagesModel : NSObject { + @private + scoped_nsobject<NSMutableArray> entries_; + Profile* profile_; // weak, used for loading favicons +} + +// Initialize with |profile|, which must not be NULL. The profile is used for +// loading favicons for urls. +- (id)initWithProfile:(Profile*)profile; + +// Get/set the urls the model currently contains as a group. Only one change +// notification will be sent. +- (std::vector<GURL>)URLs; +- (void)setURLs:(const std::vector<GURL>&)urls; + +// Reloads the URLs from their stored state. This will notify using KVO +// |customHomePages|. +- (void)reloadURLs; + +// Validates the set of URLs stored in the model. The user may have input bad +// data. This function removes invalid entries from the model, which will result +// in anyone observing being updated. +- (void)validateURLs; + +// For binding |customHomePages| to a mutable array controller. +- (NSUInteger)countOfCustomHomePages; +- (id)objectInCustomHomePagesAtIndex:(NSUInteger)index; +- (void)insertObject:(id)object inCustomHomePagesAtIndex:(NSUInteger)index; +- (void)removeObjectFromCustomHomePagesAtIndex:(NSUInteger)index; +@end + +//////////////////////////////////////////////////////////////////////////////// + +// An entry representing a single item in the custom home page model. Stores +// a url and a favicon. +@interface CustomHomePageEntry : NSObject { + @private + scoped_nsobject<NSString> url_; + scoped_nsobject<NSImage> icon_; + + // If non-zero, indicates we're loading the favicon for the page. + HistoryService::Handle icon_handle_; +} + +@property(nonatomic, copy) NSString* URL; +@property(nonatomic, retain) NSImage* image; + +@end + +//////////////////////////////////////////////////////////////////////////////// + +@interface CustomHomePagesModel (InternalOrTestingAPI) + +// Clears the URL string at the specified index. This constitutes bad data. The +// validator should scrub the entry from the list the next time it is run. +- (void)setURLStringEmptyAt:(NSUInteger)index; + +@end + +// A notification that fires when the URL of one of the entries changes. +// Prevents interested parties from having to observe all model objects in order +// to persist changes to a single entry. Changes to the number of items in the +// model can be observed by watching |customHomePages| via KVO so an additional +// notification is not sent. +extern NSString* const kHomepageEntryChangedNotification; + +#endif // CHROME_BROWSER_UI_COCOA_CUSTOM_HOME_PAGES_MODEL_H_ diff --git a/chrome/browser/ui/cocoa/custom_home_pages_model.mm b/chrome/browser/ui/cocoa/custom_home_pages_model.mm new file mode 100644 index 0000000..2e0be88 --- /dev/null +++ b/chrome/browser/ui/cocoa/custom_home_pages_model.mm @@ -0,0 +1,140 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/custom_home_pages_model.h" + +#include "base/sys_string_conversions.h" +#include "chrome/browser/net/url_fixer_upper.h" +#include "chrome/browser/prefs/session_startup_pref.h" + +NSString* const kHomepageEntryChangedNotification = + @"kHomepageEntryChangedNotification"; + +@interface CustomHomePagesModel (Private) +- (void)setURLsInternal:(const std::vector<GURL>&)urls; +@end + +@implementation CustomHomePagesModel + +- (id)initWithProfile:(Profile*)profile { + if ((self = [super init])) { + profile_ = profile; + entries_.reset([[NSMutableArray alloc] init]); + } + return self; +} + +- (NSUInteger)countOfCustomHomePages { + return [entries_ count]; +} + +- (id)objectInCustomHomePagesAtIndex:(NSUInteger)index { + return [entries_ objectAtIndex:index]; +} + +- (void)insertObject:(id)object inCustomHomePagesAtIndex:(NSUInteger)index { + [entries_ insertObject:object atIndex:index]; +} + +- (void)removeObjectFromCustomHomePagesAtIndex:(NSUInteger)index { + [entries_ removeObjectAtIndex:index]; + // Force a save. + [self validateURLs]; +} + +// Get/set the urls the model currently contains as a group. These will weed +// out any URLs that are empty and not add them to the model. As a result, +// the next time they're persisted to the prefs backend, they'll disappear. +- (std::vector<GURL>)URLs { + std::vector<GURL> urls; + for (CustomHomePageEntry* entry in entries_.get()) { + const char* urlString = [[entry URL] UTF8String]; + if (urlString && std::strlen(urlString)) { + urls.push_back(GURL(std::string(urlString))); + } + } + return urls; +} + +- (void)setURLs:(const std::vector<GURL>&)urls { + [self willChangeValueForKey:@"customHomePages"]; + [self setURLsInternal:urls]; + SessionStartupPref pref(SessionStartupPref::GetStartupPref(profile_)); + pref.urls = urls; + SessionStartupPref::SetStartupPref(profile_, pref); + [self didChangeValueForKey:@"customHomePages"]; +} + +// Converts the C++ URLs to Cocoa objects without notifying KVO. +- (void)setURLsInternal:(const std::vector<GURL>&)urls { + [entries_ removeAllObjects]; + for (size_t i = 0; i < urls.size(); ++i) { + scoped_nsobject<CustomHomePageEntry> entry( + [[CustomHomePageEntry alloc] init]); + const char* urlString = urls[i].spec().c_str(); + if (urlString && std::strlen(urlString)) { + [entry setURL:[NSString stringWithCString:urlString + encoding:NSUTF8StringEncoding]]; + [entries_ addObject:entry]; + } + } +} + +- (void)reloadURLs { + [self willChangeValueForKey:@"customHomePages"]; + SessionStartupPref pref(SessionStartupPref::GetStartupPref(profile_)); + [self setURLsInternal:pref.urls]; + [self didChangeValueForKey:@"customHomePages"]; +} + +- (void)validateURLs { + [self setURLs:[self URLs]]; +} + +- (void)setURLStringEmptyAt:(NSUInteger)index { + // This replaces the data at |index| with an empty (invalid) URL string. + CustomHomePageEntry* entry = [entries_ objectAtIndex:index]; + [entry setURL:[NSString stringWithString:@""]]; +} + +@end + +//--------------------------------------------------------------------------- + +@implementation CustomHomePageEntry + +- (void)setURL:(NSString*)url { + // |url| can be nil if the user cleared the text from the edit field. + if (!url) + url = [NSString stringWithString:@""]; + + // Make sure the url is valid before setting it by fixing it up. + std::string fixedUrl(URLFixerUpper::FixupURL( + base::SysNSStringToUTF8(url), std::string()).possibly_invalid_spec()); + url_.reset([base::SysUTF8ToNSString(fixedUrl) retain]); + + // Broadcast that an individual item has changed. + [[NSNotificationCenter defaultCenter] + postNotificationName:kHomepageEntryChangedNotification object:nil]; + + // TODO(pinkerton): fetch favicon, convert to NSImage http://crbug.com/34642 +} + +- (NSString*)URL { + return url_.get(); +} + +- (void)setImage:(NSImage*)image { + icon_.reset(image); +} + +- (NSImage*)image { + return icon_.get(); +} + +- (NSString*)description { + return url_.get(); +} + +@end diff --git a/chrome/browser/ui/cocoa/custom_home_pages_model_unittest.mm b/chrome/browser/ui/cocoa/custom_home_pages_model_unittest.mm new file mode 100644 index 0000000..c744775 --- /dev/null +++ b/chrome/browser/ui/cocoa/custom_home_pages_model_unittest.mm @@ -0,0 +1,196 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/scoped_nsobject.h" +#include "chrome/browser/prefs/session_startup_pref.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#import "chrome/browser/ui/cocoa/custom_home_pages_model.h" +#include "testing/gtest/include/gtest/gtest.h" +#import "testing/gtest_mac.h" +#include "testing/platform_test.h" + +// A helper for KVO and NSNotifications. Makes a note that it's been called +// back. +@interface CustomHomePageHelper : NSObject { + @public + BOOL sawNotification_; +} +@end + +@implementation CustomHomePageHelper +- (void)observeValueForKeyPath:(NSString*)keyPath + ofObject:(id)object + change:(NSDictionary*)change + context:(void*)context { + sawNotification_ = YES; +} + +- (void)entryChanged:(NSNotification*)notify { + sawNotification_ = YES; +} +@end + +@interface NSObject () +- (void)setURL:(NSString*)url; +@end + +namespace { + +// Helper that creates an autoreleased entry. +CustomHomePageEntry* MakeEntry(NSString* url) { + CustomHomePageEntry* entry = [[[CustomHomePageEntry alloc] init] autorelease]; + [entry setURL:url]; + return entry; +} + +// Helper that casts from |id| to the Entry type and returns the URL string. +NSString* EntryURL(id entry) { + return [static_cast<CustomHomePageEntry*>(entry) URL]; +} + +class CustomHomePagesModelTest : public PlatformTest { + public: + CustomHomePagesModelTest() { + model_.reset([[CustomHomePagesModel alloc] + initWithProfile:helper_.profile()]); + } + ~CustomHomePagesModelTest() { } + + BrowserTestHelper helper_; + scoped_nsobject<CustomHomePagesModel> model_; +}; + +TEST_F(CustomHomePagesModelTest, Init) { + scoped_nsobject<CustomHomePagesModel> model( + [[CustomHomePagesModel alloc] initWithProfile:helper_.profile()]); +} + +TEST_F(CustomHomePagesModelTest, GetSetURLs) { + // Basic test. + std::vector<GURL> urls; + urls.push_back(GURL("http://www.google.com")); + [model_ setURLs:urls]; + std::vector<GURL> received_urls = [model_.get() URLs]; + EXPECT_EQ(received_urls.size(), 1U); + EXPECT_TRUE(urls[0] == received_urls[0]); + + // Set an empty list, make sure we get back an empty list. + std::vector<GURL> empty; + [model_ setURLs:empty]; + received_urls = [model_.get() URLs]; + EXPECT_EQ(received_urls.size(), 0U); + + // Give it a list with not well-formed URLs and make sure we get back. + // only the good ones. + std::vector<GURL> poorly_formed; + poorly_formed.push_back(GURL("http://www.google.com")); // good + poorly_formed.push_back(GURL("www.google.com")); // bad + poorly_formed.push_back(GURL("www.yahoo.")); // bad + poorly_formed.push_back(GURL("http://www.yahoo.com")); // good + [model_ setURLs:poorly_formed]; + received_urls = [model_.get() URLs]; + EXPECT_EQ(received_urls.size(), 2U); +} + +// Test that we get a KVO notification when called setURLs. +TEST_F(CustomHomePagesModelTest, KVOObserveWhenListChanges) { + scoped_nsobject<CustomHomePageHelper> kvo_helper( + [[CustomHomePageHelper alloc] init]); + [model_ addObserver:kvo_helper + forKeyPath:@"customHomePages" + options:0L + context:NULL]; + EXPECT_FALSE(kvo_helper.get()->sawNotification_); + + std::vector<GURL> urls; + urls.push_back(GURL("http://www.google.com")); + [model_ setURLs:urls]; // Should send kvo change notification. + EXPECT_TRUE(kvo_helper.get()->sawNotification_); + + [model_ removeObserver:kvo_helper forKeyPath:@"customHomePages"]; +} + +// Test the KVO "to-many" bindings for |customHomePages| and the KVO +// notifiation when items are added to and removed from the list. +TEST_F(CustomHomePagesModelTest, KVO) { + EXPECT_EQ([model_ countOfCustomHomePages], 0U); + + scoped_nsobject<CustomHomePageHelper> kvo_helper( + [[CustomHomePageHelper alloc] init]); + [model_ addObserver:kvo_helper + forKeyPath:@"customHomePages" + options:0L + context:NULL]; + EXPECT_FALSE(kvo_helper.get()->sawNotification_); + + // Cheat and insert NSString objects into the array. As long as we don't + // call -URLs, we'll be ok. + [model_ insertObject:MakeEntry(@"www.google.com") inCustomHomePagesAtIndex:0]; + EXPECT_TRUE(kvo_helper.get()->sawNotification_); + [model_ insertObject:MakeEntry(@"www.yahoo.com") inCustomHomePagesAtIndex:1]; + [model_ insertObject:MakeEntry(@"dev.chromium.org") + inCustomHomePagesAtIndex:2]; + EXPECT_EQ([model_ countOfCustomHomePages], 3U); + + EXPECT_NSEQ(@"http://www.yahoo.com/", + EntryURL([model_ objectInCustomHomePagesAtIndex:1])); + + kvo_helper.get()->sawNotification_ = NO; + [model_ removeObjectFromCustomHomePagesAtIndex:1]; + EXPECT_TRUE(kvo_helper.get()->sawNotification_); + EXPECT_EQ([model_ countOfCustomHomePages], 2U); + EXPECT_NSEQ(@"http://dev.chromium.org/", + EntryURL([model_ objectInCustomHomePagesAtIndex:1])); + EXPECT_NSEQ(@"http://www.google.com/", + EntryURL([model_ objectInCustomHomePagesAtIndex:0])); + + [model_ removeObserver:kvo_helper forKeyPath:@"customHomePages"]; +} + +// Test that when individual items are changed that they broadcast a message. +TEST_F(CustomHomePagesModelTest, ModelChangedNotification) { + scoped_nsobject<CustomHomePageHelper> kvo_helper( + [[CustomHomePageHelper alloc] init]); + [[NSNotificationCenter defaultCenter] + addObserver:kvo_helper + selector:@selector(entryChanged:) + name:kHomepageEntryChangedNotification + object:nil]; + + std::vector<GURL> urls; + urls.push_back(GURL("http://www.google.com")); + [model_ setURLs:urls]; + NSObject* entry = [model_ objectInCustomHomePagesAtIndex:0]; + [entry setURL:@"http://www.foo.bar"]; + EXPECT_TRUE(kvo_helper.get()->sawNotification_); + [[NSNotificationCenter defaultCenter] removeObserver:kvo_helper]; +} + +TEST_F(CustomHomePagesModelTest, ReloadURLs) { + scoped_nsobject<CustomHomePageHelper> kvo_helper( + [[CustomHomePageHelper alloc] init]); + [model_ addObserver:kvo_helper + forKeyPath:@"customHomePages" + options:0L + context:NULL]; + EXPECT_FALSE(kvo_helper.get()->sawNotification_); + EXPECT_EQ([model_ countOfCustomHomePages], 0U); + + std::vector<GURL> urls; + urls.push_back(GURL("http://www.google.com")); + SessionStartupPref pref; + pref.urls = urls; + SessionStartupPref::SetStartupPref(helper_.profile(), pref); + + [model_ reloadURLs]; + + EXPECT_TRUE(kvo_helper.get()->sawNotification_); + EXPECT_EQ([model_ countOfCustomHomePages], 1U); + EXPECT_NSEQ(@"http://www.google.com/", + EntryURL([model_ objectInCustomHomePagesAtIndex:0])); + + [model_ removeObserver:kvo_helper.get() forKeyPath:@"customHomePages"]; +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/delayedmenu_button.h b/chrome/browser/ui/cocoa/delayedmenu_button.h new file mode 100644 index 0000000..6363d30 --- /dev/null +++ b/chrome/browser/ui/cocoa/delayedmenu_button.h @@ -0,0 +1,32 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_DELAYEDMENU_BUTTON_H_ +#define CHROME_BROWSER_UI_COCOA_DELAYEDMENU_BUTTON_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" + +@interface DelayedMenuButton : NSButton { + NSMenu* attachedMenu_; // Strong (retained). + BOOL attachedMenuEnabled_; + scoped_nsobject<NSPopUpButtonCell> popUpCell_; +} + +// The menu to display. Note that it should have no (i.e., a blank) title and +// that the 0-th entry should be blank (and won't be displayed). (This is +// because we use a pulldown list, for which Cocoa uses the 0-th item as "title" +// in the button. This might change if we ever switch to a pop-up. Our direct +// use of the given NSMenu object means that the one can set and use NSMenu's +// delegate as usual.) +@property(retain, nonatomic) NSMenu* attachedMenu; + +// Is the menu enabled? (If not, don't act like a click-hold button.) +@property(assign, nonatomic) BOOL attachedMenuEnabled; + +@end // @interface DelayedMenuButton + +#endif // CHROME_BROWSER_UI_COCOA_DELAYEDMENU_BUTTON_H_ diff --git a/chrome/browser/ui/cocoa/delayedmenu_button.mm b/chrome/browser/ui/cocoa/delayedmenu_button.mm new file mode 100644 index 0000000..9a9d73d --- /dev/null +++ b/chrome/browser/ui/cocoa/delayedmenu_button.mm @@ -0,0 +1,137 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/delayedmenu_button.h" + +#include "base/logging.h" +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/clickhold_button_cell.h" + +@interface DelayedMenuButton (Private) + +- (void)setupCell; +- (void)attachedMenuAction:(id)sender; + +@end // @interface DelayedMenuButton (Private) + +@implementation DelayedMenuButton + +// Overrides: + ++ (Class)cellClass { + return [ClickHoldButtonCell class]; +} + +- (id)init { + if ((self = [super init])) + [self setupCell]; + return self; +} + +- (id)initWithCoder:(NSCoder*)decoder { + if ((self = [super initWithCoder:decoder])) + [self setupCell]; + return self; +} + +- (id)initWithFrame:(NSRect)frameRect { + if ((self = [super initWithFrame:frameRect])) + [self setupCell]; + return self; +} + +- (void)dealloc { + [attachedMenu_ release]; + [super dealloc]; +} + +- (void)awakeFromNib { + [self setupCell]; +} + +- (void)setCell:(NSCell*)cell { + [super setCell:cell]; + [self setupCell]; +} + +// Accessors and mutators: + +@synthesize attachedMenu = attachedMenu_; + +// Don't synthesize for attachedMenuEnabled_; its mutator must do other things. +- (void)setAttachedMenuEnabled:(BOOL)enabled { + attachedMenuEnabled_ = enabled; + [[self cell] setEnableClickHold:attachedMenuEnabled_]; +} + +- (BOOL)attachedMenuEnabled { + return attachedMenuEnabled_; +} + +@end // @implementation DelayedMenuButton + +@implementation DelayedMenuButton (Private) + +// Set up the button's cell if we've reached a point where it's been set. +- (void)setupCell { + ClickHoldButtonCell* cell = [self cell]; + if (cell) { + DCHECK([cell isKindOfClass:[ClickHoldButtonCell class]]); + [self setEnabled:NO]; // Make the controller put in a menu and + // enable it explicitly. This also takes + // care of |[cell setEnableClickHold:]|. + [cell setClickHoldAction:@selector(attachedMenuAction:)]; + [cell setClickHoldTarget:self]; + } +} + +// Display the menu. +- (void)attachedMenuAction:(id)sender { + // We shouldn't get here unless the menu is enabled. + DCHECK(attachedMenuEnabled_); + + // If we don't have a menu (in which case the person using this control is + // being bad), just wait for a mouse up. + if (!attachedMenu_) { + LOG(WARNING) << "No menu available."; + [NSApp nextEventMatchingMask:NSLeftMouseUpMask + untilDate:[NSDate distantFuture] + inMode:NSEventTrackingRunLoopMode + dequeue:YES]; + return; + } + + // TODO(viettrungluu): We have some fudge factors below to make things line up + // (approximately). I wish I knew how to get rid of them. (Note that our view + // is flipped, and that frame should be in our coordinates.) The y/height is + // very odd, since it doesn't seem to respond to changes the way that it + // should. I don't understand it. + NSRect frame = [self convertRect:[self frame] + fromView:[self superview]]; + frame.origin.x -= 2.0; + frame.size.height += 10.0; + + // Make our pop-up button cell and set things up. This is, as of 10.5, the + // official Apple-recommended hack. Later, perhaps |-[NSMenu + // popUpMenuPositioningItem:atLocation:inView:]| may be a better option. + // However, using a pulldown has the benefit that Cocoa automatically places + // the menu correctly even when we're at the edge of the screen (including + // "dragging upwards" when the button is close to the bottom of the screen). + // A |scoped_nsobject| local variable cannot be used here because + // Accessibility on 10.5 grabs the NSPopUpButtonCell without retaining it, and + // uses it later. (This is fixed in 10.6.) + if (!popUpCell_.get()) { + popUpCell_.reset([[NSPopUpButtonCell alloc] initTextCell:@"" + pullsDown:YES]); + } + DCHECK(popUpCell_.get()); + [popUpCell_ setMenu:attachedMenu_]; + [popUpCell_ selectItem:nil]; + [popUpCell_ attachPopUpWithFrame:frame + inView:self]; + [popUpCell_ performClickWithFrame:frame + inView:self]; +} + +@end // @implementation DelayedMenuButton (Private) diff --git a/chrome/browser/ui/cocoa/delayedmenu_button_unittest.mm b/chrome/browser/ui/cocoa/delayedmenu_button_unittest.mm new file mode 100644 index 0000000..d1d7b77 --- /dev/null +++ b/chrome/browser/ui/cocoa/delayedmenu_button_unittest.mm @@ -0,0 +1,62 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/clickhold_button_cell.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/delayedmenu_button.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +namespace { + +class DelayedMenuButtonTest : public CocoaTest { + public: + DelayedMenuButtonTest() { + NSRect frame = NSMakeRect(0, 0, 50, 30); + scoped_nsobject<DelayedMenuButton>button([[DelayedMenuButton alloc] + initWithFrame:frame]); + button_ = button.get(); + scoped_nsobject<ClickHoldButtonCell> cell( + [[ClickHoldButtonCell alloc] initTextCell:@"Testing"]); + [button_ setCell:cell.get()]; + [[test_window() contentView] addSubview:button_]; + } + + DelayedMenuButton* button_; +}; + +TEST_VIEW(DelayedMenuButtonTest, button_) + +// Test assigning and enabling a menu, again mostly to ensure nothing leaks or +// crashes. +TEST_F(DelayedMenuButtonTest, MenuAssign) { + scoped_nsobject<NSMenu> menu([[NSMenu alloc] initWithTitle:@""]); + ASSERT_TRUE(menu.get()); + + [menu insertItemWithTitle:@"" action:nil keyEquivalent:@"" atIndex:0]; + [menu insertItemWithTitle:@"foo" action:nil keyEquivalent:@"" atIndex:1]; + [menu insertItemWithTitle:@"bar" action:nil keyEquivalent:@"" atIndex:2]; + [menu insertItemWithTitle:@"baz" action:nil keyEquivalent:@"" atIndex:3]; + + [button_ setAttachedMenu:menu]; + EXPECT_TRUE([button_ attachedMenu]); + + [button_ setAttachedMenuEnabled:YES]; + EXPECT_TRUE([button_ attachedMenuEnabled]); + + // TODO(viettrungluu): Display the menu. (Calling DelayedMenuButton's private + // |-attachedMenuAction:| method displays it fine, but the problem is + // getting rid of the menu. We can catch the + // |NSMenuDidBeginTrackingNotification| from |menu| fine, but then + // |-cancelTracking| doesn't dismiss it. I don't know why.) +} + +// TODO(viettrungluu): Test the two actions of the button (the normal one and +// displaying the menu, also making sure the latter drags correctly)? It would +// require "emulating" a mouse.... + +} // namespace diff --git a/chrome/browser/ui/cocoa/dev_tools_controller.h b/chrome/browser/ui/cocoa/dev_tools_controller.h new file mode 100644 index 0000000..c89a9f8 --- /dev/null +++ b/chrome/browser/ui/cocoa/dev_tools_controller.h @@ -0,0 +1,51 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_DEV_TOOLS_CONTROLLER_H_ +#define CHROME_BROWSER_UI_COCOA_DEV_TOOLS_CONTROLLER_H_ +#pragma once + +#import <Foundation/Foundation.h> + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/tab_contents_controller.h" + +@class NSSplitView; +@class NSView; + +class TabContents; + +// A class that handles updates of the devTools view within a browser window. +// It swaps in the relevant devTools contents for a given TabContents or removes +// the vew, if there's no devTools contents to show. +@interface DevToolsController : NSObject { + @private + // A view hosting docked devTools contents. + scoped_nsobject<NSSplitView> splitView_; + + // Manages currently displayed devTools contents. + scoped_nsobject<TabContentsController> contentsController_; +} + +- (id)initWithDelegate:(id<TabContentsControllerDelegate>)delegate; + +// This controller's view. +- (NSView*)view; + +// The compiler seems to have trouble handling a function named "view" that +// returns an NSSplitView, so provide a differently-named method. +- (NSSplitView*)splitView; + +// Depending on |contents|'s state, decides whether the docked web inspector +// should be shown or hidden and adjusts its height (|delegate_| handles +// the actual resize). +- (void)updateDevToolsForTabContents:(TabContents*)contents; + +// Call when the devTools view is properly sized and the render widget host view +// should be put into the view hierarchy. +- (void)ensureContentsVisible; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_DEV_TOOLS_CONTROLLER_H_ diff --git a/chrome/browser/ui/cocoa/dev_tools_controller.mm b/chrome/browser/ui/cocoa/dev_tools_controller.mm new file mode 100644 index 0000000..596bdae --- /dev/null +++ b/chrome/browser/ui/cocoa/dev_tools_controller.mm @@ -0,0 +1,164 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/dev_tools_controller.h" + +#include <algorithm> + +#include <Cocoa/Cocoa.h> + +#include "chrome/browser/browser_process.h" +#include "chrome/browser/debugger/devtools_window.h" +#include "chrome/browser/prefs/pref_service.h" +#include "chrome/browser/tab_contents/tab_contents.h" +#include "chrome/browser/ui/browser.h" +#import "chrome/browser/ui/cocoa/view_id_util.h" +#include "chrome/common/pref_names.h" + +namespace { + +// Default offset of the contents splitter in pixels. +const int kDefaultContentsSplitOffset = 400; + +// Never make the web part of the tab contents smaller than this (needed if the +// window is only a few pixels high). +const int kMinWebHeight = 50; + +} // end namespace + + +@interface DevToolsController (Private) +- (void)showDevToolsContents:(TabContents*)devToolsContents; +- (void)resizeDevToolsToNewHeight:(CGFloat)height; +@end + + +@implementation DevToolsController + +- (id)initWithDelegate:(id<TabContentsControllerDelegate>)delegate { + if ((self = [super init])) { + splitView_.reset([[NSSplitView alloc] initWithFrame:NSZeroRect]); + [splitView_ setDividerStyle:NSSplitViewDividerStyleThin]; + [splitView_ setVertical:NO]; + [splitView_ setAutoresizingMask:NSViewWidthSizable|NSViewHeightSizable]; + [splitView_ setDelegate:self]; + + contentsController_.reset( + [[TabContentsController alloc] initWithContents:NULL + delegate:delegate]); + } + return self; +} + +- (void)dealloc { + [splitView_ setDelegate:nil]; + [super dealloc]; +} + +- (NSView*)view { + return splitView_.get(); +} + +- (NSSplitView*)splitView { + return splitView_.get(); +} + +- (void)updateDevToolsForTabContents:(TabContents*)contents { + // Get current devtools content. + TabContents* devToolsContents = contents ? + DevToolsWindow::GetDevToolsContents(contents) : NULL; + + [self showDevToolsContents:devToolsContents]; +} + +- (void)ensureContentsVisible { + [contentsController_ ensureContentsVisible]; +} + +- (void)showDevToolsContents:(TabContents*)devToolsContents { + [contentsController_ ensureContentsSizeDoesNotChange]; + + NSArray* subviews = [splitView_ subviews]; + if (devToolsContents) { + DCHECK_GE([subviews count], 1u); + + // |devToolsView| is a TabContentsViewCocoa object, whose ViewID was + // set to VIEW_ID_TAB_CONTAINER initially, so we need to change it to + // VIEW_ID_DEV_TOOLS_DOCKED here. + view_id_util::SetID( + devToolsContents->GetNativeView(), VIEW_ID_DEV_TOOLS_DOCKED); + + CGFloat splitOffset = 0; + if ([subviews count] == 1) { + // Load the default split offset. + splitOffset = g_browser_process->local_state()->GetInteger( + prefs::kDevToolsSplitLocation); + if (splitOffset < 0) { + // Initial load, set to default value. + splitOffset = kDefaultContentsSplitOffset; + } + [splitView_ addSubview:[contentsController_ view]]; + } else { + DCHECK_EQ([subviews count], 2u); + // If devtools are already visible, keep the current size. + splitOffset = NSHeight([[subviews objectAtIndex:1] frame]); + } + + // Make sure |splitOffset| isn't too large or too small. + splitOffset = std::max(static_cast<CGFloat>(kMinWebHeight), splitOffset); + splitOffset = + std::min(splitOffset, NSHeight([splitView_ frame]) - kMinWebHeight); + DCHECK_GE(splitOffset, 0) << "kMinWebHeight needs to be smaller than " + << "smallest available tab contents space."; + + [self resizeDevToolsToNewHeight:splitOffset]; + } else { + if ([subviews count] > 1) { + NSView* oldDevToolsContentsView = [subviews objectAtIndex:1]; + // Store split offset when hiding devtools window only. + int splitOffset = NSHeight([oldDevToolsContentsView frame]); + g_browser_process->local_state()->SetInteger( + prefs::kDevToolsSplitLocation, splitOffset); + [oldDevToolsContentsView removeFromSuperview]; + [splitView_ adjustSubviews]; + } + } + + [contentsController_ changeTabContents:devToolsContents]; +} + +- (void)resizeDevToolsToNewHeight:(CGFloat)height { + NSArray* subviews = [splitView_ subviews]; + + // It seems as if |-setPosition:ofDividerAtIndex:| should do what's needed, + // but I can't figure out how to use it. Manually resize web and devtools. + // TODO(alekseys): either make setPosition:ofDividerAtIndex: work or to add a + // category on NSSplitView to handle manual resizing. + NSView* devToolsView = [subviews objectAtIndex:1]; + NSRect devToolsFrame = [devToolsView frame]; + devToolsFrame.size.height = height; + [devToolsView setFrame:devToolsFrame]; + + NSView* webView = [subviews objectAtIndex:0]; + NSRect webFrame = [webView frame]; + webFrame.size.height = + NSHeight([splitView_ frame]) - ([splitView_ dividerThickness] + height); + [webView setFrame:webFrame]; + + [splitView_ adjustSubviews]; +} + +// NSSplitViewDelegate protocol. +- (BOOL)splitView:(NSSplitView *)splitView + shouldAdjustSizeOfSubview:(NSView *)subview { + // Return NO for the devTools view to indicate that it should not be resized + // automatically. It preserves the height set by the user and also keeps + // view height the same while changing tabs when one of the tabs shows infobar + // and others are not. + if ([[splitView_ subviews] indexOfObject:subview] == 1) + return NO; + return YES; +} + +@end diff --git a/chrome/browser/ui/cocoa/dock_icon.h b/chrome/browser/ui/cocoa/dock_icon.h new file mode 100644 index 0000000..4a96537 --- /dev/null +++ b/chrome/browser/ui/cocoa/dock_icon.h @@ -0,0 +1,30 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +// A class representing the dock icon of the Chromium app. It's its own class +// since several parts of the app want to manipulate the display of the dock +// icon. +@interface DockIcon : NSObject { +} + ++ (DockIcon*)sharedDockIcon; + +// Updates the icon. Use the setters below to set the details first. +- (void)updateIcon; + +// Download progress /////////////////////////////////////////////////////////// + +// Indicates how many downloads are in progress. +- (void)setDownloads:(int)downloads; + +// Indicates whether the progress indicator should be in an indeterminate state +// or not. +- (void)setIndeterminate:(BOOL)indeterminate; + +// Indicates the amount of progress made of the download. Ranges from [0..1]. +- (void)setProgress:(float)progress; + +@end diff --git a/chrome/browser/ui/cocoa/dock_icon.mm b/chrome/browser/ui/cocoa/dock_icon.mm new file mode 100644 index 0000000..980519a --- /dev/null +++ b/chrome/browser/ui/cocoa/dock_icon.mm @@ -0,0 +1,224 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/dock_icon.h" + +#include "base/scoped_nsobject.h" + +// The fraction of the size of the dock icon that the badge is. +static const float kBadgeFraction = 0.4f; + +// The indentation of the badge. +static const float kBadgeIndent = 5.0f; + +// A view that draws our dock tile. +@interface DockTileView : NSView { + @private + int downloads_; + BOOL indeterminate_; + float progress_; +} + +// Indicates how many downloads are in progress. +@property (nonatomic) int downloads; + +// Indicates whether the progress indicator should be in an indeterminate state +// or not. +@property (nonatomic) BOOL indeterminate; + +// Indicates the amount of progress made of the download. Ranges from [0..1]. +@property (nonatomic) float progress; + +@end + +@implementation DockTileView + +@synthesize downloads = downloads_; +@synthesize indeterminate = indeterminate_; +@synthesize progress = progress_; + +- (void)drawRect:(NSRect)dirtyRect { + // Not -[NSApplication applicationIconImage]; that fails to return a pasted + // custom icon. + NSString* appPath = [[NSBundle mainBundle] bundlePath]; + NSImage* appIcon = [[NSWorkspace sharedWorkspace] iconForFile:appPath]; + [appIcon drawInRect:[self bounds] + fromRect:NSZeroRect + operation:NSCompositeSourceOver + fraction:1.0]; + + if (downloads_ == 0) + return; + + NSRect badgeRect = [self bounds]; + badgeRect.size.height = (int)(kBadgeFraction * badgeRect.size.height); + int newWidth = kBadgeFraction * badgeRect.size.width; + badgeRect.origin.x = badgeRect.size.width - newWidth; + badgeRect.size.width = newWidth; + + CGFloat badgeRadius = NSMidY(badgeRect); + + badgeRect.origin.x -= kBadgeIndent; + badgeRect.origin.y += kBadgeIndent; + + NSPoint badgeCenter = NSMakePoint(NSMidX(badgeRect), + NSMidY(badgeRect)); + + // Background + NSColor* backgroundColor = [NSColor colorWithCalibratedRed:0.85 + green:0.85 + blue:0.85 + alpha:1.0]; + NSColor* backgroundHighlight = + [backgroundColor blendedColorWithFraction:0.85 + ofColor:[NSColor whiteColor]]; + scoped_nsobject<NSGradient> backgroundGradient( + [[NSGradient alloc] initWithStartingColor:backgroundHighlight + endingColor:backgroundColor]); + NSBezierPath* badgeEdge = [NSBezierPath bezierPathWithOvalInRect:badgeRect]; + [NSGraphicsContext saveGraphicsState]; + [badgeEdge addClip]; + [backgroundGradient drawFromCenter:badgeCenter + radius:0.0 + toCenter:badgeCenter + radius:badgeRadius + options:0]; + [NSGraphicsContext restoreGraphicsState]; + + // Slice + if (!indeterminate_) { + NSColor* sliceColor = [NSColor colorWithCalibratedRed:0.45 + green:0.8 + blue:0.25 + alpha:1.0]; + NSColor* sliceHighlight = + [sliceColor blendedColorWithFraction:0.4 + ofColor:[NSColor whiteColor]]; + scoped_nsobject<NSGradient> sliceGradient( + [[NSGradient alloc] initWithStartingColor:sliceHighlight + endingColor:sliceColor]); + NSBezierPath* progressSlice; + if (progress_ >= 1.0) { + progressSlice = [NSBezierPath bezierPathWithOvalInRect:badgeRect]; + } else { + CGFloat endAngle = 90.0 - 360.0 * progress_; + if (endAngle < 0.0) + endAngle += 360.0; + progressSlice = [NSBezierPath bezierPath]; + [progressSlice moveToPoint:badgeCenter]; + [progressSlice appendBezierPathWithArcWithCenter:badgeCenter + radius:badgeRadius + startAngle:90.0 + endAngle:endAngle + clockwise:YES]; + [progressSlice closePath]; + } + [NSGraphicsContext saveGraphicsState]; + [progressSlice addClip]; + [sliceGradient drawFromCenter:badgeCenter + radius:0.0 + toCenter:badgeCenter + radius:badgeRadius + options:0]; + [NSGraphicsContext restoreGraphicsState]; + } + + // Edge + [NSGraphicsContext saveGraphicsState]; + [[NSColor whiteColor] set]; + scoped_nsobject<NSShadow> shadow([[NSShadow alloc] init]); + [shadow.get() setShadowOffset:NSMakeSize(0, -2)]; + [shadow setShadowBlurRadius:2]; + [shadow set]; + [badgeEdge setLineWidth:2]; + [badgeEdge stroke]; + [NSGraphicsContext restoreGraphicsState]; + + // Download count + scoped_nsobject<NSNumberFormatter> formatter( + [[NSNumberFormatter alloc] init]); + NSString* countString = + [formatter stringFromNumber:[NSNumber numberWithInt:downloads_]]; + + scoped_nsobject<NSShadow> countShadow([[NSShadow alloc] init]); + [countShadow setShadowBlurRadius:3.0]; + [countShadow.get() setShadowColor:[NSColor whiteColor]]; + [countShadow.get() setShadowOffset:NSMakeSize(0.0, 0.0)]; + NSMutableDictionary* countAttrsDict = + [NSMutableDictionary dictionaryWithObjectsAndKeys: + [NSColor blackColor], NSForegroundColorAttributeName, + countShadow.get(), NSShadowAttributeName, + nil]; + CGFloat countFontSize = badgeRadius; + NSSize countSize = NSZeroSize; + scoped_nsobject<NSAttributedString> countAttrString; + while (1) { + NSFont* countFont = [NSFont fontWithName:@"Helvetica-Bold" + size:countFontSize]; + [countAttrsDict setObject:countFont forKey:NSFontAttributeName]; + countAttrString.reset( + [[NSAttributedString alloc] initWithString:countString + attributes:countAttrsDict]); + countSize = [countAttrString size]; + if (countSize.width > badgeRadius * 1.5) { + countFontSize -= 1.0; + } else { + break; + } + } + + NSPoint countOrigin = badgeCenter; + countOrigin.x -= countSize.width / 2; + countOrigin.y -= countSize.height / 2.2; // tweak; otherwise too low + + [countAttrString.get() drawAtPoint:countOrigin]; +} + +@end + + +@implementation DockIcon + ++ (DockIcon*)sharedDockIcon { + static DockIcon* icon; + if (!icon) { + NSDockTile* dockTile = [[NSApplication sharedApplication] dockTile]; + + scoped_nsobject<DockTileView> dockTileView([[DockTileView alloc] init]); + [dockTile setContentView:dockTileView]; + + icon = [[DockIcon alloc] init]; + } + + return icon; +} + +- (void)updateIcon { + NSDockTile* dockTile = [[NSApplication sharedApplication] dockTile]; + + [dockTile display]; +} + +- (void)setDownloads:(int)downloads { + NSDockTile* dockTile = [[NSApplication sharedApplication] dockTile]; + DockTileView* dockTileView = (DockTileView*)([dockTile contentView]); + + [dockTileView setDownloads:downloads]; +} + +- (void)setIndeterminate:(BOOL)indeterminate { + NSDockTile* dockTile = [[NSApplication sharedApplication] dockTile]; + DockTileView* dockTileView = (DockTileView*)([dockTile contentView]); + + [dockTileView setIndeterminate:indeterminate]; +} + +- (void)setProgress:(float)progress { + NSDockTile* dockTile = [[NSApplication sharedApplication] dockTile]; + DockTileView* dockTileView = (DockTileView*)([dockTile contentView]); + + [dockTileView setProgress:progress]; +} + +@end diff --git a/chrome/browser/ui/cocoa/download/download_item_button.h b/chrome/browser/ui/cocoa/download/download_item_button.h new file mode 100644 index 0000000..c344341 --- /dev/null +++ b/chrome/browser/ui/cocoa/download/download_item_button.h @@ -0,0 +1,27 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#import "base/cocoa_protocols_mac.h" +#include "base/file_path.h" +#import "chrome/browser/ui/cocoa/draggable_button.h" + +@class DownloadItemController; + +// A button that is a drag source for a file and that displays a context menu +// instead of firing an action when clicked in a certain area. +@interface DownloadItemButton : DraggableButton<NSMenuDelegate> { + @private + FilePath downloadPath_; + DownloadItemController* controller_; // weak +} + +@property(assign, nonatomic) FilePath download; +@property(assign, nonatomic) DownloadItemController* controller; + +// Overridden from DraggableButton. +- (void)beginDrag:(NSEvent*)event; + +@end diff --git a/chrome/browser/ui/cocoa/download/download_item_button.mm b/chrome/browser/ui/cocoa/download/download_item_button.mm new file mode 100644 index 0000000..da9f6b4 --- /dev/null +++ b/chrome/browser/ui/cocoa/download/download_item_button.mm @@ -0,0 +1,50 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/download/download_item_button.h" + +#include "base/logging.h" +#include "base/sys_string_conversions.h" +#import "chrome/browser/ui/cocoa/download/download_item_cell.h" +#import "chrome/browser/ui/cocoa/download/download_item_controller.h" + +@implementation DownloadItemButton + +@synthesize download = downloadPath_; +@synthesize controller = controller_; + +// Overridden from DraggableButton. +- (void)beginDrag:(NSEvent*)event { + if (!downloadPath_.empty()) { + NSString* filename = base::SysUTF8ToNSString(downloadPath_.value()); + [self dragFile:filename fromRect:[self bounds] slideBack:YES event:event]; + } +} + +// Override to show a context menu on mouse down if clicked over the context +// menu area. +- (void)mouseDown:(NSEvent*)event { + DCHECK(controller_); + // Override so that we can pop up a context menu on mouse down. + NSCell* cell = [self cell]; + DCHECK([cell respondsToSelector:@selector(isMouseOverButtonPart)]); + if ([reinterpret_cast<DownloadItemCell*>(cell) isMouseOverButtonPart]) { + [super mouseDown:event]; + } else { + // Hold a reference to our controller in case the download completes and we + // represent a file that's auto-removed (e.g. a theme). + scoped_nsobject<DownloadItemController> ref([controller_ retain]); + [cell setHighlighted:YES]; + [[self menu] setDelegate:self]; + [NSMenu popUpContextMenu:[self menu] + withEvent:[NSApp currentEvent] + forView:self]; + } +} + +- (void)menuDidClose:(NSMenu*)menu { + [[self cell] setHighlighted:NO]; +} + +@end diff --git a/chrome/browser/ui/cocoa/download/download_item_button_unittest.mm b/chrome/browser/ui/cocoa/download/download_item_button_unittest.mm new file mode 100644 index 0000000..bb0279d --- /dev/null +++ b/chrome/browser/ui/cocoa/download/download_item_button_unittest.mm @@ -0,0 +1,21 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/download/download_item_button.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +// Make sure nothing leaks. +TEST(DownloadItemButtonTest, Create) { + scoped_nsobject<DownloadItemButton> button; + button.reset([[DownloadItemButton alloc] + initWithFrame:NSMakeRect(0,0,500,500)]); + + // Test setter + FilePath path("foo"); + [button.get() setDownload:path]; + EXPECT_EQ(path.value(), [button.get() download].value()); +} diff --git a/chrome/browser/ui/cocoa/download/download_item_cell.h b/chrome/browser/ui/cocoa/download/download_item_cell.h new file mode 100644 index 0000000..4f24837 --- /dev/null +++ b/chrome/browser/ui/cocoa/download/download_item_cell.h @@ -0,0 +1,61 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_DOWNLOAD_DOWNLOAD_ITEM_CELL_H_ +#define CHROME_BROWSER_UI_COCOA_DOWNLOAD_DOWNLOAD_ITEM_CELL_H_ +#pragma once + +#import "base/cocoa_protocols_mac.h" +#include "base/scoped_ptr.h" +#import "chrome/browser/ui/cocoa/gradient_button_cell.h" + +#include "base/file_path.h" + +class BaseDownloadItemModel; + +// A button cell that implements the weird button/popup button hybrid that is +// used by the download items. + +// The button represented by this cell consists of a button part on the left +// and a dropdown-menu part on the right. This enum describes which part the +// mouse cursor is over currently. +enum DownloadItemMousePosition { + kDownloadItemMouseOutside, + kDownloadItemMouseOverButtonPart, + kDownloadItemMouseOverDropdownPart +}; + +@interface DownloadItemCell : GradientButtonCell<NSAnimationDelegate> { + @private + // Track which part of the button the mouse is over + DownloadItemMousePosition mousePosition_; + int mouseInsideCount_; + scoped_nsobject<NSTrackingArea> trackingAreaButton_; + scoped_nsobject<NSTrackingArea> trackingAreaDropdown_; + + FilePath downloadPath_; // stored unelided + NSString* secondaryTitle_; + NSFont* secondaryFont_; + int percentDone_; + scoped_nsobject<NSAnimation> completionAnimation_; + + BOOL isStatusTextVisible_; + CGFloat titleY_; + CGFloat statusAlpha_; + scoped_nsobject<NSAnimation> hideStatusAnimation_; + + scoped_ptr<ThemeProvider> themeProvider_; +} + +- (void)setStateFromDownload:(BaseDownloadItemModel*)downloadModel; + +@property (nonatomic, copy) NSString* secondaryTitle; +@property (nonatomic, retain) NSFont* secondaryFont; + +// Returns if the mouse is over the button part of the cell. +- (BOOL)isMouseOverButtonPart; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_DOWNLOAD_DOWNLOAD_ITEM_CELL_H_ diff --git a/chrome/browser/ui/cocoa/download/download_item_cell.mm b/chrome/browser/ui/cocoa/download/download_item_cell.mm new file mode 100644 index 0000000..b83d6f5 --- /dev/null +++ b/chrome/browser/ui/cocoa/download/download_item_cell.mm @@ -0,0 +1,708 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/download/download_item_cell.h" + +#include "app/l10n_util.h" +#include "app/text_elider.h" +#include "base/mac_util.h" +#include "base/sys_string_conversions.h" +#include "chrome/browser/download/download_item.h" +#include "chrome/browser/download/download_item_model.h" +#include "chrome/browser/download/download_manager.h" +#include "chrome/browser/download/download_util.h" +#import "chrome/browser/themes/browser_theme_provider.h" +#import "chrome/browser/ui/cocoa/download/download_item_cell.h" +#import "chrome/browser/ui/cocoa/image_utils.h" +#import "chrome/browser/ui/cocoa/themed_window.h" +#include "gfx/canvas_skia_paint.h" +#include "grit/theme_resources.h" +#import "third_party/GTM/AppKit/GTMNSAnimation+Duration.h" +#import "third_party/GTM/AppKit/GTMNSColor+Luminance.h" + +namespace { + +// Distance from top border to icon +const CGFloat kImagePaddingTop = 7; + +// Distance from left border to icon +const CGFloat kImagePaddingLeft = 9; + +// Width of icon +const CGFloat kImageWidth = 16; + +// Height of icon +const CGFloat kImageHeight = 16; + +// x coordinate of download name string, in view coords +const CGFloat kTextPosLeft = kImagePaddingLeft + + kImageWidth + download_util::kSmallProgressIconOffset; + +// Distance from end of download name string to dropdown area +const CGFloat kTextPaddingRight = 3; + +// y coordinate of download name string, in view coords, when status message +// is visible +const CGFloat kPrimaryTextPosTop = 3; + +// y coordinate of download name string, in view coords, when status message +// is not visible +const CGFloat kPrimaryTextOnlyPosTop = 10; + +// y coordinate of status message, in view coords +const CGFloat kSecondaryTextPosTop = 18; + +// Grey value of status text +const CGFloat kSecondaryTextColor = 0.5; + +// Width of dropdown area on the right (includes 1px for the border on each +// side). +const CGFloat kDropdownAreaWidth = 14; + +// Width of dropdown arrow +const CGFloat kDropdownArrowWidth = 5; + +// Height of dropdown arrow +const CGFloat kDropdownArrowHeight = 3; + +// Vertical displacement of dropdown area, relative to the "centered" position. +const CGFloat kDropdownAreaY = -2; + +// Duration of the two-lines-to-one-line animation, in seconds +NSTimeInterval kHideStatusDuration = 0.3; + +// Duration of the 'download complete' animation, in seconds +const int kCompleteAnimationDuration = 2.5; + +} + +// This is a helper class to animate the fading out of the status text. +@interface DownloadItemCellAnimation : NSAnimation { + DownloadItemCell* cell_; +} +- (id)initWithDownloadItemCell:(DownloadItemCell*)cell + duration:(NSTimeInterval)duration + animationCurve:(NSAnimationCurve)animationCurve; +@end + +class BackgroundTheme : public ThemeProvider { +public: + BackgroundTheme(ThemeProvider* provider); + + virtual void Init(Profile* profile) { } + virtual SkBitmap* GetBitmapNamed(int id) const { return nil; } + virtual SkColor GetColor(int id) const { return SkColor(); } + virtual bool GetDisplayProperty(int id, int* result) const { return false; } + virtual bool ShouldUseNativeFrame() const { return false; } + virtual bool HasCustomImage(int id) const { return false; } + virtual RefCountedMemory* GetRawData(int id) const { return NULL; } + virtual NSImage* GetNSImageNamed(int id, bool allow_default) const; + virtual NSColor* GetNSImageColorNamed(int id, bool allow_default) const; + virtual NSColor* GetNSColor(int id, bool allow_default) const; + virtual NSColor* GetNSColorTint(int id, bool allow_default) const; + virtual NSGradient* GetNSGradient(int id) const; + +private: + ThemeProvider* provider_; + scoped_nsobject<NSGradient> buttonGradient_; + scoped_nsobject<NSGradient> buttonPressedGradient_; + scoped_nsobject<NSColor> borderColor_; +}; + +BackgroundTheme::BackgroundTheme(ThemeProvider* provider) : + provider_(provider) { + NSColor* bgColor = [NSColor colorWithCalibratedRed:241/255.0 + green:245/255.0 + blue:250/255.0 + alpha:77/255.0]; + NSColor* clickedColor = [NSColor colorWithCalibratedRed:239/255.0 + green:245/255.0 + blue:252/255.0 + alpha:51/255.0]; + + borderColor_.reset( + [[NSColor colorWithCalibratedWhite:0 alpha:36/255.0] retain]); + buttonGradient_.reset([[NSGradient alloc] + initWithColors:[NSArray arrayWithObject:bgColor]]); + buttonPressedGradient_.reset([[NSGradient alloc] + initWithColors:[NSArray arrayWithObject:clickedColor]]); +} + +NSImage* BackgroundTheme::GetNSImageNamed(int id, bool allow_default) const { + return nil; +} + +NSColor* BackgroundTheme::GetNSImageColorNamed(int id, + bool allow_default) const { + return nil; +} + +NSColor* BackgroundTheme::GetNSColor(int id, bool allow_default) const { + return provider_->GetNSColor(id, allow_default); +} + +NSColor* BackgroundTheme::GetNSColorTint(int id, bool allow_default) const { + if (id == BrowserThemeProvider::TINT_BUTTONS) + return borderColor_.get(); + + return provider_->GetNSColorTint(id, allow_default); +} + +NSGradient* BackgroundTheme::GetNSGradient(int id) const { + switch (id) { + case BrowserThemeProvider::GRADIENT_TOOLBAR_BUTTON: + case BrowserThemeProvider::GRADIENT_TOOLBAR_BUTTON_INACTIVE: + return buttonGradient_.get(); + case BrowserThemeProvider::GRADIENT_TOOLBAR_BUTTON_PRESSED: + case BrowserThemeProvider::GRADIENT_TOOLBAR_BUTTON_PRESSED_INACTIVE: + return buttonPressedGradient_.get(); + default: + return provider_->GetNSGradient(id); + } +} + +@interface DownloadItemCell(Private) +- (void)updateTrackingAreas:(id)sender; +- (void)hideSecondaryTitle; +- (void)animation:(NSAnimation*)animation + progressed:(NSAnimationProgress)progress; +- (NSString*)elideTitle:(int)availableWidth; +- (NSString*)elideStatus:(int)availableWidth; +- (ThemeProvider*)backgroundThemeWrappingProvider:(ThemeProvider*)provider; +- (BOOL)pressedWithDefaultThemeOnPart:(DownloadItemMousePosition)part; +- (NSColor*)titleColorForPart:(DownloadItemMousePosition)part; +- (void)drawSecondaryTitleInRect:(NSRect)innerFrame; +@end + +@implementation DownloadItemCell + +@synthesize secondaryTitle = secondaryTitle_; +@synthesize secondaryFont = secondaryFont_; + +- (void)setInitialState { + isStatusTextVisible_ = NO; + titleY_ = kPrimaryTextPosTop; + statusAlpha_ = 1.0; + + [self setFont:[NSFont systemFontOfSize: + [NSFont systemFontSizeForControlSize:NSSmallControlSize]]]; + [self setSecondaryFont:[NSFont systemFontOfSize: + [NSFont systemFontSizeForControlSize:NSSmallControlSize]]]; + + [self updateTrackingAreas:self]; + [[NSNotificationCenter defaultCenter] + addObserver:self + selector:@selector(updateTrackingAreas:) + name:NSViewFrameDidChangeNotification + object:[self controlView]]; +} + +// For nib instantiations +- (id)initWithCoder:(NSCoder*)decoder { + if ((self = [super initWithCoder:decoder])) { + [self setInitialState]; + } + return self; +} + +// For programmatic instantiations. +- (id)initTextCell:(NSString *)string { + if ((self = [super initTextCell:string])) { + [self setInitialState]; + } + return self; +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; + if ([completionAnimation_ isAnimating]) + [completionAnimation_ stopAnimation]; + if ([hideStatusAnimation_ isAnimating]) + [hideStatusAnimation_ stopAnimation]; + if (trackingAreaButton_) { + [[self controlView] removeTrackingArea:trackingAreaButton_]; + trackingAreaButton_.reset(); + } + if (trackingAreaDropdown_) { + [[self controlView] removeTrackingArea:trackingAreaDropdown_]; + trackingAreaDropdown_.reset(); + } + [secondaryTitle_ release]; + [secondaryFont_ release]; + [super dealloc]; +} + +- (void)setStateFromDownload:(BaseDownloadItemModel*)downloadModel { + // Set the name of the download. + downloadPath_ = downloadModel->download()->GetFileNameToReportUser(); + + std::wstring statusText = downloadModel->GetStatusText(); + if (statusText.empty()) { + // Remove the status text label. + [self hideSecondaryTitle]; + isStatusTextVisible_ = NO; + } else { + // Set status text. + NSString* statusString = base::SysWideToNSString(statusText); + [self setSecondaryTitle:statusString]; + isStatusTextVisible_ = YES; + } + + switch (downloadModel->download()->state()) { + case DownloadItem::COMPLETE: + // Small downloads may start in a complete state due to asynchronous + // notifications. In this case, we'll get a second complete notification + // via the observers, so we ignore it and avoid creating a second complete + // animation. + if (completionAnimation_.get()) + break; + completionAnimation_.reset([[DownloadItemCellAnimation alloc] + initWithDownloadItemCell:self + duration:kCompleteAnimationDuration + animationCurve:NSAnimationLinear]); + [completionAnimation_.get() setDelegate:self]; + [completionAnimation_.get() startAnimation]; + percentDone_ = -1; + break; + case DownloadItem::CANCELLED: + percentDone_ = -1; + break; + case DownloadItem::IN_PROGRESS: + percentDone_ = downloadModel->download()->is_paused() ? + -1 : downloadModel->download()->PercentComplete(); + break; + default: + NOTREACHED(); + } + + [[self controlView] setNeedsDisplay:YES]; +} + +- (void)updateTrackingAreas:(id)sender { + if (trackingAreaButton_) { + [[self controlView] removeTrackingArea:trackingAreaButton_.get()]; + trackingAreaButton_.reset(nil); + } + if (trackingAreaDropdown_) { + [[self controlView] removeTrackingArea:trackingAreaDropdown_.get()]; + trackingAreaDropdown_.reset(nil); + } + + // Use two distinct tracking rects for left and right parts. + // The tracking areas are also used to decide how to handle clicks. They must + // always be active, so the click is handled correctly when a download item + // is clicked while chrome is not the active app ( http://crbug.com/21916 ). + NSRect bounds = [[self controlView] bounds]; + NSRect buttonRect, dropdownRect; + NSDivideRect(bounds, &dropdownRect, &buttonRect, + kDropdownAreaWidth, NSMaxXEdge); + + trackingAreaButton_.reset([[NSTrackingArea alloc] + initWithRect:buttonRect + options:(NSTrackingMouseEnteredAndExited | + NSTrackingActiveAlways) + owner:self + userInfo:nil]); + [[self controlView] addTrackingArea:trackingAreaButton_.get()]; + + trackingAreaDropdown_.reset([[NSTrackingArea alloc] + initWithRect:dropdownRect + options:(NSTrackingMouseEnteredAndExited | + NSTrackingActiveAlways) + owner:self + userInfo:nil]); + [[self controlView] addTrackingArea:trackingAreaDropdown_.get()]; +} + +- (void)setShowsBorderOnlyWhileMouseInside:(BOOL)showOnly { + // Override to make sure it doesn't do anything if it's called accidentally. +} + +- (void)mouseEntered:(NSEvent*)theEvent { + mouseInsideCount_++; + if ([theEvent trackingArea] == trackingAreaButton_.get()) + mousePosition_ = kDownloadItemMouseOverButtonPart; + else if ([theEvent trackingArea] == trackingAreaDropdown_.get()) + mousePosition_ = kDownloadItemMouseOverDropdownPart; + [[self controlView] setNeedsDisplay:YES]; +} + +- (void)mouseExited:(NSEvent *)theEvent { + mouseInsideCount_--; + if (mouseInsideCount_ == 0) + mousePosition_ = kDownloadItemMouseOutside; + [[self controlView] setNeedsDisplay:YES]; +} + +- (BOOL)isMouseInside { + return mousePosition_ != kDownloadItemMouseOutside; +} + +- (BOOL)isMouseOverButtonPart { + return mousePosition_ == kDownloadItemMouseOverButtonPart; +} + +- (BOOL)isButtonPartPressed { + return [self isHighlighted] + && mousePosition_ == kDownloadItemMouseOverButtonPart; +} + +- (BOOL)isMouseOverDropdownPart { + return mousePosition_ == kDownloadItemMouseOverDropdownPart; +} + +- (BOOL)isDropdownPartPressed { + return [self isHighlighted] + && mousePosition_ == kDownloadItemMouseOverDropdownPart; +} + +- (NSBezierPath*)leftRoundedPath:(CGFloat)radius inRect:(NSRect)rect { + + NSPoint topLeft = NSMakePoint(NSMinX(rect), NSMaxY(rect)); + NSPoint topRight = NSMakePoint(NSMaxX(rect), NSMaxY(rect)); + NSPoint bottomRight = NSMakePoint(NSMaxX(rect) , NSMinY(rect)); + + NSBezierPath* path = [NSBezierPath bezierPath]; + [path moveToPoint:topRight]; + [path appendBezierPathWithArcFromPoint:topLeft + toPoint:rect.origin + radius:radius]; + [path appendBezierPathWithArcFromPoint:rect.origin + toPoint:bottomRight + radius:radius]; + [path lineToPoint:bottomRight]; + return path; +} + +- (NSBezierPath*)rightRoundedPath:(CGFloat)radius inRect:(NSRect)rect { + + NSPoint topLeft = NSMakePoint(NSMinX(rect), NSMaxY(rect)); + NSPoint topRight = NSMakePoint(NSMaxX(rect), NSMaxY(rect)); + NSPoint bottomRight = NSMakePoint(NSMaxX(rect), NSMinY(rect)); + + NSBezierPath* path = [NSBezierPath bezierPath]; + [path moveToPoint:rect.origin]; + [path appendBezierPathWithArcFromPoint:bottomRight + toPoint:topRight + radius:radius]; + [path appendBezierPathWithArcFromPoint:topRight + toPoint:topLeft + radius:radius]; + [path lineToPoint:topLeft]; + return path; +} + +- (NSString*)elideTitle:(int)availableWidth { + NSFont* font = [self font]; + gfx::Font font_chr(base::SysNSStringToWide([font fontName]), + [font pointSize]); + + return base::SysUTF16ToNSString( + ElideFilename(downloadPath_, font_chr, availableWidth)); +} + +- (NSString*)elideStatus:(int)availableWidth { + NSFont* font = [self secondaryFont]; + gfx::Font font_chr(base::SysNSStringToWide([font fontName]), + [font pointSize]); + + return base::SysUTF16ToNSString(ElideText( + base::SysNSStringToUTF16([self secondaryTitle]), + font_chr, + availableWidth, + false)); +} + +- (ThemeProvider*)backgroundThemeWrappingProvider:(ThemeProvider*)provider { + if (!themeProvider_.get()) { + themeProvider_.reset(new BackgroundTheme(provider)); + } + + return themeProvider_.get(); +} + +// Returns if |part| was pressed while the default theme was active. +- (BOOL)pressedWithDefaultThemeOnPart:(DownloadItemMousePosition)part { + ThemeProvider* themeProvider = [[[self controlView] window] themeProvider]; + bool isDefaultTheme = + !themeProvider->HasCustomImage(IDR_THEME_BUTTON_BACKGROUND); + return isDefaultTheme && [self isHighlighted] && mousePosition_ == part; +} + +// Returns the text color that should be used to draw text on |part|. +- (NSColor*)titleColorForPart:(DownloadItemMousePosition)part { + ThemeProvider* themeProvider = [[[self controlView] window] themeProvider]; + NSColor* themeTextColor = + themeProvider->GetNSColor(BrowserThemeProvider::COLOR_BOOKMARK_TEXT, + true); + return [self pressedWithDefaultThemeOnPart:part] + ? [NSColor alternateSelectedControlTextColor] : themeTextColor; +} + +- (void)drawSecondaryTitleInRect:(NSRect)innerFrame { + if (![self secondaryTitle] || statusAlpha_ <= 0) + return; + + CGFloat textWidth = innerFrame.size.width - + (kTextPosLeft + kTextPaddingRight + kDropdownAreaWidth); + NSString* secondaryText = [self elideStatus:textWidth]; + NSColor* secondaryColor = + [self titleColorForPart:kDownloadItemMouseOverButtonPart]; + + // If text is light-on-dark, lightening it alone will do nothing. + // Therefore we mute luminance a wee bit before drawing in this case. + if (![secondaryColor gtm_isDarkColor]) + secondaryColor = [secondaryColor gtm_colorByAdjustingLuminance:-0.2]; + + NSDictionary* secondaryTextAttributes = + [NSDictionary dictionaryWithObjectsAndKeys: + secondaryColor, NSForegroundColorAttributeName, + [self secondaryFont], NSFontAttributeName, + nil]; + NSPoint secondaryPos = + NSMakePoint(innerFrame.origin.x + kTextPosLeft, kSecondaryTextPosTop); + [secondaryText drawAtPoint:secondaryPos + withAttributes:secondaryTextAttributes]; +} + +- (void)drawWithFrame:(NSRect)cellFrame inView:(NSView*)controlView { + // Constants from Cole. Will kConstant them once the feedback loop + // is complete. + NSRect drawFrame = NSInsetRect(cellFrame, 1.5, 1.5); + NSRect innerFrame = NSInsetRect(cellFrame, 2, 2); + + const float radius = 5; + NSWindow* window = [controlView window]; + BOOL active = [window isKeyWindow] || [window isMainWindow]; + + // In the default theme, draw download items with the bookmark button + // gradient. For some themes, this leads to unreadable text, so draw the item + // with a background that looks like windows (some transparent white) if a + // theme is used. Use custom theme object with a white color gradient to trick + // the superclass into drawing what we want. + ThemeProvider* themeProvider = [[[self controlView] window] themeProvider]; + bool isDefaultTheme = + !themeProvider->HasCustomImage(IDR_THEME_BUTTON_BACKGROUND); + + NSGradient* bgGradient = nil; + if (!isDefaultTheme) { + themeProvider = [self backgroundThemeWrappingProvider:themeProvider]; + bgGradient = themeProvider->GetNSGradient( + active ? BrowserThemeProvider::GRADIENT_TOOLBAR_BUTTON : + BrowserThemeProvider::GRADIENT_TOOLBAR_BUTTON_INACTIVE); + } + + NSRect buttonDrawRect, dropdownDrawRect; + NSDivideRect(drawFrame, &dropdownDrawRect, &buttonDrawRect, + kDropdownAreaWidth, NSMaxXEdge); + + NSBezierPath* buttonInnerPath = [self + leftRoundedPath:radius inRect:buttonDrawRect]; + NSBezierPath* dropdownInnerPath = [self + rightRoundedPath:radius inRect:dropdownDrawRect]; + + // Draw secondary title, if any. Do this before drawing the (transparent) + // fill so that the text becomes a bit lighter. The default theme's "pressed" + // gradient is not transparent, so only do this if a theme is active. + bool drawStatusOnTop = + [self pressedWithDefaultThemeOnPart:kDownloadItemMouseOverButtonPart]; + if (!drawStatusOnTop) + [self drawSecondaryTitleInRect:innerFrame]; + + // Stroke the borders and appropriate fill gradient. + [self drawBorderAndFillForTheme:themeProvider + controlView:controlView + innerPath:buttonInnerPath + showClickedGradient:[self isButtonPartPressed] + showHighlightGradient:[self isMouseOverButtonPart] + hoverAlpha:0.0 + active:active + cellFrame:cellFrame + defaultGradient:bgGradient]; + + [self drawBorderAndFillForTheme:themeProvider + controlView:controlView + innerPath:dropdownInnerPath + showClickedGradient:[self isDropdownPartPressed] + showHighlightGradient:[self isMouseOverDropdownPart] + hoverAlpha:0.0 + active:active + cellFrame:cellFrame + defaultGradient:bgGradient]; + + [self drawInteriorWithFrame:innerFrame inView:controlView]; + + // For the default theme, draw the status text on top of the (opaque) button + // gradient. + if (drawStatusOnTop) + [self drawSecondaryTitleInRect:innerFrame]; +} + +- (void)drawInteriorWithFrame:(NSRect)cellFrame inView:(NSView*)controlView { + // Draw title + CGFloat textWidth = cellFrame.size.width - + (kTextPosLeft + kTextPaddingRight + kDropdownAreaWidth); + [self setTitle:[self elideTitle:textWidth]]; + + NSColor* color = [self titleColorForPart:kDownloadItemMouseOverButtonPart]; + NSString* primaryText = [self title]; + + NSDictionary* primaryTextAttributes = + [NSDictionary dictionaryWithObjectsAndKeys: + color, NSForegroundColorAttributeName, + [self font], NSFontAttributeName, + nil]; + NSPoint primaryPos = NSMakePoint( + cellFrame.origin.x + kTextPosLeft, + titleY_); + + [primaryText drawAtPoint:primaryPos withAttributes:primaryTextAttributes]; + + // Draw progress disk + { + // CanvasSkiaPaint draws its content to the current NSGraphicsContext in its + // destructor, which needs to be invoked before the icon is drawn below - + // hence this nested block. + + // Always repaint the whole disk. + NSPoint imagePosition = [self imageRectForBounds:cellFrame].origin; + int x = imagePosition.x - download_util::kSmallProgressIconOffset; + int y = imagePosition.y - download_util::kSmallProgressIconOffset; + NSRect dirtyRect = NSMakeRect( + x, y, + download_util::kSmallProgressIconSize, + download_util::kSmallProgressIconSize); + + gfx::CanvasSkiaPaint canvas(dirtyRect, false); + canvas.set_composite_alpha(true); + if (completionAnimation_.get()) { + if ([completionAnimation_ isAnimating]) { + download_util::PaintDownloadComplete(&canvas, + x, y, + [completionAnimation_ currentValue], + download_util::SMALL); + } + } else if (percentDone_ >= 0) { + download_util::PaintDownloadProgress(&canvas, + x, y, + download_util::kStartAngleDegrees, // TODO(thakis): Animate + percentDone_, + download_util::SMALL); + } + } + + // Draw icon + NSRect imageRect = NSZeroRect; + imageRect.size = [[self image] size]; + [[self image] drawInRect:[self imageRectForBounds:cellFrame] + fromRect:imageRect + operation:NSCompositeSourceOver + fraction:[self isEnabled] ? 1.0 : 0.5 + neverFlipped:YES]; + + // Separator between button and popup parts + CGFloat lx = NSMaxX(cellFrame) - kDropdownAreaWidth + 0.5; + [[NSColor colorWithDeviceWhite:0.0 alpha:0.1] set]; + [NSBezierPath strokeLineFromPoint:NSMakePoint(lx, NSMinY(cellFrame) + 1) + toPoint:NSMakePoint(lx, NSMaxY(cellFrame) - 1)]; + [[NSColor colorWithDeviceWhite:1.0 alpha:0.1] set]; + [NSBezierPath strokeLineFromPoint:NSMakePoint(lx + 1, NSMinY(cellFrame) + 1) + toPoint:NSMakePoint(lx + 1, NSMaxY(cellFrame) - 1)]; + + // Popup arrow. Put center of mass of the arrow in the center of the + // dropdown area. + CGFloat cx = NSMaxX(cellFrame) - kDropdownAreaWidth/2 + 0.5; + CGFloat cy = NSMidY(cellFrame); + NSPoint p1 = NSMakePoint(cx - kDropdownArrowWidth/2, + cy - kDropdownArrowHeight/3 + kDropdownAreaY); + NSPoint p2 = NSMakePoint(cx + kDropdownArrowWidth/2, + cy - kDropdownArrowHeight/3 + kDropdownAreaY); + NSPoint p3 = NSMakePoint(cx, cy + kDropdownArrowHeight*2/3 + kDropdownAreaY); + NSBezierPath *triangle = [NSBezierPath bezierPath]; + [triangle moveToPoint:p1]; + [triangle lineToPoint:p2]; + [triangle lineToPoint:p3]; + [triangle closePath]; + + NSGraphicsContext* context = [NSGraphicsContext currentContext]; + [context saveGraphicsState]; + + scoped_nsobject<NSShadow> shadow([[NSShadow alloc] init]); + [shadow.get() setShadowColor:[NSColor whiteColor]]; + [shadow.get() setShadowOffset:NSMakeSize(0, -1)]; + [shadow setShadowBlurRadius:0.0]; + [shadow set]; + + NSColor* fill = [self titleColorForPart:kDownloadItemMouseOverDropdownPart]; + [fill setFill]; + + [triangle fill]; + + [context restoreGraphicsState]; +} + +- (NSRect)imageRectForBounds:(NSRect)cellFrame { + return NSMakeRect(cellFrame.origin.x + kImagePaddingLeft, + cellFrame.origin.y + kImagePaddingTop, + kImageWidth, + kImageHeight); +} + +- (void)hideSecondaryTitle { + if (isStatusTextVisible_) { + // No core animation -- text in CA layers is not subpixel antialiased :-/ + hideStatusAnimation_.reset([[DownloadItemCellAnimation alloc] + initWithDownloadItemCell:self + duration:kHideStatusDuration + animationCurve:NSAnimationEaseIn]); + [hideStatusAnimation_.get() setDelegate:self]; + [hideStatusAnimation_.get() startAnimation]; + } else { + // If the download is done so quickly that the status line is never visible, + // don't show an animation + [self animation:nil progressed:1.0]; + } +} + +- (void)animation:(NSAnimation*)animation + progressed:(NSAnimationProgress)progress { + if (animation == hideStatusAnimation_ || animation == nil) { + titleY_ = progress*kPrimaryTextOnlyPosTop + + (1 - progress)*kPrimaryTextPosTop; + statusAlpha_ = 1 - progress; + [[self controlView] setNeedsDisplay:YES]; + } else if (animation == completionAnimation_) { + [[self controlView] setNeedsDisplay:YES]; + } +} + +- (void)animationDidEnd:(NSAnimation *)animation { + if (animation == hideStatusAnimation_) + hideStatusAnimation_.reset(); + else if (animation == completionAnimation_) + completionAnimation_.reset(); +} + +@end + +@implementation DownloadItemCellAnimation + +- (id)initWithDownloadItemCell:(DownloadItemCell*)cell + duration:(NSTimeInterval)duration + animationCurve:(NSAnimationCurve)animationCurve { + if ((self = [super gtm_initWithDuration:duration + eventMask:NSLeftMouseDownMask + animationCurve:animationCurve])) { + cell_ = cell; + [self setAnimationBlockingMode:NSAnimationNonblocking]; + } + return self; +} + +- (void)setCurrentProgress:(NSAnimationProgress)progress { + [super setCurrentProgress:progress]; + [cell_ animation:self progressed:progress]; +} + +@end diff --git a/chrome/browser/ui/cocoa/download/download_item_controller.h b/chrome/browser/ui/cocoa/download/download_item_controller.h new file mode 100644 index 0000000..dea7722 --- /dev/null +++ b/chrome/browser/ui/cocoa/download/download_item_controller.h @@ -0,0 +1,105 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_ptr.h" +#include "base/time.h" + +class BaseDownloadItemModel; +@class ChromeUILocalizer; +@class DownloadItemCell; +class DownloadItem; +@class DownloadItemButton; +class DownloadItemMac; +class DownloadShelfContextMenuMac; +@class DownloadShelfController; +@class GTMWidthBasedTweaker; + +// A controller class that manages one download item. + +@interface DownloadItemController : NSViewController { + @private + IBOutlet DownloadItemButton* progressView_; + IBOutlet DownloadItemCell* cell_; + + IBOutlet NSMenu* activeDownloadMenu_; + IBOutlet NSMenu* completeDownloadMenu_; + + // This is shown instead of progressView_ for dangerous downloads. + IBOutlet NSView* dangerousDownloadView_; + IBOutlet NSTextField* dangerousDownloadLabel_; + IBOutlet NSButton* dangerousDownloadConfirmButton_; + + // Needed to find out how much the tweaker changed sizes to update the + // other views. + IBOutlet GTMWidthBasedTweaker* buttonTweaker_; + + // Because the confirm text and button for dangerous downloads are determined + // at runtime, an outlet to the localizer is needed to construct the layout + // tweaker in awakeFromNib in order to adjust the UI after all strings are + // determined. + IBOutlet ChromeUILocalizer* localizer_; + + IBOutlet NSImageView* image_; + + scoped_ptr<DownloadItemMac> bridge_; + scoped_ptr<DownloadShelfContextMenuMac> menuBridge_; + + // Weak pointer to the shelf that owns us. + DownloadShelfController* shelf_; + + // The time at which this view was created. + base::Time creationTime_; + + // The state of this item. + enum DownoadItemState { + kNormal, + kDangerous + } state_; +}; + +// Takes ownership of |downloadModel|. +- (id)initWithModel:(BaseDownloadItemModel*)downloadModel + shelf:(DownloadShelfController*)shelf; + +// Updates the UI and menu state from |downloadModel|. +- (void)setStateFromDownload:(BaseDownloadItemModel*)downloadModel; + +// Remove ourself from the download UI. +- (void)remove; + +// Update item's visibility depending on if the item is still completely +// contained in its parent. +- (void)updateVisibility:(id)sender; + +// Asynchronous icon loading callback. +- (void)setIcon:(NSImage*)icon; + +// Download item button clicked +- (IBAction)handleButtonClick:(id)sender; + +// Returns the size this item wants to have. +- (NSSize)preferredSize; + +// Returns the DownloadItem model object belonging to this item. +- (DownloadItem*)download; + +// Updates the tooltip with the download's path. +- (void)updateToolTip; + +// Handling of dangerous downloads +- (void)clearDangerousMode; +- (BOOL)isDangerousMode; +- (IBAction)saveDownload:(id)sender; +- (IBAction)discardDownload:(id)sender; + +// Context menu handlers. +- (IBAction)handleOpen:(id)sender; +- (IBAction)handleAlwaysOpen:(id)sender; +- (IBAction)handleReveal:(id)sender; +- (IBAction)handleCancel:(id)sender; +- (IBAction)handleTogglePause:(id)sender; + +@end diff --git a/chrome/browser/ui/cocoa/download/download_item_controller.mm b/chrome/browser/ui/cocoa/download/download_item_controller.mm new file mode 100644 index 0000000..0d67b83 --- /dev/null +++ b/chrome/browser/ui/cocoa/download/download_item_controller.mm @@ -0,0 +1,398 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/download/download_item_controller.h" + +#include "app/l10n_util_mac.h" +#include "app/resource_bundle.h" +#include "app/text_elider.h" +#include "base/mac_util.h" +#include "base/metrics/histogram.h" +#include "base/string16.h" +#include "base/string_util.h" +#include "base/sys_string_conversions.h" +#include "base/utf_string_conversions.h" +#include "chrome/browser/download/download_item.h" +#include "chrome/browser/download/download_item_model.h" +#include "chrome/browser/download/download_shelf.h" +#include "chrome/browser/download/download_util.h" +#import "chrome/browser/themes/browser_theme_provider.h" +#import "chrome/browser/ui/cocoa/download/download_item_button.h" +#import "chrome/browser/ui/cocoa/download/download_item_cell.h" +#include "chrome/browser/ui/cocoa/download/download_item_mac.h" +#import "chrome/browser/ui/cocoa/download/download_shelf_controller.h" +#import "chrome/browser/ui/cocoa/themed_window.h" +#import "chrome/browser/ui/cocoa/ui_localizer.h" +#include "grit/generated_resources.h" +#include "grit/theme_resources.h" +#include "third_party/GTM/AppKit/GTMUILocalizerAndLayoutTweaker.h" + +namespace { + +// NOTE: Mac currently doesn't use this like Windows does. Mac uses this to +// control the min size on the dangerous download text. TVL sent a query off to +// UX to fully spec all the the behaviors of download items and truncations +// rules so all platforms can get inline in the future. +const int kTextWidth = 140; // Pixels + +// The maximum number of characters we show in a file name when displaying the +// dangerous download message. +const int kFileNameMaxLength = 20; + +// The maximum width in pixels for the file name tooltip. +const int kToolTipMaxWidth = 900; + + +// Helper to widen a view. +void WidenView(NSView* view, CGFloat widthChange) { + // If it is an NSBox, the autoresize of the contentView is the issue. + NSView* contentView = view; + if ([view isKindOfClass:[NSBox class]]) { + contentView = [(NSBox*)view contentView]; + } + BOOL autoresizesSubviews = [contentView autoresizesSubviews]; + if (autoresizesSubviews) { + [contentView setAutoresizesSubviews:NO]; + } + + NSRect frame = [view frame]; + frame.size.width += widthChange; + [view setFrame:frame]; + + if (autoresizesSubviews) { + [contentView setAutoresizesSubviews:YES]; + } +} + +} // namespace + +// A class for the chromium-side part of the download shelf context menu. + +class DownloadShelfContextMenuMac : public DownloadShelfContextMenu { + public: + DownloadShelfContextMenuMac(BaseDownloadItemModel* model) + : DownloadShelfContextMenu(model) { } + + using DownloadShelfContextMenu::ExecuteCommand; + using DownloadShelfContextMenu::IsCommandIdChecked; + using DownloadShelfContextMenu::IsCommandIdEnabled; + + using DownloadShelfContextMenu::SHOW_IN_FOLDER; + using DownloadShelfContextMenu::OPEN_WHEN_COMPLETE; + using DownloadShelfContextMenu::ALWAYS_OPEN_TYPE; + using DownloadShelfContextMenu::CANCEL; + using DownloadShelfContextMenu::TOGGLE_PAUSE; +}; + +@interface DownloadItemController (Private) +- (void)themeDidChangeNotification:(NSNotification*)aNotification; +- (void)updateTheme:(ThemeProvider*)themeProvider; +- (void)setState:(DownoadItemState)state; +@end + +// Implementation of DownloadItemController + +@implementation DownloadItemController + +- (id)initWithModel:(BaseDownloadItemModel*)downloadModel + shelf:(DownloadShelfController*)shelf { + if ((self = [super initWithNibName:@"DownloadItem" + bundle:mac_util::MainAppBundle()])) { + // Must be called before [self view], so that bridge_ is set in awakeFromNib + bridge_.reset(new DownloadItemMac(downloadModel, self)); + menuBridge_.reset(new DownloadShelfContextMenuMac(downloadModel)); + + NSNotificationCenter* defaultCenter = [NSNotificationCenter defaultCenter]; + [defaultCenter addObserver:self + selector:@selector(themeDidChangeNotification:) + name:kBrowserThemeDidChangeNotification + object:nil]; + + shelf_ = shelf; + state_ = kNormal; + creationTime_ = base::Time::Now(); + } + return self; +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [progressView_ setController:nil]; + [[self view] removeFromSuperview]; + [super dealloc]; +} + +- (void)awakeFromNib { + [progressView_ setController:self]; + + [self setStateFromDownload:bridge_->download_model()]; + + GTMUILocalizerAndLayoutTweaker* localizerAndLayoutTweaker = + [[[GTMUILocalizerAndLayoutTweaker alloc] init] autorelease]; + [localizerAndLayoutTweaker applyLocalizer:localizer_ tweakingUI:[self view]]; + + // The strings are based on the download item's name, sizing tweaks have to be + // manually done. + DCHECK(buttonTweaker_ != nil); + CGFloat widthChange = [buttonTweaker_ changedWidth]; + // If it's a dangerous download, size the two lines so the text/filename + // is always visible. + if ([self isDangerousMode]) { + widthChange += + [GTMUILocalizerAndLayoutTweaker + sizeToFitFixedHeightTextField:dangerousDownloadLabel_ + minWidth:kTextWidth]; + } + // Grow the parent views + WidenView([self view], widthChange); + WidenView(dangerousDownloadView_, widthChange); + // Slide the two buttons over. + NSPoint frameOrigin = [buttonTweaker_ frame].origin; + frameOrigin.x += widthChange; + [buttonTweaker_ setFrameOrigin:frameOrigin]; + + ResourceBundle& rb = ResourceBundle::GetSharedInstance(); + NSImage* alertIcon = rb.GetNativeImageNamed(IDR_WARNING); + DCHECK(alertIcon); + [image_ setImage:alertIcon]; + + bridge_->LoadIcon(); + [self updateToolTip]; +} + +- (void)setStateFromDownload:(BaseDownloadItemModel*)downloadModel { + DCHECK_EQ(bridge_->download_model(), downloadModel); + + // Handle dangerous downloads. + if (downloadModel->download()->safety_state() == DownloadItem::DANGEROUS) { + [self setState:kDangerous]; + + NSString* dangerousWarning; + NSString* confirmButtonTitle; + // The dangerous download label and button text are different for an + // extension file. + if (downloadModel->download()->is_extension_install()) { + dangerousWarning = l10n_util::GetNSStringWithFixup( + IDS_PROMPT_DANGEROUS_DOWNLOAD_EXTENSION); + confirmButtonTitle = l10n_util::GetNSStringWithFixup( + IDS_CONTINUE_EXTENSION_DOWNLOAD); + } else { + // This basic fixup copies Windows DownloadItemView::DownloadItemView(). + + // Extract the file extension (if any). + FilePath filename(downloadModel->download()->target_name()); + FilePath::StringType extension = filename.Extension(); + + // Remove leading '.' from the extension + if (extension.length() > 0) + extension = extension.substr(1); + + // Elide giant extensions. + if (extension.length() > kFileNameMaxLength / 2) { + std::wstring wide_extension; + ElideString(UTF8ToWide(extension), kFileNameMaxLength / 2, + &wide_extension); + extension = WideToUTF8(wide_extension); + } + + // Rebuild the filename.extension. + std::wstring rootname = UTF8ToWide(filename.RemoveExtension().value()); + ElideString(rootname, kFileNameMaxLength - extension.length(), &rootname); + std::string new_filename = WideToUTF8(rootname); + if (extension.length()) + new_filename += std::string(".") + extension; + + dangerousWarning = l10n_util::GetNSStringFWithFixup( + IDS_PROMPT_DANGEROUS_DOWNLOAD, UTF8ToUTF16(new_filename)); + confirmButtonTitle = l10n_util::GetNSStringWithFixup(IDS_SAVE_DOWNLOAD); + } + [dangerousDownloadLabel_ setStringValue:dangerousWarning]; + [dangerousDownloadConfirmButton_ setTitle:confirmButtonTitle]; + return; + } + + // Set correct popup menu. Also, set draggable download on completion. + if (downloadModel->download()->state() == DownloadItem::COMPLETE) { + [progressView_ setMenu:completeDownloadMenu_]; + [progressView_ setDownload:downloadModel->download()->full_path()]; + } else { + [progressView_ setMenu:activeDownloadMenu_]; + } + + [cell_ setStateFromDownload:downloadModel]; +} + +- (void)setIcon:(NSImage*)icon { + [cell_ setImage:icon]; +} + +- (void)remove { + // We are deleted after this! + [shelf_ remove:self]; +} + +- (void)updateVisibility:(id)sender { + if ([[self view] window]) + [self updateTheme:[[[self view] window] themeProvider]]; + + // TODO(thakis): Make this prettier, by fading the items out or overlaying + // the partial visible one with a horizontal alpha gradient -- crbug.com/17830 + NSView* view = [self view]; + NSRect containerFrame = [[view superview] frame]; + [view setHidden:(NSMaxX([view frame]) > NSWidth(containerFrame))]; +} + +- (IBAction)handleButtonClick:(id)sender { + NSEvent* event = [NSApp currentEvent]; + if ([event modifierFlags] & NSCommandKeyMask) { + // Let cmd-click show the file in Finder, like e.g. in Safari and Spotlight. + menuBridge_->ExecuteCommand(DownloadShelfContextMenuMac::SHOW_IN_FOLDER); + } else { + DownloadItem* download = bridge_->download_model()->download(); + download->OpenDownload(); + } +} + +- (NSSize)preferredSize { + if (state_ == kNormal) + return [progressView_ frame].size; + DCHECK_EQ(kDangerous, state_); + return [dangerousDownloadView_ frame].size; +} + +- (DownloadItem*)download { + return bridge_->download_model()->download(); +} + +- (void)updateToolTip { + string16 elidedFilename = gfx::ElideFilename( + [self download]->GetFileNameToReportUser(), + gfx::Font(), kToolTipMaxWidth); + [progressView_ setToolTip:base::SysUTF16ToNSString(elidedFilename)]; +} + +- (void)clearDangerousMode { + [self setState:kNormal]; + // The state change hide the dangerouse download view and is now showing the + // download progress view. This means the view is likely to be a different + // size, so trigger a shelf layout to fix up spacing. + [shelf_ layoutItems]; +} + +- (BOOL)isDangerousMode { + return state_ == kDangerous; +} + +- (void)setState:(DownoadItemState)state { + if (state_ == state) + return; + state_ = state; + if (state_ == kNormal) { + [progressView_ setHidden:NO]; + [dangerousDownloadView_ setHidden:YES]; + } else { + DCHECK_EQ(kDangerous, state_); + [progressView_ setHidden:YES]; + [dangerousDownloadView_ setHidden:NO]; + } + // NOTE: Do not relayout the shelf, as this could get called during initial + // setup of the the item, so the localized text and sizing might not have + // happened yet. +} + +// Called after the current theme has changed. +- (void)themeDidChangeNotification:(NSNotification*)aNotification { + ThemeProvider* themeProvider = + static_cast<ThemeProvider*>([[aNotification object] pointerValue]); + [self updateTheme:themeProvider]; +} + +// Adapt appearance to the current theme. Called after theme changes and before +// this is shown for the first time. +- (void)updateTheme:(ThemeProvider*)themeProvider { + NSColor* color = + themeProvider->GetNSColor(BrowserThemeProvider::COLOR_TAB_TEXT, true); + [dangerousDownloadLabel_ setTextColor:color]; +} + +- (IBAction)saveDownload:(id)sender { + // The user has confirmed a dangerous download. We record how quickly the + // user did this to detect whether we're being clickjacked. + UMA_HISTOGRAM_LONG_TIMES("clickjacking.save_download", + base::Time::Now() - creationTime_); + // This will change the state and notify us. + bridge_->download_model()->download()->DangerousDownloadValidated(); +} + +- (IBAction)discardDownload:(id)sender { + UMA_HISTOGRAM_LONG_TIMES("clickjacking.discard_download", + base::Time::Now() - creationTime_); + if (bridge_->download_model()->download()->state() == + DownloadItem::IN_PROGRESS) + bridge_->download_model()->download()->Cancel(true); + bridge_->download_model()->download()->Remove(true); + // WARNING: we are deleted at this point. Don't access 'this'. +} + + +// Sets the enabled and checked state of a particular menu item for this +// download. We translate the NSMenuItem selection to menu selections understood +// by the non platform specific download context menu. +- (BOOL)validateMenuItem:(NSMenuItem *)item { + SEL action = [item action]; + + int actionId = 0; + if (action == @selector(handleOpen:)) { + actionId = DownloadShelfContextMenuMac::OPEN_WHEN_COMPLETE; + } else if (action == @selector(handleAlwaysOpen:)) { + actionId = DownloadShelfContextMenuMac::ALWAYS_OPEN_TYPE; + } else if (action == @selector(handleReveal:)) { + actionId = DownloadShelfContextMenuMac::SHOW_IN_FOLDER; + } else if (action == @selector(handleCancel:)) { + actionId = DownloadShelfContextMenuMac::CANCEL; + } else if (action == @selector(handleTogglePause:)) { + actionId = DownloadShelfContextMenuMac::TOGGLE_PAUSE; + } else { + NOTREACHED(); + return YES; + } + + if (menuBridge_->IsCommandIdChecked(actionId)) + [item setState:NSOnState]; + else + [item setState:NSOffState]; + + return menuBridge_->IsCommandIdEnabled(actionId) ? YES : NO; +} + +- (IBAction)handleOpen:(id)sender { + menuBridge_->ExecuteCommand( + DownloadShelfContextMenuMac::OPEN_WHEN_COMPLETE); +} + +- (IBAction)handleAlwaysOpen:(id)sender { + menuBridge_->ExecuteCommand( + DownloadShelfContextMenuMac::ALWAYS_OPEN_TYPE); +} + +- (IBAction)handleReveal:(id)sender { + menuBridge_->ExecuteCommand(DownloadShelfContextMenuMac::SHOW_IN_FOLDER); +} + +- (IBAction)handleCancel:(id)sender { + menuBridge_->ExecuteCommand(DownloadShelfContextMenuMac::CANCEL); +} + +- (IBAction)handleTogglePause:(id)sender { + if([sender state] == NSOnState) { + [sender setTitle:l10n_util::GetNSStringWithFixup( + IDS_DOWNLOAD_MENU_PAUSE_ITEM)]; + } else { + [sender setTitle:l10n_util::GetNSStringWithFixup( + IDS_DOWNLOAD_MENU_RESUME_ITEM)]; + } + menuBridge_->ExecuteCommand(DownloadShelfContextMenuMac::TOGGLE_PAUSE); +} + +@end diff --git a/chrome/browser/ui/cocoa/download/download_item_mac.h b/chrome/browser/ui/cocoa/download/download_item_mac.h new file mode 100644 index 0000000..4a4c7fe --- /dev/null +++ b/chrome/browser/ui/cocoa/download/download_item_mac.h @@ -0,0 +1,63 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_DOWNLOAD_DOWNLOAD_ITEM_MAC_H_ +#define CHROME_BROWSER_UI_COCOA_DOWNLOAD_DOWNLOAD_ITEM_MAC_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#include "base/scoped_ptr.h" +#include "chrome/browser/cancelable_request.h" +#include "chrome/browser/download/download_item.h" +#include "chrome/browser/download/download_manager.h" +#include "chrome/browser/icon_manager.h" + +class BaseDownloadItemModel; +@class DownloadItemController; + +// A class that bridges the visible mac download items to chromium's download +// model. The owning object (DownloadItemController) must explicitly call +// |LoadIcon| if it wants to display the icon associated with this download. + +class DownloadItemMac : DownloadItem::Observer { + public: + // DownloadItemMac takes ownership of |download_model|. + DownloadItemMac(BaseDownloadItemModel* download_model, + DownloadItemController* controller); + + // Destructor. + ~DownloadItemMac(); + + // DownloadItem::Observer implementation + virtual void OnDownloadUpdated(DownloadItem* download); + virtual void OnDownloadFileCompleted(DownloadItem* download) { } + virtual void OnDownloadOpened(DownloadItem* download) { } + + BaseDownloadItemModel* download_model() { return download_model_.get(); } + + // Asynchronous icon loading support. + void LoadIcon(); + + private: + // Callback for asynchronous icon loading. + void OnExtractIconComplete(IconManager::Handle handle, SkBitmap* icon_bitmap); + + // The download item model we represent. + scoped_ptr<BaseDownloadItemModel> download_model_; + + // The objective-c controller object. + DownloadItemController* item_controller_; // weak, owns us. + + // For canceling an in progress icon request. + CancelableRequestConsumerT<int, 0> icon_consumer_; + + // Stores the last known path where the file will be saved. + FilePath lastFilePath_; + + DISALLOW_COPY_AND_ASSIGN(DownloadItemMac); +}; + +#endif // CHROME_BROWSER_UI_COCOA_DOWNLOAD_DOWNLOAD_ITEM_MAC_H_ diff --git a/chrome/browser/ui/cocoa/download/download_item_mac.mm b/chrome/browser/ui/cocoa/download/download_item_mac.mm new file mode 100644 index 0000000..d6737ef --- /dev/null +++ b/chrome/browser/ui/cocoa/download/download_item_mac.mm @@ -0,0 +1,96 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/download/download_item_mac.h" + +#include "base/callback.h" +#include "chrome/browser/browser_process.h" +#include "chrome/browser/download/download_item.h" +#include "chrome/browser/download/download_item_model.h" +#import "chrome/browser/ui/cocoa/download/download_item_controller.h" +#include "chrome/browser/ui/cocoa/download/download_util_mac.h" +#include "skia/ext/skia_utils_mac.h" + +// DownloadItemMac ------------------------------------------------------------- + +DownloadItemMac::DownloadItemMac(BaseDownloadItemModel* download_model, + DownloadItemController* controller) + : download_model_(download_model), item_controller_(controller) { + download_model_->download()->AddObserver(this); +} + +DownloadItemMac::~DownloadItemMac() { + download_model_->download()->RemoveObserver(this); + icon_consumer_.CancelAllRequests(); +} + +void DownloadItemMac::OnDownloadUpdated(DownloadItem* download) { + DCHECK_EQ(download, download_model_->download()); + + if ([item_controller_ isDangerousMode] && + download->safety_state() == DownloadItem::DANGEROUS_BUT_VALIDATED) { + // We have been approved. + [item_controller_ clearDangerousMode]; + } + + if (download->GetUserVerifiedFilePath() != lastFilePath_) { + // Turns out the file path is "unconfirmed %d.crdownload" for dangerous + // downloads. When the download is confirmed, the file is renamed on + // another thread, so reload the icon if the download filename changes. + LoadIcon(); + lastFilePath_ = download->GetUserVerifiedFilePath(); + + [item_controller_ updateToolTip]; + } + + switch (download->state()) { + case DownloadItem::REMOVING: + [item_controller_ remove]; // We're deleted now! + break; + case DownloadItem::COMPLETE: + if (download->auto_opened()) { + [item_controller_ remove]; // We're deleted now! + return; + } + download_util::NotifySystemOfDownloadComplete(download->full_path()); + // fall through + case DownloadItem::IN_PROGRESS: + case DownloadItem::CANCELLED: + [item_controller_ setStateFromDownload:download_model_.get()]; + break; + default: + NOTREACHED(); + } +} + +void DownloadItemMac::LoadIcon() { + IconManager* icon_manager = g_browser_process->icon_manager(); + if (!icon_manager) { + NOTREACHED(); + return; + } + + // We may already have this particular image cached. + FilePath file = download_model_->download()->GetUserVerifiedFilePath(); + SkBitmap* icon_bitmap = icon_manager->LookupIcon(file, IconLoader::SMALL); + if (icon_bitmap) { + NSImage* icon = gfx::SkBitmapToNSImage(*icon_bitmap); + [item_controller_ setIcon:icon]; + return; + } + + // The icon isn't cached, load it asynchronously. + icon_manager->LoadIcon(file, IconLoader::SMALL, &icon_consumer_, + NewCallback(this, + &DownloadItemMac::OnExtractIconComplete)); +} + +void DownloadItemMac::OnExtractIconComplete(IconManager::Handle handle, + SkBitmap* icon_bitmap) { + if (!icon_bitmap) + return; + + NSImage* icon = gfx::SkBitmapToNSImage(*icon_bitmap); + [item_controller_ setIcon:icon]; +} diff --git a/chrome/browser/ui/cocoa/download/download_shelf_controller.h b/chrome/browser/ui/cocoa/download/download_shelf_controller.h new file mode 100644 index 0000000..e67fab9 --- /dev/null +++ b/chrome/browser/ui/cocoa/download/download_shelf_controller.h @@ -0,0 +1,95 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#import "base/cocoa_protocols_mac.h" +#include "base/scoped_nsobject.h" +#include "base/scoped_ptr.h" +#import "chrome/browser/ui/cocoa/view_resizer.h" + +@class AnimatableView; +class BaseDownloadItemModel; +class Browser; +@class BrowserWindowController; +@class DownloadItemController; +class DownloadShelf; +@class DownloadShelfView; +@class HyperlinkButtonCell; + +// A controller class that manages the download shelf for one window. It is +// responsible for the behavior of the shelf itself (showing/hiding, handling +// the link, layout) as well as for managing the download items it contains. +// +// All the files in cocoa/downloads_* are related as follows: +// +// download_shelf_mac bridges calls from chromium's c++ world to the objc +// download_shelf_controller for the shelf (this file). The shelf's background +// is drawn by download_shelf_view. Every item in a shelf is controlled by a +// download_item_controller. +// +// download_item_mac bridges calls from chromium's c++ world to the objc +// download_item_controller, which is responsible for managing a single item +// on the shelf. The item controller loads its UI from a xib file, where the +// UI of an item itself is represented by a button that is drawn by +// download_item_cell. + +@interface DownloadShelfController : NSViewController<NSTextViewDelegate> { + @private + IBOutlet HyperlinkButtonCell* showAllDownloadsCell_; + + IBOutlet NSImageView* image_; + + BOOL barIsVisible_; + + scoped_ptr<DownloadShelf> bridge_; + + // Height of the shelf when it's fully visible. + CGFloat maxShelfHeight_; + + // Current height of the shelf. Changes while the shelf is animating in or + // out. + CGFloat currentShelfHeight_; + + // The download items we have added to our shelf. + scoped_nsobject<NSMutableArray> downloadItemControllers_; + + // The container that contains (and clamps) all the download items. + IBOutlet NSView* itemContainerView_; + + // Delegate that handles resizing our view. + id<ViewResizer> resizeDelegate_; +}; + +- (id)initWithBrowser:(Browser*)browser + resizeDelegate:(id<ViewResizer>)resizeDelegate; + +- (IBAction)showDownloadsTab:(id)sender; + +// Returns our view cast as an AnimatableView. +- (AnimatableView*)animatableView; + +- (DownloadShelf*)bridge; +- (BOOL)isVisible; + +- (IBAction)show:(id)sender; + +// Run when the user clicks the close button on the right side of the shelf. +- (IBAction)hide:(id)sender; + +- (void)addDownloadItem:(BaseDownloadItemModel*)model; + +// Remove a download, possibly via clearing browser data. +- (void)remove:(DownloadItemController*)download; + +// Notification that we are closing and should release our downloads. +- (void)exiting; + +// Return the height of the download shelf. +- (float)height; + +// Re-layouts all download items based on their current state. +- (void)layoutItems; + +@end diff --git a/chrome/browser/ui/cocoa/download/download_shelf_controller.mm b/chrome/browser/ui/cocoa/download/download_shelf_controller.mm new file mode 100644 index 0000000..436fb13 --- /dev/null +++ b/chrome/browser/ui/cocoa/download/download_shelf_controller.mm @@ -0,0 +1,327 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/download/download_shelf_controller.h" + +#include "app/l10n_util.h" +#include "app/resource_bundle.h" +#include "base/mac_util.h" +#include "base/sys_string_conversions.h" +#include "chrome/browser/download/download_item.h" +#include "chrome/browser/download/download_manager.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/themes/browser_theme_provider.h" +#include "chrome/browser/ui/browser.h" +#import "chrome/browser/ui/cocoa/animatable_view.h" +#include "chrome/browser/ui/cocoa/browser_window_cocoa.h" +#import "chrome/browser/ui/cocoa/browser_window_controller.h" +#include "chrome/browser/ui/cocoa/download/download_item_controller.h" +#include "chrome/browser/ui/cocoa/download/download_shelf_mac.h" +#import "chrome/browser/ui/cocoa/download/download_shelf_view.h" +#import "chrome/browser/ui/cocoa/hyperlink_button_cell.h" +#include "grit/generated_resources.h" +#include "grit/theme_resources.h" +#import "third_party/GTM/AppKit/GTMNSAnimation+Duration.h" + +namespace { + +// Max number of download views we'll contain. Any time a view is added and +// we already have this many download views, one is removed. +const size_t kMaxDownloadItemCount = 16; + +// Horizontal padding between two download items. +const int kDownloadItemPadding = 0; + +// Duration for the open-new-leftmost-item animation, in seconds. +const NSTimeInterval kDownloadItemOpenDuration = 0.8; + +// Duration for download shelf closing animation, in seconds. +const NSTimeInterval kDownloadShelfCloseDuration = 0.12; + +} // namespace + +@interface DownloadShelfController(Private) +- (void)showDownloadShelf:(BOOL)enable; +- (void)layoutItems:(BOOL)skipFirst; +- (void)closed; + +- (void)updateTheme; +- (void)themeDidChangeNotification:(NSNotification*)notification; +- (void)viewFrameDidChange:(NSNotification*)notification; +@end + + +@implementation DownloadShelfController + +- (id)initWithBrowser:(Browser*)browser + resizeDelegate:(id<ViewResizer>)resizeDelegate { + if ((self = [super initWithNibName:@"DownloadShelf" + bundle:mac_util::MainAppBundle()])) { + resizeDelegate_ = resizeDelegate; + maxShelfHeight_ = NSHeight([[self view] bounds]); + currentShelfHeight_ = maxShelfHeight_; + + // Reset the download shelf's frame height to zero. It will be properly + // positioned and sized the first time we try to set its height. (Just + // setting the rect to NSZeroRect does not work: it confuses Cocoa's view + // layout logic. If the shelf's width is too small, cocoa makes the download + // item container view wider than the browser window). + NSRect frame = [[self view] frame]; + frame.size.height = 0; + [[self view] setFrame:frame]; + + downloadItemControllers_.reset([[NSMutableArray alloc] init]); + + bridge_.reset(new DownloadShelfMac(browser, self)); + } + return self; +} + +- (void)awakeFromNib { + NSNotificationCenter* defaultCenter = [NSNotificationCenter defaultCenter]; + [defaultCenter addObserver:self + selector:@selector(themeDidChangeNotification:) + name:kBrowserThemeDidChangeNotification + object:nil]; + + [[self animatableView] setResizeDelegate:resizeDelegate_]; + [[self view] setPostsFrameChangedNotifications:YES]; + [defaultCenter addObserver:self + selector:@selector(viewFrameDidChange:) + name:NSViewFrameDidChangeNotification + object:[self view]]; + + ResourceBundle& rb = ResourceBundle::GetSharedInstance(); + NSImage* favicon = rb.GetNativeImageNamed(IDR_DOWNLOADS_FAVICON); + DCHECK(favicon); + [image_ setImage:favicon]; +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; + + // The controllers will unregister themselves as observers when they are + // deallocated. No need to do that here. + [super dealloc]; +} + +// Called after the current theme has changed. +- (void)themeDidChangeNotification:(NSNotification*)notification { + [self updateTheme]; +} + +// Called after the frame's rect has changed; usually when the height is +// animated. +- (void)viewFrameDidChange:(NSNotification*)notification { + // Anchor subviews at the top of |view|, so that it looks like the shelf + // is sliding out. + CGFloat newShelfHeight = NSHeight([[self view] frame]); + if (newShelfHeight == currentShelfHeight_) + return; + + for (NSView* view in [[self view] subviews]) { + NSRect frame = [view frame]; + frame.origin.y -= currentShelfHeight_ - newShelfHeight; + [view setFrame:frame]; + } + currentShelfHeight_ = newShelfHeight; +} + +// Adapt appearance to the current theme. Called after theme changes and before +// this is shown for the first time. +- (void)updateTheme { + NSColor* color = nil; + + if (bridge_.get() && bridge_->browser() && bridge_->browser()->profile()) { + ThemeProvider* provider = bridge_->browser()->profile()->GetThemeProvider(); + + color = + provider->GetNSColor(BrowserThemeProvider::COLOR_BOOKMARK_TEXT, false); + } + + if (!color) + color = [HyperlinkButtonCell defaultTextColor]; + + [showAllDownloadsCell_ setTextColor:color]; +} + +- (AnimatableView*)animatableView { + return static_cast<AnimatableView*>([self view]); +} + +- (void)showDownloadsTab:(id)sender { + bridge_->browser()->ShowDownloadsTab(); +} + +- (void)remove:(DownloadItemController*)download { + // Look for the download in our controller array and remove it. This will + // explicity release it so that it removes itself as an Observer of the + // DownloadItem. We don't want to wait for autorelease since the DownloadItem + // we are observing will likely be gone by then. + [[NSNotificationCenter defaultCenter] removeObserver:download]; + + // TODO(dmaclach): Remove -- http://crbug.com/25845 + [[download view] removeFromSuperview]; + + [downloadItemControllers_ removeObject:download]; + + [self layoutItems]; + + // Check to see if we have any downloads remaining and if not, hide the shelf. + if (![downloadItemControllers_ count]) + [self showDownloadShelf:NO]; +} + +// We need to explicitly release our download controllers here since they need +// to remove themselves as observers before the remaining shutdown happens. +- (void)exiting { + [[self animatableView] stopAnimation]; + downloadItemControllers_.reset(); +} + +// Show or hide the bar based on the value of |enable|. Handles animating the +// resize of the content view. +- (void)showDownloadShelf:(BOOL)enable { + if ([self isVisible] == enable) + return; + + if ([[self view] window]) + [self updateTheme]; + + // Animate the shelf out, but not in. + // TODO(rohitrao): We do not animate on the way in because Cocoa is already + // doing a lot of work to set up the download arrow animation. I've chosen to + // do no animation over janky animation. Find a way to make animating in + // smoother. + AnimatableView* view = [self animatableView]; + if (enable) + [view setHeight:maxShelfHeight_]; + else + [view animateToNewHeight:0 duration:kDownloadShelfCloseDuration]; + + barIsVisible_ = enable; +} + +- (DownloadShelf*)bridge { + return bridge_.get(); +} + +- (BOOL)isVisible { + return barIsVisible_; +} + +- (void)show:(id)sender { + [self showDownloadShelf:YES]; +} + +- (void)hide:(id)sender { + // If |sender| isn't nil, then we're being closed from the UI by the user and + // we need to tell our shelf implementation to close. Otherwise, we're being + // closed programmatically by our shelf implementation. + if (sender) + bridge_->Close(); + else + [self showDownloadShelf:NO]; +} + +- (void)animationDidEnd:(NSAnimation*)animation { + if (![self isVisible]) + [self closed]; +} + +- (float)height { + return maxShelfHeight_; +} + +// If |skipFirst| is true, the frame of the leftmost item is not set. +- (void)layoutItems:(BOOL)skipFirst { + CGFloat currentX = 0; + for (DownloadItemController* itemController + in downloadItemControllers_.get()) { + NSRect frame = [[itemController view] frame]; + frame.origin.x = currentX; + frame.size.width = [itemController preferredSize].width; + if (!skipFirst) + [[[itemController view] animator] setFrame:frame]; + currentX += frame.size.width + kDownloadItemPadding; + skipFirst = NO; + } +} + +- (void)layoutItems { + [self layoutItems:NO]; +} + +- (void)addDownloadItem:(BaseDownloadItemModel*)model { + DCHECK([NSThread isMainThread]); + // Insert new item at the left. + scoped_nsobject<DownloadItemController> controller( + [[DownloadItemController alloc] initWithModel:model shelf:self]); + + // Adding at index 0 in NSMutableArrays is O(1). + [downloadItemControllers_ insertObject:controller.get() atIndex:0]; + + [itemContainerView_ addSubview:[controller view]]; + + // The controller is in charge of removing itself as an observer in its + // dealloc. + [[NSNotificationCenter defaultCenter] + addObserver:controller + selector:@selector(updateVisibility:) + name:NSViewFrameDidChangeNotification + object:[controller view]]; + [[NSNotificationCenter defaultCenter] + addObserver:controller + selector:@selector(updateVisibility:) + name:NSViewFrameDidChangeNotification + object:itemContainerView_]; + + // Start at width 0... + NSSize size = [controller preferredSize]; + NSRect frame = NSMakeRect(0, 0, 0, size.height); + [[controller view] setFrame:frame]; + + // ...then animate in + frame.size.width = size.width; + [NSAnimationContext beginGrouping]; + [[NSAnimationContext currentContext] + gtm_setDuration:kDownloadItemOpenDuration + eventMask:NSLeftMouseUpMask]; + [[[controller view] animator] setFrame:frame]; + [NSAnimationContext endGrouping]; + + // Keep only a limited number of items in the shelf. + if ([downloadItemControllers_ count] > kMaxDownloadItemCount) { + DCHECK(kMaxDownloadItemCount > 0); + + // Since no user will ever see the item being removed (needs a horizontal + // screen resolution greater than 3200 at 16 items at 200 pixels each), + // there's no point in animating the removal. + [self remove:[downloadItemControllers_ lastObject]]; + } + + // Finally, move the remaining items to the right. Skip the first item when + // laying out the items, so that the longer animation duration we set up above + // is not overwritten. + [self layoutItems:YES]; +} + +- (void)closed { + NSUInteger i = 0; + while (i < [downloadItemControllers_ count]) { + DownloadItemController* itemController = + [downloadItemControllers_ objectAtIndex:i]; + bool isTransferDone = + [itemController download]->state() == DownloadItem::COMPLETE || + [itemController download]->state() == DownloadItem::CANCELLED; + if (isTransferDone && + [itemController download]->safety_state() != DownloadItem::DANGEROUS) { + [self remove:itemController]; + } else { + ++i; + } + } +} + +@end diff --git a/chrome/browser/ui/cocoa/download/download_shelf_mac.h b/chrome/browser/ui/cocoa/download/download_shelf_mac.h new file mode 100644 index 0000000..ddfc6f8 --- /dev/null +++ b/chrome/browser/ui/cocoa/download/download_shelf_mac.h @@ -0,0 +1,43 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_DOWNLOAD_DOWNLOAD_SHELF_MAC_H_ +#define CHROME_BROWSER_UI_COCOA_DOWNLOAD_DOWNLOAD_SHELF_MAC_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "chrome/browser/download/download_shelf.h" + +class BaseDownloadItemModel; +class CustomDrawButton; +class DownloadItemMac; + +@class ShelfView; +@class DownloadShelfController; + +// A class to bridge the chromium download shelf to mac gui. This is just a +// wrapper class that forward everything to DownloadShelfController. + +class DownloadShelfMac : public DownloadShelf { + public: + explicit DownloadShelfMac(Browser* browser, + DownloadShelfController* controller); + + // DownloadShelf implementation. + virtual void AddDownload(BaseDownloadItemModel* download_model); + virtual bool IsShowing() const; + virtual bool IsClosing() const; + virtual void Show(); + virtual void Close(); + virtual Browser* browser() const { return browser_; } + + private: + // The browser that owns this shelf. + Browser* browser_; + + DownloadShelfController* shelf_controller_; // weak, owns us +}; + +#endif // CHROME_BROWSER_UI_COCOA_DOWNLOAD_DOWNLOAD_SHELF_MAC_H_ diff --git a/chrome/browser/ui/cocoa/download/download_shelf_mac.mm b/chrome/browser/ui/cocoa/download/download_shelf_mac.mm new file mode 100644 index 0000000..53c13f4 --- /dev/null +++ b/chrome/browser/ui/cocoa/download/download_shelf_mac.mm @@ -0,0 +1,40 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/download/download_shelf_mac.h" + +#include "chrome/browser/download/download_item_model.h" +#include "chrome/browser/ui/browser.h" +#import "chrome/browser/ui/cocoa/download/download_shelf_controller.h" +#include "chrome/browser/ui/cocoa/download/download_item_mac.h" + +DownloadShelfMac::DownloadShelfMac(Browser* browser, + DownloadShelfController* controller) + : browser_(browser), + shelf_controller_(controller) { +} + +void DownloadShelfMac::AddDownload(BaseDownloadItemModel* download_model) { + [shelf_controller_ addDownloadItem:download_model]; + Show(); +} + +bool DownloadShelfMac::IsShowing() const { + return [shelf_controller_ isVisible] == YES; +} + +bool DownloadShelfMac::IsClosing() const { + // TODO(estade): This is never called. For now just return false. + return false; +} + +void DownloadShelfMac::Show() { + [shelf_controller_ show:nil]; + browser_->UpdateDownloadShelfVisibility(true); +} + +void DownloadShelfMac::Close() { + [shelf_controller_ hide:nil]; + browser_->UpdateDownloadShelfVisibility(false); +} diff --git a/chrome/browser/ui/cocoa/download/download_shelf_mac_unittest.mm b/chrome/browser/ui/cocoa/download/download_shelf_mac_unittest.mm new file mode 100644 index 0000000..961d2db --- /dev/null +++ b/chrome/browser/ui/cocoa/download/download_shelf_mac_unittest.mm @@ -0,0 +1,91 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#include "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "chrome/browser/ui/cocoa/download/download_shelf_mac.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +// A fake implementation of DownloadShelfController. It implements only the +// methods that DownloadShelfMac call during the tests in this file. We get this +// class into the DownloadShelfMac constructor by some questionable casting -- +// Objective C is a dynamic language, so we pretend that's ok. + +@interface FakeDownloadShelfController : NSObject { + @public + int callCountIsVisible; + int callCountShow; + int callCountHide; +} + +- (BOOL)isVisible; +- (IBAction)show:(id)sender; +- (IBAction)hide:(id)sender; +@end + +@implementation FakeDownloadShelfController + +- (BOOL)isVisible { + ++callCountIsVisible; + return YES; +} + +- (IBAction)show:(id)sender { + ++callCountShow; +} + +- (IBAction)hide:(id)sender { + ++callCountHide; +} + +@end + + +namespace { + +class DownloadShelfMacTest : public CocoaTest { + + virtual void SetUp() { + CocoaTest::SetUp(); + shelf_controller_.reset([[FakeDownloadShelfController alloc] init]); + } + + protected: + scoped_nsobject<FakeDownloadShelfController> shelf_controller_; + BrowserTestHelper browser_helper_; +}; + +TEST_F(DownloadShelfMacTest, CreationDoesNotCallShow) { + // Also make sure the DownloadShelfMacTest constructor doesn't crash. + DownloadShelfMac shelf(browser_helper_.browser(), + (DownloadShelfController*)shelf_controller_.get()); + EXPECT_EQ(0, shelf_controller_.get()->callCountShow); +} + +TEST_F(DownloadShelfMacTest, ForwardsShow) { + DownloadShelfMac shelf(browser_helper_.browser(), + (DownloadShelfController*)shelf_controller_.get()); + EXPECT_EQ(0, shelf_controller_.get()->callCountShow); + shelf.Show(); + EXPECT_EQ(1, shelf_controller_.get()->callCountShow); +} + +TEST_F(DownloadShelfMacTest, ForwardsHide) { + DownloadShelfMac shelf(browser_helper_.browser(), + (DownloadShelfController*)shelf_controller_.get()); + EXPECT_EQ(0, shelf_controller_.get()->callCountHide); + shelf.Close(); + EXPECT_EQ(1, shelf_controller_.get()->callCountHide); +} + +TEST_F(DownloadShelfMacTest, ForwardsIsShowing) { + DownloadShelfMac shelf(browser_helper_.browser(), + (DownloadShelfController*)shelf_controller_.get()); + EXPECT_EQ(0, shelf_controller_.get()->callCountIsVisible); + shelf.IsShowing(); + EXPECT_EQ(1, shelf_controller_.get()->callCountIsVisible); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/download/download_shelf_view.h b/chrome/browser/ui/cocoa/download/download_shelf_view.h new file mode 100644 index 0000000..bcd949c --- /dev/null +++ b/chrome/browser/ui/cocoa/download/download_shelf_view.h @@ -0,0 +1,20 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_DOWNLOAD_DOWNLOAD_SHELF_VIEW_H_ +#define CHROME_BROWSER_UI_COCOA_DOWNLOAD_DOWNLOAD_SHELF_VIEW_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#import "chrome/browser/ui/cocoa/animatable_view.h" + +// A view that handles any special rendering for the download shelf, painting +// a gradient and managing a set of DownloadItemViews. + +@interface DownloadShelfView : AnimatableView { +} +@end + +#endif // CHROME_BROWSER_UI_COCOA_DOWNLOAD_DOWNLOAD_SHELF_VIEW_H_ diff --git a/chrome/browser/ui/cocoa/download/download_shelf_view.mm b/chrome/browser/ui/cocoa/download/download_shelf_view.mm new file mode 100644 index 0000000..f3840ef --- /dev/null +++ b/chrome/browser/ui/cocoa/download/download_shelf_view.mm @@ -0,0 +1,71 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/download/download_shelf_view.h" + +#include "base/scoped_nsobject.h" +#include "chrome/browser/themes/browser_theme_provider.h" +#import "chrome/browser/ui/cocoa/themed_window.h" +#import "chrome/browser/ui/cocoa/view_id_util.h" +#include "grit/theme_resources.h" + +@implementation DownloadShelfView + +- (NSColor*)strokeColor { + BOOL isKey = [[self window] isKeyWindow]; + ThemeProvider* themeProvider = [[self window] themeProvider]; + return themeProvider ? themeProvider->GetNSColor( + isKey ? BrowserThemeProvider::COLOR_TOOLBAR_STROKE : + BrowserThemeProvider::COLOR_TOOLBAR_STROKE_INACTIVE, true) : + [NSColor blackColor]; +} + +- (void)drawRect:(NSRect)rect { + BOOL isKey = [[self window] isKeyWindow]; + ThemeProvider* themeProvider = [[self window] themeProvider]; + if (!themeProvider) + return; + + NSColor* backgroundImageColor = + themeProvider->GetNSImageColorNamed(IDR_THEME_TOOLBAR, false); + if (backgroundImageColor) { + // We want our backgrounds for the shelf to be phased from the upper + // left hand corner of the view. + NSPoint phase = NSMakePoint(0, NSHeight([self bounds])); + [[NSGraphicsContext currentContext] setPatternPhase:phase]; + [backgroundImageColor set]; + NSRectFill([self bounds]); + } else { + NSGradient* gradient = themeProvider->GetNSGradient( + isKey ? BrowserThemeProvider::GRADIENT_TOOLBAR : + BrowserThemeProvider::GRADIENT_TOOLBAR_INACTIVE); + NSPoint startPoint = [self convertPoint:NSMakePoint(0, 0) fromView:nil]; + NSPoint endPoint = + [self convertPoint:NSMakePoint(0, [self frame].size.height) + fromView:nil]; + + [gradient drawFromPoint:startPoint + toPoint:endPoint + options:NSGradientDrawsBeforeStartingLocation | + NSGradientDrawsAfterEndingLocation]; + } + + // Draw top stroke + [[self strokeColor] set]; + NSRect borderRect, contentRect; + NSDivideRect([self bounds], &borderRect, &contentRect, 1, NSMaxYEdge); + NSRectFillUsingOperation(borderRect, NSCompositeSourceOver); +} + +// Mouse down events on the download shelf should not allow dragging the parent +// window around. +- (BOOL)mouseDownCanMoveWindow { + return NO; +} + +- (ViewID)viewID { + return VIEW_ID_DOWNLOAD_SHELF; +} + +@end diff --git a/chrome/browser/ui/cocoa/download/download_shelf_view_unittest.mm b/chrome/browser/ui/cocoa/download/download_shelf_view_unittest.mm new file mode 100644 index 0000000..926593f --- /dev/null +++ b/chrome/browser/ui/cocoa/download/download_shelf_view_unittest.mm @@ -0,0 +1,23 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/download/download_shelf_view.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +namespace { + +class DownloadShelfViewTest : public CocoaTest { +}; + +// This class only needs to do one thing: prevent mouse down events from moving +// the parent window around. +TEST_F(DownloadShelfViewTest, CanDragWindow) { + scoped_nsobject<DownloadShelfView> view([[DownloadShelfView alloc] init]); + EXPECT_FALSE([view mouseDownCanMoveWindow]); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/download/download_started_animation_mac.mm b/chrome/browser/ui/cocoa/download/download_started_animation_mac.mm new file mode 100644 index 0000000..3bf29d5 --- /dev/null +++ b/chrome/browser/ui/cocoa/download/download_started_animation_mac.mm @@ -0,0 +1,195 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +// +// This file contains the Mac implementation the download animation, displayed +// at the start of a download. The animation produces an arrow pointing +// downwards and animates towards the bottom of the window where the new +// download appears in the download shelf. + +#include "chrome/browser/download/download_started_animation.h" + +#import <QuartzCore/QuartzCore.h> + +#include "app/resource_bundle.h" +#include "chrome/browser/tab_contents/tab_contents.h" +#include "chrome/browser/tab_contents/tab_contents_view_mac.h" +#include "chrome/common/notification_registrar.h" +#include "chrome/common/notification_service.h" +#import "chrome/browser/ui/cocoa/animatable_image.h" +#include "grit/theme_resources.h" +#import "third_party/GTM/AppKit/GTMNSAnimation+Duration.h" +#include "third_party/skia/include/utils/mac/SkCGUtils.h" + +class DownloadAnimationTabObserver; + +// A class for managing the Core Animation download animation. +// Should be instantiated using +startAnimationWithTabContents:. +@interface DownloadStartedAnimationMac : NSObject { + @private + // The observer for the TabContents we are drawing on. + scoped_ptr<DownloadAnimationTabObserver> observer_; + CGFloat imageWidth_; + AnimatableImage* animation_; +}; + ++ (void)startAnimationWithTabContents:(TabContents*)tabContents; + +// Called by the Observer if the tab is hidden or closed. +- (void)closeAnimation; + +@end + +// A helper class to monitor tab hidden and closed notifications. If we receive +// such a notification, we stop the animation. +class DownloadAnimationTabObserver : public NotificationObserver { + public: + DownloadAnimationTabObserver(DownloadStartedAnimationMac* owner, + TabContents* tab_contents) + : owner_(owner), + tab_contents_(tab_contents) { + registrar_.Add(this, + NotificationType::TAB_CONTENTS_HIDDEN, + Source<TabContents>(tab_contents_)); + registrar_.Add(this, + NotificationType::TAB_CONTENTS_DESTROYED, + Source<TabContents>(tab_contents_)); + } + + // Runs when a tab is hidden or destroyed. Let our owner know we should end + // the animation. + void Observe(NotificationType type, + const NotificationSource& source, + const NotificationDetails& details) { + // This ends up deleting us. + [owner_ closeAnimation]; + } + + private: + // The object we need to inform when we get a notification. Weak. + DownloadStartedAnimationMac* owner_; + + // The tab we are observing. Weak. + TabContents* tab_contents_; + + // Used for registering to receive notifications and automatic clean up. + NotificationRegistrar registrar_; + + DISALLOW_COPY_AND_ASSIGN(DownloadAnimationTabObserver); +}; + +@implementation DownloadStartedAnimationMac + +- (id)initWithTabContents:(TabContents*)tabContents { + if ((self = [super init])) { + // Load the image of the download arrow. + ResourceBundle& bundle = ResourceBundle::GetSharedInstance(); + NSImage* image = bundle.GetNativeImageNamed(IDR_DOWNLOAD_ANIMATION_BEGIN); + + // Figure out the positioning in the current tab. Try to position the layer + // against the left edge, and three times the download image's height from + // the bottom of the tab, assuming there is enough room. If there isn't + // enough, don't show the animation and let the shelf speak for itself. + gfx::Rect bounds; + tabContents->GetContainerBounds(&bounds); + imageWidth_ = [image size].width; + CGFloat imageHeight = [image size].height; + + // Sanity check the size in case there's no room to display the animation. + if (bounds.height() < imageHeight) { + [self release]; + return nil; + } + + NSView* tabContentsView = tabContents->GetNativeView(); + NSWindow* parentWindow = [tabContentsView window]; + if (!parentWindow) { + // The tab is no longer frontmost. + [self release]; + return nil; + } + + NSPoint origin = [tabContentsView frame].origin; + origin = [tabContentsView convertPoint:origin toView:nil]; + origin = [parentWindow convertBaseToScreen:origin]; + + // Create the animation object to assist in animating and fading. + CGFloat animationHeight = MIN(bounds.height(), 4 * imageHeight); + NSRect frame = NSMakeRect(origin.x, origin.y, imageWidth_, animationHeight); + animation_ = [[AnimatableImage alloc] initWithImage:image + animationFrame:frame]; + [parentWindow addChildWindow:animation_ ordered:NSWindowAbove]; + + animationHeight = MIN(bounds.height(), 3 * imageHeight); + [animation_ setStartFrame:CGRectMake(0, animationHeight, + imageWidth_, imageHeight)]; + [animation_ setEndFrame:CGRectMake(0, imageHeight, + imageWidth_, imageHeight)]; + [animation_ setStartOpacity:1.0]; + [animation_ setEndOpacity:0.4]; + [animation_ setDuration:0.6]; + + observer_.reset(new DownloadAnimationTabObserver(self, tabContents)); + + // Set up to get notified about resize events on the parent window. + NSNotificationCenter* center = [NSNotificationCenter defaultCenter]; + [center addObserver:self + selector:@selector(parentWindowChanged:) + name:NSWindowDidResizeNotification + object:parentWindow]; + // When the animation window closes, it needs to be removed from the + // parent window. + [center addObserver:self + selector:@selector(windowWillClose:) + name:NSWindowWillCloseNotification + object:animation_]; + } + return self; +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [super dealloc]; +} + +// Called when the parent window is resized. +- (void)parentWindowChanged:(NSNotification*)notification { + NSWindow* parentWindow = [animation_ parentWindow]; + DCHECK([[notification object] isEqual:parentWindow]); + NSRect parentFrame = [parentWindow frame]; + NSRect frame = parentFrame; + frame.size.width = MIN(imageWidth_, NSWidth(parentFrame)); + [animation_ setFrame:frame display:YES]; +} + +- (void)closeAnimation { + [animation_ close]; +} + +// When the animation closes, release self. +- (void)windowWillClose:(NSNotification*)notification { + DCHECK([[notification object] isEqual:animation_]); + [[animation_ parentWindow] removeChildWindow:animation_]; + [self release]; +} + ++ (void)startAnimationWithTabContents:(TabContents*)contents { + // Will be deleted when the animation window closes. + DownloadStartedAnimationMac* controller = + [[self alloc] initWithTabContents:contents]; + // The initializer can return nil. + if (!controller) + return; + + // The |animation_| releases itself when done. + [controller->animation_ startAnimation]; +} + +@end + +void DownloadStartedAnimation::Show(TabContents* tab_contents) { + DCHECK(tab_contents); + + // Will be deleted when the animation is complete. + [DownloadStartedAnimationMac startAnimationWithTabContents:tab_contents]; +} diff --git a/chrome/browser/ui/cocoa/download/download_util_mac.h b/chrome/browser/ui/cocoa/download/download_util_mac.h new file mode 100644 index 0000000..8f99c8b --- /dev/null +++ b/chrome/browser/ui/cocoa/download/download_util_mac.h @@ -0,0 +1,25 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +// +// Download utility functions for Mac OS X. + +#ifndef CHROME_BROWSER_UI_COCOA_DOWNLOAD_DOWNLOAD_UTIL_MAC_H_ +#define CHROME_BROWSER_UI_COCOA_DOWNLOAD_DOWNLOAD_UTIL_MAC_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +class FilePath; + +namespace download_util { + +void AddFileToPasteboard(NSPasteboard* pasteboard, const FilePath& path); + +// Notify the system that a download completed. This will cause the download +// folder in the dock to bounce. +void NotifySystemOfDownloadComplete(const FilePath& path); + +} // namespace download_util + +#endif // CHROME_BROWSER_UI_COCOA_DOWNLOAD_DOWNLOAD_UTIL_MAC_H_ diff --git a/chrome/browser/ui/cocoa/download/download_util_mac.mm b/chrome/browser/ui/cocoa/download/download_util_mac.mm new file mode 100644 index 0000000..baafbbf --- /dev/null +++ b/chrome/browser/ui/cocoa/download/download_util_mac.mm @@ -0,0 +1,83 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +// +// Download utility implementation for Mac OS X. + +#include "chrome/browser/ui/cocoa/download/download_util_mac.h" + +#include "base/sys_string_conversions.h" +#include "chrome/browser/download/download_item.h" +#include "chrome/browser/download/download_manager.h" +#import "chrome/browser/ui/cocoa/dock_icon.h" +#include "gfx/native_widget_types.h" +#include "skia/ext/skia_utils_mac.h" + +namespace download_util { + +void AddFileToPasteboard(NSPasteboard* pasteboard, const FilePath& path) { + // Write information about the file being dragged to the pasteboard. + NSString* file = base::SysUTF8ToNSString(path.value()); + NSArray* fileList = [NSArray arrayWithObject:file]; + [pasteboard declareTypes:[NSArray arrayWithObject:NSFilenamesPboardType] + owner:nil]; + [pasteboard setPropertyList:fileList forType:NSFilenamesPboardType]; +} + +void NotifySystemOfDownloadComplete(const FilePath& path) { + NSString* filePath = base::SysUTF8ToNSString(path.value()); + [[NSDistributedNotificationCenter defaultCenter] + postNotificationName:@"com.apple.DownloadFileFinished" + object:filePath]; + + NSString* parentPath = [filePath stringByDeletingLastPathComponent]; + FNNotifyByPath( + reinterpret_cast<const UInt8*>([parentPath fileSystemRepresentation]), + kFNDirectoryModifiedMessage, + kNilOptions); +} + +void DragDownload(const DownloadItem* download, + SkBitmap* icon, + gfx::NativeView view) { + NSPasteboard* pasteboard = [NSPasteboard pasteboardWithName:NSDragPboard]; + AddFileToPasteboard(pasteboard, download->full_path()); + + // Convert to an NSImage. + NSImage* dragImage = gfx::SkBitmapToNSImage(*icon); + + // Synthesize a drag event, since we don't have access to the actual event + // that initiated a drag (possibly consumed by the DOM UI, for example). + NSPoint position = [[view window] mouseLocationOutsideOfEventStream]; + NSTimeInterval eventTime = [[NSApp currentEvent] timestamp]; + NSEvent* dragEvent = [NSEvent mouseEventWithType:NSLeftMouseDragged + location:position + modifierFlags:NSLeftMouseDraggedMask + timestamp:eventTime + windowNumber:[[view window] windowNumber] + context:nil + eventNumber:0 + clickCount:1 + pressure:1.0]; + + // Run the drag operation. + [[view window] dragImage:dragImage + at:position + offset:NSZeroSize + event:dragEvent + pasteboard:pasteboard + source:view + slideBack:YES]; +} + +void UpdateAppIconDownloadProgress(int download_count, + bool progress_known, + float progress) { + DockIcon* dock_icon = [DockIcon sharedDockIcon]; + [dock_icon setDownloads:download_count]; + [dock_icon setIndeterminate:!progress_known]; + [dock_icon setProgress:progress]; + [dock_icon updateIcon]; +} + +} // namespace download_util diff --git a/chrome/browser/ui/cocoa/download/download_util_mac_unittest.mm b/chrome/browser/ui/cocoa/download/download_util_mac_unittest.mm new file mode 100644 index 0000000..bd99e02 --- /dev/null +++ b/chrome/browser/ui/cocoa/download/download_util_mac_unittest.mm @@ -0,0 +1,58 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// Download utility test for Mac OS X. + +#include "base/path_service.h" +#include "base/sys_string_conversions.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/download/download_util_mac.h" +#include "chrome/common/chrome_paths.h" +#include "testing/gtest/include/gtest/gtest.h" +#import "testing/gtest_mac.h" +#include "testing/platform_test.h" + +namespace { + +class DownloadUtilMacTest : public CocoaTest { + public: + DownloadUtilMacTest() { + pasteboard_ = [NSPasteboard pasteboardWithUniqueName]; + } + + virtual ~DownloadUtilMacTest() { + [pasteboard_ releaseGlobally]; + } + + NSPasteboard* const pasteboard() { return pasteboard_; } + + private: + NSPasteboard* pasteboard_; +}; + +// Ensure adding files to the pasteboard methods works as expected. +TEST_F(DownloadUtilMacTest, AddFileToPasteboardTest) { + // Get a download test file for addition to the pasteboard. + FilePath testPath; + ASSERT_TRUE(PathService::Get(chrome::DIR_TEST_DATA, &testPath)); + FilePath testFile(FILE_PATH_LITERAL("download-test1.lib")); + testPath = testPath.Append(testFile); + + // Add a test file to the pasteboard via the download_util method. + download_util::AddFileToPasteboard(pasteboard(), testPath); + + // Test to see that the object type for dragging files is available. + NSArray* types = [NSArray arrayWithObject:NSFilenamesPboardType]; + NSString* available = [pasteboard() availableTypeFromArray:types]; + EXPECT_TRUE(available != nil); + + // Ensure the path is what we expect. + NSArray* files = [pasteboard() propertyListForType:NSFilenamesPboardType]; + ASSERT_TRUE(files != nil); + NSString* expectedPath = [files objectAtIndex:0]; + NSString* realPath = base::SysWideToNSString(testPath.ToWStringHack()); + EXPECT_NSEQ(expectedPath, realPath); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/draggable_button.h b/chrome/browser/ui/cocoa/draggable_button.h new file mode 100644 index 0000000..2e166b7 --- /dev/null +++ b/chrome/browser/ui/cocoa/draggable_button.h @@ -0,0 +1,33 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +// Class for buttons that can be drag sources. If the mouse is clicked and moved +// more than a given distance, this class will call |-beginDrag:| instead of +// |-performClick:|. Subclasses should override these two methods. +@interface DraggableButton : NSButton { + @private + BOOL draggable_; // Is this a draggable type of button? +} + +// Enable or disable dragability for special buttons like "Other Bookmarks". +@property (nonatomic) BOOL draggable; + +// Called when a drag should start. Subclasses must override this to do any +// pasteboard manipulation and begin the drag, usually with +// -dragImage:at:offset:event:. Subclasses must call one of the blocking +// -drag* methods of NSView when overriding this method. +- (void)beginDrag:(NSEvent*)dragEvent; + +@end // @interface DraggableButton + +@interface DraggableButton (Private) + +// Resets the draggable state of the button after dragging is finished. This is +// called by DraggableButton when the beginDrag call returns, it should not be +// called by the subclass. +- (void)endDrag; + +@end // @interface DraggableButton(Private) diff --git a/chrome/browser/ui/cocoa/draggable_button.mm b/chrome/browser/ui/cocoa/draggable_button.mm new file mode 100644 index 0000000..923476b --- /dev/null +++ b/chrome/browser/ui/cocoa/draggable_button.mm @@ -0,0 +1,150 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/draggable_button.h" + +#include "base/logging.h" +#import "base/scoped_nsobject.h" + +namespace { + +// Code taken from <http://codereview.chromium.org/180036/diff/3001/3004>. +// TODO(viettrungluu): Do we want common, standard code for drag hysteresis? +const CGFloat kWebDragStartHysteresisX = 5.0; +const CGFloat kWebDragStartHysteresisY = 5.0; +const CGFloat kDragExpirationTimeout = 1.0; + +} + +@implementation DraggableButton + +@synthesize draggable = draggable_; + +- (id)initWithFrame:(NSRect)frame { + if ((self = [super initWithFrame:frame])) { + draggable_ = YES; + } + return self; +} + +- (id)initWithCoder:(NSCoder*)coder { + if ((self = [super initWithCoder:coder])) { + draggable_ = YES; + } + return self; +} + +// Determine whether a mouse down should turn into a drag; started as copy of +// NSTableView code. +- (BOOL)dragShouldBeginFromMouseDown:(NSEvent*)mouseDownEvent + withExpiration:(NSDate*)expiration + xHysteresis:(float)xHysteresis + yHysteresis:(float)yHysteresis { + if ([mouseDownEvent type] != NSLeftMouseDown) { + return NO; + } + + NSEvent* nextEvent = nil; + NSEvent* firstEvent = nil; + NSEvent* dragEvent = nil; + NSEvent* mouseUp = nil; + BOOL dragIt = NO; + + while ((nextEvent = [[self window] + nextEventMatchingMask:(NSLeftMouseUpMask | NSLeftMouseDraggedMask) + untilDate:expiration + inMode:NSEventTrackingRunLoopMode + dequeue:YES]) != nil) { + if (firstEvent == nil) { + firstEvent = nextEvent; + } + if ([nextEvent type] == NSLeftMouseDragged) { + float deltax = ABS([nextEvent locationInWindow].x - + [mouseDownEvent locationInWindow].x); + float deltay = ABS([nextEvent locationInWindow].y - + [mouseDownEvent locationInWindow].y); + dragEvent = nextEvent; + if (deltax >= xHysteresis) { + dragIt = YES; + break; + } + if (deltay >= yHysteresis) { + dragIt = YES; + break; + } + } else if ([nextEvent type] == NSLeftMouseUp) { + mouseUp = nextEvent; + break; + } + } + + // Since we've been dequeuing the events (If we don't, we'll never see + // the mouse up...), we need to push some of the events back on. + // It makes sense to put the first and last drag events and the mouse + // up if there was one. + if (mouseUp != nil) { + [NSApp postEvent:mouseUp atStart:YES]; + } + if (dragEvent != nil) { + [NSApp postEvent:dragEvent atStart:YES]; + } + if (firstEvent != mouseUp && firstEvent != dragEvent) { + [NSApp postEvent:firstEvent atStart:YES]; + } + + return dragIt; +} + +- (BOOL)dragShouldBeginFromMouseDown:(NSEvent*)mouseDownEvent + withExpiration:(NSDate*)expiration { + return [self dragShouldBeginFromMouseDown:mouseDownEvent + withExpiration:expiration + xHysteresis:kWebDragStartHysteresisX + yHysteresis:kWebDragStartHysteresisY]; +} + +- (void)mouseUp:(NSEvent*)theEvent { + if (!draggable_) { + [super mouseUp:theEvent]; + return; + } + + // There are non-drag cases where a mouseUp: may happen + // (e.g. mouse-down, cmd-tab to another application, move mouse, + // mouse-up). So we check. + NSPoint viewLocal = [self convertPoint:[theEvent locationInWindow] + fromView:[[self window] contentView]]; + if (NSPointInRect(viewLocal, [self bounds])) { + [self performClick:self]; + } +} + +// Mimic "begin a click" operation visually. Do NOT follow through +// with normal button event handling. +- (void)mouseDown:(NSEvent*)theEvent { + if (draggable_) { + [[self cell] setHighlighted:YES]; + NSDate* date = [NSDate dateWithTimeIntervalSinceNow:kDragExpirationTimeout]; + if ([self dragShouldBeginFromMouseDown:theEvent + withExpiration:date]) { + [self beginDrag:theEvent]; + [self endDrag]; + } else { + [super mouseDown:theEvent]; + } + } else { + [super mouseDown:theEvent]; + } +} + +- (void)beginDrag:(NSEvent*)dragEvent { + // Must be overridden by subclasses. + NOTREACHED(); +} + +- (void)endDrag { + [[self cell] setHighlighted:NO]; +} + +@end // @interface DraggableButton diff --git a/chrome/browser/ui/cocoa/draggable_button_unittest.mm b/chrome/browser/ui/cocoa/draggable_button_unittest.mm new file mode 100644 index 0000000..2700a49 --- /dev/null +++ b/chrome/browser/ui/cocoa/draggable_button_unittest.mm @@ -0,0 +1,137 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/draggable_button.h" +#import "chrome/browser/ui/cocoa/test_event_utils.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +@interface TestableDraggableButton : DraggableButton { + NSUInteger dragCount_; + BOOL wasTriggered_; +} +- (void)trigger:(id)sender; +- (BOOL)wasTriggered; +- (NSUInteger)dragCount; +@end + +@implementation TestableDraggableButton +- (id)initWithFrame:(NSRect)frame { + if ((self = [super initWithFrame:frame])) { + dragCount_ = 0; + wasTriggered_ = NO; + } + return self; +} +- (void)beginDrag:(NSEvent*)theEvent { + dragCount_++; +} + +- (void)trigger:(id)sender { + wasTriggered_ = YES; +} + +- (BOOL)wasTriggered { + return wasTriggered_; +} + +- (NSUInteger)dragCount { + return dragCount_; +} +@end + +class DraggableButtonTest : public CocoaTest {}; + +// Make sure the basic case of "click" still works. +TEST_F(DraggableButtonTest, DownUp) { + scoped_nsobject<TestableDraggableButton> button( + [[TestableDraggableButton alloc] initWithFrame:NSMakeRect(0,0,500,500)]); + [[test_window() contentView] addSubview:button.get()]; + [button setTarget:button]; + [button setAction:@selector(trigger:)]; + EXPECT_FALSE([button wasTriggered]); + NSEvent* downEvent = + test_event_utils::MouseEventAtPoint(NSMakePoint(10,10), + NSLeftMouseDown, 0); + NSEvent* upEvent = + test_event_utils::MouseEventAtPoint(NSMakePoint(10,10), + NSLeftMouseUp, 0); + [NSApp postEvent:upEvent atStart:YES]; + [test_window() sendEvent:downEvent]; + EXPECT_TRUE([button wasTriggered]); // confirms target/action fired +} + +TEST_F(DraggableButtonTest, DraggableHysteresis) { + scoped_nsobject<TestableDraggableButton> button( + [[TestableDraggableButton alloc] initWithFrame:NSMakeRect(0,0,500,500)]); + [[test_window() contentView] addSubview:button.get()]; + NSEvent* downEvent = + test_event_utils::MouseEventAtPoint(NSMakePoint(10,10), + NSLeftMouseDown, + 0); + NSEvent* firstMove = + test_event_utils::MouseEventAtPoint(NSMakePoint(11,11), + NSLeftMouseDragged, + 0); + NSEvent* firstUpEvent = + test_event_utils::MouseEventAtPoint(NSMakePoint(11,11), + NSLeftMouseUp, + 0); + NSEvent* secondMove = + test_event_utils::MouseEventAtPoint(NSMakePoint(100,100), + NSLeftMouseDragged, + 0); + NSEvent* secondUpEvent = + test_event_utils::MouseEventAtPoint(NSMakePoint(100,100), + NSLeftMouseUp, + 0); + // If the mouse only moves one pixel in each direction + // it should not cause a drag. + [NSApp postEvent:firstUpEvent atStart:YES]; + [NSApp postEvent:firstMove atStart:YES]; + [button mouseDown:downEvent]; + EXPECT_EQ(0U, [button dragCount]); + + // If the mouse moves > 5 pixels in either direciton + // it should cause a drag. + [NSApp postEvent:secondUpEvent atStart:YES]; + [NSApp postEvent:secondMove atStart:YES]; + [button mouseDown:downEvent]; + EXPECT_EQ(1U, [button dragCount]); +} + +TEST_F(DraggableButtonTest, ResetState) { + scoped_nsobject<TestableDraggableButton> button( + [[TestableDraggableButton alloc] initWithFrame:NSMakeRect(0,0,500,500)]); + [[test_window() contentView] addSubview:button.get()]; + NSEvent* downEvent = + test_event_utils::MouseEventAtPoint(NSMakePoint(10,10), + NSLeftMouseDown, + 0); + NSEvent* moveEvent = + test_event_utils::MouseEventAtPoint(NSMakePoint(100,100), + NSLeftMouseDragged, + 0); + NSEvent* upEvent = + test_event_utils::MouseEventAtPoint(NSMakePoint(100,100), + NSLeftMouseUp, + 0); + // If the mouse moves > 5 pixels in either direciton it should cause a drag. + [NSApp postEvent:upEvent atStart:YES]; + [NSApp postEvent:moveEvent atStart:YES]; + [button mouseDown:downEvent]; + + // The button should not be highlighted after the drag finishes. + EXPECT_FALSE([[button cell] isHighlighted]); + EXPECT_EQ(1U, [button dragCount]); + + // We should be able to initiate another drag immediately after the first one. + [NSApp postEvent:upEvent atStart:YES]; + [NSApp postEvent:moveEvent atStart:YES]; + [button mouseDown:downEvent]; + EXPECT_EQ(2U, [button dragCount]); + EXPECT_FALSE([[button cell] isHighlighted]); +} diff --git a/chrome/browser/ui/cocoa/edit_search_engine_cocoa_controller.h b/chrome/browser/ui/cocoa/edit_search_engine_cocoa_controller.h new file mode 100644 index 0000000..b1bfabc --- /dev/null +++ b/chrome/browser/ui/cocoa/edit_search_engine_cocoa_controller.h @@ -0,0 +1,53 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +class TemplateURL; + +#include "base/cocoa_protocols_mac.h" +#include "base/scoped_nsobject.h" +#include "base/scoped_ptr.h" +#include "chrome/browser/search_engines/edit_search_engine_controller.h" + +// This controller presents a dialog that allows a user to add or edit a search +// engine. If constructed with a nil |templateURL| then it is an add operation, +// otherwise it will modify the passed URL. A |delegate| is necessary to +// perform the actual database modifications, and should probably be an +// instance of KeywordEditorModelObserver. + +@interface EditSearchEngineCocoaController : + NSWindowController<NSWindowDelegate> { + IBOutlet NSTextField* nameField_; + IBOutlet NSTextField* keywordField_; + IBOutlet NSTextField* urlField_; + IBOutlet NSImageView* nameImage_; + IBOutlet NSImageView* keywordImage_; + IBOutlet NSImageView* urlImage_; + IBOutlet NSButton* doneButton_; + IBOutlet NSTextField* urlDescriptionField_; + IBOutlet NSView* labelContainer_; + IBOutlet NSBox* fieldAndImageContainer_; + + // Refs to the good and bad images used in the interface validation. + scoped_nsobject<NSImage> goodImage_; + scoped_nsobject<NSImage> badImage_; + + Profile* profile_; // weak + const TemplateURL* templateURL_; // weak + scoped_ptr<EditSearchEngineController> controller_; +} + +- (id)initWithProfile:(Profile*)profile + delegate:(EditSearchEngineControllerDelegate*)delegate + templateURL:(const TemplateURL*)url; + +- (IBAction)cancel:(id)sender; +- (IBAction)save:(id)sender; + +@end + +@interface EditSearchEngineCocoaController (ExposedForTesting) +- (BOOL)validateFields; +@end diff --git a/chrome/browser/ui/cocoa/edit_search_engine_cocoa_controller.mm b/chrome/browser/ui/cocoa/edit_search_engine_cocoa_controller.mm new file mode 100644 index 0000000..06fc94b --- /dev/null +++ b/chrome/browser/ui/cocoa/edit_search_engine_cocoa_controller.mm @@ -0,0 +1,187 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/edit_search_engine_cocoa_controller.h" + +#include "app/l10n_util_mac.h" +#include "app/resource_bundle.h" +#include "base/logging.h" +#import "base/mac_util.h" +#include "base/string16.h" +#include "base/sys_string_conversions.h" +#include "chrome/browser/search_engines/template_url.h" +#include "grit/app_resources.h" +#include "grit/generated_resources.h" +#include "grit/theme_resources.h" +#include "third_party/GTM/AppKit/GTMUILocalizerAndLayoutTweaker.h" + +namespace { + +void ShiftOriginY(NSView* view, CGFloat amount) { + NSPoint origin = [view frame].origin; + origin.y += amount; + [view setFrameOrigin:origin]; +} + +} // namespace + +@implementation EditSearchEngineCocoaController + +- (id)initWithProfile:(Profile*)profile + delegate:(EditSearchEngineControllerDelegate*)delegate + templateURL:(const TemplateURL*)url { + DCHECK(profile); + NSString* nibpath = [mac_util::MainAppBundle() + pathForResource:@"EditSearchEngine" + ofType:@"nib"]; + if ((self = [super initWithWindowNibPath:nibpath owner:self])) { + profile_ = profile; + templateURL_ = url; + controller_.reset( + new EditSearchEngineController(templateURL_, delegate, profile_)); + } + return self; +} + +- (void)awakeFromNib { + DCHECK([self window]); + DCHECK_EQ(self, [[self window] delegate]); + + // Make sure the url description field fits the text in it. + CGFloat descriptionShift = [GTMUILocalizerAndLayoutTweaker + sizeToFitFixedWidthTextField:urlDescriptionField_]; + + // Move the label container above the url description. + ShiftOriginY(labelContainer_, descriptionShift); + // There was no way via view containment to use a helper view to move all + // the textfields and images at once, most move them all on their own so + // they stay above the url description. + ShiftOriginY(nameField_, descriptionShift); + ShiftOriginY(keywordField_, descriptionShift); + ShiftOriginY(urlField_, descriptionShift); + ShiftOriginY(nameImage_, descriptionShift); + ShiftOriginY(keywordImage_, descriptionShift); + ShiftOriginY(urlImage_, descriptionShift); + + // Resize the containing box for the name/keyword/url fields/images since it + // also contains the url description (which just grew). + [[fieldAndImageContainer_ contentView] setAutoresizesSubviews:NO]; + NSRect rect = [fieldAndImageContainer_ frame]; + rect.size.height += descriptionShift; + [fieldAndImageContainer_ setFrame:rect]; + [[fieldAndImageContainer_ contentView] setAutoresizesSubviews:YES]; + + // Resize the window. + NSWindow* window = [self window]; + NSSize windowDelta = NSMakeSize(0, descriptionShift); + [GTMUILocalizerAndLayoutTweaker + resizeWindowWithoutAutoResizingSubViews:window + delta:windowDelta]; + + ResourceBundle& bundle = ResourceBundle::GetSharedInstance(); + goodImage_.reset([bundle.GetNativeImageNamed(IDR_INPUT_GOOD) retain]); + badImage_.reset([bundle.GetNativeImageNamed(IDR_INPUT_ALERT) retain]); + if (templateURL_) { + // Defaults to |..._NEW_WINDOW_TITLE|. + [window setTitle:l10n_util::GetNSString( + IDS_SEARCH_ENGINES_EDITOR_EDIT_WINDOW_TITLE)]; + [nameField_ setStringValue: + base::SysWideToNSString(templateURL_->short_name())]; + [keywordField_ setStringValue: + base::SysWideToNSString(templateURL_->keyword())]; + [urlField_ setStringValue: + base::SysWideToNSString(templateURL_->url()->DisplayURL())]; + [urlField_ setEnabled:(templateURL_->prepopulate_id() == 0)]; + } + // When creating a new keyword, this will mark the fields as "invalid" and + // will not let the user save. If this is an edit, then this will set all + // the images to the "valid" state. + [self validateFields]; +} + +// When the window closes, clean ourselves up. +- (void)windowWillClose:(NSNotification*)notif { + [self autorelease]; +} + +// Performs the logic of closing the window. If we are a sheet, then it ends the +// modal session; otherwise, it closes the window. +- (void)doClose { + if ([[self window] isSheet]) { + [NSApp endSheet:[self window]]; + } else { + [[self window] close]; + } +} + +- (IBAction)cancel:(id)sender { + [self doClose]; +} + +- (IBAction)save:(id)sender { + DCHECK([self validateFields]); + string16 title = base::SysNSStringToUTF16([nameField_ stringValue]); + string16 keyword = base::SysNSStringToUTF16([keywordField_ stringValue]); + std::string url = base::SysNSStringToUTF8([urlField_ stringValue]); + controller_->AcceptAddOrEdit(title, keyword, url); + [self doClose]; +} + +// Delegate method for the text fields. + +- (void)controlTextDidChange:(NSNotification*)notif { + [self validateFields]; +} + +- (void)controlTextDidEndEditing:(NSNotification*)notif { + [self validateFields]; +} + +// Private -------------------------------------------------------------------- + +// Sets the appropriate image and tooltip based on a boolean |valid|. +- (void)setIsValid:(BOOL)valid + toolTip:(int)messageID + forImageView:(NSImageView*)imageView + textField:(NSTextField*)textField { + NSImage* image = (valid) ? goodImage_ : badImage_; + [imageView setImage:image]; + + NSString* toolTip = nil; + if (!valid) + toolTip = l10n_util::GetNSString(messageID); + [textField setToolTip:toolTip]; + [imageView setToolTip:toolTip]; +} + +// This sets the image state for all the controls and enables or disables the +// done button. Returns YES if all the fields are valid. +- (BOOL)validateFields { + string16 title = base::SysNSStringToUTF16([nameField_ stringValue]); + BOOL titleValid = controller_->IsTitleValid(title); + [self setIsValid:titleValid + toolTip:IDS_SEARCH_ENGINES_INVALID_TITLE_TT + forImageView:nameImage_ + textField:nameField_]; + + string16 keyword = base::SysNSStringToUTF16([keywordField_ stringValue]); + BOOL keywordValid = controller_->IsKeywordValid(keyword); + [self setIsValid:keywordValid + toolTip:IDS_SEARCH_ENGINES_INVALID_KEYWORD_TT + forImageView:keywordImage_ + textField:keywordField_]; + + std::string url = base::SysNSStringToUTF8([urlField_ stringValue]); + BOOL urlValid = controller_->IsURLValid(url); + [self setIsValid:urlValid + toolTip:IDS_SEARCH_ENGINES_INVALID_URL_TT + forImageView:urlImage_ + textField:urlField_]; + + BOOL isValid = (titleValid && keywordValid && urlValid); + [doneButton_ setEnabled:isValid]; + return isValid; +} + +@end diff --git a/chrome/browser/ui/cocoa/edit_search_engine_cocoa_controller_unittest.mm b/chrome/browser/ui/cocoa/edit_search_engine_cocoa_controller_unittest.mm new file mode 100644 index 0000000..72bfe7b --- /dev/null +++ b/chrome/browser/ui/cocoa/edit_search_engine_cocoa_controller_unittest.mm @@ -0,0 +1,233 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "app/l10n_util_mac.h" +#include "base/scoped_nsobject.h" +#include "chrome/browser/search_engines/template_url.h" +#include "chrome/browser/ui/browser.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#include "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/edit_search_engine_cocoa_controller.h" +#include "chrome/test/testing_profile.h" +#include "grit/generated_resources.h" +#include "testing/gtest/include/gtest/gtest.h" +#import "testing/gtest_mac.h" +#include "testing/platform_test.h" + +@interface FakeEditSearchEngineController : EditSearchEngineCocoaController { +} +@property (nonatomic, readonly) NSTextField* nameField; +@property (nonatomic, readonly) NSTextField* keywordField; +@property (nonatomic, readonly) NSTextField* urlField; +@property (nonatomic, readonly) NSImageView* nameImage; +@property (nonatomic, readonly) NSImageView* keywordImage; +@property (nonatomic, readonly) NSImageView* urlImage; +@property (nonatomic, readonly) NSButton* doneButton; +@property (nonatomic, readonly) NSImage* goodImage; +@property (nonatomic, readonly) NSImage* badImage; +@end + +@implementation FakeEditSearchEngineController +@synthesize nameField = nameField_; +@synthesize keywordField = keywordField_; +@synthesize urlField = urlField_; +@synthesize nameImage = nameImage_; +@synthesize keywordImage = keywordImage_; +@synthesize urlImage = urlImage_; +@synthesize doneButton = doneButton_; +- (NSImage*)goodImage { + return goodImage_.get(); +} +- (NSImage*)badImage { + return badImage_.get(); +} +@end + +namespace { + +class EditSearchEngineControllerTest : public CocoaTest { + public: + virtual void SetUp() { + CocoaTest::SetUp(); + TestingProfile* profile = + static_cast<TestingProfile*>(browser_helper_.profile()); + profile->CreateTemplateURLModel(); + controller_ = [[FakeEditSearchEngineController alloc] + initWithProfile:profile + delegate:nil + templateURL:nil]; + } + + virtual void TearDown() { + // Force the window to load so we hit |-awakeFromNib| to register as the + // window's delegate so that the controller can clean itself up in + // |-windowWillClose:|. + ASSERT_TRUE([controller_ window]); + + [controller_ close]; + CocoaTest::TearDown(); + } + + BrowserTestHelper browser_helper_; + FakeEditSearchEngineController* controller_; +}; + +TEST_F(EditSearchEngineControllerTest, ValidImageOriginals) { + EXPECT_FALSE([controller_ goodImage]); + EXPECT_FALSE([controller_ badImage]); + + EXPECT_TRUE([controller_ window]); // Force the window to load. + + EXPECT_TRUE([[controller_ goodImage] isKindOfClass:[NSImage class]]); + EXPECT_TRUE([[controller_ badImage] isKindOfClass:[NSImage class]]); + + // Test window title is set correctly. + NSString* title = l10n_util::GetNSString( + IDS_SEARCH_ENGINES_EDITOR_NEW_WINDOW_TITLE); + EXPECT_NSEQ(title, [[controller_ window] title]); +} + +TEST_F(EditSearchEngineControllerTest, SetImageViews) { + EXPECT_TRUE([controller_ window]); // Force the window to load. + EXPECT_EQ([controller_ badImage], [[controller_ nameImage] image]); + // An empty keyword is not OK. + EXPECT_EQ([controller_ badImage], [[controller_ keywordImage] image]); + EXPECT_EQ([controller_ badImage], [[controller_ urlImage] image]); +} + +// This test ensures that on creating a new keyword, we are in an "invalid" +// state that cannot save. +TEST_F(EditSearchEngineControllerTest, InvalidState) { + EXPECT_TRUE([controller_ window]); // Force window to load. + NSString* toolTip = nil; + EXPECT_FALSE([controller_ validateFields]); + + EXPECT_NSEQ(@"", [[controller_ nameField] stringValue]); + EXPECT_EQ([controller_ badImage], [[controller_ nameImage] image]); + toolTip = l10n_util::GetNSString(IDS_SEARCH_ENGINES_INVALID_TITLE_TT); + EXPECT_NSEQ(toolTip, [[controller_ nameField] toolTip]); + EXPECT_NSEQ(toolTip, [[controller_ nameImage] toolTip]); + + // Keywords can not be empty strings. + EXPECT_NSEQ(@"", [[controller_ keywordField] stringValue]); + EXPECT_EQ([controller_ badImage], [[controller_ keywordImage] image]); + EXPECT_TRUE([[controller_ keywordField] toolTip]); + EXPECT_TRUE([[controller_ keywordImage] toolTip]); + + EXPECT_NSEQ(@"", [[controller_ urlField] stringValue]); + EXPECT_EQ([controller_ badImage], [[controller_ urlImage] image]); + toolTip = l10n_util::GetNSString(IDS_SEARCH_ENGINES_INVALID_URL_TT); + EXPECT_NSEQ(toolTip, [[controller_ urlField] toolTip]); + EXPECT_NSEQ(toolTip, [[controller_ urlImage] toolTip]); +} + +// Tests that the single name field validates. +TEST_F(EditSearchEngineControllerTest, ValidateName) { + EXPECT_TRUE([controller_ window]); // Force window to load. + + EXPECT_EQ([controller_ badImage], [[controller_ nameImage] image]); + EXPECT_FALSE([controller_ validateFields]); + NSString* toolTip = + l10n_util::GetNSString(IDS_SEARCH_ENGINES_INVALID_TITLE_TT); + EXPECT_NSEQ(toolTip, [[controller_ nameField] toolTip]); + EXPECT_NSEQ(toolTip, [[controller_ nameImage] toolTip]); + [[controller_ nameField] setStringValue:@"Test Name"]; + EXPECT_FALSE([controller_ validateFields]); + EXPECT_EQ([controller_ goodImage], [[controller_ nameImage] image]); + EXPECT_FALSE([[controller_ nameField] toolTip]); + EXPECT_FALSE([[controller_ nameImage] toolTip]); + EXPECT_FALSE([[controller_ doneButton] isEnabled]); +} + +// The keyword field is not valid if it is empty. +TEST_F(EditSearchEngineControllerTest, ValidateKeyword) { + EXPECT_TRUE([controller_ window]); // Force window load. + + EXPECT_EQ([controller_ badImage], [[controller_ keywordImage] image]); + EXPECT_FALSE([controller_ validateFields]); + EXPECT_TRUE([[controller_ keywordField] toolTip]); + EXPECT_TRUE([[controller_ keywordImage] toolTip]); + [[controller_ keywordField] setStringValue:@"foobar"]; + EXPECT_FALSE([controller_ validateFields]); + EXPECT_EQ([controller_ goodImage], [[controller_ keywordImage] image]); + EXPECT_FALSE([[controller_ keywordField] toolTip]); + EXPECT_FALSE([[controller_ keywordImage] toolTip]); + EXPECT_FALSE([[controller_ doneButton] isEnabled]); +} + +// Tests that the URL field validates. +TEST_F(EditSearchEngineControllerTest, ValidateURL) { + EXPECT_TRUE([controller_ window]); // Force window to load. + + EXPECT_EQ([controller_ badImage], [[controller_ urlImage] image]); + EXPECT_FALSE([controller_ validateFields]); + NSString* toolTip = + l10n_util::GetNSString(IDS_SEARCH_ENGINES_INVALID_URL_TT); + EXPECT_NSEQ(toolTip, [[controller_ urlField] toolTip]); + EXPECT_NSEQ(toolTip, [[controller_ urlImage] toolTip]); + [[controller_ urlField] setStringValue:@"http://foo-bar.com"]; + EXPECT_FALSE([controller_ validateFields]); + EXPECT_EQ([controller_ goodImage], [[controller_ urlImage] image]); + EXPECT_FALSE([[controller_ urlField] toolTip]); + EXPECT_FALSE([[controller_ urlImage] toolTip]); + EXPECT_FALSE([[controller_ doneButton] isEnabled]); +} + +// Tests that if the user enters all valid data that the UI reflects that +// and that they can save. +TEST_F(EditSearchEngineControllerTest, ValidateFields) { + EXPECT_TRUE([controller_ window]); // Force window to load. + + // State before entering data. + EXPECT_EQ([controller_ badImage], [[controller_ nameImage] image]); + EXPECT_EQ([controller_ badImage], [[controller_ keywordImage] image]); + EXPECT_EQ([controller_ badImage], [[controller_ urlImage] image]); + EXPECT_FALSE([[controller_ doneButton] isEnabled]); + EXPECT_FALSE([controller_ validateFields]); + + [[controller_ nameField] setStringValue:@"Test Name"]; + EXPECT_FALSE([controller_ validateFields]); + EXPECT_EQ([controller_ goodImage], [[controller_ nameImage] image]); + EXPECT_FALSE([[controller_ doneButton] isEnabled]); + + [[controller_ keywordField] setStringValue:@"foobar"]; + EXPECT_FALSE([controller_ validateFields]); + EXPECT_EQ([controller_ goodImage], [[controller_ keywordImage] image]); + EXPECT_FALSE([[controller_ doneButton] isEnabled]); + + // Once the URL is entered, we should have all 3 valid fields. + [[controller_ urlField] setStringValue:@"http://foo-bar.com"]; + EXPECT_TRUE([controller_ validateFields]); + EXPECT_EQ([controller_ goodImage], [[controller_ urlImage] image]); + EXPECT_TRUE([[controller_ doneButton] isEnabled]); +} + +// Tests editing an existing TemplateURL. +TEST_F(EditSearchEngineControllerTest, EditTemplateURL) { + TemplateURL url; + url.set_short_name(L"Foobar"); + url.set_keyword(L"keyword"); + std::string urlString = TemplateURLRef::DisplayURLToURLRef( + L"http://foo-bar.com"); + url.SetURL(urlString, 0, 1); + TestingProfile* profile = browser_helper_.profile(); + FakeEditSearchEngineController *controller = + [[FakeEditSearchEngineController alloc] initWithProfile:profile + delegate:nil + templateURL:&url]; + EXPECT_TRUE([controller window]); + NSString* title = l10n_util::GetNSString( + IDS_SEARCH_ENGINES_EDITOR_EDIT_WINDOW_TITLE); + EXPECT_NSEQ(title, [[controller window] title]); + NSString* nameString = [[controller nameField] stringValue]; + EXPECT_NSEQ(@"Foobar", nameString); + NSString* keywordString = [[controller keywordField] stringValue]; + EXPECT_NSEQ(@"keyword", keywordString); + NSString* urlValueString = [[controller urlField] stringValue]; + EXPECT_NSEQ(@"http://foo-bar.com", urlValueString); + EXPECT_TRUE([controller validateFields]); + [controller close]; +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/encoding_menu_controller_delegate_mac.h b/chrome/browser/ui/cocoa/encoding_menu_controller_delegate_mac.h new file mode 100644 index 0000000..989105c --- /dev/null +++ b/chrome/browser/ui/cocoa/encoding_menu_controller_delegate_mac.h @@ -0,0 +1,24 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_ENCODING_MENU_CONTROLLER_DELEGATE_MAC_H_ +#define CHROME_BROWSER_UI_COCOA_ENCODING_MENU_CONTROLLER_DELEGATE_MAC_H_ +#pragma once + +#include "base/basictypes.h" // For DISALLOW_IMPLICIT_CONSTRUCTORS + +@class NSMenu; +class Profile; + +// The Windows version of this class manages the Encoding Menu, but since Cocoa +// does that for us automagically, the only thing left to do is construct +// the encoding menu. +class EncodingMenuControllerDelegate { + public: + static void BuildEncodingMenu(Profile *profile, NSMenu* encoding_menu); + private: + DISALLOW_IMPLICIT_CONSTRUCTORS(EncodingMenuControllerDelegate); +}; + +#endif // CHROME_BROWSER_UI_COCOA_ENCODING_MENU_CONTROLLER_DELEGATE_MAC_H_ diff --git a/chrome/browser/ui/cocoa/encoding_menu_controller_delegate_mac.mm b/chrome/browser/ui/cocoa/encoding_menu_controller_delegate_mac.mm new file mode 100644 index 0000000..9045540 --- /dev/null +++ b/chrome/browser/ui/cocoa/encoding_menu_controller_delegate_mac.mm @@ -0,0 +1,60 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/encoding_menu_controller_delegate_mac.h" + +#import <Cocoa/Cocoa.h> + +#include "base/string16.h" +#include "base/sys_string_conversions.h" +#include "chrome/app/chrome_command_ids.h" +#include "chrome/browser/encoding_menu_controller.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/ui/browser.h" + +namespace { + +void AddSeparatorToMenu(NSMenu *parent_menu) { + NSMenuItem* separator = [NSMenuItem separatorItem]; + [parent_menu addItem:separator]; +} + +void AppendMenuItem(NSMenu *parent_menu, int tag, NSString *title) { + + NSMenuItem* item = [[[NSMenuItem alloc] initWithTitle:title + action:nil + keyEquivalent:@""] autorelease]; + [parent_menu addItem:item]; + [item setAction:@selector(commandDispatch:)]; + [item setTag:tag]; +} + +} // namespace + +// static +void EncodingMenuControllerDelegate::BuildEncodingMenu(Profile *profile, + NSMenu* encoding_menu) { + DCHECK(profile); + + typedef EncodingMenuController::EncodingMenuItemList EncodingMenuItemList; + EncodingMenuItemList menuItems; + EncodingMenuController controller; + controller.GetEncodingMenuItems(profile, &menuItems); + + for (EncodingMenuItemList::iterator it = menuItems.begin(); + it != menuItems.end(); + ++it) { + int item_id = it->first; + string16 &localized_title_string16 = it->second; + + if (item_id == 0) { + AddSeparatorToMenu(encoding_menu); + } else { + NSString *localized_title = + base::SysUTF16ToNSString(localized_title_string16); + AppendMenuItem(encoding_menu, item_id, localized_title); + } + } + +} diff --git a/chrome/browser/ui/cocoa/event_utils.h b/chrome/browser/ui/cocoa/event_utils.h new file mode 100644 index 0000000..a8f24f8 --- /dev/null +++ b/chrome/browser/ui/cocoa/event_utils.h @@ -0,0 +1,30 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_EVENT_UTILS_H_ +#define CHROME_BROWSER_UI_COCOA_EVENT_UTILS_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "webkit/glue/window_open_disposition.h" + +namespace event_utils { + +// Retrieves the WindowOpenDisposition used to open a link from a user gesture +// represented by |event|. For example, a Cmd+Click would mean open the +// associated link in a background tab. +WindowOpenDisposition WindowOpenDispositionFromNSEvent(NSEvent* event); + +// Retrieves the WindowOpenDisposition used to open a link from a user gesture +// represented by |event|, but instead use the modifier flags given by |flags|, +// which is the same format as |-NSEvent modifierFlags|. This allows +// substitution of the modifiers without having to create a new event from +// scratch. +WindowOpenDisposition WindowOpenDispositionFromNSEventWithFlags( + NSEvent* event, NSUInteger flags); + +} // namespace event_utils + +#endif // CHROME_BROWSER_UI_COCOA_EVENT_UTILS_H_ diff --git a/chrome/browser/ui/cocoa/event_utils.mm b/chrome/browser/ui/cocoa/event_utils.mm new file mode 100644 index 0000000..ec475b5 --- /dev/null +++ b/chrome/browser/ui/cocoa/event_utils.mm @@ -0,0 +1,21 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/event_utils.h" + +namespace event_utils { + +WindowOpenDisposition WindowOpenDispositionFromNSEvent(NSEvent* event) { + NSUInteger modifiers = [event modifierFlags]; + return WindowOpenDispositionFromNSEventWithFlags(event, modifiers); +} + +WindowOpenDisposition WindowOpenDispositionFromNSEventWithFlags( + NSEvent* event, NSUInteger flags) { + if ([event buttonNumber] == 2 || flags & NSCommandKeyMask) + return flags & NSShiftKeyMask ? NEW_FOREGROUND_TAB : NEW_BACKGROUND_TAB; + return flags & NSShiftKeyMask ? NEW_WINDOW : CURRENT_TAB; +} + +} // namespace event_utils diff --git a/chrome/browser/ui/cocoa/event_utils_unittest.mm b/chrome/browser/ui/cocoa/event_utils_unittest.mm new file mode 100644 index 0000000..6f0b7fe --- /dev/null +++ b/chrome/browser/ui/cocoa/event_utils_unittest.mm @@ -0,0 +1,61 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <objc/objc-class.h> + +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "chrome/browser/ui/cocoa/event_utils.h" +#include "chrome/browser/ui/cocoa/test_event_utils.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +// We provide a donor class with a specially modified |modifierFlags| +// implementation that we swap with NSEvent's. This is because we can't create a +// NSEvent that represents a middle click with modifiers. +@interface TestEvent : NSObject +@end +@implementation TestEvent +- (NSUInteger)modifierFlags { return NSShiftKeyMask; } +@end + +namespace { + +class EventUtilsTest : public CocoaTest { +}; + +TEST_F(EventUtilsTest, TestWindowOpenDispositionFromNSEvent) { + // Left Click = same tab. + NSEvent* me = test_event_utils::MakeMouseEvent(NSLeftMouseUp, 0); + EXPECT_EQ(CURRENT_TAB, event_utils::WindowOpenDispositionFromNSEvent(me)); + + // Middle Click = new background tab. + me = test_event_utils::MakeMouseEvent(NSOtherMouseUp, 0); + EXPECT_EQ(NEW_BACKGROUND_TAB, + event_utils::WindowOpenDispositionFromNSEvent(me)); + + // Shift+Middle Click = new foreground tab. + { + ScopedClassSwizzler swizzler([NSEvent class], [TestEvent class], + @selector(modifierFlags)); + me = test_event_utils::MakeMouseEvent(NSOtherMouseUp, NSShiftKeyMask); + EXPECT_EQ(NEW_FOREGROUND_TAB, + event_utils::WindowOpenDispositionFromNSEvent(me)); + } + + // Cmd+Left Click = new background tab. + me = test_event_utils::MakeMouseEvent(NSLeftMouseUp, NSCommandKeyMask); + EXPECT_EQ(NEW_BACKGROUND_TAB, + event_utils::WindowOpenDispositionFromNSEvent(me)); + + // Cmd+Shift+Left Click = new foreground tab. + me = test_event_utils::MakeMouseEvent(NSLeftMouseUp, NSCommandKeyMask | NSShiftKeyMask); + EXPECT_EQ(NEW_FOREGROUND_TAB, + event_utils::WindowOpenDispositionFromNSEvent(me)); + + // Shift+Left Click = new window + me = test_event_utils::MakeMouseEvent(NSLeftMouseUp, NSShiftKeyMask); + EXPECT_EQ(NEW_WINDOW, event_utils::WindowOpenDispositionFromNSEvent(me)); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/extension_install_prompt.mm b/chrome/browser/ui/cocoa/extension_install_prompt.mm new file mode 100644 index 0000000..3306806 --- /dev/null +++ b/chrome/browser/ui/cocoa/extension_install_prompt.mm @@ -0,0 +1,51 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include <string> + +#include "app/l10n_util_mac.h" +#include "app/resource_bundle.h" +#include "base/sys_string_conversions.h" +#include "base/utf_string_conversions.h" +#include "chrome/browser/extensions/extension_install_ui.h" +#include "chrome/common/extensions/extension.h" +#include "grit/browser_resources.h" +#include "grit/chromium_strings.h" +#include "grit/generated_resources.h" +#include "skia/ext/skia_utils_mac.h" + +class Profile; + +void ExtensionInstallUI::ShowExtensionInstallUIPromptImpl( + Profile* profile, + Delegate* delegate, + const Extension* extension, + SkBitmap* icon, + ExtensionInstallUI::PromptType type) { + NSAlert* alert = [[[NSAlert alloc] init] autorelease]; + + NSButton* continueButton = [alert addButtonWithTitle:l10n_util::GetNSString( + ExtensionInstallUI::kButtonIds[type])]; + // Clear the key equivalent (currently 'Return') because cancel is the default + // button. + [continueButton setKeyEquivalent:@""]; + + NSButton* cancelButton = [alert addButtonWithTitle:l10n_util::GetNSString( + IDS_CANCEL)]; + [cancelButton setKeyEquivalent:@"\r"]; + + [alert setMessageText:l10n_util::GetNSStringF( + ExtensionInstallUI::kHeadingIds[type], + UTF8ToUTF16(extension->name()))]; + [alert setAlertStyle:NSWarningAlertStyle]; + [alert setIcon:gfx::SkBitmapToNSImage(*icon)]; + + if ([alert runModal] == NSAlertFirstButtonReturn) { + delegate->InstallUIProceed(); + } else { + delegate->InstallUIAbort(); + } +} diff --git a/chrome/browser/ui/cocoa/extension_installed_bubble_bridge.h b/chrome/browser/ui/cocoa/extension_installed_bubble_bridge.h new file mode 100644 index 0000000..62d72c3 --- /dev/null +++ b/chrome/browser/ui/cocoa/extension_installed_bubble_bridge.h @@ -0,0 +1,28 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// C++ bridge function to connect ExtensionInstallUI to the Cocoa-based +// extension installed bubble. + +#ifndef CHROME_BROWSER_UI_COCOA_EXTENSION_INSTALLED_BUBBLE_BRIDGE_H_ +#define CHROME_BROWSER_UI_COCOA_EXTENSION_INSTALLED_BUBBLE_BRIDGE_H_ +#pragma once + +#include "gfx/native_widget_types.h" +#include "third_party/skia/include/core/SkBitmap.h" + +class Browser; +class Extension; + +namespace ExtensionInstalledBubbleCocoa { + +// This function is called by the ExtensionInstallUI when an extension has been +// installed. +void ShowExtensionInstalledBubble(gfx::NativeWindow window, + const Extension* extension, + Browser* browser, + SkBitmap icon); +} + +#endif // CHROME_BROWSER_UI_COCOA_EXTENSION_INSTALLED_BUBBLE_BRIDGE_H_ diff --git a/chrome/browser/ui/cocoa/extension_installed_bubble_bridge.mm b/chrome/browser/ui/cocoa/extension_installed_bubble_bridge.mm new file mode 100644 index 0000000..d439899 --- /dev/null +++ b/chrome/browser/ui/cocoa/extension_installed_bubble_bridge.mm @@ -0,0 +1,25 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#import "extension_installed_bubble_bridge.h" + +#import "chrome/browser/ui/cocoa/extension_installed_bubble_controller.h" +#include "chrome/browser/ui/browser.h" +#include "chrome/common/extensions/extension.h" + +void ExtensionInstalledBubbleCocoa::ShowExtensionInstalledBubble( + gfx::NativeWindow window, + const Extension* extension, + Browser* browser, + SkBitmap icon) { + // The controller is deallocated when the window is closed, so no need to + // worry about it here. + [[ExtensionInstalledBubbleController alloc] + initWithParentWindow:window + extension:extension + browser:browser + icon:icon]; +} diff --git a/chrome/browser/ui/cocoa/extension_installed_bubble_controller.h b/chrome/browser/ui/cocoa/extension_installed_bubble_controller.h new file mode 100644 index 0000000..3178d47 --- /dev/null +++ b/chrome/browser/ui/cocoa/extension_installed_bubble_controller.h @@ -0,0 +1,112 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_EXTENSION_INSTALLED_BUBBLE_CONTROLLER_H_ +#define CHROME_BROWSER_UI_COCOA_EXTENSION_INSTALLED_BUBBLE_CONTROLLER_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#import "base/cocoa_protocols_mac.h" +#include "base/scoped_ptr.h" +#import "chrome/browser/ui/cocoa/browser_window_controller.h" +#include "third_party/skia/include/core/SkBitmap.h" + +class Browser; +class Extension; +class ExtensionLoadedNotificationObserver; +@class HoverCloseButton; +@class InfoBubbleView; + +namespace extension_installed_bubble { + +// Maximum height or width of extension's icon (corresponds to Windows & GTK). +const int kIconSize = 43; + +// Outer vertical margin for text, icon, and closing x. +const int kOuterVerticalMargin = 15; + +// Inner vertical margin for text messages. +const int kInnerVerticalMargin = 10; + +// We use a different kind of notification for each of these extension types. +typedef enum { + kBrowserAction, + kGeneric, + kOmniboxKeyword, + kPageAction +} ExtensionType; + +} + +// Controller for the extension installed bubble. This bubble pops up after +// an extension has been installed to inform the user that the install happened +// properly, and to let the user know how to manage this extension in the +// future. +@interface ExtensionInstalledBubbleController : + NSWindowController<NSWindowDelegate> { + @private + NSWindow* parentWindow_; // weak + const Extension* extension_; // weak + Browser* browser_; // weak + scoped_nsobject<NSImage> icon_; + + extension_installed_bubble::ExtensionType type_; + + // We need to remove the page action immediately when the browser window + // closes while this bubble is still open, so the bubble's closing animation + // doesn't overlap browser destruction. + BOOL pageActionRemoved_; + + // Lets us register for EXTENSION_LOADED notifications. The actual + // notifications are sent to the observer object, which proxies them + // back to the controller. + scoped_ptr<ExtensionLoadedNotificationObserver> extensionObserver_; + + // References below are weak, being obtained from the nib. + IBOutlet InfoBubbleView* infoBubbleView_; + IBOutlet HoverCloseButton* closeButton_; + IBOutlet NSImageView* iconImage_; + IBOutlet NSTextField* extensionInstalledMsg_; + // Only shown for page actions and omnibox keywords. + IBOutlet NSTextField* extraInfoMsg_; + IBOutlet NSTextField* extensionInstalledInfoMsg_; +} + +@property (nonatomic, readonly) const Extension* extension; +@property (nonatomic) BOOL pageActionRemoved; + +// Initialize the window, and then create observers to wait for the extension +// to complete loading, or the browser window to close. +- (id)initWithParentWindow:(NSWindow*)parentWindow + extension:(const Extension*)extension + browser:(Browser*)browser + icon:(SkBitmap)icon; + +// Action for close button. +- (IBAction)closeWindow:(id)sender; + +// Displays the extension installed bubble. This callback is triggered by +// the extensionObserver when the extension has completed loading. +- (void)showWindow:(id)sender; + +// Clears our weak pointer to the Extension. This callback is triggered by +// the extensionObserver when the extension is unloaded. +- (void)extensionUnloaded:(id)sender; + +@end + +@interface ExtensionInstalledBubbleController(ExposedForTesting) + +- (void)removePageActionPreviewIfNecessary; +- (NSWindow*)initializeWindow; +- (int)calculateWindowHeight; +- (void)setMessageFrames:(int)newWindowHeight; +- (NSRect)getExtensionInstalledMsgFrame; +- (NSRect)getExtraInfoMsgFrame; +- (NSRect)getExtensionInstalledInfoMsgFrame; + +@end // ExtensionInstalledBubbleController(ExposedForTesting) + +#endif // CHROME_BROWSER_UI_COCOA_EXTENSION_INSTALLED_BUBBLE_CONTROLLER_H_ diff --git a/chrome/browser/ui/cocoa/extension_installed_bubble_controller.mm b/chrome/browser/ui/cocoa/extension_installed_bubble_controller.mm new file mode 100644 index 0000000..1744fb7 --- /dev/null +++ b/chrome/browser/ui/cocoa/extension_installed_bubble_controller.mm @@ -0,0 +1,374 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "extension_installed_bubble_controller.h" + +#include "app/l10n_util.h" +#include "base/mac_util.h" +#include "base/sys_string_conversions.h" +#include "base/utf_string_conversions.h" +#include "chrome/browser/ui/cocoa/browser_window_cocoa.h" +#include "chrome/browser/ui/cocoa/browser_window_controller.h" +#include "chrome/browser/ui/cocoa/extensions/browser_actions_controller.h" +#include "chrome/browser/ui/cocoa/hover_close_button.h" +#include "chrome/browser/ui/cocoa/info_bubble_view.h" +#include "chrome/browser/ui/cocoa/location_bar/location_bar_view_mac.h" +#include "chrome/browser/ui/cocoa/toolbar_controller.h" +#include "chrome/browser/ui/browser.h" +#include "chrome/browser/ui/browser_window.h" +#include "chrome/common/extensions/extension.h" +#include "chrome/common/extensions/extension_action.h" +#include "chrome/common/notification_registrar.h" +#include "chrome/common/notification_service.h" +#include "grit/generated_resources.h" +#import "skia/ext/skia_utils_mac.h" +#import "third_party/GTM/AppKit/GTMUILocalizerAndLayoutTweaker.h" + + +// C++ class that receives EXTENSION_LOADED notifications and proxies them back +// to |controller|. +class ExtensionLoadedNotificationObserver : public NotificationObserver { + public: + ExtensionLoadedNotificationObserver( + ExtensionInstalledBubbleController* controller, Profile* profile) + : controller_(controller) { + registrar_.Add(this, NotificationType::EXTENSION_LOADED, + Source<Profile>(profile)); + registrar_.Add(this, NotificationType::EXTENSION_UNLOADED, + Source<Profile>(profile)); + } + + private: + // NotificationObserver implementation. Tells the controller to start showing + // its window on the main thread when the extension has finished loading. + void Observe(NotificationType type, + const NotificationSource& source, + const NotificationDetails& details) { + if (type == NotificationType::EXTENSION_LOADED) { + const Extension* extension = Details<const Extension>(details).ptr(); + if (extension == [controller_ extension]) { + [controller_ performSelectorOnMainThread:@selector(showWindow:) + withObject:controller_ + waitUntilDone:NO]; + } + } else if (type == NotificationType::EXTENSION_UNLOADED) { + const Extension* extension = Details<const Extension>(details).ptr(); + if (extension == [controller_ extension]) { + [controller_ performSelectorOnMainThread:@selector(extensionUnloaded:) + withObject:controller_ + waitUntilDone:NO]; + } + } else { + NOTREACHED() << "Received unexpected notification."; + } + } + + NotificationRegistrar registrar_; + ExtensionInstalledBubbleController* controller_; // weak, owns us +}; + +@implementation ExtensionInstalledBubbleController + +@synthesize extension = extension_; +@synthesize pageActionRemoved = pageActionRemoved_; // Exposed for unit test. + +- (id)initWithParentWindow:(NSWindow*)parentWindow + extension:(const Extension*)extension + browser:(Browser*)browser + icon:(SkBitmap)icon { + NSString* nibPath = + [mac_util::MainAppBundle() pathForResource:@"ExtensionInstalledBubble" + ofType:@"nib"]; + if ((self = [super initWithWindowNibPath:nibPath owner:self])) { + DCHECK(parentWindow); + parentWindow_ = parentWindow; + DCHECK(extension); + extension_ = extension; + DCHECK(browser); + browser_ = browser; + icon_.reset([gfx::SkBitmapToNSImage(icon) retain]); + pageActionRemoved_ = NO; + + if (!extension->omnibox_keyword().empty()) { + type_ = extension_installed_bubble::kOmniboxKeyword; + } else if (extension->browser_action()) { + type_ = extension_installed_bubble::kBrowserAction; + } else if (extension->page_action() && + !extension->page_action()->default_icon_path().empty()) { + type_ = extension_installed_bubble::kPageAction; + } else { + NOTREACHED(); // kGeneric installs handled in the extension_install_ui. + } + + // Start showing window only after extension has fully loaded. + extensionObserver_.reset(new ExtensionLoadedNotificationObserver( + self, browser->profile())); + } + return self; +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [super dealloc]; +} + +- (void)close { + [parentWindow_ removeChildWindow:[self window]]; + [super close]; +} + +- (void)windowWillClose:(NSNotification*)notification { + // Turn off page action icon preview when the window closes, unless we + // already removed it when the window resigned key status. + [self removePageActionPreviewIfNecessary]; + extension_ = NULL; + browser_ = NULL; + parentWindow_ = nil; + // We caught a close so we don't need to watch for the parent closing. + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [self autorelease]; +} + +// The controller is the delegate of the window, so it receives "did resign +// key" notifications. When key is resigned, close the window. +- (void)windowDidResignKey:(NSNotification*)notification { + NSWindow* window = [self window]; + DCHECK_EQ([notification object], window); + DCHECK([window isVisible]); + + // If the browser window is closing, we need to remove the page action + // immediately, otherwise the closing animation may overlap with + // browser destruction. + [self removePageActionPreviewIfNecessary]; + [self close]; +} + +- (IBAction)closeWindow:(id)sender { + DCHECK([[self window] isVisible]); + [self close]; +} + +// Extracted to a function here so that it can be overwritten for unit +// testing. +- (void)removePageActionPreviewIfNecessary { + if (!extension_ || !extension_->page_action() || pageActionRemoved_) + return; + pageActionRemoved_ = YES; + + BrowserWindowCocoa* window = + static_cast<BrowserWindowCocoa*>(browser_->window()); + LocationBarViewMac* locationBarView = + [window->cocoa_controller() locationBarBridge]; + locationBarView->SetPreviewEnabledPageAction(extension_->page_action(), + false); // disables preview. +} + +// The extension installed bubble points at the browser action icon or the +// page action icon (shown as a preview), depending on the extension type. +// We need to calculate the location of these icons and the size of the +// message itself (which varies with the title of the extension) in order +// to figure out the origin point for the extension installed bubble. +// TODO(mirandac): add framework to easily test extension UI components! +- (NSPoint)calculateArrowPoint { + BrowserWindowCocoa* window = + static_cast<BrowserWindowCocoa*>(browser_->window()); + NSPoint arrowPoint = NSZeroPoint; + + switch(type_) { + case extension_installed_bubble::kOmniboxKeyword: { + LocationBarViewMac* locationBarView = + [window->cocoa_controller() locationBarBridge]; + arrowPoint = locationBarView->GetPageInfoBubblePoint(); + break; + } + case extension_installed_bubble::kBrowserAction: { + BrowserActionsController* controller = + [[window->cocoa_controller() toolbarController] + browserActionsController]; + arrowPoint = [controller popupPointForBrowserAction:extension_]; + break; + } + case extension_installed_bubble::kPageAction: { + LocationBarViewMac* locationBarView = + [window->cocoa_controller() locationBarBridge]; + + // Tell the location bar to show a preview of the page action icon, which + // would ordinarily only be displayed on a page of the appropriate type. + // We remove this preview when the extension installed bubble closes. + locationBarView->SetPreviewEnabledPageAction(extension_->page_action(), + true); + + // Find the center of the bottom of the page action icon. + arrowPoint = + locationBarView->GetPageActionBubblePoint(extension_->page_action()); + break; + } + default: { + NOTREACHED() << "Generic extension type not allowed in install bubble."; + } + } + return arrowPoint; +} + +// We want this to be a child of a browser window. addChildWindow: +// (called from this function) will bring the window on-screen; +// unfortunately, [NSWindowController showWindow:] will also bring it +// on-screen (but will cause unexpected changes to the window's +// position). We cannot have an addChildWindow: and a subsequent +// showWindow:. Thus, we have our own version. +- (void)showWindow:(id)sender { + // Generic extensions get an infobar rather than a bubble. + DCHECK(type_ != extension_installed_bubble::kGeneric); + DCHECK(BrowserThread::CurrentlyOn(BrowserThread::UI)); + + // Load nib and calculate height based on messages to be shown. + NSWindow* window = [self initializeWindow]; + int newWindowHeight = [self calculateWindowHeight]; + [infoBubbleView_ setFrameSize:NSMakeSize( + NSWidth([[window contentView] bounds]), newWindowHeight)]; + NSSize windowDelta = NSMakeSize( + 0, newWindowHeight - NSHeight([[window contentView] bounds])); + windowDelta = [[window contentView] convertSize:windowDelta toView:nil]; + NSRect newFrame = [window frame]; + newFrame.size.height += windowDelta.height; + [window setFrame:newFrame display:NO]; + + // Now that we have resized the window, adjust y pos of the messages. + [self setMessageFrames:newWindowHeight]; + + // Find window origin, taking into account bubble size and arrow location. + NSPoint origin = + [parentWindow_ convertBaseToScreen:[self calculateArrowPoint]]; + NSSize offsets = NSMakeSize(info_bubble::kBubbleArrowXOffset + + info_bubble::kBubbleArrowWidth / 2.0, 0); + offsets = [[window contentView] convertSize:offsets toView:nil]; + if ([infoBubbleView_ arrowLocation] == info_bubble::kTopRight) + origin.x -= NSWidth([window frame]) - offsets.width; + origin.y -= NSHeight([window frame]); + [window setFrameOrigin:origin]; + + [parentWindow_ addChildWindow:window + ordered:NSWindowAbove]; + [window makeKeyAndOrderFront:self]; +} + +// Finish nib loading, set arrow location and load icon into window. This +// function is exposed for unit testing. +- (NSWindow*)initializeWindow { + NSWindow* window = [self window]; // completes nib load + + if (type_ == extension_installed_bubble::kOmniboxKeyword) { + [infoBubbleView_ setArrowLocation:info_bubble::kTopLeft]; + } else { + [infoBubbleView_ setArrowLocation:info_bubble::kTopRight]; + } + + // Set appropriate icon, resizing if necessary. + if ([icon_ size].width > extension_installed_bubble::kIconSize) { + [icon_ setSize:NSMakeSize(extension_installed_bubble::kIconSize, + extension_installed_bubble::kIconSize)]; + } + [iconImage_ setImage:icon_]; + [iconImage_ setNeedsDisplay:YES]; + return window; + } + +// Calculate the height of each install message, resizing messages in their +// frames to fit window width. Return the new window height, based on the +// total of all message heights. +- (int)calculateWindowHeight { + // Adjust the window height to reflect the sum height of all messages + // and vertical padding. + int newWindowHeight = 2 * extension_installed_bubble::kOuterVerticalMargin; + + // First part of extension installed message. + [extensionInstalledMsg_ setStringValue:l10n_util::GetNSStringF( + IDS_EXTENSION_INSTALLED_HEADING, UTF8ToUTF16(extension_->name()))]; + [GTMUILocalizerAndLayoutTweaker + sizeToFitFixedWidthTextField:extensionInstalledMsg_]; + newWindowHeight += [extensionInstalledMsg_ frame].size.height + + extension_installed_bubble::kInnerVerticalMargin; + + // If type is page action, include a special message about page actions. + if (type_ == extension_installed_bubble::kPageAction) { + [extraInfoMsg_ setHidden:NO]; + [[extraInfoMsg_ cell] + setFont:[NSFont systemFontOfSize:[NSFont smallSystemFontSize]]]; + [GTMUILocalizerAndLayoutTweaker + sizeToFitFixedWidthTextField:extraInfoMsg_]; + newWindowHeight += [extraInfoMsg_ frame].size.height + + extension_installed_bubble::kInnerVerticalMargin; + } + + // If type is omnibox keyword, include a special message about the keyword. + if (type_ == extension_installed_bubble::kOmniboxKeyword) { + [extraInfoMsg_ setStringValue:l10n_util::GetNSStringF( + IDS_EXTENSION_INSTALLED_OMNIBOX_KEYWORD_INFO, + UTF8ToUTF16(extension_->omnibox_keyword()))]; + [extraInfoMsg_ setHidden:NO]; + [[extraInfoMsg_ cell] + setFont:[NSFont systemFontOfSize:[NSFont smallSystemFontSize]]]; + [GTMUILocalizerAndLayoutTweaker + sizeToFitFixedWidthTextField:extraInfoMsg_]; + newWindowHeight += [extraInfoMsg_ frame].size.height + + extension_installed_bubble::kInnerVerticalMargin; + } + + // Second part of extension installed message. + [[extensionInstalledInfoMsg_ cell] + setFont:[NSFont systemFontOfSize:[NSFont smallSystemFontSize]]]; + [GTMUILocalizerAndLayoutTweaker + sizeToFitFixedWidthTextField:extensionInstalledInfoMsg_]; + newWindowHeight += [extensionInstalledInfoMsg_ frame].size.height; + + return newWindowHeight; +} + +// Adjust y-position of messages to sit properly in new window height. +- (void)setMessageFrames:(int)newWindowHeight { + // The extension messages will always be shown. + NSRect extensionMessageFrame1 = [extensionInstalledMsg_ frame]; + NSRect extensionMessageFrame2 = [extensionInstalledInfoMsg_ frame]; + + extensionMessageFrame1.origin.y = newWindowHeight - ( + extensionMessageFrame1.size.height + + extension_installed_bubble::kOuterVerticalMargin); + [extensionInstalledMsg_ setFrame:extensionMessageFrame1]; + if (type_ == extension_installed_bubble::kPageAction || + type_ == extension_installed_bubble::kOmniboxKeyword) { + // The extra message is only shown when appropriate. + NSRect extraMessageFrame = [extraInfoMsg_ frame]; + extraMessageFrame.origin.y = extensionMessageFrame1.origin.y - ( + extraMessageFrame.size.height + + extension_installed_bubble::kInnerVerticalMargin); + [extraInfoMsg_ setFrame:extraMessageFrame]; + extensionMessageFrame2.origin.y = extraMessageFrame.origin.y - ( + extensionMessageFrame2.size.height + + extension_installed_bubble::kInnerVerticalMargin); + } else { + extensionMessageFrame2.origin.y = extensionMessageFrame1.origin.y - ( + extensionMessageFrame2.size.height + + extension_installed_bubble::kInnerVerticalMargin); + } + [extensionInstalledInfoMsg_ setFrame:extensionMessageFrame2]; +} + +// Exposed for unit testing. +- (NSRect)getExtensionInstalledMsgFrame { + return [extensionInstalledMsg_ frame]; +} + +- (NSRect)getExtraInfoMsgFrame { + return [extraInfoMsg_ frame]; +} + +- (NSRect)getExtensionInstalledInfoMsgFrame { + return [extensionInstalledInfoMsg_ frame]; +} + +- (void)extensionUnloaded:(id)sender { + extension_ = NULL; +} + +@end diff --git a/chrome/browser/ui/cocoa/extension_installed_bubble_controller_unittest.mm b/chrome/browser/ui/cocoa/extension_installed_bubble_controller_unittest.mm new file mode 100644 index 0000000..f1171d1 --- /dev/null +++ b/chrome/browser/ui/cocoa/extension_installed_bubble_controller_unittest.mm @@ -0,0 +1,202 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/basictypes.h" +#include "base/file_path.h" +#include "base/file_util.h" +#include "base/path_service.h" +#include "base/scoped_ptr.h" +#include "base/values.h" +#import "chrome/browser/browser_window.h" +#import "chrome/browser/ui/cocoa/browser_test_helper.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/extension_installed_bubble_controller.h" +#include "chrome/common/chrome_paths.h" +#include "chrome/common/extensions/extension.h" +#include "chrome/common/extensions/extension_constants.h" +#include "webkit/glue/image_decoder.h" + +// ExtensionInstalledBubbleController with removePageActionPreview overridden +// to a no-op, because pageActions are not yet hooked up in the test browser. +@interface ExtensionInstalledBubbleControllerForTest : + ExtensionInstalledBubbleController { +} + +// Do nothing, because browser window is not set up with page actions +// for unit testing. +- (void)removePageActionPreview; + +@end + +@implementation ExtensionInstalledBubbleControllerForTest + +- (void)removePageActionPreview { } + +@end + +namespace keys = extension_manifest_keys; + +class ExtensionInstalledBubbleControllerTest : public CocoaTest { + + public: + virtual void SetUp() { + CocoaTest::SetUp(); + browser_ = helper_.browser(); + window_ = helper_.CreateBrowserWindow()->GetNativeHandle(); + icon_ = LoadTestIcon(); + } + + virtual void TearDown() { + helper_.CloseBrowserWindow(); + CocoaTest::TearDown(); + } + + // Load test icon from extension test directory. + SkBitmap LoadTestIcon() { + FilePath path; + PathService::Get(chrome::DIR_TEST_DATA, &path); + path = path.AppendASCII("extensions").AppendASCII("icon1.png"); + + std::string file_contents; + file_util::ReadFileToString(path, &file_contents); + const unsigned char* data = + reinterpret_cast<const unsigned char*>(file_contents.data()); + + SkBitmap bitmap; + webkit_glue::ImageDecoder decoder; + bitmap = decoder.Decode(data, file_contents.length()); + + return bitmap; + } + + // Create a skeletal framework of either page action or browser action + // type. This extension only needs to have a type and a name to initialize + // the ExtensionInstalledBubble for unit testing. + scoped_refptr<Extension> CreateExtension( + extension_installed_bubble::ExtensionType type) { + FilePath path; + PathService::Get(chrome::DIR_TEST_DATA, &path); + path = path.AppendASCII("extensions").AppendASCII("dummy"); + + DictionaryValue extension_input_value; + extension_input_value.SetString(keys::kVersion, "1.0.0.0"); + if (type == extension_installed_bubble::kPageAction) { + extension_input_value.SetString(keys::kName, "page action extension"); + DictionaryValue* action = new DictionaryValue; + action->SetString(keys::kPageActionId, "ExtensionActionId"); + action->SetString(keys::kPageActionDefaultTitle, "ExtensionActionTitle"); + action->SetString(keys::kPageActionDefaultIcon, "image1.png"); + ListValue* action_list = new ListValue; + action_list->Append(action); + extension_input_value.Set(keys::kPageActions, action_list); + } else { + extension_input_value.SetString(keys::kName, "browser action extension"); + DictionaryValue* browser_action = new DictionaryValue; + // An empty dictionary is enough to create a Browser Action. + extension_input_value.Set(keys::kBrowserAction, browser_action); + } + + std::string error; + return Extension::Create( + path, Extension::INVALID, extension_input_value, false, &error); + } + + // Allows us to create the window and browser for testing. + BrowserTestHelper helper_; + + // Required to initialize the extension installed bubble. + NSWindow* window_; // weak, owned by BrowserTestHelper. + + // Required to initialize the extension installed bubble. + Browser* browser_; // weak, owned by BrowserTestHelper. + + // Skeleton extension to be tested; reinitialized for each test. + scoped_refptr<Extension> extension_; + + // The icon_ to be loaded into the bubble window. + SkBitmap icon_; +}; + +// Confirm that window sizes are set correctly for a page action extension. +TEST_F(ExtensionInstalledBubbleControllerTest, PageActionTest) { + extension_ = CreateExtension(extension_installed_bubble::kPageAction); + ExtensionInstalledBubbleControllerForTest* controller = + [[ExtensionInstalledBubbleControllerForTest alloc] + initWithParentWindow:window_ + extension:extension_.get() + browser:browser_ + icon:icon_]; + EXPECT_TRUE(controller); + + // Initialize window without having to calculate tabstrip locations. + [controller initializeWindow]; + EXPECT_TRUE([controller window]); + + int height = [controller calculateWindowHeight]; + // Height should equal the vertical padding + height of all messages. + int correctHeight = 2 * extension_installed_bubble::kOuterVerticalMargin + + 2 * extension_installed_bubble::kInnerVerticalMargin + + [controller getExtensionInstalledMsgFrame].size.height + + [controller getExtensionInstalledInfoMsgFrame].size.height + + [controller getExtraInfoMsgFrame].size.height; + EXPECT_EQ(height, correctHeight); + + [controller setMessageFrames:height]; + NSRect msg3Frame = [controller getExtensionInstalledInfoMsgFrame]; + // Bottom message should be kOuterVerticalMargin pixels above window edge. + EXPECT_EQ(msg3Frame.origin.y, + extension_installed_bubble::kOuterVerticalMargin); + NSRect msg2Frame = [controller getExtraInfoMsgFrame]; + // Pageaction message should be kInnerVerticalMargin pixels above bottom msg. + EXPECT_EQ(msg2Frame.origin.y, + msg3Frame.origin.y + msg3Frame.size.height + + extension_installed_bubble::kInnerVerticalMargin); + NSRect msg1Frame = [controller getExtensionInstalledMsgFrame]; + // Top message should be kInnerVerticalMargin pixels above Pageaction msg. + EXPECT_EQ(msg1Frame.origin.y, + msg2Frame.origin.y + msg2Frame.size.height + + extension_installed_bubble::kInnerVerticalMargin); + + [controller setPageActionRemoved:YES]; + [controller close]; +} + +TEST_F(ExtensionInstalledBubbleControllerTest, BrowserActionTest) { + extension_ = CreateExtension(extension_installed_bubble::kBrowserAction); + ExtensionInstalledBubbleControllerForTest* controller = + [[ExtensionInstalledBubbleControllerForTest alloc] + initWithParentWindow:window_ + extension:extension_.get() + browser:browser_ + icon:icon_]; + EXPECT_TRUE(controller); + + // Initialize window without having to calculate tabstrip locations. + [controller initializeWindow]; + EXPECT_TRUE([controller window]); + + int height = [controller calculateWindowHeight]; + // Height should equal the vertical padding + height of all messages. + int correctHeight = 2 * extension_installed_bubble::kOuterVerticalMargin + + extension_installed_bubble::kInnerVerticalMargin + + [controller getExtensionInstalledMsgFrame].size.height + + [controller getExtensionInstalledInfoMsgFrame].size.height; + EXPECT_EQ(height, correctHeight); + + [controller setMessageFrames:height]; + NSRect msg3Frame = [controller getExtensionInstalledInfoMsgFrame]; + // Bottom message should start kOuterVerticalMargin pixels above window edge. + EXPECT_EQ(msg3Frame.origin.y, + extension_installed_bubble::kOuterVerticalMargin); + NSRect msg1Frame = [controller getExtensionInstalledMsgFrame]; + // Top message should start kInnerVerticalMargin pixels above top of + // extensionInstalled message, because page action message is hidden. + EXPECT_EQ(msg1Frame.origin.y, + msg3Frame.origin.y + msg3Frame.size.height + + extension_installed_bubble::kInnerVerticalMargin); + + [controller close]; +} diff --git a/chrome/browser/ui/cocoa/extension_view_mac.h b/chrome/browser/ui/cocoa/extension_view_mac.h new file mode 100644 index 0000000..43e9058 --- /dev/null +++ b/chrome/browser/ui/cocoa/extension_view_mac.h @@ -0,0 +1,88 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_EXTENSION_VIEW_MAC_H_ +#define CHROME_BROWSER_UI_COCOA_EXTENSION_VIEW_MAC_H_ +#pragma once + +#include "base/basictypes.h" +#include "gfx/native_widget_types.h" +#include "gfx/size.h" +#include "third_party/skia/include/core/SkBitmap.h" + +class Browser; +class ExtensionHost; +class RenderViewHost; +class RenderWidgetHostViewMac; +class SkBitmap; + +// This class represents extension views. An extension view internally contains +// a bridge to an extension process, which draws to the extension view's +// native view object through IPC. +class ExtensionViewMac { + public: + ExtensionViewMac(ExtensionHost* extension_host, Browser* browser); + ~ExtensionViewMac(); + + // Starts the extension process and creates the native view. You must call + // this method before calling any of this class's other methods. + void Init(); + + // Returns the extension's native view. + gfx::NativeView native_view(); + + // Returns the browser the extension belongs to. + Browser* browser() const { return browser_; } + + // Does this extension live as a toolstrip in an extension shelf? + bool is_toolstrip() const { return is_toolstrip_; } + void set_is_toolstrip(bool is_toolstrip) { is_toolstrip_ = is_toolstrip; } + + // Sets the extensions's background image. + void SetBackground(const SkBitmap& background); + + // Method for the ExtensionHost to notify us about the correct size for + // extension contents. + void UpdatePreferredSize(const gfx::Size& new_size); + + // Method for the ExtensionHost to notify us when the RenderViewHost has a + // connection. + void RenderViewCreated(); + + // Informs the view that its containing window's frame changed. + void WindowFrameChanged(); + + // The minimum/maximum dimensions of the popup. + // The minimum is just a little larger than the size of the button itself. + // The maximum is an arbitrary number that should be smaller than most + // screens. + static const CGFloat kMinWidth; + static const CGFloat kMinHeight; + static const CGFloat kMaxWidth; + static const CGFloat kMaxHeight; + + private: + RenderViewHost* render_view_host() const; + + void CreateWidgetHostView(); + + // True if the contents are being displayed inside the extension shelf. + bool is_toolstrip_; + + Browser* browser_; // weak + + ExtensionHost* extension_host_; // weak + + // Created by us, but owned by its |native_view()|. We |release| the + // rwhv's native view in our destructor, effectively freeing this. + RenderWidgetHostViewMac* render_widget_host_view_; + + // The background the view should have once it is initialized. This is set + // when the view has a custom background, but hasn't been initialized yet. + SkBitmap pending_background_; + + DISALLOW_COPY_AND_ASSIGN(ExtensionViewMac); +}; + +#endif // CHROME_BROWSER_UI_COCOA_EXTENSION_VIEW_MAC_H_ diff --git a/chrome/browser/ui/cocoa/extension_view_mac.mm b/chrome/browser/ui/cocoa/extension_view_mac.mm new file mode 100644 index 0000000..d3a10e7 --- /dev/null +++ b/chrome/browser/ui/cocoa/extension_view_mac.mm @@ -0,0 +1,112 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/extension_view_mac.h" + +#include "chrome/browser/extensions/extension_host.h" +#include "chrome/browser/renderer_host/render_view_host.h" +#include "chrome/browser/renderer_host/render_widget_host_view_mac.h" + +// The minimum/maximum dimensions of the popup. +const CGFloat ExtensionViewMac::kMinWidth = 25.0; +const CGFloat ExtensionViewMac::kMinHeight = 25.0; +const CGFloat ExtensionViewMac::kMaxWidth = 800.0; +const CGFloat ExtensionViewMac::kMaxHeight = 600.0; + +ExtensionViewMac::ExtensionViewMac(ExtensionHost* extension_host, + Browser* browser) + : is_toolstrip_(true), + browser_(browser), + extension_host_(extension_host), + render_widget_host_view_(NULL) { + DCHECK(extension_host_); +} + +ExtensionViewMac::~ExtensionViewMac() { + if (render_widget_host_view_) + [render_widget_host_view_->native_view() release]; +} + +void ExtensionViewMac::Init() { + CreateWidgetHostView(); +} + +gfx::NativeView ExtensionViewMac::native_view() { + DCHECK(render_widget_host_view_); + return render_widget_host_view_->native_view(); +} + +RenderViewHost* ExtensionViewMac::render_view_host() const { + return extension_host_->render_view_host(); +} + +void ExtensionViewMac::SetBackground(const SkBitmap& background) { + DCHECK(render_widget_host_view_); + if (render_view_host()->IsRenderViewLive()) { + render_widget_host_view_->SetBackground(background); + } else { + pending_background_ = background; + } +} + +void ExtensionViewMac::UpdatePreferredSize(const gfx::Size& new_size) { + // TODO(thakis, erikkay): Windows does some tricks to resize the extension + // view not before it's visible. Do something similar here. + + // No need to use CA here, our caller calls us repeatedly to animate the + // resizing. + NSView* view = native_view(); + NSRect frame = [view frame]; + frame.size.width = new_size.width(); + frame.size.height = new_size.height(); + + // On first display of some extensions, this function is called with zero + // width after the correct size has been set. Bail if zero is seen, assuming + // that an extension's view doesn't want any dimensions to ever be zero. + // TODO(andybons): Verify this assumption and look into WebCore's + // |contentesPreferredWidth| to see why this is occurring. + if (NSIsEmptyRect(frame)) + return; + + DCHECK([view isKindOfClass:[RenderWidgetHostViewCocoa class]]); + RenderWidgetHostViewCocoa* hostView = (RenderWidgetHostViewCocoa*)view; + + // RenderWidgetHostViewCocoa overrides setFrame but not setFrameSize. + // We need to defer the update back to the RenderWidgetHost so we don't + // get the flickering effect on 10.5 of http://crbug.com/31970 + [hostView setFrameWithDeferredUpdate:frame]; + [hostView setNeedsDisplay:YES]; +} + +void ExtensionViewMac::RenderViewCreated() { + // Do not allow webkit to draw scroll bars on views smaller than + // the largest size view allowed. The view will be resized to make + // scroll bars unnecessary. Scroll bars change the height of the + // view, so not drawing them is necessary to avoid infinite resizing. + gfx::Size largest_popup_size( + CGSizeMake(ExtensionViewMac::kMaxWidth, ExtensionViewMac::kMaxHeight)); + extension_host_->DisableScrollbarsForSmallWindows(largest_popup_size); + + if (!pending_background_.empty() && render_view_host()->view()) { + render_widget_host_view_->SetBackground(pending_background_); + pending_background_.reset(); + } +} + +void ExtensionViewMac::WindowFrameChanged() { + if (render_widget_host_view_) + render_widget_host_view_->WindowFrameChanged(); +} + +void ExtensionViewMac::CreateWidgetHostView() { + DCHECK(!render_widget_host_view_); + render_widget_host_view_ = new RenderWidgetHostViewMac(render_view_host()); + + // The RenderWidgetHostViewMac is owned by its native view, which is created + // in an autoreleased state. retain it, so that it doesn't immediately + // disappear. + [render_widget_host_view_->native_view() retain]; + + extension_host_->CreateRenderViewSoon(render_widget_host_view_); +} diff --git a/chrome/browser/ui/cocoa/extensions/browser_action_button.h b/chrome/browser/ui/cocoa/extensions/browser_action_button.h new file mode 100644 index 0000000..a09ce7f --- /dev/null +++ b/chrome/browser/ui/cocoa/extensions/browser_action_button.h @@ -0,0 +1,98 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_EXTENSIONS_BROWSER_ACTION_BUTTON_H_ +#define CHROME_BROWSER_UI_COCOA_EXTENSIONS_BROWSER_ACTION_BUTTON_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#import "base/scoped_nsobject.h" +#include "base/scoped_ptr.h" +#import "chrome/browser/ui/cocoa/gradient_button_cell.h" + +class Extension; +class ExtensionAction; +class ExtensionImageTrackerBridge; +class Profile; + +// Fired when the Browser Action's state has changed. Usually the image needs to +// be updated. +extern NSString* const kBrowserActionButtonUpdatedNotification; + +// Fired on each drag event while the user is moving the button. +extern NSString* const kBrowserActionButtonDraggingNotification; +// Fired when the user drops the button. +extern NSString* const kBrowserActionButtonDragEndNotification; + +@interface BrowserActionButton : NSButton { + @private + // Bridge to proxy Chrome notifications to the Obj-C class as well as load the + // extension's icon. + scoped_ptr<ExtensionImageTrackerBridge> imageLoadingBridge_; + + // The default icon of the Button. + scoped_nsobject<NSImage> defaultIcon_; + + // The icon specific to the active tab. + scoped_nsobject<NSImage> tabSpecificIcon_; + + // Used to move the button and query whether a button is currently animating. + scoped_nsobject<NSViewAnimation> moveAnimation_; + + // The extension for this button. Weak. + const Extension* extension_; + + // The ID of the active tab. + int tabId_; + + // Whether the button is currently being dragged. + BOOL isBeingDragged_; + + // Drag events could be intercepted by other buttons, so to make sure that + // this is the only button moving if it ends up being dragged. This is set to + // YES upon |mouseDown:|. + BOOL dragCouldStart_; +} + +- (id)initWithFrame:(NSRect)frame + extension:(const Extension*)extension + profile:(Profile*)profile + tabId:(int)tabId; + +- (void)setFrame:(NSRect)frameRect animate:(BOOL)animate; + +- (void)setDefaultIcon:(NSImage*)image; + +- (void)setTabSpecificIcon:(NSImage*)image; + +- (void)updateState; + +- (BOOL)isAnimating; + +// Returns a pointer to an autoreleased NSImage with the badge, shadow and +// cell image drawn into it. +- (NSImage*)compositedImage; + +@property(readonly, nonatomic) BOOL isBeingDragged; +@property(readonly, nonatomic) const Extension* extension; +@property(readwrite, nonatomic) int tabId; + +@end + +@interface BrowserActionCell : GradientButtonCell { + @private + // The current tab ID used when drawing the cell. + int tabId_; + + // The action we're drawing the cell for. Weak. + ExtensionAction* extensionAction_; +} + +@property(readwrite, nonatomic) int tabId; +@property(readwrite, nonatomic) ExtensionAction* extensionAction; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_EXTENSIONS_BROWSER_ACTION_BUTTON_H_ diff --git a/chrome/browser/ui/cocoa/extensions/browser_action_button.mm b/chrome/browser/ui/cocoa/extensions/browser_action_button.mm new file mode 100644 index 0000000..14701c8 --- /dev/null +++ b/chrome/browser/ui/cocoa/extensions/browser_action_button.mm @@ -0,0 +1,333 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/extensions/browser_action_button.h" + +#include <algorithm> +#include <cmath> + +#include "base/logging.h" +#include "base/sys_string_conversions.h" +#include "chrome/browser/extensions/image_loading_tracker.h" +#include "chrome/browser/ui/cocoa/extensions/extension_action_context_menu.h" +#import "chrome/browser/ui/cocoa/image_utils.h" +#include "chrome/common/extensions/extension.h" +#include "chrome/common/extensions/extension_action.h" +#include "chrome/common/extensions/extension_resource.h" +#include "chrome/common/notification_observer.h" +#include "chrome/common/notification_registrar.h" +#include "chrome/common/notification_source.h" +#include "chrome/common/notification_type.h" +#include "gfx/canvas_skia_paint.h" +#include "gfx/rect.h" +#include "gfx/size.h" +#include "skia/ext/skia_utils_mac.h" +#import "third_party/GTM/AppKit/GTMNSAnimation+Duration.h" + +NSString* const kBrowserActionButtonUpdatedNotification = + @"BrowserActionButtonUpdatedNotification"; + +NSString* const kBrowserActionButtonDraggingNotification = + @"BrowserActionButtonDraggingNotification"; +NSString* const kBrowserActionButtonDragEndNotification = + @"BrowserActionButtonDragEndNotification"; + +static const CGFloat kBrowserActionBadgeOriginYOffset = 5; + +namespace { +const CGFloat kAnimationDuration = 0.2; +const CGFloat kShadowOffset = 2.0; +} // anonymous namespace + +// A helper class to bridge the asynchronous Skia bitmap loading mechanism to +// the extension's button. +class ExtensionImageTrackerBridge : public NotificationObserver, + public ImageLoadingTracker::Observer { + public: + ExtensionImageTrackerBridge(BrowserActionButton* owner, + const Extension* extension) + : owner_(owner), + tracker_(this) { + // The Browser Action API does not allow the default icon path to be + // changed at runtime, so we can load this now and cache it. + std::string path = extension->browser_action()->default_icon_path(); + if (!path.empty()) { + tracker_.LoadImage(extension, extension->GetResource(path), + gfx::Size(Extension::kBrowserActionIconMaxSize, + Extension::kBrowserActionIconMaxSize), + ImageLoadingTracker::DONT_CACHE); + } + registrar_.Add(this, NotificationType::EXTENSION_BROWSER_ACTION_UPDATED, + Source<ExtensionAction>(extension->browser_action())); + } + + ~ExtensionImageTrackerBridge() {} + + // ImageLoadingTracker::Observer implementation. + void OnImageLoaded(SkBitmap* image, ExtensionResource resource, int index) { + if (image) + [owner_ setDefaultIcon:gfx::SkBitmapToNSImage(*image)]; + [owner_ updateState]; + } + + // Overridden from NotificationObserver. + void Observe(NotificationType type, + const NotificationSource& source, + const NotificationDetails& details) { + if (type == NotificationType::EXTENSION_BROWSER_ACTION_UPDATED) + [owner_ updateState]; + else + NOTREACHED(); + } + + private: + // Weak. Owns us. + BrowserActionButton* owner_; + + // Loads the button's icons for us on the file thread. + ImageLoadingTracker tracker_; + + // Used for registering to receive notifications and automatic clean up. + NotificationRegistrar registrar_; + + DISALLOW_COPY_AND_ASSIGN(ExtensionImageTrackerBridge); +}; + +@interface BrowserActionCell(Internals) +- (void)setIconShadow; +- (void)drawBadgeWithinFrame:(NSRect)frame; +@end + +@interface BrowserActionButton(Private) +- (void)endDrag; +@end + +@implementation BrowserActionButton + +@synthesize isBeingDragged = isBeingDragged_; +@synthesize extension = extension_; +@synthesize tabId = tabId_; + ++ (Class)cellClass { + return [BrowserActionCell class]; +} + +- (id)initWithFrame:(NSRect)frame + extension:(const Extension*)extension + profile:(Profile*)profile + tabId:(int)tabId { + if ((self = [super initWithFrame:frame])) { + BrowserActionCell* cell = [[[BrowserActionCell alloc] init] autorelease]; + // [NSButton setCell:] warns to NOT use setCell: other than in the + // initializer of a control. However, we are using a basic + // NSButton whose initializer does not take an NSCell as an + // object. To honor the assumed semantics, we do nothing with + // NSButton between alloc/init and setCell:. + [self setCell:cell]; + [cell setTabId:tabId]; + [cell setExtensionAction:extension->browser_action()]; + + [self setTitle:@""]; + [self setButtonType:NSMomentaryChangeButton]; + [self setShowsBorderOnlyWhileMouseInside:YES]; + + [self setMenu:[[[ExtensionActionContextMenu alloc] + initWithExtension:extension + profile:profile + extensionAction:extension->browser_action()] autorelease]]; + + tabId_ = tabId; + extension_ = extension; + imageLoadingBridge_.reset(new ExtensionImageTrackerBridge(self, extension)); + + moveAnimation_.reset([[NSViewAnimation alloc] init]); + [moveAnimation_ gtm_setDuration:kAnimationDuration + eventMask:NSLeftMouseUpMask]; + [moveAnimation_ setAnimationBlockingMode:NSAnimationNonblocking]; + + [self updateState]; + } + + return self; +} + +- (BOOL)acceptsFirstResponder { + return YES; +} + +- (void)mouseDown:(NSEvent*)theEvent { + [[self cell] setHighlighted:YES]; + dragCouldStart_ = YES; +} + +- (void)mouseDragged:(NSEvent*)theEvent { + if (!dragCouldStart_) + return; + + if (!isBeingDragged_) { + // The start of a drag. Position the button above all others. + [[self superview] addSubview:self positioned:NSWindowAbove relativeTo:nil]; + } + isBeingDragged_ = YES; + NSRect buttonFrame = [self frame]; + // TODO(andybons): Constrain the buttons to be within the container. + // Clamp the button to be within its superview along the X-axis. + buttonFrame.origin.x += [theEvent deltaX]; + [self setFrame:buttonFrame]; + [self setNeedsDisplay:YES]; + [[NSNotificationCenter defaultCenter] + postNotificationName:kBrowserActionButtonDraggingNotification + object:self]; +} + +- (void)mouseUp:(NSEvent*)theEvent { + dragCouldStart_ = NO; + // There are non-drag cases where a mouseUp: may happen + // (e.g. mouse-down, cmd-tab to another application, move mouse, + // mouse-up). + NSPoint location = [self convertPoint:[theEvent locationInWindow] + fromView:nil]; + if (NSPointInRect(location, [self bounds]) && !isBeingDragged_) { + // Only perform the click if we didn't drag the button. + [self performClick:self]; + } else { + // Make sure an ESC to end a drag doesn't trigger 2 endDrags. + if (isBeingDragged_) { + [self endDrag]; + } else { + [super mouseUp:theEvent]; + } + } +} + +- (void)endDrag { + isBeingDragged_ = NO; + [[NSNotificationCenter defaultCenter] + postNotificationName:kBrowserActionButtonDragEndNotification + object:self]; + [[self cell] setHighlighted:NO]; +} + +- (void)setFrame:(NSRect)frameRect animate:(BOOL)animate { + if (!animate) { + [self setFrame:frameRect]; + } else { + if ([moveAnimation_ isAnimating]) + [moveAnimation_ stopAnimation]; + + NSDictionary* animationDictionary = + [NSDictionary dictionaryWithObjectsAndKeys: + self, NSViewAnimationTargetKey, + [NSValue valueWithRect:[self frame]], NSViewAnimationStartFrameKey, + [NSValue valueWithRect:frameRect], NSViewAnimationEndFrameKey, + nil]; + [moveAnimation_ setViewAnimations: + [NSArray arrayWithObject:animationDictionary]]; + [moveAnimation_ startAnimation]; + } +} + +- (void)setDefaultIcon:(NSImage*)image { + defaultIcon_.reset([image retain]); +} + +- (void)setTabSpecificIcon:(NSImage*)image { + tabSpecificIcon_.reset([image retain]); +} + +- (void)updateState { + if (tabId_ < 0) + return; + + std::string tooltip = extension_->browser_action()->GetTitle(tabId_); + if (tooltip.empty()) { + [self setToolTip:nil]; + } else { + [self setToolTip:base::SysUTF8ToNSString(tooltip)]; + } + + SkBitmap image = extension_->browser_action()->GetIcon(tabId_); + if (!image.isNull()) { + [self setTabSpecificIcon:gfx::SkBitmapToNSImage(image)]; + [self setImage:tabSpecificIcon_]; + } else if (defaultIcon_) { + [self setImage:defaultIcon_]; + } + + [[self cell] setTabId:tabId_]; + + [self setNeedsDisplay:YES]; + + [[NSNotificationCenter defaultCenter] + postNotificationName:kBrowserActionButtonUpdatedNotification + object:self]; +} + +- (BOOL)isAnimating { + return [moveAnimation_ isAnimating]; +} + +- (NSImage*)compositedImage { + NSRect bounds = [self bounds]; + NSImage* image = [[[NSImage alloc] initWithSize:bounds.size] autorelease]; + [image lockFocus]; + + [[NSColor clearColor] set]; + NSRectFill(bounds); + [[self cell] setIconShadow]; + + NSImage* actionImage = [self image]; + const NSSize imageSize = [actionImage size]; + const NSRect imageRect = + NSMakeRect(std::floor((NSWidth(bounds) - imageSize.width) / 2.0), + std::floor((NSHeight(bounds) - imageSize.height) / 2.0), + imageSize.width, imageSize.height); + [actionImage drawInRect:imageRect + fromRect:NSZeroRect + operation:NSCompositeSourceOver + fraction:1.0 + neverFlipped:YES]; + + bounds.origin.y += kShadowOffset - kBrowserActionBadgeOriginYOffset; + bounds.origin.x -= kShadowOffset; + [[self cell] drawBadgeWithinFrame:bounds]; + + [image unlockFocus]; + return image; +} + +@end + +@implementation BrowserActionCell + +@synthesize tabId = tabId_; +@synthesize extensionAction = extensionAction_; + +- (void)setIconShadow { + // Create the shadow below and to the right of the drawn image. + scoped_nsobject<NSShadow> imgShadow([[NSShadow alloc] init]); + [imgShadow.get() setShadowOffset:NSMakeSize(kShadowOffset, -kShadowOffset)]; + [imgShadow setShadowBlurRadius:2.0]; + [imgShadow.get() setShadowColor:[[NSColor blackColor] + colorWithAlphaComponent:0.3]]; + [imgShadow set]; +} + +- (void)drawBadgeWithinFrame:(NSRect)frame { + gfx::CanvasSkiaPaint canvas(frame, false); + canvas.set_composite_alpha(true); + gfx::Rect boundingRect(NSRectToCGRect(frame)); + extensionAction_->PaintBadge(&canvas, boundingRect, tabId_); +} + +- (void)drawInteriorWithFrame:(NSRect)cellFrame inView:(NSView*)controlView { + [NSGraphicsContext saveGraphicsState]; + [self setIconShadow]; + [super drawInteriorWithFrame:cellFrame inView:controlView]; + cellFrame.origin.y += kBrowserActionBadgeOriginYOffset; + [self drawBadgeWithinFrame:cellFrame]; + [NSGraphicsContext restoreGraphicsState]; +} + +@end diff --git a/chrome/browser/ui/cocoa/extensions/browser_actions_container_view.h b/chrome/browser/ui/cocoa/extensions/browser_actions_container_view.h new file mode 100644 index 0000000..d9ebf6e --- /dev/null +++ b/chrome/browser/ui/cocoa/extensions/browser_actions_container_view.h @@ -0,0 +1,84 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_EXTENSIONS_BROWSER_ACTIONS_CONTAINER_VIEW_ +#define CHROME_BROWSER_UI_COCOA_EXTENSIONS_BROWSER_ACTIONS_CONTAINER_VIEW_ +#pragma once + +#import <Cocoa/Cocoa.h> + +// Sent when a user-initiated drag to resize the container is initiated. +extern NSString* const kBrowserActionGrippyDragStartedNotification; + +// Sent when a user-initiated drag is resizing the container. +extern NSString* const kBrowserActionGrippyDraggingNotification; + +// Sent when a user-initiated drag to resize the container has finished. +extern NSString* const kBrowserActionGrippyDragFinishedNotification; + +// The view that encompasses the Browser Action buttons in the toolbar and +// provides mechanisms for resizing. +@interface BrowserActionsContainerView : NSView { + @private + // The frame encompasing the grippy used for resizing the container. + NSRect grippyRect_; + + // The end frame of the animation currently running for this container or + // NSZeroRect if none is in progress. + NSRect animationEndFrame_; + + // Used to cache the original position within the container that initiated the + // drag. + NSPoint initialDragPoint_; + + // Used to cache the previous x-pos of the frame rect for resizing purposes. + CGFloat lastXPos_; + + // The maximum width of the container. + CGFloat maxWidth_; + + // Whether the container is currently being resized by the user. + BOOL userIsResizing_; + + // Whether the user can resize this at all. Resizing is disabled in incognito + // mode since any changes done in incognito mode are not saved anyway, and + // also to avoid a crash. http://crbug.com/42848 + BOOL resizable_; + + // Whether the user is allowed to drag the grippy to the left. NO if all + // extensions are shown or the location bar has hit its minimum width (handled + // within toolbar_controller.mm). + BOOL canDragLeft_; + + // Whether the user is allowed to drag the grippy to the right. NO if all + // extensions are hidden. + BOOL canDragRight_; + + // When the left grippy is pinned, resizing the window has no effect on its + // position. This prevents it from overlapping with other elements as well + // as letting the container expand when the window is going from super small + // to large. + BOOL grippyPinned_; +} + +// Resizes the container to the given ideal width, adjusting the |lastXPos_| so +// that |resizeDeltaX| is accurate. +- (void)resizeToWidth:(CGFloat)width animate:(BOOL)animate; + +// Returns the change in the x-pos of the frame rect during resizing. Meant to +// be queried when a NSViewFrameDidChangeNotification is fired to determine +// placement of surrounding elements. +- (CGFloat)resizeDeltaX; + +@property(nonatomic, readonly) NSRect animationEndFrame; +@property(nonatomic) BOOL canDragLeft; +@property(nonatomic) BOOL canDragRight; +@property(nonatomic) BOOL grippyPinned; +@property(nonatomic,getter=isResizable) BOOL resizable; +@property(nonatomic) CGFloat maxWidth; +@property(readonly, nonatomic) BOOL userIsResizing; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_EXTENSIONS_BROWSER_ACTIONS_CONTAINER_VIEW_ diff --git a/chrome/browser/ui/cocoa/extensions/browser_actions_container_view.mm b/chrome/browser/ui/cocoa/extensions/browser_actions_container_view.mm new file mode 100644 index 0000000..3d10e22 --- /dev/null +++ b/chrome/browser/ui/cocoa/extensions/browser_actions_container_view.mm @@ -0,0 +1,192 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/extensions/browser_actions_container_view.h" + +#include <algorithm> + +#include "base/basictypes.h" +#import "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/view_id_util.h" + +NSString* const kBrowserActionGrippyDragStartedNotification = + @"BrowserActionGrippyDragStartedNotification"; +NSString* const kBrowserActionGrippyDraggingNotification = + @"BrowserActionGrippyDraggingNotification"; +NSString* const kBrowserActionGrippyDragFinishedNotification = + @"BrowserActionGrippyDragFinishedNotification"; + +namespace { +const CGFloat kAnimationDuration = 0.2; +const CGFloat kGrippyWidth = 4.0; +const CGFloat kMinimumContainerWidth = 10.0; +} // namespace + +@interface BrowserActionsContainerView(Private) +// Returns the cursor that should be shown when hovering over the grippy based +// on |canDragLeft_| and |canDragRight_|. +- (NSCursor*)appropriateCursorForGrippy; +@end + +@implementation BrowserActionsContainerView + +@synthesize animationEndFrame = animationEndFrame_; +@synthesize canDragLeft = canDragLeft_; +@synthesize canDragRight = canDragRight_; +@synthesize grippyPinned = grippyPinned_; +@synthesize maxWidth = maxWidth_; +@synthesize userIsResizing = userIsResizing_; + +#pragma mark - +#pragma mark Overridden Class Functions + +- (id)initWithFrame:(NSRect)frameRect { + if ((self = [super initWithFrame:frameRect])) { + grippyRect_ = NSMakeRect(0.0, 0.0, kGrippyWidth, NSHeight([self bounds])); + canDragLeft_ = YES; + canDragRight_ = YES; + resizable_ = YES; + [self setHidden:YES]; + } + return self; +} + +- (void)setResizable:(BOOL)resizable { + if (resizable == resizable_) + return; + resizable_ = resizable; + [self setNeedsDisplay:YES]; +} + +- (BOOL)isResizable { + return resizable_; +} + +- (void)resetCursorRects { + [self discardCursorRects]; + [self addCursorRect:grippyRect_ cursor:[self appropriateCursorForGrippy]]; +} + +- (BOOL)acceptsFirstResponder { + return YES; +} + +- (void)mouseDown:(NSEvent*)theEvent { + initialDragPoint_ = [self convertPoint:[theEvent locationInWindow] + fromView:nil]; + if (!resizable_ || + !NSMouseInRect(initialDragPoint_, grippyRect_, [self isFlipped])) + return; + + lastXPos_ = [self frame].origin.x; + userIsResizing_ = YES; + + [[self appropriateCursorForGrippy] push]; + // Disable cursor rects so that the Omnibox and other UI elements don't push + // cursors while the user is dragging. The cursor should be grippy until + // the |-mouseUp:| message is received. + [[self window] disableCursorRects]; + + [[NSNotificationCenter defaultCenter] + postNotificationName:kBrowserActionGrippyDragStartedNotification + object:self]; +} + +- (void)mouseUp:(NSEvent*)theEvent { + if (!userIsResizing_) + return; + + [NSCursor pop]; + [[self window] enableCursorRects]; + + userIsResizing_ = NO; + [[NSNotificationCenter defaultCenter] + postNotificationName:kBrowserActionGrippyDragFinishedNotification + object:self]; +} + +- (void)mouseDragged:(NSEvent*)theEvent { + if (!userIsResizing_) + return; + + NSPoint location = [self convertPoint:[theEvent locationInWindow] + fromView:nil]; + NSRect containerFrame = [self frame]; + CGFloat dX = [theEvent deltaX]; + CGFloat withDelta = location.x - dX; + canDragRight_ = (withDelta >= initialDragPoint_.x) && + (NSWidth(containerFrame) > kMinimumContainerWidth); + canDragLeft_ = (withDelta <= initialDragPoint_.x) && + (NSWidth(containerFrame) < maxWidth_); + if ((dX < 0.0 && !canDragLeft_) || (dX > 0.0 && !canDragRight_)) + return; + + containerFrame.size.width = + std::max(NSWidth(containerFrame) - dX, kMinimumContainerWidth); + + if (NSWidth(containerFrame) == kMinimumContainerWidth) + return; + + containerFrame.origin.x += dX; + + [self setFrame:containerFrame]; + [self setNeedsDisplay:YES]; + + [[NSNotificationCenter defaultCenter] + postNotificationName:kBrowserActionGrippyDraggingNotification + object:self]; + + lastXPos_ += dX; +} + +- (ViewID)viewID { + return VIEW_ID_BROWSER_ACTION_TOOLBAR; +} + +#pragma mark - +#pragma mark Public Methods + +- (void)resizeToWidth:(CGFloat)width animate:(BOOL)animate { + width = std::max(width, kMinimumContainerWidth); + NSRect frame = [self frame]; + lastXPos_ = frame.origin.x; + CGFloat dX = frame.size.width - width; + frame.size.width = width; + NSRect newFrame = NSOffsetRect(frame, dX, 0); + if (animate) { + [NSAnimationContext beginGrouping]; + [[NSAnimationContext currentContext] setDuration:kAnimationDuration]; + [[self animator] setFrame:newFrame]; + [NSAnimationContext endGrouping]; + animationEndFrame_ = newFrame; + } else { + [self setFrame:newFrame]; + [self setNeedsDisplay:YES]; + } +} + +- (CGFloat)resizeDeltaX { + return [self frame].origin.x - lastXPos_; +} + +#pragma mark - +#pragma mark Private Methods + +// Returns the cursor to display over the grippy hover region depending on the +// current drag state. +- (NSCursor*)appropriateCursorForGrippy { + NSCursor* retVal; + if (!resizable_ || (!canDragLeft_ && !canDragRight_)) { + retVal = [NSCursor arrowCursor]; + } else if (!canDragLeft_) { + retVal = [NSCursor resizeRightCursor]; + } else if (!canDragRight_) { + retVal = [NSCursor resizeLeftCursor]; + } else { + retVal = [NSCursor resizeLeftRightCursor]; + } + return retVal; +} + +@end diff --git a/chrome/browser/ui/cocoa/extensions/browser_actions_container_view_unittest.mm b/chrome/browser/ui/cocoa/extensions/browser_actions_container_view_unittest.mm new file mode 100644 index 0000000..002aa05 --- /dev/null +++ b/chrome/browser/ui/cocoa/extensions/browser_actions_container_view_unittest.mm @@ -0,0 +1,52 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/extensions/browser_actions_container_view.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +namespace { + +const CGFloat kContainerHeight = 15.0; +const CGFloat kMinimumContainerWidth = 10.0; + +class BrowserActionsContainerViewTest : public CocoaTest { + public: + virtual void SetUp() { + CocoaTest::SetUp(); + view_.reset([[BrowserActionsContainerView alloc] + initWithFrame:NSMakeRect(0, 0, 0, kContainerHeight)]); + } + + scoped_nsobject<BrowserActionsContainerView> view_; +}; + +TEST_F(BrowserActionsContainerViewTest, BasicTests) { + EXPECT_TRUE([view_ isResizable]); + EXPECT_TRUE([view_ canDragLeft]); + EXPECT_TRUE([view_ canDragRight]); + EXPECT_TRUE([view_ isHidden]); +} + +TEST_F(BrowserActionsContainerViewTest, SetWidthTests) { + // Try setting below the minimum width (10 pixels). + [view_ resizeToWidth:5.0 animate:NO]; + EXPECT_EQ(kMinimumContainerWidth, NSWidth([view_ frame])) << "Frame width is " + << "less than the minimum allowed."; + // Since the frame expands to the left, the x-position delta value will be + // negative. + EXPECT_EQ(-kMinimumContainerWidth, [view_ resizeDeltaX]); + + [view_ resizeToWidth:35.0 animate:NO]; + EXPECT_EQ(35.0, NSWidth([view_ frame])); + EXPECT_EQ(-25.0, [view_ resizeDeltaX]); + + [view_ resizeToWidth:20.0 animate:NO]; + EXPECT_EQ(20.0, NSWidth([view_ frame])); + EXPECT_EQ(15.0, [view_ resizeDeltaX]); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/extensions/browser_actions_controller.h b/chrome/browser/ui/cocoa/extensions/browser_actions_controller.h new file mode 100644 index 0000000..1c8b700 --- /dev/null +++ b/chrome/browser/ui/cocoa/extensions/browser_actions_controller.h @@ -0,0 +1,117 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_EXTENSIONS_BROWSER_ACTIONS_CONTROLLER_H_ +#define CHROME_BROWSER_UI_COCOA_EXTENSIONS_BROWSER_ACTIONS_CONTROLLER_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#import "base/scoped_nsobject.h" +#include "base/scoped_ptr.h" + +class Browser; +@class BrowserActionButton; +@class BrowserActionsContainerView; +class Extension; +@class ExtensionPopupController; +class ExtensionToolbarModel; +class ExtensionsServiceObserverBridge; +@class MenuButton; +class PrefService; +class Profile; + +// Sent when the visibility of the Browser Actions changes. +extern NSString* const kBrowserActionVisibilityChangedNotification; + +// Handles state and provides an interface for controlling the Browser Actions +// container within the Toolbar. +@interface BrowserActionsController : NSObject { + @private + // Reference to the current browser. Weak. + Browser* browser_; + + // The view from Toolbar.xib we'll be rendering our browser actions in. Weak. + BrowserActionsContainerView* containerView_; + + // The current profile. Weak. + Profile* profile_; + + // The model that tracks the order of the toolbar icons. Weak. + ExtensionToolbarModel* toolbarModel_; + + // The observer for the ExtensionsService we're getting events from. + scoped_ptr<ExtensionsServiceObserverBridge> observer_; + + // A dictionary of Extension ID -> BrowserActionButton pairs representing the + // buttons present in the container view. The ID is a string unique to each + // extension. + scoped_nsobject<NSMutableDictionary> buttons_; + + // Array of hidden buttons in the correct order in which the user specified. + scoped_nsobject<NSMutableArray> hiddenButtons_; + + // The currently running chevron animation (fade in/out). + scoped_nsobject<NSViewAnimation> chevronAnimation_; + + // The chevron button used when Browser Actions are hidden. + scoped_nsobject<MenuButton> chevronMenuButton_; + + // The Browser Actions overflow menu. + scoped_nsobject<NSMenu> overflowMenu_; +} + +@property(readonly, nonatomic) BrowserActionsContainerView* containerView; + +// Initializes the controller given the current browser and container view that +// will hold the browser action buttons. +- (id)initWithBrowser:(Browser*)browser + containerView:(BrowserActionsContainerView*)container; + +// Update the display of all buttons. +- (void)update; + +// Returns the current number of browser action buttons within the container, +// whether or not they are displayed. +- (NSUInteger)buttonCount; + +// Returns the current number of browser action buttons displayed in the +// container. +- (NSUInteger)visibleButtonCount; + +// Returns a pointer to the chevron menu button. +- (MenuButton*)chevronMenuButton; + +// Resizes the container given the number of visible buttons, taking into +// account the size of the grippy. Also updates the persistent width preference. +- (void)resizeContainerAndAnimate:(BOOL)animate; + +// Returns the NSView for the action button associated with an extension. +- (NSView*)browserActionViewForExtension:(const Extension*)extension; + +// Returns the saved width determined by the number of shown Browser Actions +// preference property. If no preference is found, then the width for the +// container is returned as if all buttons are shown. +- (CGFloat)savedWidth; + +// Returns where the popup arrow should point to for a given Browser Action. If +// it is passed an extension that is not a Browser Action, then it will return +// NSZeroPoint. +- (NSPoint)popupPointForBrowserAction:(const Extension*)extension; + +// Returns whether the chevron button is currently hidden or in the process of +// being hidden (fading out). Will return NO if it is not hidden or is in the +// process of fading in. +- (BOOL)chevronIsHidden; + +// Registers the user preferences used by this class. ++ (void)registerUserPrefs:(PrefService*)prefs; + +@end // @interface BrowserActionsController + +@interface BrowserActionsController(TestingAPI) +- (NSButton*)buttonWithIndex:(NSUInteger)index; +@end + +#endif // CHROME_BROWSER_UI_COCOA_EXTENSIONS_BROWSER_ACTIONS_CONTROLLER_H_ diff --git a/chrome/browser/ui/cocoa/extensions/browser_actions_controller.mm b/chrome/browser/ui/cocoa/extensions/browser_actions_controller.mm new file mode 100644 index 0000000..2ae72a4 --- /dev/null +++ b/chrome/browser/ui/cocoa/extensions/browser_actions_controller.mm @@ -0,0 +1,863 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "browser_actions_controller.h" + +#include <cmath> +#include <string> + +#include "base/nsimage_cache_mac.h" +#include "base/sys_string_conversions.h" +#include "chrome/browser/extensions/extension_browser_event_router.h" +#include "chrome/browser/extensions/extension_host.h" +#include "chrome/browser/extensions/extension_toolbar_model.h" +#include "chrome/browser/extensions/extensions_service.h" +#include "chrome/browser/prefs/pref_service.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/tab_contents/tab_contents.h" +#include "chrome/browser/ui/browser.h" +#import "chrome/browser/ui/cocoa/extensions/browser_action_button.h" +#import "chrome/browser/ui/cocoa/extensions/browser_actions_container_view.h" +#import "chrome/browser/ui/cocoa/extensions/chevron_menu_button.h" +#import "chrome/browser/ui/cocoa/extensions/extension_popup_controller.h" +#import "chrome/browser/ui/cocoa/menu_button.h" +#include "chrome/common/extensions/extension_action.h" +#include "chrome/common/notification_observer.h" +#include "chrome/common/notification_registrar.h" +#include "chrome/common/pref_names.h" +#import "third_party/GTM/AppKit/GTMNSAnimation+Duration.h" + +NSString* const kBrowserActionVisibilityChangedNotification = + @"BrowserActionVisibilityChangedNotification"; + +namespace { +const CGFloat kAnimationDuration = 0.2; + +const CGFloat kChevronWidth = 14.0; + +// Image used for the overflow button. +NSString* const kOverflowChevronsName = + @"browser_actions_overflow_Template.pdf"; + +// Since the container is the maximum height of the toolbar, we have +// to move the buttons up by this amount in order to have them look +// vertically centered within the toolbar. +const CGFloat kBrowserActionOriginYOffset = 5.0; + +// The size of each button on the toolbar. +const CGFloat kBrowserActionHeight = 29.0; +const CGFloat kBrowserActionWidth = 29.0; + +// The padding between browser action buttons. +const CGFloat kBrowserActionButtonPadding = 2.0; + +// Padding between Omnibox and first button. Since the buttons have a +// pixel of internal padding, this needs an extra pixel. +const CGFloat kBrowserActionLeftPadding = kBrowserActionButtonPadding + 1.0; + +// How far to inset from the bottom of the view to get the top border +// of the popup 2px below the bottom of the Omnibox. +const CGFloat kBrowserActionBubbleYOffset = 3.0; + +} // namespace + +@interface BrowserActionsController(Private) +// Used during initialization to create the BrowserActionButton objects from the +// stored toolbar model. +- (void)createButtons; + +// Creates and then adds the given extension's action button to the container +// at the given index within the container. It does not affect the toolbar model +// object since it is called when the toolbar model changes. +- (void)createActionButtonForExtension:(const Extension*)extension + withIndex:(NSUInteger)index; + +// Removes an action button for the given extension from the container. This +// method also does not affect the underlying toolbar model since it is called +// when the toolbar model changes. +- (void)removeActionButtonForExtension:(const Extension*)extension; + +// Useful in the case of a Browser Action being added/removed from the middle of +// the container, this method repositions each button according to the current +// toolbar model. +- (void)positionActionButtonsAndAnimate:(BOOL)animate; + +// During container resizing, buttons become more transparent as they are pushed +// off the screen. This method updates each button's opacity determined by the +// position of the button. +- (void)updateButtonOpacity; + +// Returns the existing button with the given extension backing it; nil if it +// cannot be found or the extension's ID is invalid. +- (BrowserActionButton*)buttonForExtension:(const Extension*)extension; + +// Returns the preferred width of the container given the number of visible +// buttons |buttonCount|. +- (CGFloat)containerWidthWithButtonCount:(NSUInteger)buttonCount; + +// Returns the number of buttons that can fit in the container according to its +// current size. +- (NSUInteger)containerButtonCapacity; + +// Notification handlers for events registered by the class. + +// Updates each button's opacity, the cursor rects and chevron position. +- (void)containerFrameChanged:(NSNotification*)notification; + +// Hides the chevron and unhides every hidden button so that dragging the +// container out smoothly shows the Browser Action buttons. +- (void)containerDragStart:(NSNotification*)notification; + +// Sends a notification for the toolbar to reposition surrounding UI elements. +- (void)containerDragging:(NSNotification*)notification; + +// Determines which buttons need to be hidden based on the new size, hides them +// and updates the chevron overflow menu. Also fires a notification to let the +// toolbar know that the drag has finished. +- (void)containerDragFinished:(NSNotification*)notification; + +// Updates the image associated with the button should it be within the chevron +// menu. +- (void)actionButtonUpdated:(NSNotification*)notification; + +// Adjusts the position of the surrounding action buttons depending on where the +// button is within the container. +- (void)actionButtonDragging:(NSNotification*)notification; + +// Updates the position of the Browser Actions within the container. This fires +// when _any_ Browser Action button is done dragging to keep all open windows in +// sync visually. +- (void)actionButtonDragFinished:(NSNotification*)notification; + +// Moves the given button both visually and within the toolbar model to the +// specified index. +- (void)moveButton:(BrowserActionButton*)button + toIndex:(NSUInteger)index + animate:(BOOL)animate; + +// Handles when the given BrowserActionButton object is clicked. +- (void)browserActionClicked:(BrowserActionButton*)button; + +// Returns whether the given extension should be displayed. Only displays +// incognito-enabled extensions in incognito mode. Otherwise returns YES. +- (BOOL)shouldDisplayBrowserAction:(const Extension*)extension; + +// The reason |frame| is specified in these chevron functions is because the +// container may be animating and the end frame of the animation should be +// passed instead of the current frame (which may be off and cause the chevron +// to jump at the end of its animation). + +// Shows the overflow chevron button depending on whether there are any hidden +// extensions within the frame given. +- (void)showChevronIfNecessaryInFrame:(NSRect)frame animate:(BOOL)animate; + +// Moves the chevron to its correct position within |frame|. +- (void)updateChevronPositionInFrame:(NSRect)frame; + +// Shows or hides the chevron, animating as specified by |animate|. +- (void)setChevronHidden:(BOOL)hidden + inFrame:(NSRect)frame + animate:(BOOL)animate; + +// Handles when a menu item within the chevron overflow menu is selected. +- (void)chevronItemSelected:(id)menuItem; + +// Clears and then populates the overflow menu based on the contents of +// |hiddenButtons_|. +- (void)updateOverflowMenu; + +// Updates the container's grippy cursor based on the number of hidden buttons. +- (void)updateGrippyCursors; + +// Returns the ID of the currently selected tab or -1 if none exists. +- (int)currentTabId; +@end + +// A helper class to proxy extension notifications to the view controller's +// appropriate methods. +class ExtensionsServiceObserverBridge : public NotificationObserver, + public ExtensionToolbarModel::Observer { + public: + ExtensionsServiceObserverBridge(BrowserActionsController* owner, + Profile* profile) : owner_(owner) { + registrar_.Add(this, NotificationType::EXTENSION_HOST_VIEW_SHOULD_CLOSE, + Source<Profile>(profile)); + } + + // Overridden from NotificationObserver. + void Observe(NotificationType type, + const NotificationSource& source, + const NotificationDetails& details) { + switch (type.value) { + case NotificationType::EXTENSION_HOST_VIEW_SHOULD_CLOSE: { + ExtensionPopupController* popup = [ExtensionPopupController popup]; + if (popup && ![popup isClosing]) + [popup close]; + + break; + } + default: + NOTREACHED() << L"Unexpected notification"; + } + } + + // ExtensionToolbarModel::Observer implementation. + void BrowserActionAdded(const Extension* extension, int index) { + [owner_ createActionButtonForExtension:extension withIndex:index]; + [owner_ resizeContainerAndAnimate:NO]; + } + + void BrowserActionRemoved(const Extension* extension) { + [owner_ removeActionButtonForExtension:extension]; + [owner_ resizeContainerAndAnimate:NO]; + } + + private: + // The object we need to inform when we get a notification. Weak. Owns us. + BrowserActionsController* owner_; + + // Used for registering to receive notifications and automatic clean up. + NotificationRegistrar registrar_; + + DISALLOW_COPY_AND_ASSIGN(ExtensionsServiceObserverBridge); +}; + +@implementation BrowserActionsController + +@synthesize containerView = containerView_; + +#pragma mark - +#pragma mark Public Methods + +- (id)initWithBrowser:(Browser*)browser + containerView:(BrowserActionsContainerView*)container { + DCHECK(browser && container); + + if ((self = [super init])) { + browser_ = browser; + profile_ = browser->profile(); + + if (!profile_->GetPrefs()->FindPreference( + prefs::kBrowserActionContainerWidth)) + [BrowserActionsController registerUserPrefs:profile_->GetPrefs()]; + + observer_.reset(new ExtensionsServiceObserverBridge(self, profile_)); + ExtensionsService* extensionsService = profile_->GetExtensionsService(); + // |extensionsService| can be NULL in Incognito. + if (extensionsService) { + toolbarModel_ = extensionsService->toolbar_model(); + toolbarModel_->AddObserver(observer_.get()); + } + + containerView_ = container; + [containerView_ setPostsFrameChangedNotifications:YES]; + [[NSNotificationCenter defaultCenter] + addObserver:self + selector:@selector(containerFrameChanged:) + name:NSViewFrameDidChangeNotification + object:containerView_]; + [[NSNotificationCenter defaultCenter] + addObserver:self + selector:@selector(containerDragStart:) + name:kBrowserActionGrippyDragStartedNotification + object:containerView_]; + [[NSNotificationCenter defaultCenter] + addObserver:self + selector:@selector(containerDragging:) + name:kBrowserActionGrippyDraggingNotification + object:containerView_]; + [[NSNotificationCenter defaultCenter] + addObserver:self + selector:@selector(containerDragFinished:) + name:kBrowserActionGrippyDragFinishedNotification + object:containerView_]; + // Listen for a finished drag from any button to make sure each open window + // stays in sync. + [[NSNotificationCenter defaultCenter] + addObserver:self + selector:@selector(actionButtonDragFinished:) + name:kBrowserActionButtonDragEndNotification + object:nil]; + + chevronAnimation_.reset([[NSViewAnimation alloc] init]); + [chevronAnimation_ gtm_setDuration:kAnimationDuration + eventMask:NSLeftMouseUpMask]; + [chevronAnimation_ setAnimationBlockingMode:NSAnimationNonblocking]; + + hiddenButtons_.reset([[NSMutableArray alloc] init]); + buttons_.reset([[NSMutableDictionary alloc] init]); + [self createButtons]; + [self showChevronIfNecessaryInFrame:[containerView_ frame] animate:NO]; + [self updateGrippyCursors]; + [container setResizable:!profile_->IsOffTheRecord()]; + } + + return self; +} + +- (void)dealloc { + if (toolbarModel_) + toolbarModel_->RemoveObserver(observer_.get()); + + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [super dealloc]; +} + +- (void)update { + for (BrowserActionButton* button in [buttons_ allValues]) { + [button setTabId:[self currentTabId]]; + [button updateState]; + } +} + +- (NSUInteger)buttonCount { + return [buttons_ count]; +} + +- (NSUInteger)visibleButtonCount { + return [self buttonCount] - [hiddenButtons_ count]; +} + +- (MenuButton*)chevronMenuButton { + return chevronMenuButton_.get(); +} + +- (void)resizeContainerAndAnimate:(BOOL)animate { + int iconCount = toolbarModel_->GetVisibleIconCount(); + if (iconCount < 0) // If no buttons are hidden. + iconCount = [self buttonCount]; + + [containerView_ resizeToWidth:[self containerWidthWithButtonCount:iconCount] + animate:animate]; + NSRect frame = animate ? [containerView_ animationEndFrame] : + [containerView_ frame]; + + [self showChevronIfNecessaryInFrame:frame animate:animate]; + + if (!animate) { + [[NSNotificationCenter defaultCenter] + postNotificationName:kBrowserActionVisibilityChangedNotification + object:self]; + } +} + +- (NSView*)browserActionViewForExtension:(const Extension*)extension { + for (BrowserActionButton* button in [buttons_ allValues]) { + if ([button extension] == extension) + return button; + } + NOTREACHED(); + return nil; +} + +- (CGFloat)savedWidth { + if (!toolbarModel_) + return 0; + if (!profile_->GetPrefs()->HasPrefPath(prefs::kExtensionToolbarSize)) { + // Migration code to the new VisibleIconCount pref. + // TODO(mpcomplete): remove this at some point. + double predefinedWidth = + profile_->GetPrefs()->GetReal(prefs::kBrowserActionContainerWidth); + if (predefinedWidth != 0) { + int iconWidth = kBrowserActionWidth + kBrowserActionButtonPadding; + int extraWidth = kChevronWidth; + toolbarModel_->SetVisibleIconCount( + (predefinedWidth - extraWidth) / iconWidth); + } + } + + int savedButtonCount = toolbarModel_->GetVisibleIconCount(); + if (savedButtonCount < 0 || // all icons are visible + static_cast<NSUInteger>(savedButtonCount) > [self buttonCount]) + savedButtonCount = [self buttonCount]; + return [self containerWidthWithButtonCount:savedButtonCount]; +} + +- (NSPoint)popupPointForBrowserAction:(const Extension*)extension { + if (!extension->browser_action()) + return NSZeroPoint; + + NSButton* button = [self buttonForExtension:extension]; + if (!button) + return NSZeroPoint; + + if ([hiddenButtons_ containsObject:button]) + button = chevronMenuButton_.get(); + + // Anchor point just above the center of the bottom. + const NSRect bounds = [button bounds]; + DCHECK([button isFlipped]); + NSPoint anchor = NSMakePoint(NSMidX(bounds), + NSMaxY(bounds) - kBrowserActionBubbleYOffset); + return [button convertPoint:anchor toView:nil]; +} + +- (BOOL)chevronIsHidden { + if (!chevronMenuButton_.get()) + return YES; + + if (![chevronAnimation_ isAnimating]) + return [chevronMenuButton_ isHidden]; + + DCHECK([[chevronAnimation_ viewAnimations] count] > 0); + + // The chevron is animating in or out. Determine which one and have the return + // value reflect where the animation is headed. + NSString* effect = [[[chevronAnimation_ viewAnimations] objectAtIndex:0] + valueForKey:NSViewAnimationEffectKey]; + if (effect == NSViewAnimationFadeInEffect) { + return NO; + } else if (effect == NSViewAnimationFadeOutEffect) { + return YES; + } + + NOTREACHED(); + return YES; +} + ++ (void)registerUserPrefs:(PrefService*)prefs { + prefs->RegisterRealPref(prefs::kBrowserActionContainerWidth, 0); +} + +#pragma mark - +#pragma mark Private Methods + +- (void)createButtons { + if (!toolbarModel_) + return; + + NSUInteger i = 0; + for (ExtensionList::iterator iter = toolbarModel_->begin(); + iter != toolbarModel_->end(); ++iter) { + if (![self shouldDisplayBrowserAction:*iter]) + continue; + + [self createActionButtonForExtension:*iter withIndex:i++]; + } + + [[NSNotificationCenter defaultCenter] + addObserver:self + selector:@selector(actionButtonUpdated:) + name:kBrowserActionButtonUpdatedNotification + object:nil]; + + CGFloat width = [self savedWidth]; + [containerView_ resizeToWidth:width animate:NO]; +} + +- (void)createActionButtonForExtension:(const Extension*)extension + withIndex:(NSUInteger)index { + if (!extension->browser_action()) + return; + + if (![self shouldDisplayBrowserAction:extension]) + return; + + if (profile_->IsOffTheRecord()) + index = toolbarModel_->OriginalIndexToIncognito(index); + + // Show the container if it's the first button. Otherwise it will be shown + // already. + if ([self buttonCount] == 0) + [containerView_ setHidden:NO]; + + NSRect buttonFrame = NSMakeRect(0.0, kBrowserActionOriginYOffset, + kBrowserActionWidth, kBrowserActionHeight); + BrowserActionButton* newButton = + [[[BrowserActionButton alloc] + initWithFrame:buttonFrame + extension:extension + profile:profile_ + tabId:[self currentTabId]] autorelease]; + [newButton setTarget:self]; + [newButton setAction:@selector(browserActionClicked:)]; + NSString* buttonKey = base::SysUTF8ToNSString(extension->id()); + if (!buttonKey) + return; + [buttons_ setObject:newButton forKey:buttonKey]; + + [self positionActionButtonsAndAnimate:NO]; + + [[NSNotificationCenter defaultCenter] + addObserver:self + selector:@selector(actionButtonDragging:) + name:kBrowserActionButtonDraggingNotification + object:newButton]; + + + [containerView_ setMaxWidth: + [self containerWidthWithButtonCount:[self buttonCount]]]; + [containerView_ setNeedsDisplay:YES]; +} + +- (void)removeActionButtonForExtension:(const Extension*)extension { + if (!extension->browser_action()) + return; + + NSString* buttonKey = base::SysUTF8ToNSString(extension->id()); + if (!buttonKey) + return; + + BrowserActionButton* button = [buttons_ objectForKey:buttonKey]; + // This could be the case in incognito, where only a subset of extensions are + // shown. + if (!button) + return; + + [button removeFromSuperview]; + // It may or may not be hidden, but it won't matter to NSMutableArray either + // way. + [hiddenButtons_ removeObject:button]; + [self updateOverflowMenu]; + + [buttons_ removeObjectForKey:buttonKey]; + if ([self buttonCount] == 0) { + // No more buttons? Hide the container. + [containerView_ setHidden:YES]; + } else { + [self positionActionButtonsAndAnimate:NO]; + } + [containerView_ setMaxWidth: + [self containerWidthWithButtonCount:[self buttonCount]]]; + [containerView_ setNeedsDisplay:YES]; +} + +- (void)positionActionButtonsAndAnimate:(BOOL)animate { + NSUInteger i = 0; + for (ExtensionList::iterator iter = toolbarModel_->begin(); + iter != toolbarModel_->end(); ++iter) { + if (![self shouldDisplayBrowserAction:*iter]) + continue; + BrowserActionButton* button = [self buttonForExtension:(*iter)]; + if (!button) + continue; + if (![button isBeingDragged]) + [self moveButton:button toIndex:i animate:animate]; + ++i; + } +} + +- (void)updateButtonOpacity { + for (BrowserActionButton* button in [buttons_ allValues]) { + NSRect buttonFrame = [button frame]; + if (NSContainsRect([containerView_ bounds], buttonFrame)) { + if ([button alphaValue] != 1.0) + [button setAlphaValue:1.0]; + + continue; + } + CGFloat intersectionWidth = + NSWidth(NSIntersectionRect([containerView_ bounds], buttonFrame)); + CGFloat alpha = std::max(0.0f, intersectionWidth / NSWidth(buttonFrame)); + [button setAlphaValue:alpha]; + [button setNeedsDisplay:YES]; + } +} + +- (BrowserActionButton*)buttonForExtension:(const Extension*)extension { + NSString* extensionId = base::SysUTF8ToNSString(extension->id()); + DCHECK(extensionId); + if (!extensionId) + return nil; + return [buttons_ objectForKey:extensionId]; +} + +- (CGFloat)containerWidthWithButtonCount:(NSUInteger)buttonCount { + // Left-side padding which works regardless of whether a button or + // chevron leads. + CGFloat width = kBrowserActionLeftPadding; + + // Include the buttons and padding between. + if (buttonCount > 0) { + width += buttonCount * kBrowserActionWidth; + width += (buttonCount - 1) * kBrowserActionButtonPadding; + } + + // Make room for the chevron if any buttons are hidden. + if ([self buttonCount] != [self visibleButtonCount]) { + // Chevron and buttons both include 1px padding w/in their bounds, + // so this leaves 2px between the last browser action and chevron, + // and also works right if the chevron is the only button. + width += kChevronWidth; + } + + return width; +} + +- (NSUInteger)containerButtonCapacity { + // Edge-to-edge span of the browser action buttons. + CGFloat actionSpan = [self savedWidth] - kBrowserActionLeftPadding; + + // Add in some padding for the browser action on the end, then + // divide out to get the number of action buttons that fit. + return (actionSpan + kBrowserActionButtonPadding) / + (kBrowserActionWidth + kBrowserActionButtonPadding); +} + +- (void)containerFrameChanged:(NSNotification*)notification { + [self updateButtonOpacity]; + [[containerView_ window] invalidateCursorRectsForView:containerView_]; + [self updateChevronPositionInFrame:[containerView_ frame]]; +} + +- (void)containerDragStart:(NSNotification*)notification { + [self setChevronHidden:YES inFrame:[containerView_ frame] animate:YES]; + while([hiddenButtons_ count] > 0) { + [containerView_ addSubview:[hiddenButtons_ objectAtIndex:0]]; + [hiddenButtons_ removeObjectAtIndex:0]; + } +} + +- (void)containerDragging:(NSNotification*)notification { + [[NSNotificationCenter defaultCenter] + postNotificationName:kBrowserActionGrippyDraggingNotification + object:self]; +} + +- (void)containerDragFinished:(NSNotification*)notification { + for (ExtensionList::iterator iter = toolbarModel_->begin(); + iter != toolbarModel_->end(); ++iter) { + BrowserActionButton* button = [self buttonForExtension:(*iter)]; + NSRect buttonFrame = [button frame]; + if (NSContainsRect([containerView_ bounds], buttonFrame)) + continue; + + CGFloat intersectionWidth = + NSWidth(NSIntersectionRect([containerView_ bounds], buttonFrame)); + // Pad the threshold by 5 pixels in order to have the buttons hide more + // easily. + if (([containerView_ grippyPinned] && intersectionWidth > 0) || + (intersectionWidth <= (NSWidth(buttonFrame) / 2) + 5.0)) { + [button setAlphaValue:0.0]; + [button removeFromSuperview]; + [hiddenButtons_ addObject:button]; + } + } + [self updateOverflowMenu]; + [self updateGrippyCursors]; + + if (!profile_->IsOffTheRecord()) + toolbarModel_->SetVisibleIconCount([self visibleButtonCount]); + + [[NSNotificationCenter defaultCenter] + postNotificationName:kBrowserActionGrippyDragFinishedNotification + object:self]; +} + +- (void)actionButtonUpdated:(NSNotification*)notification { + BrowserActionButton* button = [notification object]; + if (![hiddenButtons_ containsObject:button]) + return; + + // +1 item because of the title placeholder. See |updateOverflowMenu|. + NSUInteger menuIndex = [hiddenButtons_ indexOfObject:button] + 1; + NSMenuItem* item = [[chevronMenuButton_ attachedMenu] itemAtIndex:menuIndex]; + DCHECK(button == [item representedObject]); + [item setImage:[button compositedImage]]; +} + +- (void)actionButtonDragging:(NSNotification*)notification { + if (![self chevronIsHidden]) + [self setChevronHidden:YES inFrame:[containerView_ frame] animate:YES]; + + // Determine what index the dragged button should lie in, alter the model and + // reposition the buttons. + CGFloat dragThreshold = std::floor(kBrowserActionWidth / 2); + BrowserActionButton* draggedButton = [notification object]; + NSRect draggedButtonFrame = [draggedButton frame]; + + NSUInteger index = 0; + for (ExtensionList::iterator iter = toolbarModel_->begin(); + iter != toolbarModel_->end(); ++iter) { + BrowserActionButton* button = [self buttonForExtension:(*iter)]; + CGFloat intersectionWidth = + NSWidth(NSIntersectionRect(draggedButtonFrame, [button frame])); + + if (intersectionWidth > dragThreshold && button != draggedButton && + ![button isAnimating] && index < [self visibleButtonCount]) { + toolbarModel_->MoveBrowserAction([draggedButton extension], index); + [self positionActionButtonsAndAnimate:YES]; + return; + } + ++index; + } +} + +- (void)actionButtonDragFinished:(NSNotification*)notification { + [self showChevronIfNecessaryInFrame:[containerView_ frame] animate:YES]; + [self positionActionButtonsAndAnimate:YES]; +} + +- (void)moveButton:(BrowserActionButton*)button + toIndex:(NSUInteger)index + animate:(BOOL)animate { + CGFloat xOffset = kBrowserActionLeftPadding + + (index * (kBrowserActionWidth + kBrowserActionButtonPadding)); + NSRect buttonFrame = [button frame]; + buttonFrame.origin.x = xOffset; + [button setFrame:buttonFrame animate:animate]; + + if (index < [self containerButtonCapacity]) { + // Make sure the button is within the visible container. + if ([button superview] != containerView_) { + [containerView_ addSubview:button]; + [button setAlphaValue:1.0]; + [hiddenButtons_ removeObjectIdenticalTo:button]; + } + } else if (![hiddenButtons_ containsObject:button]) { + [hiddenButtons_ addObject:button]; + [button removeFromSuperview]; + [button setAlphaValue:0.0]; + [self updateOverflowMenu]; + } +} + +- (void)browserActionClicked:(BrowserActionButton*)button { + int tabId = [self currentTabId]; + if (tabId < 0) { + NOTREACHED() << "No current tab."; + return; + } + + ExtensionAction* action = [button extension]->browser_action(); + if (action->HasPopup(tabId)) { + GURL popupUrl = action->GetPopupUrl(tabId); + // If a popup is already showing, check if the popup URL is the same. If so, + // then close the popup. + ExtensionPopupController* popup = [ExtensionPopupController popup]; + if (popup && + [[popup window] isVisible] && + [popup extensionHost]->GetURL() == popupUrl) { + [popup close]; + return; + } + NSPoint arrowPoint = [self popupPointForBrowserAction:[button extension]]; + [ExtensionPopupController showURL:popupUrl + inBrowser:browser_ + anchoredAt:arrowPoint + arrowLocation:info_bubble::kTopRight + devMode:NO]; + } else { + ExtensionBrowserEventRouter::GetInstance()->BrowserActionExecuted( + profile_, action->extension_id(), browser_); + } +} + +- (BOOL)shouldDisplayBrowserAction:(const Extension*)extension { + // Only display incognito-enabled extensions while in incognito mode. + return (!profile_->IsOffTheRecord() || + profile_->GetExtensionsService()->IsIncognitoEnabled(extension)); +} + +- (void)showChevronIfNecessaryInFrame:(NSRect)frame animate:(BOOL)animate { + [self setChevronHidden:([self buttonCount] == [self visibleButtonCount]) + inFrame:frame + animate:animate]; +} + +- (void)updateChevronPositionInFrame:(NSRect)frame { + CGFloat xPos = NSWidth(frame) - kChevronWidth; + NSRect buttonFrame = NSMakeRect(xPos, + kBrowserActionOriginYOffset, + kChevronWidth, + kBrowserActionHeight); + [chevronMenuButton_ setFrame:buttonFrame]; +} + +- (void)setChevronHidden:(BOOL)hidden + inFrame:(NSRect)frame + animate:(BOOL)animate { + if (hidden == [self chevronIsHidden]) + return; + + if (!chevronMenuButton_.get()) { + chevronMenuButton_.reset([[ChevronMenuButton alloc] init]); + [chevronMenuButton_ setBordered:NO]; + [chevronMenuButton_ setShowsBorderOnlyWhileMouseInside:YES]; + NSImage* chevronImage = nsimage_cache::ImageNamed(kOverflowChevronsName); + [chevronMenuButton_ setImage:chevronImage]; + [containerView_ addSubview:chevronMenuButton_]; + } + + if (!hidden) + [self updateOverflowMenu]; + + [self updateChevronPositionInFrame:frame]; + + // Stop any running animation. + [chevronAnimation_ stopAnimation]; + + if (!animate) { + [chevronMenuButton_ setHidden:hidden]; + return; + } + + NSDictionary* animationDictionary; + if (hidden) { + animationDictionary = [NSDictionary dictionaryWithObjectsAndKeys: + chevronMenuButton_.get(), NSViewAnimationTargetKey, + NSViewAnimationFadeOutEffect, NSViewAnimationEffectKey, + nil]; + } else { + [chevronMenuButton_ setHidden:NO]; + animationDictionary = [NSDictionary dictionaryWithObjectsAndKeys: + chevronMenuButton_.get(), NSViewAnimationTargetKey, + NSViewAnimationFadeInEffect, NSViewAnimationEffectKey, + nil]; + } + [chevronAnimation_ setViewAnimations: + [NSArray arrayWithObject:animationDictionary]]; + [chevronAnimation_ startAnimation]; +} + +- (void)chevronItemSelected:(id)menuItem { + [self browserActionClicked:[menuItem representedObject]]; +} + +- (void)updateOverflowMenu { + overflowMenu_.reset([[NSMenu alloc] initWithTitle:@""]); + // See menu_button.h for documentation on why this is needed. + [overflowMenu_ addItemWithTitle:@"" action:nil keyEquivalent:@""]; + + for (BrowserActionButton* button in hiddenButtons_.get()) { + NSString* name = base::SysUTF8ToNSString([button extension]->name()); + NSMenuItem* item = + [overflowMenu_ addItemWithTitle:name + action:@selector(chevronItemSelected:) + keyEquivalent:@""]; + [item setRepresentedObject:button]; + [item setImage:[button compositedImage]]; + [item setTarget:self]; + } + [chevronMenuButton_ setAttachedMenu:overflowMenu_]; +} + +- (void)updateGrippyCursors { + [containerView_ setCanDragLeft:[hiddenButtons_ count] > 0]; + [containerView_ setCanDragRight:[self visibleButtonCount] > 0]; + [[containerView_ window] invalidateCursorRectsForView:containerView_]; +} + +- (int)currentTabId { + TabContents* selected_tab = browser_->GetSelectedTabContents(); + if (!selected_tab) + return -1; + + return selected_tab->controller().session_id().id(); +} + +#pragma mark - +#pragma mark Testing Methods + +- (NSButton*)buttonWithIndex:(NSUInteger)index { + if (profile_->IsOffTheRecord()) + index = toolbarModel_->IncognitoIndexToOriginal(index); + if (index < toolbarModel_->size()) { + const Extension* extension = toolbarModel_->GetExtensionByIndex(index); + return [buttons_ objectForKey:base::SysUTF8ToNSString(extension->id())]; + } + return nil; +} + +@end diff --git a/chrome/browser/ui/cocoa/extensions/chevron_menu_button.h b/chrome/browser/ui/cocoa/extensions/chevron_menu_button.h new file mode 100644 index 0000000..86c8209 --- /dev/null +++ b/chrome/browser/ui/cocoa/extensions/chevron_menu_button.h @@ -0,0 +1,19 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_EXTENSIONS_CHEVRON_MENU_BUTTON_H_ +#define CHROME_BROWSER_UI_COCOA_EXTENSIONS_CHEVRON_MENU_BUTTON_H_ +#pragma once + +#import "chrome/browser/ui/cocoa/menu_button.h" + +@interface ChevronMenuButton : MenuButton { +} + +// Overrides cell class with |ChevronMenuButtonCell|. ++ (Class)cellClass; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_EXTENSIONS_CHEVRON_MENU_BUTTON_H_ diff --git a/chrome/browser/ui/cocoa/extensions/chevron_menu_button.mm b/chrome/browser/ui/cocoa/extensions/chevron_menu_button.mm new file mode 100644 index 0000000..04f1506 --- /dev/null +++ b/chrome/browser/ui/cocoa/extensions/chevron_menu_button.mm @@ -0,0 +1,15 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/extensions/chevron_menu_button.h" + +#include "chrome/browser/ui/cocoa/extensions/chevron_menu_button_cell.h" + +@implementation ChevronMenuButton + ++ (Class)cellClass { + return [ChevronMenuButtonCell class]; +} + +@end diff --git a/chrome/browser/ui/cocoa/extensions/chevron_menu_button_cell.h b/chrome/browser/ui/cocoa/extensions/chevron_menu_button_cell.h new file mode 100644 index 0000000..429015c --- /dev/null +++ b/chrome/browser/ui/cocoa/extensions/chevron_menu_button_cell.h @@ -0,0 +1,19 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_EXTENSIONS_CHEVRON_MENU_BUTTON_CELL_H_ +#define CHROME_BROWSER_UI_COCOA_EXTENSIONS_CHEVRON_MENU_BUTTON_CELL_H_ +#pragma once + +#import "chrome/browser/ui/cocoa/clickhold_button_cell.h" + +@interface ChevronMenuButtonCell : ClickHoldButtonCell { +} + +// Adds a gradient border to the RHS of the cell when not hovered. +- (void)drawWithFrame:(NSRect)cellFrame inView:(NSView*)controlView; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_EXTENSIONS_CHEVRON_MENU_BUTTON_CELL_H_ diff --git a/chrome/browser/ui/cocoa/extensions/chevron_menu_button_cell.mm b/chrome/browser/ui/cocoa/extensions/chevron_menu_button_cell.mm new file mode 100644 index 0000000..8d4408a --- /dev/null +++ b/chrome/browser/ui/cocoa/extensions/chevron_menu_button_cell.mm @@ -0,0 +1,47 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/extensions/chevron_menu_button_cell.h" + +namespace { + +// Width of the divider. +const CGFloat kDividerWidth = 1.0; + +// Vertical inset from edge of cell to divider start. +const CGFloat kDividerInset = 3.0; + +// Grayscale for the center of the divider. +const CGFloat kDividerGrayscale = 0.5; + +} // namespace + +@implementation ChevronMenuButtonCell + +- (void)drawWithFrame:(NSRect)cellFrame inView:(NSView*)controlView { + [super drawWithFrame:cellFrame inView:controlView]; + + if ([self isMouseInside]) + return; + + NSColor* middleColor = + [NSColor colorWithCalibratedWhite:kDividerGrayscale alpha:1.0]; + NSColor* endPointColor = [middleColor colorWithAlphaComponent:0.0]; + + // Blend from background to |kDividerGrayscale| and back to + // background. + scoped_nsobject<NSGradient> borderGradient([[NSGradient alloc] + initWithColorsAndLocations:endPointColor, (CGFloat)0.0, + middleColor, (CGFloat)0.5, + endPointColor, (CGFloat)1.0, + nil]); + + NSRect edgeRect, remainder; + NSDivideRect(cellFrame, &edgeRect, &remainder, kDividerWidth, NSMaxXEdge); + edgeRect = NSInsetRect(edgeRect, 0.0, kDividerInset); + + [borderGradient drawInRect:edgeRect angle:90.0]; +} + +@end diff --git a/chrome/browser/ui/cocoa/extensions/chevron_menu_button_unittest.mm b/chrome/browser/ui/cocoa/extensions/chevron_menu_button_unittest.mm new file mode 100644 index 0000000..71c8929 --- /dev/null +++ b/chrome/browser/ui/cocoa/extensions/chevron_menu_button_unittest.mm @@ -0,0 +1,50 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/extensions/chevron_menu_button.h" +#import "chrome/browser/ui/cocoa/extensions/chevron_menu_button_cell.h" + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" + +namespace { + +class ChevronMenuButtonTest : public CocoaTest { + public: + ChevronMenuButtonTest() { + NSRect frame = NSMakeRect(0, 0, 50, 30); + scoped_nsobject<ChevronMenuButton> button( + [[ChevronMenuButton alloc] initWithFrame:frame]); + button_ = button.get(); + [[test_window() contentView] addSubview:button_]; + } + + ChevronMenuButton* button_; +}; + +// Test basic view operation. +TEST_VIEW(ChevronMenuButtonTest, button_); + +// |ChevronMenuButton exists entirely to override the cell class. +TEST_F(ChevronMenuButtonTest, CellSubclass) { + EXPECT_TRUE([[button_ cell] isKindOfClass:[ChevronMenuButtonCell class]]); +} + +// Test both hovered and non-hovered display. +TEST_F(ChevronMenuButtonTest, HoverAndNonHoverDisplay) { + ChevronMenuButtonCell* cell = [button_ cell]; + EXPECT_FALSE([cell showsBorderOnlyWhileMouseInside]); + EXPECT_FALSE([cell isMouseInside]); + + [cell setShowsBorderOnlyWhileMouseInside:YES]; + [cell mouseEntered:nil]; + EXPECT_TRUE([cell isMouseInside]); + [button_ display]; + + [cell mouseExited:nil]; + EXPECT_FALSE([cell isMouseInside]); + [button_ display]; +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/extensions/extension_action_context_menu.h b/chrome/browser/ui/cocoa/extensions/extension_action_context_menu.h new file mode 100644 index 0000000..e40388f --- /dev/null +++ b/chrome/browser/ui/cocoa/extensions/extension_action_context_menu.h @@ -0,0 +1,62 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_EXTENSIONS_EXTENSION_ACTION_CONTEXT_MENU_H_ +#define CHROME_BROWSER_UI_COCOA_EXTENSIONS_EXTENSION_ACTION_CONTEXT_MENU_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_ptr.h" +#include "base/scoped_nsobject.h" + +class AsyncUninstaller; +class DevmodeObserver; +class Extension; +class ExtensionAction; +class NotificationRegistrar; +class Profile; + +namespace extension_action_context_menu { + +class DevmodeObserver; + +} // namespace extension_action_context_menu + +// A context menu used by any extension UI components that require it. +@interface ExtensionActionContextMenu : NSMenu { + @private + // The extension that this menu belongs to. Weak. + const Extension* extension_; + + // The extension action this menu belongs to. Weak. + ExtensionAction* action_; + + // The browser profile of the window that contains this extension. Weak. + Profile* profile_; + + // The inspector menu item. Need to keep this around to add and remove it. + scoped_nsobject<NSMenuItem> inspectorItem_; + + // The observer used to listen for pref changed notifications. + scoped_ptr<extension_action_context_menu::DevmodeObserver> observer_; + + // Used to load the extension icon asynchronously on the I/O thread then show + // the uninstall confirmation dialog. + scoped_ptr<AsyncUninstaller> uninstaller_; +} + +// Initializes and returns a context menu for the given extension and profile. +- (id)initWithExtension:(const Extension*)extension + profile:(Profile*)profile + extensionAction:(ExtensionAction*)action; + +// Show or hide the inspector menu item. +- (void)updateInspectorItem; + +@end + +typedef ExtensionActionContextMenu ExtensionActionContextMenuMac; + +#endif // CHROME_BROWSER_UI_COCOA_EXTENSIONS_EXTENSION_ACTION_CONTEXT_MENU_H_ diff --git a/chrome/browser/ui/cocoa/extensions/extension_action_context_menu.mm b/chrome/browser/ui/cocoa/extensions/extension_action_context_menu.mm new file mode 100644 index 0000000..df25dba --- /dev/null +++ b/chrome/browser/ui/cocoa/extensions/extension_action_context_menu.mm @@ -0,0 +1,278 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/extensions/extension_action_context_menu.h" + +#include "app/l10n_util_mac.h" +#include "base/sys_string_conversions.h" +#include "base/task.h" +#include "chrome/browser/browser_list.h" +#include "chrome/browser/extensions/extension_install_ui.h" +#include "chrome/browser/extensions/extensions_service.h" +#include "chrome/browser/extensions/extension_tabs_module.h" +#include "chrome/browser/prefs/pref_change_registrar.h" +#include "chrome/browser/prefs/pref_service.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/ui/cocoa/browser_window_cocoa.h" +#include "chrome/browser/ui/cocoa/browser_window_controller.h" +#include "chrome/browser/ui/cocoa/extensions/browser_actions_controller.h" +#include "chrome/browser/ui/cocoa/extensions/extension_popup_controller.h" +#include "chrome/browser/ui/cocoa/info_bubble_view.h" +#import "chrome/browser/ui/cocoa/location_bar/location_bar_view_mac.h" +#include "chrome/browser/ui/cocoa/toolbar_controller.h" +#include "chrome/common/extensions/extension.h" +#include "chrome/common/extensions/extension_action.h" +#include "chrome/common/extensions/extension_constants.h" +#include "chrome/common/notification_details.h" +#include "chrome/common/notification_observer.h" +#include "chrome/common/notification_type.h" +#include "chrome/common/pref_names.h" +#include "chrome/common/url_constants.h" +#include "grit/generated_resources.h" + +// A class that loads the extension icon on the I/O thread before showing the +// confirmation dialog to uninstall the given extension. +// Also acts as the extension's UI delegate in order to display the dialog. +class AsyncUninstaller : public ExtensionInstallUI::Delegate { + public: + AsyncUninstaller(const Extension* extension, Profile* profile) + : extension_(extension), + profile_(profile) { + install_ui_.reset(new ExtensionInstallUI(profile)); + install_ui_->ConfirmUninstall(this, extension_); + } + + ~AsyncUninstaller() {} + + // Overridden by ExtensionInstallUI::Delegate. + virtual void InstallUIProceed() { + profile_->GetExtensionsService()-> + UninstallExtension(extension_->id(), false); + } + + virtual void InstallUIAbort() {} + + private: + // The extension that we're loading the icon for. Weak. + const Extension* extension_; + + // The current profile. Weak. + Profile* profile_; + + scoped_ptr<ExtensionInstallUI> install_ui_; + + DISALLOW_COPY_AND_ASSIGN(AsyncUninstaller); +}; + +namespace extension_action_context_menu { + +class DevmodeObserver : public NotificationObserver { + public: + DevmodeObserver(ExtensionActionContextMenu* menu, + PrefService* service) + : menu_(menu), pref_service_(service) { + registrar_.Init(pref_service_); + registrar_.Add(prefs::kExtensionsUIDeveloperMode, this); + } + virtual ~DevmodeObserver() {} + + void Observe(NotificationType type, + const NotificationSource& source, + const NotificationDetails& details) { + if (type == NotificationType::PREF_CHANGED) + [menu_ updateInspectorItem]; + else + NOTREACHED(); + } + + private: + ExtensionActionContextMenu* menu_; + PrefService* pref_service_; + PrefChangeRegistrar registrar_; +}; + +} // namespace extension_action_context_menu + +@interface ExtensionActionContextMenu(Private) +// Callback for the context menu items. +- (void)dispatch:(id)menuItem; +@end + +@implementation ExtensionActionContextMenu + +namespace { +// Enum of menu item choices to their respective indices. +// NOTE: You MUST keep this in sync with the |menuItems| NSArray below. +enum { + kExtensionContextName = 0, + kExtensionContextOptions = 2, + kExtensionContextDisable = 3, + kExtensionContextUninstall = 4, + kExtensionContextManage = 6, + kExtensionContextInspect = 7 +}; + +int CurrentTabId() { + Browser* browser = BrowserList::GetLastActive(); + if(!browser) + return -1; + TabContents* contents = browser->GetSelectedTabContents(); + if (!contents) + return -1; + return ExtensionTabUtil::GetTabId(contents); +} + +} // namespace + +- (id)initWithExtension:(const Extension*)extension + profile:(Profile*)profile + extensionAction:(ExtensionAction*)action{ + if ((self = [super initWithTitle:@""])) { + action_ = action; + extension_ = extension; + profile_ = profile; + + NSArray* menuItems = [NSArray arrayWithObjects: + base::SysUTF8ToNSString(extension->name()), + [NSMenuItem separatorItem], + l10n_util::GetNSStringWithFixup(IDS_EXTENSIONS_OPTIONS), + l10n_util::GetNSStringWithFixup(IDS_EXTENSIONS_DISABLE), + l10n_util::GetNSStringWithFixup(IDS_EXTENSIONS_UNINSTALL), + [NSMenuItem separatorItem], + l10n_util::GetNSStringWithFixup(IDS_MANAGE_EXTENSIONS), + nil]; + + for (id item in menuItems) { + if ([item isKindOfClass:[NSMenuItem class]]) { + [self addItem:item]; + } else if ([item isKindOfClass:[NSString class]]) { + NSMenuItem* itemObj = [self addItemWithTitle:item + action:@selector(dispatch:) + keyEquivalent:@""]; + // The tag should correspond to the enum above. + // NOTE: The enum and the order of the menu items MUST be in sync. + [itemObj setTag:[self indexOfItem:itemObj]]; + + // Disable the 'Options' item if there are no options to set. + if ([itemObj tag] == kExtensionContextOptions && + extension_->options_url().spec().length() <= 0) { + // Setting the target to nil will disable the item. For some reason + // setEnabled:NO does not work. + [itemObj setTarget:nil]; + } else { + [itemObj setTarget:self]; + } + } + } + + NSString* inspectorTitle = + l10n_util::GetNSStringWithFixup(IDS_EXTENSION_ACTION_INSPECT_POPUP); + inspectorItem_.reset([[NSMenuItem alloc] initWithTitle:inspectorTitle + action:@selector(dispatch:) + keyEquivalent:@""]); + [inspectorItem_.get() setTarget:self]; + [inspectorItem_.get() setTag:kExtensionContextInspect]; + + PrefService* service = profile_->GetPrefs(); + observer_.reset( + new extension_action_context_menu::DevmodeObserver(self, service)); + + [self updateInspectorItem]; + return self; + } + return nil; +} + +- (void)updateInspectorItem { + PrefService* service = profile_->GetPrefs(); + bool devmode = service->GetBoolean(prefs::kExtensionsUIDeveloperMode); + if (devmode) { + if ([self indexOfItem:inspectorItem_.get()] == -1) + [self addItem:inspectorItem_.get()]; + } else { + if ([self indexOfItem:inspectorItem_.get()] != -1) + [self removeItem:inspectorItem_.get()]; + } +} + +- (void)dispatch:(id)menuItem { + Browser* browser = BrowserList::FindBrowserWithProfile(profile_); + if (!browser) + return; + + NSMenuItem* item = (NSMenuItem*)menuItem; + switch ([item tag]) { + case kExtensionContextName: { + GURL url(std::string(extension_urls::kGalleryBrowsePrefix) + + std::string("/detail/") + extension_->id()); + browser->OpenURL(url, GURL(), NEW_FOREGROUND_TAB, PageTransition::LINK); + break; + } + case kExtensionContextOptions: { + DCHECK(!extension_->options_url().is_empty()); + profile_->GetExtensionProcessManager()->OpenOptionsPage(extension_, + browser); + break; + } + case kExtensionContextDisable: { + ExtensionsService* extensionService = profile_->GetExtensionsService(); + if (!extensionService) + return; // Incognito mode. + extensionService->DisableExtension(extension_->id()); + break; + } + case kExtensionContextUninstall: { + uninstaller_.reset(new AsyncUninstaller(extension_, profile_)); + break; + } + case kExtensionContextManage: { + browser->OpenURL(GURL(chrome::kChromeUIExtensionsURL), GURL(), + NEW_FOREGROUND_TAB, PageTransition::LINK); + break; + } + case kExtensionContextInspect: { + BrowserWindowCocoa* window = + static_cast<BrowserWindowCocoa*>(browser->window()); + ToolbarController* toolbarController = + [window->cocoa_controller() toolbarController]; + LocationBarViewMac* locationBarView = + [toolbarController locationBarBridge]; + + NSPoint popupPoint = NSZeroPoint; + if (extension_->page_action() == action_) { + popupPoint = locationBarView->GetPageActionBubblePoint(action_); + + } else if (extension_->browser_action() == action_) { + BrowserActionsController* controller = + [toolbarController browserActionsController]; + popupPoint = [controller popupPointForBrowserAction:extension_]; + + } else { + NOTREACHED() << "action_ is not a page action or browser action?"; + } + + int tabId = CurrentTabId(); + GURL url = action_->GetPopupUrl(tabId); + DCHECK(url.is_valid()); + [ExtensionPopupController showURL:url + inBrowser:BrowserList::GetLastActive() + anchoredAt:popupPoint + arrowLocation:info_bubble::kTopRight + devMode:YES]; + break; + } + default: + NOTREACHED(); + break; + } +} + +- (BOOL)validateMenuItem:(NSMenuItem*)menuItem { + if([menuItem isEqualTo:inspectorItem_.get()]) { + return action_ && action_->HasPopup(CurrentTabId()); + } + return YES; +} + +@end diff --git a/chrome/browser/ui/cocoa/extensions/extension_infobar_controller.h b/chrome/browser/ui/cocoa/extensions/extension_infobar_controller.h new file mode 100644 index 0000000..b2c9a8e --- /dev/null +++ b/chrome/browser/ui/cocoa/extensions/extension_infobar_controller.h @@ -0,0 +1,41 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_EXTENSIONS_EXTENSION_INFOBAR_CONTROLLER_H_ +#define CHROME_BROWSER_UI_COCOA_EXTENSIONS_EXTENSION_INFOBAR_CONTROLLER_H_ +#pragma once + +#import "chrome/browser/ui/cocoa/infobar_controller.h" + +#import <Cocoa/Cocoa.h> + +#import "base/scoped_nsobject.h" +#include "base/scoped_ptr.h" + +@class ExtensionActionContextMenu; +class ExtensionInfoBarDelegate; +class InfobarBridge; +@class MenuButton; + +@interface ExtensionInfoBarController : InfoBarController { + // The native extension view retrieved from the extension host. Weak. + NSView* extensionView_; + + // The window containing this InfoBar. Weak. + NSWindow* window_; + + // The InfoBar's button with the Extension's icon that launches the context + // menu. + scoped_nsobject<MenuButton> dropdownButton_; + + // The context menu that pops up when the left button is clicked. + scoped_nsobject<ExtensionActionContextMenu> contextMenu_; + + // Helper class to bridge C++ and ObjC functionality together for the infobar. + scoped_ptr<InfobarBridge> bridge_; +} + +@end + +#endif // CHROME_BROWSER_UI_COCOA_EXTENSIONS_EXTENSION_INFOBAR_CONTROLLER_H_ diff --git a/chrome/browser/ui/cocoa/extensions/extension_infobar_controller.mm b/chrome/browser/ui/cocoa/extensions/extension_infobar_controller.mm new file mode 100644 index 0000000..1214370 --- /dev/null +++ b/chrome/browser/ui/cocoa/extensions/extension_infobar_controller.mm @@ -0,0 +1,266 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/extensions/extension_infobar_controller.h" + +#include <cmath> + +#include "app/resource_bundle.h" +#include "chrome/browser/extensions/extension_host.h" +#include "chrome/browser/extensions/extension_infobar_delegate.h" +#include "chrome/browser/tab_contents/tab_contents.h" +#import "chrome/browser/ui/cocoa/animatable_view.h" +#import "chrome/browser/ui/cocoa/extensions/extension_action_context_menu.h" +#import "chrome/browser/ui/cocoa/menu_button.h" +#include "chrome/browser/ui/cocoa/infobar.h" +#include "chrome/common/extensions/extension.h" +#include "chrome/common/extensions/extension_icon_set.h" +#include "chrome/common/extensions/extension_resource.h" +#include "gfx/canvas_skia.h" +#include "grit/theme_resources.h" +#include "skia/ext/skia_utils_mac.h" + +namespace { +const CGFloat kAnimationDuration = 0.12; +const CGFloat kBottomBorderHeightPx = 1.0; +const CGFloat kButtonHeightPx = 26.0; +const CGFloat kButtonLeftMarginPx = 2.0; +const CGFloat kButtonWidthPx = 34.0; +const CGFloat kDropArrowLeftMarginPx = 3.0; +const CGFloat kToolbarMinHeightPx = 36.0; +const CGFloat kToolbarMaxHeightPx = 72.0; +} // namespace + +@interface ExtensionInfoBarController(Private) +// Called when the extension's hosted NSView has been resized. +- (void)extensionViewFrameChanged; +// Returns the clamped height of the extension view to be within the min and max +// values defined above. +- (CGFloat)clampedExtensionViewHeight; +// Adjusts the width of the extension's hosted view to match the window's width +// and sets the proper height for it as well. +- (void)adjustExtensionViewSize; +// Sets the image to be used in the button on the left side of the infobar. +- (void)setButtonImage:(NSImage*)image; +@end + +// A helper class to bridge the asynchronous Skia bitmap loading mechanism to +// the extension's button. +class InfobarBridge : public ExtensionInfoBarDelegate::DelegateObserver, + public ImageLoadingTracker::Observer { + public: + explicit InfobarBridge(ExtensionInfoBarController* owner) + : owner_(owner), + delegate_([owner delegate]->AsExtensionInfoBarDelegate()), + tracker_(this) { + delegate_->set_observer(this); + LoadIcon(); + } + + virtual ~InfobarBridge() { + if (delegate_) + delegate_->set_observer(NULL); + } + + // Load the Extension's icon image. + void LoadIcon() { + const Extension* extension = delegate_->extension_host()->extension(); + ExtensionResource icon_resource = extension->GetIconResource( + Extension::EXTENSION_ICON_BITTY, ExtensionIconSet::MATCH_EXACTLY); + if (!icon_resource.relative_path().empty()) { + tracker_.LoadImage(extension, icon_resource, + gfx::Size(Extension::EXTENSION_ICON_BITTY, + Extension::EXTENSION_ICON_BITTY), + ImageLoadingTracker::DONT_CACHE); + } else { + OnImageLoaded(NULL, icon_resource, 0); + } + } + + // ImageLoadingTracker::Observer implementation. + // TODO(andybons): The infobar view implementations share a lot of the same + // code. Come up with a strategy to share amongst them. + virtual void OnImageLoaded( + SkBitmap* image, ExtensionResource resource, int index) { + if (!delegate_) + return; // The delegate can go away while the image asynchronously loads. + + ResourceBundle& rb = ResourceBundle::GetSharedInstance(); + + // Fall back on the default extension icon on failure. + SkBitmap* icon; + if (!image || image->empty()) + icon = rb.GetBitmapNamed(IDR_EXTENSIONS_SECTION); + else + icon = image; + + SkBitmap* drop_image = rb.GetBitmapNamed(IDR_APP_DROPARROW); + + const int image_size = Extension::EXTENSION_ICON_BITTY; + scoped_ptr<gfx::CanvasSkia> canvas( + new gfx::CanvasSkia( + image_size + kDropArrowLeftMarginPx + drop_image->width(), + image_size, false)); + canvas->DrawBitmapInt(*icon, + 0, 0, icon->width(), icon->height(), + 0, 0, image_size, image_size, + false); + canvas->DrawBitmapInt(*drop_image, + image_size + kDropArrowLeftMarginPx, + image_size / 2); + [owner_ setButtonImage:gfx::SkBitmapToNSImage(canvas->ExtractBitmap())]; + } + + // Overridden from ExtensionInfoBarDelegate::DelegateObserver: + virtual void OnDelegateDeleted() { + delegate_ = NULL; + } + + private: + // Weak. Owns us. + ExtensionInfoBarController* owner_; + + // Weak. + ExtensionInfoBarDelegate* delegate_; + + // Loads the extensions's icon on the file thread. + ImageLoadingTracker tracker_; + + DISALLOW_COPY_AND_ASSIGN(InfobarBridge); +}; + + +@implementation ExtensionInfoBarController + +- (id)initWithDelegate:(InfoBarDelegate*)delegate + window:(NSWindow*)window { + if ((self = [super initWithDelegate:delegate])) { + window_ = window; + dropdownButton_.reset([[MenuButton alloc] init]); + + ExtensionHost* extensionHost = delegate_->AsExtensionInfoBarDelegate()-> + extension_host(); + contextMenu_.reset([[ExtensionActionContextMenu alloc] + initWithExtension:extensionHost->extension() + profile:extensionHost->profile() + extensionAction:NULL]); + // See menu_button.h for documentation on why this is needed. + NSMenuItem* dummyItem = + [[[NSMenuItem alloc] initWithTitle:@"" + action:nil + keyEquivalent:@""] autorelease]; + [contextMenu_ insertItem:dummyItem atIndex:0]; + [dropdownButton_ setAttachedMenu:contextMenu_.get()]; + + bridge_.reset(new InfobarBridge(self)); + } + return self; +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [super dealloc]; +} + +- (void)addAdditionalControls { + [self removeButtons]; + + extensionView_ = delegate_->AsExtensionInfoBarDelegate()->extension_host()-> + view()->native_view(); + + // Add the extension's RenderWidgetHostViewMac to the view hierarchy of the + // InfoBar and make sure to place it below the Close button. + [infoBarView_ addSubview:extensionView_ + positioned:NSWindowBelow + relativeTo:(NSView*)closeButton_]; + + // Add the context menu button to the hierarchy. + [dropdownButton_ setShowsBorderOnlyWhileMouseInside:YES]; + CGFloat buttonY = + std::floor(NSMidY([infoBarView_ frame]) - (kButtonHeightPx / 2.0)) + + kBottomBorderHeightPx; + NSRect buttonFrame = NSMakeRect( + kButtonLeftMarginPx, buttonY, kButtonWidthPx, kButtonHeightPx); + [dropdownButton_ setFrame:buttonFrame]; + [dropdownButton_ setAutoresizingMask:NSViewMinYMargin | NSViewMaxYMargin]; + [infoBarView_ addSubview:dropdownButton_]; + + // Because the parent view has a bottom border, account for it during + // positioning. + NSRect extensionFrame = [extensionView_ frame]; + extensionFrame.origin.y = kBottomBorderHeightPx; + + [extensionView_ setFrame:extensionFrame]; + // The extension's native view will only have a height that is non-zero if it + // already has been loaded and rendered, which is the case when you switch + // back to a tab with an extension infobar within it. The reason this is + // needed is because the extension view's frame will not have changed in the + // above case, so the NSViewFrameDidChangeNotification registered below will + // never fire. + if (NSHeight(extensionFrame) > 0.0) { + NSSize infoBarSize = [[self view] frame].size; + infoBarSize.height = [self clampedExtensionViewHeight] + + kBottomBorderHeightPx; + [[self view] setFrameSize:infoBarSize]; + [infoBarView_ setFrameSize:infoBarSize]; + } + + [self adjustExtensionViewSize]; + + // These two notification handlers are here to ensure the width of the + // native extension view is the same as the browser window's width and that + // the parent infobar view matches the height of the extension's native view. + [[NSNotificationCenter defaultCenter] + addObserver:self + selector:@selector(extensionViewFrameChanged) + name:NSViewFrameDidChangeNotification + object:extensionView_]; + + [[NSNotificationCenter defaultCenter] + addObserver:self + selector:@selector(adjustWidthToFitWindow) + name:NSWindowDidResizeNotification + object:window_]; +} + +- (void)extensionViewFrameChanged { + [self adjustExtensionViewSize]; + + AnimatableView* view = [self animatableView]; + NSRect infoBarFrame = [view frame]; + CGFloat newHeight = [self clampedExtensionViewHeight] + kBottomBorderHeightPx; + [infoBarView_ setPostsFrameChangedNotifications:NO]; + infoBarFrame.size.height = newHeight; + [infoBarView_ setFrame:infoBarFrame]; + [infoBarView_ setPostsFrameChangedNotifications:YES]; + [view animateToNewHeight:newHeight duration:kAnimationDuration]; +} + +- (CGFloat)clampedExtensionViewHeight { + return std::max(kToolbarMinHeightPx, + std::min(NSHeight([extensionView_ frame]), kToolbarMaxHeightPx)); +} + +- (void)adjustExtensionViewSize { + [extensionView_ setPostsFrameChangedNotifications:NO]; + NSSize extensionViewSize = [extensionView_ frame].size; + extensionViewSize.width = NSWidth([window_ frame]); + extensionViewSize.height = [self clampedExtensionViewHeight]; + [extensionView_ setFrameSize:extensionViewSize]; + [extensionView_ setPostsFrameChangedNotifications:YES]; +} + +- (void)setButtonImage:(NSImage*)image { + [dropdownButton_ setImage:image]; +} + +@end + +InfoBar* ExtensionInfoBarDelegate::CreateInfoBar() { + NSWindow* window = [(NSView*)tab_contents_->GetContentNativeView() window]; + ExtensionInfoBarController* controller = + [[ExtensionInfoBarController alloc] initWithDelegate:this + window:window]; + return new InfoBar(controller); +} diff --git a/chrome/browser/ui/cocoa/extensions/extension_install_prompt_controller.h b/chrome/browser/ui/cocoa/extensions/extension_install_prompt_controller.h new file mode 100644 index 0000000..6ae5884 --- /dev/null +++ b/chrome/browser/ui/cocoa/extensions/extension_install_prompt_controller.h @@ -0,0 +1,62 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_EXTENSION_INSTALL_PROMPT_H_ +#define CHROME_BROWSER_UI_COCOA_EXTENSION_INSTALL_PROMPT_H_ +#pragma once + +#include <vector> + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#include "base/string16.h" +#include "chrome/browser/extensions/extension_install_ui.h" +#include "third_party/skia/include/core/SkBitmap.h" + +class Extension; +class Profile; + +// A controller for dialog to let the user install an extension. Created by +// CrxInstaller. +@interface ExtensionInstallPromptController : NSWindowController { +@private + IBOutlet NSImageView* iconView_; + IBOutlet NSTextField* titleField_; + IBOutlet NSTextField* subtitleField_; + IBOutlet NSTextField* warningsField_; + IBOutlet NSBox* warningsBox_; + IBOutlet NSButton* cancelButton_; + IBOutlet NSButton* okButton_; + + NSWindow* parentWindow_; // weak + Profile* profile_; // weak + ExtensionInstallUI::Delegate* delegate_; // weak + + scoped_nsobject<NSString> title_; + scoped_nsobject<NSString> warnings_; + SkBitmap icon_; +} + +@property (nonatomic, readonly) NSImageView* iconView; +@property (nonatomic, readonly) NSTextField* titleField; +@property (nonatomic, readonly) NSTextField* subtitleField; +@property (nonatomic, readonly) NSTextField* warningsField; +@property (nonatomic, readonly) NSBox* warningsBox; +@property (nonatomic, readonly) NSButton* cancelButton; +@property (nonatomic, readonly) NSButton* okButton; + +- (id)initWithParentWindow:(NSWindow*)window + profile:(Profile*)profile + extension:(const Extension*)extension + delegate:(ExtensionInstallUI::Delegate*)delegate + icon:(SkBitmap*)bitmap + warnings:(const std::vector<string16>&)warnings; +- (void)runAsModalSheet; +- (IBAction)cancel:(id)sender; +- (IBAction)ok:(id)sender; + +@end + +#endif /* CHROME_BROWSER_UI_COCOA_EXTENSION_INSTALL_PROMPT_H_ */ diff --git a/chrome/browser/ui/cocoa/extensions/extension_install_prompt_controller.mm b/chrome/browser/ui/cocoa/extensions/extension_install_prompt_controller.mm new file mode 100644 index 0000000..e40fcd4 --- /dev/null +++ b/chrome/browser/ui/cocoa/extensions/extension_install_prompt_controller.mm @@ -0,0 +1,217 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/extensions/extension_install_prompt_controller.h" + +#include "app/l10n_util.h" +#include "app/l10n_util_mac.h" +#include "base/mac_util.h" +#include "base/string_util.h" +#include "base/sys_string_conversions.h" +#include "base/utf_string_conversions.h" +#include "chrome/browser/ui/browser.h" +#include "chrome/browser/ui/browser_list.h" +#include "chrome/browser/ui/browser_window.h" +#include "chrome/common/extensions/extension.h" +#include "grit/generated_resources.h" +#include "skia/ext/skia_utils_mac.h" + +namespace { + +// Maximum height we will adjust controls to when trying to accomodate their +// contents. +const CGFloat kMaxControlHeight = 400; + +// Adjust a control's height so that its content its not clipped. Returns the +// amount the control's height had to be adjusted. +CGFloat AdjustControlHeightToFitContent(NSControl* control) { + NSRect currentRect = [control frame]; + NSRect fitRect = currentRect; + fitRect.size.height = kMaxControlHeight; + CGFloat desiredHeight = [[control cell] cellSizeForBounds:fitRect].height; + CGFloat offset = desiredHeight - currentRect.size.height; + + [control setFrameSize:NSMakeSize(currentRect.size.width, + currentRect.size.height + offset)]; + return offset; +} + +// Moves the control vertically by the specified amount. +void OffsetControlVertically(NSControl* control, CGFloat amount) { + NSPoint origin = [control frame].origin; + origin.y += amount; + [control setFrameOrigin:origin]; +} + +} + +@implementation ExtensionInstallPromptController + +@synthesize iconView = iconView_; +@synthesize titleField = titleField_; +@synthesize subtitleField = subtitleField_; +@synthesize warningsField = warningsField_; +@synthesize warningsBox= warningsBox_; +@synthesize cancelButton = cancelButton_; +@synthesize okButton = okButton_; + +- (id)initWithParentWindow:(NSWindow*)window + profile:(Profile*)profile + extension:(const Extension*)extension + delegate:(ExtensionInstallUI::Delegate*)delegate + icon:(SkBitmap*)icon + warnings:(const std::vector<string16>&)warnings { + NSString* nibpath = nil; + + // We use a different XIB in the case of no warnings, that is a little bit + // more nicely laid out. + if (warnings.empty()) { + nibpath = [mac_util::MainAppBundle() + pathForResource:@"ExtensionInstallPromptNoWarnings" + ofType:@"nib"]; + } else { + nibpath = [mac_util::MainAppBundle() + pathForResource:@"ExtensionInstallPrompt" + ofType:@"nib"]; + } + + if ((self = [super initWithWindowNibPath:nibpath owner:self])) { + parentWindow_ = window; + profile_ = profile; + icon_ = *icon; + delegate_ = delegate; + + title_.reset( + [l10n_util::GetNSStringF(IDS_EXTENSION_INSTALL_PROMPT_HEADING, + UTF8ToUTF16(extension->name())) retain]); + + // We display the warnings as a simple text string, separated by newlines. + if (!warnings.empty()) { + string16 joined_warnings; + for (size_t i = 0; i < warnings.size(); ++i) { + if (i > 0) + joined_warnings += UTF8ToUTF16("\n\n"); + + joined_warnings += warnings[i]; + } + + warnings_.reset( + [base::SysUTF16ToNSString(joined_warnings) retain]); + } + } + return self; +} + +- (void)runAsModalSheet { + [NSApp beginSheet:[self window] + modalForWindow:parentWindow_ + modalDelegate:self + didEndSelector:@selector(didEndSheet:returnCode:contextInfo:) + contextInfo:nil]; +} + +- (IBAction)cancel:(id)sender { + delegate_->InstallUIAbort(); + [NSApp endSheet:[self window]]; +} + +- (IBAction)ok:(id)sender { + delegate_->InstallUIProceed(); + [NSApp endSheet:[self window]]; +} + +- (void)awakeFromNib { + [titleField_ setStringValue:title_.get()]; + + NSImage* image = gfx::SkBitmapToNSImage(icon_); + [iconView_ setImage:image]; + + // Make sure we're the window's delegate as set in the nib. + DCHECK_EQ(self, static_cast<ExtensionInstallPromptController*>( + [[self window] delegate])); + + // If there are any warnings, then we have to do some special layout. + if ([warnings_.get() length] > 0) { + [warningsField_ setStringValue:warnings_.get()]; + + // The dialog is laid out in the NIB exactly how we want it assuming that + // each label fits on one line. However, for each label, we want to allow + // wrapping onto multiple lines. So we accumulate an offset by measuring how + // big each label wants to be, and comparing it to how bit it actually is. + // Then we shift each label down and resize by the appropriate amount, then + // finally resize the window. + CGFloat totalOffset = 0.0; + + // Text fields. + totalOffset += AdjustControlHeightToFitContent(titleField_); + OffsetControlVertically(titleField_, -totalOffset); + + totalOffset += AdjustControlHeightToFitContent(subtitleField_); + OffsetControlVertically(subtitleField_, -totalOffset); + + CGFloat warningsOffset = AdjustControlHeightToFitContent(warningsField_); + OffsetControlVertically(warningsField_, -warningsOffset); + totalOffset += warningsOffset; + + NSRect warningsBoxRect = [warningsBox_ frame]; + warningsBoxRect.origin.y -= totalOffset; + warningsBoxRect.size.height += warningsOffset; + [warningsBox_ setFrame:warningsBoxRect]; + + // buttons are positioned automatically in the XIB. + + // Finally, adjust the window size. + NSRect currentRect = [[self window] frame]; + [[self window] setFrame:NSMakeRect(currentRect.origin.x, + currentRect.origin.y - totalOffset, + currentRect.size.width, + currentRect.size.height + totalOffset) + display:NO]; + } +} + +- (void)didEndSheet:(NSWindow*)sheet + returnCode:(int)returnCode + contextInfo:(void*)contextInfo { + [sheet close]; +} + +- (void)windowWillClose:(NSNotification*)notification { + [self autorelease]; +} + +@end // ExtensionInstallPromptController + + +void ExtensionInstallUI::ShowExtensionInstallUIPrompt2Impl( + Profile* profile, + Delegate* delegate, + const Extension* extension, + SkBitmap* icon, + const std::vector<string16>& warnings) { + Browser* browser = BrowserList::GetLastActiveWithProfile(profile); + if (!browser) { + delegate->InstallUIAbort(); + return; + } + + BrowserWindow* window = browser->window(); + if (!window) { + delegate->InstallUIAbort(); + return; + } + + gfx::NativeWindow native_window = window->GetNativeHandle(); + + ExtensionInstallPromptController* controller = + [[ExtensionInstallPromptController alloc] + initWithParentWindow:native_window + profile:profile + extension:extension + delegate:delegate + icon:icon + warnings:warnings]; + + [controller runAsModalSheet]; +} diff --git a/chrome/browser/ui/cocoa/extensions/extension_install_prompt_controller_unittest.mm b/chrome/browser/ui/cocoa/extensions/extension_install_prompt_controller_unittest.mm new file mode 100644 index 0000000..225aad6 --- /dev/null +++ b/chrome/browser/ui/cocoa/extensions/extension_install_prompt_controller_unittest.mm @@ -0,0 +1,286 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/file_path.h" +#include "base/file_util.h" +#include "base/path_service.h" +#include "base/sys_string_conversions.h" +#include "base/utf_string_conversions.h" +#include "base/values.h" +#import "chrome/browser/extensions/extension_install_ui.h" +#import "chrome/browser/ui/cocoa/extensions/extension_install_prompt_controller.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "chrome/common/chrome_paths.h" +#include "chrome/common/extensions/extension.h" +#include "chrome/common/json_value_serializer.h" +#include "testing/gtest/include/gtest/gtest.h" +#import "testing/gtest_mac.h" +#include "testing/platform_test.h" +#include "third_party/skia/include/core/SkBitmap.h" +#include "webkit/glue/image_decoder.h" + + +// Base class for our tests. +class ExtensionInstallPromptControllerTest : public CocoaTest { +public: + ExtensionInstallPromptControllerTest() { + PathService::Get(chrome::DIR_TEST_DATA, &test_data_dir_); + test_data_dir_ = test_data_dir_.AppendASCII("extensions") + .AppendASCII("install_prompt"); + + LoadIcon(); + LoadExtension(); + } + + protected: + void LoadIcon() { + std::string file_contents; + file_util::ReadFileToString(test_data_dir_.AppendASCII("icon.png"), + &file_contents); + + webkit_glue::ImageDecoder decoder; + icon_ = decoder.Decode( + reinterpret_cast<const unsigned char*>(file_contents.c_str()), + file_contents.length()); + } + + void LoadExtension() { + FilePath path = test_data_dir_.AppendASCII("extension.json"); + + std::string error; + JSONFileValueSerializer serializer(path); + scoped_ptr<DictionaryValue> value(static_cast<DictionaryValue*>( + serializer.Deserialize(NULL, &error))); + if (!value.get()) { + LOG(ERROR) << error; + return; + } + + extension_ = Extension::Create( + path.DirName(), Extension::INVALID, *value, false, &error); + if (!extension_.get()) { + LOG(ERROR) << error; + return; + } + } + + BrowserTestHelper helper_; + FilePath test_data_dir_; + SkBitmap icon_; + scoped_refptr<Extension> extension_; +}; + + +// Mock out the ExtensionInstallUI::Delegate interface so we can ensure the +// dialog is interacting with it correctly. +class MockExtensionInstallUIDelegate : public ExtensionInstallUI::Delegate { + public: + MockExtensionInstallUIDelegate() + : proceed_count_(0), + abort_count_(0) {} + + // ExtensionInstallUI::Delegate overrides. + virtual void InstallUIProceed() { + proceed_count_++; + } + + virtual void InstallUIAbort() { + abort_count_++; + } + + int proceed_count() { return proceed_count_; } + int abort_count() { return abort_count_; } + + protected: + int proceed_count_; + int abort_count_; +}; + +// Test that we can load the two kinds of prompts correctly, that the outlets +// are hooked up, and that the dialog calls cancel when cancel is pressed. +TEST_F(ExtensionInstallPromptControllerTest, BasicsNormalCancel) { + scoped_ptr<MockExtensionInstallUIDelegate> delegate( + new MockExtensionInstallUIDelegate); + + std::vector<string16> warnings; + warnings.push_back(UTF8ToUTF16("warning 1")); + + scoped_nsobject<ExtensionInstallPromptController> + controller([[ExtensionInstallPromptController alloc] + initWithParentWindow:test_window() + profile:helper_.profile() + extension:extension_.get() + delegate:delegate.get() + icon:&icon_ + warnings:warnings]); + + [controller window]; // force nib load + + // Test the right nib loaded. + EXPECT_NSEQ(@"ExtensionInstallPrompt", [controller windowNibName]); + + // Check all the controls. + // Make sure everything is non-nil, and that the fields that are + // auto-translated don't start with a caret (that would indicate that they + // were not translated). + EXPECT_TRUE([controller iconView] != nil); + EXPECT_TRUE([[controller iconView] image] != nil); + + EXPECT_TRUE([controller titleField] != nil); + EXPECT_NE(0u, [[[controller titleField] stringValue] length]); + + EXPECT_TRUE([controller subtitleField] != nil); + EXPECT_NE(0u, [[[controller subtitleField] stringValue] length]); + EXPECT_NE('^', [[[controller subtitleField] stringValue] characterAtIndex:0]); + + EXPECT_TRUE([controller warningsField] != nil); + EXPECT_NSEQ([[controller warningsField] stringValue], + base::SysUTF16ToNSString(warnings[0])); + + EXPECT_TRUE([controller warningsBox] != nil); + + EXPECT_TRUE([controller cancelButton] != nil); + EXPECT_NE(0u, [[[controller cancelButton] stringValue] length]); + EXPECT_NE('^', [[[controller cancelButton] stringValue] characterAtIndex:0]); + + EXPECT_TRUE([controller okButton] != nil); + EXPECT_NE(0u, [[[controller okButton] stringValue] length]); + EXPECT_NE('^', [[[controller okButton] stringValue] characterAtIndex:0]); + + // Test that cancel calls our delegate. + [controller cancel:nil]; + EXPECT_EQ(1, delegate->abort_count()); + EXPECT_EQ(0, delegate->proceed_count()); +} + + +TEST_F(ExtensionInstallPromptControllerTest, BasicsNormalOK) { + scoped_ptr<MockExtensionInstallUIDelegate> delegate( + new MockExtensionInstallUIDelegate); + + std::vector<string16> warnings; + warnings.push_back(UTF8ToUTF16("warning 1")); + + scoped_nsobject<ExtensionInstallPromptController> + controller([[ExtensionInstallPromptController alloc] + initWithParentWindow:test_window() + profile:helper_.profile() + extension:extension_.get() + delegate:delegate.get() + icon:&icon_ + warnings:warnings]); + + [controller window]; // force nib load + [controller ok:nil]; + + EXPECT_EQ(0, delegate->abort_count()); + EXPECT_EQ(1, delegate->proceed_count()); +} + +// Test that controls get repositioned when there are two warnings vs one +// warning. +TEST_F(ExtensionInstallPromptControllerTest, MultipleWarnings) { + scoped_ptr<MockExtensionInstallUIDelegate> delegate1( + new MockExtensionInstallUIDelegate); + scoped_ptr<MockExtensionInstallUIDelegate> delegate2( + new MockExtensionInstallUIDelegate); + + std::vector<string16> one_warning; + one_warning.push_back(UTF8ToUTF16("warning 1")); + + std::vector<string16> two_warnings; + two_warnings.push_back(UTF8ToUTF16("warning 1")); + two_warnings.push_back(UTF8ToUTF16("warning 2")); + + scoped_nsobject<ExtensionInstallPromptController> + controller1([[ExtensionInstallPromptController alloc] + initWithParentWindow:test_window() + profile:helper_.profile() + extension:extension_.get() + delegate:delegate1.get() + icon:&icon_ + warnings:one_warning]); + + [controller1 window]; // force nib load + + scoped_nsobject<ExtensionInstallPromptController> + controller2([[ExtensionInstallPromptController alloc] + initWithParentWindow:test_window() + profile:helper_.profile() + extension:extension_.get() + delegate:delegate2.get() + icon:&icon_ + warnings:two_warnings]); + + [controller2 window]; // force nib load + + // Test control positioning. We don't test exact positioning because we don't + // want this to depend on string details and localization. But we do know the + // relative effect that adding a second warning should have on the layout. + ASSERT_LT([[controller1 window] frame].size.height, + [[controller2 window] frame].size.height); + + ASSERT_LT([[controller1 warningsField] frame].size.height, + [[controller2 warningsField] frame].size.height); + + ASSERT_LT([[controller1 warningsBox] frame].size.height, + [[controller2 warningsBox] frame].size.height); + + ASSERT_EQ([[controller1 warningsBox] frame].origin.y, + [[controller2 warningsBox] frame].origin.y); + + ASSERT_LT([[controller1 subtitleField] frame].origin.y, + [[controller2 subtitleField] frame].origin.y); + + ASSERT_LT([[controller1 titleField] frame].origin.y, + [[controller2 titleField] frame].origin.y); +} + +// Test that we can load the skinny prompt correctly, and that the outlets are +// are hooked up. +TEST_F(ExtensionInstallPromptControllerTest, BasicsSkinny) { + scoped_ptr<MockExtensionInstallUIDelegate> delegate( + new MockExtensionInstallUIDelegate); + + // No warnings should trigger skinny prompt. + std::vector<string16> warnings; + + scoped_nsobject<ExtensionInstallPromptController> + controller([[ExtensionInstallPromptController alloc] + initWithParentWindow:test_window() + profile:helper_.profile() + extension:extension_.get() + delegate:delegate.get() + icon:&icon_ + warnings:warnings]); + + [controller window]; // force nib load + + // Test the right nib loaded. + EXPECT_NSEQ(@"ExtensionInstallPromptNoWarnings", [controller windowNibName]); + + // Check all the controls. + // In the skinny prompt, only the icon, title and buttons are non-nill. + // Everything else is nil. + EXPECT_TRUE([controller iconView] != nil); + EXPECT_TRUE([[controller iconView] image] != nil); + + EXPECT_TRUE([controller titleField] != nil); + EXPECT_NE(0u, [[[controller titleField] stringValue] length]); + + EXPECT_TRUE([controller cancelButton] != nil); + EXPECT_NE(0u, [[[controller cancelButton] stringValue] length]); + EXPECT_NE('^', [[[controller cancelButton] stringValue] characterAtIndex:0]); + + EXPECT_TRUE([controller okButton] != nil); + EXPECT_NE(0u, [[[controller okButton] stringValue] length]); + EXPECT_NE('^', [[[controller okButton] stringValue] characterAtIndex:0]); + + EXPECT_TRUE([controller subtitleField] == nil); + EXPECT_TRUE([controller warningsField] == nil); + EXPECT_TRUE([controller warningsBox] == nil); +} diff --git a/chrome/browser/ui/cocoa/extensions/extension_popup_controller.h b/chrome/browser/ui/cocoa/extensions/extension_popup_controller.h new file mode 100644 index 0000000..91bdba6 --- /dev/null +++ b/chrome/browser/ui/cocoa/extensions/extension_popup_controller.h @@ -0,0 +1,99 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_EXTENSIONS_EXTENSION_POPUP_CONTROLLER_H_ +#define CHROME_BROWSER_UI_COCOA_EXTENSIONS_EXTENSION_POPUP_CONTROLLER_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#import "base/cocoa_protocols_mac.h" +#import "base/scoped_nsobject.h" +#include "base/scoped_ptr.h" +#include "chrome/browser/ui/cocoa/info_bubble_view.h" +#include "googleurl/src/gurl.h" + + +class Browser; +class DevtoolsNotificationBridge; +class ExtensionHost; +@class InfoBubbleWindow; +class NotificationRegistrar; + +// This controller manages a single browser action popup that can appear once a +// user has clicked on a browser action button. It instantiates the extension +// popup view showing the content and resizes the window to accomodate any size +// changes as they occur. +// +// There can only be one browser action popup open at a time, so a static +// variable holds a reference to the current popup. +@interface ExtensionPopupController : NSWindowController<NSWindowDelegate> { + @private + // The native extension view retrieved from the extension host. Weak. + NSView* extensionView_; + + // The popup's parent window. Weak. + NSWindow* parentWindow_; + + // Where the window is anchored. Right now it's the bottom center of the + // browser action button. + NSPoint anchor_; + + // The current frame of the extension view. Cached to prevent setting the + // frame if the size hasn't changed. + NSRect extensionFrame_; + + // The extension host object. + scoped_ptr<ExtensionHost> host_; + + scoped_ptr<NotificationRegistrar> registrar_; + scoped_ptr<DevtoolsNotificationBridge> notificationBridge_; + + // Whether the popup has a devtools window attached to it. + BOOL beingInspected_; +} + +// Returns the ExtensionHost object associated with this popup. +- (ExtensionHost*)extensionHost; + +// Starts the process of showing the given popup URL. Instantiates an +// ExtensionPopupController with the parent window retrieved from |browser|, a +// host for the popup created by the extension process manager specific to the +// browser profile and the remaining arguments |anchoredAt| and |arrowLocation|. +// |anchoredAt| is expected to be in the window's coordinates at the bottom +// center of the browser action button. +// The actual display of the popup is delayed until the page contents finish +// loading in order to minimize UI flashing and resizing. +// Passing YES to |devMode| will launch the webkit inspector for the popup, +// and prevent the popup from closing when focus is lost. It will be closed +// after the inspector is closed, or another popup is opened. ++ (ExtensionPopupController*)showURL:(GURL)url + inBrowser:(Browser*)browser + anchoredAt:(NSPoint)anchoredAt + arrowLocation:(info_bubble::BubbleArrowLocation) + arrowLocation + devMode:(BOOL)devMode; + +// Returns the controller used to display the popup being shown. If no popup is +// currently open, then nil is returned. Static because only one extension popup +// window can be open at a time. ++ (ExtensionPopupController*)popup; + +// Whether the popup is in the process of closing (via Core Animation). +- (BOOL)isClosing; + +// Show the dev tools attached to the popup. +- (void)showDevTools; +@end + +@interface ExtensionPopupController(TestingAPI) +// Returns a weak pointer to the current popup's view. +- (NSView*)view; +// Returns the minimum allowed size for an extension popup. ++ (NSSize)minPopupSize; +// Returns the maximum allowed size for an extension popup. ++ (NSSize)maxPopupSize; +@end + +#endif // CHROME_BROWSER_UI_COCOA_EXTENSIONS_EXTENSION_POPUP_CONTROLLER_H_ diff --git a/chrome/browser/ui/cocoa/extensions/extension_popup_controller.mm b/chrome/browser/ui/cocoa/extensions/extension_popup_controller.mm new file mode 100644 index 0000000..bf3408e --- /dev/null +++ b/chrome/browser/ui/cocoa/extensions/extension_popup_controller.mm @@ -0,0 +1,338 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/extensions/extension_popup_controller.h" + +#include <algorithm> + +#include "chrome/browser/debugger/devtools_manager.h" +#include "chrome/browser/extensions/extension_host.h" +#include "chrome/browser/extensions/extension_process_manager.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/ui/browser.h" +#import "chrome/browser/ui/cocoa/browser_window_cocoa.h" +#import "chrome/browser/ui/cocoa/extension_view_mac.h" +#import "chrome/browser/ui/cocoa/info_bubble_window.h" +#include "chrome/common/notification_registrar.h" +#include "chrome/common/notification_service.h" + +namespace { +// The duration for any animations that might be invoked by this controller. +const NSTimeInterval kAnimationDuration = 0.2; + +// There should only be one extension popup showing at one time. Keep a +// reference to it here. +static ExtensionPopupController* gPopup; + +// Given a value and a rage, clamp the value into the range. +CGFloat Clamp(CGFloat value, CGFloat min, CGFloat max) { + return std::max(min, std::min(max, value)); +} + +} // namespace + +class DevtoolsNotificationBridge : public NotificationObserver { + public: + explicit DevtoolsNotificationBridge(ExtensionPopupController* controller) + : controller_(controller) {} + + void Observe(NotificationType type, + const NotificationSource& source, + const NotificationDetails& details) { + switch (type.value) { + case NotificationType::EXTENSION_HOST_DID_STOP_LOADING: { + if (Details<ExtensionHost>([controller_ extensionHost]) == details) + [controller_ showDevTools]; + break; + } + case NotificationType::DEVTOOLS_WINDOW_CLOSING: { + RenderViewHost* rvh = [controller_ extensionHost]->render_view_host(); + if (Details<RenderViewHost>(rvh) == details) + // Allow the devtools to finish detaching before we close the popup + [controller_ performSelector:@selector(close) + withObject:nil + afterDelay:0.0]; + break; + } + default: { + NOTREACHED() << "Received unexpected notification"; + break; + } + }; + } + + private: + ExtensionPopupController* controller_; +}; + +@interface ExtensionPopupController(Private) +// Callers should be using the public static method for initialization. +// NOTE: This takes ownership of |host|. +- (id)initWithHost:(ExtensionHost*)host + parentWindow:(NSWindow*)parentWindow + anchoredAt:(NSPoint)anchoredAt + arrowLocation:(info_bubble::BubbleArrowLocation)arrowLocation + devMode:(BOOL)devMode; + +// Called when the extension's hosted NSView has been resized. +- (void)extensionViewFrameChanged; +@end + +@implementation ExtensionPopupController + +- (id)initWithHost:(ExtensionHost*)host + parentWindow:(NSWindow*)parentWindow + anchoredAt:(NSPoint)anchoredAt + arrowLocation:(info_bubble::BubbleArrowLocation)arrowLocation + devMode:(BOOL)devMode { + + parentWindow_ = parentWindow; + anchor_ = [parentWindow convertBaseToScreen:anchoredAt]; + host_.reset(host); + beingInspected_ = devMode; + + scoped_nsobject<InfoBubbleView> view([[InfoBubbleView alloc] init]); + if (!view.get()) + return nil; + [view setArrowLocation:arrowLocation]; + [view setBubbleType:info_bubble::kWhiteInfoBubble]; + + host->view()->set_is_toolstrip(NO); + + extensionView_ = host->view()->native_view(); + NSNotificationCenter* center = [NSNotificationCenter defaultCenter]; + [center addObserver:self + selector:@selector(extensionViewFrameChanged) + name:NSViewFrameDidChangeNotification + object:extensionView_]; + + // Watch to see if the parent window closes, and if so, close this one. + [center addObserver:self + selector:@selector(parentWindowWillClose:) + name:NSWindowWillCloseNotification + object:parentWindow_]; + + [view addSubview:extensionView_]; + scoped_nsobject<InfoBubbleWindow> window( + [[InfoBubbleWindow alloc] + initWithContentRect:NSZeroRect + styleMask:NSBorderlessWindowMask + backing:NSBackingStoreBuffered + defer:YES]); + if (!window.get()) + return nil; + + [window setDelegate:self]; + [window setContentView:view]; + self = [super initWithWindow:window]; + if (beingInspected_) { + // Listen for the the devtools window closing. + notificationBridge_.reset(new DevtoolsNotificationBridge(self)); + registrar_.reset(new NotificationRegistrar); + registrar_->Add(notificationBridge_.get(), + NotificationType::DEVTOOLS_WINDOW_CLOSING, + Source<Profile>(host->profile())); + registrar_->Add(notificationBridge_.get(), + NotificationType::EXTENSION_HOST_DID_STOP_LOADING, + Source<Profile>(host->profile())); + } + return self; +} + +- (void)showDevTools { + DevToolsManager::GetInstance()->OpenDevToolsWindow(host_->render_view_host()); +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [super dealloc]; +} + +- (void)parentWindowWillClose:(NSNotification*)notification { + [self close]; +} + +- (void)windowWillClose:(NSNotification *)notification { + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [gPopup autorelease]; + gPopup = nil; +} + +- (void)windowDidResignKey:(NSNotification *)notification { + NSWindow* window = [self window]; + DCHECK_EQ([notification object], window); + // If the window isn't visible, it is already closed, and this notification + // has been sent as part of the closing operation, so no need to close. + if ([window isVisible] && !beingInspected_) { + [self close]; + } +} + +- (void)close { + [parentWindow_ removeChildWindow:[self window]]; + + // No longer have a parent window, so nil out the pointer and deregister for + // notifications. + NSNotificationCenter* center = [NSNotificationCenter defaultCenter]; + [center removeObserver:self + name:NSWindowWillCloseNotification + object:parentWindow_]; + parentWindow_ = nil; + [super close]; +} + +- (BOOL)isClosing { + return [static_cast<InfoBubbleWindow*>([self window]) isClosing]; +} + +- (ExtensionHost*)extensionHost { + return host_.get(); +} + ++ (ExtensionPopupController*)showURL:(GURL)url + inBrowser:(Browser*)browser + anchoredAt:(NSPoint)anchoredAt + arrowLocation:(info_bubble::BubbleArrowLocation) + arrowLocation + devMode:(BOOL)devMode { + DCHECK([NSThread isMainThread]); + DCHECK(browser); + if (!browser) + return nil; + + ExtensionProcessManager* manager = + browser->profile()->GetExtensionProcessManager(); + DCHECK(manager); + if (!manager) + return nil; + + ExtensionHost* host = manager->CreatePopup(url, browser); + DCHECK(host); + if (!host) + return nil; + + // Make absolutely sure that no popups are leaked. + if (gPopup) { + if ([[gPopup window] isVisible]) + [gPopup close]; + + [gPopup autorelease]; + gPopup = nil; + } + DCHECK(!gPopup); + + // Takes ownership of |host|. Also will autorelease itself when the popup is + // closed, so no need to do that here. + gPopup = [[ExtensionPopupController alloc] + initWithHost:host + parentWindow:browser->window()->GetNativeHandle() + anchoredAt:anchoredAt + arrowLocation:arrowLocation + devMode:devMode]; + return gPopup; +} + ++ (ExtensionPopupController*)popup { + return gPopup; +} + +- (void)extensionViewFrameChanged { + // If there are no changes in the width or height of the frame, then ignore. + if (NSEqualSizes([extensionView_ frame].size, extensionFrame_.size)) + return; + + extensionFrame_ = [extensionView_ frame]; + // Constrain the size of the view. + [extensionView_ setFrameSize:NSMakeSize( + Clamp(NSWidth(extensionFrame_), + ExtensionViewMac::kMinWidth, + ExtensionViewMac::kMaxWidth), + Clamp(NSHeight(extensionFrame_), + ExtensionViewMac::kMinHeight, + ExtensionViewMac::kMaxHeight))]; + + // Pad the window by half of the rounded corner radius to prevent the + // extension's view from bleeding out over the corners. + CGFloat inset = info_bubble::kBubbleCornerRadius / 2.0; + [extensionView_ setFrameOrigin:NSMakePoint(inset, inset)]; + + NSRect frame = [extensionView_ frame]; + frame.size.height += info_bubble::kBubbleArrowHeight + + info_bubble::kBubbleCornerRadius; + frame.size.width += info_bubble::kBubbleCornerRadius; + frame = [extensionView_ convertRectToBase:frame]; + // Adjust the origin according to the height and width so that the arrow is + // positioned correctly at the middle and slightly down from the button. + NSPoint windowOrigin = anchor_; + NSSize offsets = NSMakeSize(info_bubble::kBubbleArrowXOffset + + info_bubble::kBubbleArrowWidth / 2.0, + info_bubble::kBubbleArrowHeight / 2.0); + offsets = [extensionView_ convertSize:offsets toView:nil]; + windowOrigin.x -= NSWidth(frame) - offsets.width; + windowOrigin.y -= NSHeight(frame) - offsets.height; + frame.origin = windowOrigin; + + // Is the window still animating in? If so, then cancel that and create a new + // animation setting the opacity and new frame value. Otherwise the current + // animation will continue after this frame is set, reverting the frame to + // what it was when the animation started. + NSWindow* window = [self window]; + if ([window isVisible] && [[window animator] alphaValue] < 1.0) { + [NSAnimationContext beginGrouping]; + [[NSAnimationContext currentContext] setDuration:kAnimationDuration]; + [[window animator] setAlphaValue:1.0]; + [[window animator] setFrame:frame display:YES]; + [NSAnimationContext endGrouping]; + } else { + [window setFrame:frame display:YES]; + } + + // A NSViewFrameDidChangeNotification won't be sent until the extension view + // content is loaded. The window is hidden on init, so show it the first time + // the notification is fired (and consequently the view contents have loaded). + if (![window isVisible]) { + [self showWindow:self]; + } +} + +// We want this to be a child of a browser window. addChildWindow: (called from +// this function) will bring the window on-screen; unfortunately, +// [NSWindowController showWindow:] will also bring it on-screen (but will cause +// unexpected changes to the window's position). We cannot have an +// addChildWindow: and a subsequent showWindow:. Thus, we have our own version. +- (void)showWindow:(id)sender { + [parentWindow_ addChildWindow:[self window] ordered:NSWindowAbove]; + [[self window] makeKeyAndOrderFront:self]; +} + +- (void)windowDidResize:(NSNotification*)notification { + // Let the extension view know, so that it can tell plugins. + if (host_->view()) + host_->view()->WindowFrameChanged(); +} + +- (void)windowDidMove:(NSNotification*)notification { + // Let the extension view know, so that it can tell plugins. + if (host_->view()) + host_->view()->WindowFrameChanged(); +} + +// Private (TestingAPI) +- (NSView*)view { + return extensionView_; +} + +// Private (TestingAPI) ++ (NSSize)minPopupSize { + NSSize minSize = {ExtensionViewMac::kMinWidth, ExtensionViewMac::kMinHeight}; + return minSize; +} + +// Private (TestingAPI) ++ (NSSize)maxPopupSize { + NSSize maxSize = {ExtensionViewMac::kMaxWidth, ExtensionViewMac::kMaxHeight}; + return maxSize; +} + +@end diff --git a/chrome/browser/ui/cocoa/extensions/extension_popup_controller_unittest.mm b/chrome/browser/ui/cocoa/extensions/extension_popup_controller_unittest.mm new file mode 100644 index 0000000..0e74e5e --- /dev/null +++ b/chrome/browser/ui/cocoa/extensions/extension_popup_controller_unittest.mm @@ -0,0 +1,89 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/scoped_nsobject.h" +#include "chrome/browser/extensions/extension_process_manager.h" +#include "chrome/browser/extensions/extensions_service.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#include "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "chrome/browser/ui/cocoa/extensions/extension_popup_controller.h" +#include "chrome/test/testing_profile.h" + +namespace { + +class ExtensionTestingProfile : public TestingProfile { + public: + ExtensionTestingProfile() {} + + FilePath GetExtensionsInstallDir() { + return GetPath().AppendASCII(ExtensionsService::kInstallDirectoryName); + } + + void InitExtensionProfile() { + DCHECK(!GetExtensionProcessManager()); + DCHECK(!GetExtensionsService()); + + manager_.reset(ExtensionProcessManager::Create(this)); + service_ = new ExtensionsService(this, + CommandLine::ForCurrentProcess(), + GetExtensionsInstallDir(), + false); + service_->set_extensions_enabled(true); + service_->set_show_extensions_prompts(false); + service_->ClearProvidersForTesting(); + service_->Init(); + } + + void ShutdownExtensionProfile() { + manager_.reset(); + service_ = NULL; + } + + virtual ExtensionProcessManager* GetExtensionProcessManager() { + return manager_.get(); + } + + virtual ExtensionsService* GetExtensionsService() { + return service_.get(); + } + + private: + scoped_ptr<ExtensionProcessManager> manager_; + scoped_refptr<ExtensionsService> service_; + + DISALLOW_COPY_AND_ASSIGN(ExtensionTestingProfile); +}; + +class ExtensionPopupControllerTest : public CocoaTest { + public: + virtual void SetUp() { + CocoaTest::SetUp(); + profile_.reset(new ExtensionTestingProfile()); + profile_->InitExtensionProfile(); + browser_.reset(new Browser(Browser::TYPE_NORMAL, profile_.get())); + [ExtensionPopupController showURL:GURL("http://google.com") + inBrowser:browser_.get() + anchoredAt:NSZeroPoint + arrowLocation:info_bubble::kTopRight + devMode:NO]; + } + virtual void TearDown() { + profile_->ShutdownExtensionProfile(); + [[ExtensionPopupController popup] close]; + CocoaTest::TearDown(); + } + + protected: + scoped_ptr<Browser> browser_; + scoped_ptr<ExtensionTestingProfile> profile_; +}; + +TEST_F(ExtensionPopupControllerTest, DISABLED_Basics) { + // TODO(andybons): Better mechanisms for mocking out the extensions service + // and extensions for easy testing need to be implemented. + // http://crbug.com/28316 + EXPECT_TRUE([ExtensionPopupController popup]); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/external_protocol_dialog.h b/chrome/browser/ui/cocoa/external_protocol_dialog.h new file mode 100644 index 0000000..224c280 --- /dev/null +++ b/chrome/browser/ui/cocoa/external_protocol_dialog.h @@ -0,0 +1,19 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/time.h" +#include "googleurl/src/gurl.h" + +@interface ExternalProtocolDialogController : NSObject { + @private + NSAlert* alert_; + GURL url_; + base::Time creation_time_; +}; + +- (id)initWithGURL:(const GURL*)url; + +@end diff --git a/chrome/browser/ui/cocoa/external_protocol_dialog.mm b/chrome/browser/ui/cocoa/external_protocol_dialog.mm new file mode 100644 index 0000000..8dafab9 --- /dev/null +++ b/chrome/browser/ui/cocoa/external_protocol_dialog.mm @@ -0,0 +1,152 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/external_protocol_dialog.h" + +#include "app/l10n_util_mac.h" +#include "base/message_loop.h" +#include "base/metrics/histogram.h" +#include "base/string_util.h" +#include "base/sys_string_conversions.h" +#include "base/utf_string_conversions.h" +#include "chrome/browser/external_protocol_handler.h" +#include "grit/chromium_strings.h" +#include "grit/generated_resources.h" + +/////////////////////////////////////////////////////////////////////////////// +// ExternalProtocolHandler + +// static +void ExternalProtocolHandler::RunExternalProtocolDialog( + const GURL& url, int render_process_host_id, int routing_id) { + [[ExternalProtocolDialogController alloc] initWithGURL:&url]; +} + +/////////////////////////////////////////////////////////////////////////////// +// ExternalProtocolDialogController + +@interface ExternalProtocolDialogController(Private) +- (void)alertEnded:(NSAlert *)alert + returnCode:(int)returnCode + contextInfo:(void*)contextInfo; +- (string16)appNameForProtocol; +@end + +@implementation ExternalProtocolDialogController +- (id)initWithGURL:(const GURL*)url { + DCHECK_EQ(MessageLoop::TYPE_UI, MessageLoop::current()->type()); + + url_ = *url; + creation_time_ = base::Time::Now(); + + string16 appName = [self appNameForProtocol]; + if (appName.length() == 0) { + // No registered apps for this protocol; give up and go home. + [self autorelease]; + return nil; + } + + alert_ = [[NSAlert alloc] init]; + + [alert_ setMessageText: + l10n_util::GetNSStringWithFixup(IDS_EXTERNAL_PROTOCOL_TITLE)]; + + NSButton* allowButton = [alert_ addButtonWithTitle: + l10n_util::GetNSStringWithFixup(IDS_EXTERNAL_PROTOCOL_OK_BUTTON_TEXT)]; + [allowButton setKeyEquivalent:@""]; // disallow as default + [alert_ addButtonWithTitle: + l10n_util::GetNSStringWithFixup( + IDS_EXTERNAL_PROTOCOL_CANCEL_BUTTON_TEXT)]; + + const int kMaxUrlWithoutSchemeSize = 256; + std::wstring elided_url_without_scheme; + ElideString(ASCIIToWide(url_.possibly_invalid_spec()), + kMaxUrlWithoutSchemeSize, &elided_url_without_scheme); + + NSString* urlString = l10n_util::GetNSStringFWithFixup( + IDS_EXTERNAL_PROTOCOL_INFORMATION, + ASCIIToUTF16(url_.scheme() + ":"), + WideToUTF16(elided_url_without_scheme)); + NSString* appString = l10n_util::GetNSStringFWithFixup( + IDS_EXTERNAL_PROTOCOL_APPLICATION_TO_LAUNCH, + appName); + NSString* warningString = + l10n_util::GetNSStringWithFixup(IDS_EXTERNAL_PROTOCOL_WARNING); + NSString* informativeText = + [NSString stringWithFormat:@"%@\n\n%@\n\n%@", + urlString, + appString, + warningString]; + + [alert_ setInformativeText:informativeText]; + + [alert_ setShowsSuppressionButton:YES]; + [[alert_ suppressionButton] setTitle: + l10n_util::GetNSStringWithFixup(IDS_EXTERNAL_PROTOCOL_CHECKBOX_TEXT)]; + + [alert_ beginSheetModalForWindow:nil // nil here makes it app-modal + modalDelegate:self + didEndSelector:@selector(alertEnded:returnCode:contextInfo:) + contextInfo:nil]; + + return self; +} + +- (void)dealloc { + [alert_ release]; + + [super dealloc]; +} + +- (void)alertEnded:(NSAlert *)alert + returnCode:(int)returnCode + contextInfo:(void*)contextInfo { + ExternalProtocolHandler::BlockState blockState = + ExternalProtocolHandler::UNKNOWN; + switch (returnCode) { + case NSAlertFirstButtonReturn: + blockState = ExternalProtocolHandler::DONT_BLOCK; + break; + case NSAlertSecondButtonReturn: + blockState = ExternalProtocolHandler::BLOCK; + break; + default: + NOTREACHED(); + } + + // Set the "don't warn me again" info. + if ([[alert_ suppressionButton] state] == NSOnState) + ExternalProtocolHandler::SetBlockState(url_.scheme(), blockState); + + if (blockState == ExternalProtocolHandler::DONT_BLOCK) { + UMA_HISTOGRAM_LONG_TIMES("clickjacking.launch_url", + base::Time::Now() - creation_time_); + + ExternalProtocolHandler::LaunchUrlWithoutSecurityCheck(url_); + } + + [self autorelease]; +} + +- (string16)appNameForProtocol { + NSURL* url = [NSURL URLWithString: + base::SysUTF8ToNSString(url_.possibly_invalid_spec())]; + CFURLRef openingApp = NULL; + OSStatus status = LSGetApplicationForURL((CFURLRef)url, + kLSRolesAll, + NULL, + &openingApp); + if (status != noErr) { + // likely kLSApplicationNotFoundErr + return string16(); + } + NSString* appPath = [(NSURL*)openingApp path]; + CFRelease(openingApp); // NOT A BUG; LSGetApplicationForURL retains for us + NSString* appDisplayName = + [[NSFileManager defaultManager] displayNameAtPath:appPath]; + + return base::SysNSStringToUTF16(appDisplayName); +} + +@end diff --git a/chrome/browser/ui/cocoa/fast_resize_view.h b/chrome/browser/ui/cocoa/fast_resize_view.h new file mode 100644 index 0000000..1da6004 --- /dev/null +++ b/chrome/browser/ui/cocoa/fast_resize_view.h @@ -0,0 +1,29 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_FAST_RESIZE_VIEW_H_ +#define CHROME_BROWSER_UI_COCOA_FAST_RESIZE_VIEW_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +// A Cocoa view that supports an alternate resizing mode, normally used when +// animations are in progress. In normal resizing mode, subviews are sized to +// completely fill this view's bounds. In fast resizing mode, the subviews' +// size is not changed and the subview is clipped to fit, if necessary. Fast +// resize mode is useful when animating a view that normally takes a significant +// amount of time to relayout and redraw when its size is changed. +@interface FastResizeView : NSView { + @private + BOOL fastResizeMode_; +} + +// Turns fast resizing mode on or off, which determines how this view resizes +// its subviews. Turning fast resizing mode off has the effect of immediately +// resizing subviews to fit; callers do not need to explictly call |setFrame:| +// to trigger a resize. +- (void)setFastResizeMode:(BOOL)fastResizeMode; +@end + +#endif // CHROME_BROWSER_UI_COCOA_FAST_RESIZE_VIEW_H_ diff --git a/chrome/browser/ui/cocoa/fast_resize_view.mm b/chrome/browser/ui/cocoa/fast_resize_view.mm new file mode 100644 index 0000000..8755bc4 --- /dev/null +++ b/chrome/browser/ui/cocoa/fast_resize_view.mm @@ -0,0 +1,65 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> +#import "chrome/browser/ui/cocoa/fast_resize_view.h" + +#include "base/logging.h" + +@interface FastResizeView (PrivateMethods) +// Lays out this views subviews. If fast resize mode is on, does not resize any +// subviews and instead pegs them to the top left. If fast resize mode is off, +// sets the subviews' frame to be equal to this view's bounds. +- (void)layoutSubviews; +@end + +@implementation FastResizeView +- (void)setFastResizeMode:(BOOL)fastResizeMode { + fastResizeMode_ = fastResizeMode; + + // Force a relayout when coming out of fast resize mode. + if (!fastResizeMode_) + [self layoutSubviews]; +} + +- (void)resizeSubviewsWithOldSize:(NSSize)oldSize { + [self layoutSubviews]; +} + +- (void)drawRect:(NSRect)dirtyRect { + // If we are in fast resize mode, our subviews may not completely cover our + // bounds, so we fill with white. If we are not in fast resize mode, we do + // not need to draw anything. + if (fastResizeMode_) { + [[NSColor whiteColor] set]; + NSRectFill(dirtyRect); + } +} + + +@end + +@implementation FastResizeView (PrivateMethods) +- (void)layoutSubviews { + // There should never be more than one subview. There can be zero, if we are + // in the process of switching tabs or closing the window. In those cases, no + // layout is needed. + NSArray* subviews = [self subviews]; + DCHECK([subviews count] <= 1); + if ([subviews count] < 1) + return; + + NSView* subview = [subviews objectAtIndex:0]; + NSRect bounds = [self bounds]; + + if (fastResizeMode_) { + NSRect frame = [subview frame]; + frame.origin.x = 0; + frame.origin.y = NSHeight(bounds) - NSHeight(frame); + [subview setFrame:frame]; + } else { + [subview setFrame:bounds]; + } +} +@end diff --git a/chrome/browser/ui/cocoa/fast_resize_view_unittest.mm b/chrome/browser/ui/cocoa/fast_resize_view_unittest.mm new file mode 100644 index 0000000..d64be3f --- /dev/null +++ b/chrome/browser/ui/cocoa/fast_resize_view_unittest.mm @@ -0,0 +1,60 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/fast_resize_view.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" + +namespace { + +class FastResizeViewTest : public CocoaTest { + public: + FastResizeViewTest() { + NSRect frame = NSMakeRect(0, 0, 100, 30); + scoped_nsobject<FastResizeView> view( + [[FastResizeView alloc] initWithFrame:frame]); + view_ = view.get(); + [[test_window() contentView] addSubview:view_]; + + scoped_nsobject<NSView> childView([[NSView alloc] initWithFrame:frame]); + childView_ = childView.get(); + [view_ addSubview:childView_]; + } + + FastResizeView* view_; + NSView* childView_; +}; + +TEST_VIEW(FastResizeViewTest, view_); + +TEST_F(FastResizeViewTest, TestResizingOfChildren) { + NSRect squareFrame = NSMakeRect(0, 0, 200, 200); + NSRect rectFrame = NSMakeRect(1, 1, 150, 300); + + // Test that changing the view's frame also changes the child's frame. + [view_ setFrame:squareFrame]; + EXPECT_TRUE(NSEqualRects([view_ bounds], [childView_ frame])); + + // Turn fast resize mode on and change the view's frame. This time, the child + // should not resize, but it should be anchored to the top left. + [view_ setFastResizeMode:YES]; + [view_ setFrame:NSMakeRect(15, 30, 250, 250)]; + EXPECT_TRUE(NSEqualSizes([childView_ frame].size, squareFrame.size)); + EXPECT_EQ(NSMinX([view_ bounds]), NSMinX([childView_ frame])); + EXPECT_EQ(NSMaxY([view_ bounds]), NSMaxY([childView_ frame])); + + // Another resize with fast resize mode on. + [view_ setFrame:rectFrame]; + EXPECT_TRUE(NSEqualSizes([childView_ frame].size, squareFrame.size)); + EXPECT_EQ(NSMinX([view_ bounds]), NSMinX([childView_ frame])); + EXPECT_EQ(NSMaxY([view_ bounds]), NSMaxY([childView_ frame])); + + // Turn fast resize mode off. This should initiate an immediate resize, even + // though we haven't called setFrame directly. + [view_ setFastResizeMode:NO]; + EXPECT_TRUE(NSEqualRects([view_ frame], rectFrame)); + EXPECT_TRUE(NSEqualRects([view_ bounds], [childView_ frame])); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/file_metadata.h b/chrome/browser/ui/cocoa/file_metadata.h new file mode 100644 index 0000000..2a6cfc5 --- /dev/null +++ b/chrome/browser/ui/cocoa/file_metadata.h @@ -0,0 +1,29 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_FILE_METADATA_H_ +#define CHROME_BROWSER_UI_COCOA_FILE_METADATA_H_ +#pragma once + +class FilePath; +class GURL; + +namespace file_metadata { + +// Adds origin metadata to the file. +// |source| should be the source URL for the download, and |referrer| should be +// the URL the user initiated the download from. +void AddOriginMetadataToFile(const FilePath& file, const GURL& source, + const GURL& referrer); + +// Adds quarantine metadata to the file, assuming it has already been +// quarantined by the OS. +// |source| should be the source URL for the download, and |referrer| should be +// the URL the user initiated the download from. +void AddQuarantineMetadataToFile(const FilePath& file, const GURL& source, + const GURL& referrer); + +} // namespace file_metadata + +#endif // CHROME_BROWSER_UI_COCOA_FILE_METADATA_H_ diff --git a/chrome/browser/ui/cocoa/file_metadata.mm b/chrome/browser/ui/cocoa/file_metadata.mm new file mode 100644 index 0000000..d19e3ac --- /dev/null +++ b/chrome/browser/ui/cocoa/file_metadata.mm @@ -0,0 +1,167 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/file_metadata.h" + +#include <ApplicationServices/ApplicationServices.h> +#include <Foundation/Foundation.h> + +#include "base/file_path.h" +#include "base/logging.h" +#include "base/mac_util.h" +#include "base/mac/scoped_cftyperef.h" +#include "googleurl/src/gurl.h" + +namespace file_metadata { + +// As of Mac OS X 10.4 ("Tiger"), files can be tagged with metadata describing +// various attributes. Metadata is integrated with the system's Spotlight +// feature and is searchable. Ordinarily, metadata can only be set by +// Spotlight importers, which requires that the importer own the target file. +// However, there's an attribute intended to describe the origin of a +// file, that can store the source URL and referrer of a downloaded file. +// It's stored as a "com.apple.metadata:kMDItemWhereFroms" extended attribute, +// structured as a binary1-format plist containing a list of sources. This +// attribute can only be populated by the downloader, not a Spotlight importer. +// Safari on 10.4 and later populates this attribute. +// +// With this metadata set, you can locate downloads by performing a Spotlight +// search for their source or referrer URLs, either from within the Spotlight +// UI or from the command line: +// mdfind 'kMDItemWhereFroms == "http://releases.mozilla.org/*"' +// +// There is no documented API to set metadata on a file directly as of the +// 10.5 SDK. The MDSetItemAttribute function does exist to perform this task, +// but it's undocumented. +void AddOriginMetadataToFile(const FilePath& file, const GURL& source, + const GURL& referrer) { + // There's no declaration for MDItemSetAttribute in any known public SDK. + // It exists in the 10.4 and 10.5 runtimes. To play it safe, do the lookup + // at runtime instead of declaring it ourselves and linking against what's + // provided. This has two benefits: + // - If Apple relents and declares the function in a future SDK (it's + // happened before), our build won't break. + // - If Apple removes or renames the function in a future runtime, the + // loader won't refuse to let the application launch. Instead, we'll + // silently fail to set any metadata. + typedef OSStatus (*MDItemSetAttribute_type)(MDItemRef, CFStringRef, + CFTypeRef); + static MDItemSetAttribute_type md_item_set_attribute_func = NULL; + + static bool did_symbol_lookup = false; + if (!did_symbol_lookup) { + did_symbol_lookup = true; + CFBundleRef metadata_bundle = + CFBundleGetBundleWithIdentifier(CFSTR("com.apple.Metadata")); + if (!metadata_bundle) + return; + + md_item_set_attribute_func = (MDItemSetAttribute_type) + CFBundleGetFunctionPointerForName(metadata_bundle, + CFSTR("MDItemSetAttribute")); + } + if (!md_item_set_attribute_func) + return; + + NSString* file_path = + [NSString stringWithUTF8String:file.value().c_str()]; + if (!file_path) + return; + + base::mac::ScopedCFTypeRef<MDItemRef> md_item( + MDItemCreate(NULL, reinterpret_cast<CFStringRef>(file_path))); + if (!md_item) + return; + + // We won't put any more than 2 items into the attribute. + NSMutableArray* list = [NSMutableArray arrayWithCapacity:2]; + + // Follow Safari's lead: the first item in the list is the source URL of + // the downloaded file. If the referrer is known, store that, too. + NSString* origin_url = [NSString stringWithUTF8String:source.spec().c_str()]; + if (origin_url) + [list addObject:origin_url]; + NSString* referrer_url = + [NSString stringWithUTF8String:referrer.spec().c_str()]; + if (referrer_url) + [list addObject:referrer_url]; + + md_item_set_attribute_func(md_item, kMDItemWhereFroms, + reinterpret_cast<CFArrayRef>(list)); +} + +// The OS will automatically quarantine files due to the +// LSFileQuarantineEnabled entry in our Info.plist, but it knows relatively +// little about the files. We add more information about the download to +// improve the UI shown by the OS when the users tries to open the file. +void AddQuarantineMetadataToFile(const FilePath& file, const GURL& source, + const GURL& referrer) { + FSRef file_ref; + if (!mac_util::FSRefFromPath(file.value(), &file_ref)) + return; + + NSMutableDictionary* quarantine_properties = nil; + CFTypeRef quarantine_properties_base = NULL; + if (LSCopyItemAttribute(&file_ref, kLSRolesAll, kLSItemQuarantineProperties, + &quarantine_properties_base) == noErr) { + if (CFGetTypeID(quarantine_properties_base) == + CFDictionaryGetTypeID()) { + // Quarantine properties will already exist if LSFileQuarantineEnabled + // is on and the file doesn't match an exclusion. + quarantine_properties = + [[(NSDictionary*)quarantine_properties_base mutableCopy] autorelease]; + } else { + LOG(WARNING) << "kLSItemQuarantineProperties is not a dictionary on file " + << file.value(); + } + CFRelease(quarantine_properties_base); + } + + if (!quarantine_properties) { + // If there are no quarantine properties, then the file isn't quarantined + // (e.g., because the user has set up exclusions for certain file types). + // We don't want to add any metadata, because that will cause the file to + // be quarantined against the user's wishes. + return; + } + + // kLSQuarantineAgentNameKey, kLSQuarantineAgentBundleIdentifierKey, and + // kLSQuarantineTimeStampKey are set for us (see LSQuarantine.h), so we only + // need to set the values that the OS can't infer. + + if (![quarantine_properties valueForKey:(NSString*)kLSQuarantineTypeKey]) { + CFStringRef type = (source.SchemeIs("http") || source.SchemeIs("https")) + ? kLSQuarantineTypeWebDownload + : kLSQuarantineTypeOtherDownload; + [quarantine_properties setValue:(NSString*)type + forKey:(NSString*)kLSQuarantineTypeKey]; + } + + if (![quarantine_properties + valueForKey:(NSString*)kLSQuarantineOriginURLKey] && + referrer.is_valid()) { + NSString* referrer_url = + [NSString stringWithUTF8String:referrer.spec().c_str()]; + [quarantine_properties setValue:referrer_url + forKey:(NSString*)kLSQuarantineOriginURLKey]; + } + + if (![quarantine_properties valueForKey:(NSString*)kLSQuarantineDataURLKey] && + source.is_valid()) { + NSString* origin_url = + [NSString stringWithUTF8String:source.spec().c_str()]; + [quarantine_properties setValue:origin_url + forKey:(NSString*)kLSQuarantineDataURLKey]; + } + + OSStatus os_error = LSSetItemAttribute(&file_ref, kLSRolesAll, + kLSItemQuarantineProperties, + quarantine_properties); + if (os_error != noErr) { + LOG(WARNING) << "Unable to set quarantine attributes on file " + << file.value(); + } +} + +} // namespace file_metadata diff --git a/chrome/browser/ui/cocoa/find_bar_bridge.h b/chrome/browser/ui/cocoa/find_bar_bridge.h new file mode 100644 index 0000000..05a1516 --- /dev/null +++ b/chrome/browser/ui/cocoa/find_bar_bridge.h @@ -0,0 +1,95 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_FIND_BAR_BRIDGE_H_ +#define CHROME_BROWSER_UI_COCOA_FIND_BAR_BRIDGE_H_ +#pragma once + +#include "base/logging.h" +#include "chrome/browser/find_bar.h" + +class BrowserWindowCocoa; +class FindBarController; + +// This class is included by find_bar_host_browsertest.cc, so it has to be +// objc-free. +#ifdef __OBJC__ +@class FindBarCocoaController; +#else +class FindBarCocoaController; +#endif + +// Implementation of FindBar for the Mac. This class simply passes +// each message along to |cocoa_controller_|. +// +// The initialization here is a bit complicated. FindBarBridge is +// created by a static method in BrowserWindow. The FindBarBridge +// constructor creates a FindBarCocoaController, which in turn loads a +// FindBarView from a nib file. All of this is happening outside of +// the main view hierarchy, so the static method also calls +// BrowserWindowCocoa::AddFindBar() in order to add its FindBarView to +// the cocoa views hierarchy. +// +// Memory ownership is relatively straightforward. The FindBarBridge +// object is owned by the Browser. FindBarCocoaController is retained +// by bother FindBarBridge and BrowserWindowController, since both use it. + +class FindBarBridge : public FindBar, + public FindBarTesting { + public: + FindBarBridge(); + virtual ~FindBarBridge(); + + FindBarCocoaController* find_bar_cocoa_controller() { + return cocoa_controller_; + } + + virtual void SetFindBarController(FindBarController* find_bar_controller) { + find_bar_controller_ = find_bar_controller; + } + + virtual FindBarController* GetFindBarController() const { + DCHECK(find_bar_controller_); + return find_bar_controller_; + } + + virtual FindBarTesting* GetFindBarTesting() { + return this; + } + + // Methods from FindBar. + virtual void Show(bool animate); + virtual void Hide(bool animate); + virtual void SetFocusAndSelection(); + virtual void ClearResults(const FindNotificationDetails& results); + virtual void StopAnimation(); + virtual void SetFindText(const string16& find_text); + virtual void UpdateUIForFindResult(const FindNotificationDetails& result, + const string16& find_text); + virtual void AudibleAlert(); + virtual bool IsFindBarVisible(); + virtual void RestoreSavedFocus(); + virtual void MoveWindowIfNecessary(const gfx::Rect& selection_rect, + bool no_redraw); + + // Methods from FindBarTesting. + virtual bool GetFindBarWindowInfo(gfx::Point* position, + bool* fully_visible); + virtual string16 GetFindText(); + + // Used to disable find bar animations when testing. + static bool disable_animations_during_testing_; + + private: + // Pointer to the cocoa controller which manages the cocoa view. Is + // never nil. + FindBarCocoaController* cocoa_controller_; + + // Pointer back to the owning controller. + FindBarController* find_bar_controller_; // weak, owns us + + DISALLOW_COPY_AND_ASSIGN(FindBarBridge); +}; + +#endif // CHROME_BROWSER_UI_COCOA_FIND_BAR_BRIDGE_H_ diff --git a/chrome/browser/ui/cocoa/find_bar_bridge.mm b/chrome/browser/ui/cocoa/find_bar_bridge.mm new file mode 100644 index 0000000..dc9146c --- /dev/null +++ b/chrome/browser/ui/cocoa/find_bar_bridge.mm @@ -0,0 +1,96 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/find_bar_bridge.h" + +#include "base/sys_string_conversions.h" +#import "chrome/browser/ui/cocoa/find_bar_cocoa_controller.h" + +// static +bool FindBarBridge::disable_animations_during_testing_ = false; + +FindBarBridge::FindBarBridge() + : find_bar_controller_(NULL) { + cocoa_controller_ = [[FindBarCocoaController alloc] init]; + [cocoa_controller_ setFindBarBridge:this]; +} + +FindBarBridge::~FindBarBridge() { + [cocoa_controller_ release]; +} + +void FindBarBridge::Show(bool animate) { + bool really_animate = animate && !disable_animations_during_testing_; + [cocoa_controller_ showFindBar:(really_animate ? YES : NO)]; +} + +void FindBarBridge::Hide(bool animate) { + bool really_animate = animate && !disable_animations_during_testing_; + [cocoa_controller_ hideFindBar:(really_animate ? YES : NO)]; +} + +void FindBarBridge::SetFocusAndSelection() { + [cocoa_controller_ setFocusAndSelection]; +} + +void FindBarBridge::ClearResults(const FindNotificationDetails& results) { + [cocoa_controller_ clearResults:results]; +} + +void FindBarBridge::SetFindText(const string16& find_text) { + [cocoa_controller_ setFindText:base::SysUTF16ToNSString(find_text)]; +} + +void FindBarBridge::UpdateUIForFindResult(const FindNotificationDetails& result, + const string16& find_text) { + [cocoa_controller_ updateUIForFindResult:result withText:find_text]; +} + +void FindBarBridge::AudibleAlert() { + // Beep beep, beep beep, Yeah! + NSBeep(); +} + +bool FindBarBridge::IsFindBarVisible() { + return [cocoa_controller_ isFindBarVisible] ? true : false; +} + +void FindBarBridge::MoveWindowIfNecessary(const gfx::Rect& selection_rect, + bool no_redraw) { + // http://crbug.com/11084 + // http://crbug.com/22036 +} + +void FindBarBridge::StopAnimation() { + [cocoa_controller_ stopAnimation]; +} + +void FindBarBridge::RestoreSavedFocus() { + [cocoa_controller_ restoreSavedFocus]; +} + +bool FindBarBridge::GetFindBarWindowInfo(gfx::Point* position, + bool* fully_visible) { + // TODO(rohitrao): Return the proper position. http://crbug.com/22036 + if (position) + *position = gfx::Point(0, 0); + + NSWindow* window = [[cocoa_controller_ view] window]; + bool window_visible = [window isVisible] ? true : false; + if (fully_visible) { + *fully_visible = window_visible && + [cocoa_controller_ isFindBarVisible] && + ![cocoa_controller_ isFindBarAnimating]; + } + return window_visible; +} + +string16 FindBarBridge::GetFindText() { + // This function is currently only used in Windows and Linux specific browser + // tests (testing prepopulate values that Mac's don't rely on), but if we add + // more tests that are non-platform specific, we need to flesh out this + // function. + NOTIMPLEMENTED(); + return string16(); +} diff --git a/chrome/browser/ui/cocoa/find_bar_bridge_unittest.mm b/chrome/browser/ui/cocoa/find_bar_bridge_unittest.mm new file mode 100644 index 0000000..81679fdb --- /dev/null +++ b/chrome/browser/ui/cocoa/find_bar_bridge_unittest.mm @@ -0,0 +1,30 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/find_bar_controller.h" +#include "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "chrome/browser/ui/cocoa/find_bar_bridge.h" + +namespace { + +class FindBarBridgeTest : public CocoaTest { +}; + +TEST_F(FindBarBridgeTest, Creation) { + // Make sure the FindBarBridge constructor doesn't crash and + // properly initializes its FindBarCocoaController. + FindBarBridge bridge; + EXPECT_TRUE(bridge.find_bar_cocoa_controller() != NULL); +} + +TEST_F(FindBarBridgeTest, Accessors) { + // Get/SetFindBarController are virtual methods implemented in + // FindBarBridge, so we test them here. + FindBarBridge* bridge = new FindBarBridge(); + FindBarController controller(bridge); // takes ownership of |bridge|. + bridge->SetFindBarController(&controller); + + EXPECT_EQ(&controller, bridge->GetFindBarController()); +} +} // namespace diff --git a/chrome/browser/ui/cocoa/find_bar_cocoa_controller.h b/chrome/browser/ui/cocoa/find_bar_cocoa_controller.h new file mode 100644 index 0000000..289d89c --- /dev/null +++ b/chrome/browser/ui/cocoa/find_bar_cocoa_controller.h @@ -0,0 +1,78 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#import "chrome/browser/ui/cocoa/find_bar_cocoa_controller.h" + +#include "base/scoped_nsobject.h" +#include "base/string16.h" + +class BrowserWindowCocoa; +class FindBarBridge; +@class FindBarTextField; +class FindNotificationDetails; +@class FocusTracker; + +// A controller for the find bar in the browser window. Manages +// updating the state of the find bar and provides a target for the +// next/previous/close buttons. Certain operations require a pointer +// to the cross-platform FindBarController, so be sure to call +// setFindBarBridge: after creating this controller. + +@interface FindBarCocoaController : NSViewController { + @private + IBOutlet NSView* findBarView_; + IBOutlet FindBarTextField* findText_; + IBOutlet NSButton* nextButton_; + IBOutlet NSButton* previousButton_; + + // Needed to call methods on FindBarController. + FindBarBridge* findBarBridge_; // weak + + scoped_nsobject<FocusTracker> focusTracker_; + + // The currently-running animation. This is defined to be non-nil if an + // animation is running, and is always nil otherwise. The + // FindBarCocoaController should not be deallocated while an animation is + // running (stopAnimation is currently called before the last tab in a + // window is removed). + scoped_nsobject<NSViewAnimation> currentAnimation_; + + // If YES, do nothing as a result of find pasteboard update notifications. + BOOL suppressPboardUpdateActions_; +}; + +// Initializes a new FindBarCocoaController. +- (id)init; + +- (void)setFindBarBridge:(FindBarBridge*)findBar; + +- (IBAction)close:(id)sender; + +- (IBAction)nextResult:(id)sender; + +- (IBAction)previousResult:(id)sender; + +// Position the find bar at the given maximum y-coordinate (the min-y of the +// bar -- toolbar + possibly bookmark bar, but not including the infobars) with +// the given maximum width (i.e., the find bar should fit between 0 and +// |maxWidth|). +- (void)positionFindBarViewAtMaxY:(CGFloat)maxY maxWidth:(CGFloat)maxWidth; + +// Methods called from FindBarBridge. +- (void)showFindBar:(BOOL)animate; +- (void)hideFindBar:(BOOL)animate; +- (void)stopAnimation; +- (void)setFocusAndSelection; +- (void)restoreSavedFocus; +- (void)setFindText:(NSString*)findText; + +- (void)clearResults:(const FindNotificationDetails&)results; +- (void)updateUIForFindResult:(const FindNotificationDetails&)results + withText:(const string16&)findText; +- (BOOL)isFindBarVisible; +- (BOOL)isFindBarAnimating; + +@end diff --git a/chrome/browser/ui/cocoa/find_bar_cocoa_controller.mm b/chrome/browser/ui/cocoa/find_bar_cocoa_controller.mm new file mode 100644 index 0000000..f50d6db --- /dev/null +++ b/chrome/browser/ui/cocoa/find_bar_cocoa_controller.mm @@ -0,0 +1,384 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/mac_util.h" +#include "base/sys_string_conversions.h" +#include "chrome/browser/find_bar_controller.h" +#include "chrome/browser/renderer_host/render_view_host.h" +#include "chrome/browser/tab_contents/tab_contents.h" +#include "chrome/browser/ui/cocoa/browser_window_cocoa.h" +#import "chrome/browser/ui/cocoa/find_bar_cocoa_controller.h" +#import "chrome/browser/ui/cocoa/find_bar_bridge.h" +#import "chrome/browser/ui/cocoa/find_bar_text_field.h" +#import "chrome/browser/ui/cocoa/find_bar_text_field_cell.h" +#import "chrome/browser/ui/cocoa/find_pasteboard.h" +#import "chrome/browser/ui/cocoa/focus_tracker.h" +#import "chrome/browser/ui/cocoa/tab_strip_controller.h" +#import "third_party/GTM/AppKit/GTMNSAnimation+Duration.h" + +namespace { +const float kFindBarOpenDuration = 0.2; +const float kFindBarCloseDuration = 0.15; +} + +@interface FindBarCocoaController (PrivateMethods) +// Returns the appropriate frame for a hidden find bar. +- (NSRect)hiddenFindBarFrame; + +// Sets the frame of |findBarView_|. |duration| is ignored if |animate| is NO. +- (void)setFindBarFrame:(NSRect)endFrame + animate:(BOOL)animate + duration:(float)duration; + +// Optionally stops the current search, puts |text| into the find bar, and +// enables the buttons, but doesn't start a new search for |text|. +- (void)prepopulateText:(NSString*)text stopSearch:(BOOL)stopSearch; +@end + +@implementation FindBarCocoaController + +- (id)init { + if ((self = [super initWithNibName:@"FindBar" + bundle:mac_util::MainAppBundle()])) { + [[NSNotificationCenter defaultCenter] + addObserver:self + selector:@selector(findPboardUpdated:) + name:kFindPasteboardChangedNotification + object:[FindPasteboard sharedInstance]]; + } + return self; +} + +- (void)dealloc { + // All animations should be explicitly stopped by the TabContents before a tab + // is closed. + DCHECK(!currentAnimation_.get()); + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [super dealloc]; +} + +- (void)setFindBarBridge:(FindBarBridge*)findBarBridge { + DCHECK(!findBarBridge_); // should only be called once. + findBarBridge_ = findBarBridge; +} + +- (void)awakeFromNib { + [findBarView_ setFrame:[self hiddenFindBarFrame]]; + + // Stopping the search requires a findbar controller, which isn't valid yet + // during setup. Furthermore, there is no active search yet anyway. + [self prepopulateText:[[FindPasteboard sharedInstance] findText] + stopSearch:NO]; +} + +- (IBAction)close:(id)sender { + if (findBarBridge_) + findBarBridge_->GetFindBarController()->EndFindSession( + FindBarController::kKeepSelection); +} + +- (IBAction)previousResult:(id)sender { + if (findBarBridge_) + findBarBridge_->GetFindBarController()->tab_contents()->StartFinding( + base::SysNSStringToUTF16([findText_ stringValue]), + false, false); +} + +- (IBAction)nextResult:(id)sender { + if (findBarBridge_) + findBarBridge_->GetFindBarController()->tab_contents()->StartFinding( + base::SysNSStringToUTF16([findText_ stringValue]), + true, false); +} + +- (void)findPboardUpdated:(NSNotification*)notification { + if (suppressPboardUpdateActions_) + return; + [self prepopulateText:[[FindPasteboard sharedInstance] findText] + stopSearch:YES]; +} + +- (void)positionFindBarViewAtMaxY:(CGFloat)maxY maxWidth:(CGFloat)maxWidth { + static const CGFloat kRightEdgeOffset = 25; + NSView* containerView = [self view]; + CGFloat containerHeight = NSHeight([containerView frame]); + CGFloat containerWidth = NSWidth([containerView frame]); + + // Adjust where we'll actually place the find bar. + CGFloat maxX = maxWidth - kRightEdgeOffset; + DLOG_IF(WARNING, maxX < 0) << "Window too narrow for find bar"; + maxY += 1; + + NSRect newFrame = NSMakeRect(maxX - containerWidth, maxY - containerHeight, + containerWidth, containerHeight); + [containerView setFrame:newFrame]; +} + +// NSControl delegate method. +- (void)controlTextDidChange:(NSNotification *)aNotification { + if (!findBarBridge_) + return; + + TabContents* tab_contents = + findBarBridge_->GetFindBarController()->tab_contents(); + if (!tab_contents) + return; + + NSString* findText = [findText_ stringValue]; + suppressPboardUpdateActions_ = YES; + [[FindPasteboard sharedInstance] setFindText:findText]; + suppressPboardUpdateActions_ = NO; + + if ([findText length] > 0) { + tab_contents->StartFinding(base::SysNSStringToUTF16(findText), true, false); + } else { + // The textbox is empty so we reset. + tab_contents->StopFinding(FindBarController::kClearSelection); + [self updateUIForFindResult:tab_contents->find_result() + withText:string16()]; + } +} + +// NSControl delegate method +- (BOOL)control:(NSControl*)control + textView:(NSTextView*)textView + doCommandBySelector:(SEL)command { + if (command == @selector(insertNewline:)) { + // Pressing Return + NSEvent* event = [NSApp currentEvent]; + + if ([event modifierFlags] & NSShiftKeyMask) + [previousButton_ performClick:nil]; + else + [nextButton_ performClick:nil]; + + return YES; + } else if (command == @selector(insertLineBreak:)) { + // Pressing Ctrl-Return + if (findBarBridge_) { + findBarBridge_->GetFindBarController()->EndFindSession( + FindBarController::kActivateSelection); + } + return YES; + } else if (command == @selector(pageUp:) || + command == @selector(pageUpAndModifySelection:) || + command == @selector(scrollPageUp:) || + command == @selector(pageDown:) || + command == @selector(pageDownAndModifySelection:) || + command == @selector(scrollPageDown:) || + command == @selector(scrollToBeginningOfDocument:) || + command == @selector(scrollToEndOfDocument:) || + command == @selector(moveUp:) || + command == @selector(moveDown:)) { + TabContents* contents = + findBarBridge_->GetFindBarController()->tab_contents(); + if (!contents) + return NO; + + // Sanity-check to make sure we got a keyboard event. + NSEvent* event = [NSApp currentEvent]; + if ([event type] != NSKeyDown && [event type] != NSKeyUp) + return NO; + + // Forward the event to the renderer. + // TODO(rohitrao): Should this call -[BaseView keyEvent:]? Is there code in + // that function that we want to keep or avoid? Calling + // |ForwardKeyboardEvent()| directly ignores edit commands, which breaks + // cmd-up/down if we ever decide to include |moveToBeginningOfDocument:| in + // the list above. + RenderViewHost* render_view_host = contents->render_view_host(); + render_view_host->ForwardKeyboardEvent(NativeWebKeyboardEvent(event)); + return YES; + } + + return NO; +} + +// Methods from FindBar +- (void)showFindBar:(BOOL)animate { + // Save the currently-focused view. |findBarView_| is in the view + // hierarchy by now. showFindBar can be called even when the + // findbar is already open, so do not overwrite an already saved + // view. + if (!focusTracker_.get()) + focusTracker_.reset( + [[FocusTracker alloc] initWithWindow:[findBarView_ window]]); + + // Animate the view into place. + NSRect frame = [findBarView_ frame]; + frame.origin = NSMakePoint(0, 0); + [self setFindBarFrame:frame animate:animate duration:kFindBarOpenDuration]; +} + +- (void)hideFindBar:(BOOL)animate { + NSRect frame = [self hiddenFindBarFrame]; + [self setFindBarFrame:frame animate:animate duration:kFindBarCloseDuration]; +} + +- (void)stopAnimation { + if (currentAnimation_.get()) { + [currentAnimation_ stopAnimation]; + currentAnimation_.reset(nil); + } +} + +- (void)setFocusAndSelection { + [[findText_ window] makeFirstResponder:findText_]; + + // Enable the buttons if the find text is non-empty. + BOOL buttonsEnabled = ([[findText_ stringValue] length] > 0) ? YES : NO; + [previousButton_ setEnabled:buttonsEnabled]; + [nextButton_ setEnabled:buttonsEnabled]; +} + +- (void)restoreSavedFocus { + if (!(focusTracker_.get() && + [focusTracker_ restoreFocusInWindow:[findBarView_ window]])) { + // Fall back to giving focus to the tab contents. + findBarBridge_->GetFindBarController()->tab_contents()->Focus(); + } + focusTracker_.reset(nil); +} + +- (void)setFindText:(NSString*)findText { + [findText_ setStringValue:findText]; + + // Make sure the text in the find bar always ends up in the find pasteboard + // (and, via notifications, in the other find bars too). + [[FindPasteboard sharedInstance] setFindText:findText]; +} + +- (void)clearResults:(const FindNotificationDetails&)results { + // Just call updateUIForFindResult, which will take care of clearing + // the search text and the results label. + [self updateUIForFindResult:results withText:string16()]; +} + +- (void)updateUIForFindResult:(const FindNotificationDetails&)result + withText:(const string16&)findText { + // If we don't have any results and something was passed in, then + // that means someone pressed Cmd-G while the Find box was + // closed. In that case we need to repopulate the Find box with what + // was passed in. + if ([[findText_ stringValue] length] == 0 && !findText.empty()) { + [findText_ setStringValue:base::SysUTF16ToNSString(findText)]; + [findText_ selectText:self]; + } + + // Make sure Find Next and Find Previous are enabled if we found any matches. + BOOL buttonsEnabled = result.number_of_matches() > 0 ? YES : NO; + [previousButton_ setEnabled:buttonsEnabled]; + [nextButton_ setEnabled:buttonsEnabled]; + + // Update the results label. + BOOL validRange = result.active_match_ordinal() != -1 && + result.number_of_matches() != -1; + NSString* searchString = [findText_ stringValue]; + if ([searchString length] > 0 && validRange) { + [[findText_ findBarTextFieldCell] + setActiveMatch:result.active_match_ordinal() + of:result.number_of_matches()]; + } else { + // If there was no text entered, we don't show anything in the results area. + [[findText_ findBarTextFieldCell] clearResults]; + } + + [findText_ resetFieldEditorFrameIfNeeded]; + + // If we found any results, reset the focus tracker, so we always + // restore focus to the tab contents. + if (result.number_of_matches() > 0) + focusTracker_.reset(nil); +} + +- (BOOL)isFindBarVisible { + // Find bar is visible if any part of it is on the screen. + return NSIntersectsRect([[self view] bounds], [findBarView_ frame]); +} + +- (BOOL)isFindBarAnimating { + return (currentAnimation_.get() != nil); +} + +// NSAnimation delegate methods. +- (void)animationDidEnd:(NSAnimation*)animation { + // Autorelease the animation (cannot use release because the animation object + // is still on the stack. + DCHECK(animation == currentAnimation_.get()); + [currentAnimation_.release() autorelease]; + + // If the find bar is not visible, make it actually hidden, so it'll no longer + // respond to key events. + [findBarView_ setHidden:![self isFindBarVisible]]; +} + +@end + +@implementation FindBarCocoaController (PrivateMethods) + +- (NSRect)hiddenFindBarFrame { + NSRect frame = [findBarView_ frame]; + NSRect containerBounds = [[self view] bounds]; + frame.origin = NSMakePoint(NSMinX(containerBounds), NSMaxY(containerBounds)); + return frame; +} + +- (void)setFindBarFrame:(NSRect)endFrame + animate:(BOOL)animate + duration:(float)duration { + // Save the current frame. + NSRect startFrame = [findBarView_ frame]; + + // Stop any existing animations. + [currentAnimation_ stopAnimation]; + + if (!animate) { + [findBarView_ setFrame:endFrame]; + [findBarView_ setHidden:![self isFindBarVisible]]; + currentAnimation_.reset(nil); + return; + } + + // If animating, ensure that the find bar is not hidden. Hidden status will be + // updated at the end of the animation. + [findBarView_ setHidden:NO]; + + // Reset the frame to what was saved above. + [findBarView_ setFrame:startFrame]; + NSDictionary* dict = [NSDictionary dictionaryWithObjectsAndKeys: + findBarView_, NSViewAnimationTargetKey, + [NSValue valueWithRect:endFrame], NSViewAnimationEndFrameKey, nil]; + + currentAnimation_.reset( + [[NSViewAnimation alloc] + initWithViewAnimations:[NSArray arrayWithObjects:dict, nil]]); + [currentAnimation_ gtm_setDuration:duration + eventMask:NSLeftMouseUpMask]; + [currentAnimation_ setDelegate:self]; + [currentAnimation_ startAnimation]; +} + +- (void)prepopulateText:(NSString*)text stopSearch:(BOOL)stopSearch{ + [self setFindText:text]; + + // End the find session, hide the "x of y" text and disable the + // buttons, but do not close the find bar or raise the window here. + if (stopSearch && findBarBridge_) { + TabContents* contents = + findBarBridge_->GetFindBarController()->tab_contents(); + if (contents) { + contents->StopFinding(FindBarController::kClearSelection); + findBarBridge_->ClearResults(contents->find_result()); + } + } + + // Has to happen after |ClearResults()| above. + BOOL buttonsEnabled = [text length] > 0 ? YES : NO; + [previousButton_ setEnabled:buttonsEnabled]; + [nextButton_ setEnabled:buttonsEnabled]; +} + +@end diff --git a/chrome/browser/ui/cocoa/find_bar_cocoa_controller_unittest.mm b/chrome/browser/ui/cocoa/find_bar_cocoa_controller_unittest.mm new file mode 100644 index 0000000..ca85dd2 --- /dev/null +++ b/chrome/browser/ui/cocoa/find_bar_cocoa_controller_unittest.mm @@ -0,0 +1,138 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/string_util.h" +#include "base/sys_string_conversions.h" +#include "chrome/browser/browser_window.h" +#include "chrome/browser/find_notification_details.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/find_bar_cocoa_controller.h" +#import "chrome/browser/ui/cocoa/find_pasteboard.h" +#import "chrome/browser/ui/cocoa/find_bar_text_field.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +// Expose private variables to make testing easier. +@interface FindBarCocoaController(Testing) +- (NSView*)findBarView; +- (NSString*)findText; +- (FindBarTextField*)findTextField; +@end + +@implementation FindBarCocoaController(Testing) +- (NSView*)findBarView { + return findBarView_; +} + +- (NSString*)findText { + return [findText_ stringValue]; +} + +- (FindBarTextField*)findTextField { + return findText_; +} + +- (NSButton*)nextButton { + return nextButton_; +} + +- (NSButton*)previousButton { + return previousButton_; +} +@end + +namespace { + +class FindBarCocoaControllerTest : public CocoaTest { + public: + virtual void SetUp() { + CocoaTest::SetUp(); + controller_.reset([[FindBarCocoaController alloc] init]); + [[test_window() contentView] addSubview:[controller_ view]]; + } + + protected: + scoped_nsobject<FindBarCocoaController> controller_; +}; + +TEST_VIEW(FindBarCocoaControllerTest, [controller_ view]) + +TEST_F(FindBarCocoaControllerTest, ImagesLoadedProperly) { + EXPECT_TRUE([[[controller_ nextButton] image] isValid]); + EXPECT_TRUE([[[controller_ previousButton] image] isValid]); +} + +TEST_F(FindBarCocoaControllerTest, ShowAndHide) { + NSView* findBarView = [controller_ findBarView]; + + ASSERT_GT([findBarView frame].origin.y, 0); + ASSERT_FALSE([controller_ isFindBarVisible]); + + [controller_ showFindBar:NO]; + EXPECT_EQ([findBarView frame].origin.y, 0); + EXPECT_TRUE([controller_ isFindBarVisible]); + + [controller_ hideFindBar:NO]; + EXPECT_GT([findBarView frame].origin.y, 0); + EXPECT_FALSE([controller_ isFindBarVisible]); +} + +TEST_F(FindBarCocoaControllerTest, SetFindText) { + NSTextField* findTextField = [controller_ findTextField]; + + // Start by making the find bar visible. + [controller_ showFindBar:NO]; + EXPECT_TRUE([controller_ isFindBarVisible]); + + // Set the find text. + NSString* const kFindText = @"Google"; + [controller_ setFindText:kFindText]; + EXPECT_EQ( + NSOrderedSame, + [[findTextField stringValue] compare:kFindText]); + + // Call clearResults, which doesn't actually clear the find text but + // simply sets it back to what it was before. This is silly, but + // matches the behavior on other platforms. |details| isn't used by + // our implementation of clearResults, so it's ok to pass in an + // empty |details|. + FindNotificationDetails details; + [controller_ clearResults:details]; + EXPECT_EQ( + NSOrderedSame, + [[findTextField stringValue] compare:kFindText]); +} + +TEST_F(FindBarCocoaControllerTest, ResultLabelUpdatesCorrectly) { + // TODO(rohitrao): Test this. It may involve creating some dummy + // FindNotificationDetails objects. +} + +TEST_F(FindBarCocoaControllerTest, FindTextIsGlobal) { + scoped_nsobject<FindBarCocoaController> otherController( + [[FindBarCocoaController alloc] init]); + [[test_window() contentView] addSubview:[otherController view]]; + + // Setting the text in one controller should update the other controller's + // text as well. + NSString* const kFindText = @"Respect to the man in the ice cream van"; + [controller_ setFindText:kFindText]; + EXPECT_EQ( + NSOrderedSame, + [[controller_ findText] compare:kFindText]); + EXPECT_EQ( + NSOrderedSame, + [[otherController.get() findText] compare:kFindText]); +} + +TEST_F(FindBarCocoaControllerTest, SettingFindTextUpdatesFindPboard) { + NSString* const kFindText = + @"It's not a bird, it's not a plane, it must be Dave who's on the train"; + [controller_ setFindText:kFindText]; + EXPECT_EQ( + NSOrderedSame, + [[[FindPasteboard sharedInstance] findText] compare:kFindText]); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/find_bar_text_field.h b/chrome/browser/ui/cocoa/find_bar_text_field.h new file mode 100644 index 0000000..765cc64 --- /dev/null +++ b/chrome/browser/ui/cocoa/find_bar_text_field.h @@ -0,0 +1,21 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> +#import "chrome/browser/ui/cocoa/styled_text_field.h" + +@class FindBarTextFieldCell; + +// TODO(rohitrao): This class may not need to exist, since it does not really +// add any functionality over StyledTextField. See if we can change the nib +// file to put a FindBarTextFieldCell into a StyledTextField. + +// Extends StyledTextField to use a custom cell class (FindBarTextFieldCell). +@interface FindBarTextField : StyledTextField { +} + +// Convenience method to return the cell, casted appropriately. +- (FindBarTextFieldCell*)findBarTextFieldCell; + +@end diff --git a/chrome/browser/ui/cocoa/find_bar_text_field.mm b/chrome/browser/ui/cocoa/find_bar_text_field.mm new file mode 100644 index 0000000..805fca3 --- /dev/null +++ b/chrome/browser/ui/cocoa/find_bar_text_field.mm @@ -0,0 +1,40 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/find_bar_text_field.h" + +#include "base/logging.h" +#import "chrome/browser/ui/cocoa/find_bar_text_field_cell.h" +#import "chrome/browser/ui/cocoa/view_id_util.h" + +@implementation FindBarTextField + ++ (Class)cellClass { + return [FindBarTextFieldCell class]; +} + +- (void)awakeFromNib { + DCHECK([[self cell] isKindOfClass:[FindBarTextFieldCell class]]); + + [self registerForDraggedTypes: + [NSArray arrayWithObjects:NSStringPboardType, nil]]; +} + +- (FindBarTextFieldCell*)findBarTextFieldCell { + DCHECK([[self cell] isKindOfClass:[FindBarTextFieldCell class]]); + return static_cast<FindBarTextFieldCell*>([self cell]); +} + +- (ViewID)viewID { + return VIEW_ID_FIND_IN_PAGE_TEXT_FIELD; +} + +- (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)info { + // When a drag enters the text field, focus the field. This will swap in the + // field editor, which will then handle the drag itself. + [[self window] makeFirstResponder:self]; + return NSDragOperationNone; +} + +@end diff --git a/chrome/browser/ui/cocoa/find_bar_text_field_cell.h b/chrome/browser/ui/cocoa/find_bar_text_field_cell.h new file mode 100644 index 0000000..ee6785f --- /dev/null +++ b/chrome/browser/ui/cocoa/find_bar_text_field_cell.h @@ -0,0 +1,24 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#import "chrome/browser/ui/cocoa/styled_text_field_cell.h" + +#include "base/scoped_nsobject.h" + +// FindBarTextFieldCell extends StyledTextFieldCell to provide support for a +// results label rooted at the right edge of the cell. +@interface FindBarTextFieldCell : StyledTextFieldCell { + @private + // Set if there is a results label to display on the right side of the cell. + scoped_nsobject<NSAttributedString> resultsString_; +} + +// Sets the results label to the localized equivalent of "X of Y". +- (void)setActiveMatch:(NSInteger)current of:(NSInteger)total; + +- (void)clearResults; + +@end diff --git a/chrome/browser/ui/cocoa/find_bar_text_field_cell.mm b/chrome/browser/ui/cocoa/find_bar_text_field_cell.mm new file mode 100644 index 0000000..3f93766 --- /dev/null +++ b/chrome/browser/ui/cocoa/find_bar_text_field_cell.mm @@ -0,0 +1,119 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/find_bar_text_field_cell.h" + +#include "app/l10n_util.h" +#include "base/logging.h" +#include "base/string_number_conversions.h" +#include "base/string_util.h" +#include "base/sys_string_conversions.h" +#include "grit/generated_resources.h" + +namespace { + +const CGFloat kBaselineAdjust = 1.0; + +// How far to offset the keyword token into the field. +const NSInteger kResultsXOffset = 3; + +// How much width (beyond text) to add to the keyword token on each +// side. +const NSInteger kResultsTokenInset = 3; + +// How far to shift bounding box of hint down from top of field. +// Assumes -setFlipped:YES. +const NSInteger kResultsYOffset = 4; + +// How far the editor insets itself, for purposes of determining if +// decorations need to be trimmed. +const CGFloat kEditorHorizontalInset = 3.0; + +// Conveniences to centralize width+offset calculations. +CGFloat WidthForResults(NSAttributedString* resultsString) { + return kResultsXOffset + ceil([resultsString size].width) + + 2 * kResultsTokenInset; +} + +} // namespace + +@implementation FindBarTextFieldCell + +- (CGFloat)baselineAdjust { + return kBaselineAdjust; +} + +- (CGFloat)cornerRadius { + return 4.0; +} + +- (StyledTextFieldCellRoundedFlags)roundedFlags { + return StyledTextFieldCellRoundedLeft; +} + +// @synthesize doesn't seem to compile for this transition. +- (NSAttributedString*)resultsString { + return resultsString_.get(); +} + +// Convenience for the attributes used in the right-justified info +// cells. Sets the background color to red if |foundMatches| is YES. +- (NSDictionary*)resultsAttributes:(BOOL)foundMatches { + scoped_nsobject<NSMutableParagraphStyle> style( + [[NSMutableParagraphStyle alloc] init]); + [style setAlignment:NSRightTextAlignment]; + + return [NSDictionary dictionaryWithObjectsAndKeys: + [self font], NSFontAttributeName, + [NSColor lightGrayColor], NSForegroundColorAttributeName, + [NSColor whiteColor], NSBackgroundColorAttributeName, + style.get(), NSParagraphStyleAttributeName, + nil]; +} + +- (void)setActiveMatch:(NSInteger)current of:(NSInteger)total { + NSString* results = + base::SysUTF16ToNSString(l10n_util::GetStringFUTF16( + IDS_FIND_IN_PAGE_COUNT, + base::IntToString16(current), + base::IntToString16(total))); + resultsString_.reset([[NSAttributedString alloc] + initWithString:results + attributes:[self resultsAttributes:(total > 0)]]); +} + +- (void)clearResults { + resultsString_.reset(nil); +} + +- (NSRect)textFrameForFrame:(NSRect)cellFrame { + NSRect textFrame([super textFrameForFrame:cellFrame]); + if (resultsString_) + textFrame.size.width -= WidthForResults(resultsString_); + return textFrame; +} + +// Do not show the I-beam cursor over the results label. +- (NSRect)textCursorFrameForFrame:(NSRect)cellFrame { + return [self textFrameForFrame:cellFrame]; +} + +- (void)drawResultsWithFrame:(NSRect)cellFrame inView:(NSView*)controlView { + DCHECK(resultsString_); + + NSRect textFrame = [self textFrameForFrame:cellFrame]; + NSRect infoFrame(NSMakeRect(NSMaxX(textFrame), + cellFrame.origin.y + kResultsYOffset, + ceil([resultsString_ size].width), + cellFrame.size.height - kResultsYOffset)); + [resultsString_.get() drawInRect:infoFrame]; +} + +- (void)drawInteriorWithFrame:(NSRect)cellFrame inView:(NSView*)controlView { + if (resultsString_) + [self drawResultsWithFrame:cellFrame inView:controlView]; + [super drawInteriorWithFrame:cellFrame inView:controlView]; +} + +@end diff --git a/chrome/browser/ui/cocoa/find_bar_text_field_cell_unittest.mm b/chrome/browser/ui/cocoa/find_bar_text_field_cell_unittest.mm new file mode 100644 index 0000000..1448632 --- /dev/null +++ b/chrome/browser/ui/cocoa/find_bar_text_field_cell_unittest.mm @@ -0,0 +1,135 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/find_bar_text_field_cell.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +@interface FindBarTextFieldCell (ExposedForTesting) +- (NSAttributedString*)resultsString; +@end + +@implementation FindBarTextFieldCell (ExposedForTesting) +- (NSAttributedString*)resultsString { + return resultsString_.get(); +} +@end + +namespace { + +// Width of the field so that we don't have to ask |field_| for it all +// the time. +const CGFloat kWidth(300.0); + +// A narrow width for tests which test things that don't fit. +const CGFloat kNarrowWidth(5.0); + +class FindBarTextFieldCellTest : public CocoaTest { + public: + FindBarTextFieldCellTest() { + // Make sure this is wide enough to play games with the cell + // decorations. + const NSRect frame = NSMakeRect(0, 0, kWidth, 30); + + scoped_nsobject<FindBarTextFieldCell> cell( + [[FindBarTextFieldCell alloc] initTextCell:@"Testing"]); + cell_ = cell; + [cell_ setEditable:YES]; + [cell_ setBordered:YES]; + + scoped_nsobject<NSTextField> view( + [[NSTextField alloc] initWithFrame:frame]); + view_ = view; + [view_ setCell:cell_]; + + [[test_window() contentView] addSubview:view_]; + } + + NSTextField* view_; + FindBarTextFieldCell* cell_; +}; + +// Basic view tests (AddRemove, Display). +TEST_VIEW(FindBarTextFieldCellTest, view_); + +// Test drawing, mostly to ensure nothing leaks or crashes. +TEST_F(FindBarTextFieldCellTest, FocusedDisplay) { + [view_ display]; + + // Test focused drawing. + [test_window() makePretendKeyWindowAndSetFirstResponder:view_]; + [view_ display]; + [test_window() clearPretendKeyWindowAndFirstResponder]; + + // Test display of various cell configurations. + [cell_ setActiveMatch:4 of:30]; + [view_ display]; + + [cell_ setActiveMatch:0 of:0]; + [view_ display]; + + [cell_ clearResults]; + [view_ display]; +} + +// Verify that setting and clearing the find results changes the results string +// appropriately. +TEST_F(FindBarTextFieldCellTest, SetAndClearFindResults) { + [cell_ setActiveMatch:10 of:30]; + scoped_nsobject<NSAttributedString> tenString([[cell_ resultsString] copy]); + EXPECT_GT([tenString length], 0U); + + [cell_ setActiveMatch:0 of:0]; + scoped_nsobject<NSAttributedString> zeroString([[cell_ resultsString] copy]); + EXPECT_GT([zeroString length], 0U); + EXPECT_FALSE([tenString isEqualToAttributedString:zeroString]); + + [cell_ clearResults]; + EXPECT_EQ(0U, [[cell_ resultsString] length]); +} + +TEST_F(FindBarTextFieldCellTest, TextFrame) { + const NSRect bounds = [view_ bounds]; + NSRect textFrame = [cell_ textFrameForFrame:bounds]; + NSRect cursorFrame = [cell_ textCursorFrameForFrame:bounds]; + + // At default settings, everything goes to the text area. + EXPECT_FALSE(NSIsEmptyRect(textFrame)); + EXPECT_TRUE(NSContainsRect(bounds, textFrame)); + EXPECT_EQ(NSMinX(bounds), NSMinX(textFrame)); + EXPECT_EQ(NSMaxX(bounds), NSMaxX(textFrame)); + EXPECT_TRUE(NSEqualRects(cursorFrame, textFrame)); + + // Setting an active match leaves text frame to left. + [cell_ setActiveMatch:4 of:5]; + textFrame = [cell_ textFrameForFrame:bounds]; + cursorFrame = [cell_ textCursorFrameForFrame:bounds]; + EXPECT_FALSE(NSIsEmptyRect(textFrame)); + EXPECT_TRUE(NSContainsRect(bounds, textFrame)); + EXPECT_LT(NSMaxX(textFrame), NSMaxX(bounds)); + EXPECT_TRUE(NSEqualRects(cursorFrame, textFrame)); + +} + +// The editor frame should be slightly inset from the text frame. +TEST_F(FindBarTextFieldCellTest, DrawingRectForBounds) { + const NSRect bounds = [view_ bounds]; + NSRect textFrame = [cell_ textFrameForFrame:bounds]; + NSRect drawingRect = [cell_ drawingRectForBounds:bounds]; + + EXPECT_FALSE(NSIsEmptyRect(drawingRect)); + EXPECT_TRUE(NSContainsRect(textFrame, NSInsetRect(drawingRect, 1, 1))); + + [cell_ setActiveMatch:4 of:5]; + textFrame = [cell_ textFrameForFrame:bounds]; + drawingRect = [cell_ drawingRectForBounds:bounds]; + EXPECT_FALSE(NSIsEmptyRect(drawingRect)); + EXPECT_TRUE(NSContainsRect(textFrame, NSInsetRect(drawingRect, 1, 1))); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/find_bar_text_field_unittest.mm b/chrome/browser/ui/cocoa/find_bar_text_field_unittest.mm new file mode 100644 index 0000000..e9cab87 --- /dev/null +++ b/chrome/browser/ui/cocoa/find_bar_text_field_unittest.mm @@ -0,0 +1,92 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#import "base/cocoa_protocols_mac.h" +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/find_bar_text_field.h" +#import "chrome/browser/ui/cocoa/find_bar_text_field_cell.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" +#import "third_party/ocmock/OCMock/OCMock.h" + +// OCMock wants to mock a concrete class or protocol. This should +// provide a correct protocol for newer versions of the SDK, while +// providing something mockable for older versions. + +@protocol MockTextEditingDelegate<NSControlTextEditingDelegate> +- (void)controlTextDidBeginEditing:(NSNotification*)aNotification; +- (BOOL)control:(NSControl*)control textShouldEndEditing:(NSText*)fieldEditor; +@end + +namespace { + +// Width of the field so that we don't have to ask |field_| for it all +// the time. +static const CGFloat kWidth(300.0); + +class FindBarTextFieldTest : public CocoaTest { + public: + FindBarTextFieldTest() { + // Make sure this is wide enough to play games with the cell + // decorations. + NSRect frame = NSMakeRect(0, 0, kWidth, 30); + scoped_nsobject<FindBarTextField> field( + [[FindBarTextField alloc] initWithFrame:frame]); + field_ = field.get(); + + [field_ setStringValue:@"Test test"]; + [[test_window() contentView] addSubview:field_]; + } + + FindBarTextField* field_; +}; + +// Basic view tests (AddRemove, Display). +TEST_VIEW(FindBarTextFieldTest, field_); + +// Test that we have the right cell class. +TEST_F(FindBarTextFieldTest, CellClass) { + EXPECT_TRUE([[field_ cell] isKindOfClass:[FindBarTextFieldCell class]]); +} + +// Test that we get the same cell from -cell and +// -findBarTextFieldCell. +TEST_F(FindBarTextFieldTest, Cell) { + FindBarTextFieldCell* cell = [field_ findBarTextFieldCell]; + EXPECT_EQ(cell, [field_ cell]); + EXPECT_TRUE(cell != nil); +} + +// Test that becoming first responder sets things up correctly. +TEST_F(FindBarTextFieldTest, FirstResponder) { + EXPECT_EQ(nil, [field_ currentEditor]); + EXPECT_EQ([[field_ subviews] count], 0U); + [test_window() makePretendKeyWindowAndSetFirstResponder:field_]; + EXPECT_FALSE(nil == [field_ currentEditor]); + EXPECT_EQ([[field_ subviews] count], 1U); + EXPECT_TRUE([[field_ currentEditor] isDescendantOf:field_]); +} + +// Test drawing, mostly to ensure nothing leaks or crashes. +TEST_F(FindBarTextFieldTest, Display) { + [field_ display]; + + // Test focussed drawing. + [test_window() makePretendKeyWindowAndSetFirstResponder:field_]; + [field_ display]; + [test_window() clearPretendKeyWindowAndFirstResponder]; + + // Test display of various cell configurations. + FindBarTextFieldCell* cell = [field_ findBarTextFieldCell]; + [cell setActiveMatch:4 of:5]; + [field_ display]; + + [cell clearResults]; + [field_ display]; +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/find_bar_view.h b/chrome/browser/ui/cocoa/find_bar_view.h new file mode 100644 index 0000000..ff5753d --- /dev/null +++ b/chrome/browser/ui/cocoa/find_bar_view.h @@ -0,0 +1,19 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_FIND_BAR_VIEW_H_ +#define CHROME_BROWSER_UI_COCOA_FIND_BAR_VIEW_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "chrome/browser/ui/cocoa/background_gradient_view.h" + +// A view that handles painting the border for the FindBar. + +@interface FindBarView : BackgroundGradientView { +} +@end + +#endif // CHROME_BROWSER_UI_COCOA_FIND_BAR_VIEW_H_ diff --git a/chrome/browser/ui/cocoa/find_bar_view.mm b/chrome/browser/ui/cocoa/find_bar_view.mm new file mode 100644 index 0000000..5e1ce21 --- /dev/null +++ b/chrome/browser/ui/cocoa/find_bar_view.mm @@ -0,0 +1,131 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/find_bar_view.h" + +#import "chrome/browser/ui/cocoa/themed_window.h" +#import "chrome/browser/ui/cocoa/url_drop_target.h" +#import "chrome/browser/ui/cocoa/view_id_util.h" + +namespace { +CGFloat kCurveSize = 8; +} // end namespace + +@implementation FindBarView + +- (void)awakeFromNib { + // Register for all the drag types handled by the RWHVCocoa. + [self registerForDraggedTypes:[URLDropTargetHandler handledDragTypes]]; +} + +- (void)drawRect:(NSRect)rect { + // TODO(rohitrao): Make this prettier. + rect = NSInsetRect([self bounds], 0.5, 0.5); + rect = NSOffsetRect(rect, 0, 1.0); + + NSPoint topLeft = NSMakePoint(NSMinX(rect), NSMaxY(rect)); + NSPoint topRight = NSMakePoint(NSMaxX(rect), NSMaxY(rect)); + NSPoint midLeft1 = + NSMakePoint(NSMinX(rect) + kCurveSize, NSMaxY(rect) - kCurveSize); + NSPoint midLeft2 = + NSMakePoint(NSMinX(rect) + kCurveSize, NSMinY(rect) + kCurveSize); + NSPoint midRight1 = + NSMakePoint(NSMaxX(rect) - kCurveSize, NSMinY(rect) + kCurveSize); + NSPoint midRight2 = + NSMakePoint(NSMaxX(rect) - kCurveSize, NSMaxY(rect) - kCurveSize); + NSPoint bottomLeft = + NSMakePoint(NSMinX(rect) + (2 * kCurveSize), NSMinY(rect)); + NSPoint bottomRight = + NSMakePoint(NSMaxX(rect) - (2 * kCurveSize), NSMinY(rect)); + + NSBezierPath* path = [NSBezierPath bezierPath]; + [path moveToPoint:topLeft]; + [path curveToPoint:midLeft1 + controlPoint1:NSMakePoint(midLeft1.x, topLeft.y) + controlPoint2:NSMakePoint(midLeft1.x, topLeft.y)]; + [path lineToPoint:midLeft2]; + [path curveToPoint:bottomLeft + controlPoint1:NSMakePoint(midLeft2.x, bottomLeft.y) + controlPoint2:NSMakePoint(midLeft2.x, bottomLeft.y)]; + + [path lineToPoint:bottomRight]; + [path curveToPoint:midRight1 + controlPoint1:NSMakePoint(midRight1.x, bottomLeft.y) + controlPoint2:NSMakePoint(midRight1.x, bottomLeft.y)]; + [path lineToPoint:midRight2]; + [path curveToPoint:topRight + controlPoint1:NSMakePoint(midRight2.x, topLeft.y) + controlPoint2:NSMakePoint(midRight2.x, topLeft.y)]; + NSGraphicsContext* context = [NSGraphicsContext currentContext]; + [context saveGraphicsState]; + [path addClip]; + + // Set the pattern phase + NSPoint phase = [[self window] themePatternPhase]; + + [context setPatternPhase:phase]; + [super drawBackground]; + [context restoreGraphicsState]; + + [[self strokeColor] set]; + [path stroke]; +} + +// The findbar is mostly opaque, but has an 8px transparent border on the left +// and right sides (see |kCurveSize|). This is an artifact of the way it is +// drawn. We override hitTest to return nil for points in this transparent +// area. +- (NSView*)hitTest:(NSPoint)point { + NSView* hitView = [super hitTest:point]; + if (hitView == self) { + // |rect| is approximately equivalent to the opaque area of the findbar. + NSRect rect = NSInsetRect([self bounds], kCurveSize, 0); + if (!NSMouseInRect(point, rect, [self isFlipped])) + return nil; + } + + return hitView; +} + +// Eat all mouse events, to prevent clicks from falling through to views below. +- (void)mouseDown:(NSEvent *)theEvent { +} + +- (void)rightMouseDown:(NSEvent *)theEvent { +} + +- (void)otherMouseDown:(NSEvent *)theEvent { +} + +- (void)mouseUp:(NSEvent *)theEvent { +} + +- (void)rightMouseUp:(NSEvent *)theEvent { +} + +- (void)otherMouseUp:(NSEvent *)theEvent { +} + +- (void)mouseMoved:(NSEvent *)theEvent { +} + +- (void)mouseDragged:(NSEvent *)theEvent { +} + +- (void)rightMouseDragged:(NSEvent *)theEvent { +} + +- (void)otherMouseDragged:(NSEvent *)theEvent { +} + +// Eat drag operations, to prevent drags from going through to the views below. +- (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)info { + return NSDragOperationNone; +} + +- (ViewID)viewID { + return VIEW_ID_FIND_IN_PAGE; +} + +@end diff --git a/chrome/browser/ui/cocoa/find_bar_view_unittest.mm b/chrome/browser/ui/cocoa/find_bar_view_unittest.mm new file mode 100644 index 0000000..639b5ef --- /dev/null +++ b/chrome/browser/ui/cocoa/find_bar_view_unittest.mm @@ -0,0 +1,90 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/find_bar_view.h" +#include "chrome/browser/ui/cocoa/test_event_utils.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +@interface MouseDownViewPong : NSView { + BOOL pong_; +} +@property (nonatomic, assign) BOOL pong; +@end + +@implementation MouseDownViewPong +@synthesize pong = pong_; +- (void)mouseDown:(NSEvent*)event { + pong_ = YES; +} +@end + + +namespace { + +class FindBarViewTest : public CocoaTest { + public: + FindBarViewTest() { + NSRect frame = NSMakeRect(0, 0, 100, 30); + scoped_nsobject<FindBarView> view( + [[FindBarView alloc] initWithFrame:frame]); + view_ = view.get(); + [[test_window() contentView] addSubview:view_]; + } + + FindBarView* view_; +}; + +TEST_VIEW(FindBarViewTest, view_) + +TEST_F(FindBarViewTest, FindBarEatsMouseClicksInBackgroundArea) { + scoped_nsobject<MouseDownViewPong> pongView( + [[MouseDownViewPong alloc] initWithFrame:NSMakeRect(0, 0, 200, 200)]); + + // Remove all of the subviews of the findbar, to make sure we don't + // accidentally hit a subview when trying to simulate a click in the + // background area. + [view_ setSubviews:[NSArray array]]; + [view_ setFrame:NSMakeRect(0, 0, 200, 200)]; + + // Add the pong view as a sibling of the findbar. + [[test_window() contentView] addSubview:pongView.get() + positioned:NSWindowBelow + relativeTo:view_]; + + // Synthesize a mousedown event and send it to the window. The event is + // placed in the center of the find bar. + NSPoint pointInCenterOfFindBar = NSMakePoint(100, 100); + [pongView setPong:NO]; + [test_window() + sendEvent:test_event_utils::LeftMouseDownAtPoint(pointInCenterOfFindBar)]; + // Click gets eaten by findbar, not passed through to underlying view. + EXPECT_FALSE([pongView pong]); +} + +TEST_F(FindBarViewTest, FindBarPassesThroughClicksInTransparentArea) { + scoped_nsobject<MouseDownViewPong> pongView( + [[MouseDownViewPong alloc] initWithFrame:NSMakeRect(0, 0, 200, 200)]); + [view_ setFrame:NSMakeRect(0, 0, 200, 200)]; + + // Add the pong view as a sibling of the findbar. + [[test_window() contentView] addSubview:pongView.get() + positioned:NSWindowBelow + relativeTo:view_]; + + // Synthesize a mousedown event and send it to the window. The event is inset + // a few pixels from the lower left corner of the window, which places it in + // the transparent area surrounding the findbar. + NSPoint pointInTransparentArea = NSMakePoint(2, 2); + [pongView setPong:NO]; + [test_window() + sendEvent:test_event_utils::LeftMouseDownAtPoint(pointInTransparentArea)]; + // Click is ignored by findbar, passed through to underlying view. + EXPECT_TRUE([pongView pong]); +} +} // namespace diff --git a/chrome/browser/ui/cocoa/find_pasteboard.h b/chrome/browser/ui/cocoa/find_pasteboard.h new file mode 100644 index 0000000..f7153f6 --- /dev/null +++ b/chrome/browser/ui/cocoa/find_pasteboard.h @@ -0,0 +1,58 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_FIND_PASTEBOARD_H_ +#define CHROME_BROWSER_UI_COCOA_FIND_PASTEBOARD_H_ +#pragma once + +#include "base/string16.h" + +#ifdef __OBJC__ + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" + +extern NSString* kFindPasteboardChangedNotification; + +// Manages the find pasteboard. Use this to copy text to the find pasteboard, +// to get the text currently on the find pasteboard, and to receive +// notifications when the text on the find pasteboard has changed. You should +// always use this class instead of accessing +// [NSPasteboard pasteboardWithName:NSFindPboard] directly. +// +// This is not thread-safe and must be used on the main thread. +// +// This is supposed to be a singleton. +@interface FindPasteboard : NSObject { + @private + scoped_nsobject<NSString> findText_; +} + +// Returns the singleton instance of this class. ++ (FindPasteboard*)sharedInstance; + +// Returns the current find text. This is never nil; if there is no text on the +// find pasteboard, this returns an empty string. +- (NSString*)findText; + +// Sets the current find text to |newText| and sends a +// |kFindPasteboardChangedNotification| to the default notification center if +// it the new text different from the current text. |newText| must not be nil. +- (void)setFindText:(NSString*)newText; +@end + +@interface FindPasteboard (TestingAPI) +- (void)loadTextFromPasteboard:(NSNotification*)notification; + +// This methods is meant to be overridden in tests. +- (NSPasteboard*)findPboard; +@end + +#endif // __OBJC__ + +// Also provide a c++ interface +string16 GetFindPboardText(); + +#endif // CHROME_BROWSER_UI_COCOA_FIND_PASTEBOARD_H_ diff --git a/chrome/browser/ui/cocoa/find_pasteboard.mm b/chrome/browser/ui/cocoa/find_pasteboard.mm new file mode 100644 index 0000000..0e86111 --- /dev/null +++ b/chrome/browser/ui/cocoa/find_pasteboard.mm @@ -0,0 +1,82 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/find_pasteboard.h" + +#include "base/logging.h" +#include "base/sys_string_conversions.h" + +NSString* kFindPasteboardChangedNotification = + @"kFindPasteboardChangedNotification_Chrome"; + +@implementation FindPasteboard + ++ (FindPasteboard*)sharedInstance { + static FindPasteboard* instance = nil; + if (!instance) { + instance = [[FindPasteboard alloc] init]; + } + return instance; +} + +- (id)init { + if ((self = [super init])) { + findText_.reset([[NSString alloc] init]); + + // Check if the text in the findboard has changed on app activate. + [[NSNotificationCenter defaultCenter] + addObserver:self + selector:@selector(loadTextFromPasteboard:) + name:NSApplicationDidBecomeActiveNotification + object:nil]; + [self loadTextFromPasteboard:nil]; + } + return self; +} + +- (void)dealloc { + // Since this is a singleton, this should only be executed in test code. + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [super dealloc]; +} + +- (NSPasteboard*)findPboard { + return [NSPasteboard pasteboardWithName:NSFindPboard]; +} + +- (void)loadTextFromPasteboard:(NSNotification*)notification { + NSPasteboard* findPboard = [self findPboard]; + if ([[findPboard types] containsObject:NSStringPboardType]) + [self setFindText:[findPboard stringForType:NSStringPboardType]]; +} + +- (NSString*)findText { + return findText_; +} + +- (void)setFindText:(NSString*)newText { + DCHECK(newText); + if (!newText) + return; + + DCHECK([NSThread isMainThread]); + + BOOL needToSendNotification = ![findText_.get() isEqualToString:newText]; + if (needToSendNotification) { + findText_.reset([newText copy]); + NSPasteboard* findPboard = [self findPboard]; + [findPboard declareTypes:[NSArray arrayWithObject:NSStringPboardType] + owner:nil]; + [findPboard setString:findText_.get() forType:NSStringPboardType]; + [[NSNotificationCenter defaultCenter] + postNotificationName:kFindPasteboardChangedNotification + object:self]; + } +} + +@end + +string16 GetFindPboardText() { + return base::SysNSStringToUTF16([[FindPasteboard sharedInstance] findText]); +} diff --git a/chrome/browser/ui/cocoa/find_pasteboard_unittest.mm b/chrome/browser/ui/cocoa/find_pasteboard_unittest.mm new file mode 100644 index 0000000..739eff3 --- /dev/null +++ b/chrome/browser/ui/cocoa/find_pasteboard_unittest.mm @@ -0,0 +1,115 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/find_pasteboard.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +// A subclass of FindPasteboard that doesn't write to the real find pasteboard. +@interface FindPasteboardTesting : FindPasteboard { + @public + int notificationCount_; + @private + NSPasteboard* pboard_; +} +- (NSPasteboard*)findPboard; + +- (void)callback:(id)sender; + +// These are for checking that pasteboard content is copied to/from the +// FindPasteboard correctly. +- (NSString*)findPboardText; +- (void)setFindPboardText:(NSString*)text; +@end + +@implementation FindPasteboardTesting + +- (id)init { + if ((self = [super init])) { + pboard_ = [NSPasteboard pasteboardWithUniqueName]; + } + return self; +} + +- (void)dealloc { + [pboard_ releaseGlobally]; + [super dealloc]; +} + +- (NSPasteboard*)findPboard { + return pboard_; +} + +- (void)callback:(id)sender { + ++notificationCount_; +} + +- (void)setFindPboardText:(NSString*)text { + [pboard_ declareTypes:[NSArray arrayWithObject:NSStringPboardType] + owner:nil]; + [pboard_ setString:text forType:NSStringPboardType]; +} + +- (NSString*)findPboardText { + return [pboard_ stringForType:NSStringPboardType]; +} +@end + +namespace { + +class FindPasteboardTest : public CocoaTest { + public: + FindPasteboardTest() { + pboard_.reset([[FindPasteboardTesting alloc] init]); + } + protected: + scoped_nsobject<FindPasteboardTesting> pboard_; +}; + +TEST_F(FindPasteboardTest, SettingTextUpdatesPboard) { + [pboard_.get() setFindText:@"text"]; + EXPECT_EQ( + NSOrderedSame, + [[pboard_.get() findPboardText] compare:@"text"]); +} + +TEST_F(FindPasteboardTest, ReadingFromPboardUpdatesFindText) { + [pboard_.get() setFindPboardText:@"text"]; + [pboard_.get() loadTextFromPasteboard:nil]; + EXPECT_EQ( + NSOrderedSame, + [[pboard_.get() findText] compare:@"text"]); +} + +TEST_F(FindPasteboardTest, SendsNotificationWhenTextChanges) { + [[NSNotificationCenter defaultCenter] + addObserver:pboard_.get() + selector:@selector(callback:) + name:kFindPasteboardChangedNotification + object:pboard_.get()]; + EXPECT_EQ(0, pboard_.get()->notificationCount_); + [pboard_.get() setFindText:@"text"]; + EXPECT_EQ(1, pboard_.get()->notificationCount_); + [pboard_.get() setFindText:@"text"]; + EXPECT_EQ(1, pboard_.get()->notificationCount_); + [pboard_.get() setFindText:@"other text"]; + EXPECT_EQ(2, pboard_.get()->notificationCount_); + + [pboard_.get() setFindPboardText:@"other text"]; + [pboard_.get() loadTextFromPasteboard:nil]; + EXPECT_EQ(2, pboard_.get()->notificationCount_); + + [pboard_.get() setFindPboardText:@"otherer text"]; + [pboard_.get() loadTextFromPasteboard:nil]; + EXPECT_EQ(3, pboard_.get()->notificationCount_); + + [[NSNotificationCenter defaultCenter] removeObserver:pboard_.get()]; +} + + +} // namespace diff --git a/chrome/browser/ui/cocoa/first_run_bubble_controller.h b/chrome/browser/ui/cocoa/first_run_bubble_controller.h new file mode 100644 index 0000000..5c5f768 --- /dev/null +++ b/chrome/browser/ui/cocoa/first_run_bubble_controller.h @@ -0,0 +1,24 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#import "chrome/browser/ui/cocoa/base_bubble_controller.h" + +class Profile; + +// Manages the first run bubble. +@interface FirstRunBubbleController : BaseBubbleController { + @private + // Header label. + IBOutlet NSTextField* header_; + + Profile* profile_; +} + +// Creates and shows a firstRun bubble. ++ (FirstRunBubbleController*) showForView:(NSView*)view + offset:(NSPoint)offset + profile:(Profile*)profile; +@end diff --git a/chrome/browser/ui/cocoa/first_run_bubble_controller.mm b/chrome/browser/ui/cocoa/first_run_bubble_controller.mm new file mode 100644 index 0000000..a61239a --- /dev/null +++ b/chrome/browser/ui/cocoa/first_run_bubble_controller.mm @@ -0,0 +1,84 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/first_run_bubble_controller.h" + +#include "app/l10n_util.h" +#include "base/logging.h" +#include "base/utf_string_conversions.h" +#include "chrome/browser/search_engines/util.h" +#import "chrome/browser/ui/cocoa/l10n_util.h" +#import "chrome/browser/ui/cocoa/info_bubble_view.h" +#include "grit/generated_resources.h" + +@interface FirstRunBubbleController(Private) +- (id)initRelativeToView:(NSView*)view + offset:(NSPoint)offset + profile:(Profile*)profile; +- (void)closeIfNotKey; +@end + +@implementation FirstRunBubbleController + ++ (FirstRunBubbleController*) showForView:(NSView*)view + offset:(NSPoint)offset + profile:(Profile*)profile { + // Autoreleases itself on bubble close. + return [[FirstRunBubbleController alloc] initRelativeToView:view + offset:offset + profile:profile]; +} + +- (id)initRelativeToView:(NSView*)view + offset:(NSPoint)offset + profile:(Profile*)profile { + if ((self = [super initWithWindowNibPath:@"FirstRunBubble" + relativeToView:view + offset:offset])) { + profile_ = profile; + [self showWindow:nil]; + + // On 10.5, the first run bubble sometimes does not disappear when clicking + // the omnibox. This happens if the bubble never became key, due to it + // showing up so early in the startup sequence. As a workaround, close it + // automatically after a few seconds if it doesn't become key. + // http://crbug.com/52726 + [self performSelector:@selector(closeIfNotKey) withObject:nil afterDelay:3]; + } + return self; +} + +- (void)awakeFromNib { + [[self bubble] setBubbleType:info_bubble::kWhiteInfoBubble]; + + DCHECK(header_); + [header_ setStringValue:cocoa_l10n_util::ReplaceNSStringPlaceholders( + [header_ stringValue], GetDefaultSearchEngineName(profile_), NULL)]; + + // Adapt window size to bottom buttons. Do this before all other layouting. + CGFloat dy = cocoa_l10n_util::VerticallyReflowGroup([[self bubble] subviews]); + NSSize ds = NSMakeSize(0, dy); + ds = [[self bubble] convertSize:ds toView:nil]; + + NSRect frame = [[self window] frame]; + frame.origin.y -= ds.height; + frame.size.height += ds.height; + [[self window] setFrame:frame display:YES]; +} + +- (void)close { + // If the window is closed before the timer is fired, cancel the timer, since + // it retains the controller. + [NSObject cancelPreviousPerformRequestsWithTarget:self + selector:@selector(closeIfNotKey) + object:nil]; + [super close]; +} + +- (void)closeIfNotKey { + if (![[self window] isKeyWindow]) + [self close]; +} + +@end // FirstRunBubbleController diff --git a/chrome/browser/ui/cocoa/first_run_bubble_controller_unittest.mm b/chrome/browser/ui/cocoa/first_run_bubble_controller_unittest.mm new file mode 100644 index 0000000..d9f37c93 --- /dev/null +++ b/chrome/browser/ui/cocoa/first_run_bubble_controller_unittest.mm @@ -0,0 +1,44 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/first_run_bubble_controller.h" + +#import <Cocoa/Cocoa.h> + +#include "base/debug/debugger.h" +#include "base/scoped_nsobject.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "testing/gtest/include/gtest/gtest.h" + +namespace { + +class FirstRunBubbleControllerTest : public CocoaTest { + public: + BrowserTestHelper helper_; +}; + +// Check that the bubble doesn't crash or leak. +TEST_F(FirstRunBubbleControllerTest, Init) { + scoped_nsobject<NSWindow> parent([[NSWindow alloc] + initWithContentRect:NSMakeRect(0, 0, 800, 600) + styleMask:NSBorderlessWindowMask + backing:NSBackingStoreBuffered + defer:NO]); + [parent setReleasedWhenClosed:NO]; + if (base::debug::BeingDebugged()) + [parent.get() orderFront:nil]; + else + [parent.get() orderBack:nil]; + + FirstRunBubbleController* controller = [FirstRunBubbleController + showForView:[parent.get() contentView] + offset:NSMakePoint(300, 300) + profile:helper_.profile()]; + EXPECT_TRUE(controller != nil); + EXPECT_TRUE([[controller window] isVisible]); + [parent.get() close]; +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/first_run_dialog.h b/chrome/browser/ui/cocoa/first_run_dialog.h new file mode 100644 index 0000000..3e575a3 --- /dev/null +++ b/chrome/browser/ui/cocoa/first_run_dialog.h @@ -0,0 +1,36 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_FIRST_RUN_DIALOG_H_ +#define CHROME_BROWSER_FIRST_RUN_DIALOG_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +// Class that acts as a controller for the modal first run dialog. +// The dialog asks the user's explicit permission for reporting stats to help +// us improve Chromium. +@interface FirstRunDialogController : NSWindowController { + @private + BOOL statsEnabled_; + BOOL makeDefaultBrowser_; + + IBOutlet NSArray* objectsToSize_; + IBOutlet NSButton* statsCheckbox_; + BOOL beenSized_; +} + +// Called when the "Start Google Chrome" button is pressed. +- (IBAction)ok:(id)sender; + +// Called when the "Learn More" button is pressed. +- (IBAction)learnMore:(id)sender; + +// Properties for bindings. +@property(assign, nonatomic) BOOL statsEnabled; +@property(assign, nonatomic) BOOL makeDefaultBrowser; + +@end + +#endif // CHROME_BROWSER_FIRST_RUN_DIALOG_H_ diff --git a/chrome/browser/ui/cocoa/first_run_dialog.mm b/chrome/browser/ui/cocoa/first_run_dialog.mm new file mode 100644 index 0000000..9590b9b --- /dev/null +++ b/chrome/browser/ui/cocoa/first_run_dialog.mm @@ -0,0 +1,195 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/first_run_dialog.h" + +#include "app/l10n_util_mac.h" +#include "base/mac_util.h" +#include "base/message_loop.h" +#include "base/ref_counted.h" +#include "grit/locale_settings.h" +#import "third_party/GTM/AppKit/GTMUILocalizerAndLayoutTweaker.h" + +@interface FirstRunDialogController (PrivateMethods) +// Show the dialog. +- (void)show; +@end + +namespace { + +// Compare function for -[NSArray sortedArrayUsingFunction:context:] that +// sorts the views in Y order bottom up. +NSInteger CompareFrameY(id view1, id view2, void* context) { + CGFloat y1 = NSMinY([view1 frame]); + CGFloat y2 = NSMinY([view2 frame]); + if (y1 < y2) + return NSOrderedAscending; + else if (y1 > y2) + return NSOrderedDescending; + else + return NSOrderedSame; +} + +class FirstRunShowBridge : public base::RefCounted<FirstRunShowBridge> { + public: + FirstRunShowBridge(FirstRunDialogController* controller); + + void ShowDialog(); + private: + FirstRunDialogController* controller_; +}; + +FirstRunShowBridge::FirstRunShowBridge( + FirstRunDialogController* controller) : controller_(controller) { +} + +void FirstRunShowBridge::ShowDialog() { + [controller_ show]; + MessageLoop::current()->QuitNow(); +} + +}; + +@implementation FirstRunDialogController + +@synthesize statsEnabled = statsEnabled_; +@synthesize makeDefaultBrowser = makeDefaultBrowser_; + +- (id)init { + NSString* nibpath = + [mac_util::MainAppBundle() pathForResource:@"FirstRunDialog" + ofType:@"nib"]; + self = [super initWithWindowNibPath:nibpath owner:self]; + if (self != nil) { + // Bound to the dialog checkbox, default to true. + makeDefaultBrowser_ = YES; + } + return self; +} + +- (void)dealloc { + [super dealloc]; +} + +- (IBAction)showWindow:(id)sender { + // The main MessageLoop has not yet run, but has been spun. If we call + // -[NSApplication runModalForWindow:] we will hang <http://crbug.com/54248>. + // Therefore the main MessageLoop is run so things work. + + scoped_refptr<FirstRunShowBridge> bridge(new FirstRunShowBridge(self)); + MessageLoop::current()->PostTask( + FROM_HERE, + NewRunnableMethod(bridge.get(), + &FirstRunShowBridge::ShowDialog)); + MessageLoop::current()->Run(); +} + +- (void)show { + NSWindow* win = [self window]; + + // Only support the sizing the window once. + DCHECK(!beenSized_) << "ShowWindow was called twice?"; + if (!beenSized_) { + beenSized_ = YES; + DCHECK_GT([objectsToSize_ count], 0U); + + // Size everything to fit, collecting the widest growth needed (XIB provides + // the min size, i.e.-never shrink, just grow). + CGFloat largestWidthChange = 0.0; + for (NSView* view in objectsToSize_) { + DCHECK_NE(statsCheckbox_, view) << "Stats checkbox shouldn't be in list"; + if (![view isHidden]) { + NSSize delta = [GTMUILocalizerAndLayoutTweaker sizeToFitView:view]; + DCHECK_EQ(delta.height, 0.0) + << "Didn't expect anything to change heights"; + if (largestWidthChange < delta.width) + largestWidthChange = delta.width; + } + } + + // Make the window wide enough to fit everything. + if (largestWidthChange > 0.0) { + NSView* contentView = [win contentView]; + NSRect windowFrame = [contentView convertRect:[win frame] fromView:nil]; + windowFrame.size.width += largestWidthChange; + windowFrame = [contentView convertRect:windowFrame toView:nil]; + [win setFrame:windowFrame display:NO]; + } + + // The stats checkbox gets some really long text, so it gets word wrapped + // and then sized. + DCHECK(statsCheckbox_); + CGFloat statsCheckboxHeightChange = 0.0; + [GTMUILocalizerAndLayoutTweaker wrapButtonTitleForWidth:statsCheckbox_]; + statsCheckboxHeightChange = + [GTMUILocalizerAndLayoutTweaker sizeToFitView:statsCheckbox_].height; + + // Walk bottom up shuffling for all the hidden views. + NSArray* subViews = + [[[win contentView] subviews] sortedArrayUsingFunction:CompareFrameY + context:NULL]; + CGFloat moveDown = 0.0; + NSUInteger numSubViews = [subViews count]; + for (NSUInteger idx = 0 ; idx < numSubViews ; ++idx) { + NSView* view = [subViews objectAtIndex:idx]; + + // If the view is hidden, collect the amount to move everything above it + // down, if it's not hidden, apply any shift down. + if ([view isHidden]) { + DCHECK_GT((numSubViews - 1), idx) + << "Don't support top view being hidden"; + NSView* nextView = [subViews objectAtIndex:(idx + 1)]; + CGFloat viewBottom = [view frame].origin.y; + CGFloat nextViewBottom = [nextView frame].origin.y; + moveDown += nextViewBottom - viewBottom; + } else { + if (moveDown != 0.0) { + NSPoint origin = [view frame].origin; + origin.y -= moveDown; + [view setFrameOrigin:origin]; + } + } + // Special case, if this is the stats checkbox, everything above it needs + // to get moved up by the amount it changed height. + if (view == statsCheckbox_) { + moveDown -= statsCheckboxHeightChange; + } + } + + // Resize the window for any height change from hidden views, etc. + if (moveDown != 0.0) { + NSView* contentView = [win contentView]; + [contentView setAutoresizesSubviews:NO]; + NSRect windowFrame = [contentView convertRect:[win frame] fromView:nil]; + windowFrame.size.height -= moveDown; + windowFrame = [contentView convertRect:windowFrame toView:nil]; + [win setFrame:windowFrame display:NO]; + [contentView setAutoresizesSubviews:YES]; + } + + } + + // Neat weirdness in the below code - the Application menu stays enabled + // while the window is open but selecting items from it (e.g. Quit) has + // no effect. I'm guessing that this is an artifact of us being a + // background-only application at this stage and displaying a modal + // window. + + // Display dialog. + [win center]; + [NSApp runModalForWindow:win]; +} + +- (IBAction)ok:(id)sender { + [[self window] close]; + [NSApp stopModal]; +} + +- (IBAction)learnMore:(id)sender { + NSString* urlStr = l10n_util::GetNSString(IDS_LEARN_MORE_REPORTING_URL); + NSURL* learnMoreUrl = [NSURL URLWithString:urlStr]; + [[NSWorkspace sharedWorkspace] openURL:learnMoreUrl]; +} + +@end diff --git a/chrome/browser/ui/cocoa/floating_bar_backing_view.h b/chrome/browser/ui/cocoa/floating_bar_backing_view.h new file mode 100644 index 0000000..7cd6b2d --- /dev/null +++ b/chrome/browser/ui/cocoa/floating_bar_backing_view.h @@ -0,0 +1,15 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_FLOATING_BAR_BACKING_VIEW_H_ +#define CHROME_BROWSER_UI_COCOA_FLOATING_BAR_BACKING_VIEW_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +// A custom view that draws the tab strip background for fullscreen windows. +@interface FloatingBarBackingView : NSView +@end + +#endif // CHROME_BROWSER_UI_COCOA_FLOATING_BAR_BACKING_VIEW_H_ diff --git a/chrome/browser/ui/cocoa/floating_bar_backing_view.mm b/chrome/browser/ui/cocoa/floating_bar_backing_view.mm new file mode 100644 index 0000000..70f785c --- /dev/null +++ b/chrome/browser/ui/cocoa/floating_bar_backing_view.mm @@ -0,0 +1,51 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/floating_bar_backing_view.h" + +#include "base/mac_util.h" +#import "chrome/browser/ui/cocoa/browser_frame_view.h" + +@implementation FloatingBarBackingView + +- (void)drawRect:(NSRect)rect { + NSWindow* window = [self window]; + BOOL isMainWindow = [window isMainWindow]; + + if (isMainWindow) + [[NSColor windowFrameColor] set]; + else + [[NSColor windowBackgroundColor] set]; + NSRectFill(rect); + + // TODO(rohitrao): Don't assume -22 here. + [BrowserFrameView drawWindowThemeInDirtyRect:rect + forView:self + bounds:[self bounds] + offset:NSMakePoint(0, -22) + forceBlackBackground:YES]; + +} + +// Eat all mouse events (and do *not* pass them on to the next responder!). +- (void)mouseDown:(NSEvent*)event {} +- (void)rightMouseDown:(NSEvent*)event {} +- (void)otherMouseDown:(NSEvent*)event {} +- (void)rightMouseUp:(NSEvent*)event {} +- (void)otherMouseUp:(NSEvent*)event {} +- (void)mouseMoved:(NSEvent*)event {} +- (void)mouseDragged:(NSEvent*)event {} +- (void)rightMouseDragged:(NSEvent*)event {} +- (void)otherMouseDragged:(NSEvent*)event {} + +// Eat this too, except that ... +- (void)mouseUp:(NSEvent*)event { + // a double-click in the blank area should try to minimize, to be consistent + // with double-clicks on the contiguous tab strip area. (It'll fail and beep.) + if ([event clickCount] == 2 && + mac_util::ShouldWindowsMiniaturizeOnDoubleClick()) + [[self window] performMiniaturize:self]; +} + +@end // @implementation FloatingBarBackingView diff --git a/chrome/browser/ui/cocoa/floating_bar_backing_view_unittest.mm b/chrome/browser/ui/cocoa/floating_bar_backing_view_unittest.mm new file mode 100644 index 0000000..4753a26 --- /dev/null +++ b/chrome/browser/ui/cocoa/floating_bar_backing_view_unittest.mm @@ -0,0 +1,26 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/floating_bar_backing_view.h" + +namespace { + +class FloatingBarBackingViewTest : public CocoaTest { + public: + FloatingBarBackingViewTest() { + NSRect content_frame = [[test_window() contentView] frame]; + scoped_nsobject<FloatingBarBackingView> view( + [[FloatingBarBackingView alloc] initWithFrame:content_frame]); + view_ = view.get(); + [[test_window() contentView] addSubview:view_]; + } + + FloatingBarBackingView* view_; // Weak. Owned by the view hierarchy. +}; + +// Tests display, add/remove. +TEST_VIEW(FloatingBarBackingViewTest, view_); + +} // namespace diff --git a/chrome/browser/ui/cocoa/focus_tracker.h b/chrome/browser/ui/cocoa/focus_tracker.h new file mode 100644 index 0000000..f828979 --- /dev/null +++ b/chrome/browser/ui/cocoa/focus_tracker.h @@ -0,0 +1,28 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" + +// A class that handles saving and restoring focus. An instance of +// this class snapshots the currently focused view when it is +// constructed, and callers can use restoreFocus to return focus to +// that view. FocusTracker will not restore focus to views that are +// no longer in the view hierarchy or are not in the correct window. + +@interface FocusTracker : NSObject { + @private + scoped_nsobject<NSView> focusedView_; +} + +// |window| is the window that we are saving focus for. This +// method snapshots the currently focused view. +- (id)initWithWindow:(NSWindow*)window; + +// Attempts to restore focus to the snapshotted view. Returns YES if +// focus was restored. Will not restore focus if the view is no +// longer in the view hierarchy under |window|. +- (BOOL)restoreFocusInWindow:(NSWindow*)window; +@end diff --git a/chrome/browser/ui/cocoa/focus_tracker.mm b/chrome/browser/ui/cocoa/focus_tracker.mm new file mode 100644 index 0000000..dc52791 --- /dev/null +++ b/chrome/browser/ui/cocoa/focus_tracker.mm @@ -0,0 +1,46 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/focus_tracker.h" + +#include "base/basictypes.h" + +@implementation FocusTracker + +- (id)initWithWindow:(NSWindow*)window { + if ((self = [super init])) { + NSResponder* current_focus = [window firstResponder]; + + // Special case NSTextViews, because they are removed from the + // view hierarchy when their text field does not have focus. If + // an NSTextView is the current first responder, save a pointer to + // its NSTextField delegate instead. + if ([current_focus isKindOfClass:[NSTextView class]]) { + id delegate = [(NSTextView*)current_focus delegate]; + if ([delegate isKindOfClass:[NSTextField class]]) + current_focus = delegate; + else + current_focus = nil; + } + + if ([current_focus isKindOfClass:[NSView class]]) { + NSView* current_focus_view = (NSView*)current_focus; + focusedView_.reset([current_focus_view retain]); + } + } + + return self; +} + +- (BOOL)restoreFocusInWindow:(NSWindow*)window { + if (!focusedView_.get()) + return NO; + + if ([focusedView_ window] && [focusedView_ window] == window) + return [window makeFirstResponder:focusedView_.get()]; + + return NO; +} + +@end diff --git a/chrome/browser/ui/cocoa/focus_tracker_unittest.mm b/chrome/browser/ui/cocoa/focus_tracker_unittest.mm new file mode 100644 index 0000000..9868cbd --- /dev/null +++ b/chrome/browser/ui/cocoa/focus_tracker_unittest.mm @@ -0,0 +1,90 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/focus_tracker.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +namespace { + +class FocusTrackerTest : public CocoaTest { + public: + virtual void SetUp() { + CocoaTest::SetUp(); + scoped_nsobject<NSView> view([[NSView alloc] initWithFrame:NSZeroRect]); + viewA_ = view.get(); + [[test_window() contentView] addSubview:viewA_]; + + view.reset([[NSView alloc] initWithFrame:NSZeroRect]); + viewB_ = view.get(); + [[test_window() contentView] addSubview:viewB_]; + } + + protected: + NSView* viewA_; + NSView* viewB_; +}; + +TEST_F(FocusTrackerTest, SaveRestore) { + NSWindow* window = test_window(); + ASSERT_TRUE([window makeFirstResponder:viewA_]); + scoped_nsobject<FocusTracker> tracker( + [[FocusTracker alloc] initWithWindow:window]); + // Give focus to |viewB_|, then try and restore it to view1. + ASSERT_TRUE([window makeFirstResponder:viewB_]); + EXPECT_TRUE([tracker restoreFocusInWindow:window]); + EXPECT_EQ(viewA_, [window firstResponder]); +} + +TEST_F(FocusTrackerTest, SaveRestoreWithTextView) { + // Valgrind will complain if the text field has zero size. + NSRect frame = NSMakeRect(0, 0, 100, 20); + NSWindow* window = test_window(); + scoped_nsobject<NSTextField> text([[NSTextField alloc] initWithFrame:frame]); + [[window contentView] addSubview:text]; + + ASSERT_TRUE([window makeFirstResponder:text]); + scoped_nsobject<FocusTracker> tracker([[FocusTracker alloc] + initWithWindow:window]); + // Give focus to |viewB_|, then try and restore it to the text field. + ASSERT_TRUE([window makeFirstResponder:viewB_]); + EXPECT_TRUE([tracker restoreFocusInWindow:window]); + EXPECT_TRUE([[window firstResponder] isKindOfClass:[NSTextView class]]); +} + +TEST_F(FocusTrackerTest, DontRestoreToViewNotInWindow) { + NSWindow* window = test_window(); + scoped_nsobject<NSView> viewC([[NSView alloc] initWithFrame:NSZeroRect]); + [[window contentView] addSubview:viewC]; + + ASSERT_TRUE([window makeFirstResponder:viewC]); + scoped_nsobject<FocusTracker> tracker( + [[FocusTracker alloc] initWithWindow:window]); + + // Give focus to |viewB_|, then remove viewC from the hierarchy and try + // to restore focus. The restore should fail. + ASSERT_TRUE([window makeFirstResponder:viewB_]); + [viewC removeFromSuperview]; + EXPECT_FALSE([tracker restoreFocusInWindow:window]); +} + +TEST_F(FocusTrackerTest, DontRestoreFocusToViewInDifferentWindow) { + NSWindow* window = test_window(); + ASSERT_TRUE([window makeFirstResponder:viewA_]); + scoped_nsobject<FocusTracker> tracker( + [[FocusTracker alloc] initWithWindow:window]); + + // Give focus to |viewB_|, then try and restore focus in a different + // window. It is ok to pass a nil NSWindow here because we only use + // it for direct comparison. + ASSERT_TRUE([window makeFirstResponder:viewB_]); + EXPECT_FALSE([tracker restoreFocusInWindow:nil]); +} + + +} // namespace diff --git a/chrome/browser/ui/cocoa/font_language_settings_controller.h b/chrome/browser/ui/cocoa/font_language_settings_controller.h new file mode 100644 index 0000000..9e03323 --- /dev/null +++ b/chrome/browser/ui/cocoa/font_language_settings_controller.h @@ -0,0 +1,94 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/cocoa_protocols_mac.h" +#include "base/scoped_nsobject.h" +#include "chrome/browser/prefs/pref_member.h" + +class Profile; + +// Used to keep track of which type of font the user is currently selecting. +enum FontSettingType { + FontSettingSerif, + FontSettingSansSerif, + FontSettingFixed +}; + +// Keys for the dictionaries in the |encodings_| array. +extern NSString* const kCharacterInfoEncoding; // NSString value. +extern NSString* const kCharacterInfoName; // NSString value. +extern NSString* const kCharacterInfoID; // NSNumber value. + +// A window controller that allows the user to change the default WebKit fonts +// and language encodings for web pages. This window controller is meant to be +// used as a modal sheet on another window. +@interface FontLanguageSettingsController : NSWindowController + <NSWindowDelegate> { + @private + // The font that we are currently changing. + NSFont* currentFont_; // weak + FontSettingType currentType_; + + IBOutlet NSButton* serifButton_; + IBOutlet NSTextField* serifField_; + scoped_nsobject<NSFont> serifFont_; + IBOutlet NSTextField* serifLabel_; + BOOL changedSerif_; + + IBOutlet NSButton* sansSerifButton_; + IBOutlet NSTextField* sansSerifField_; + scoped_nsobject<NSFont> sansSerifFont_; + IBOutlet NSTextField* sansSerifLabel_; + BOOL changedSansSerif_; + + IBOutlet NSButton* fixedWidthButton_; + IBOutlet NSTextField* fixedWidthField_; + scoped_nsobject<NSFont> fixedWidthFont_; + IBOutlet NSTextField* fixedWidthLabel_; + BOOL changedFixedWidth_; + + // The actual preference members. + StringPrefMember serifName_; + StringPrefMember sansSerifName_; + StringPrefMember fixedWidthName_; + IntegerPrefMember serifSize_; + IntegerPrefMember sansSerifSize_; + IntegerPrefMember fixedWidthSize_; + + // Array of dictionaries that contain the canonical encoding name, human- + // readable name, and the ID. See the constants defined at the top of this + // file for the keys. + scoped_nsobject<NSMutableArray> encodings_; + + IBOutlet NSPopUpButton* encodingsMenu_; + NSInteger defaultEncodingIndex_; + StringPrefMember defaultEncoding_; + BOOL changedEncoding_; + + Profile* profile_; // weak +} + +// Profile cannot be NULL. Caller is responsible for showing the window as a +// modal sheet. +- (id)initWithProfile:(Profile*)profile; + +// Action for all the font changing buttons. This starts the font picker. +- (IBAction)selectFont:(id)sender; + +// Sent by the FontManager after the user has selected a font. +- (void)changeFont:(id)fontManager; + +// Performs the closing of the window. This is used by both the cancel button +// and |-save:| after it persists the settings. +- (IBAction)closeSheet:(id)sender; + +// Persists the new values into the preferences and closes the sheet. +- (IBAction)save:(id)sender; + +// Returns the |encodings_| array. This is used by bindings for KVO/KVC. +- (NSArray*)encodings; + +@end diff --git a/chrome/browser/ui/cocoa/font_language_settings_controller.mm b/chrome/browser/ui/cocoa/font_language_settings_controller.mm new file mode 100644 index 0000000..b3bdb980 --- /dev/null +++ b/chrome/browser/ui/cocoa/font_language_settings_controller.mm @@ -0,0 +1,280 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/font_language_settings_controller.h" + +#import <Cocoa/Cocoa.h> +#import "base/mac_util.h" +#include "base/sys_string_conversions.h" +#include "chrome/browser/character_encoding.h" +#include "chrome/browser/fonts_languages_window.h" +#include "chrome/browser/prefs/pref_service.h" +#include "chrome/browser/profile.h" +#include "chrome/common/pref_names.h" + +NSString* const kCharacterInfoEncoding = @"encoding"; +NSString* const kCharacterInfoName = @"name"; +NSString* const kCharacterInfoID = @"id"; + +void ShowFontsLanguagesWindow(gfx::NativeWindow window, + FontsLanguagesPage page, + Profile* profile) { + NOTIMPLEMENTED(); +} + +@interface FontLanguageSettingsController (Private) +- (void)updateDisplayField:(NSTextField*)field + withFont:(NSFont*)font + withLabel:(NSTextField*)label; +@end + +@implementation FontLanguageSettingsController + +- (id)initWithProfile:(Profile*)profile { + DCHECK(profile); + NSString* nibpath = [mac_util::MainAppBundle() + pathForResource:@"FontLanguageSettings" + ofType:@"nib"]; + if ((self = [super initWithWindowNibPath:nibpath owner:self])) { + profile_ = profile; + + // Convert the name/size preference values to NSFont objects. + serifName_.Init(prefs::kWebKitSerifFontFamily, profile->GetPrefs(), NULL); + serifSize_.Init(prefs::kWebKitDefaultFontSize, profile->GetPrefs(), NULL); + NSString* serif = base::SysUTF8ToNSString(serifName_.GetValue()); + serifFont_.reset( + [[NSFont fontWithName:serif size:serifSize_.GetValue()] retain]); + + sansSerifName_.Init(prefs::kWebKitSansSerifFontFamily, profile->GetPrefs(), + NULL); + sansSerifSize_.Init(prefs::kWebKitDefaultFontSize, profile->GetPrefs(), + NULL); + NSString* sansSerif = base::SysUTF8ToNSString(sansSerifName_.GetValue()); + sansSerifFont_.reset( + [[NSFont fontWithName:sansSerif + size:sansSerifSize_.GetValue()] retain]); + + fixedWidthName_.Init(prefs::kWebKitFixedFontFamily, profile->GetPrefs(), + NULL); + fixedWidthSize_.Init(prefs::kWebKitDefaultFixedFontSize, + profile->GetPrefs(), NULL); + NSString* fixedWidth = base::SysUTF8ToNSString(fixedWidthName_.GetValue()); + fixedWidthFont_.reset( + [[NSFont fontWithName:fixedWidth + size:fixedWidthSize_.GetValue()] retain]); + + // Generate a list of encodings. + NSInteger count = CharacterEncoding::GetSupportCanonicalEncodingCount(); + NSMutableArray* encodings = [NSMutableArray arrayWithCapacity:count]; + for (NSInteger i = 0; i < count; ++i) { + int commandId = CharacterEncoding::GetEncodingCommandIdByIndex(i); + string16 name = CharacterEncoding::\ + GetCanonicalEncodingDisplayNameByCommandId(commandId); + std::string encoding = + CharacterEncoding::GetCanonicalEncodingNameByCommandId(commandId); + NSDictionary* strings = [NSDictionary dictionaryWithObjectsAndKeys: + base::SysUTF16ToNSString(name), kCharacterInfoName, + base::SysUTF8ToNSString(encoding), kCharacterInfoEncoding, + [NSNumber numberWithInt:commandId], kCharacterInfoID, + nil + ]; + [encodings addObject:strings]; + } + + // Sort the encodings. + scoped_nsobject<NSSortDescriptor> sorter( + [[NSSortDescriptor alloc] initWithKey:kCharacterInfoName + ascending:YES]); + NSArray* sorterArray = [NSArray arrayWithObject:sorter.get()]; + encodings_.reset( + [[encodings sortedArrayUsingDescriptors:sorterArray] retain]); + + // Find and set the default encoding. + defaultEncoding_.Init(prefs::kDefaultCharset, profile->GetPrefs(), NULL); + NSString* defaultEncoding = + base::SysUTF8ToNSString(defaultEncoding_.GetValue()); + NSUInteger index = 0; + for (NSDictionary* entry in encodings_.get()) { + NSString* encoding = [entry objectForKey:kCharacterInfoEncoding]; + if ([encoding isEqualToString:defaultEncoding]) { + defaultEncodingIndex_ = index; + break; + } + ++index; + } + + // Register as a KVO observer so we can receive updates when the encoding + // changes. + [self addObserver:self + forKeyPath:@"defaultEncodingIndex_" + options:NSKeyValueObservingOptionNew + context:NULL]; + } + return self; +} + +- (void)dealloc { + [self removeObserver:self forKeyPath:@"defaultEncodingIndex_"]; + [super dealloc]; +} + +- (void)awakeFromNib { + DCHECK([self window]); + [[self window] setDelegate:self]; + + // Set up the font display. + [self updateDisplayField:serifField_ + withFont:serifFont_.get() + withLabel:serifLabel_]; + [self updateDisplayField:sansSerifField_ + withFont:sansSerifFont_.get() + withLabel:sansSerifLabel_]; + [self updateDisplayField:fixedWidthField_ + withFont:fixedWidthFont_.get() + withLabel:fixedWidthLabel_]; +} + +- (void)windowWillClose:(NSNotification*)notif { + [self autorelease]; +} + +- (IBAction)selectFont:(id)sender { + if (sender == serifButton_) { + currentFont_ = serifFont_.get(); + currentType_ = FontSettingSerif; + } else if (sender == sansSerifButton_) { + currentFont_ = sansSerifFont_.get(); + currentType_ = FontSettingSansSerif; + } else if (sender == fixedWidthButton_) { + currentFont_ = fixedWidthFont_.get(); + currentType_ = FontSettingFixed; + } else { + NOTREACHED(); + } + + // Validate whatever editing is currently happening. + if ([[self window] makeFirstResponder:nil]) { + NSFontManager* manager = [NSFontManager sharedFontManager]; + [manager setTarget:self]; + [manager setSelectedFont:currentFont_ isMultiple:NO]; + [manager orderFrontFontPanel:self]; + } +} + +// Called by the font manager when the user has selected a new font. We should +// then persist those changes into the preference system. +- (void)changeFont:(id)fontManager { + switch (currentType_) { + case FontSettingSerif: + serifFont_.reset([[fontManager convertFont:serifFont_] retain]); + [self updateDisplayField:serifField_ + withFont:serifFont_.get() + withLabel:serifLabel_]; + changedSerif_ = YES; + break; + case FontSettingSansSerif: + sansSerifFont_.reset([[fontManager convertFont:sansSerifFont_] retain]); + [self updateDisplayField:sansSerifField_ + withFont:sansSerifFont_.get() + withLabel:sansSerifLabel_]; + changedSansSerif_ = YES; + break; + case FontSettingFixed: + fixedWidthFont_.reset( + [[fontManager convertFont:fixedWidthFont_] retain]); + [self updateDisplayField:fixedWidthField_ + withFont:fixedWidthFont_.get() + withLabel:fixedWidthLabel_]; + changedFixedWidth_ = YES; + break; + default: + NOTREACHED(); + } +} + +- (IBAction)closeSheet:(id)sender { + NSFontPanel* panel = [[NSFontManager sharedFontManager] fontPanel:NO]; + [panel close]; + [NSApp endSheet:[self window]]; +} + +- (IBAction)save:(id)sender { + if (changedSerif_) { + serifName_.SetValue(base::SysNSStringToUTF8([serifFont_ fontName])); + serifSize_.SetValue([serifFont_ pointSize]); + } + if (changedSansSerif_) { + sansSerifName_.SetValue( + base::SysNSStringToUTF8([sansSerifFont_ fontName])); + sansSerifSize_.SetValue([sansSerifFont_ pointSize]); + } + if (changedFixedWidth_) { + fixedWidthName_.SetValue( + base::SysNSStringToUTF8([fixedWidthFont_ fontName])); + fixedWidthSize_.SetValue([fixedWidthFont_ pointSize]); + } + if (changedEncoding_) { + NSDictionary* object = [encodings_ objectAtIndex:defaultEncodingIndex_]; + NSString* newEncoding = [object objectForKey:kCharacterInfoEncoding]; + std::string encoding = base::SysNSStringToUTF8(newEncoding); + defaultEncoding_.SetValue(encoding); + } + [self closeSheet:sender]; +} + +- (NSArray*)encodings { + return encodings_.get(); +} + +// KVO notification. +- (void)observeValueForKeyPath:(NSString*)keyPath + ofObject:(id)object + change:(NSDictionary*)change + context:(void*)context { + // If this is the default encoding, then set the flag to persist the value. + if ([keyPath isEqual:@"defaultEncodingIndex_"]) { + changedEncoding_ = YES; + return; + } + + [super observeValueForKeyPath:keyPath + ofObject:object + change:change + context:context]; +} + +#pragma mark Private + +// Set the baseline for the font field to be aligned with the baseline +// of its corresponding label. +- (NSPoint)getFontFieldOrigin:(NSTextField*)field + forLabel:(NSTextField*)label { + [field sizeToFit]; + NSRect labelFrame = [label frame]; + NSPoint newOrigin = + [[label superview] convertPoint:labelFrame.origin + toView:[field superview]]; + newOrigin.x = 0; // Left-align font field. + newOrigin.y += [[field font] descender] - [[label font] descender]; + return newOrigin; +} + +// This will set the font on |field| to be |font|, and will set the string +// value to something human-readable. +- (void)updateDisplayField:(NSTextField*)field + withFont:(NSFont*)font + withLabel:(NSTextField*)label { + if (!font) { + // Something has gone really wrong. Don't make things worse by showing the + // user "(null)". + return; + } + [field setFont:font]; + NSString* value = + [NSString stringWithFormat:@"%@, %g", [font fontName], [font pointSize]]; + [field setStringValue:value]; + [field setFrameOrigin:[self getFontFieldOrigin:field forLabel:label]]; +} + +@end diff --git a/chrome/browser/ui/cocoa/font_language_settings_controller_unittest.mm b/chrome/browser/ui/cocoa/font_language_settings_controller_unittest.mm new file mode 100644 index 0000000..9b0b259 --- /dev/null +++ b/chrome/browser/ui/cocoa/font_language_settings_controller_unittest.mm @@ -0,0 +1,91 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/scoped_nsobject.h" +#include "chrome/browser/character_encoding.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/font_language_settings_controller.h" +#include "testing/gtest/include/gtest/gtest.h" +#import "testing/gtest_mac.h" +#include "testing/platform_test.h" + +// The FontLanguageSettingsControllerForTest overrides the getFontFieldOrigin +// method to provide a dummy point, so we don't have to actually display the +// window to test the controller. +@interface FontLanguageSettingsControllerForTest : + FontLanguageSettingsController { +} + +- (NSPoint)getFontFieldOrigin:(NSTextField*)field + forLabel:(NSTextField*)label; + +@end + +@implementation FontLanguageSettingsControllerForTest + +- (NSPoint)getFontFieldOrigin:(NSTextField*)field + forLabel:(NSTextField*)label { + return NSMakePoint(10, 10); +} + +@end + +@interface FontLanguageSettingsController (Testing) +- (void)updateDisplayField:(NSTextField*)field + withFont:(NSFont*)font + withLabel:(NSTextField*)label; +@end + +class FontLanguageSettingsControllerTest : public CocoaTest { + public: + FontLanguageSettingsControllerTest() { + Profile* profile = helper_.profile(); + font_controller_.reset( + [[FontLanguageSettingsControllerForTest alloc] initWithProfile:profile]); + } + ~FontLanguageSettingsControllerTest() {} + + BrowserTestHelper helper_; + scoped_nsobject<FontLanguageSettingsController> font_controller_; +}; + +TEST_F(FontLanguageSettingsControllerTest, Init) { + ASSERT_EQ(CharacterEncoding::GetSupportCanonicalEncodingCount(), + static_cast<int>([[font_controller_ encodings] count])); +} + +TEST_F(FontLanguageSettingsControllerTest, UpdateDisplayField) { + NSFont* font = [NSFont fontWithName:@"Times-Roman" size:12.0]; + scoped_nsobject<NSTextField> field( + [[NSTextField alloc] initWithFrame:NSMakeRect(100, 100, 100, 100)]); + scoped_nsobject<NSTextField> label( + [[NSTextField alloc] initWithFrame:NSMakeRect(100, 100, 100, 100)]); + [font_controller_ updateDisplayField:field.get() + withFont:font + withLabel:label]; + + ASSERT_NSEQ([font fontName], [[field font] fontName]); + ASSERT_NSEQ(@"Times-Roman, 12", [field stringValue]); +} + +TEST_F(FontLanguageSettingsControllerTest, UpdateDisplayFieldNilFont) { + scoped_nsobject<NSTextField> field( + [[NSTextField alloc] initWithFrame:NSMakeRect(100, 100, 100, 100)]); + scoped_nsobject<NSTextField> label( + [[NSTextField alloc] initWithFrame:NSMakeRect(100, 100, 100, 100)]); + [field setStringValue:@"foo"]; + [font_controller_ updateDisplayField:field.get() + withFont:nil + withLabel:label]; + + ASSERT_NSEQ(@"foo", [field stringValue]); +} + +TEST_F(FontLanguageSettingsControllerTest, UpdateDisplayFieldNilField) { + // Don't crash. + NSFont* font = [NSFont fontWithName:@"Times-Roman" size:12.0]; + [font_controller_ updateDisplayField:nil withFont:font withLabel:nil]; +} diff --git a/chrome/browser/ui/cocoa/framed_browser_window.h b/chrome/browser/ui/cocoa/framed_browser_window.h new file mode 100644 index 0000000..40f330c --- /dev/null +++ b/chrome/browser/ui/cocoa/framed_browser_window.h @@ -0,0 +1,65 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_FRAMED_BROWSER_WINDOW_H_ +#define CHROME_BROWSER_UI_COCOA_FRAMED_BROWSER_WINDOW_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#include "chrome/browser/ui/cocoa/chrome_browser_window.h" + +// Offset from the top of the window frame to the top of the window controls +// (zoom, close, miniaturize) for a window with a tabstrip. +const NSInteger kFramedWindowButtonsWithTabStripOffsetFromTop = 6; + +// Offset from the top of the window frame to the top of the window controls +// (zoom, close, miniaturize) for a window without a tabstrip. +const NSInteger kFramedWindowButtonsWithoutTabStripOffsetFromTop = 4; + +// Offset from the left of the window frame to the top of the window controls +// (zoom, close, miniaturize). +const NSInteger kFramedWindowButtonsOffsetFromLeft = 8; + +// Offset between the window controls (zoom, close, miniaturize). +const NSInteger kFramedWindowButtonsInterButtonSpacing = 7; + +// Cocoa class representing a framed browser window. +// We need to override NSWindow with our own class since we need access to all +// unhandled keyboard events and subclassing NSWindow is the only method to do +// this. We also handle our own window controls and custom window frame drawing. +@interface FramedBrowserWindow : ChromeBrowserWindow { + @private + BOOL shouldHideTitle_; + NSButton* closeButton_; + NSButton* miniaturizeButton_; + NSButton* zoomButton_; + BOOL entered_; + scoped_nsobject<NSTrackingArea> widgetTrackingArea_; +} + +// Tells the window to suppress title drawing. +- (void)setShouldHideTitle:(BOOL)flag; + +// Return true if the mouse is currently in our tracking area for our window +// widgets. +- (BOOL)mouseInGroup:(NSButton*)widget; + +// Update the tracking areas for our window widgets as appropriate. +- (void)updateTrackingAreas; + +@end + +@interface NSWindow (UndocumentedAPI) + +// Undocumented Cocoa API to suppress drawing of the window's title. +// -setTitle: still works, but the title set only applies to the +// miniwindow and menus (and, importantly, Expose). Overridden to +// return |shouldHideTitle_|. +-(BOOL)_isTitleHidden; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_FRAMED_BROWSER_WINDOW_H_ diff --git a/chrome/browser/ui/cocoa/framed_browser_window.mm b/chrome/browser/ui/cocoa/framed_browser_window.mm new file mode 100644 index 0000000..9d63cb7 --- /dev/null +++ b/chrome/browser/ui/cocoa/framed_browser_window.mm @@ -0,0 +1,350 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/framed_browser_window.h" + +#include "base/logging.h" +#include "chrome/browser/global_keyboard_shortcuts_mac.h" +#import "chrome/browser/ui/cocoa/browser_frame_view.h" +#import "chrome/browser/ui/cocoa/browser_window_controller.h" +#import "chrome/browser/ui/cocoa/tab_strip_controller.h" +#import "chrome/browser/ui/cocoa/themed_window.h" +#import "chrome/browser/renderer_host/render_widget_host_view_mac.h" +#include "chrome/browser/themes/browser_theme_provider.h" + +namespace { + // Size of the gradient. Empirically determined so that the gradient looks + // like what the heuristic does when there are just a few tabs. + const CGFloat kWindowGradientHeight = 24.0; +} + +// Our browser window does some interesting things to get the behaviors that +// we want. We replace the standard window controls (zoom, close, miniaturize) +// with our own versions, so that we can position them slightly differently than +// the default window has them. To do this, we hide the ones that Apple provides +// us with, and create our own. This requires us to handle tracking for the +// buttons (so that they highlight and activate correctly) as well as implement +// the private method _mouseInGroup in our frame view class which is required +// to get the rollover highlight drawing to draw correctly. +@interface FramedBrowserWindow(PrivateMethods) +// Return the view that does the "frame" drawing. +- (NSView*)frameView; +@end + +@implementation FramedBrowserWindow + +- (id)initWithContentRect:(NSRect)contentRect + styleMask:(NSUInteger)aStyle + backing:(NSBackingStoreType)bufferingType + defer:(BOOL)flag { + if ((self = [super initWithContentRect:contentRect + styleMask:aStyle + backing:bufferingType + defer:flag])) { + if (aStyle & NSTexturedBackgroundWindowMask) { + // The following two calls fix http://www.crbug.com/25684 by preventing + // the window from recalculating the border thickness as the window is + // resized. + // This was causing the window tint to change for the default system theme + // when the window was being resized. + [self setAutorecalculatesContentBorderThickness:NO forEdge:NSMaxYEdge]; + [self setContentBorderThickness:kWindowGradientHeight forEdge:NSMaxYEdge]; + } + } + return self; +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [[NSDistributedNotificationCenter defaultCenter] removeObserver:self]; + if (widgetTrackingArea_) { + [[self frameView] removeTrackingArea:widgetTrackingArea_]; + widgetTrackingArea_.reset(); + } + [super dealloc]; +} + +- (void)setWindowController:(NSWindowController*)controller { + if (controller == [self windowController]) { + return; + } + // Clean up our old stuff. + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [[NSDistributedNotificationCenter defaultCenter] removeObserver:self]; + [closeButton_ removeFromSuperview]; + closeButton_ = nil; + [miniaturizeButton_ removeFromSuperview]; + miniaturizeButton_ = nil; + [zoomButton_ removeFromSuperview]; + zoomButton_ = nil; + + [super setWindowController:controller]; + + BrowserWindowController* browserController + = static_cast<BrowserWindowController*>(controller); + if ([browserController isKindOfClass:[BrowserWindowController class]]) { + NSNotificationCenter* defaultCenter = [NSNotificationCenter defaultCenter]; + [defaultCenter addObserver:self + selector:@selector(themeDidChangeNotification:) + name:kBrowserThemeDidChangeNotification + object:nil]; + + // Hook ourselves up to get notified if the user changes the system + // theme on us. + NSDistributedNotificationCenter* distCenter = + [NSDistributedNotificationCenter defaultCenter]; + [distCenter addObserver:self + selector:@selector(systemThemeDidChangeNotification:) + name:@"AppleAquaColorVariantChanged" + object:nil]; + // Set up our buttons how we like them. + NSView* frameView = [self frameView]; + NSRect frameViewBounds = [frameView bounds]; + + // Find all the "original" buttons, and hide them. We can't use the original + // buttons because the OS likes to move them around when we resize windows + // and will put them back in what it considers to be their "preferred" + // locations. + NSButton* oldButton = [self standardWindowButton:NSWindowCloseButton]; + [oldButton setHidden:YES]; + oldButton = [self standardWindowButton:NSWindowMiniaturizeButton]; + [oldButton setHidden:YES]; + oldButton = [self standardWindowButton:NSWindowZoomButton]; + [oldButton setHidden:YES]; + + // Create and position our new buttons. + NSUInteger aStyle = [self styleMask]; + closeButton_ = [NSWindow standardWindowButton:NSWindowCloseButton + forStyleMask:aStyle]; + NSRect closeButtonFrame = [closeButton_ frame]; + CGFloat yOffset = [browserController hasTabStrip] ? + kFramedWindowButtonsWithTabStripOffsetFromTop : + kFramedWindowButtonsWithoutTabStripOffsetFromTop; + closeButtonFrame.origin = + NSMakePoint(kFramedWindowButtonsOffsetFromLeft, + (NSHeight(frameViewBounds) - + NSHeight(closeButtonFrame) - yOffset)); + + [closeButton_ setFrame:closeButtonFrame]; + [closeButton_ setTarget:self]; + [closeButton_ setAutoresizingMask:NSViewMaxXMargin | NSViewMinYMargin]; + [frameView addSubview:closeButton_]; + + miniaturizeButton_ = + [NSWindow standardWindowButton:NSWindowMiniaturizeButton + forStyleMask:aStyle]; + NSRect miniaturizeButtonFrame = [miniaturizeButton_ frame]; + miniaturizeButtonFrame.origin = + NSMakePoint((NSMaxX(closeButtonFrame) + + kFramedWindowButtonsInterButtonSpacing), + NSMinY(closeButtonFrame)); + [miniaturizeButton_ setFrame:miniaturizeButtonFrame]; + [miniaturizeButton_ setTarget:self]; + [miniaturizeButton_ setAutoresizingMask:(NSViewMaxXMargin | + NSViewMinYMargin)]; + [frameView addSubview:miniaturizeButton_]; + + zoomButton_ = [NSWindow standardWindowButton:NSWindowZoomButton + forStyleMask:aStyle]; + NSRect zoomButtonFrame = [zoomButton_ frame]; + zoomButtonFrame.origin = + NSMakePoint((NSMaxX(miniaturizeButtonFrame) + + kFramedWindowButtonsInterButtonSpacing), + NSMinY(miniaturizeButtonFrame)); + [zoomButton_ setFrame:zoomButtonFrame]; + [zoomButton_ setTarget:self]; + [zoomButton_ setAutoresizingMask:(NSViewMaxXMargin | + NSViewMinYMargin)]; + + [frameView addSubview:zoomButton_]; + } + + // Update our tracking areas. We want to update them even if we haven't + // added buttons above as we need to remove the old tracking area. If the + // buttons aren't to be shown, updateTrackingAreas won't add new ones. + [self updateTrackingAreas]; +} + +- (NSView*)frameView { + return [[self contentView] superview]; +} + +// The tab strip view covers our window buttons. So we add hit testing here +// to find them properly and return them to the accessibility system. +- (id)accessibilityHitTest:(NSPoint)point { + NSPoint windowPoint = [self convertScreenToBase:point]; + NSControl* controls[] = { closeButton_, zoomButton_, miniaturizeButton_ }; + id value = nil; + for (size_t i = 0; i < sizeof(controls) / sizeof(controls[0]); ++i) { + if (NSPointInRect(windowPoint, [controls[i] frame])) { + value = [controls[i] accessibilityHitTest:point]; + break; + } + } + if (!value) { + value = [super accessibilityHitTest:point]; + } + return value; +} + +// Map our custom buttons into the accessibility hierarchy correctly. +- (id)accessibilityAttributeValue:(NSString*)attribute { + id value = nil; + struct { + NSString* attribute_; + id value_; + } attributeMap[] = { + { NSAccessibilityCloseButtonAttribute, [closeButton_ cell]}, + { NSAccessibilityZoomButtonAttribute, [zoomButton_ cell]}, + { NSAccessibilityMinimizeButtonAttribute, [miniaturizeButton_ cell]}, + }; + + for (size_t i = 0; i < sizeof(attributeMap) / sizeof(attributeMap[0]); ++i) { + if ([attributeMap[i].attribute_ isEqualToString:attribute]) { + value = attributeMap[i].value_; + break; + } + } + if (!value) { + value = [super accessibilityAttributeValue:attribute]; + } + return value; +} + +- (void)updateTrackingAreas { + NSView* frameView = [self frameView]; + if (widgetTrackingArea_) { + [frameView removeTrackingArea:widgetTrackingArea_]; + } + if (closeButton_) { + NSRect trackingRect = [closeButton_ frame]; + trackingRect.size.width = NSMaxX([zoomButton_ frame]) - + NSMinX(trackingRect); + widgetTrackingArea_.reset( + [[NSTrackingArea alloc] initWithRect:trackingRect + options:(NSTrackingMouseEnteredAndExited | + NSTrackingActiveAlways) + owner:self + userInfo:nil]); + [frameView addTrackingArea:widgetTrackingArea_]; + + // Check to see if the cursor is still in trackingRect. + NSPoint point = [self mouseLocationOutsideOfEventStream]; + point = [[self contentView] convertPoint:point fromView:nil]; + BOOL newEntered = NSPointInRect (point, trackingRect); + if (newEntered != entered_) { + // Buttons have moved, so update button state. + entered_ = newEntered; + [closeButton_ setNeedsDisplay]; + [zoomButton_ setNeedsDisplay]; + [miniaturizeButton_ setNeedsDisplay]; + } + } +} + +- (void)windowMainStatusChanged { + [closeButton_ setNeedsDisplay]; + [zoomButton_ setNeedsDisplay]; + [miniaturizeButton_ setNeedsDisplay]; + NSView* frameView = [self frameView]; + NSView* contentView = [self contentView]; + NSRect updateRect = [frameView frame]; + NSRect contentRect = [contentView frame]; + CGFloat tabStripHeight = [TabStripController defaultTabHeight]; + updateRect.size.height -= NSHeight(contentRect) - tabStripHeight; + updateRect.origin.y = NSMaxY(contentRect) - tabStripHeight; + [[self frameView] setNeedsDisplayInRect:updateRect]; +} + +- (void)becomeMainWindow { + [self windowMainStatusChanged]; + [super becomeMainWindow]; +} + +- (void)resignMainWindow { + [self windowMainStatusChanged]; + [super resignMainWindow]; +} + +// Called after the current theme has changed. +- (void)themeDidChangeNotification:(NSNotification*)aNotification { + [[self frameView] setNeedsDisplay:YES]; +} + +- (void)systemThemeDidChangeNotification:(NSNotification*)aNotification { + [closeButton_ setNeedsDisplay]; + [zoomButton_ setNeedsDisplay]; + [miniaturizeButton_ setNeedsDisplay]; +} + +- (void)sendEvent:(NSEvent*)event { + // For cocoa windows, clicking on the close and the miniaturize (but not the + // zoom buttons) while a window is in the background does NOT bring that + // window to the front. We don't get that behavior for free, so we handle + // it here. Zoom buttons do bring the window to the front. Note that + // Finder windows (in Leopard) behave differently in this regard in that + // zoom buttons don't bring the window to the foreground. + BOOL eventHandled = NO; + if (![self isMainWindow]) { + if ([event type] == NSLeftMouseDown) { + NSView* frameView = [self frameView]; + NSPoint mouse = [frameView convertPoint:[event locationInWindow] + fromView:nil]; + if (NSPointInRect(mouse, [closeButton_ frame])) { + [closeButton_ mouseDown:event]; + eventHandled = YES; + } else if (NSPointInRect(mouse, [miniaturizeButton_ frame])) { + [miniaturizeButton_ mouseDown:event]; + eventHandled = YES; + } + } + } + if (!eventHandled) { + [super sendEvent:event]; + } +} + +// Update our buttons so that they highlight correctly. +- (void)mouseEntered:(NSEvent*)event { + entered_ = YES; + [closeButton_ setNeedsDisplay]; + [zoomButton_ setNeedsDisplay]; + [miniaturizeButton_ setNeedsDisplay]; +} + +// Update our buttons so that they highlight correctly. +- (void)mouseExited:(NSEvent*)event { + entered_ = NO; + [closeButton_ setNeedsDisplay]; + [zoomButton_ setNeedsDisplay]; + [miniaturizeButton_ setNeedsDisplay]; +} + +- (BOOL)mouseInGroup:(NSButton*)widget { + return entered_; +} + +- (void)setShouldHideTitle:(BOOL)flag { + shouldHideTitle_ = flag; +} + +-(BOOL)_isTitleHidden { + return shouldHideTitle_; +} + +// This method is called whenever a window is moved in order to ensure it fits +// on the screen. We cannot always handle resizes without breaking, so we +// prevent frame constraining in those cases. +- (NSRect)constrainFrameRect:(NSRect)frame toScreen:(NSScreen*)screen { + // Do not constrain the frame rect if our delegate says no. In this case, + // return the original (unconstrained) frame. + id delegate = [self delegate]; + if ([delegate respondsToSelector:@selector(shouldConstrainFrameRect)] && + ![delegate shouldConstrainFrameRect]) + return frame; + + return [super constrainFrameRect:frame toScreen:screen]; +} + +@end diff --git a/chrome/browser/ui/cocoa/framed_browser_window_unittest.mm b/chrome/browser/ui/cocoa/framed_browser_window_unittest.mm new file mode 100644 index 0000000..ad05334 --- /dev/null +++ b/chrome/browser/ui/cocoa/framed_browser_window_unittest.mm @@ -0,0 +1,184 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/debug/debugger.h" +#include "base/scoped_nsobject.h" +#include "chrome/app/chrome_command_ids.h" +#import "chrome/browser/ui/cocoa/browser_window_controller.h" +#import "chrome/browser/ui/cocoa/browser_frame_view.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/framed_browser_window.h" +#include "testing/gtest/include/gtest/gtest.h" +#import "testing/gtest_mac.h" +#include "testing/platform_test.h" +#import "third_party/ocmock/OCMock/OCMock.h" + +class FramedBrowserWindowTest : public CocoaTest { + public: + virtual void SetUp() { + CocoaTest::SetUp(); + // Create a window. + const NSUInteger mask = NSTitledWindowMask | NSClosableWindowMask | + NSMiniaturizableWindowMask | NSResizableWindowMask; + window_ = [[FramedBrowserWindow alloc] + initWithContentRect:NSMakeRect(0, 0, 800, 600) + styleMask:mask + backing:NSBackingStoreBuffered + defer:NO]; + if (base::debug::BeingDebugged()) { + [window_ orderFront:nil]; + } else { + [window_ orderBack:nil]; + } + } + + virtual void TearDown() { + [window_ close]; + CocoaTest::TearDown(); + } + + // Returns a canonical snapshot of the window. + NSData* WindowContentsAsTIFF() { + [window_ display]; + + NSView* frameView = [window_ contentView]; + while ([frameView superview]) { + frameView = [frameView superview]; + } + const NSRect bounds = [frameView bounds]; + + [frameView lockFocus]; + scoped_nsobject<NSBitmapImageRep> bitmap( + [[NSBitmapImageRep alloc] initWithFocusedViewRect:bounds]); + [frameView unlockFocus]; + + return [bitmap TIFFRepresentation]; + } + + FramedBrowserWindow* window_; +}; + +// Baseline test that the window creates, displays, closes, and +// releases. +TEST_F(FramedBrowserWindowTest, ShowAndClose) { + [window_ display]; +} + +// Test that undocumented title-hiding API we're using does the job. +TEST_F(FramedBrowserWindowTest, DoesHideTitle) { + // The -display calls are not strictly necessary, but they do + // make it easier to see what's happening when debugging (without + // them the changes are never flushed to the screen). + + [window_ setTitle:@""]; + [window_ display]; + NSData* emptyTitleData = WindowContentsAsTIFF(); + + [window_ setTitle:@"This is a title"]; + [window_ display]; + NSData* thisTitleData = WindowContentsAsTIFF(); + + // The default window with a title should look different from the + // window with an empty title. + EXPECT_FALSE([emptyTitleData isEqualToData:thisTitleData]); + + [window_ setShouldHideTitle:YES]; + [window_ setTitle:@""]; + [window_ display]; + [window_ setTitle:@"This is a title"]; + [window_ display]; + NSData* hiddenTitleData = WindowContentsAsTIFF(); + + // With our magic setting, the window with a title should look the + // same as the window with an empty title. + EXPECT_TRUE([window_ _isTitleHidden]); + EXPECT_TRUE([emptyTitleData isEqualToData:hiddenTitleData]); +} + +// Test to make sure that our window widgets are in the right place. +TEST_F(FramedBrowserWindowTest, WindowWidgetLocation) { + // First without tabstrip. + NSCell* closeBoxCell = [window_ accessibilityAttributeValue: + NSAccessibilityCloseButtonAttribute]; + NSView* closeBoxControl = [closeBoxCell controlView]; + EXPECT_TRUE(closeBoxControl); + NSRect closeBoxFrame = [closeBoxControl frame]; + NSRect windowBounds = [window_ frame]; + windowBounds.origin = NSZeroPoint; + EXPECT_EQ(NSMaxY(closeBoxFrame), + NSMaxY(windowBounds) - + kFramedWindowButtonsWithoutTabStripOffsetFromTop); + EXPECT_EQ(NSMinX(closeBoxFrame), kFramedWindowButtonsOffsetFromLeft); + + NSCell* miniaturizeCell = [window_ accessibilityAttributeValue: + NSAccessibilityMinimizeButtonAttribute]; + NSView* miniaturizeControl = [miniaturizeCell controlView]; + EXPECT_TRUE(miniaturizeControl); + NSRect miniaturizeFrame = [miniaturizeControl frame]; + EXPECT_EQ(NSMaxY(miniaturizeFrame), + NSMaxY(windowBounds) - + kFramedWindowButtonsWithoutTabStripOffsetFromTop); + EXPECT_EQ(NSMinX(miniaturizeFrame), + NSMaxX(closeBoxFrame) + kFramedWindowButtonsInterButtonSpacing); + + // Then with a tabstrip. + id controller = [OCMockObject mockForClass:[BrowserWindowController class]]; + BOOL yes = YES; + BOOL no = NO; + [[[controller stub] andReturnValue:OCMOCK_VALUE(yes)] + isKindOfClass:[BrowserWindowController class]]; + [[[controller expect] andReturnValue:OCMOCK_VALUE(yes)] hasTabStrip]; + [[[controller expect] andReturnValue:OCMOCK_VALUE(no)] hasTitleBar]; + [[[controller expect] andReturnValue:OCMOCK_VALUE(yes)] isNormalWindow]; + [window_ setWindowController:controller]; + + closeBoxCell = [window_ accessibilityAttributeValue: + NSAccessibilityCloseButtonAttribute]; + closeBoxControl = [closeBoxCell controlView]; + EXPECT_TRUE(closeBoxControl); + closeBoxFrame = [closeBoxControl frame]; + windowBounds = [window_ frame]; + windowBounds.origin = NSZeroPoint; + EXPECT_EQ(NSMaxY(closeBoxFrame), + NSMaxY(windowBounds) - + kFramedWindowButtonsWithTabStripOffsetFromTop); + EXPECT_EQ(NSMinX(closeBoxFrame), kFramedWindowButtonsOffsetFromLeft); + + miniaturizeCell = [window_ accessibilityAttributeValue: + NSAccessibilityMinimizeButtonAttribute]; + miniaturizeControl = [miniaturizeCell controlView]; + EXPECT_TRUE(miniaturizeControl); + miniaturizeFrame = [miniaturizeControl frame]; + EXPECT_EQ(NSMaxY(miniaturizeFrame), + NSMaxY(windowBounds) - + kFramedWindowButtonsWithTabStripOffsetFromTop); + EXPECT_EQ(NSMinX(miniaturizeFrame), + NSMaxX(closeBoxFrame) + kFramedWindowButtonsInterButtonSpacing); + [window_ setWindowController:nil]; +} + +// Test that we actually have a tracking area in place. +TEST_F(FramedBrowserWindowTest, WindowWidgetTrackingArea) { + NSCell* closeBoxCell = + [window_ accessibilityAttributeValue:NSAccessibilityCloseButtonAttribute]; + NSView* closeBoxControl = [closeBoxCell controlView]; + NSView* frameView = [[window_ contentView] superview]; + NSArray* trackingAreas = [frameView trackingAreas]; + NSPoint point = [closeBoxControl frame].origin; + point.x += 1; + point.y += 1; + BOOL foundArea = NO; + for (NSTrackingArea* area in trackingAreas) { + NSRect rect = [area rect]; + foundArea = NSPointInRect(point, rect); + if (foundArea) { + EXPECT_NSEQ(frameView, [area owner]); + break; + } + } + EXPECT_TRUE(foundArea); +} + diff --git a/chrome/browser/ui/cocoa/fullscreen_controller.h b/chrome/browser/ui/cocoa/fullscreen_controller.h new file mode 100644 index 0000000..2e96b61 --- /dev/null +++ b/chrome/browser/ui/cocoa/fullscreen_controller.h @@ -0,0 +1,122 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_FULLSCREEN_CONTROLLER_H_ +#define CHROME_BROWSER_UI_COCOA_FULLSCREEN_CONTROLLER_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#import "base/cocoa_protocols_mac.h" +#include "base/mac_util.h" +#include "chrome/browser/ui/cocoa/location_bar/location_bar_view_mac.h" + +@class BrowserWindowController; +@class DropdownAnimation; + +// Provides a controller to manage fullscreen mode for a single browser window. +// This class handles running animations, showing and hiding the floating +// dropdown bar, and managing the tracking area associated with the dropdown. +// This class does not directly manage any views -- the BrowserWindowController +// is responsible for positioning and z-ordering views. +// +// Tracking areas are disabled while animations are running. If +// |overlayFrameChanged:| is called while an animation is running, the +// controller saves the new frame and installs the appropriate tracking area +// when the animation finishes. This is largely done for ease of +// implementation; it is easier to check the mouse location at each animation +// step than it is to manage a constantly-changing tracking area. +@interface FullscreenController : NSObject<NSAnimationDelegate> { + @private + // Our parent controller. + BrowserWindowController* browserController_; // weak + + // The content view for the fullscreen window. This is nil when not in + // fullscreen mode. + NSView* contentView_; // weak + + // Whether or not we are in fullscreen mode. + BOOL isFullscreen_; + + // The tracking area associated with the floating dropdown bar. This tracking + // area is attached to |contentView_|, because when the dropdown is completely + // hidden, we still need to keep a 1px tall tracking area visible. Attaching + // to the content view allows us to do this. |trackingArea_| can be nil if + // not in fullscreen mode or during animations. + scoped_nsobject<NSTrackingArea> trackingArea_; + + // Pointer to the currently running animation. Is nil if no animation is + // running. + scoped_nsobject<DropdownAnimation> currentAnimation_; + + // Timers for scheduled showing/hiding of the bar (which are always done with + // animation). + scoped_nsobject<NSTimer> showTimer_; + scoped_nsobject<NSTimer> hideTimer_; + + // Holds the current bounds of |trackingArea_|, even if |trackingArea_| is + // currently nil. Used to restore the tracking area when an animation + // completes. + NSRect trackingAreaBounds_; + + // Tracks the currently requested fullscreen mode. This should be + // |kFullScreenModeNormal| when the window is not main or not fullscreen, + // |kFullScreenModeHideAll| while the overlay is hidden, and + // |kFullScreenModeHideDock| while the overlay is shown. If the window is not + // on the primary screen, this should always be |kFullScreenModeNormal|. This + // value can get out of sync with the correct state if we miss a notification + // (which can happen when a fullscreen window is closed). Used to track the + // current state and make sure we properly restore the menu bar when this + // controller is destroyed. + mac_util::FullScreenMode currentFullscreenMode_; +} + +@property(readonly, nonatomic) BOOL isFullscreen; + +// Designated initializer. +- (id)initWithBrowserController:(BrowserWindowController*)controller; + +// Informs the controller that the browser has entered or exited fullscreen +// mode. |-enterFullscreenForContentView:showDropdown:| should be called after +// the fullscreen window is setup, just before it is shown. |-exitFullscreen| +// should be called before any views are moved back to the non-fullscreen +// window. If |-enterFullscreenForContentView:showDropdown:| is called, it must +// be followed with a call to |-exitFullscreen| before the controller is +// released. +- (void)enterFullscreenForContentView:(NSView*)contentView + showDropdown:(BOOL)showDropdown; +- (void)exitFullscreen; + +// Returns the amount by which the floating bar should be offset downwards (to +// avoid the menu) and by which the overlay view should be enlarged vertically. +// Generally, this is > 0 when the fullscreen window is on the primary screen +// and 0 otherwise. +- (CGFloat)floatingBarVerticalOffset; + +// Informs the controller that the overlay's frame has changed. The controller +// uses this information to update its tracking areas. +- (void)overlayFrameChanged:(NSRect)frame; + +// Informs the controller that the overlay should be shown/hidden, possibly with +// animation, possibly after a delay (only applicable for the animated case). +- (void)ensureOverlayShownWithAnimation:(BOOL)animate delay:(BOOL)delay; +- (void)ensureOverlayHiddenWithAnimation:(BOOL)animate delay:(BOOL)delay; + +// Cancels any running animation and timers. +- (void)cancelAnimationAndTimers; + +// Gets the current floating bar shown fraction. +- (CGFloat)floatingBarShownFraction; + +// Sets a new current floating bar shown fraction. NOTE: This function has side +// effects, such as modifying the fullscreen mode (menu bar shown state). +- (void)changeFloatingBarShownFraction:(CGFloat)fraction; + +@end + +// Notification posted when we're about to enter or leave fullscreen. +extern NSString* const kWillEnterFullscreenNotification; +extern NSString* const kWillLeaveFullscreenNotification; + +#endif // CHROME_BROWSER_UI_COCOA_FULLSCREEN_CONTROLLER_H_ diff --git a/chrome/browser/ui/cocoa/fullscreen_controller.mm b/chrome/browser/ui/cocoa/fullscreen_controller.mm new file mode 100644 index 0000000..0f06e22 --- /dev/null +++ b/chrome/browser/ui/cocoa/fullscreen_controller.mm @@ -0,0 +1,633 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/fullscreen_controller.h" + +#include <algorithm> + +#import "chrome/browser/ui/cocoa/browser_window_controller.h" +#import "third_party/GTM/AppKit/GTMNSAnimation+Duration.h" + +NSString* const kWillEnterFullscreenNotification = + @"WillEnterFullscreenNotification"; +NSString* const kWillLeaveFullscreenNotification = + @"WillLeaveFullscreenNotification"; + +namespace { +// The activation zone for the main menu is 4 pixels high; if we make it any +// smaller, then the menu can be made to appear without the bar sliding down. +const CGFloat kDropdownActivationZoneHeight = 4; +const NSTimeInterval kDropdownAnimationDuration = 0.12; +const NSTimeInterval kMouseExitCheckDelay = 0.1; +// This show delay attempts to match the delay for the main menu. +const NSTimeInterval kDropdownShowDelay = 0.3; +const NSTimeInterval kDropdownHideDelay = 0.2; + +// The amount by which the floating bar is offset downwards (to avoid the menu) +// in fullscreen mode. (We can't use |-[NSMenu menuBarHeight]| since it returns +// 0 when the menu bar is hidden.) +const CGFloat kFloatingBarVerticalOffset = 22; + +} // end namespace + + +// Helper class to manage animations for the fullscreen dropdown bar. Calls +// [FullscreenController changeFloatingBarShownFraction] once per animation +// step. +@interface DropdownAnimation : NSAnimation { + @private + FullscreenController* controller_; + CGFloat startFraction_; + CGFloat endFraction_; +} + +@property(readonly, nonatomic) CGFloat startFraction; +@property(readonly, nonatomic) CGFloat endFraction; + +// Designated initializer. Asks |controller| for the current shown fraction, so +// if the bar is already partially shown or partially hidden, the animation +// duration may be less than |fullDuration|. +- (id)initWithFraction:(CGFloat)fromFraction + fullDuration:(CGFloat)fullDuration + animationCurve:(NSInteger)animationCurve + controller:(FullscreenController*)controller; + +@end + +@implementation DropdownAnimation + +@synthesize startFraction = startFraction_; +@synthesize endFraction = endFraction_; + +- (id)initWithFraction:(CGFloat)toFraction + fullDuration:(CGFloat)fullDuration + animationCurve:(NSInteger)animationCurve + controller:(FullscreenController*)controller { + // Calculate the effective duration, based on the current shown fraction. + DCHECK(controller); + CGFloat fromFraction = [controller floatingBarShownFraction]; + CGFloat effectiveDuration = fabs(fullDuration * (fromFraction - toFraction)); + + if ((self = [super gtm_initWithDuration:effectiveDuration + eventMask:NSLeftMouseDownMask + animationCurve:animationCurve])) { + startFraction_ = fromFraction; + endFraction_ = toFraction; + controller_ = controller; + } + return self; +} + +// Called once per animation step. Overridden to change the floating bar's +// position based on the animation's progress. +- (void)setCurrentProgress:(NSAnimationProgress)progress { + CGFloat fraction = + startFraction_ + (progress * (endFraction_ - startFraction_)); + [controller_ changeFloatingBarShownFraction:fraction]; +} + +@end + + +@interface FullscreenController (PrivateMethods) + +// Returns YES if the fullscreen window is on the primary screen. +- (BOOL)isWindowOnPrimaryScreen; + +// Returns YES if it is ok to show and hide the menu bar in response to the +// overlay opening and closing. Will return NO if the window is not main or not +// on the primary monitor. +- (BOOL)shouldToggleMenuBar; + +// Returns |kFullScreenModeHideAll| when the overlay is hidden and +// |kFullScreenModeHideDock| when the overlay is shown. +- (mac_util::FullScreenMode)desiredFullscreenMode; + +// Change the overlay to the given fraction, with or without animation. Only +// guaranteed to work properly with |fraction == 0| or |fraction == 1|. This +// performs the show/hide (animation) immediately. It does not touch the timers. +- (void)changeOverlayToFraction:(CGFloat)fraction + withAnimation:(BOOL)animate; + +// Schedule the floating bar to be shown/hidden because of mouse position. +- (void)scheduleShowForMouse; +- (void)scheduleHideForMouse; + +// Set up the tracking area used to activate the sliding bar or keep it active +// using with the rectangle in |trackingAreaBounds_|, or remove the tracking +// area if one was previously set up. +- (void)setupTrackingArea; +- (void)removeTrackingAreaIfNecessary; + +// Returns YES if the mouse is currently in any current tracking rectangle, NO +// otherwise. +- (BOOL)mouseInsideTrackingRect; + +// The tracking area can "falsely" report exits when the menu slides down over +// it. In that case, we have to monitor for a "real" mouse exit on a timer. +// |-setupMouseExitCheck| schedules a check; |-cancelMouseExitCheck| cancels any +// scheduled check. +- (void)setupMouseExitCheck; +- (void)cancelMouseExitCheck; + +// Called (after a delay) by |-setupMouseExitCheck|, to check whether the mouse +// has exited or not; if it hasn't, it will schedule another check. +- (void)checkForMouseExit; + +// Start timers for showing/hiding the floating bar. +- (void)startShowTimer; +- (void)startHideTimer; +- (void)cancelShowTimer; +- (void)cancelHideTimer; +- (void)cancelAllTimers; + +// Methods called when the show/hide timers fire. Do not call directly. +- (void)showTimerFire:(NSTimer*)timer; +- (void)hideTimerFire:(NSTimer*)timer; + +// Stops any running animations, removes tracking areas, etc. +- (void)cleanup; + +// Shows and hides the UI associated with this window being active (having main +// status). This includes hiding the menu bar and displaying the "Exit +// Fullscreen" button. These functions are called when the window gains or +// loses main status as well as in |-cleanup|. +- (void)showActiveWindowUI; +- (void)hideActiveWindowUI; + +@end + + +@implementation FullscreenController + +@synthesize isFullscreen = isFullscreen_; + +- (id)initWithBrowserController:(BrowserWindowController*)controller { + if ((self == [super init])) { + browserController_ = controller; + currentFullscreenMode_ = mac_util::kFullScreenModeNormal; + } + + // Let the world know what we're up to. + [[NSNotificationCenter defaultCenter] + postNotificationName:kWillEnterFullscreenNotification + object:nil]; + + return self; +} + +- (void)dealloc { + DCHECK(!isFullscreen_); + DCHECK(!trackingArea_); + [super dealloc]; +} + +- (void)enterFullscreenForContentView:(NSView*)contentView + showDropdown:(BOOL)showDropdown { + DCHECK(!isFullscreen_); + isFullscreen_ = YES; + contentView_ = contentView; + [self changeFloatingBarShownFraction:(showDropdown ? 1 : 0)]; + + // Register for notifications. Self is removed as an observer in |-cleanup|. + NSNotificationCenter* nc = [NSNotificationCenter defaultCenter]; + NSWindow* window = [browserController_ window]; + [nc addObserver:self + selector:@selector(windowDidChangeScreen:) + name:NSWindowDidChangeScreenNotification + object:window]; + + [nc addObserver:self + selector:@selector(windowDidMove:) + name:NSWindowDidMoveNotification + object:window]; + + [nc addObserver:self + selector:@selector(windowDidBecomeMain:) + name:NSWindowDidBecomeMainNotification + object:window]; + + [nc addObserver:self + selector:@selector(windowDidResignMain:) + name:NSWindowDidResignMainNotification + object:window]; +} + +- (void)exitFullscreen { + [[NSNotificationCenter defaultCenter] + postNotificationName:kWillLeaveFullscreenNotification + object:nil]; + DCHECK(isFullscreen_); + [self cleanup]; + isFullscreen_ = NO; +} + +- (void)windowDidChangeScreen:(NSNotification*)notification { + [browserController_ resizeFullscreenWindow]; +} + +- (void)windowDidMove:(NSNotification*)notification { + [browserController_ resizeFullscreenWindow]; +} + +- (void)windowDidBecomeMain:(NSNotification*)notification { + [self showActiveWindowUI]; +} + +- (void)windowDidResignMain:(NSNotification*)notification { + [self hideActiveWindowUI]; +} + +- (CGFloat)floatingBarVerticalOffset { + return [self isWindowOnPrimaryScreen] ? kFloatingBarVerticalOffset : 0; +} + +- (void)overlayFrameChanged:(NSRect)frame { + if (!isFullscreen_) + return; + + // Make sure |trackingAreaBounds_| always reflects either the tracking area or + // the desired tracking area. + trackingAreaBounds_ = frame; + // The tracking area should always be at least the height of activation zone. + NSRect contentBounds = [contentView_ bounds]; + trackingAreaBounds_.origin.y = + std::min(trackingAreaBounds_.origin.y, + NSMaxY(contentBounds) - kDropdownActivationZoneHeight); + trackingAreaBounds_.size.height = + NSMaxY(contentBounds) - trackingAreaBounds_.origin.y + 1; + + // If an animation is currently running, do not set up a tracking area now. + // Instead, leave it to be created it in |-animationDidEnd:|. + if (currentAnimation_) + return; + + [self setupTrackingArea]; +} + +- (void)ensureOverlayShownWithAnimation:(BOOL)animate delay:(BOOL)delay { + if (!isFullscreen_) + return; + + if (animate) { + if (delay) { + [self startShowTimer]; + } else { + [self cancelAllTimers]; + [self changeOverlayToFraction:1 withAnimation:YES]; + } + } else { + DCHECK(!delay); + [self cancelAllTimers]; + [self changeOverlayToFraction:1 withAnimation:NO]; + } +} + +- (void)ensureOverlayHiddenWithAnimation:(BOOL)animate delay:(BOOL)delay { + if (!isFullscreen_) + return; + + if (animate) { + if (delay) { + [self startHideTimer]; + } else { + [self cancelAllTimers]; + [self changeOverlayToFraction:0 withAnimation:YES]; + } + } else { + DCHECK(!delay); + [self cancelAllTimers]; + [self changeOverlayToFraction:0 withAnimation:NO]; + } +} + +- (void)cancelAnimationAndTimers { + [self cancelAllTimers]; + [currentAnimation_ stopAnimation]; + currentAnimation_.reset(); +} + +- (CGFloat)floatingBarShownFraction { + return [browserController_ floatingBarShownFraction]; +} + +- (void)changeFloatingBarShownFraction:(CGFloat)fraction { + [browserController_ setFloatingBarShownFraction:fraction]; + + mac_util::FullScreenMode desiredMode = [self desiredFullscreenMode]; + if (desiredMode != currentFullscreenMode_ && [self shouldToggleMenuBar]) { + if (currentFullscreenMode_ == mac_util::kFullScreenModeNormal) + mac_util::RequestFullScreen(desiredMode); + else + mac_util::SwitchFullScreenModes(currentFullscreenMode_, desiredMode); + currentFullscreenMode_ = desiredMode; + } +} + +// Used to activate the floating bar in fullscreen mode. +- (void)mouseEntered:(NSEvent*)event { + DCHECK(isFullscreen_); + + // Having gotten a mouse entered, we no longer need to do exit checks. + [self cancelMouseExitCheck]; + + NSTrackingArea* trackingArea = [event trackingArea]; + if (trackingArea == trackingArea_) { + // The tracking area shouldn't be active during animation. + DCHECK(!currentAnimation_); + [self scheduleShowForMouse]; + } +} + +// Used to deactivate the floating bar in fullscreen mode. +- (void)mouseExited:(NSEvent*)event { + DCHECK(isFullscreen_); + + NSTrackingArea* trackingArea = [event trackingArea]; + if (trackingArea == trackingArea_) { + // The tracking area shouldn't be active during animation. + DCHECK(!currentAnimation_); + + // We can get a false mouse exit when the menu slides down, so if the mouse + // is still actually over the tracking area, we ignore the mouse exit, but + // we set up to check the mouse position again after a delay. + if ([self mouseInsideTrackingRect]) { + [self setupMouseExitCheck]; + return; + } + + [self scheduleHideForMouse]; + } +} + +- (void)animationDidStop:(NSAnimation*)animation { + // Reset the |currentAnimation_| pointer now that the animation is over. + currentAnimation_.reset(); + + // Invariant says that the tracking area is not installed while animations are + // in progress. Ensure this is true. + DCHECK(!trackingArea_); + [self removeTrackingAreaIfNecessary]; // For paranoia. + + // Don't automatically set up a new tracking area. When explicitly stopped, + // either another animation is going to start immediately or the state will be + // changed immediately. +} + +- (void)animationDidEnd:(NSAnimation*)animation { + [self animationDidStop:animation]; + + // |trackingAreaBounds_| contains the correct tracking area bounds, including + // |any updates that may have come while the animation was running. Install a + // new tracking area with these bounds. + [self setupTrackingArea]; + + // TODO(viettrungluu): Better would be to check during the animation; doing it + // here means that the timing is slightly off. + if (![self mouseInsideTrackingRect]) + [self scheduleHideForMouse]; +} + +@end + + +@implementation FullscreenController (PrivateMethods) + +- (BOOL)isWindowOnPrimaryScreen { + NSScreen* screen = [[browserController_ window] screen]; + NSScreen* primaryScreen = [[NSScreen screens] objectAtIndex:0]; + return (screen == primaryScreen); +} + +- (BOOL)shouldToggleMenuBar { + return [self isWindowOnPrimaryScreen] && + [[browserController_ window] isMainWindow]; +} + +- (mac_util::FullScreenMode)desiredFullscreenMode { + if ([browserController_ floatingBarShownFraction] >= 1.0) + return mac_util::kFullScreenModeHideDock; + return mac_util::kFullScreenModeHideAll; +} + +- (void)changeOverlayToFraction:(CGFloat)fraction + withAnimation:(BOOL)animate { + // The non-animated case is really simple, so do it and return. + if (!animate) { + [currentAnimation_ stopAnimation]; + [self changeFloatingBarShownFraction:fraction]; + return; + } + + // If we're already animating to the given fraction, then there's nothing more + // to do. + if (currentAnimation_ && [currentAnimation_ endFraction] == fraction) + return; + + // In all other cases, we want to cancel any running animation (which may be + // to show or to hide). + [currentAnimation_ stopAnimation]; + + // Now, if it happens to already be in the right state, there's nothing more + // to do. + if ([browserController_ floatingBarShownFraction] == fraction) + return; + + // Create the animation and set it up. + currentAnimation_.reset( + [[DropdownAnimation alloc] initWithFraction:fraction + fullDuration:kDropdownAnimationDuration + animationCurve:NSAnimationEaseOut + controller:self]); + DCHECK(currentAnimation_); + [currentAnimation_ setAnimationBlockingMode:NSAnimationNonblocking]; + [currentAnimation_ setDelegate:self]; + + // If there is an existing tracking area, remove it. We do not track mouse + // movements during animations (see class comment in the header file). + [self removeTrackingAreaIfNecessary]; + + [currentAnimation_ startAnimation]; +} + +- (void)scheduleShowForMouse { + [browserController_ lockBarVisibilityForOwner:self + withAnimation:YES + delay:YES]; +} + +- (void)scheduleHideForMouse { + [browserController_ releaseBarVisibilityForOwner:self + withAnimation:YES + delay:YES]; +} + +- (void)setupTrackingArea { + if (trackingArea_) { + // If the tracking rectangle is already |trackingAreaBounds_|, quit early. + NSRect oldRect = [trackingArea_ rect]; + if (NSEqualRects(trackingAreaBounds_, oldRect)) + return; + + // Otherwise, remove it. + [self removeTrackingAreaIfNecessary]; + } + + // Create and add a new tracking area for |frame|. + trackingArea_.reset( + [[NSTrackingArea alloc] initWithRect:trackingAreaBounds_ + options:NSTrackingMouseEnteredAndExited | + NSTrackingActiveInKeyWindow + owner:self + userInfo:nil]); + DCHECK(contentView_); + [contentView_ addTrackingArea:trackingArea_]; +} + +- (void)removeTrackingAreaIfNecessary { + if (trackingArea_) { + DCHECK(contentView_); // |contentView_| better be valid. + [contentView_ removeTrackingArea:trackingArea_]; + trackingArea_.reset(); + } +} + +- (BOOL)mouseInsideTrackingRect { + NSWindow* window = [browserController_ window]; + NSPoint mouseLoc = [window mouseLocationOutsideOfEventStream]; + NSPoint mousePos = [contentView_ convertPoint:mouseLoc fromView:nil]; + return NSMouseInRect(mousePos, trackingAreaBounds_, [contentView_ isFlipped]); +} + +- (void)setupMouseExitCheck { + [self performSelector:@selector(checkForMouseExit) + withObject:nil + afterDelay:kMouseExitCheckDelay]; +} + +- (void)cancelMouseExitCheck { + [NSObject cancelPreviousPerformRequestsWithTarget:self + selector:@selector(checkForMouseExit) object:nil]; +} + +- (void)checkForMouseExit { + if ([self mouseInsideTrackingRect]) + [self setupMouseExitCheck]; + else + [self scheduleHideForMouse]; +} + +- (void)startShowTimer { + // If there's already a show timer going, just keep it. + if (showTimer_) { + DCHECK([showTimer_ isValid]); + DCHECK(!hideTimer_); + return; + } + + // Cancel the hide timer (if necessary) and set up the new show timer. + [self cancelHideTimer]; + showTimer_.reset( + [[NSTimer scheduledTimerWithTimeInterval:kDropdownShowDelay + target:self + selector:@selector(showTimerFire:) + userInfo:nil + repeats:NO] retain]); + DCHECK([showTimer_ isValid]); // This also checks that |showTimer_ != nil|. +} + +- (void)startHideTimer { + // If there's already a hide timer going, just keep it. + if (hideTimer_) { + DCHECK([hideTimer_ isValid]); + DCHECK(!showTimer_); + return; + } + + // Cancel the show timer (if necessary) and set up the new hide timer. + [self cancelShowTimer]; + hideTimer_.reset( + [[NSTimer scheduledTimerWithTimeInterval:kDropdownHideDelay + target:self + selector:@selector(hideTimerFire:) + userInfo:nil + repeats:NO] retain]); + DCHECK([hideTimer_ isValid]); // This also checks that |hideTimer_ != nil|. +} + +- (void)cancelShowTimer { + [showTimer_ invalidate]; + showTimer_.reset(); +} + +- (void)cancelHideTimer { + [hideTimer_ invalidate]; + hideTimer_.reset(); +} + +- (void)cancelAllTimers { + [self cancelShowTimer]; + [self cancelHideTimer]; +} + +- (void)showTimerFire:(NSTimer*)timer { + DCHECK_EQ(showTimer_, timer); // This better be our show timer. + [showTimer_ invalidate]; // Make sure it doesn't repeat. + showTimer_.reset(); // And get rid of it. + [self changeOverlayToFraction:1 withAnimation:YES]; +} + +- (void)hideTimerFire:(NSTimer*)timer { + DCHECK_EQ(hideTimer_, timer); // This better be our hide timer. + [hideTimer_ invalidate]; // Make sure it doesn't repeat. + hideTimer_.reset(); // And get rid of it. + [self changeOverlayToFraction:0 withAnimation:YES]; +} + +- (void)cleanup { + [self cancelMouseExitCheck]; + [self cancelAnimationAndTimers]; + [[NSNotificationCenter defaultCenter] removeObserver:self]; + + [self removeTrackingAreaIfNecessary]; + contentView_ = nil; + + // This isn't tracked when not in fullscreen mode. + [browserController_ releaseBarVisibilityForOwner:self + withAnimation:NO + delay:NO]; + + // Call the main status resignation code to perform the associated cleanup, + // since we will no longer be receiving actual status resignation + // notifications. + [self hideActiveWindowUI]; + + // No more calls back up to the BWC. + browserController_ = nil; +} + +- (void)showActiveWindowUI { + DCHECK_EQ(currentFullscreenMode_, mac_util::kFullScreenModeNormal); + if (currentFullscreenMode_ != mac_util::kFullScreenModeNormal) + return; + + if ([self shouldToggleMenuBar]) { + mac_util::FullScreenMode desiredMode = [self desiredFullscreenMode]; + mac_util::RequestFullScreen(desiredMode); + currentFullscreenMode_ = desiredMode; + } + + // TODO(rohitrao): Insert the Exit Fullscreen button. http://crbug.com/35956 +} + +- (void)hideActiveWindowUI { + if (currentFullscreenMode_ != mac_util::kFullScreenModeNormal) { + mac_util::ReleaseFullScreen(currentFullscreenMode_); + currentFullscreenMode_ = mac_util::kFullScreenModeNormal; + } + + // TODO(rohitrao): Remove the Exit Fullscreen button. http://crbug.com/35956 +} + +@end diff --git a/chrome/browser/ui/cocoa/fullscreen_window.h b/chrome/browser/ui/cocoa/fullscreen_window.h new file mode 100644 index 0000000..12be00d --- /dev/null +++ b/chrome/browser/ui/cocoa/fullscreen_window.h @@ -0,0 +1,19 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include <Cocoa/Cocoa.h> +#import "chrome/browser/ui/cocoa/chrome_browser_window.h" + +// A FullscreenWindow is a borderless window suitable for going fullscreen. The +// returned window is NOT release when closed and is not initially visible. +// FullscreenWindow derives from ChromeBrowserWindow to inherit hole punching, +// theming methods, and special event handling +// (e.g. handleExtraKeyboardShortcut). +@interface FullscreenWindow : ChromeBrowserWindow + +// Initialize a FullscreenWindow for the given screen. +// Designated initializer. +- (id)initForScreen:(NSScreen*)screen; + +@end diff --git a/chrome/browser/ui/cocoa/fullscreen_window.mm b/chrome/browser/ui/cocoa/fullscreen_window.mm new file mode 100644 index 0000000..ecbb34c --- /dev/null +++ b/chrome/browser/ui/cocoa/fullscreen_window.mm @@ -0,0 +1,100 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/fullscreen_window.h" + +#include "base/mac_util.h" +#include "chrome/browser/themes/browser_theme_provider.h" +#import "chrome/browser/ui/cocoa/themed_window.h" + +@implementation FullscreenWindow + +// Make sure our designated initializer gets called. +- (id)init { + return [self initForScreen:[NSScreen mainScreen]]; +} + +- (id)initForScreen:(NSScreen*)screen { + NSRect contentRect; + contentRect.origin = NSZeroPoint; + contentRect.size = [screen frame].size; + + if ((self = [super initWithContentRect:contentRect + styleMask:NSBorderlessWindowMask + backing:NSBackingStoreBuffered + defer:YES + screen:screen])) { + [self setReleasedWhenClosed:NO]; + // Borderless windows don't usually show up in the Windows menu so whine at + // Cocoa until it complies. See -dealloc and -setTitle: as well. + [NSApp addWindowsItem:self title:@"" filename:NO]; + } + return self; +} + +- (void)dealloc { + // Paranoia; doesn't seem to be necessary but it doesn't hurt. + [NSApp removeWindowsItem:self]; + + [super dealloc]; +} + +- (void)setTitle:(NSString *)title { + [NSApp changeWindowsItem:self title:title filename:NO]; + [super setTitle:title]; +} + +// According to +// http://www.cocoabuilder.com/archive/message/cocoa/2006/6/19/165953 , +// NSBorderlessWindowMask windows cannot become key or main. +// In our case, however, we don't want that behavior, so we override +// canBecomeKeyWindow and canBecomeMainWindow. + +- (BOOL)canBecomeKeyWindow { + return YES; +} + +- (BOOL)canBecomeMainWindow { + return YES; +} + +// When becoming/resigning main status, explicitly set the background color, +// which is required by |TabView|. +- (void)becomeMainWindow { + [super becomeMainWindow]; + [self setBackgroundColor:[NSColor windowFrameColor]]; +} + +- (void)resignMainWindow { + [super resignMainWindow]; + [self setBackgroundColor:[NSColor windowBackgroundColor]]; +} + +// We need our own version, since the default one wants to flash the close +// button (and possibly other things), which results in nothing happening. +- (void)performClose:(id)sender { + BOOL shouldClose = YES; + + // If applicable, check if this window should close. + id delegate = [self delegate]; + if ([delegate respondsToSelector:@selector(windowShouldClose:)]) + shouldClose = [delegate windowShouldClose:self]; + + if (shouldClose) { + [self close]; + } +} + +- (BOOL)validateUserInterfaceItem:(id<NSValidatedUserInterfaceItem>)item { + SEL action = [item action]; + + // Explicitly enable |-performClose:| (see above); otherwise the fact that + // this window does not have a close button results in it being disabled. + if (action == @selector(performClose:)) + return YES; + + return [super validateUserInterfaceItem:item]; +} + +@end diff --git a/chrome/browser/ui/cocoa/fullscreen_window_unittest.mm b/chrome/browser/ui/cocoa/fullscreen_window_unittest.mm new file mode 100644 index 0000000..7e54581 --- /dev/null +++ b/chrome/browser/ui/cocoa/fullscreen_window_unittest.mm @@ -0,0 +1,47 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/scoped_ptr.h" +#include "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "chrome/browser/ui/cocoa/fullscreen_window.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +@interface PerformCloseUIItem : NSObject<NSValidatedUserInterfaceItem> +@end + +@implementation PerformCloseUIItem +- (SEL)action { + return @selector(performClose:); +} + +- (NSInteger)tag { + return 0; +} +@end + +class FullscreenWindowTest : public CocoaTest { +}; + +TEST_F(FullscreenWindowTest, Basics) { + scoped_nsobject<FullscreenWindow> window; + window.reset([[FullscreenWindow alloc] init]); + + EXPECT_EQ([NSScreen mainScreen], [window screen]); + EXPECT_TRUE([window canBecomeKeyWindow]); + EXPECT_TRUE([window canBecomeMainWindow]); + EXPECT_EQ(NSBorderlessWindowMask, [window styleMask]); + EXPECT_TRUE(NSEqualRects([[NSScreen mainScreen] frame], [window frame])); + EXPECT_FALSE([window isReleasedWhenClosed]); +} + +TEST_F(FullscreenWindowTest, CanPerformClose) { + scoped_nsobject<FullscreenWindow> window; + window.reset([[FullscreenWindow alloc] init]); + + scoped_nsobject<PerformCloseUIItem> item; + item.reset([[PerformCloseUIItem alloc] init]); + + EXPECT_TRUE([window validateUserInterfaceItem:item.get()]); +} diff --git a/chrome/browser/ui/cocoa/gradient_button_cell.h b/chrome/browser/ui/cocoa/gradient_button_cell.h new file mode 100644 index 0000000..a1e905f --- /dev/null +++ b/chrome/browser/ui/cocoa/gradient_button_cell.h @@ -0,0 +1,120 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_GRADIENT_BUTTON_CELL_H_ +#define CHROME_BROWSER_UI_COCOA_GRADIENT_BUTTON_CELL_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" + +class ThemeProvider; + +// Base class for button cells for toolbar and bookmark bar. +// +// This is a button cell that handles drawing/highlighting of buttons. +// The appearance is determined by setting the cell's tag (not the +// view's) to one of the constants below (ButtonType). + +// Set this as the cell's tag. +enum { + kLeftButtonType = -1, + kLeftButtonWithShadowType = -2, + kStandardButtonType = 0, + kRightButtonType = 1, + kMiddleButtonType = 2, + // Draws like a standard button, except when clicked where the interior + // doesn't darken using the theme's "pressed" gradient. Instead uses the + // normal un-pressed gradient. + kStandardButtonTypeWithLimitedClickFeedback = 3, +}; +typedef NSInteger ButtonType; + +namespace gradient_button_cell { + +// Pulsing state for this button. +typedef enum { + // Stable states. + kPulsedOn, + kPulsedOff, + // In motion which will end in a stable state. + kPulsingOn, + kPulsingOff, + // In continuous motion. + kPulsingContinuous, +} PulseState; + +}; + + +@interface GradientButtonCell : NSButtonCell { + @private + // Custom drawing means we need to perform our own mouse tracking if + // the cell is setShowsBorderOnlyWhileMouseInside:YES. + BOOL isMouseInside_; + scoped_nsobject<NSTrackingArea> trackingArea_; + BOOL shouldTheme_; + CGFloat hoverAlpha_; // 0-1. Controls the alpha during mouse hover + NSTimeInterval lastHoverUpdate_; + scoped_nsobject<NSGradient> gradient_; + gradient_button_cell::PulseState pulseState_; + CGFloat pulseMultiplier_; // for selecting pulse direction when continuous. + CGFloat outerStrokeAlphaMult_; // For pulsing. + scoped_nsobject<NSImage> overlayImage_; +} + +// Turn off theming. Temporary work-around. +- (void)setShouldTheme:(BOOL)shouldTheme; + +- (void)drawBorderAndFillForTheme:(ThemeProvider*)themeProvider + controlView:(NSView*)controlView + innerPath:(NSBezierPath*)innerPath + showClickedGradient:(BOOL)showClickedGradient + showHighlightGradient:(BOOL)showHighlightGradient + hoverAlpha:(CGFloat)hoverAlpha + active:(BOOL)active + cellFrame:(NSRect)cellFrame + defaultGradient:(NSGradient*)defaultGradient; + +// Let the view know when the mouse moves in and out. A timer will update +// the current hoverAlpha_ based on these events. +- (void)setMouseInside:(BOOL)flag animate:(BOOL)animate; + +// Gets the path which tightly bounds the outside of the button. This is needed +// to produce images of clear buttons which only include the area inside, since +// the background of the button is drawn by someone else. +- (NSBezierPath*)clipPathForFrame:(NSRect)cellFrame + inView:(NSView*)controlView; + +// Turn on or off continuous pulsing. When turning off continuous +// pulsing, leave our pulse state in the correct ending position for +// our isMouseInside_ property. Public since it's called from the +// bookmark bubble. +- (void)setIsContinuousPulsing:(BOOL)continuous; + +// Returns continuous pulse state. +- (BOOL)isContinuousPulsing; + +// Safely stop continuous pulsing by turning off all timers. +// May leave the cell in an odd state. +// Needed by an owning control's dealloc routine. +- (void)safelyStopPulsing; + +@property(assign, nonatomic) CGFloat hoverAlpha; + +// An image that will be drawn after the normal content of the button cell, +// overlaying it. Never themed. +@property(retain, nonatomic) NSImage* overlayImage; + +@end + +@interface GradientButtonCell(TestingAPI) +- (BOOL)isMouseInside; +- (BOOL)pulsing; +- (gradient_button_cell::PulseState)pulseState; +- (void)setPulseState:(gradient_button_cell::PulseState)pstate; +@end + +#endif // CHROME_BROWSER_UI_COCOA_GRADIENT_BUTTON_CELL_H_ diff --git a/chrome/browser/ui/cocoa/gradient_button_cell.mm b/chrome/browser/ui/cocoa/gradient_button_cell.mm new file mode 100644 index 0000000..205e139 --- /dev/null +++ b/chrome/browser/ui/cocoa/gradient_button_cell.mm @@ -0,0 +1,719 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/gradient_button_cell.h" + +#include "base/logging.h" +#import "base/scoped_nsobject.h" +#import "chrome/browser/themes/browser_theme_provider.h" +#import "chrome/browser/ui/cocoa/image_utils.h" +#import "chrome/browser/ui/cocoa/themed_window.h" +#include "grit/theme_resources.h" +#import "third_party/GTM/AppKit/GTMNSColor+Luminance.h" + +@interface GradientButtonCell (Private) +- (void)sharedInit; + +// Get drawing parameters for a given cell frame in a given view. The inner +// frame is the one required by |-drawInteriorWithFrame:inView:|. The inner and +// outer paths are the ones required by |-drawBorderAndFillForTheme:...|. The +// outer path also gives the area in which to clip. Any of the |return...| +// arguments may be NULL (in which case the given parameter won't be returned). +// If |returnInnerPath| or |returnOuterPath|, |*returnInnerPath| or +// |*returnOuterPath| should be nil, respectively. +- (void)getDrawParamsForFrame:(NSRect)cellFrame + inView:(NSView*)controlView + innerFrame:(NSRect*)returnInnerFrame + innerPath:(NSBezierPath**)returnInnerPath + clipPath:(NSBezierPath**)returnClipPath; + + +@end + + +static const NSTimeInterval kAnimationShowDuration = 0.2; + +// Note: due to a bug (?), drawWithFrame:inView: does not call +// drawBorderAndFillForTheme::::: unless the mouse is inside. The net +// effect is that our "fade out" when the mouse leaves becaumes +// instantaneous. When I "fixed" it things looked horrible; the +// hover-overed bookmark button would stay highlit for 0.4 seconds +// which felt like latency/lag. I'm leaving the "bug" in place for +// now so we don't suck. -jrg +static const NSTimeInterval kAnimationHideDuration = 0.4; + +static const NSTimeInterval kAnimationContinuousCycleDuration = 0.4; + +@implementation GradientButtonCell + +@synthesize hoverAlpha = hoverAlpha_; + +// For nib instantiations +- (id)initWithCoder:(NSCoder*)decoder { + if ((self = [super initWithCoder:decoder])) { + [self sharedInit]; + } + return self; +} + +// For programmatic instantiations +- (id)initTextCell:(NSString*)string { + if ((self = [super initTextCell:string])) { + [self sharedInit]; + } + return self; +} + +- (void)dealloc { + if (trackingArea_) { + [[self controlView] removeTrackingArea:trackingArea_]; + trackingArea_.reset(); + } + [super dealloc]; +} + +// Return YES if we are pulsing (towards another state or continuously). +- (BOOL)pulsing { + if ((pulseState_ == gradient_button_cell::kPulsingOn) || + (pulseState_ == gradient_button_cell::kPulsingOff) || + (pulseState_ == gradient_button_cell::kPulsingContinuous)) + return YES; + return NO; +} + +// Perform one pulse step when animating a pulse. +- (void)performOnePulseStep { + NSTimeInterval thisUpdate = [NSDate timeIntervalSinceReferenceDate]; + NSTimeInterval elapsed = thisUpdate - lastHoverUpdate_; + CGFloat opacity = [self hoverAlpha]; + + // Update opacity based on state. + // Adjust state if we have finished. + switch (pulseState_) { + case gradient_button_cell::kPulsingOn: + opacity += elapsed / kAnimationShowDuration; + if (opacity > 1.0) { + [self setPulseState:gradient_button_cell::kPulsedOn]; + return; + } + break; + case gradient_button_cell::kPulsingOff: + opacity -= elapsed / kAnimationHideDuration; + if (opacity < 0.0) { + [self setPulseState:gradient_button_cell::kPulsedOff]; + return; + } + break; + case gradient_button_cell::kPulsingContinuous: + opacity += elapsed / kAnimationContinuousCycleDuration * pulseMultiplier_; + if (opacity > 1.0) { + opacity = 1.0; + pulseMultiplier_ *= -1.0; + } else if (opacity < 0.0) { + opacity = 0.0; + pulseMultiplier_ *= -1.0; + } + outerStrokeAlphaMult_ = opacity; + break; + default: + NOTREACHED() << "unknown pulse state"; + } + + // Update our control. + lastHoverUpdate_ = thisUpdate; + [self setHoverAlpha:opacity]; + [[self controlView] setNeedsDisplay:YES]; + + // If our state needs it, keep going. + if ([self pulsing]) { + [self performSelector:_cmd withObject:nil afterDelay:0.02]; + } +} + +- (gradient_button_cell::PulseState)pulseState { + return pulseState_; +} + +// Set the pulsing state. This can either set the pulse to on or off +// immediately (e.g. kPulsedOn, kPulsedOff) or initiate an animated +// state change. +- (void)setPulseState:(gradient_button_cell::PulseState)pstate { + pulseState_ = pstate; + pulseMultiplier_ = 0.0; + [NSObject cancelPreviousPerformRequestsWithTarget:self]; + lastHoverUpdate_ = [NSDate timeIntervalSinceReferenceDate]; + + switch (pstate) { + case gradient_button_cell::kPulsedOn: + case gradient_button_cell::kPulsedOff: + outerStrokeAlphaMult_ = 1.0; + [self setHoverAlpha:((pulseState_ == gradient_button_cell::kPulsedOn) ? + 1.0 : 0.0)]; + [[self controlView] setNeedsDisplay:YES]; + break; + case gradient_button_cell::kPulsingOn: + case gradient_button_cell::kPulsingOff: + outerStrokeAlphaMult_ = 1.0; + // Set initial value then engage timer. + [self setHoverAlpha:((pulseState_ == gradient_button_cell::kPulsingOn) ? + 0.0 : 1.0)]; + [self performOnePulseStep]; + break; + case gradient_button_cell::kPulsingContinuous: + // Semantics of continuous pulsing are that we pulse independent + // of mouse position. + pulseMultiplier_ = 1.0; + [self performOnePulseStep]; + break; + default: + CHECK(0); + break; + } +} + +- (void)safelyStopPulsing { + [NSObject cancelPreviousPerformRequestsWithTarget:self]; +} + +- (void)setIsContinuousPulsing:(BOOL)continuous { + if (!continuous && pulseState_ != gradient_button_cell::kPulsingContinuous) + return; + if (continuous) { + [self setPulseState:gradient_button_cell::kPulsingContinuous]; + } else { + [self setPulseState:(isMouseInside_ ? gradient_button_cell::kPulsedOn : + gradient_button_cell::kPulsedOff)]; + } +} + +- (BOOL)isContinuousPulsing { + return (pulseState_ == gradient_button_cell::kPulsingContinuous) ? + YES : NO; +} + +#if 1 +// If we are not continuously pulsing, perform a pulse animation to +// reflect our new state. +- (void)setMouseInside:(BOOL)flag animate:(BOOL)animated { + isMouseInside_ = flag; + if (pulseState_ != gradient_button_cell::kPulsingContinuous) { + if (animated) { + [self setPulseState:(isMouseInside_ ? gradient_button_cell::kPulsingOn : + gradient_button_cell::kPulsingOff)]; + } else { + [self setPulseState:(isMouseInside_ ? gradient_button_cell::kPulsedOn : + gradient_button_cell::kPulsedOff)]; + } + } +} +#else + +- (void)adjustHoverValue { + NSTimeInterval thisUpdate = [NSDate timeIntervalSinceReferenceDate]; + + NSTimeInterval elapsed = thisUpdate - lastHoverUpdate_; + + CGFloat opacity = [self hoverAlpha]; + if (isMouseInside_) { + opacity += elapsed / kAnimationShowDuration; + } else { + opacity -= elapsed / kAnimationHideDuration; + } + + if (!isMouseInside_ && opacity < 0) { + opacity = 0; + } else if (isMouseInside_ && opacity > 1) { + opacity = 1; + } else { + [self performSelector:_cmd withObject:nil afterDelay:0.02]; + } + lastHoverUpdate_ = thisUpdate; + [self setHoverAlpha:opacity]; + + [[self controlView] setNeedsDisplay:YES]; +} + +- (void)setMouseInside:(BOOL)flag animate:(BOOL)animated { + isMouseInside_ = flag; + if (animated) { + lastHoverUpdate_ = [NSDate timeIntervalSinceReferenceDate]; + [self adjustHoverValue]; + } else { + [NSObject cancelPreviousPerformRequestsWithTarget:self]; + [self setHoverAlpha:flag ? 1.0 : 0.0]; + } + [[self controlView] setNeedsDisplay:YES]; +} + + + +#endif + +- (NSGradient*)gradientForHoverAlpha:(CGFloat)hoverAlpha + isThemed:(BOOL)themed { + CGFloat startAlpha = 0.6 + 0.3 * hoverAlpha; + CGFloat endAlpha = 0.333 * hoverAlpha; + + if (themed) { + startAlpha = 0.2 + 0.35 * hoverAlpha; + endAlpha = 0.333 * hoverAlpha; + } + + NSColor* startColor = + [NSColor colorWithCalibratedWhite:1.0 + alpha:startAlpha]; + NSColor* endColor = + [NSColor colorWithCalibratedWhite:1.0 - 0.15 * hoverAlpha + alpha:endAlpha]; + NSGradient* gradient = [[NSGradient alloc] initWithColorsAndLocations: + startColor, hoverAlpha * 0.33, + endColor, 1.0, nil]; + + return [gradient autorelease]; +} + +- (void)sharedInit { + shouldTheme_ = YES; + pulseState_ = gradient_button_cell::kPulsedOff; + pulseMultiplier_ = 1.0; + outerStrokeAlphaMult_ = 1.0; + gradient_.reset([[self gradientForHoverAlpha:0.0 isThemed:NO] retain]); +} + +- (void)setShouldTheme:(BOOL)shouldTheme { + shouldTheme_ = shouldTheme; +} + +- (NSImage*)overlayImage { + return overlayImage_.get(); +} + +- (void)setOverlayImage:(NSImage*)image { + overlayImage_.reset([image retain]); + [[self controlView] setNeedsDisplay:YES]; +} + +- (NSBackgroundStyle)interiorBackgroundStyle { + // Never lower the interior, since that just leads to a weird shadow which can + // often interact badly with the theme. + return NSBackgroundStyleRaised; +} + +- (void)mouseEntered:(NSEvent*)theEvent { + [self setMouseInside:YES animate:YES]; +} + +- (void)mouseExited:(NSEvent*)theEvent { + [self setMouseInside:NO animate:YES]; +} + +- (BOOL)isMouseInside { + return trackingArea_ && isMouseInside_; +} + +// Since we have our own drawWithFrame:, we need to also have our own +// logic for determining when the mouse is inside for honoring this +// request. +- (void)setShowsBorderOnlyWhileMouseInside:(BOOL)showOnly { + [super setShowsBorderOnlyWhileMouseInside:showOnly]; + if (showOnly) { + if (trackingArea_.get()) { + [self setShowsBorderOnlyWhileMouseInside:NO]; + [[self controlView] removeTrackingArea:trackingArea_]; + } + trackingArea_.reset([[NSTrackingArea alloc] + initWithRect:[[self controlView] + bounds] + options:(NSTrackingMouseEnteredAndExited | + NSTrackingActiveInActiveApp) + owner:self + userInfo:nil]); + [[self controlView] addTrackingArea:trackingArea_]; + } else { + if (trackingArea_) { + [[self controlView] removeTrackingArea:trackingArea_]; + trackingArea_.reset(nil); + isMouseInside_ = NO; + } + } +} + +// TODO(viettrungluu): clean up/reorganize. +- (void)drawBorderAndFillForTheme:(ThemeProvider*)themeProvider + controlView:(NSView*)controlView + innerPath:(NSBezierPath*)innerPath + showClickedGradient:(BOOL)showClickedGradient + showHighlightGradient:(BOOL)showHighlightGradient + hoverAlpha:(CGFloat)hoverAlpha + active:(BOOL)active + cellFrame:(NSRect)cellFrame + defaultGradient:(NSGradient*)defaultGradient { + BOOL isFlatButton = [self showsBorderOnlyWhileMouseInside]; + + // For flat (unbordered when not hovered) buttons, never use the toolbar + // button background image, but the modest gradient used for themed buttons. + // To make things even more modest, scale the hover alpha down by 40 percent + // unless clicked. + NSColor* backgroundImageColor; + BOOL useThemeGradient; + if (isFlatButton) { + backgroundImageColor = nil; + useThemeGradient = YES; + if (!showClickedGradient) + hoverAlpha *= 0.6; + } else { + backgroundImageColor = + themeProvider ? + themeProvider->GetNSImageColorNamed(IDR_THEME_BUTTON_BACKGROUND, + false) : + nil; + useThemeGradient = backgroundImageColor ? YES : NO; + } + + // The basic gradient shown inside; see above. + NSGradient* gradient; + if (hoverAlpha == 0 && !useThemeGradient) { + gradient = defaultGradient ? defaultGradient + : gradient_; + } else { + gradient = [self gradientForHoverAlpha:hoverAlpha + isThemed:useThemeGradient]; + } + + // If we're drawing a background image, show that; else possibly show the + // clicked gradient. + if (backgroundImageColor) { + [backgroundImageColor set]; + // Set the phase to match window. + NSRect trueRect = [controlView convertRect:cellFrame toView:nil]; + [[NSGraphicsContext currentContext] + setPatternPhase:NSMakePoint(NSMinX(trueRect), NSMaxY(trueRect))]; + [innerPath fill]; + } else { + if (showClickedGradient) { + NSGradient* clickedGradient = nil; + if (isFlatButton && + [self tag] == kStandardButtonTypeWithLimitedClickFeedback) { + clickedGradient = gradient; + } else { + clickedGradient = themeProvider ? themeProvider->GetNSGradient( + active ? + BrowserThemeProvider::GRADIENT_TOOLBAR_BUTTON_PRESSED : + BrowserThemeProvider::GRADIENT_TOOLBAR_BUTTON_PRESSED_INACTIVE) : + nil; + } + [clickedGradient drawInBezierPath:innerPath angle:90.0]; + } + } + + // Visually indicate unclicked, enabled buttons. + if (!showClickedGradient && [self isEnabled]) { + [NSGraphicsContext saveGraphicsState]; + [innerPath addClip]; + + // Draw the inner glow. + if (hoverAlpha > 0) { + [innerPath setLineWidth:2]; + [[NSColor colorWithCalibratedWhite:1.0 alpha:0.2 * hoverAlpha] setStroke]; + [innerPath stroke]; + } + + // Draw the top inner highlight. + NSAffineTransform* highlightTransform = [NSAffineTransform transform]; + [highlightTransform translateXBy:1 yBy:1]; + scoped_nsobject<NSBezierPath> highlightPath([innerPath copy]); + [highlightPath transformUsingAffineTransform:highlightTransform]; + [[NSColor colorWithCalibratedWhite:1.0 alpha:0.2] setStroke]; + [highlightPath stroke]; + + // Draw the gradient inside. + [gradient drawInBezierPath:innerPath angle:90.0]; + + [NSGraphicsContext restoreGraphicsState]; + } + + // Don't draw anything else for disabled flat buttons. + if (isFlatButton && ![self isEnabled]) + return; + + // Draw the outer stroke. + NSColor* strokeColor = nil; + if (showClickedGradient) { + strokeColor = [NSColor + colorWithCalibratedWhite:0.0 + alpha:0.3 * outerStrokeAlphaMult_]; + } else { + strokeColor = themeProvider ? themeProvider->GetNSColor( + active ? BrowserThemeProvider::COLOR_TOOLBAR_BUTTON_STROKE : + BrowserThemeProvider::COLOR_TOOLBAR_BUTTON_STROKE_INACTIVE, + true) : [NSColor colorWithCalibratedWhite:0.0 + alpha:0.3 * outerStrokeAlphaMult_]; + } + [strokeColor setStroke]; + + [innerPath setLineWidth:1]; + [innerPath stroke]; +} + +// TODO(viettrungluu): clean this up. +// (Private) +- (void)getDrawParamsForFrame:(NSRect)cellFrame + inView:(NSView*)controlView + innerFrame:(NSRect*)returnInnerFrame + innerPath:(NSBezierPath**)returnInnerPath + clipPath:(NSBezierPath**)returnClipPath { + // Constants from Cole. Will kConstant them once the feedback loop + // is complete. + NSRect drawFrame = NSInsetRect(cellFrame, 1.5, 1.5); + NSRect innerFrame = NSInsetRect(cellFrame, 2, 1); + const CGFloat radius = 3.5; + + ButtonType type = [[(NSControl*)controlView cell] tag]; + switch (type) { + case kMiddleButtonType: + drawFrame.size.width += 20; + innerFrame.size.width += 2; + // Fallthrough + case kRightButtonType: + drawFrame.origin.x -= 20; + innerFrame.origin.x -= 2; + // Fallthrough + case kLeftButtonType: + case kLeftButtonWithShadowType: + drawFrame.size.width += 20; + innerFrame.size.width += 2; + default: + break; + } + if (type == kLeftButtonWithShadowType) + innerFrame.size.width -= 1.0; + + // Return results if |return...| not null. + if (returnInnerFrame) + *returnInnerFrame = innerFrame; + if (returnInnerPath) { + DCHECK(*returnInnerPath == nil); + *returnInnerPath = [NSBezierPath bezierPathWithRoundedRect:drawFrame + xRadius:radius + yRadius:radius]; + } + if (returnClipPath) { + DCHECK(*returnClipPath == nil); + NSRect clipPathRect = NSInsetRect(drawFrame, -0.5, -0.5); + *returnClipPath = [NSBezierPath bezierPathWithRoundedRect:clipPathRect + xRadius:radius + 0.5 + yRadius:radius + 0.5]; + } +} + +// TODO(viettrungluu): clean this up. +- (void)drawWithFrame:(NSRect)cellFrame inView:(NSView*)controlView { + NSRect innerFrame; + NSBezierPath* innerPath = nil; + [self getDrawParamsForFrame:cellFrame + inView:controlView + innerFrame:&innerFrame + innerPath:&innerPath + clipPath:NULL]; + + BOOL pressed = ([((NSControl*)[self controlView]) isEnabled] && + [self isHighlighted]); + NSWindow* window = [controlView window]; + ThemeProvider* themeProvider = [window themeProvider]; + BOOL active = [window isKeyWindow] || [window isMainWindow]; + + // Stroke the borders and appropriate fill gradient. If we're borderless, the + // only time we want to draw the inner gradient is if we're highlighted or if + // we're the first responder (when "Full Keyboard Access" is turned on). + if (([self isBordered] && ![self showsBorderOnlyWhileMouseInside]) || + pressed || + [self isMouseInside] || + [self isContinuousPulsing] || + [self showsFirstResponder]) { + + // When pulsing we want the bookmark to stand out a little more. + BOOL showClickedGradient = pressed || + (pulseState_ == gradient_button_cell::kPulsingContinuous); + + // When first responder, turn the hover alpha all the way up. + CGFloat hoverAlpha = [self hoverAlpha]; + if ([self showsFirstResponder]) + hoverAlpha = 1.0; + + [self drawBorderAndFillForTheme:themeProvider + controlView:controlView + innerPath:innerPath + showClickedGradient:showClickedGradient + showHighlightGradient:[self isHighlighted] + hoverAlpha:hoverAlpha + active:active + cellFrame:cellFrame + defaultGradient:nil]; + } + + // If this is the left side of a segmented button, draw a slight shadow. + ButtonType type = [[(NSControl*)controlView cell] tag]; + if (type == kLeftButtonWithShadowType) { + NSRect borderRect, contentRect; + NSDivideRect(cellFrame, &borderRect, &contentRect, 1.0, NSMaxXEdge); + NSColor* stroke = themeProvider ? themeProvider->GetNSColor( + active ? BrowserThemeProvider::COLOR_TOOLBAR_BUTTON_STROKE : + BrowserThemeProvider::COLOR_TOOLBAR_BUTTON_STROKE_INACTIVE, + true) : [NSColor blackColor]; + + [[stroke colorWithAlphaComponent:0.2] set]; + NSRectFillUsingOperation(NSInsetRect(borderRect, 0, 2), + NSCompositeSourceOver); + } + [self drawInteriorWithFrame:innerFrame inView:controlView]; +} + +- (void)drawInteriorWithFrame:(NSRect)cellFrame inView:(NSView*)controlView { + if (shouldTheme_) { + BOOL isTemplate = [[self image] isTemplate]; + + [NSGraphicsContext saveGraphicsState]; + + CGContextRef context = + (CGContextRef)([[NSGraphicsContext currentContext] graphicsPort]); + + BrowserThemeProvider* themeProvider = static_cast<BrowserThemeProvider*>( + [[controlView window] themeProvider]); + NSColor* color = themeProvider ? + themeProvider->GetNSColorTint(BrowserThemeProvider::TINT_BUTTONS, + true) : + [NSColor blackColor]; + + if (isTemplate && themeProvider && themeProvider->UsingDefaultTheme()) { + scoped_nsobject<NSShadow> shadow([[NSShadow alloc] init]); + [shadow.get() setShadowColor:themeProvider->GetNSColor( + BrowserThemeProvider::COLOR_TOOLBAR_BEZEL, true)]; + [shadow.get() setShadowOffset:NSMakeSize(0.0, -1.0)]; + [shadow setShadowBlurRadius:1.0]; + [shadow set]; + } + + CGContextBeginTransparencyLayer(context, 0); + NSRect imageRect = NSZeroRect; + imageRect.size = [[self image] size]; + NSRect drawRect = [self imageRectForBounds:cellFrame]; + [[self image] drawInRect:drawRect + fromRect:imageRect + operation:NSCompositeSourceOver + fraction:[self isEnabled] ? 1.0 : 0.5 + neverFlipped:YES]; + if (isTemplate && color) { + [color set]; + NSRectFillUsingOperation(cellFrame, NSCompositeSourceAtop); + } + CGContextEndTransparencyLayer(context); + + [NSGraphicsContext restoreGraphicsState]; + } else { + // NSCell draws these off-center for some reason, probably because of the + // positioning of the control in the xib. + [super drawInteriorWithFrame:NSOffsetRect(cellFrame, 0, 1) + inView:controlView]; + } + + if (overlayImage_) { + NSRect imageRect = NSZeroRect; + imageRect.size = [overlayImage_ size]; + [overlayImage_ drawInRect:[self imageRectForBounds:cellFrame] + fromRect:imageRect + operation:NSCompositeSourceOver + fraction:[self isEnabled] ? 1.0 : 0.5 + neverFlipped:YES]; + } +} + +// Overriden from NSButtonCell so we can display a nice fadeout effect for +// button titles that overflow. +// This method is copied in the most part from GTMFadeTruncatingTextFieldCell, +// the only difference is that here we draw the text ourselves rather than +// calling the super to do the work. +// We can't use GTMFadeTruncatingTextFieldCell because there's no easy way to +// get it to work with NSButtonCell. +// TODO(jeremy): Move this to GTM. +- (NSRect)drawTitle:(NSAttributedString *)title + withFrame:(NSRect)cellFrame + inView:(NSView *)controlView { + NSSize size = [title size]; + + // Empirically, Cocoa will draw an extra 2 pixels past NSWidth(cellFrame) + // before it clips the text. + const CGFloat kOverflowBeforeClip = 2; + // Don't complicate drawing unless we need to clip. + if (floor(size.width) <= (NSWidth(cellFrame) + kOverflowBeforeClip)) { + return [super drawTitle:title withFrame:cellFrame inView:controlView]; + } + + // Gradient is about twice our line height long. + CGFloat gradientWidth = MIN(size.height * 2, NSWidth(cellFrame) / 4); + + NSRect solidPart, gradientPart; + NSDivideRect(cellFrame, &gradientPart, &solidPart, gradientWidth, NSMaxXEdge); + + // Draw non-gradient part without transparency layer, as light text on a dark + // background looks bad with a gradient layer. + [[NSGraphicsContext currentContext] saveGraphicsState]; + [NSBezierPath clipRect:solidPart]; + + // 11 is the magic number needed to make this match the native NSButtonCell's + // label display. + CGFloat textLeft = [[self image] size].width + 11; + + // For some reason, the height of cellFrame as passed in is totally bogus. + // For vertical centering purposes, we need the bounds of the containing + // view. + NSRect buttonFrame = [[self controlView] frame]; + + // Off-by-one to match native NSButtonCell's version. + NSPoint textOffset = NSMakePoint(textLeft, + (NSHeight(buttonFrame) - size.height)/2 + 1); + [title drawAtPoint:textOffset]; + [[NSGraphicsContext currentContext] restoreGraphicsState]; + + // Draw the gradient part with a transparency layer. This makes the text look + // suboptimal, but since it fades out, that's ok. + [[NSGraphicsContext currentContext] saveGraphicsState]; + [NSBezierPath clipRect:gradientPart]; + CGContextRef context = static_cast<CGContextRef>( + [[NSGraphicsContext currentContext] graphicsPort]); + CGContextBeginTransparencyLayerWithRect(context, + NSRectToCGRect(gradientPart), 0); + [title drawAtPoint:textOffset]; + + // TODO(alcor): switch this to GTMLinearRGBShading if we ever need on 10.4 + NSColor *color = [NSColor textColor]; //[self textColor]; + NSColor *alphaColor = [color colorWithAlphaComponent:0.0]; + NSGradient *mask = [[NSGradient alloc] initWithStartingColor:color + endingColor:alphaColor]; + + // Draw the gradient mask + CGContextSetBlendMode(context, kCGBlendModeDestinationIn); + [mask drawFromPoint:NSMakePoint(NSMaxX(cellFrame) - gradientWidth, + NSMinY(cellFrame)) + toPoint:NSMakePoint(NSMaxX(cellFrame), + NSMinY(cellFrame)) + options:NSGradientDrawsBeforeStartingLocation]; + [mask release]; + CGContextEndTransparencyLayer(context); + [[NSGraphicsContext currentContext] restoreGraphicsState]; + + return cellFrame; +} + +- (NSBezierPath*)clipPathForFrame:(NSRect)cellFrame + inView:(NSView*)controlView { + NSBezierPath* boundingPath = nil; + [self getDrawParamsForFrame:cellFrame + inView:controlView + innerFrame:NULL + innerPath:NULL + clipPath:&boundingPath]; + return boundingPath; +} + +@end diff --git a/chrome/browser/ui/cocoa/gradient_button_cell_unittest.mm b/chrome/browser/ui/cocoa/gradient_button_cell_unittest.mm new file mode 100644 index 0000000..a9d09da --- /dev/null +++ b/chrome/browser/ui/cocoa/gradient_button_cell_unittest.mm @@ -0,0 +1,112 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/gradient_button_cell.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +@interface GradientButtonCell (HoverValueTesting) +- (void)performOnePulseStep; +@end + +namespace { + +class GradientButtonCellTest : public CocoaTest { + public: + GradientButtonCellTest() { + NSRect frame = NSMakeRect(0, 0, 50, 30); + scoped_nsobject<NSButton>view([[NSButton alloc] initWithFrame:frame]); + view_ = view.get(); + scoped_nsobject<GradientButtonCell> cell([[GradientButtonCell alloc] + initTextCell:@"Testing"]); + [view_ setCell:cell.get()]; + [[test_window() contentView] addSubview:view_]; + } + + NSButton* view_; +}; + +TEST_VIEW(GradientButtonCellTest, view_) + +// Test drawing, mostly to ensure nothing leaks or crashes. +TEST_F(GradientButtonCellTest, DisplayWithHover) { + [[view_ cell] setHoverAlpha:0.0]; + [view_ display]; + [[view_ cell] setHoverAlpha:0.5]; + [view_ display]; + [[view_ cell] setHoverAlpha:1.0]; + [view_ display]; +} + +// Test hover, mostly to ensure nothing leaks or crashes. +TEST_F(GradientButtonCellTest, Hover) { + GradientButtonCell* cell = [view_ cell]; + [cell setMouseInside:YES animate:NO]; + EXPECT_EQ([[view_ cell] hoverAlpha], 1.0); + + [cell setMouseInside:NO animate:YES]; + CGFloat alpha1 = [cell hoverAlpha]; + [cell performOnePulseStep]; + CGFloat alpha2 = [cell hoverAlpha]; + EXPECT_TRUE(alpha2 < alpha1); +} + +// Tracking rects +TEST_F(GradientButtonCellTest, TrackingRects) { + GradientButtonCell* cell = [view_ cell]; + EXPECT_FALSE([cell showsBorderOnlyWhileMouseInside]); + EXPECT_FALSE([cell isMouseInside]); + + [cell setShowsBorderOnlyWhileMouseInside:YES]; + [cell mouseEntered:nil]; + EXPECT_TRUE([cell isMouseInside]); + [cell mouseExited:nil]; + EXPECT_FALSE([cell isMouseInside]); + + [cell setShowsBorderOnlyWhileMouseInside:NO]; + EXPECT_FALSE([cell isMouseInside]); + + [cell setShowsBorderOnlyWhileMouseInside:YES]; + [cell setShowsBorderOnlyWhileMouseInside:YES]; + [cell setShowsBorderOnlyWhileMouseInside:NO]; + [cell setShowsBorderOnlyWhileMouseInside:NO]; +} + +TEST_F(GradientButtonCellTest, ContinuousPulseOnOff) { + GradientButtonCell* cell = [view_ cell]; + + // On/off + EXPECT_FALSE([cell isContinuousPulsing]); + [cell setIsContinuousPulsing:YES]; + EXPECT_TRUE([cell isContinuousPulsing]); + EXPECT_TRUE([cell pulsing]); + [cell setIsContinuousPulsing:NO]; + EXPECT_FALSE([cell isContinuousPulsing]); + + // On/safeOff + [cell setIsContinuousPulsing:YES]; + EXPECT_TRUE([cell isContinuousPulsing]); + [cell safelyStopPulsing]; +} + +// More for valgrind; we don't confirm state change does anything useful. +TEST_F(GradientButtonCellTest, PulseState) { + GradientButtonCell* cell = [view_ cell]; + + [cell setMouseInside:YES animate:YES]; + // Allow for immediate state changes to keep test unflaky + EXPECT_TRUE(([cell pulseState] == gradient_button_cell::kPulsingOn) || + ([cell pulseState] == gradient_button_cell::kPulsedOn)); + + [cell setMouseInside:NO animate:YES]; + // Allow for immediate state changes to keep test unflaky + EXPECT_TRUE(([cell pulseState] == gradient_button_cell::kPulsingOff) || + ([cell pulseState] == gradient_button_cell::kPulsedOff)); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/history_menu_bridge.h b/chrome/browser/ui/cocoa/history_menu_bridge.h new file mode 100644 index 0000000..db4a37b --- /dev/null +++ b/chrome/browser/ui/cocoa/history_menu_bridge.h @@ -0,0 +1,232 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_HISTORY_MENU_BRIDGE_H_ +#define CHROME_BROWSER_UI_COCOA_HISTORY_MENU_BRIDGE_H_ +#pragma once + +#import <Cocoa/Cocoa.h> +#include <map> + +#include "base/ref_counted.h" +#include "base/scoped_nsobject.h" +#include "chrome/browser/cancelable_request.h" +#import "chrome/browser/favicon_service.h" +#include "chrome/browser/history/history.h" +#include "chrome/browser/sessions/session_id.h" +#include "chrome/browser/sessions/tab_restore_service.h" +#include "chrome/browser/sessions/tab_restore_service_observer.h" +#include "chrome/common/notification_observer.h" + +class NavigationEntry; +class NotificationRegistrar; +class PageUsageData; +class Profile; +class TabNavigationEntry; +class TabRestoreService; +@class HistoryMenuCocoaController; + +namespace { + +class HistoryMenuBridgeTest; + +} + +// C++ bridge for the history menu; one per AppController (means there +// is only one). This class observes various data sources, namely the +// HistoryService and the TabRestoreService, and then updates the NSMenu when +// there is new data. +// +// The history menu is broken up into sections: most visisted and recently +// closed. The overall menu has a tag of IDC_HISTORY_MENU, with the user content +// items having the local tags defined in the enum below. Items within a section +// all share the same tag. The structure of the menu is laid out in MainMenu.xib +// and the generated content is inserted after the Title elements. The recently +// closed section is special in that those menu items can have submenus to list +// all the tabs within that closed window. By convention, these submenu items +// have a tag that's equal to the parent + 1. Tags within the history menu have +// a range of [400,500) and do not go through CommandDispatch for their target- +// action mechanism. +// +// These menu items do not use firstResponder as their target. Rather, they are +// hooked directly up to the HistoryMenuCocoaController that then bridges back +// to this class. These items are created via the AddItemToMenu() helper. Also, +// unlike the typical ownership model, this bridge owns its controller. The +// controller is very thin and only exists to interact with Cocoa, but this +// class does the bulk of the work. +class HistoryMenuBridge : public NotificationObserver, + public TabRestoreServiceObserver { + public: + // This is a generalization of the data we store in the history menu because + // we pull things from different sources with different data types. + struct HistoryItem { + public: + HistoryItem(); + // Copy constructor allowed. + HistoryItem(const HistoryItem& copy); + ~HistoryItem(); + + // The title for the menu item. + string16 title; + // The URL that will be navigated to if the user selects this item. + GURL url; + // Favicon for the URL. + scoped_nsobject<NSImage> icon; + + // If the icon is being requested from the FaviconService, |icon_requested| + // will be true and |icon_handle| will be non-NULL. If this is false, then + // |icon_handle| will be NULL. + bool icon_requested; + // The Handle given to us by the FaviconService for the icon fetch request. + FaviconService::Handle icon_handle; + + // The pointer to the item after it has been created. Strong; NSMenu also + // retains this. During a rebuild flood (if the user closes a lot of tabs + // quickly), the NSMenu can release the item before the HistoryItem has + // been fully deleted. If this were a weak pointer, it would result in a + // zombie. + scoped_nsobject<NSMenuItem> menu_item; + + // This ID is unique for a browser session and can be passed to the + // TabRestoreService to re-open the closed window or tab that this + // references. A non-0 session ID indicates that this is an entry can be + // restored that way. Otherwise, the URL will be used to open the item and + // this ID will be 0. + SessionID::id_type session_id; + + // If the HistoryItem is a window, this will be the vector of tabs. Note + // that this is a list of weak references. The |menu_item_map_| is the owner + // of all items. If it is not a window, then the entry is a single page and + // the vector will be empty. + std::vector<HistoryItem*> tabs; + + private: + // Copying is explicitly allowed, but assignment is not. + void operator=(const HistoryItem&); + }; + + // These tags are not global view tags and are local to the history menu. The + // normal procedure for menu items is to go through CommandDispatch, but since + // history menu items are hooked directly up to their target, they do not need + // to have the global IDC view tags. + enum Tags { + kMostVisitedSeparator = 400, // Separator before most visited section. + kMostVisitedTitle = 401, // Title of the most visited section. + kMostVisited = 420, // Used for all entries in the most visited section. + kRecentlyClosedSeparator = 440, // Item before recently closed section. + kRecentlyClosedTitle = 441, // Title of recently closed section. + kRecentlyClosed = 460, // Used for items in the recently closed section. + kShowFullSeparator = 480 // Separator after the recently closed section. + }; + + explicit HistoryMenuBridge(Profile* profile); + virtual ~HistoryMenuBridge(); + + // Overriden from NotificationObserver. + virtual void Observe(NotificationType type, + const NotificationSource& source, + const NotificationDetails& details); + + // For TabRestoreServiceObserver + virtual void TabRestoreServiceChanged(TabRestoreService* service); + virtual void TabRestoreServiceDestroyed(TabRestoreService* service); + + // Looks up an NSMenuItem in the |menu_item_map_| and returns the + // corresponding HistoryItem. + HistoryItem* HistoryItemForMenuItem(NSMenuItem* item); + + // I wish I has a "friend @class" construct. These are used by the HMCC + // to access model information when responding to actions. + HistoryService* service(); + Profile* profile(); + + protected: + // Return the History menu. + virtual NSMenu* HistoryMenu(); + + // Clear items in the given |menu|. Menu items in the same section are given + // the same tag. This will go through the entire history menu, removing all + // items with a given tag. Note that this will recurse to submenus, removing + // child items from the menu item map. This will only remove items that have + // a target hooked up to the |controller_|. + void ClearMenuSection(NSMenu* menu, NSInteger tag); + + // Adds a given title and URL to the passed-in menu with a certain tag and + // index. This will add |item| and the newly created menu item to the + // |menu_item_map_|, which takes ownership. Items are deleted in + // ClearMenuSection(). This returns the new menu item that was just added. + NSMenuItem* AddItemToMenu(HistoryItem* item, + NSMenu* menu, + NSInteger tag, + NSInteger index); + + // Called by the ctor if |service_| is ready at the time, or by a + // notification receiver. Finishes initialization tasks by subscribing for + // change notifications and calling CreateMenu(). + void Init(); + + // Does the query for the history information to create the menu. + void CreateMenu(); + + // Callback method for when HistoryService query results are ready with the + // most recently-visited sites. + void OnVisitedHistoryResults(CancelableRequestProvider::Handle handle, + std::vector<PageUsageData*>* results); + + // Creates a HistoryItem* for the given tab entry. Caller takes ownership of + // the result and must delete it when finished. + HistoryItem* HistoryItemForTab(const TabRestoreService::Tab& entry); + + // Helper function that sends an async request to the FaviconService to get + // an icon. The callback will update the NSMenuItem directly. + void GetFaviconForHistoryItem(HistoryItem* item); + + // Callback for the FaviconService to return favicon image data when we + // request it. This decodes the raw data, updates the HistoryItem, and then + // sets the image on the menu. Called on the same same thread that + // GetFaviconForHistoryItem() was called on (UI thread). + void GotFaviconData(FaviconService::Handle handle, + bool know_favicon, + scoped_refptr<RefCountedMemory> data, + bool expired, + GURL url); + + // Cancels a favicon load request for a given HistoryItem, if one is in + // progress. + void CancelFaviconRequest(HistoryItem* item); + + private: + friend class ::HistoryMenuBridgeTest; + friend class HistoryMenuCocoaControllerTest; + + scoped_nsobject<HistoryMenuCocoaController> controller_; // strong + + Profile* profile_; // weak + HistoryService* history_service_; // weak + TabRestoreService* tab_restore_service_; // weak + + NotificationRegistrar registrar_; + CancelableRequestConsumer cancelable_request_consumer_; + + // Mapping of NSMenuItems to HistoryItems. This owns the HistoryItems until + // they are removed and deleted via ClearMenuSection(). + std::map<NSMenuItem*, HistoryItem*> menu_item_map_; + + // Maps HistoryItems to favicon request Handles. + CancelableRequestConsumerTSimple<HistoryItem*> favicon_consumer_; + + // Requests to re-create the menu are coalesced. |create_in_progress_| is true + // when either waiting for the history service to return query results, or + // when the menu is rebuilding. |need_recreate_| is true whenever a rebuild + // has been scheduled but is waiting for the current one to finish. + bool create_in_progress_; + bool need_recreate_; + + // The default favicon if a HistoryItem does not have one. + scoped_nsobject<NSImage> default_favicon_; + + DISALLOW_COPY_AND_ASSIGN(HistoryMenuBridge); +}; + +#endif // CHROME_BROWSER_UI_COCOA_HISTORY_MENU_BRIDGE_H_ diff --git a/chrome/browser/ui/cocoa/history_menu_bridge.mm b/chrome/browser/ui/cocoa/history_menu_bridge.mm new file mode 100644 index 0000000..320632f --- /dev/null +++ b/chrome/browser/ui/cocoa/history_menu_bridge.mm @@ -0,0 +1,470 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/history_menu_bridge.h" + +#include "app/l10n_util.h" +#include "app/resource_bundle.h" +#include "base/callback.h" +#include "base/stl_util-inl.h" +#include "base/string_number_conversions.h" +#include "base/string_util.h" +#include "base/sys_string_conversions.h" +#include "chrome/app/chrome_command_ids.h" // IDC_HISTORY_MENU +#import "chrome/browser/app_controller_mac.h" +#include "chrome/browser/history/page_usage_data.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/sessions/session_types.h" +#import "chrome/browser/ui/cocoa/history_menu_cocoa_controller.h" +#include "chrome/common/notification_registrar.h" +#include "chrome/common/notification_service.h" +#include "chrome/common/url_constants.h" +#include "gfx/codec/png_codec.h" +#include "grit/app_resources.h" +#include "grit/generated_resources.h" +#include "grit/theme_resources.h" +#include "skia/ext/skia_utils_mac.h" +#include "third_party/skia/include/core/SkBitmap.h" + +namespace { + +// Menus more than this many chars long will get trimmed. +const NSUInteger kMaximumMenuWidthInChars = 50; + +// When trimming, use this many chars from each side. +const NSUInteger kMenuTrimSizeInChars = 25; + +// Number of days to consider when getting the number of most visited items. +const int kMostVisitedScope = 90; + +// The number of most visisted results to get. +const int kMostVisitedCount = 9; + +// The number of recently closed items to get. +const unsigned int kRecentlyClosedCount = 10; + +} // namespace + +HistoryMenuBridge::HistoryItem::HistoryItem() + : icon_requested(false), + menu_item(nil), + session_id(0) { +} + +HistoryMenuBridge::HistoryItem::HistoryItem(const HistoryItem& copy) + : title(copy.title), + url(copy.url), + icon_requested(false), + menu_item(nil), + session_id(copy.session_id) { +} + +HistoryMenuBridge::HistoryItem::~HistoryItem() { +} + +HistoryMenuBridge::HistoryMenuBridge(Profile* profile) + : controller_([[HistoryMenuCocoaController alloc] initWithBridge:this]), + profile_(profile), + history_service_(NULL), + tab_restore_service_(NULL), + create_in_progress_(false), + need_recreate_(false) { + // If we don't have a profile, do not bother initializing our data sources. + // This shouldn't happen except in unit tests. + if (profile_) { + // Check to see if the history service is ready. Because it loads async, it + // may not be ready when the Bridge is created. If this happens, register + // for a notification that tells us the HistoryService is ready. + HistoryService* hs = profile_->GetHistoryService(Profile::EXPLICIT_ACCESS); + if (hs != NULL && hs->BackendLoaded()) { + history_service_ = hs; + Init(); + } + + // TODO(???): NULL here means we're OTR. Show this in the GUI somehow? + tab_restore_service_ = profile_->GetTabRestoreService(); + if (tab_restore_service_) { + tab_restore_service_->AddObserver(this); + tab_restore_service_->LoadTabsFromLastSession(); + } + } + + ResourceBundle& rb = ResourceBundle::GetSharedInstance(); + default_favicon_.reset([rb.GetNativeImageNamed(IDR_DEFAULT_FAVICON) retain]); + + // Set the static icons in the menu. + NSMenuItem* item = [HistoryMenu() itemWithTag:IDC_SHOW_HISTORY]; + [item setImage:rb.GetNativeImageNamed(IDR_HISTORY_FAVICON)]; + + // The service is not ready for use yet, so become notified when it does. + if (!history_service_) { + registrar_.Add(this, + NotificationType::HISTORY_LOADED, + NotificationService::AllSources()); + } +} + +// Note that all requests sent to either the history service or the favicon +// service will be automatically cancelled by their respective Consumers, so +// task cancellation is not done manually here in the dtor. +HistoryMenuBridge::~HistoryMenuBridge() { + // Unregister ourselves as observers and notifications. + const NotificationSource& src = NotificationService::AllSources(); + if (history_service_) { + registrar_.Remove(this, NotificationType::HISTORY_TYPED_URLS_MODIFIED, src); + registrar_.Remove(this, NotificationType::HISTORY_URL_VISITED, src); + registrar_.Remove(this, NotificationType::HISTORY_URLS_DELETED, src); + } else { + registrar_.Remove(this, NotificationType::HISTORY_LOADED, src); + } + + if (tab_restore_service_) + tab_restore_service_->RemoveObserver(this); + + // Since the map owns the HistoryItems, delete anything that still exists. + std::map<NSMenuItem*, HistoryItem*>::iterator it = menu_item_map_.begin(); + while (it != menu_item_map_.end()) { + HistoryItem* item = it->second; + menu_item_map_.erase(it++); + delete item; + } +} + +void HistoryMenuBridge::Observe(NotificationType type, + const NotificationSource& source, + const NotificationDetails& details) { + // A history service is now ready. Check to see if it's the one for the main + // profile. If so, perform final initialization. + if (type == NotificationType::HISTORY_LOADED) { + HistoryService* hs = + profile_->GetHistoryService(Profile::EXPLICIT_ACCESS); + if (hs != NULL && hs->BackendLoaded()) { + history_service_ = hs; + Init(); + + // Found our HistoryService, so stop listening for this notification. + registrar_.Remove(this, + NotificationType::HISTORY_LOADED, + NotificationService::AllSources()); + } + } + + // All other notification types that we observe indicate that the history has + // changed and we need to rebuild. + need_recreate_ = true; + CreateMenu(); +} + +void HistoryMenuBridge::TabRestoreServiceChanged(TabRestoreService* service) { + const TabRestoreService::Entries& entries = service->entries(); + + // Clear the history menu before rebuilding. + NSMenu* menu = HistoryMenu(); + ClearMenuSection(menu, kRecentlyClosed); + + // Index for the next menu item. + NSInteger index = [menu indexOfItemWithTag:kRecentlyClosedTitle] + 1; + NSUInteger added_count = 0; + + for (TabRestoreService::Entries::const_iterator it = entries.begin(); + it != entries.end() && added_count < kRecentlyClosedCount; ++it) { + TabRestoreService::Entry* entry = *it; + + // If this is a window, create a submenu for all of its tabs. + if (entry->type == TabRestoreService::WINDOW) { + TabRestoreService::Window* entry_win = (TabRestoreService::Window*)entry; + std::vector<TabRestoreService::Tab>& tabs = entry_win->tabs; + if (!tabs.size()) + continue; + + // Create the item for the parent/window. Do not set the title yet because + // the actual number of items that are in the menu will not be known until + // things like the NTP are filtered out, which is done when the tab items + // are actually created. + HistoryItem* item = new HistoryItem(); + item->session_id = entry_win->id; + + // Create the submenu. + scoped_nsobject<NSMenu> submenu([[NSMenu alloc] init]); + + // Create standard items within the window submenu. + NSString* restore_title = l10n_util::GetNSString( + IDS_HISTORY_CLOSED_RESTORE_WINDOW_MAC); + scoped_nsobject<NSMenuItem> restore_item( + [[NSMenuItem alloc] initWithTitle:restore_title + action:@selector(openHistoryMenuItem:) + keyEquivalent:@""]); + [restore_item setTarget:controller_.get()]; + // Duplicate the HistoryItem otherwise the different NSMenuItems will + // point to the same HistoryItem, which would then be double-freed when + // removing the items from the map or in the dtor. + HistoryItem* dup_item = new HistoryItem(*item); + menu_item_map_.insert(std::make_pair(restore_item.get(), dup_item)); + [submenu addItem:restore_item.get()]; + [submenu addItem:[NSMenuItem separatorItem]]; + + // Loop over the window's tabs and add them to the submenu. + NSInteger subindex = [[submenu itemArray] count]; + std::vector<TabRestoreService::Tab>::const_iterator it; + for (it = tabs.begin(); it != tabs.end(); ++it) { + TabRestoreService::Tab tab = *it; + HistoryItem* tab_item = HistoryItemForTab(tab); + if (tab_item) { + item->tabs.push_back(tab_item); + AddItemToMenu(tab_item, submenu.get(), kRecentlyClosed + 1, + subindex++); + } + } + + // Now that the number of tabs that has been added is known, set the title + // of the parent menu item. + if (item->tabs.size() == 1) { + item->title = l10n_util::GetStringUTF16( + IDS_NEW_TAB_RECENTLY_CLOSED_WINDOW_SINGLE); + } else { + item->title =l10n_util::GetStringFUTF16( + IDS_NEW_TAB_RECENTLY_CLOSED_WINDOW_MULTIPLE, + base::IntToString16(item->tabs.size())); + } + + // Sometimes it is possible for there to not be any subitems for a given + // window; if that is the case, do not add the entry to the main menu. + if ([[submenu itemArray] count] > 2) { + // Create the menu item parent. + NSMenuItem* parent_item = + AddItemToMenu(item, menu, kRecentlyClosed, index++); + [parent_item setSubmenu:submenu.get()]; + ++added_count; + } + } else if (entry->type == TabRestoreService::TAB) { + TabRestoreService::Tab* tab = + static_cast<TabRestoreService::Tab*>(entry); + HistoryItem* item = HistoryItemForTab(*tab); + if (item) { + AddItemToMenu(item, menu, kRecentlyClosed, index++); + ++added_count; + } + } + } +} + +void HistoryMenuBridge::TabRestoreServiceDestroyed( + TabRestoreService* service) { + // Intentionally left blank. We hold a weak reference to the service. +} + +HistoryMenuBridge::HistoryItem* HistoryMenuBridge::HistoryItemForMenuItem( + NSMenuItem* item) { + std::map<NSMenuItem*, HistoryItem*>::iterator it = menu_item_map_.find(item); + if (it != menu_item_map_.end()) { + return it->second; + } + return NULL; +} + +HistoryService* HistoryMenuBridge::service() { + return history_service_; +} + +Profile* HistoryMenuBridge::profile() { + return profile_; +} + +NSMenu* HistoryMenuBridge::HistoryMenu() { + NSMenu* history_menu = [[[NSApp mainMenu] itemWithTag:IDC_HISTORY_MENU] + submenu]; + return history_menu; +} + +void HistoryMenuBridge::ClearMenuSection(NSMenu* menu, NSInteger tag) { + for (NSMenuItem* menu_item in [menu itemArray]) { + if ([menu_item tag] == tag && [menu_item target] == controller_.get()) { + // This is an item that should be removed, so find the corresponding model + // item. + HistoryItem* item = HistoryItemForMenuItem(menu_item); + + // Cancel favicon requests that could hold onto stale pointers. Also + // remove the item from the mapping. + if (item) { + CancelFaviconRequest(item); + menu_item_map_.erase(menu_item); + delete item; + } + + // If this menu item has a submenu, recurse. + if ([menu_item hasSubmenu]) { + ClearMenuSection([menu_item submenu], tag + 1); + } + + // Now actually remove the item from the menu. + [menu removeItem:menu_item]; + } + } +} + +NSMenuItem* HistoryMenuBridge::AddItemToMenu(HistoryItem* item, + NSMenu* menu, + NSInteger tag, + NSInteger index) { + NSString* title = base::SysUTF16ToNSString(item->title); + std::string url_string = item->url.possibly_invalid_spec(); + + // If we don't have a title, use the URL. + if ([title isEqualToString:@""]) + title = base::SysUTF8ToNSString(url_string); + NSString* full_title = title; + if ([title length] > kMaximumMenuWidthInChars) { + // TODO(rsesek): use app/text_elider.h once it uses string16 and can + // take out the middle of strings. + title = [NSString stringWithFormat:@"%@…%@", + [title substringToIndex:kMenuTrimSizeInChars], + [title substringFromIndex:([title length] - + kMenuTrimSizeInChars)]]; + } + item->menu_item.reset( + [[NSMenuItem alloc] initWithTitle:title + action:nil + keyEquivalent:@""]); + [item->menu_item setTarget:controller_]; + [item->menu_item setAction:@selector(openHistoryMenuItem:)]; + [item->menu_item setTag:tag]; + if (item->icon.get()) + [item->menu_item setImage:item->icon.get()]; + else if (!item->tabs.size()) + [item->menu_item setImage:default_favicon_.get()]; + + // Add a tooltip. + NSString* tooltip = [NSString stringWithFormat:@"%@\n%s", full_title, + url_string.c_str()]; + [item->menu_item setToolTip:tooltip]; + + [menu insertItem:item->menu_item.get() atIndex:index]; + menu_item_map_.insert(std::make_pair(item->menu_item.get(), item)); + + return item->menu_item.get(); +} + +void HistoryMenuBridge::Init() { + const NotificationSource& source = NotificationService::AllSources(); + registrar_.Add(this, NotificationType::HISTORY_TYPED_URLS_MODIFIED, source); + registrar_.Add(this, NotificationType::HISTORY_URL_VISITED, source); + registrar_.Add(this, NotificationType::HISTORY_URLS_DELETED, source); +} + +void HistoryMenuBridge::CreateMenu() { + // If we're currently running CreateMenu(), wait until it finishes. + if (create_in_progress_) + return; + create_in_progress_ = true; + need_recreate_ = false; + + history_service_->QuerySegmentUsageSince( + &cancelable_request_consumer_, + base::Time::Now() - base::TimeDelta::FromDays(kMostVisitedScope), + kMostVisitedCount, + NewCallback(this, &HistoryMenuBridge::OnVisitedHistoryResults)); +} + +void HistoryMenuBridge::OnVisitedHistoryResults( + CancelableRequestProvider::Handle handle, + std::vector<PageUsageData*>* results) { + NSMenu* menu = HistoryMenu(); + ClearMenuSection(menu, kMostVisited); + NSInteger top_item = [menu indexOfItemWithTag:kMostVisitedTitle] + 1; + + size_t count = results->size(); + for (size_t i = 0; i < count; ++i) { + PageUsageData* history_item = (*results)[i]; + + HistoryItem* item = new HistoryItem(); + item->title = history_item->GetTitle(); + item->url = history_item->GetURL(); + if (history_item->HasFavIcon()) { + const SkBitmap* icon = history_item->GetFavIcon(); + item->icon.reset([gfx::SkBitmapToNSImage(*icon) retain]); + } else { + GetFaviconForHistoryItem(item); + } + // This will add |item| to the |menu_item_map_|, which takes ownership. + AddItemToMenu(item, HistoryMenu(), kMostVisited, top_item + i); + } + + // We are already invalid by the time we finished, darn. + if (need_recreate_) + CreateMenu(); + + create_in_progress_ = false; +} + +HistoryMenuBridge::HistoryItem* HistoryMenuBridge::HistoryItemForTab( + const TabRestoreService::Tab& entry) { + if (entry.navigations.empty()) + return NULL; + + const TabNavigation& current_navigation = + entry.navigations.at(entry.current_navigation_index); + if (current_navigation.virtual_url() == GURL(chrome::kChromeUINewTabURL)) + return NULL; + + HistoryItem* item = new HistoryItem(); + item->title = current_navigation.title(); + item->url = current_navigation.virtual_url(); + item->session_id = entry.id; + + // Tab navigations don't come with icons, so we always have to request them. + GetFaviconForHistoryItem(item); + + return item; +} + +void HistoryMenuBridge::GetFaviconForHistoryItem(HistoryItem* item) { + FaviconService* service = + profile_->GetFaviconService(Profile::EXPLICIT_ACCESS); + FaviconService::Handle handle = service->GetFaviconForURL(item->url, + &favicon_consumer_, + NewCallback(this, &HistoryMenuBridge::GotFaviconData)); + favicon_consumer_.SetClientData(service, handle, item); + item->icon_handle = handle; + item->icon_requested = true; +} + +void HistoryMenuBridge::GotFaviconData(FaviconService::Handle handle, + bool know_favicon, + scoped_refptr<RefCountedMemory> data, + bool expired, + GURL url) { + // Since we're going to do Cocoa-y things, make sure this is the main thread. + DCHECK([NSThread isMainThread]); + + HistoryItem* item = + favicon_consumer_.GetClientData( + profile_->GetFaviconService(Profile::EXPLICIT_ACCESS), handle); + DCHECK(item); + item->icon_requested = false; + item->icon_handle = NULL; + + // Convert the raw data to Skia and then to a NSImage. + // TODO(rsesek): Is there an easier way to do this? + SkBitmap icon; + if (know_favicon && data.get() && data->size() && + gfx::PNGCodec::Decode(data->front(), data->size(), &icon)) { + NSImage* image = gfx::SkBitmapToNSImage(icon); + if (image) { + // The conversion was successful. + item->icon.reset([image retain]); + [item->menu_item setImage:item->icon.get()]; + } + } +} + +void HistoryMenuBridge::CancelFaviconRequest(HistoryItem* item) { + DCHECK(item); + if (item->icon_requested) { + FaviconService* service = + profile_->GetFaviconService(Profile::EXPLICIT_ACCESS); + service->CancelRequest(item->icon_handle); + item->icon_requested = false; + item->icon_handle = NULL; + } +} diff --git a/chrome/browser/ui/cocoa/history_menu_bridge_unittest.mm b/chrome/browser/ui/cocoa/history_menu_bridge_unittest.mm new file mode 100644 index 0000000..843e964 --- /dev/null +++ b/chrome/browser/ui/cocoa/history_menu_bridge_unittest.mm @@ -0,0 +1,386 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> +#include <vector> + +#include "base/ref_counted_memory.h" +#include "base/string_util.h" +#include "base/sys_string_conversions.h" +#include "base/utf_string_conversions.h" +#include "chrome/app/chrome_command_ids.h" +#include "chrome/browser/cancelable_request.h" +#include "chrome/browser/sessions/tab_restore_service.h" +#include "chrome/browser/ui/browser.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#include "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "chrome/browser/ui/cocoa/history_menu_bridge.h" +#include "gfx/codec/png_codec.h" +#include "testing/gmock/include/gmock/gmock.h" +#include "testing/gtest/include/gtest/gtest.h" +#import "testing/gtest_mac.h" + +namespace { + +class MockTRS : public TabRestoreService { + public: + MockTRS(Profile* profile) : TabRestoreService(profile, NULL) {} + MOCK_CONST_METHOD0(entries, const TabRestoreService::Entries&()); +}; + +class MockBridge : public HistoryMenuBridge { + public: + MockBridge(Profile* profile) + : HistoryMenuBridge(profile), + menu_([[NSMenu alloc] initWithTitle:@"History"]) {} + + virtual NSMenu* HistoryMenu() { + return menu_.get(); + } + + private: + scoped_nsobject<NSMenu> menu_; +}; + +class HistoryMenuBridgeTest : public CocoaTest { + public: + + virtual void SetUp() { + CocoaTest::SetUp(); + browser_test_helper_.profile()->CreateFaviconService(); + bridge_.reset(new MockBridge(browser_test_helper_.profile())); + } + + // We are a friend of HistoryMenuBridge (and have access to + // protected methods), but none of the classes generated by TEST_F() + // are. Wraps common commands. + void ClearMenuSection(NSMenu* menu, + NSInteger tag) { + bridge_->ClearMenuSection(menu, tag); + } + + void AddItemToBridgeMenu(HistoryMenuBridge::HistoryItem* item, + NSMenu* menu, + NSInteger tag, + NSInteger index) { + bridge_->AddItemToMenu(item, menu, tag, index); + } + + NSMenuItem* AddItemToMenu(NSMenu* menu, + NSString* title, + SEL selector, + int tag) { + NSMenuItem* item = [[[NSMenuItem alloc] initWithTitle:title action:NULL + keyEquivalent:@""] autorelease]; + [item setTag:tag]; + if (selector) { + [item setAction:selector]; + [item setTarget:bridge_->controller_.get()]; + } + [menu addItem:item]; + return item; + } + + HistoryMenuBridge::HistoryItem* CreateItem(const string16& title) { + HistoryMenuBridge::HistoryItem* item = + new HistoryMenuBridge::HistoryItem(); + item->title = title; + item->url = GURL(title); + return item; + } + + MockTRS::Tab CreateSessionTab(const GURL& url, const string16& title) { + MockTRS::Tab tab; + tab.current_navigation_index = 0; + tab.navigations.push_back( + TabNavigation(0, url, GURL(), title, std::string(), + PageTransition::LINK)); + return tab; + } + + void GetFaviconForHistoryItem(HistoryMenuBridge::HistoryItem* item) { + bridge_->GetFaviconForHistoryItem(item); + } + + void GotFaviconData(FaviconService::Handle handle, + bool know_favicon, + scoped_refptr<RefCountedBytes> data, + bool expired, + GURL url) { + bridge_->GotFaviconData(handle, know_favicon, data, expired, url); + } + + CancelableRequestConsumerTSimple<HistoryMenuBridge::HistoryItem*>& + favicon_consumer() { + return bridge_->favicon_consumer_; + } + + BrowserTestHelper browser_test_helper_; + scoped_ptr<MockBridge> bridge_; +}; + +// Edge case test for clearing until the end of a menu. +TEST_F(HistoryMenuBridgeTest, ClearHistoryMenuUntilEnd) { + NSMenu* menu = [[[NSMenu alloc] initWithTitle:@"history foo"] autorelease]; + AddItemToMenu(menu, @"HEADER", NULL, HistoryMenuBridge::kMostVisitedTitle); + + NSInteger tag = HistoryMenuBridge::kMostVisited; + AddItemToMenu(menu, @"alpha", @selector(openHistoryMenuItem:), tag); + AddItemToMenu(menu, @"bravo", @selector(openHistoryMenuItem:), tag); + AddItemToMenu(menu, @"charlie", @selector(openHistoryMenuItem:), tag); + AddItemToMenu(menu, @"delta", @selector(openHistoryMenuItem:), tag); + + ClearMenuSection(menu, HistoryMenuBridge::kMostVisited); + + EXPECT_EQ(1, [menu numberOfItems]); + EXPECT_NSEQ(@"HEADER", + [[menu itemWithTag:HistoryMenuBridge::kMostVisitedTitle] title]); +} + +// Skip menu items that are not hooked up to |-openHistoryMenuItem:|. +TEST_F(HistoryMenuBridgeTest, ClearHistoryMenuSkipping) { + NSMenu* menu = [[[NSMenu alloc] initWithTitle:@"history foo"] autorelease]; + AddItemToMenu(menu, @"HEADER", NULL, HistoryMenuBridge::kMostVisitedTitle); + + NSInteger tag = HistoryMenuBridge::kMostVisited; + AddItemToMenu(menu, @"alpha", @selector(openHistoryMenuItem:), tag); + AddItemToMenu(menu, @"bravo", @selector(openHistoryMenuItem:), tag); + AddItemToMenu(menu, @"TITLE", NULL, HistoryMenuBridge::kRecentlyClosedTitle); + AddItemToMenu(menu, @"charlie", @selector(openHistoryMenuItem:), tag); + + ClearMenuSection(menu, tag); + + EXPECT_EQ(2, [menu numberOfItems]); + EXPECT_NSEQ(@"HEADER", + [[menu itemWithTag:HistoryMenuBridge::kMostVisitedTitle] title]); + EXPECT_NSEQ(@"TITLE", + [[menu itemAtIndex:1] title]); +} + +// Edge case test for clearing an empty menu. +TEST_F(HistoryMenuBridgeTest, ClearHistoryMenuEmpty) { + NSMenu* menu = [[[NSMenu alloc] initWithTitle:@"history foo"] autorelease]; + AddItemToMenu(menu, @"HEADER", NULL, HistoryMenuBridge::kMostVisited); + + ClearMenuSection(menu, HistoryMenuBridge::kMostVisited); + + EXPECT_EQ(1, [menu numberOfItems]); + EXPECT_NSEQ(@"HEADER", + [[menu itemWithTag:HistoryMenuBridge::kMostVisited] title]); +} + +// Test that AddItemToMenu() properly adds HistoryItem objects as menus. +TEST_F(HistoryMenuBridgeTest, AddItemToMenu) { + NSMenu* menu = [[[NSMenu alloc] initWithTitle:@"history foo"] autorelease]; + + const string16 short_url = ASCIIToUTF16("http://foo/"); + const string16 long_url = ASCIIToUTF16("http://super-duper-long-url--." + "that.cannot.possibly.fit.even-in-80-columns" + "or.be.reasonably-displayed-in-a-menu" + "without.looking-ridiculous.com/"); // 140 chars total + + // HistoryItems are owned by the HistoryMenuBridge when AddItemToBridgeMenu() + // is called, which places them into the |menu_item_map_|, which owns them. + HistoryMenuBridge::HistoryItem* item1 = CreateItem(short_url); + AddItemToBridgeMenu(item1, menu, 100, 0); + + HistoryMenuBridge::HistoryItem* item2 = CreateItem(long_url); + AddItemToBridgeMenu(item2, menu, 101, 1); + + EXPECT_EQ(2, [menu numberOfItems]); + + EXPECT_EQ(@selector(openHistoryMenuItem:), [[menu itemAtIndex:0] action]); + EXPECT_EQ(@selector(openHistoryMenuItem:), [[menu itemAtIndex:1] action]); + + EXPECT_EQ(100, [[menu itemAtIndex:0] tag]); + EXPECT_EQ(101, [[menu itemAtIndex:1] tag]); + + // Make sure a short title looks fine + NSString* s = [[menu itemAtIndex:0] title]; + EXPECT_EQ(base::SysNSStringToUTF16(s), short_url); + + // Make sure a super-long title gets trimmed + s = [[menu itemAtIndex:0] title]; + EXPECT_TRUE([s length] < long_url.length()); + + // Confirm tooltips and confirm they are not trimmed (like the item + // name might be). Add tolerance for URL fixer-upping; + // e.g. http://foo becomes http://foo/) + EXPECT_GE([[[menu itemAtIndex:0] toolTip] length], (2*short_url.length()-5)); + EXPECT_GE([[[menu itemAtIndex:1] toolTip] length], (2*long_url.length()-5)); +} + +// Test that the menu is created for a set of simple tabs. +TEST_F(HistoryMenuBridgeTest, RecentlyClosedTabs) { + scoped_refptr<MockTRS> trs(new MockTRS(browser_test_helper_.profile())); + MockTRS::Entries entries; + + MockTRS::Tab tab1 = CreateSessionTab(GURL("http://google.com"), + ASCIIToUTF16("Google")); + tab1.id = 24; + entries.push_back(&tab1); + + MockTRS::Tab tab2 = CreateSessionTab(GURL("http://apple.com"), + ASCIIToUTF16("Apple")); + tab2.id = 42; + entries.push_back(&tab2); + + using ::testing::ReturnRef; + EXPECT_CALL(*trs.get(), entries()).WillOnce(ReturnRef(entries)); + + bridge_->TabRestoreServiceChanged(trs.get()); + + NSMenu* menu = bridge_->HistoryMenu(); + ASSERT_EQ(2U, [[menu itemArray] count]); + + NSMenuItem* item1 = [menu itemAtIndex:0]; + MockBridge::HistoryItem* hist1 = bridge_->HistoryItemForMenuItem(item1); + EXPECT_TRUE(hist1); + EXPECT_EQ(24, hist1->session_id); + EXPECT_NSEQ(@"Google", [item1 title]); + + NSMenuItem* item2 = [menu itemAtIndex:1]; + MockBridge::HistoryItem* hist2 = bridge_->HistoryItemForMenuItem(item2); + EXPECT_TRUE(hist2); + EXPECT_EQ(42, hist2->session_id); + EXPECT_NSEQ(@"Apple", [item2 title]); +} + +// Test that the menu is created for a mix of windows and tabs. +TEST_F(HistoryMenuBridgeTest, RecentlyClosedTabsAndWindows) { + scoped_refptr<MockTRS> trs(new MockTRS(browser_test_helper_.profile())); + MockTRS::Entries entries; + + MockTRS::Tab tab1 = CreateSessionTab(GURL("http://google.com"), + ASCIIToUTF16("Google")); + tab1.id = 24; + entries.push_back(&tab1); + + MockTRS::Window win1; + win1.id = 30; + win1.tabs.push_back( + CreateSessionTab(GURL("http://foo.com"), ASCIIToUTF16("foo"))); + win1.tabs[0].id = 31; + win1.tabs.push_back( + CreateSessionTab(GURL("http://bar.com"), ASCIIToUTF16("bar"))); + win1.tabs[1].id = 32; + entries.push_back(&win1); + + MockTRS::Tab tab2 = CreateSessionTab(GURL("http://apple.com"), + ASCIIToUTF16("Apple")); + tab2.id = 42; + entries.push_back(&tab2); + + MockTRS::Window win2; + win2.id = 50; + win2.tabs.push_back( + CreateSessionTab(GURL("http://magic.com"), ASCIIToUTF16("magic"))); + win2.tabs[0].id = 51; + win2.tabs.push_back( + CreateSessionTab(GURL("http://goats.com"), ASCIIToUTF16("goats"))); + win2.tabs[1].id = 52; + win2.tabs.push_back( + CreateSessionTab(GURL("http://teleporter.com"), + ASCIIToUTF16("teleporter"))); + win2.tabs[1].id = 53; + entries.push_back(&win2); + + using ::testing::ReturnRef; + EXPECT_CALL(*trs.get(), entries()).WillOnce(ReturnRef(entries)); + + bridge_->TabRestoreServiceChanged(trs.get()); + + NSMenu* menu = bridge_->HistoryMenu(); + ASSERT_EQ(4U, [[menu itemArray] count]); + + NSMenuItem* item1 = [menu itemAtIndex:0]; + MockBridge::HistoryItem* hist1 = bridge_->HistoryItemForMenuItem(item1); + EXPECT_TRUE(hist1); + EXPECT_EQ(24, hist1->session_id); + EXPECT_NSEQ(@"Google", [item1 title]); + + NSMenuItem* item2 = [menu itemAtIndex:1]; + MockBridge::HistoryItem* hist2 = bridge_->HistoryItemForMenuItem(item2); + EXPECT_TRUE(hist2); + EXPECT_EQ(30, hist2->session_id); + EXPECT_EQ(2U, hist2->tabs.size()); + // Do not test menu item title because it is localized. + NSMenu* submenu1 = [item2 submenu]; + EXPECT_EQ(4U, [[submenu1 itemArray] count]); + // Do not test Restore All Tabs because it is localiced. + EXPECT_TRUE([[submenu1 itemAtIndex:1] isSeparatorItem]); + EXPECT_NSEQ(@"foo", [[submenu1 itemAtIndex:2] title]); + EXPECT_NSEQ(@"bar", [[submenu1 itemAtIndex:3] title]); + + NSMenuItem* item3 = [menu itemAtIndex:2]; + MockBridge::HistoryItem* hist3 = bridge_->HistoryItemForMenuItem(item3); + EXPECT_TRUE(hist3); + EXPECT_EQ(42, hist3->session_id); + EXPECT_NSEQ(@"Apple", [item3 title]); + + NSMenuItem* item4 = [menu itemAtIndex:3]; + MockBridge::HistoryItem* hist4 = bridge_->HistoryItemForMenuItem(item4); + EXPECT_TRUE(hist4); + EXPECT_EQ(50, hist4->session_id); + EXPECT_EQ(3U, hist4->tabs.size()); + // Do not test menu item title because it is localized. + NSMenu* submenu2 = [item4 submenu]; + EXPECT_EQ(5U, [[submenu2 itemArray] count]); + // Do not test Restore All Tabs because it is localiced. + EXPECT_TRUE([[submenu2 itemAtIndex:1] isSeparatorItem]); + EXPECT_NSEQ(@"magic", [[submenu2 itemAtIndex:2] title]); + EXPECT_NSEQ(@"goats", [[submenu2 itemAtIndex:3] title]); + EXPECT_NSEQ(@"teleporter", [[submenu2 itemAtIndex:4] title]); +} + +// Tests that we properly request an icon from the FaviconService. +TEST_F(HistoryMenuBridgeTest, GetFaviconForHistoryItem) { + // Create a fake item. + HistoryMenuBridge::HistoryItem item; + item.title = ASCIIToUTF16("Title"); + item.url = GURL("http://google.com"); + + // Request the icon. + GetFaviconForHistoryItem(&item); + + // Make sure that there is ClientData for the request. + std::vector<HistoryMenuBridge::HistoryItem*> data; + favicon_consumer().GetAllClientData(&data); + ASSERT_EQ(data.size(), 1U); + EXPECT_EQ(&item, data[0]); + + // Make sure the item was modified properly. + EXPECT_TRUE(item.icon_requested); + EXPECT_GT(item.icon_handle, 0); +} + +TEST_F(HistoryMenuBridgeTest, GotFaviconData) { + // Create a dummy bitmap. + SkBitmap bitmap; + bitmap.setConfig(SkBitmap::kARGB_8888_Config, 25, 25); + bitmap.allocPixels(); + bitmap.eraseRGB(255, 0, 0); + + // Convert it to raw PNG bytes. We totally ignore color order here because + // we just want to test the roundtrip through the Bridge, not that we can + // make icons look pretty. + std::vector<unsigned char> raw; + gfx::PNGCodec::EncodeBGRASkBitmap(bitmap, true, &raw); + scoped_refptr<RefCountedBytes> bytes(new RefCountedBytes(raw)); + + // Set up the HistoryItem. + HistoryMenuBridge::HistoryItem item; + item.menu_item.reset([[NSMenuItem alloc] init]); + GetFaviconForHistoryItem(&item); + + // Pretend to be called back. + GotFaviconData(item.icon_handle, true, bytes, false, GURL()); + + // Make sure the callback works. + EXPECT_FALSE(item.icon_requested); + EXPECT_TRUE(item.icon.get()); + EXPECT_TRUE([item.menu_item image]); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/history_menu_cocoa_controller.h b/chrome/browser/ui/cocoa/history_menu_cocoa_controller.h new file mode 100644 index 0000000..d91409e --- /dev/null +++ b/chrome/browser/ui/cocoa/history_menu_cocoa_controller.h @@ -0,0 +1,32 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// Controller (MVC) for the history menu. All history menu item commands get +// directed here. This class only responds to menu events, but the actual +// creation and maintenance of the menu happens in the Bridge. + +#ifndef CHROME_BROWSER_UI_COCOA_HISTORY_MENU_COCOA_CONTROLLER_H_ +#define CHROME_BROWSER_UI_COCOA_HISTORY_MENU_COCOA_CONTROLLER_H_ +#pragma once + +#import <Cocoa/Cocoa.h> +#import "chrome/browser/ui/cocoa/history_menu_bridge.h" + +@interface HistoryMenuCocoaController : NSObject { + @private + HistoryMenuBridge* bridge_; // weak; owns us +} + +- (id)initWithBridge:(HistoryMenuBridge*)bridge; + +// Called by any history menu item. +- (IBAction)openHistoryMenuItem:(id)sender; + +@end // HistoryMenuCocoaController + +@interface HistoryMenuCocoaController (ExposedForUnitTests) +- (void)openURLForItem:(const HistoryMenuBridge::HistoryItem*)node; +@end // HistoryMenuCocoaController (ExposedForUnitTests) + +#endif // CHROME_BROWSER_UI_COCOA_HISTORY_MENU_COCOA_CONTROLLER_H_ diff --git a/chrome/browser/ui/cocoa/history_menu_cocoa_controller.mm b/chrome/browser/ui/cocoa/history_menu_cocoa_controller.mm new file mode 100644 index 0000000..79aef69 --- /dev/null +++ b/chrome/browser/ui/cocoa/history_menu_cocoa_controller.mm @@ -0,0 +1,58 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/history_menu_cocoa_controller.h" + +#include "base/scoped_vector.h" +#include "chrome/app/chrome_command_ids.h" // IDC_HISTORY_MENU +#import "chrome/browser/app_controller_mac.h" +#include "chrome/browser/browser_process.h" +#include "chrome/browser/history/history.h" +#include "chrome/browser/history/history_types.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/tab_contents/tab_contents.h" +#include "chrome/browser/ui/browser.h" +#include "chrome/browser/ui/cocoa/event_utils.h" +#include "webkit/glue/window_open_disposition.h" + +@implementation HistoryMenuCocoaController + +- (id)initWithBridge:(HistoryMenuBridge*)bridge { + if ((self = [super init])) { + bridge_ = bridge; + DCHECK(bridge_); + } + return self; +} + +- (BOOL)validateMenuItem:(NSMenuItem*)menuItem { + AppController* controller = [NSApp delegate]; + return [controller keyWindowIsNotModal]; +} + +// Open the URL of the given history item in the current tab. +- (void)openURLForItem:(const HistoryMenuBridge::HistoryItem*)node { + Browser* browser = Browser::GetOrCreateTabbedBrowser(bridge_->profile()); + WindowOpenDisposition disposition = + event_utils::WindowOpenDispositionFromNSEvent([NSApp currentEvent]); + + // If this item can be restored using TabRestoreService, do so. Otherwise, + // just load the URL. + TabRestoreService* service = bridge_->profile()->GetTabRestoreService(); + if (node->session_id && service) { + service->RestoreEntryById(browser, node->session_id, false); + } else { + DCHECK(node->url.is_valid()); + browser->OpenURL(node->url, GURL(), disposition, + PageTransition::AUTO_BOOKMARK); + } +} + +- (IBAction)openHistoryMenuItem:(id)sender { + const HistoryMenuBridge::HistoryItem* item = + bridge_->HistoryItemForMenuItem(sender); + [self openURLForItem:item]; +} + +@end // HistoryMenuCocoaController diff --git a/chrome/browser/ui/cocoa/history_menu_cocoa_controller_unittest.mm b/chrome/browser/ui/cocoa/history_menu_cocoa_controller_unittest.mm new file mode 100644 index 0000000..26f7388 --- /dev/null +++ b/chrome/browser/ui/cocoa/history_menu_cocoa_controller_unittest.mm @@ -0,0 +1,91 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/scoped_nsobject.h" +#include "base/scoped_ptr.h" +#include "base/string_util.h" +#include "base/sys_string_conversions.h" +#include "chrome/app/chrome_command_ids.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#include "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "chrome/browser/ui/cocoa/history_menu_bridge.h" +#include "chrome/browser/ui/cocoa/history_menu_cocoa_controller.h" +#include "chrome/browser/ui/browser.h" +#include "testing/gtest/include/gtest/gtest.h" + +@interface FakeHistoryMenuController : HistoryMenuCocoaController { + @public + BOOL opened_[2]; +} +@end + +@implementation FakeHistoryMenuController + +- (id)initTest { + if ((self = [super init])) { + opened_[0] = NO; + opened_[1] = NO; + } + return self; +} + +- (void)openURLForItem:(HistoryMenuBridge::HistoryItem*)item { + opened_[item->session_id] = YES; +} + +@end // FakeHistoryMenuController + +class HistoryMenuCocoaControllerTest : public CocoaTest { + public: + + virtual void SetUp() { + CocoaTest::SetUp(); + bridge_.reset(new HistoryMenuBridge(browser_test_helper_.profile())); + bridge_->controller_.reset( + [[FakeHistoryMenuController alloc] initWithBridge:bridge_.get()]); + [controller() initTest]; + } + + void CreateItems(NSMenu* menu) { + HistoryMenuBridge::HistoryItem* item = new HistoryMenuBridge::HistoryItem(); + item->url = GURL("http://google.com"); + item->session_id = 0; + bridge_->AddItemToMenu(item, menu, HistoryMenuBridge::kMostVisited, 0); + + item = new HistoryMenuBridge::HistoryItem(); + item->url = GURL("http://apple.com"); + item->session_id = 1; + bridge_->AddItemToMenu(item, menu, HistoryMenuBridge::kMostVisited, 1); + } + + std::map<NSMenuItem*, HistoryMenuBridge::HistoryItem*>& menu_item_map() { + return bridge_->menu_item_map_; + } + + FakeHistoryMenuController* controller() { + return static_cast<FakeHistoryMenuController*>(bridge_->controller_.get()); + } + + private: + BrowserTestHelper browser_test_helper_; + scoped_ptr<HistoryMenuBridge> bridge_; +}; + +TEST_F(HistoryMenuCocoaControllerTest, OpenURLForItem) { + + scoped_nsobject<NSMenu> menu([[NSMenu alloc] initWithTitle:@"History"]); + CreateItems(menu.get()); + + std::map<NSMenuItem*, HistoryMenuBridge::HistoryItem*>& items = + menu_item_map(); + std::map<NSMenuItem*, HistoryMenuBridge::HistoryItem*>::iterator it = + items.begin(); + + for ( ; it != items.end(); ++it) { + HistoryMenuBridge::HistoryItem* item = it->second; + EXPECT_FALSE(controller()->opened_[item->session_id]); + [controller() openHistoryMenuItem:it->first]; + EXPECT_TRUE(controller()->opened_[item->session_id]); + } +} diff --git a/chrome/browser/ui/cocoa/hover_button.h b/chrome/browser/ui/cocoa/hover_button.h new file mode 100644 index 0000000..e411434 --- /dev/null +++ b/chrome/browser/ui/cocoa/hover_button.h @@ -0,0 +1,35 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" + +// A button that changes when you hover over it and click it. +@interface HoverButton : NSButton { + @protected + // Enumeration of the hover states that the close button can be in at any one + // time. The button cannot be in more than one hover state at a time. + enum HoverState { + kHoverStateNone = 0, + kHoverStateMouseOver = 1, + kHoverStateMouseDown = 2 + }; + + HoverState hoverState_; + + @private + // Tracking area for button mouseover states. + scoped_nsobject<NSTrackingArea> trackingArea_; +} + +// Enables or disables the |NSTrackingRect|s for the button. +- (void)setTrackingEnabled:(BOOL)enabled; + +// Checks to see whether the mouse is in the button's bounds and update +// the image in case it gets out of sync. This occurs to the close button +// when you close a tab so the tab to the left of it takes its place, and +// drag the button without moving the mouse before you press the button down. +- (void)checkImageState; +@end diff --git a/chrome/browser/ui/cocoa/hover_button.mm b/chrome/browser/ui/cocoa/hover_button.mm new file mode 100644 index 0000000..9d7a412 --- /dev/null +++ b/chrome/browser/ui/cocoa/hover_button.mm @@ -0,0 +1,97 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/hover_button.h" + +@implementation HoverButton + +- (id)initWithFrame:(NSRect)frameRect { + if ((self = [super initWithFrame:frameRect])) { + [self setTrackingEnabled:YES]; + hoverState_ = kHoverStateNone; + [self updateTrackingAreas]; + } + return self; +} + +- (void)awakeFromNib { + [self setTrackingEnabled:YES]; + hoverState_ = kHoverStateNone; + [self updateTrackingAreas]; +} + +- (void)dealloc { + [self setTrackingEnabled:NO]; + [super dealloc]; +} + +- (void)mouseEntered:(NSEvent*)theEvent { + hoverState_ = kHoverStateMouseOver; + [self setNeedsDisplay:YES]; +} + +- (void)mouseExited:(NSEvent*)theEvent { + hoverState_ = kHoverStateNone; + [self setNeedsDisplay:YES]; +} + +- (void)mouseDown:(NSEvent*)theEvent { + hoverState_ = kHoverStateMouseDown; + [self setNeedsDisplay:YES]; + // The hover button needs to hold onto itself here for a bit. Otherwise, + // it can be freed while |super mouseDown:| is in it's loop, and the + // |checkImageState| call will crash. + // http://crbug.com/28220 + scoped_nsobject<HoverButton> myself([self retain]); + + [super mouseDown:theEvent]; + // We need to check the image state after the mouseDown event loop finishes. + // It's possible that we won't get a mouseExited event if the button was + // moved under the mouse during tab resize, instead of the mouse moving over + // the button. + // http://crbug.com/31279 + [self checkImageState]; +} + +- (void)setTrackingEnabled:(BOOL)enabled { + if (enabled) { + trackingArea_.reset( + [[NSTrackingArea alloc] initWithRect:[self bounds] + options:NSTrackingMouseEnteredAndExited | + NSTrackingActiveAlways + owner:self + userInfo:nil]); + [self addTrackingArea:trackingArea_.get()]; + + // If you have a separate window that overlaps the close button, and you + // move the mouse directly over the close button without entering another + // part of the tab strip, we don't get any mouseEntered event since the + // tracking area was disabled when we entered. + [self checkImageState]; + } else { + if (trackingArea_.get()) { + [self removeTrackingArea:trackingArea_.get()]; + trackingArea_.reset(nil); + } + } +} + +- (void)updateTrackingAreas { + [super updateTrackingAreas]; + [self checkImageState]; +} + +- (void)checkImageState { + if (!trackingArea_.get()) + return; + + // Update the button's state if the button has moved. + NSPoint mouseLoc = [[self window] mouseLocationOutsideOfEventStream]; + mouseLoc = [self convertPoint:mouseLoc fromView:nil]; + hoverState_ = NSPointInRect(mouseLoc, [self bounds]) ? + kHoverStateMouseOver : kHoverStateNone; + [self setNeedsDisplay:YES]; +} + +@end diff --git a/chrome/browser/ui/cocoa/hover_close_button.h b/chrome/browser/ui/cocoa/hover_close_button.h new file mode 100644 index 0000000..372582c --- /dev/null +++ b/chrome/browser/ui/cocoa/hover_close_button.h @@ -0,0 +1,26 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#include "chrome/browser/ui/cocoa/hover_button.h" + +// The standard close button for our Mac UI which is the "x" that changes to a +// dark circle with the "x" when you hover over it. At this time it is used by +// the popup blocker, download bar, info bar and tabs. +@interface HoverCloseButton : HoverButton { + @private + // Bezier path for drawing the 'x' within the button. + scoped_nsobject<NSBezierPath> xPath_; + + // Bezier path for drawing the hover state circle behind the 'x'. + scoped_nsobject<NSBezierPath> circlePath_; +} + +// Sets up the button's tracking areas and accessibility info when instantiated +// via initWithFrame or awakeFromNib. +- (void)commonInit; + +@end diff --git a/chrome/browser/ui/cocoa/hover_close_button.mm b/chrome/browser/ui/cocoa/hover_close_button.mm new file mode 100644 index 0000000..f8e29e2 --- /dev/null +++ b/chrome/browser/ui/cocoa/hover_close_button.mm @@ -0,0 +1,108 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/hover_close_button.h" + +#include "app/l10n_util.h" +#include "base/scoped_nsobject.h" +#include "grit/generated_resources.h" +#import "third_party/molokocacao/NSBezierPath+MCAdditions.h" + +namespace { +// Convenience function to return the middle point of the given |rect|. +static NSPoint MidRect(NSRect rect) { + return NSMakePoint(NSMidX(rect), NSMidY(rect)); +} + +const CGFloat kCircleRadiusPercentage = 0.415; +const CGFloat kCircleHoverWhite = 0.565; +const CGFloat kCircleClickWhite = 0.396; +const CGFloat kXShadowAlpha = 0.75; +const CGFloat kXShadowCircleAlpha = 0.1; +} // namespace + +@interface HoverCloseButton(Private) +- (void)setUpDrawingPaths; +@end + +@implementation HoverCloseButton + +- (id)initWithFrame:(NSRect)frameRect { + if ((self = [super initWithFrame:frameRect])) { + [self commonInit]; + } + return self; +} + +- (void)awakeFromNib { + [super awakeFromNib]; + [self commonInit]; +} + +- (void)drawRect:(NSRect)rect { + if (!circlePath_.get() || !xPath_.get()) + [self setUpDrawingPaths]; + + // If the user is hovering over the button, a light/dark gray circle is drawn + // behind the 'x'. + if (hoverState_ != kHoverStateNone) { + // Adjust the darkness of the circle depending on whether it is being + // clicked. + CGFloat white = (hoverState_ == kHoverStateMouseOver) ? + kCircleHoverWhite : kCircleClickWhite; + [[NSColor colorWithCalibratedWhite:white alpha:1.0] set]; + [circlePath_ fill]; + } + + [[NSColor whiteColor] set]; + [xPath_ fill]; + + // Give the 'x' an inner shadow for depth. If the button is in a hover state + // (circle behind it), then adjust the shadow accordingly (not as harsh). + NSShadow* shadow = [[[NSShadow alloc] init] autorelease]; + CGFloat alpha = (hoverState_ != kHoverStateNone) ? + kXShadowCircleAlpha : kXShadowAlpha; + [shadow setShadowColor:[NSColor colorWithCalibratedWhite:0.15 + alpha:alpha]]; + [shadow setShadowOffset:NSMakeSize(0.0, 0.0)]; + [shadow setShadowBlurRadius:2.5]; + [xPath_ fillWithInnerShadow:shadow]; +} + +- (void)commonInit { + // Set accessibility description. + NSString* description = l10n_util::GetNSStringWithFixup(IDS_ACCNAME_CLOSE); + [[self cell] + accessibilitySetOverrideValue:description + forAttribute:NSAccessibilityDescriptionAttribute]; +} + +- (void)setUpDrawingPaths { + NSPoint viewCenter = MidRect([self bounds]); + + circlePath_.reset([[NSBezierPath bezierPath] retain]); + [circlePath_ moveToPoint:viewCenter]; + CGFloat radius = kCircleRadiusPercentage * NSWidth([self bounds]); + [circlePath_ appendBezierPathWithArcWithCenter:viewCenter + radius:radius + startAngle:0.0 + endAngle:365.0]; + + // Construct an 'x' by drawing two intersecting rectangles in the shape of a + // cross and then rotating the path by 45 degrees. + xPath_.reset([[NSBezierPath bezierPath] retain]); + [xPath_ appendBezierPathWithRect:NSMakeRect(3.5, 7.0, 9.0, 2.0)]; + [xPath_ appendBezierPathWithRect:NSMakeRect(7.0, 3.5, 2.0, 9.0)]; + + NSPoint pathCenter = MidRect([xPath_ bounds]); + + NSAffineTransform* transform = [NSAffineTransform transform]; + [transform translateXBy:viewCenter.x yBy:viewCenter.y]; + [transform rotateByDegrees:45.0]; + [transform translateXBy:-pathCenter.x yBy:-pathCenter.y]; + + [xPath_ transformUsingAffineTransform:transform]; +} + +@end diff --git a/chrome/browser/ui/cocoa/hover_image_button.h b/chrome/browser/ui/cocoa/hover_image_button.h new file mode 100644 index 0000000..76a702d --- /dev/null +++ b/chrome/browser/ui/cocoa/hover_image_button.h @@ -0,0 +1,40 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#include "chrome/browser/ui/cocoa/hover_button.h" + +// A button that changes images when you hover over it and click it. +@interface HoverImageButton : HoverButton { + @private + float defaultOpacity_; + float hoverOpacity_; + float pressedOpacity_; + + scoped_nsobject<NSImage> defaultImage_; + scoped_nsobject<NSImage> hoverImage_; + scoped_nsobject<NSImage> pressedImage_; +} + +// Sets the default image. +- (void)setDefaultImage:(NSImage*)image; + +// Sets the hover image. +- (void)setHoverImage:(NSImage*)image; + +// Sets the pressed image. +- (void)setPressedImage:(NSImage*)image; + +// Sets the default opacity. +- (void)setDefaultOpacity:(float)opacity; + +// Sets the opacity on hover. +- (void)setHoverOpacity:(float)opacity; + +// Sets the opacity when pressed. +- (void)setPressedOpacity:(float)opacity; + +@end diff --git a/chrome/browser/ui/cocoa/hover_image_button.mm b/chrome/browser/ui/cocoa/hover_image_button.mm new file mode 100644 index 0000000..c5bdbf4 --- /dev/null +++ b/chrome/browser/ui/cocoa/hover_image_button.mm @@ -0,0 +1,52 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/hover_image_button.h" + +#include "app/l10n_util.h" +#include "base/scoped_nsobject.h" +#include "grit/generated_resources.h" + +@implementation HoverImageButton + +- (void)drawRect:(NSRect)rect { + if (hoverState_ == kHoverStateMouseDown && pressedImage_) { + [super setImage:pressedImage_.get()]; + [super setAlphaValue:pressedOpacity_]; + } else if (hoverState_ == kHoverStateMouseOver && hoverImage_) { + [super setImage:hoverImage_.get()]; + [super setAlphaValue:hoverOpacity_]; + } else { + [super setImage:defaultImage_.get()]; + [super setAlphaValue:defaultOpacity_]; + } + + [super drawRect:rect]; +} + +- (void)setDefaultImage:(NSImage*)image { + defaultImage_.reset([image retain]); +} + +- (void)setDefaultOpacity:(float)opacity { + defaultOpacity_ = opacity; +} + +- (void)setHoverImage:(NSImage*)image { + hoverImage_.reset([image retain]); +} + +- (void)setHoverOpacity:(float)opacity { + hoverOpacity_ = opacity; +} + +- (void)setPressedImage:(NSImage*)image { + pressedImage_.reset([image retain]); +} + +- (void)setPressedOpacity:(float)opacity { + pressedOpacity_ = opacity; +} + +@end diff --git a/chrome/browser/ui/cocoa/hover_image_button_unittest.mm b/chrome/browser/ui/cocoa/hover_image_button_unittest.mm new file mode 100644 index 0000000..d2db766 --- /dev/null +++ b/chrome/browser/ui/cocoa/hover_image_button_unittest.mm @@ -0,0 +1,67 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "app/resource_bundle.h" +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/hover_image_button.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" +#include "grit/theme_resources.h" + +namespace { + +class HoverImageButtonTest : public CocoaTest { + public: + HoverImageButtonTest() { + NSRect content_frame = [[test_window() contentView] frame]; + scoped_nsobject<HoverImageButton> button( + [[HoverImageButton alloc] initWithFrame:content_frame]); + button_ = button.get(); + [[test_window() contentView] addSubview:button_]; + } + + virtual void SetUp() { + CocoaTest::BootstrapCocoa(); + } + + HoverImageButton* button_; +}; + +// Test mouse events. +TEST_F(HoverImageButtonTest, ImageSwap) { + ResourceBundle& rb = ResourceBundle::GetSharedInstance(); + NSImage* image = rb.GetNativeImageNamed(IDR_HOME); + NSImage* hover = rb.GetNativeImageNamed(IDR_BACK); + [button_ setDefaultImage:image]; + [button_ setHoverImage:hover]; + + [button_ mouseEntered:nil]; + [button_ drawRect:[button_ frame]]; + EXPECT_EQ([button_ image], hover); + [button_ mouseExited:nil]; + [button_ drawRect:[button_ frame]]; + EXPECT_EQ([button_ image], image); +} + +// Test mouse events. +TEST_F(HoverImageButtonTest, Opacity) { + ResourceBundle& rb = ResourceBundle::GetSharedInstance(); + NSImage* image = rb.GetNativeImageNamed(IDR_HOME); + [button_ setDefaultImage:image]; + [button_ setDefaultOpacity:0.5]; + [button_ setHoverImage:image]; + [button_ setHoverOpacity:1.0]; + + [button_ mouseEntered:nil]; + [button_ drawRect:[button_ frame]]; + EXPECT_EQ([button_ alphaValue], 1.0); + [button_ mouseExited:nil]; + [button_ drawRect:[button_ frame]]; + EXPECT_EQ([button_ alphaValue], 0.5); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/html_dialog_window_controller.h b/chrome/browser/ui/cocoa/html_dialog_window_controller.h new file mode 100644 index 0000000..3fa41a3 --- /dev/null +++ b/chrome/browser/ui/cocoa/html_dialog_window_controller.h @@ -0,0 +1,55 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_HTML_DIALOG_WINDOW_CONTROLLER_H_ +#define CHROME_BROWSER_UI_COCOA_HTML_DIALOG_WINDOW_CONTROLLER_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "base/basictypes.h" +#include "base/scoped_ptr.h" +#include "chrome/browser/dom_ui/html_dialog_ui.h" + +class HtmlDialogWindowDelegateBridge; +class Profile; +class TabContents; + +// This controller manages a dialog box with properties and HTML content taken +// from a HTMLDialogUIDelegate object. +@interface HtmlDialogWindowController : NSWindowController { + @private + // Order here is important, as tab_contents_ may send messages to + // delegate_ when it gets destroyed. + scoped_ptr<HtmlDialogWindowDelegateBridge> delegate_; + scoped_ptr<TabContents> tabContents_; +} + +// Creates and shows an HtmlDialogWindowController with the given +// delegate and profile. The window is automatically destroyed when +// it is closed. Returns the created window. +// +// Make sure to use the returned window only when you know it is safe +// to do so, i.e. before OnDialogClosed() is called on the delegate. ++ (NSWindow*)showHtmlDialog:(HtmlDialogUIDelegate*)delegate + profile:(Profile*)profile; + +@end + +@interface HtmlDialogWindowController (TestingAPI) + +// This is the designated initializer. However, this is exposed only +// for testing; use showHtmlDialog instead. +- (id)initWithDelegate:(HtmlDialogUIDelegate*)delegate + profile:(Profile*)profile; + +// Loads the HTML content from the delegate; this is not a lightweight +// process which is why it is not part of the constructor. Must be +// called before showWindow. +- (void)loadDialogContents; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_HTML_DIALOG_WINDOW_CONTROLLER_H_ + diff --git a/chrome/browser/ui/cocoa/html_dialog_window_controller.mm b/chrome/browser/ui/cocoa/html_dialog_window_controller.mm new file mode 100644 index 0000000..aa219b28 --- /dev/null +++ b/chrome/browser/ui/cocoa/html_dialog_window_controller.mm @@ -0,0 +1,293 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/html_dialog_window_controller.h" + +#include "app/keyboard_codes.h" +#include "base/logging.h" +#include "base/scoped_nsobject.h" +#include "base/sys_string_conversions.h" +#include "chrome/browser/dom_ui/html_dialog_ui.h" +#include "chrome/browser/dom_ui/html_dialog_tab_contents_delegate.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/tab_contents/tab_contents.h" +#import "chrome/browser/ui/cocoa/browser_command_executor.h" +#import "chrome/browser/ui/cocoa/chrome_event_processing_window.h" +#include "chrome/common/native_web_keyboard_event.h" +#include "gfx/size.h" +#include "ipc/ipc_message.h" + +// Thin bridge that routes notifications to +// HtmlDialogWindowController's member variables. +class HtmlDialogWindowDelegateBridge : public HtmlDialogUIDelegate, + public HtmlDialogTabContentsDelegate { +public: + // All parameters must be non-NULL/non-nil. + HtmlDialogWindowDelegateBridge(HtmlDialogWindowController* controller, + Profile* profile, + HtmlDialogUIDelegate* delegate); + + virtual ~HtmlDialogWindowDelegateBridge(); + + // Called when the window is directly closed, e.g. from the close + // button or from an accelerator. + void WindowControllerClosed(); + + // HtmlDialogUIDelegate declarations. + virtual bool IsDialogModal() const; + virtual std::wstring GetDialogTitle() const; + virtual GURL GetDialogContentURL() const; + virtual void GetDOMMessageHandlers( + std::vector<DOMMessageHandler*>* handlers) const; + virtual void GetDialogSize(gfx::Size* size) const; + virtual std::string GetDialogArgs() const; + virtual void OnDialogClosed(const std::string& json_retval); + virtual void OnCloseContents(TabContents* source, bool* out_close_dialog) { } + virtual bool ShouldShowDialogTitle() const { return true; } + + // HtmlDialogTabContentsDelegate declarations. + virtual void MoveContents(TabContents* source, const gfx::Rect& pos); + virtual void ToolbarSizeChanged(TabContents* source, bool is_animating); + virtual void HandleKeyboardEvent(const NativeWebKeyboardEvent& event); + +private: + HtmlDialogWindowController* controller_; // weak + HtmlDialogUIDelegate* delegate_; // weak, owned by controller_ + + // Calls delegate_'s OnDialogClosed() exactly once, nulling it out + // afterwards so that no other HtmlDialogUIDelegate calls are sent + // to it. Returns whether or not the OnDialogClosed() was actually + // called on the delegate. + bool DelegateOnDialogClosed(const std::string& json_retval); + + DISALLOW_COPY_AND_ASSIGN(HtmlDialogWindowDelegateBridge); +}; + +// ChromeEventProcessingWindow expects its controller to implement the +// BrowserCommandExecutor protocol. +@interface HtmlDialogWindowController (InternalAPI) <BrowserCommandExecutor> + +// BrowserCommandExecutor methods. +- (void)executeCommand:(int)command; + +@end + +namespace html_dialog_window_controller { + +gfx::NativeWindow ShowHtmlDialog( + HtmlDialogUIDelegate* delegate, Profile* profile) { + return [HtmlDialogWindowController showHtmlDialog:delegate profile:profile]; +} + +} // namespace html_dialog_window_controller + +HtmlDialogWindowDelegateBridge::HtmlDialogWindowDelegateBridge( + HtmlDialogWindowController* controller, Profile* profile, + HtmlDialogUIDelegate* delegate) + : HtmlDialogTabContentsDelegate(profile), + controller_(controller), delegate_(delegate) { + DCHECK(controller_); + DCHECK(delegate_); +} + +HtmlDialogWindowDelegateBridge::~HtmlDialogWindowDelegateBridge() {} + +void HtmlDialogWindowDelegateBridge::WindowControllerClosed() { + Detach(); + controller_ = nil; + DelegateOnDialogClosed(""); +} + +bool HtmlDialogWindowDelegateBridge::DelegateOnDialogClosed( + const std::string& json_retval) { + if (delegate_) { + HtmlDialogUIDelegate* real_delegate = delegate_; + delegate_ = NULL; + real_delegate->OnDialogClosed(json_retval); + return true; + } + return false; +} + +// HtmlDialogUIDelegate definitions. + +// All of these functions check for NULL first since delegate_ is set +// to NULL when the window is closed. + +bool HtmlDialogWindowDelegateBridge::IsDialogModal() const { + // TODO(akalin): Support modal dialog boxes. + if (delegate_ && delegate_->IsDialogModal()) { + LOG(WARNING) << "Modal HTML dialogs are not supported yet"; + } + return false; +} + +std::wstring HtmlDialogWindowDelegateBridge::GetDialogTitle() const { + return delegate_ ? delegate_->GetDialogTitle() : L""; +} + +GURL HtmlDialogWindowDelegateBridge::GetDialogContentURL() const { + return delegate_ ? delegate_->GetDialogContentURL() : GURL(); +} + +void HtmlDialogWindowDelegateBridge::GetDOMMessageHandlers( + std::vector<DOMMessageHandler*>* handlers) const { + if (delegate_) { + delegate_->GetDOMMessageHandlers(handlers); + } else { + // TODO(akalin): Add this clause in the windows version. Also + // make sure that everything expects handlers to be non-NULL and + // document it. + handlers->clear(); + } +} + +void HtmlDialogWindowDelegateBridge::GetDialogSize(gfx::Size* size) const { + if (delegate_) { + delegate_->GetDialogSize(size); + } else { + *size = gfx::Size(); + } +} + +std::string HtmlDialogWindowDelegateBridge::GetDialogArgs() const { + return delegate_ ? delegate_->GetDialogArgs() : ""; +} + +void HtmlDialogWindowDelegateBridge::OnDialogClosed( + const std::string& json_retval) { + Detach(); + // [controller_ close] should be called at most once, too. + if (DelegateOnDialogClosed(json_retval)) { + [controller_ close]; + } + controller_ = nil; +} + +void HtmlDialogWindowDelegateBridge::MoveContents(TabContents* source, + const gfx::Rect& pos) { + // TODO(akalin): Actually set the window bounds. +} + +void HtmlDialogWindowDelegateBridge::ToolbarSizeChanged( + TabContents* source, bool is_animating) { + // TODO(akalin): Figure out what to do here. +} + +// A simplified version of BrowserWindowCocoa::HandleKeyboardEvent(). +// We don't handle global keyboard shortcuts here, but that's fine since +// they're all browser-specific. (This may change in the future.) +void HtmlDialogWindowDelegateBridge::HandleKeyboardEvent( + const NativeWebKeyboardEvent& event) { + if (event.skip_in_browser || event.type == NativeWebKeyboardEvent::Char) + return; + + // Close ourselves if the user hits Esc or Command-. . The normal + // way to do this is to implement (void)cancel:(int)sender, but + // since we handle keyboard events ourselves we can't do that. + // + // According to experiments, hitting Esc works regardless of the + // presence of other modifiers (as long as it's not an app-level + // shortcut, e.g. Commmand-Esc for Front Row) but no other modifiers + // can be present for Command-. to work. + // + // TODO(thakis): It would be nice to get cancel: to work somehow. + // Bug: http://code.google.com/p/chromium/issues/detail?id=32828 . + if (event.type == NativeWebKeyboardEvent::RawKeyDown && + ((event.windowsKeyCode == app::VKEY_ESCAPE) || + (event.windowsKeyCode == app::VKEY_OEM_PERIOD && + event.modifiers == NativeWebKeyboardEvent::MetaKey))) { + [controller_ close]; + return; + } + + ChromeEventProcessingWindow* event_window = + static_cast<ChromeEventProcessingWindow*>([controller_ window]); + DCHECK([event_window isKindOfClass:[ChromeEventProcessingWindow class]]); + [event_window redispatchKeyEvent:event.os_event]; +} + +@implementation HtmlDialogWindowController (InternalAPI) + +// This gets called whenever a chrome-specific keyboard shortcut is performed +// in the HTML dialog window. We simply swallow all those events. +- (void)executeCommand:(int)command {} + +@end + +@implementation HtmlDialogWindowController + +// NOTE(akalin): We'll probably have to add the parentWindow parameter back +// in once we implement modal dialogs. + ++ (NSWindow*)showHtmlDialog:(HtmlDialogUIDelegate*)delegate + profile:(Profile*)profile { + HtmlDialogWindowController* htmlDialogWindowController = + [[HtmlDialogWindowController alloc] initWithDelegate:delegate + profile:profile]; + [htmlDialogWindowController loadDialogContents]; + [htmlDialogWindowController showWindow:nil]; + return [htmlDialogWindowController window]; +} + +- (id)initWithDelegate:(HtmlDialogUIDelegate*)delegate + profile:(Profile*)profile { + DCHECK(delegate); + DCHECK(profile); + + gfx::Size dialogSize; + delegate->GetDialogSize(&dialogSize); + NSRect dialogRect = NSMakeRect(0, 0, dialogSize.width(), dialogSize.height()); + // TODO(akalin): Make the window resizable (but with the minimum size being + // dialog_size and always on top (but not modal) to match the Windows + // behavior. On the other hand, the fact that HTML dialogs on Windows + // are resizable could just be an accident. Investigate futher... + NSUInteger style = NSTitledWindowMask | NSClosableWindowMask; + scoped_nsobject<ChromeEventProcessingWindow> window( + [[ChromeEventProcessingWindow alloc] + initWithContentRect:dialogRect + styleMask:style + backing:NSBackingStoreBuffered + defer:YES]); + if (!window.get()) { + return nil; + } + self = [super initWithWindow:window]; + if (!self) { + return nil; + } + [window setWindowController:self]; + [window setDelegate:self]; + [window setTitle:base::SysWideToNSString(delegate->GetDialogTitle())]; + [window center]; + delegate_.reset(new HtmlDialogWindowDelegateBridge(self, profile, delegate)); + return self; +} + +- (void)loadDialogContents { + tabContents_.reset(new TabContents( + delegate_->profile(), NULL, MSG_ROUTING_NONE, NULL, NULL)); + [[self window] setContentView:tabContents_->GetNativeView()]; + tabContents_->set_delegate(delegate_.get()); + + // This must be done before loading the page; see the comments in + // HtmlDialogUI. + HtmlDialogUI::GetPropertyAccessor().SetProperty(tabContents_->property_bag(), + delegate_.get()); + + tabContents_->controller().LoadURL(delegate_->GetDialogContentURL(), + GURL(), PageTransition::START_PAGE); + + // TODO(akalin): add accelerator for ESC to close the dialog box. + // + // TODO(akalin): Figure out why implementing (void)cancel:(id)sender + // to do the above doesn't work. +} + +- (void)windowWillClose:(NSNotification*)notification { + delegate_->WindowControllerClosed(); + [self autorelease]; +} + +@end diff --git a/chrome/browser/ui/cocoa/html_dialog_window_controller_cppsafe.h b/chrome/browser/ui/cocoa/html_dialog_window_controller_cppsafe.h new file mode 100644 index 0000000..dd3b809 --- /dev/null +++ b/chrome/browser/ui/cocoa/html_dialog_window_controller_cppsafe.h @@ -0,0 +1,32 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_HTML_DIALOG_WINDOW_CONTROLLER_CPPSAFE_H_ +#define CHROME_BROWSER_UI_COCOA_HTML_DIALOG_WINDOW_CONTROLLER_CPPSAFE_H_ +#pragma once + +#include "gfx/native_widget_types.h" + +// We declare this in a separate file that is safe for including in C++ code. + +// TODO(akalin): It would be nice if there were a platform-agnostic way to +// create a browser-independent HTML dialog. However, this would require +// some invasive changes on the Windows/Linux side. Remove this file once +// We have this platform-agnostic API. + +namespace html_dialog_window_controller { + +// Creates and shows an HtmlDialogWindowController with the given +// delegate and profile. The window is automatically destroyed when it is +// closed. Returns the created window. +// +// Make sure to use the returned window only when you know it is safe +// to do so, i.e. before OnDialogClosed() is called on the delegate. +gfx::NativeWindow ShowHtmlDialog( + HtmlDialogUIDelegate* delegate, Profile* profile); + +} // namespace html_dialog_window_controller + +#endif // CHROME_BROWSER_UI_COCOA_HTML_DIALOG_WINDOW_CONTROLLER_CPPSAFE_H_ + diff --git a/chrome/browser/ui/cocoa/html_dialog_window_controller_unittest.mm b/chrome/browser/ui/cocoa/html_dialog_window_controller_unittest.mm new file mode 100644 index 0000000..7bbeb31 --- /dev/null +++ b/chrome/browser/ui/cocoa/html_dialog_window_controller_unittest.mm @@ -0,0 +1,94 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/html_dialog_window_controller.h" + +#include <string> +#include <vector> + +#import <Cocoa/Cocoa.h> + +#import "base/mac/scoped_nsautorelease_pool.h" +#include "base/sys_string_conversions.h" +#include "chrome/browser/dom_ui/dom_ui.h" +#include "chrome/browser/dom_ui/html_dialog_ui.h" +#include "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "chrome/test/browser_with_test_window_test.h" +#include "chrome/test/testing_profile.h" +#include "gfx/size.h" +#include "googleurl/src/gurl.h" +#include "testing/gmock/include/gmock/gmock.h" +#include "testing/gtest/include/gtest/gtest.h" + +namespace { + +class MockDelegate : public HtmlDialogUIDelegate { +public: + MOCK_CONST_METHOD0(IsDialogModal, bool()); + MOCK_CONST_METHOD0(GetDialogTitle, std::wstring()); + MOCK_CONST_METHOD0(GetDialogContentURL, GURL()); + MOCK_CONST_METHOD1(GetDOMMessageHandlers, + void(std::vector<DOMMessageHandler*>*)); + MOCK_CONST_METHOD1(GetDialogSize, void(gfx::Size*)); + MOCK_CONST_METHOD0(GetDialogArgs, std::string()); + MOCK_METHOD1(OnDialogClosed, void(const std::string& json_retval)); + MOCK_METHOD2(OnCloseContents, + void(TabContents* source, bool* out_close_dialog)); + MOCK_CONST_METHOD0(ShouldShowDialogTitle, bool()); +}; + +class HtmlDialogWindowControllerTest : public BrowserWithTestWindowTest { + public: + virtual void SetUp() { + BrowserWithTestWindowTest::SetUp(); + CocoaTest::BootstrapCocoa(); + title_ = L"Mock Title"; + size_ = gfx::Size(50, 100); + gurl_ = GURL(""); + } + + protected: + std::wstring title_; + gfx::Size size_; + GURL gurl_; + + // Order here is important. + MockDelegate delegate_; +}; + +using ::testing::_; +using ::testing::Return; +using ::testing::SetArgumentPointee; + +// TODO(akalin): We can't test much more than the below without a real browser. +// In particular, GetDOMMessageHandlers() and GetDialogArgs() are never called. +// This should be fixed. + +TEST_F(HtmlDialogWindowControllerTest, showDialog) { + // We want to make sure html_dialog_window_controller below gets + // destroyed before delegate_, so we specify our own autorelease pool. + // + // TODO(dmaclach): Remove this once + // http://code.google.com/p/chromium/issues/detail?id=26133 is fixed. + base::mac::ScopedNSAutoreleasePool release_pool; + + EXPECT_CALL(delegate_, GetDialogTitle()) + .WillOnce(Return(title_)); + EXPECT_CALL(delegate_, GetDialogSize(_)) + .WillOnce(SetArgumentPointee<0>(size_)); + EXPECT_CALL(delegate_, GetDialogContentURL()) + .WillOnce(Return(gurl_)); + EXPECT_CALL(delegate_, OnDialogClosed(_)) + .Times(1); + + HtmlDialogWindowController* html_dialog_window_controller = + [[HtmlDialogWindowController alloc] initWithDelegate:&delegate_ + profile:profile()]; + + [html_dialog_window_controller loadDialogContents]; + [html_dialog_window_controller showWindow:nil]; + [html_dialog_window_controller close]; +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/hung_renderer_controller.h b/chrome/browser/ui/cocoa/hung_renderer_controller.h new file mode 100644 index 0000000..0d45c0f --- /dev/null +++ b/chrome/browser/ui/cocoa/hung_renderer_controller.h @@ -0,0 +1,76 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_HUNG_RENDERER_CONTROLLER_H_ +#define CHROME_BROWSER_UI_COCOA_HUNG_RENDERER_CONTROLLER_H_ +#pragma once + +// A controller for the Mac hung renderer dialog window. Only one +// instance of this controller can exist at any time, although a given +// controller is destroyed when its window is closed. +// +// The dialog itself displays a list of frozen tabs, all of which +// share a render process. Since there can only be a single dialog +// open at a time, if showForTabContents is called for a different +// tab, the dialog is repurposed to show a warning for the new tab. +// +// The caller is required to call endForTabContents before deleting +// any TabContents object. + +#import <Cocoa/Cocoa.h> + +#import "base/cocoa_protocols_mac.h" +#import "base/scoped_nsobject.h" + +@class MultiKeyEquivalentButton; +class TabContents; + +@interface HungRendererController : NSWindowController<NSTableViewDataSource> { + @private + IBOutlet MultiKeyEquivalentButton* waitButton_; + IBOutlet NSButton* killButton_; + IBOutlet NSTableView* tableView_; + IBOutlet NSImageView* imageView_; + IBOutlet NSTextField* messageView_; + + // The TabContents for which this dialog is open. Should never be + // NULL while this dialog is open. + TabContents* hungContents_; + + // Backing data for |tableView_|. Titles of each TabContents that + // shares a renderer process with |hungContents_|. + scoped_nsobject<NSArray> hungTitles_; + + // Favicons of each TabContents that shares a renderer process with + // |hungContents_|. + scoped_nsobject<NSArray> hungFavicons_; +} + +// Kills the hung renderers. +- (IBAction)kill:(id)sender; + +// Waits longer for the renderers to respond. +- (IBAction)wait:(id)sender; + +// Modifies the dialog to show a warning for the given tab contents. +// The dialog will contain a list of all tabs that share a renderer +// process with |contents|. The caller must not delete any tab +// contents without first calling endForTabContents. +- (void)showForTabContents:(TabContents*)contents; + +// Notifies the dialog that |contents| is either responsive or closed. +// If |contents| shares the same render process as the tab contents +// this dialog was created for, this function will close the dialog. +// If |contents| has a different process, this function does nothing. +- (void)endForTabContents:(TabContents*)contents; + +@end // HungRendererController + + +@interface HungRendererController (JustForTesting) +- (NSButton*)killButton; +- (MultiKeyEquivalentButton*)waitButton; +@end + +#endif // CHROME_BROWSER_UI_COCOA_HUNG_RENDERER_CONTROLLER_H_ diff --git a/chrome/browser/ui/cocoa/hung_renderer_controller.mm b/chrome/browser/ui/cocoa/hung_renderer_controller.mm new file mode 100644 index 0000000..130a3f7 --- /dev/null +++ b/chrome/browser/ui/cocoa/hung_renderer_controller.mm @@ -0,0 +1,203 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/hung_renderer_controller.h" + +#import <Cocoa/Cocoa.h> + +#include "app/resource_bundle.h" +#include "app/l10n_util_mac.h" +#include "base/mac_util.h" +#include "base/process_util.h" +#include "base/sys_string_conversions.h" +#include "chrome/browser/browser_list.h" +#include "chrome/browser/hung_renderer_dialog.h" +#include "chrome/browser/renderer_host/render_process_host.h" +#include "chrome/browser/renderer_host/render_view_host.h" +#include "chrome/browser/tab_contents/tab_contents.h" +#import "chrome/browser/ui/cocoa/multi_key_equivalent_button.h" +#include "chrome/common/logging_chrome.h" +#include "chrome/common/result_codes.h" +#include "grit/chromium_strings.h" +#include "grit/app_resources.h" +#include "grit/generated_resources.h" +#include "grit/theme_resources.h" +#include "skia/ext/skia_utils_mac.h" +#include "third_party/GTM/AppKit/GTMUILocalizerAndLayoutTweaker.h" + +namespace { +// We only support showing one of these at a time per app. The +// controller owns itself and is released when its window is closed. +HungRendererController* g_instance = NULL; +} // end namespace + +@implementation HungRendererController + +- (id)initWithWindowNibName:(NSString*)nibName { + NSString* nibpath = [mac_util::MainAppBundle() pathForResource:nibName + ofType:@"nib"]; + self = [super initWithWindowNibPath:nibpath owner:self]; + if (self) { + [tableView_ setDataSource:self]; + } + return self; +} + +- (void)dealloc { + DCHECK(!g_instance); + [tableView_ setDataSource:nil]; + [super dealloc]; +} + +- (void)awakeFromNib { + // Load in the image + ResourceBundle& rb = ResourceBundle::GetSharedInstance(); + NSImage* backgroundImage = rb.GetNativeImageNamed(IDR_FROZEN_TAB_ICON); + DCHECK(backgroundImage); + [imageView_ setImage:backgroundImage]; + + // Make the message fit. + CGFloat messageShift = + [GTMUILocalizerAndLayoutTweaker sizeToFitFixedWidthTextField:messageView_]; + + // Move the graphic up to be top even with the message. + NSRect graphicFrame = [imageView_ frame]; + graphicFrame.origin.y += messageShift; + [imageView_ setFrame:graphicFrame]; + + // Make the window taller to fit everything. + NSSize windowDelta = NSMakeSize(0, messageShift); + [GTMUILocalizerAndLayoutTweaker + resizeWindowWithoutAutoResizingSubViews:[self window] + delta:windowDelta]; + + // Make the "wait" button respond to additional keys. By setting this to + // @"\e", it will respond to both Esc and Command-. (period). + KeyEquivalentAndModifierMask key; + key.charCode = @"\e"; + [waitButton_ addKeyEquivalent:key]; +} + +- (IBAction)kill:(id)sender { + if (hungContents_) + base::KillProcess(hungContents_->GetRenderProcessHost()->GetHandle(), + ResultCodes::HUNG, false); + // Cannot call performClose:, because the close button is disabled. + [self close]; +} + +- (IBAction)wait:(id)sender { + if (hungContents_ && hungContents_->render_view_host()) + hungContents_->render_view_host()->RestartHangMonitorTimeout(); + // Cannot call performClose:, because the close button is disabled. + [self close]; +} + +- (NSInteger)numberOfRowsInTableView:(NSTableView *)aTableView { + return [hungTitles_ count]; +} + +- (id)tableView:(NSTableView*)aTableView + objectValueForTableColumn:(NSTableColumn*)column + row:(NSInteger)rowIndex { + return [NSNumber numberWithInt:NSOffState]; +} + +- (NSCell*)tableView:(NSTableView*)tableView + dataCellForTableColumn:(NSTableColumn*)tableColumn + row:(NSInteger)rowIndex { + NSCell* cell = [tableColumn dataCellForRow:rowIndex]; + + if ([[tableColumn identifier] isEqualToString:@"title"]) { + DCHECK([cell isKindOfClass:[NSButtonCell class]]); + NSButtonCell* buttonCell = static_cast<NSButtonCell*>(cell); + [buttonCell setTitle:[hungTitles_ objectAtIndex:rowIndex]]; + [buttonCell setImage:[hungFavicons_ objectAtIndex:rowIndex]]; + [buttonCell setRefusesFirstResponder:YES]; // Don't push in like a button. + [buttonCell setHighlightsBy:NSNoCellMask]; + } + return cell; +} + +- (void)windowWillClose:(NSNotification*)notification { + // We have to reset g_instance before autoreleasing the window, + // because we want to avoid reusing the same dialog if someone calls + // hung_renderer_dialog::ShowForTabContents() between the autorelease + // call and the actual dealloc. + g_instance = nil; + + [self autorelease]; +} + +- (void)showForTabContents:(TabContents*)contents { + DCHECK(contents); + hungContents_ = contents; + scoped_nsobject<NSMutableArray> titles([[NSMutableArray alloc] init]); + scoped_nsobject<NSMutableArray> favicons([[NSMutableArray alloc] init]); + for (TabContentsIterator it; !it.done(); ++it) { + if (it->GetRenderProcessHost() == hungContents_->GetRenderProcessHost()) { + string16 title = (*it)->GetTitle(); + if (title.empty()) + title = TabContents::GetDefaultTitle(); + [titles addObject:base::SysUTF16ToNSString(title)]; + + // TabContents can return a null SkBitmap if it has no favicon. If this + // happens, use the default favicon. + const SkBitmap& bitmap = it->GetFavIcon(); + if (!bitmap.isNull()) { + [favicons addObject:gfx::SkBitmapToNSImage(bitmap)]; + } else { + ResourceBundle& rb = ResourceBundle::GetSharedInstance(); + [favicons addObject:rb.GetNativeImageNamed(IDR_DEFAULT_FAVICON)]; + } + } + } + hungTitles_.reset([titles copy]); + hungFavicons_.reset([favicons copy]); + [tableView_ reloadData]; + + [[self window] center]; + [self showWindow:self]; +} + +- (void)endForTabContents:(TabContents*)contents { + DCHECK(contents); + DCHECK(hungContents_); + if (hungContents_ && hungContents_->GetRenderProcessHost() == + contents->GetRenderProcessHost()) { + // Cannot call performClose:, because the close button is disabled. + [self close]; + } +} + +@end + +@implementation HungRendererController (JustForTesting) +- (NSButton*)killButton { + return killButton_; +} + +- (MultiKeyEquivalentButton*)waitButton { + return waitButton_; +} +@end + +namespace hung_renderer_dialog { + +void ShowForTabContents(TabContents* contents) { + if (!logging::DialogsAreSuppressed()) { + if (!g_instance) + g_instance = [[HungRendererController alloc] + initWithWindowNibName:@"HungRendererDialog"]; + [g_instance showForTabContents:contents]; + } +} + +// static +void HideForTabContents(TabContents* contents) { + if (!logging::DialogsAreSuppressed() && g_instance) + [g_instance endForTabContents:contents]; +} + +} // namespace hung_renderer_dialog diff --git a/chrome/browser/ui/cocoa/hung_renderer_controller_unittest.mm b/chrome/browser/ui/cocoa/hung_renderer_controller_unittest.mm new file mode 100644 index 0000000..e03becf --- /dev/null +++ b/chrome/browser/ui/cocoa/hung_renderer_controller_unittest.mm @@ -0,0 +1,51 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#import "base/scoped_nsobject.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#include "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/hung_renderer_controller.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +namespace { + +class HungRendererControllerTest : public CocoaTest { + public: + virtual void SetUp() { + CocoaTest::SetUp(); + hung_renderer_controller_ = [[HungRendererController alloc] + initWithWindowNibName:@"HungRendererDialog"]; + } + HungRendererController* hung_renderer_controller_; // owned by its window +}; + +TEST_F(HungRendererControllerTest, TestShowAndClose) { + // Doesn't test much functionality-wise, but makes sure we can + // display and tear down a window. + [hung_renderer_controller_ showWindow:nil]; + // Cannot call performClose:, because the close button is disabled. + [hung_renderer_controller_ close]; +} + +TEST_F(HungRendererControllerTest, TestKillButton) { + // We can't test killing a process because we have no running + // process to kill, but we can make sure that pressing the kill + // button closes the window. + [hung_renderer_controller_ showWindow:nil]; + [[hung_renderer_controller_ killButton] performClick:nil]; +} + +TEST_F(HungRendererControllerTest, TestWaitButton) { + // We can't test waiting because we have no running process to wait + // for, but we can make sure that pressing the wait button closes + // the window. + [hung_renderer_controller_ showWindow:nil]; + [[hung_renderer_controller_ waitButton] performClick:nil]; +} + +} // namespace + diff --git a/chrome/browser/ui/cocoa/hyperlink_button_cell.h b/chrome/browser/ui/cocoa/hyperlink_button_cell.h new file mode 100644 index 0000000..c4d27ff --- /dev/null +++ b/chrome/browser/ui/cocoa/hyperlink_button_cell.h @@ -0,0 +1,25 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> +#include "base/scoped_nsobject.h" + +// A HyperlinkButtonCell is used to create an NSButton that looks and acts +// like a hyperlink. The default styling is to look like blue, underlined text +// and to have the pointingHand cursor on mouse over. +// +// To use in Interface Builder: +// 1. Drag out an NSButton. +// 2. Double click on the button so you have the cell component selected. +// 3. In the Identity panel of the inspector, set the custom class to this. +// 4. In the Attributes panel, change the Bezel to Square. +// 5. In the Size panel, set the Height to 16. +@interface HyperlinkButtonCell : NSButtonCell { + scoped_nsobject<NSColor> textColor_; +} +@property (nonatomic, retain) NSColor* textColor; + ++ (NSColor*)defaultTextColor; + +@end diff --git a/chrome/browser/ui/cocoa/hyperlink_button_cell.mm b/chrome/browser/ui/cocoa/hyperlink_button_cell.mm new file mode 100644 index 0000000..c8bb93e8 --- /dev/null +++ b/chrome/browser/ui/cocoa/hyperlink_button_cell.mm @@ -0,0 +1,116 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/hyperlink_button_cell.h" + +@interface HyperlinkButtonCell (Private) +- (NSDictionary*)linkAttributres; +- (void)customizeButtonCell; +@end + +@implementation HyperlinkButtonCell +@dynamic textColor; + ++ (NSColor*)defaultTextColor { + return [NSColor blueColor]; +} + +// Designated initializer. +- (id)init { + if ((self = [super init])) { + [self customizeButtonCell]; + } + return self; +} + +// Initializer called when the cell is loaded from the NIB. +- (id)initWithCoder:(NSCoder*)aDecoder { + if ((self = [super initWithCoder:aDecoder])) { + [self customizeButtonCell]; + } + return self; +} + +// Initializer for code-based creation. +- (id)initTextCell:(NSString*)title { + if ((self = [super initTextCell:title])) { + [self customizeButtonCell]; + } + return self; +} + +// Because an NSButtonCell has multiple initializers, this method performs the +// common cell customization code. +- (void)customizeButtonCell { + [self setBordered:NO]; + [self setTextColor:[HyperlinkButtonCell defaultTextColor]]; + + CGFloat fontSize = [NSFont systemFontSizeForControlSize:[self controlSize]]; + NSFont* font = [NSFont controlContentFontOfSize:fontSize]; + [self setFont:font]; + + // Do not change button appearance when we are pushed. + // TODO(rsesek): Change text color to red? + [self setHighlightsBy:NSNoCellMask]; + + // We need to set this so that we can override |-mouseEntered:| and + // |-mouseExited:| to change the cursor style on hover states. + [self setShowsBorderOnlyWhileMouseInside:YES]; +} + +- (void)setControlSize:(NSControlSize)size { + [super setControlSize:size]; + [self customizeButtonCell]; // recompute |font|. +} + +// Creates the NSDictionary of attributes for the attributed string. +- (NSDictionary*)linkAttributes { + NSUInteger underlineMask = NSUnderlinePatternSolid | NSUnderlineStyleSingle; + scoped_nsobject<NSMutableParagraphStyle> paragraphStyle( + [[NSParagraphStyle defaultParagraphStyle] mutableCopy]); + [paragraphStyle setAlignment:[self alignment]]; + + return [NSDictionary dictionaryWithObjectsAndKeys: + [self textColor], NSForegroundColorAttributeName, + [NSNumber numberWithInt:underlineMask], NSUnderlineStyleAttributeName, + [self font], NSFontAttributeName, + [NSCursor pointingHandCursor], NSCursorAttributeName, + paragraphStyle.get(), NSParagraphStyleAttributeName, + nil + ]; +} + +// Override the drawing for the cell so that the custom style attributes +// can always be applied and so that ellipses will appear when appropriate. +- (NSRect)drawTitle:(NSAttributedString*)title + withFrame:(NSRect)frame + inView:(NSView*)controlView { + NSDictionary* linkAttributes = [self linkAttributes]; + NSString* plainTitle = [title string]; + [plainTitle drawWithRect:frame + options:(NSStringDrawingUsesLineFragmentOrigin | + NSStringDrawingTruncatesLastVisibleLine) + attributes:linkAttributes]; + return frame; +} + +// Override the default behavior to draw the border. Instead, change the cursor. +- (void)mouseEntered:(NSEvent*)event { + [[NSCursor pointingHandCursor] push]; +} + +- (void)mouseExited:(NSEvent*)event { + [NSCursor pop]; +} + +// Setters and getters. +- (NSColor*)textColor { + return textColor_.get(); +} + +- (void)setTextColor:(NSColor*)color { + textColor_.reset([color retain]); +} + +@end diff --git a/chrome/browser/ui/cocoa/hyperlink_button_cell_unittest.mm b/chrome/browser/ui/cocoa/hyperlink_button_cell_unittest.mm new file mode 100644 index 0000000..0adae14 --- /dev/null +++ b/chrome/browser/ui/cocoa/hyperlink_button_cell_unittest.mm @@ -0,0 +1,75 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/hyperlink_button_cell.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +namespace { + +class HyperlinkButtonCellTest : public CocoaTest { + public: + HyperlinkButtonCellTest() { + NSRect frame = NSMakeRect(0, 0, 50, 30); + scoped_nsobject<NSButton> view([[NSButton alloc] initWithFrame:frame]); + view_ = view.get(); + scoped_nsobject<HyperlinkButtonCell> cell( + [[HyperlinkButtonCell alloc] initTextCell:@"Testing"]); + cell_ = cell.get(); + [view_ setCell:cell_]; + [[test_window() contentView] addSubview:view_]; + } + + void TestCellCustomization(HyperlinkButtonCell* cell) { + EXPECT_FALSE([cell isBordered]); + EXPECT_EQ(NSNoCellMask, [cell_ highlightsBy]); + EXPECT_TRUE([cell showsBorderOnlyWhileMouseInside]); + EXPECT_TRUE([cell textColor]); + } + + NSButton* view_; + HyperlinkButtonCell* cell_; +}; + +TEST_VIEW(HyperlinkButtonCellTest, view_) + +// Tests the three designated intializers. +TEST_F(HyperlinkButtonCellTest, Initializers) { + TestCellCustomization(cell_); // |-initTextFrame:| + scoped_nsobject<HyperlinkButtonCell> cell([[HyperlinkButtonCell alloc] init]); + TestCellCustomization(cell.get()); + + // Need to create a dummy archiver to test |-initWithCoder:|. + NSData* emptyData = [NSKeyedArchiver archivedDataWithRootObject:@""]; + NSCoder* coder = + [[[NSKeyedUnarchiver alloc] initForReadingWithData:emptyData] autorelease]; + cell.reset([[HyperlinkButtonCell alloc] initWithCoder:coder]); + TestCellCustomization(cell); +} + +// Test set color. +TEST_F(HyperlinkButtonCellTest, SetTextColor) { + NSColor* textColor = [NSColor redColor]; + EXPECT_NE(textColor, [cell_ textColor]); + [cell_ setTextColor:textColor]; + EXPECT_EQ(textColor, [cell_ textColor]); +} + +// Test mouse events. +// TODO(rsesek): See if we can synthesize mouse events to more accurately +// test this. +TEST_F(HyperlinkButtonCellTest, MouseHover) { + [[NSCursor disappearingItemCursor] push]; // Set a known state. + [cell_ mouseEntered:nil]; + EXPECT_EQ([NSCursor pointingHandCursor], [NSCursor currentCursor]); + [cell_ mouseExited:nil]; + EXPECT_EQ([NSCursor disappearingItemCursor], [NSCursor currentCursor]); + [NSCursor pop]; +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/image_utils.h b/chrome/browser/ui/cocoa/image_utils.h new file mode 100644 index 0000000..5c43828 --- /dev/null +++ b/chrome/browser/ui/cocoa/image_utils.h @@ -0,0 +1,26 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_IMAGE_UTILS_H_ +#define CHROME_BROWSER_UI_COCOA_IMAGE_UTILS_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +@interface NSImage (FlippedAdditions) + +// Works like |-drawInRect:fromRect:operation:fraction:|, except that +// if |neverFlipped| is |YES|, and the context is flipped, the a +// transform is applied to flip it again before drawing the image. +// +// Compare to the 10.6 method +// |-drawInRect:fromRect:operation:fraction:respectFlipped:hints:|. +- (void)drawInRect:(NSRect)dstRect + fromRect:(NSRect)srcRect + operation:(NSCompositingOperation)op + fraction:(CGFloat)requestedAlpha + neverFlipped:(BOOL)neverFlipped; +@end + +#endif // CHROME_BROWSER_UI_COCOA_IMAGE_UTILS_H_ diff --git a/chrome/browser/ui/cocoa/image_utils.mm b/chrome/browser/ui/cocoa/image_utils.mm new file mode 100644 index 0000000..b2883ff --- /dev/null +++ b/chrome/browser/ui/cocoa/image_utils.mm @@ -0,0 +1,37 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/image_utils.h" + +@implementation NSImage (FlippedAdditions) + +- (void)drawInRect:(NSRect)dstRect + fromRect:(NSRect)srcRect + operation:(NSCompositingOperation)op + fraction:(CGFloat)requestedAlpha + neverFlipped:(BOOL)neverFlipped { + NSAffineTransform *transform = nil; + + // Flip drawing and adjust the origin to make the image come out + // right. + if (neverFlipped && [[NSGraphicsContext currentContext] isFlipped]) { + transform = [NSAffineTransform transform]; + [transform scaleXBy:1.0 yBy:-1.0]; + [transform concat]; + + // The lower edge of the image is as far from the origin as the + // upper edge was, plus it's on the other side of the origin. + dstRect.origin.y -= NSMaxY(dstRect) + NSMinY(dstRect); + } + + [self drawInRect:dstRect + fromRect:srcRect + operation:op + fraction:requestedAlpha]; + + // Flip drawing back, if needed. + [transform concat]; +} + +@end diff --git a/chrome/browser/ui/cocoa/image_utils_unittest.mm b/chrome/browser/ui/cocoa/image_utils_unittest.mm new file mode 100644 index 0000000..d13b33b --- /dev/null +++ b/chrome/browser/ui/cocoa/image_utils_unittest.mm @@ -0,0 +1,138 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "chrome/browser/ui/cocoa/image_utils.h" +#include "testing/gtest/include/gtest/gtest.h" + +@interface ImageUtilsTestView : NSView { + @private + // Determine whether the view is flipped. + BOOL isFlipped_; + + // Determines whether to draw using the new method with + // |neverFlipped:|. + BOOL useNeverFlipped_; + + // Passed to |neverFlipped:| when drawing |image_|. + BOOL neverFlipped_; + + scoped_nsobject<NSImage> image_; +} +@property(assign, nonatomic) BOOL isFlipped; +@property(assign, nonatomic) BOOL useNeverFlipped; +@property(assign, nonatomic) BOOL neverFlipped; +@end + +@implementation ImageUtilsTestView +@synthesize isFlipped = isFlipped_; +@synthesize useNeverFlipped = useNeverFlipped_; +@synthesize neverFlipped = neverFlipped_; + +- (id)initWithFrame:(NSRect)rect { + self = [super initWithFrame:rect]; + if (self) { + rect = NSInsetRect(rect, 5.0, 5.0); + rect.origin = NSZeroPoint; + const NSSize imageSize = NSInsetRect(rect, 5.0, 5.0).size; + image_.reset([[NSImage alloc] initWithSize:imageSize]); + + NSBezierPath* path = [NSBezierPath bezierPath]; + [path moveToPoint:NSMakePoint(NSMinX(rect), NSMinY(rect))]; + [path lineToPoint:NSMakePoint(NSMinX(rect), NSMaxY(rect))]; + [path lineToPoint:NSMakePoint(NSMaxX(rect), NSMinY(rect))]; + [path closePath]; + + [image_ lockFocus]; + [[NSColor blueColor] setFill]; + [path fill]; + [image_ unlockFocus]; + } + return self; +} + +- (void)drawRect:(NSRect)rect { + NSBezierPath* path = [NSBezierPath bezierPath]; + [path moveToPoint:NSMakePoint(NSMinX(rect), NSMinY(rect))]; + [path lineToPoint:NSMakePoint(NSMinX(rect), NSMaxY(rect))]; + [path lineToPoint:NSMakePoint(NSMaxX(rect), NSMinY(rect))]; + [path closePath]; + + [[NSColor redColor] setFill]; + [path fill]; + + rect = NSInsetRect(rect, 5.0, 5.0); + rect = NSOffsetRect(rect, 2.0, 2.0); + + if (useNeverFlipped_) { + [image_ drawInRect:rect + fromRect:NSZeroRect + operation:NSCompositeCopy + fraction:1.0 + neverFlipped:neverFlipped_]; + } else { + [image_ drawInRect:rect + fromRect:NSZeroRect + operation:NSCompositeCopy + fraction:1.0]; + } +} + +@end + +namespace { + +class ImageUtilTest : public CocoaTest { + public: + ImageUtilTest() { + const NSRect frame = NSMakeRect(0, 0, 300, 100); + scoped_nsobject<ImageUtilsTestView> view( + [[ImageUtilsTestView alloc] initWithFrame: frame]); + view_ = view.get(); + [[test_window() contentView] addSubview:view_]; + } + + NSData* SnapshotView() { + [view_ display]; + + const NSRect bounds = [view_ bounds]; + + [view_ lockFocus]; + scoped_nsobject<NSBitmapImageRep> bitmap( + [[NSBitmapImageRep alloc] initWithFocusedViewRect:bounds]); + [view_ unlockFocus]; + + return [bitmap TIFFRepresentation]; + } + + NSData* SnapshotViewBase() { + [view_ setUseNeverFlipped:NO]; + return SnapshotView(); + } + + NSData* SnapshotViewNeverFlipped(BOOL neverFlipped) { + [view_ setUseNeverFlipped:YES]; + [view_ setNeverFlipped:neverFlipped]; + return SnapshotView(); + } + + ImageUtilsTestView* view_; +}; + +TEST_F(ImageUtilTest, Test) { + // When not flipped, both drawing methods return the same data. + [view_ setIsFlipped:NO]; + NSData* baseSnapshotData = SnapshotViewBase(); + EXPECT_TRUE([baseSnapshotData isEqualToData:SnapshotViewNeverFlipped(YES)]); + EXPECT_TRUE([baseSnapshotData isEqualToData:SnapshotViewNeverFlipped(NO)]); + + // When flipped, there's only a difference when the context flip is + // not being respected. + [view_ setIsFlipped:YES]; + baseSnapshotData = SnapshotViewBase(); + EXPECT_FALSE([baseSnapshotData isEqualToData:SnapshotViewNeverFlipped(YES)]); + EXPECT_TRUE([baseSnapshotData isEqualToData:SnapshotViewNeverFlipped(NO)]); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/import_progress_dialog.h b/chrome/browser/ui/cocoa/import_progress_dialog.h new file mode 100644 index 0000000..c1b405b --- /dev/null +++ b/chrome/browser/ui/cocoa/import_progress_dialog.h @@ -0,0 +1,102 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_IMPORT_PROGRESS_DIALOG_H_ +#define CHROME_BROWSER_IMPORT_PROGRESS_DIALOG_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "chrome/browser/importer/importer.h" +#include "chrome/browser/importer/importer_data_types.h" + +class ImporterObserverBridge; + +// Class that acts as a controller for the dialog that shows progress for an +// import operation. +// Lifetime: This object is responsible for deleting itself. +@interface ImportProgressDialogController : NSWindowController { + scoped_ptr<ImporterObserverBridge> import_host_observer_bridge_; + ImporterHost* importer_host_; // (weak) + ImportObserver* observer_; // (weak) + + // Strings bound to static labels in the UI dialog. + NSString* explanatory_text_; + NSString* favorites_status_text_; + NSString* search_status_text_; + NSString* saved_password_status_text_; + NSString* history_status_text_; + + // Bound to the color of the status text (this is the easiest way to disable + // progress items that aren't supported by the current browser we're importing + // from). + NSColor* favorites_import_enabled_; + NSColor* search_import_enabled_; + NSColor* password_import_enabled_; + NSColor* history_import_enabled_; + + // Placeholders for "Importing..." and "Done" text. + NSString* progress_text_; + NSString* done_text_; +} + +// Cancel button calls this. +- (IBAction)cancel:(id)sender; + +// Closes the dialog. +- (void)closeDialog; + +// Methods called by importer_host via ImporterObserverBridge. +- (void)ImportItemStarted:(importer::ImportItem)item; +- (void)ImportItemEnded:(importer::ImportItem)item; +- (void)ImportEnded; + +@property (nonatomic, retain) NSString* explanatoryText; +@property (nonatomic, retain) NSString* favoritesStatusText; +@property (nonatomic, retain) NSString* searchStatusText; +@property (nonatomic, retain) NSString* savedPasswordStatusText; +@property (nonatomic, retain) NSString* historyStatusText; + +@property (nonatomic, retain) NSColor* favoritesImportEnabled; +@property (nonatomic, retain) NSColor* searchImportEnabled; +@property (nonatomic, retain) NSColor* passwordImportEnabled; +@property (nonatomic, retain) NSColor* historyImportEnabled; + +@end + +// C++ -> objc bridge for import status notifications. +class ImporterObserverBridge : public ImporterHost::Observer { + public: + ImporterObserverBridge(ImportProgressDialogController* owner) + : owner_(owner) {} + virtual ~ImporterObserverBridge() {} + + // Invoked when data for the specified item is about to be collected. + virtual void ImportItemStarted(importer::ImportItem item) { + [owner_ ImportItemStarted:item]; + } + + // Invoked when data for the specified item has been collected from the + // source profile and is now ready for further processing. + virtual void ImportItemEnded(importer::ImportItem item) { + [owner_ ImportItemEnded:item]; + } + + // Invoked when the import begins. + virtual void ImportStarted() { + // Not needed for out of process import. + } + + // Invoked when the source profile has been imported. + virtual void ImportEnded() { + [owner_ ImportEnded]; + } + + private: + ImportProgressDialogController* owner_; + + DISALLOW_COPY_AND_ASSIGN(ImporterObserverBridge); +}; + +#endif // CHROME_BROWSER_IMPORT_PROGRESS_DIALOG_H_ diff --git a/chrome/browser/ui/cocoa/import_progress_dialog.mm b/chrome/browser/ui/cocoa/import_progress_dialog.mm new file mode 100644 index 0000000..6ec0ed5 --- /dev/null +++ b/chrome/browser/ui/cocoa/import_progress_dialog.mm @@ -0,0 +1,192 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/import_progress_dialog.h" + +#include "app/l10n_util.h" +#include "app/l10n_util_mac.h" +#include "base/logging.h" +#include "base/mac_util.h" +#include "base/message_loop.h" +#import "base/scoped_nsobject.h" +#import "base/sys_string_conversions.h" +#include "base/utf_string_conversions.h" +#include "grit/chromium_strings.h" +#include "grit/generated_resources.h" + +namespace { + +// Convert ImportItem enum into the name of the ImportProgressDialogController +// property corresponding to the text for that item, this makes the code to +// change the values for said properties much more readable. +NSString* keyForImportItem(importer::ImportItem item) { + switch(item) { + case importer::HISTORY: + return @"historyStatusText"; + case importer::FAVORITES: + return @"favoritesStatusText"; + case importer::PASSWORDS: + return @"savedPasswordStatusText"; + case importer::SEARCH_ENGINES: + return @"searchStatusText"; + default: + DCHECK(false); + break; + } + return nil; +} + +} // namespace + +@implementation ImportProgressDialogController + +@synthesize explanatoryText = explanatory_text_; +@synthesize favoritesStatusText = favorites_status_text_; +@synthesize searchStatusText = search_status_text_; +@synthesize savedPasswordStatusText = saved_password_status_text_; +@synthesize historyStatusText = history_status_text_; + +@synthesize favoritesImportEnabled = favorites_import_enabled_; +@synthesize searchImportEnabled = search_import_enabled_; +@synthesize passwordImportEnabled = password_import_enabled_; +@synthesize historyImportEnabled = history_import_enabled_; + +- (id)initWithImporterHost:(ImporterHost*)host + browserName:(string16)browserName + observer:(ImportObserver*)observer + itemsEnabled:(int16)items { + NSString* nib_path = + [mac_util::MainAppBundle() pathForResource:@"ImportProgressDialog" + ofType:@"nib"]; + self = [super initWithWindowNibPath:nib_path owner:self]; + if (self != nil) { + importer_host_ = host; + observer_ = observer; + import_host_observer_bridge_.reset(new ImporterObserverBridge(self)); + importer_host_->SetObserver(import_host_observer_bridge_.get()); + + string16 productName = l10n_util::GetStringUTF16(IDS_PRODUCT_NAME); + NSString* explanatory_text = l10n_util::GetNSStringF( + IDS_IMPORT_PROGRESS_EXPLANATORY_TEXT_MAC, + productName, + browserName); + [self setExplanatoryText:explanatory_text]; + + progress_text_ = + [l10n_util::GetNSStringWithFixup(IDS_IMPORT_IMPORTING_PROGRESS_TEXT_MAC) + retain]; + done_text_ = + [l10n_util::GetNSStringWithFixup(IDS_IMPORT_IMPORTING_DONE_TEXT_MAC) + retain]; + + // Enable/disable item titles. + NSColor* disabled = [NSColor disabledControlTextColor]; + NSColor* active = [NSColor textColor]; + [self setFavoritesImportEnabled:items & importer::FAVORITES ? active : + disabled]; + [self setSearchImportEnabled:items & importer::SEARCH_ENGINES ? active : + disabled]; + [self setPasswordImportEnabled:items & importer::PASSWORDS ? active : + disabled]; + [self setHistoryImportEnabled:items & importer::HISTORY ? active : + disabled]; + } + return self; +} + +- (void)dealloc { + [explanatory_text_ release]; + [favorites_status_text_ release]; + [search_status_text_ release]; + [saved_password_status_text_ release]; + [history_status_text_ release]; + + [favorites_import_enabled_ release]; + [search_import_enabled_ release]; + [password_import_enabled_ release]; + [history_import_enabled_ release]; + + [progress_text_ release]; + [done_text_ release]; + + [super dealloc]; +} + +- (IBAction)showWindow:(id)sender { + NSWindow* win = [self window]; + [win center]; + [super showWindow:nil]; +} + +- (void)closeDialog { + if ([[self window] isVisible]) { + [[self window] close]; + } +} + +- (IBAction)cancel:(id)sender { + // The ImporterHost will notify import_host_observer_bridge_ that import has + // ended, which will trigger the ImportEnded method, in which this object is + // released. + importer_host_->Cancel(); +} + +- (void)ImportItemStarted:(importer::ImportItem)item { + [self setValue:progress_text_ forKey:keyForImportItem(item)]; +} + +- (void)ImportItemEnded:(importer::ImportItem)item { + [self setValue:done_text_ forKey:keyForImportItem(item)]; +} + +- (void)ImportEnded { + importer_host_->SetObserver(NULL); + if (observer_) + observer_->ImportComplete(); + [self closeDialog]; + [self release]; + + // Break out of modal event loop. + [NSApp stopModal]; +} + +@end + +void StartImportingWithUI(gfx::NativeWindow parent_window, + uint16 items, + ImporterHost* coordinator, + const importer::ProfileInfo& source_profile, + Profile* target_profile, + ImportObserver* observer, + bool first_run) { + DCHECK(items != 0); + + // Retrieve name of browser we're importing from and do a little dance to + // convert wstring -> string16. + string16 import_browser_name = WideToUTF16Hack(source_profile.description); + + // progress_dialog_ is responsible for deleting itself. + ImportProgressDialogController* progress_dialog_ = + [[ImportProgressDialogController alloc] + initWithImporterHost:coordinator + browserName:import_browser_name + observer:observer + itemsEnabled:items]; + // Call is async. + coordinator->StartImportSettings(source_profile, target_profile, items, + new ProfileWriter(target_profile), + first_run); + + // Display the window while spinning a message loop. + // For details on why we need a modal message loop see http://crbug.com/19169 + NSWindow* progress_window = [progress_dialog_ window]; + NSModalSession session = [NSApp beginModalSessionForWindow:progress_window]; + [progress_dialog_ showWindow:nil]; + while (true) { + if ([NSApp runModalSession:session] != NSRunContinuesResponse) + break; + MessageLoop::current()->RunAllPending(); + } + [NSApp endModalSession:session]; +} diff --git a/chrome/browser/ui/cocoa/import_settings_dialog.h b/chrome/browser/ui/cocoa/import_settings_dialog.h new file mode 100644 index 0000000..2b84ff9 --- /dev/null +++ b/chrome/browser/ui/cocoa/import_settings_dialog.h @@ -0,0 +1,98 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_IMPORT_SETTINGS_DIALOG_H_ +#define CHROME_BROWSER_UI_COCOA_IMPORT_SETTINGS_DIALOG_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#include "chrome/browser/importer/importer.h" + +class Profile; + +// Controller for the Import Bookmarks and Settings dialog. This controller +// automatically autoreleases itself when its associated dialog is dismissed. +@interface ImportSettingsDialogController : NSWindowController { + @private + NSWindow* parentWindow_; // weak + Profile* profile_; // weak + scoped_ptr<ImporterList> importerList_; + scoped_nsobject<NSArray> sourceBrowsersList_; + NSUInteger sourceBrowserIndex_; + // The following are all bound via the properties below. + BOOL importHistory_; + BOOL importFavorites_; + BOOL importPasswords_; + BOOL importSearchEngines_; + BOOL historyAvailable_; + BOOL favoritesAvailable_; + BOOL passwordsAvailable_; + BOOL searchEnginesAvailable_; +} + +// Show the import settings window. Window is displayed as an app modal dialog. +// If the dialog is already being displayed, this method whill return with +// no error. ++ (void)showImportSettingsDialogForProfile:(Profile*)profile; + +// Called when the "Import" button is pressed. +- (IBAction)ok:(id)sender; + +// Cancel button calls this. +- (IBAction)cancel:(id)sender; + +// An array of ImportSettingsProfiles, provide the list of browser profiles +// available for importing. Bound to the Browser List array controller. +- (NSArray*)sourceBrowsersList; + +// Properties for bindings. +@property(assign, nonatomic) NSUInteger sourceBrowserIndex; +@property(assign, readonly, nonatomic) BOOL importSomething; +// Bindings for the value of the import checkboxes. +@property(assign, nonatomic) BOOL importHistory; +@property(assign, nonatomic) BOOL importFavorites; +@property(assign, nonatomic) BOOL importPasswords; +@property(assign, nonatomic) BOOL importSearchEngines; +// Bindings for enabling/disabling the checkboxes. +@property(assign, readonly, nonatomic) BOOL historyAvailable; +@property(assign, readonly, nonatomic) BOOL favoritesAvailable; +@property(assign, readonly, nonatomic) BOOL passwordsAvailable; +@property(assign, readonly, nonatomic) BOOL searchEnginesAvailable; + +@end + +@interface ImportSettingsDialogController (TestingAPI) + +// Initialize by providing an array of profile dictionaries. Exposed for +// unit testing but also called by -[initWithProfile:]. +- (id)initWithProfiles:(NSArray*)profiles; + +// Return selected services to import as mapped by the ImportItem enum. +- (uint16)servicesToImport; + +@end + +// Utility class used as array elements for sourceBrowsersList, above. +@interface ImportSettingsProfile : NSObject { + @private + NSString* browserName_; + uint16 services_; // Services as defined by enum ImportItem. +} + +// Convenience creator. |services| is a bitfield of enum ImportItems. ++ (id)importSettingsProfileWithBrowserName:(NSString*)browserName + services:(uint16)services; + +// Designated initializer. |services| is a bitfield of enum ImportItems. +- (id)initWithBrowserName:(NSString*)browserName + services:(uint16)services; // Bitfield of enum ImportItems. + +@property(copy, nonatomic) NSString* browserName; +@property(assign, nonatomic) uint16 services; // Bitfield of enum ImportItems. + +@end + +#endif // CHROME_BROWSER_UI_COCOA_IMPORT_SETTINGS_DIALOG_H_ diff --git a/chrome/browser/ui/cocoa/import_settings_dialog.mm b/chrome/browser/ui/cocoa/import_settings_dialog.mm new file mode 100644 index 0000000..59a9140 --- /dev/null +++ b/chrome/browser/ui/cocoa/import_settings_dialog.mm @@ -0,0 +1,245 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/import_settings_dialog.h" + +#include "base/mac_util.h" +#include "base/sys_string_conversions.h" +#include "chrome/browser/importer/importer_data_types.h" +#include "chrome/browser/importer/importer_list.h" +#include "chrome/browser/profile.h" + +namespace { + +bool importSettingsDialogVisible = false; + +} // namespace + +@interface ImportSettingsDialogController () + +@property(assign, readwrite, nonatomic) BOOL historyAvailable; +@property(assign, readwrite, nonatomic) BOOL favoritesAvailable; +@property(assign, readwrite, nonatomic) BOOL passwordsAvailable; +@property(assign, readwrite, nonatomic) BOOL searchEnginesAvailable; + +@end + +@implementation ImportSettingsProfile + +@synthesize browserName = browserName_; +@synthesize services = services_; + ++ (id)importSettingsProfileWithBrowserName:(NSString*)browserName + services:(uint16)services { + id settingsProfile = [[[ImportSettingsProfile alloc] + initWithBrowserName:browserName + services:services] autorelease]; + return settingsProfile; +} + +- (id)initWithBrowserName:(NSString*)browserName + services:(uint16)services { + DCHECK(browserName && services); + if ((self = [super init])) { + if (browserName && services != 0) { + browserName_ = [browserName retain]; + services_ = services; + } else { + [self release]; + self = nil; + } + } + return self; +} + +- (id)init { + NOTREACHED(); // Should never be called. + return [self initWithBrowserName:NULL services:0]; +} + +- (void)dealloc { + [browserName_ release]; + [super dealloc]; +} + +@end + +@interface ImportSettingsDialogController (Private) + +// Initialize the dialog controller with either the default profile or +// the profile for the current browser. +- (id)initWithProfile:(Profile*)profile; + +// Present the app modal dialog. +- (void)runModalDialog; + +// Close the modal dialog. +- (void)closeDialog; + +@end + +@implementation ImportSettingsDialogController + +@synthesize sourceBrowserIndex = sourceBrowserIndex_; +@synthesize importHistory = importHistory_; +@synthesize importFavorites = importFavorites_; +@synthesize importPasswords = importPasswords_; +@synthesize importSearchEngines = importSearchEngines_; +@synthesize historyAvailable = historyAvailable_; +@synthesize favoritesAvailable = favoritesAvailable_; +@synthesize passwordsAvailable = passwordsAvailable_; +@synthesize searchEnginesAvailable = searchEnginesAvailable_; + +// Set bindings dependencies for importSomething property. ++ (NSSet*)keyPathsForValuesAffectingImportSomething { + return [NSSet setWithObjects:@"importHistory", @"importFavorites", + @"importPasswords", @"importSearchEngines", nil]; +} + ++ (void)showImportSettingsDialogForProfile:(Profile*)profile { + // Don't display if already visible. + if (importSettingsDialogVisible) + return; + ImportSettingsDialogController* controller = + [[ImportSettingsDialogController alloc] initWithProfile:profile]; + [controller runModalDialog]; +} + +- (id)initWithProfile:(Profile*)profile { + // Collect profile information (profile name and the services which can + // be imported from each) into an array of ImportSettingsProfile which + // are bound to the Browser List array controller and the popup name + // presentation. The services element is used to indirectly control + // checkbox enabling. + importerList_.reset(new ImporterList); + ImporterList& importerList(*(importerList_.get())); + importerList.DetectSourceProfiles(); + int profilesCount = importerList.GetAvailableProfileCount(); + // There shoule be at least the default profile so this should never be zero. + DCHECK(profilesCount > 0); + NSMutableArray* browserProfiles = + [NSMutableArray arrayWithCapacity:profilesCount]; + for (int i = 0; i < profilesCount; ++i) { + const importer::ProfileInfo& sourceProfile = + importerList.GetSourceProfileInfoAt(i); + NSString* browserName = + base::SysWideToNSString(sourceProfile.description); + uint16 browserServices = sourceProfile.services_supported; + ImportSettingsProfile* settingsProfile = + [ImportSettingsProfile + importSettingsProfileWithBrowserName:browserName + services:browserServices]; + [browserProfiles addObject:settingsProfile]; + } + if ((self = [self initWithProfiles:browserProfiles])) { + profile_ = profile; + } + return self; +} + +- (id)initWithProfiles:(NSArray*)profiles { + NSString* nibpath = + [mac_util::MainAppBundle() pathForResource:@"ImportSettingsDialog" + ofType:@"nib"]; + if ((self = [super initWithWindowNibPath:nibpath owner:self])) { + sourceBrowsersList_.reset([profiles retain]); + // Create and initialize an importerList_ when running unit tests. + if (!importerList_.get()) { + importerList_.reset(new ImporterList); + ImporterList& importerList(*(importerList_.get())); + importerList.DetectSourceProfiles(); + } + } + return self; +} + +- (id)init { + return [self initWithProfile:NULL]; +} + +- (void)awakeFromNib { + // Force an update of the checkbox enabled states. + [self setSourceBrowserIndex:0]; +} + +// Run application modal. +- (void)runModalDialog { + importSettingsDialogVisible = true; + [NSApp runModalForWindow:[self window]]; +} + +- (IBAction)ok:(id)sender { + [self closeDialog]; + const importer::ProfileInfo& sourceProfile = + importerList_.get()->GetSourceProfileInfoAt([self sourceBrowserIndex]); + uint16 items = sourceProfile.services_supported; + uint16 servicesToImport = items & [self servicesToImport]; + if (servicesToImport) { + if (profile_) { + ImporterHost* importerHost = new ExternalProcessImporterHost; + // Note that a side effect of the following call is to cause the + // importerHost to be disposed once the import has completed. + StartImportingWithUI(nil, servicesToImport, importerHost, + sourceProfile, profile_, nil, false); + } + } else { + LOG(WARNING) << "There were no settings to import from '" + << sourceProfile.description << "'."; + } +} + +- (IBAction)cancel:(id)sender { + [self closeDialog]; +} + +- (void)closeDialog { + importSettingsDialogVisible = false; + [[self window] orderOut:self]; + [NSApp stopModal]; + [self autorelease]; +} + +#pragma mark Accessors + +- (NSArray*)sourceBrowsersList { + return sourceBrowsersList_.get(); +} + +// Accessor which cascades selected-browser changes into a re-evaluation of the +// available services and the associated checkbox enable and checked states. +- (void)setSourceBrowserIndex:(NSUInteger)browserIndex { + sourceBrowserIndex_ = browserIndex; + ImportSettingsProfile* profile = + [sourceBrowsersList_.get() objectAtIndex:browserIndex]; + uint16 items = [profile services]; + [self setHistoryAvailable:(items & importer::HISTORY) ? YES : NO]; + [self setImportHistory:[self historyAvailable]]; + [self setFavoritesAvailable:(items & importer::FAVORITES) ? YES : NO]; + [self setImportFavorites:[self favoritesAvailable]]; + [self setPasswordsAvailable:(items & importer::PASSWORDS) ? YES : NO]; + [self setImportPasswords:[self passwordsAvailable]]; + [self setSearchEnginesAvailable:(items & importer::SEARCH_ENGINES) ? + YES : NO]; + [self setImportSearchEngines:[self searchEnginesAvailable]]; +} + +- (uint16)servicesToImport { + uint16 servicesToImport = 0; + if ([self importHistory]) servicesToImport |= importer::HISTORY; + if ([self importFavorites]) servicesToImport |= importer::FAVORITES; + if ([self importPasswords]) servicesToImport |= importer::PASSWORDS; + if ([self importSearchEngines]) servicesToImport |= + importer::SEARCH_ENGINES; + return servicesToImport; +} + +// KVO accessor which returns YES if at least one of the services +// provided by the selected profile has been marked for importing +// and bound to the OK button's enable property. +- (BOOL)importSomething { + return [self importHistory] || [self importFavorites] || + [self importPasswords] || [self importSearchEngines]; +} + +@end diff --git a/chrome/browser/ui/cocoa/import_settings_dialog_unittest.mm b/chrome/browser/ui/cocoa/import_settings_dialog_unittest.mm new file mode 100644 index 0000000..f9399c2 --- /dev/null +++ b/chrome/browser/ui/cocoa/import_settings_dialog_unittest.mm @@ -0,0 +1,130 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#include "chrome/browser/importer/importer.h" +#import "chrome/browser/ui/cocoa/browser_test_helper.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/import_settings_dialog.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +using importer::HISTORY; +using importer::FAVORITES; +using importer::COOKIES; +using importer::PASSWORDS; +using importer::SEARCH_ENGINES; +using importer::NONE; + +class ImportSettingsDialogTest : public CocoaTest { + public: + ImportSettingsDialogController* controller_; + + virtual void SetUp() { + CocoaTest::SetUp(); + unsigned int safariServices = + HISTORY | FAVORITES | COOKIES | PASSWORDS | SEARCH_ENGINES; + ImportSettingsProfile* mockSafari = + [ImportSettingsProfile + importSettingsProfileWithBrowserName:@"MockSafari" + services:safariServices]; + unsigned int firefoxServices = HISTORY | FAVORITES | COOKIES | PASSWORDS; + ImportSettingsProfile* mockFirefox = + [ImportSettingsProfile + importSettingsProfileWithBrowserName:@"MockFirefox" + services:firefoxServices]; + unsigned int caminoServices = HISTORY | COOKIES | SEARCH_ENGINES; + ImportSettingsProfile* mockCamino = + [ImportSettingsProfile + importSettingsProfileWithBrowserName:@"MockCamino" + services:caminoServices]; + NSArray* browsers = [NSArray arrayWithObjects: + mockSafari, mockFirefox, mockCamino, nil]; + controller_ = [[ImportSettingsDialogController alloc] + initWithProfiles:browsers]; + } + + virtual void TearDown() { + controller_ = NULL; + CocoaTest::TearDown(); + } +}; + +TEST_F(ImportSettingsDialogTest, CancelDialog) { + [controller_ cancel:nil]; +} + +TEST_F(ImportSettingsDialogTest, ChooseVariousBrowsers) { + // Initial choice should already be MockSafari with all items enabled. + [controller_ setSourceBrowserIndex:0]; + EXPECT_TRUE([controller_ importHistory]); + EXPECT_TRUE([controller_ historyAvailable]); + EXPECT_TRUE([controller_ importFavorites]); + EXPECT_TRUE([controller_ favoritesAvailable]); + EXPECT_TRUE([controller_ importPasswords]); + EXPECT_TRUE([controller_ passwordsAvailable]); + EXPECT_TRUE([controller_ importSearchEngines]); + EXPECT_TRUE([controller_ searchEnginesAvailable]); + EXPECT_EQ(HISTORY | FAVORITES | PASSWORDS | SEARCH_ENGINES, + [controller_ servicesToImport]); + + // Next choice we test is MockCamino. + [controller_ setSourceBrowserIndex:2]; + EXPECT_TRUE([controller_ importHistory]); + EXPECT_TRUE([controller_ historyAvailable]); + EXPECT_FALSE([controller_ importFavorites]); + EXPECT_FALSE([controller_ favoritesAvailable]); + EXPECT_FALSE([controller_ importPasswords]); + EXPECT_FALSE([controller_ passwordsAvailable]); + EXPECT_TRUE([controller_ importSearchEngines]); + EXPECT_TRUE([controller_ searchEnginesAvailable]); + EXPECT_EQ(HISTORY | SEARCH_ENGINES, [controller_ servicesToImport]); + + // Next choice we test is MockFirefox. + [controller_ setSourceBrowserIndex:1]; + EXPECT_TRUE([controller_ importHistory]); + EXPECT_TRUE([controller_ historyAvailable]); + EXPECT_TRUE([controller_ importFavorites]); + EXPECT_TRUE([controller_ favoritesAvailable]); + EXPECT_TRUE([controller_ importPasswords]); + EXPECT_TRUE([controller_ passwordsAvailable]); + EXPECT_FALSE([controller_ importSearchEngines]); + EXPECT_FALSE([controller_ searchEnginesAvailable]); + EXPECT_EQ(HISTORY | FAVORITES | PASSWORDS, [controller_ servicesToImport]); + + [controller_ cancel:nil]; +} + +TEST_F(ImportSettingsDialogTest, SetVariousSettings) { + // Leave the choice MockSafari, but toggle the settings. + [controller_ setImportHistory:NO]; + [controller_ setImportFavorites:NO]; + [controller_ setImportPasswords:NO]; + [controller_ setImportSearchEngines:NO]; + EXPECT_EQ(NONE, [controller_ servicesToImport]); + EXPECT_FALSE([controller_ importSomething]); + + [controller_ setImportHistory:YES]; + EXPECT_EQ(HISTORY, [controller_ servicesToImport]); + EXPECT_TRUE([controller_ importSomething]); + + [controller_ setImportHistory:NO]; + [controller_ setImportFavorites:YES]; + EXPECT_EQ(FAVORITES, [controller_ servicesToImport]); + EXPECT_TRUE([controller_ importSomething]); + [controller_ setImportFavorites:NO]; + + [controller_ setImportPasswords:YES]; + EXPECT_EQ(PASSWORDS, [controller_ servicesToImport]); + EXPECT_TRUE([controller_ importSomething]); + + [controller_ setImportPasswords:NO]; + [controller_ setImportSearchEngines:YES]; + EXPECT_EQ(SEARCH_ENGINES, [controller_ servicesToImport]); + EXPECT_TRUE([controller_ importSomething]); + + [controller_ cancel:nil]; +} diff --git a/chrome/browser/ui/cocoa/importer_lock_dialog.h b/chrome/browser/ui/cocoa/importer_lock_dialog.h new file mode 100644 index 0000000..ade74e2 --- /dev/null +++ b/chrome/browser/ui/cocoa/importer_lock_dialog.h @@ -0,0 +1,21 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_IMPORTER_LOCK_DIALOG_H_ +#define CHROME_BROWSER_UI_COCOA_IMPORTER_LOCK_DIALOG_H_ +#pragma once + +class ImporterHost; + +namespace ImportLockDialogCocoa { + +// This function is called by an ImporterHost, and displays the Firefox profile +// locked warning by creating a modal NSAlert. On the closing of the alert +// box, the ImportHost receives a callback with the message either to skip the +// import, or to try again. +void ShowWarning(ImporterHost* importer); + +} + +#endif // CHROME_BROWSER_UI_COCOA_IMPORTER_LOCK_DIALOG_H_ diff --git a/chrome/browser/ui/cocoa/importer_lock_dialog.mm b/chrome/browser/ui/cocoa/importer_lock_dialog.mm new file mode 100644 index 0000000..cc94ce5 --- /dev/null +++ b/chrome/browser/ui/cocoa/importer_lock_dialog.mm @@ -0,0 +1,35 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#import "importer_lock_dialog.h" + +#include "app/l10n_util_mac.h" +#include "base/mac_util.h" +#include "base/message_loop.h" +#include "base/scoped_nsobject.h" +#include "chrome/browser/importer/importer.h" +#include "grit/chromium_strings.h" +#include "grit/generated_resources.h" + +void ImportLockDialogCocoa::ShowWarning(ImporterHost* importer) { + scoped_nsobject<NSAlert> lock_alert([[NSAlert alloc] init]); + [lock_alert addButtonWithTitle:l10n_util::GetNSStringWithFixup( + IDS_IMPORTER_LOCK_OK)]; + [lock_alert addButtonWithTitle:l10n_util::GetNSStringWithFixup( + IDS_IMPORTER_LOCK_CANCEL)]; + [lock_alert setInformativeText:l10n_util::GetNSStringWithFixup( + IDS_IMPORTER_LOCK_TEXT)]; + [lock_alert setMessageText:l10n_util::GetNSStringWithFixup( + IDS_IMPORTER_LOCK_TITLE)]; + + if ([lock_alert runModal] == NSAlertFirstButtonReturn) { + MessageLoop::current()->PostTask(FROM_HERE, NewRunnableMethod( + importer, &ImporterHost::OnLockViewEnd, true)); + } else { + MessageLoop::current()->PostTask(FROM_HERE, NewRunnableMethod( + importer, &ImporterHost::OnLockViewEnd, false)); + } +} diff --git a/chrome/browser/ui/cocoa/info_bubble_view.h b/chrome/browser/ui/cocoa/info_bubble_view.h new file mode 100644 index 0000000..974581b --- /dev/null +++ b/chrome/browser/ui/cocoa/info_bubble_view.h @@ -0,0 +1,50 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_INFO_BUBBLE_VIEW_H_ +#define CHROME_BROWSER_UI_COCOA_INFO_BUBBLE_VIEW_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +namespace info_bubble { + +const CGFloat kBubbleArrowHeight = 8.0; +const CGFloat kBubbleArrowWidth = 15.0; +const CGFloat kBubbleCornerRadius = 8.0; +const CGFloat kBubbleArrowXOffset = kBubbleArrowWidth + kBubbleCornerRadius; + +enum BubbleArrowLocation { + kTopLeft, + kTopRight, +}; + +enum InfoBubbleType { + kWhiteInfoBubble, + // Gradient bubbles are deprecated, per alcor@google.com. Please use white. + kGradientInfoBubble +}; + +} // namespace info_bubble + +// Content view for a bubble with an arrow showing arbitrary content. +// This is where nonrectangular drawing happens. +@interface InfoBubbleView : NSView { + @private + info_bubble::BubbleArrowLocation arrowLocation_; + + // The type simply is used to determine what sort of background it should + // draw. + info_bubble::InfoBubbleType bubbleType_; +} + +@property (assign, nonatomic) info_bubble::BubbleArrowLocation arrowLocation; +@property (assign, nonatomic) info_bubble::InfoBubbleType bubbleType; + +// Returns the point location in view coordinates of the tip of the arrow. +- (NSPoint)arrowTip; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_INFO_BUBBLE_VIEW_H_ diff --git a/chrome/browser/ui/cocoa/info_bubble_view.mm b/chrome/browser/ui/cocoa/info_bubble_view.mm new file mode 100644 index 0000000..8033cb5 --- /dev/null +++ b/chrome/browser/ui/cocoa/info_bubble_view.mm @@ -0,0 +1,103 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/info_bubble_view.h" + +#include "base/logging.h" +#include "base/scoped_nsobject.h" +#import "third_party/GTM/AppKit/GTMNSColor+Luminance.h" + +@implementation InfoBubbleView + +@synthesize arrowLocation = arrowLocation_; +@synthesize bubbleType = bubbleType_; + +- (id)initWithFrame:(NSRect)frameRect { + if ((self = [super initWithFrame:frameRect])) { + arrowLocation_ = info_bubble::kTopLeft; + bubbleType_ = info_bubble::kWhiteInfoBubble; + } + + return self; +} + +- (void)drawRect:(NSRect)rect { + // Make room for the border to be seen. + NSRect bounds = [self bounds]; + bounds.size.height -= info_bubble::kBubbleArrowHeight; + NSBezierPath* bezier = [NSBezierPath bezierPath]; + rect.size.height -= info_bubble::kBubbleArrowHeight; + + // Start with a rounded rectangle. + [bezier appendBezierPathWithRoundedRect:bounds + xRadius:info_bubble::kBubbleCornerRadius + yRadius:info_bubble::kBubbleCornerRadius]; + + // Add the bubble arrow. + CGFloat dX = 0; + switch (arrowLocation_) { + case info_bubble::kTopLeft: + dX = info_bubble::kBubbleArrowXOffset; + break; + case info_bubble::kTopRight: + dX = NSWidth(bounds) - info_bubble::kBubbleArrowXOffset - + info_bubble::kBubbleArrowWidth; + break; + default: + NOTREACHED(); + break; + } + NSPoint arrowStart = NSMakePoint(NSMinX(bounds), NSMaxY(bounds)); + arrowStart.x += dX; + [bezier moveToPoint:NSMakePoint(arrowStart.x, arrowStart.y)]; + [bezier lineToPoint:NSMakePoint(arrowStart.x + + info_bubble::kBubbleArrowWidth / 2.0, + arrowStart.y + + info_bubble::kBubbleArrowHeight)]; + [bezier lineToPoint:NSMakePoint(arrowStart.x + info_bubble::kBubbleArrowWidth, + arrowStart.y)]; + [bezier closePath]; + + // Then fill the inside depending on the type of bubble. + if (bubbleType_ == info_bubble::kGradientInfoBubble) { + NSColor* base_color = [NSColor colorWithCalibratedWhite:0.5 alpha:1.0]; + NSColor* startColor = + [base_color gtm_colorAdjustedFor:GTMColorationLightHighlight + faded:YES]; + NSColor* midColor = + [base_color gtm_colorAdjustedFor:GTMColorationLightMidtone + faded:YES]; + NSColor* endColor = + [base_color gtm_colorAdjustedFor:GTMColorationLightShadow + faded:YES]; + NSColor* glowColor = + [base_color gtm_colorAdjustedFor:GTMColorationLightPenumbra + faded:YES]; + + scoped_nsobject<NSGradient> gradient( + [[NSGradient alloc] initWithColorsAndLocations:startColor, 0.0, + midColor, 0.25, + endColor, 0.5, + glowColor, 0.75, + nil]); + + [gradient.get() drawInBezierPath:bezier angle:0.0]; + } else if (bubbleType_ == info_bubble::kWhiteInfoBubble) { + [[NSColor whiteColor] set]; + [bezier fill]; + } +} + +- (NSPoint)arrowTip { + NSRect bounds = [self bounds]; + CGFloat tipXOffset = + info_bubble::kBubbleArrowXOffset + info_bubble::kBubbleArrowWidth / 2.0; + CGFloat xOffset = + (arrowLocation_ == info_bubble::kTopRight) ? NSMaxX(bounds) - tipXOffset : + NSMinX(bounds) + tipXOffset; + NSPoint arrowTip = NSMakePoint(xOffset, NSMaxY(bounds)); + return arrowTip; +} + +@end diff --git a/chrome/browser/ui/cocoa/info_bubble_view_unittest.mm b/chrome/browser/ui/cocoa/info_bubble_view_unittest.mm new file mode 100644 index 0000000..c3c438d --- /dev/null +++ b/chrome/browser/ui/cocoa/info_bubble_view_unittest.mm @@ -0,0 +1,26 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/info_bubble_view.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" + +namespace { + +class InfoBubbleViewTest : public CocoaTest { + public: + InfoBubbleViewTest() { + NSRect frame = NSMakeRect(0, 0, 100, 30); + scoped_nsobject<InfoBubbleView> view( + [[InfoBubbleView alloc] initWithFrame:frame]); + view_ = view.get(); + [[test_window() contentView] addSubview:view_]; + } + + InfoBubbleView* view_; +}; + +TEST_VIEW(InfoBubbleViewTest, view_); + +} // namespace diff --git a/chrome/browser/ui/cocoa/info_bubble_window.h b/chrome/browser/ui/cocoa/info_bubble_window.h new file mode 100644 index 0000000..38ae44d --- /dev/null +++ b/chrome/browser/ui/cocoa/info_bubble_window.h @@ -0,0 +1,32 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_ptr.h" +#import "chrome/browser/ui/cocoa/chrome_event_processing_window.h" + +class AppNotificationBridge; + +// A rounded window with an arrow used for example when you click on the STAR +// button or that pops up within our first-run UI. +@interface InfoBubbleWindow : ChromeEventProcessingWindow { + @private + // Is self in the process of closing. + BOOL closing_; + // If NO the window will close immediately instead of fading out. + // Default YES. + BOOL delayOnClose_; + // Bridge to proxy Chrome notifications to the window. + scoped_ptr<AppNotificationBridge> notificationBridge_; +} + +// Returns YES if the window is in the process of closing. +// Can't use "windowWillClose" notification because that will be sent +// after the closing animation has completed. +- (BOOL)isClosing; + +@property (nonatomic) BOOL delayOnClose; + +@end diff --git a/chrome/browser/ui/cocoa/info_bubble_window.mm b/chrome/browser/ui/cocoa/info_bubble_window.mm new file mode 100644 index 0000000..183cf8a --- /dev/null +++ b/chrome/browser/ui/cocoa/info_bubble_window.mm @@ -0,0 +1,222 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/info_bubble_window.h" + +#include "base/basictypes.h" +#include "base/logging.h" +#include "base/scoped_nsobject.h" +#include "chrome/common/notification_observer.h" +#include "chrome/common/notification_registrar.h" +#include "chrome/common/notification_service.h" +#include "chrome/common/notification_type.h" +#import "third_party/GTM/AppKit/GTMNSAnimation+Duration.h" + +namespace { +const CGFloat kOrderInSlideOffset = 10; +const NSTimeInterval kOrderInAnimationDuration = 0.2; +const NSTimeInterval kOrderOutAnimationDuration = 0.15; +// The minimum representable time interval. This can be used as the value +// passed to +[NSAnimationContext setDuration:] to stop an in-progress +// animation as quickly as possible. +const NSTimeInterval kMinimumTimeInterval = + std::numeric_limits<NSTimeInterval>::min(); +} + +@interface InfoBubbleWindow(Private) +- (void)appIsTerminating; +- (void)finishCloseAfterAnimation; +@end + +// A helper class to proxy app notifications to the window. +class AppNotificationBridge : public NotificationObserver { + public: + explicit AppNotificationBridge(InfoBubbleWindow* owner) : owner_(owner) { + registrar_.Add(this, NotificationType::APP_TERMINATING, + NotificationService::AllSources()); + } + + // Overridden from NotificationObserver. + void Observe(NotificationType type, + const NotificationSource& source, + const NotificationDetails& details) { + switch (type.value) { + case NotificationType::APP_TERMINATING: + [owner_ appIsTerminating]; + break; + default: + NOTREACHED() << L"Unexpected notification"; + } + } + + private: + // The object we need to inform when we get a notification. Weak. Owns us. + InfoBubbleWindow* owner_; + + // Used for registering to receive notifications and automatic clean up. + NotificationRegistrar registrar_; + + DISALLOW_COPY_AND_ASSIGN(AppNotificationBridge); +}; + +// A delegate object for watching the alphaValue animation on InfoBubbleWindows. +// An InfoBubbleWindow instance cannot be the delegate for its own animation +// because CAAnimations retain their delegates, and since the InfoBubbleWindow +// retains its animations a retain loop would be formed. +@interface InfoBubbleWindowCloser : NSObject { + @private + InfoBubbleWindow* window_; // Weak. Window to close. +} +- (id)initWithWindow:(InfoBubbleWindow*)window; +@end + +@implementation InfoBubbleWindowCloser + +- (id)initWithWindow:(InfoBubbleWindow*)window { + if ((self = [super init])) { + window_ = window; + } + return self; +} + +// Callback for the alpha animation. Closes window_ if appropriate. +- (void)animationDidStop:(CAAnimation*)anim finished:(BOOL)flag { + // When alpha reaches zero, close window_. + if ([window_ alphaValue] == 0.0) { + [window_ finishCloseAfterAnimation]; + } +} + +@end + + +@implementation InfoBubbleWindow + +@synthesize delayOnClose = delayOnClose_; + +- (id)initWithContentRect:(NSRect)contentRect + styleMask:(NSUInteger)aStyle + backing:(NSBackingStoreType)bufferingType + defer:(BOOL)flag { + if ((self = [super initWithContentRect:contentRect + styleMask:NSBorderlessWindowMask + backing:bufferingType + defer:flag])) { + [self setBackgroundColor:[NSColor clearColor]]; + [self setExcludedFromWindowsMenu:YES]; + [self setOpaque:NO]; + [self setHasShadow:YES]; + delayOnClose_ = YES; + notificationBridge_.reset(new AppNotificationBridge(self)); + + // Start invisible. Will be made visible when ordered front. + [self setAlphaValue:0.0]; + + // Set up alphaValue animation so that self is delegate for the animation. + // Setting up the delegate is required so that the + // animationDidStop:finished: callback can be handled. + // Notice that only the alphaValue Animation is replaced in case + // superclasses set up animations. + CAAnimation* alphaAnimation = [CABasicAnimation animation]; + scoped_nsobject<InfoBubbleWindowCloser> delegate( + [[InfoBubbleWindowCloser alloc] initWithWindow:self]); + [alphaAnimation setDelegate:delegate]; + NSMutableDictionary* animations = + [NSMutableDictionary dictionaryWithDictionary:[self animations]]; + [animations setObject:alphaAnimation forKey:@"alphaValue"]; + [self setAnimations:animations]; + } + return self; +} + +// According to +// http://www.cocoabuilder.com/archive/message/cocoa/2006/6/19/165953, +// NSBorderlessWindowMask windows cannot become key or main. In this +// case, this is not a desired behavior. As an example, the bubble could have +// buttons. +- (BOOL)canBecomeKeyWindow { + return YES; +} + +- (void)close { + // Block the window from receiving events while it fades out. + closing_ = YES; + + if (!delayOnClose_) { + [self finishCloseAfterAnimation]; + } else { + // Apply animations to hide self. + [NSAnimationContext beginGrouping]; + [[NSAnimationContext currentContext] + gtm_setDuration:kOrderOutAnimationDuration + eventMask:NSLeftMouseUpMask]; + [[self animator] setAlphaValue:0.0]; + [NSAnimationContext endGrouping]; + } +} + +// If the app is terminating but the window is still fading out, cancel the +// animation and close the window to prevent it from leaking. +// See http://crbug.com/37717 +- (void)appIsTerminating { + if (!delayOnClose_) + return; // The close has already happened with no Core Animation. + + // Cancel the current animation so that it closes immediately, triggering + // |finishCloseAfterAnimation|. + [NSAnimationContext beginGrouping]; + [[NSAnimationContext currentContext] setDuration:kMinimumTimeInterval]; + [[self animator] setAlphaValue:0.0]; + [NSAnimationContext endGrouping]; +} + +// Called by InfoBubbleWindowCloser when the window is to be really closed +// after the fading animation is complete. +- (void)finishCloseAfterAnimation { + if (closing_) + [super close]; +} + +// Adds animation for info bubbles being ordered to the front. +- (void)orderWindow:(NSWindowOrderingMode)orderingMode + relativeTo:(NSInteger)otherWindowNumber { + // According to the documentation '0' is the otherWindowNumber when the window + // is ordered front. + if (orderingMode == NSWindowAbove && otherWindowNumber == 0) { + // Order self appropriately assuming that its alpha is zero as set up + // in the designated initializer. + [super orderWindow:orderingMode relativeTo:otherWindowNumber]; + + // Set up frame so it can be adjust down by a few pixels. + NSRect frame = [self frame]; + NSPoint newOrigin = frame.origin; + newOrigin.y += kOrderInSlideOffset; + [self setFrameOrigin:newOrigin]; + + // Apply animations to show and move self. + [NSAnimationContext beginGrouping]; + // The star currently triggers on mouse down, not mouse up. + [[NSAnimationContext currentContext] + gtm_setDuration:kOrderInAnimationDuration + eventMask:NSLeftMouseUpMask|NSLeftMouseDownMask]; + [[self animator] setAlphaValue:1.0]; + [[self animator] setFrame:frame display:YES]; + [NSAnimationContext endGrouping]; + } else { + [super orderWindow:orderingMode relativeTo:otherWindowNumber]; + } +} + +// If the window is currently animating a close, block all UI events to the +// window. +- (void)sendEvent:(NSEvent*)theEvent { + if (!closing_) + [super sendEvent:theEvent]; +} + +- (BOOL)isClosing { + return closing_; +} + +@end diff --git a/chrome/browser/ui/cocoa/info_bubble_window_unittest.mm b/chrome/browser/ui/cocoa/info_bubble_window_unittest.mm new file mode 100644 index 0000000..aed5c0a --- /dev/null +++ b/chrome/browser/ui/cocoa/info_bubble_window_unittest.mm @@ -0,0 +1,22 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/scoped_ptr.h" +#include "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "chrome/browser/ui/cocoa/info_bubble_window.h" + +class InfoBubbleWindowTest : public CocoaTest {}; + +TEST_F(InfoBubbleWindowTest, Basics) { + InfoBubbleWindow* window = + [[InfoBubbleWindow alloc] initWithContentRect:NSMakeRect(0, 0, 10, 10) + styleMask:NSBorderlessWindowMask + backing:NSBackingStoreBuffered + defer:NO]; + EXPECT_TRUE([window canBecomeKeyWindow]); + EXPECT_FALSE([window canBecomeMainWindow]); + + EXPECT_TRUE([window isExcludedFromWindowsMenu]); + [window close]; +} diff --git a/chrome/browser/ui/cocoa/infobar.h b/chrome/browser/ui/cocoa/infobar.h new file mode 100644 index 0000000..fc51da7 --- /dev/null +++ b/chrome/browser/ui/cocoa/infobar.h @@ -0,0 +1,48 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_INFOBAR_H_ +#define CHROME_BROWSER_UI_COCOA_INFOBAR_H_ +#pragma once + +#include "base/logging.h" // for DCHECK + +@class InfoBarController; + +// A C++ wrapper around an Objective-C InfoBarController. This class +// exists solely to be the return value for InfoBarDelegate::CreateInfoBar(), +// as defined in chrome/browser/tab_contents/infobar_delegate.h. This +// class would be analogous to the various bridge classes we already +// have, but since there is no pre-defined InfoBar interface, it is +// easier to simply throw away this object and deal with the +// controller directly rather than pass messages through a bridge. +// +// Callers should delete the returned InfoBar immediately after +// calling CreateInfoBar(), as the returned InfoBar* object is not +// pointed to by anyone. Expected usage: +// +// scoped_ptr<InfoBar> infobar(delegate->CreateInfoBar()); +// InfoBarController* controller = infobar->controller(); +// // Do something with the controller, and save a pointer so it can be +// // deleted later. |infobar| will be deleted automatically. + +class InfoBar { + public: + InfoBar(InfoBarController* controller) { + DCHECK(controller); + controller_ = controller; + } + + InfoBarController* controller() { + return controller_; + } + + private: + // Pointer to the infobar controller. Is never null. + InfoBarController* controller_; // weak + + DISALLOW_COPY_AND_ASSIGN(InfoBar); +}; + +#endif // CHROME_BROWSER_UI_COCOA_INFOBAR_H_ diff --git a/chrome/browser/ui/cocoa/infobar_container_controller.h b/chrome/browser/ui/cocoa/infobar_container_controller.h new file mode 100644 index 0000000..82a7e52 --- /dev/null +++ b/chrome/browser/ui/cocoa/infobar_container_controller.h @@ -0,0 +1,113 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_INFOBAR_CONTAINER_CONTROLLER_H_ +#define CHROME_BROWSER_UI_COCOA_INFOBAR_CONTAINER_CONTROLLER_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#include "base/scoped_ptr.h" +#import "chrome/browser/ui/cocoa/view_resizer.h" +#include "chrome/common/notification_registrar.h" + +@class InfoBarController; +class InfoBarDelegate; +class InfoBarNotificationObserver; +class TabContents; +class TabStripModel; + +// Protocol for basic container methods, as needed by an InfoBarController. +// This protocol exists to make mocking easier in unittests. +@protocol InfoBarContainer +- (void)removeDelegate:(InfoBarDelegate*)delegate; +- (void)removeController:(InfoBarController*)controller; +@end + +// Controller for the infobar container view, which is the superview +// of all the infobar views. This class owns zero or more +// InfoBarControllers, which manage the infobar views. This class +// also receives tab strip model notifications and handles +// adding/removing infobars when needed. +@interface InfoBarContainerController : NSViewController <ViewResizer, + InfoBarContainer> { + @private + // Needed to send resize messages when infobars are added or removed. + id<ViewResizer> resizeDelegate_; // weak + + // The TabContents we are currently showing infobars for. + TabContents* currentTabContents_; // weak + + // Holds the InfoBarControllers currently owned by this container. + scoped_nsobject<NSMutableArray> infobarControllers_; + + // Lets us registers for INFOBAR_ADDED/INFOBAR_REMOVED + // notifications. The actual notifications are sent to the + // InfoBarNotificationObserver object, which proxies them back to us. + NotificationRegistrar registrar_; + scoped_ptr<InfoBarNotificationObserver> infoBarObserver_; +} + +- (id)initWithResizeDelegate:(id<ViewResizer>)resizeDelegate; + +// Informs the selected TabContents that the infobars for the given +// |delegate| need to be removed. Does not remove any infobar views +// directly, as they will be removed when handling the subsequent +// INFOBAR_REMOVED notification. Does not notify |delegate| that the +// infobar was closed. +- (void)removeDelegate:(InfoBarDelegate*)delegate; + +// Removes |controller| from the list of controllers in this container and +// removes its view from the view hierarchy. This method is safe to call while +// |controller| is still on the call stack. +- (void)removeController:(InfoBarController*)controller; + +// Modifies this container to display infobars for the given +// |contents|. Registers for INFOBAR_ADDED and INFOBAR_REMOVED +// notifications for |contents|. If we are currently showing any +// infobars, removes them first and deregisters for any +// notifications. |contents| can be NULL, in which case no infobars +// are shown and no notifications are registered for. +- (void)changeTabContents:(TabContents*)contents; + +// Stripped down version of TabStripModelObserverBridge:tabDetachedWithContents. +// Forwarded by BWC. Removes all infobars and deregisters for any notifications +// if |contents| is the current tab contents. +- (void)tabDetachedWithContents:(TabContents*)contents; + +@end + + +@interface InfoBarContainerController (ForTheObserverAndTesting) + +// Adds an infobar view for the given delegate. +- (void)addInfoBar:(InfoBarDelegate*)delegate animate:(BOOL)animate; + +// Closes all the infobar views for a given delegate, either immediately or by +// starting a close animation. +- (void)closeInfoBarsForDelegate:(InfoBarDelegate*)delegate + animate:(BOOL)animate; + +// Replaces all info bars for the delegate with a new info bar. +// This simply calls closeInfoBarsForDelegate: and then addInfoBar:. +- (void)replaceInfoBarsForDelegate:(InfoBarDelegate*)old_delegate + with:(InfoBarDelegate*)new_delegate; + +// Positions the infobar views in the container view and notifies +// |browser_controller_| that it needs to resize the container view. +- (void)positionInfoBarsAndRedraw; + +@end + + +@interface InfoBarContainerController (JustForTesting) + +// Removes all infobar views. Callers must call +// positionInfoBarsAndRedraw() after calling this method. +- (void)removeAllInfoBars; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_INFOBAR_CONTAINER_CONTROLLER_H_ diff --git a/chrome/browser/ui/cocoa/infobar_container_controller.mm b/chrome/browser/ui/cocoa/infobar_container_controller.mm new file mode 100644 index 0000000..b9d32cc --- /dev/null +++ b/chrome/browser/ui/cocoa/infobar_container_controller.mm @@ -0,0 +1,228 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/logging.h" +#include "base/mac_util.h" +#include "chrome/browser/tab_contents/infobar_delegate.h" +#include "chrome/browser/tab_contents/tab_contents.h" +#import "chrome/browser/ui/cocoa/animatable_view.h" +#include "chrome/browser/ui/cocoa/infobar.h" +#import "chrome/browser/ui/cocoa/infobar_container_controller.h" +#import "chrome/browser/ui/cocoa/infobar_controller.h" +#import "chrome/browser/ui/cocoa/view_id_util.h" +#include "chrome/common/notification_service.h" +#include "skia/ext/skia_utils_mac.h" + +// C++ class that receives INFOBAR_ADDED and INFOBAR_REMOVED +// notifications and proxies them back to |controller|. +class InfoBarNotificationObserver : public NotificationObserver { + public: + InfoBarNotificationObserver(InfoBarContainerController* controller) + : controller_(controller) { + } + + private: + // NotificationObserver implementation + void Observe(NotificationType type, + const NotificationSource& source, + const NotificationDetails& details) { + switch (type.value) { + case NotificationType::TAB_CONTENTS_INFOBAR_ADDED: + [controller_ addInfoBar:Details<InfoBarDelegate>(details).ptr() + animate:YES]; + break; + case NotificationType::TAB_CONTENTS_INFOBAR_REMOVED: + [controller_ + closeInfoBarsForDelegate:Details<InfoBarDelegate>(details).ptr() + animate:YES]; + break; + case NotificationType::TAB_CONTENTS_INFOBAR_REPLACED: { + typedef std::pair<InfoBarDelegate*, InfoBarDelegate*> + InfoBarDelegatePair; + InfoBarDelegatePair* delegates = + Details<InfoBarDelegatePair>(details).ptr(); + [controller_ + replaceInfoBarsForDelegate:delegates->first with:delegates->second]; + break; + } + default: + NOTREACHED(); // we don't ask for anything else! + break; + } + + [controller_ positionInfoBarsAndRedraw]; + } + + InfoBarContainerController* controller_; // weak, owns us. +}; + + +@interface InfoBarContainerController (PrivateMethods) +// Returns the desired height of the container view, computed by +// adding together the heights of all its subviews. +- (CGFloat)desiredHeight; + +@end + + +@implementation InfoBarContainerController +- (id)initWithResizeDelegate:(id<ViewResizer>)resizeDelegate { + DCHECK(resizeDelegate); + if ((self = [super initWithNibName:@"InfoBarContainer" + bundle:mac_util::MainAppBundle()])) { + resizeDelegate_ = resizeDelegate; + infoBarObserver_.reset(new InfoBarNotificationObserver(self)); + + // NSMutableArray needs an initial capacity, and we rarely ever see + // more than two infobars at a time, so that seems like a good choice. + infobarControllers_.reset([[NSMutableArray alloc] initWithCapacity:2]); + } + return self; +} + +- (void)dealloc { + DCHECK([infobarControllers_ count] == 0); + view_id_util::UnsetID([self view]); + [super dealloc]; +} + +- (void)awakeFromNib { + // The info bar container view is an ordinary NSView object, so we set its + // ViewID here. + view_id_util::SetID([self view], VIEW_ID_INFO_BAR_CONTAINER); +} + +- (void)removeDelegate:(InfoBarDelegate*)delegate { + DCHECK(currentTabContents_); + currentTabContents_->RemoveInfoBar(delegate); +} + +- (void)removeController:(InfoBarController*)controller { + if (![infobarControllers_ containsObject:controller]) + return; + + // This code can be executed while InfoBarController is still on the stack, so + // we retain and autorelease the controller to prevent it from being + // dealloc'ed too early. + [[controller retain] autorelease]; + [[controller view] removeFromSuperview]; + [infobarControllers_ removeObject:controller]; + [self positionInfoBarsAndRedraw]; +} + +- (void)changeTabContents:(TabContents*)contents { + registrar_.RemoveAll(); + [self removeAllInfoBars]; + + currentTabContents_ = contents; + if (currentTabContents_) { + for (int i = 0; i < currentTabContents_->infobar_delegate_count(); ++i) { + [self addInfoBar:currentTabContents_->GetInfoBarDelegateAt(i) + animate:NO]; + } + + Source<TabContents> source(currentTabContents_); + registrar_.Add(infoBarObserver_.get(), + NotificationType::TAB_CONTENTS_INFOBAR_ADDED, source); + registrar_.Add(infoBarObserver_.get(), + NotificationType::TAB_CONTENTS_INFOBAR_REMOVED, source); + registrar_.Add(infoBarObserver_.get(), + NotificationType::TAB_CONTENTS_INFOBAR_REPLACED, source); + } + + [self positionInfoBarsAndRedraw]; +} + +- (void)tabDetachedWithContents:(TabContents*)contents { + if (currentTabContents_ == contents) + [self changeTabContents:NULL]; +} + +- (void)resizeView:(NSView*)view newHeight:(CGFloat)height { + NSRect frame = [view frame]; + frame.size.height = height; + [view setFrame:frame]; + [self positionInfoBarsAndRedraw]; +} + +- (void)setAnimationInProgress:(BOOL)inProgress { + if ([resizeDelegate_ respondsToSelector:@selector(setAnimationInProgress:)]) + [resizeDelegate_ setAnimationInProgress:inProgress]; +} + +@end + +@implementation InfoBarContainerController (PrivateMethods) + +- (CGFloat)desiredHeight { + CGFloat height = 0; + for (InfoBarController* controller in infobarControllers_.get()) + height += NSHeight([[controller view] frame]); + return height; +} + +- (void)addInfoBar:(InfoBarDelegate*)delegate animate:(BOOL)animate { + scoped_ptr<InfoBar> infobar(delegate->CreateInfoBar()); + InfoBarController* controller = infobar->controller(); + [controller setContainerController:self]; + [[controller animatableView] setResizeDelegate:self]; + [[self view] addSubview:[controller view]]; + [infobarControllers_ addObject:[controller autorelease]]; + + if (animate) + [controller animateOpen]; + else + [controller open]; +} + +- (void)closeInfoBarsForDelegate:(InfoBarDelegate*)delegate + animate:(BOOL)animate { + for (InfoBarController* controller in + [NSArray arrayWithArray:infobarControllers_.get()]) { + if ([controller delegate] == delegate) { + if (animate) + [controller animateClosed]; + else + [controller close]; + } + } +} + +- (void)replaceInfoBarsForDelegate:(InfoBarDelegate*)old_delegate + with:(InfoBarDelegate*)new_delegate { + [self closeInfoBarsForDelegate:old_delegate animate:NO]; + [self addInfoBar:new_delegate animate:NO]; +} + +- (void)removeAllInfoBars { + for (InfoBarController* controller in infobarControllers_.get()) { + [[controller animatableView] stopAnimation]; + [[controller view] removeFromSuperview]; + } + [infobarControllers_ removeAllObjects]; +} + +- (void)positionInfoBarsAndRedraw { + NSRect containerBounds = [[self view] bounds]; + int minY = 0; + + // Stack the infobars at the bottom of the view, starting with the + // last infobar and working our way to the front of the array. This + // way we ensure that the first infobar added shows up on top, with + // the others below. + for (InfoBarController* controller in + [infobarControllers_ reverseObjectEnumerator]) { + NSView* view = [controller view]; + NSRect frame = [view frame]; + frame.origin.x = NSMinX(containerBounds); + frame.size.width = NSWidth(containerBounds); + frame.origin.y = minY; + minY += frame.size.height; + [view setFrame:frame]; + } + + [resizeDelegate_ resizeView:[self view] newHeight:[self desiredHeight]]; +} + +@end diff --git a/chrome/browser/ui/cocoa/infobar_container_controller_unittest.mm b/chrome/browser/ui/cocoa/infobar_container_controller_unittest.mm new file mode 100644 index 0000000..f04b1c1 --- /dev/null +++ b/chrome/browser/ui/cocoa/infobar_container_controller_unittest.mm @@ -0,0 +1,95 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/infobar_container_controller.h" +#include "chrome/browser/ui/cocoa/infobar_test_helper.h" +#import "chrome/browser/ui/cocoa/view_resizer_pong.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +namespace { + +class InfoBarContainerControllerTest : public CocoaTest { + virtual void SetUp() { + CocoaTest::SetUp(); + resizeDelegate_.reset([[ViewResizerPong alloc] init]); + ViewResizerPong *viewResizer = resizeDelegate_.get(); + controller_ = + [[InfoBarContainerController alloc] initWithResizeDelegate:viewResizer]; + NSView* view = [controller_ view]; + [[test_window() contentView] addSubview:view]; + } + + virtual void TearDown() { + [[controller_ view] removeFromSuperviewWithoutNeedingDisplay]; + [controller_ release]; + CocoaTest::TearDown(); + } + + public: + scoped_nsobject<ViewResizerPong> resizeDelegate_; + InfoBarContainerController* controller_; +}; + +TEST_VIEW(InfoBarContainerControllerTest, [controller_ view]) + +TEST_F(InfoBarContainerControllerTest, BWCPong) { + // Call positionInfoBarsAndResize and check that |resizeDelegate_| got a + // resize message. + [resizeDelegate_ setHeight:-1]; + [controller_ positionInfoBarsAndRedraw]; + EXPECT_NE(-1, [resizeDelegate_ height]); +} + +TEST_F(InfoBarContainerControllerTest, AddAndRemoveInfoBars) { + NSView* view = [controller_ view]; + + // Add three infobars, one of each type, and then remove them. + // After each step check to make sure we have the correct number of + // infobar subviews. + MockAlertInfoBarDelegate alertDelegate; + MockLinkInfoBarDelegate linkDelegate; + MockConfirmInfoBarDelegate confirmDelegate; + + [controller_ addInfoBar:&alertDelegate animate:NO]; + EXPECT_EQ(1U, [[view subviews] count]); + + [controller_ addInfoBar:&linkDelegate animate:NO]; + EXPECT_EQ(2U, [[view subviews] count]); + + [controller_ addInfoBar:&confirmDelegate animate:NO]; + EXPECT_EQ(3U, [[view subviews] count]); + + // Just to mix things up, remove them in a different order. + [controller_ closeInfoBarsForDelegate:&linkDelegate animate:NO]; + EXPECT_EQ(2U, [[view subviews] count]); + + [controller_ closeInfoBarsForDelegate:&confirmDelegate animate:NO]; + EXPECT_EQ(1U, [[view subviews] count]); + + [controller_ closeInfoBarsForDelegate:&alertDelegate animate:NO]; + EXPECT_EQ(0U, [[view subviews] count]); +} + +TEST_F(InfoBarContainerControllerTest, RemoveAllInfoBars) { + NSView* view = [controller_ view]; + + // Add three infobars and then remove them all. + MockAlertInfoBarDelegate alertDelegate; + MockLinkInfoBarDelegate linkDelegate; + MockConfirmInfoBarDelegate confirmDelegate; + + [controller_ addInfoBar:&alertDelegate animate:NO]; + [controller_ addInfoBar:&linkDelegate animate:NO]; + [controller_ addInfoBar:&confirmDelegate animate:NO]; + EXPECT_EQ(3U, [[view subviews] count]); + + [controller_ removeAllInfoBars]; + EXPECT_EQ(0U, [[view subviews] count]); +} +} // namespace diff --git a/chrome/browser/ui/cocoa/infobar_controller.h b/chrome/browser/ui/cocoa/infobar_controller.h new file mode 100644 index 0000000..265700c --- /dev/null +++ b/chrome/browser/ui/cocoa/infobar_controller.h @@ -0,0 +1,106 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#import "base/cocoa_protocols_mac.h" +#include "base/scoped_nsobject.h" + +@class AnimatableView; +@class HoverCloseButton; +@protocol InfoBarContainer; +class InfoBarDelegate; +@class InfoBarGradientView; + +// A controller for an infobar in the browser window. There is one +// controller per infobar view. The base InfoBarController is able to +// draw an icon, a text message, and a close button. Subclasses can +// override addAdditionalControls to customize the UI. +@interface InfoBarController : NSViewController<NSTextViewDelegate> { + @private + id<InfoBarContainer> containerController_; // weak, owns us + BOOL infoBarClosing_; + + @protected + IBOutlet InfoBarGradientView* infoBarView_; + IBOutlet NSImageView* image_; + IBOutlet NSTextField* labelPlaceholder_; + IBOutlet NSButton* okButton_; + IBOutlet NSButton* cancelButton_; + IBOutlet HoverCloseButton* closeButton_; + + // In rare instances, it can be possible for |delegate_| to delete itself + // while this controller is still alive. Always check |delegate_| against + // NULL before using it. + InfoBarDelegate* delegate_; // weak, can be NULL + + // Text fields don't work as well with embedded links as text views, but + // text views cannot conveniently be created in IB. The xib file contains + // a text field |labelPlaceholder_| that's replaced by this text view |label_| + // in -awakeFromNib. + scoped_nsobject<NSTextView> label_; +}; + +// Initializes a new InfoBarController. +- (id)initWithDelegate:(InfoBarDelegate*)delegate; + +// Called when someone clicks on the OK or Cancel buttons. Subclasses +// must override if they do not hide the buttons. +- (void)ok:(id)sender; +- (void)cancel:(id)sender; + +// Called when someone clicks on the close button. Dismisses the +// infobar without taking any action. +- (IBAction)dismiss:(id)sender; + +// Returns a pointer to this controller's view, cast as an AnimatableView. +- (AnimatableView*)animatableView; + +// Open or animate open the infobar. +- (void)open; +- (void)animateOpen; + +// Close or animate close the infobar. +- (void)close; +- (void)animateClosed; + +// Subclasses can override this method to add additional controls to +// the infobar view. This method is called by awakeFromNib. The +// default implementation does nothing. +- (void)addAdditionalControls; + +// Sets the info bar message to the specified |message|. +- (void)setLabelToMessage:(NSString*)message; + +// Removes the OK and Cancel buttons and resizes the textfield to use the +// space. +- (void)removeButtons; + +@property(nonatomic, assign) id<InfoBarContainer> containerController; +@property(nonatomic, readonly) InfoBarDelegate* delegate; + +@end + +///////////////////////////////////////////////////////////////////////// +// InfoBarController subclasses, one for each InfoBarDelegate +// subclass. Each of these subclasses overrides addAdditionalControls to +// configure its view as necessary. + +@interface AlertInfoBarController : InfoBarController +@end + + +@interface LinkInfoBarController : InfoBarController +// Called when there is a click on the link in the infobar. +- (void)linkClicked; +@end + + +@interface ConfirmInfoBarController : InfoBarController +// Called when the OK and Cancel buttons are clicked. +- (IBAction)ok:(id)sender; +- (IBAction)cancel:(id)sender; +// Called when there is a click on the link in the infobar. +- (void)linkClicked; +@end diff --git a/chrome/browser/ui/cocoa/infobar_controller.mm b/chrome/browser/ui/cocoa/infobar_controller.mm new file mode 100644 index 0000000..1400c66 --- /dev/null +++ b/chrome/browser/ui/cocoa/infobar_controller.mm @@ -0,0 +1,534 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/logging.h" // for NOTREACHED() +#include "base/mac_util.h" +#include "base/sys_string_conversions.h" +#include "chrome/browser/tab_contents/infobar_delegate.h" +#include "chrome/browser/tab_contents/tab_contents.h" +#import "chrome/browser/ui/cocoa/animatable_view.h" +#include "chrome/browser/ui/cocoa/event_utils.h" +#include "chrome/browser/ui/cocoa/infobar.h" +#import "chrome/browser/ui/cocoa/infobar_container_controller.h" +#import "chrome/browser/ui/cocoa/infobar_controller.h" +#include "skia/ext/skia_utils_mac.h" +#include "third_party/GTM/AppKit/GTMUILocalizerAndLayoutTweaker.h" +#include "webkit/glue/window_open_disposition.h" + +namespace { +// Durations set to match the default SlideAnimation duration. +const float kAnimateOpenDuration = 0.12; +const float kAnimateCloseDuration = 0.12; +} + +// This simple subclass of |NSTextView| just doesn't show the (text) cursor +// (|NSTextView| displays the cursor with full keyboard accessibility enabled). +@interface InfoBarTextView : NSTextView +- (void)fixupCursor; +@end + +@implementation InfoBarTextView + +// Never draw the insertion point (otherwise, it shows up without any user +// action if full keyboard accessibility is enabled). +- (BOOL)shouldDrawInsertionPoint { + return NO; +} + +- (NSRange)selectionRangeForProposedRange:(NSRange)proposedSelRange + granularity:(NSSelectionGranularity)granularity { + // Do not allow selections. + return NSMakeRange(0, 0); +} + +// Convince NSTextView to not show an I-Beam cursor when the cursor is over the +// text view but not over actual text. +// +// http://www.mail-archive.com/cocoa-dev@lists.apple.com/msg10791.html +// "NSTextView sets the cursor over itself dynamically, based on considerations +// including the text under the cursor. It does so in -mouseEntered:, +// -mouseMoved:, and -cursorUpdate:, so those would be points to consider +// overriding." +- (void)mouseMoved:(NSEvent*)e { + [super mouseMoved:e]; + [self fixupCursor]; +} + +- (void)mouseEntered:(NSEvent*)e { + [super mouseEntered:e]; + [self fixupCursor]; +} + +- (void)cursorUpdate:(NSEvent*)e { + [super cursorUpdate:e]; + [self fixupCursor]; +} + +- (void)fixupCursor { + if ([[NSCursor currentCursor] isEqual:[NSCursor IBeamCursor]]) + [[NSCursor arrowCursor] set]; +} + +@end + +@interface InfoBarController (PrivateMethods) +// Sets |label_| based on |labelPlaceholder_|, sets |labelPlaceholder_| to nil. +- (void)initializeLabel; + +// Asks the container controller to remove the infobar for this delegate. This +// call will trigger a notification that starts the infobar animating closed. +- (void)removeInfoBar; + +// Performs final cleanup after an animation is finished or stopped, including +// notifying the InfoBarDelegate that the infobar was closed and removing the +// infobar from its container, if necessary. +- (void)cleanUpAfterAnimation:(BOOL)finished; + +// Sets the info bar message to the specified |message|, with a hypertext +// style link. |link| will be inserted into message at |linkOffset|. +- (void)setLabelToMessage:(NSString*)message + withLink:(NSString*)link + atOffset:(NSUInteger)linkOffset; +@end + +@implementation InfoBarController + +@synthesize containerController = containerController_; +@synthesize delegate = delegate_; + +- (id)initWithDelegate:(InfoBarDelegate*)delegate { + DCHECK(delegate); + if ((self = [super initWithNibName:@"InfoBar" + bundle:mac_util::MainAppBundle()])) { + delegate_ = delegate; + } + return self; +} + +// All infobars have an icon, so we set up the icon in the base class +// awakeFromNib. +- (void)awakeFromNib { + DCHECK(delegate_); + if (delegate_->GetIcon()) { + [image_ setImage:gfx::SkBitmapToNSImage(*(delegate_->GetIcon()))]; + } else { + // No icon, remove it from the view and grow the textfield to include the + // space. + NSRect imageFrame = [image_ frame]; + NSRect labelFrame = [labelPlaceholder_ frame]; + labelFrame.size.width += NSMinX(imageFrame) - NSMinX(labelFrame); + labelFrame.origin.x = imageFrame.origin.x; + [image_ removeFromSuperview]; + [labelPlaceholder_ setFrame:labelFrame]; + } + [self initializeLabel]; + + [self addAdditionalControls]; +} + +// Called when someone clicks on the embedded link. +- (BOOL) textView:(NSTextView*)textView + clickedOnLink:(id)link + atIndex:(NSUInteger)charIndex { + if ([self respondsToSelector:@selector(linkClicked)]) + [self performSelector:@selector(linkClicked)]; + return YES; +} + +// Called when someone clicks on the ok button. +- (void)ok:(id)sender { + // Subclasses must override this method if they do not hide the ok button. + NOTREACHED(); +} + +// Called when someone clicks on the cancel button. +- (void)cancel:(id)sender { + // Subclasses must override this method if they do not hide the cancel button. + NOTREACHED(); +} + +// Called when someone clicks on the close button. +- (void)dismiss:(id)sender { + [self removeInfoBar]; +} + +- (AnimatableView*)animatableView { + return static_cast<AnimatableView*>([self view]); +} + +- (void)open { + // Simply reset the frame size to its opened size, forcing a relayout. + CGFloat finalHeight = [[self view] frame].size.height; + [[self animatableView] setHeight:finalHeight]; +} + +- (void)animateOpen { + // Force the frame size to be 0 and then start an animation. + NSRect frame = [[self view] frame]; + CGFloat finalHeight = frame.size.height; + frame.size.height = 0; + [[self view] setFrame:frame]; + [[self animatableView] animateToNewHeight:finalHeight + duration:kAnimateOpenDuration]; +} + +- (void)close { + // Stop any running animations. + [[self animatableView] stopAnimation]; + infoBarClosing_ = YES; + [self cleanUpAfterAnimation:YES]; +} + +- (void)animateClosed { + // Start animating closed. We will receive a notification when the animation + // is done, at which point we can remove our view from the hierarchy and + // notify the delegate that the infobar was closed. + [[self animatableView] animateToNewHeight:0 duration:kAnimateCloseDuration]; + + // The above call may trigger an animationDidStop: notification for any + // currently-running animations, so do not set |infoBarClosing_| until after + // starting the animation. + infoBarClosing_ = YES; +} + +- (void)addAdditionalControls { + // Default implementation does nothing. +} + +- (void)setLabelToMessage:(NSString*)message { + NSMutableDictionary* attributes = [NSMutableDictionary dictionary]; + NSFont* font = [NSFont labelFontOfSize: + [NSFont systemFontSizeForControlSize:NSRegularControlSize]]; + [attributes setObject:font + forKey:NSFontAttributeName]; + [attributes setObject:[NSCursor arrowCursor] + forKey:NSCursorAttributeName]; + scoped_nsobject<NSAttributedString> attributedString( + [[NSAttributedString alloc] initWithString:message + attributes:attributes]); + [[label_.get() textStorage] setAttributedString:attributedString]; +} + +- (void)removeButtons { + // Extend the label all the way across. + NSRect labelFrame = [label_.get() frame]; + labelFrame.size.width = NSMaxX([cancelButton_ frame]) - NSMinX(labelFrame); + [okButton_ removeFromSuperview]; + [cancelButton_ removeFromSuperview]; + [label_.get() setFrame:labelFrame]; +} + +@end + +@implementation InfoBarController (PrivateMethods) + +- (void)initializeLabel { + // Replace the label placeholder NSTextField with the real label NSTextView. + // The former doesn't show links in a nice way, but the latter can't be added + // in IB without a containing scroll view, so create the NSTextView + // programmatically. + label_.reset([[InfoBarTextView alloc] + initWithFrame:[labelPlaceholder_ frame]]); + [label_.get() setAutoresizingMask:[labelPlaceholder_ autoresizingMask]]; + [[labelPlaceholder_ superview] + replaceSubview:labelPlaceholder_ with:label_.get()]; + labelPlaceholder_ = nil; // Now released. + [label_.get() setDelegate:self]; + [label_.get() setEditable:NO]; + [label_.get() setDrawsBackground:NO]; + [label_.get() setHorizontallyResizable:NO]; + [label_.get() setVerticallyResizable:NO]; +} + +- (void)removeInfoBar { + // TODO(rohitrao): This method can be called even if the infobar has already + // been removed and |delegate_| is NULL. Is there a way to rewrite the code + // so that inner event loops don't cause us to try and remove the infobar + // twice? http://crbug.com/54253 + [containerController_ removeDelegate:delegate_]; +} + +- (void)cleanUpAfterAnimation:(BOOL)finished { + // Don't need to do any cleanup if the bar was animating open. + if (!infoBarClosing_) + return; + + // Notify the delegate that the infobar was closed. The delegate may delete + // itself as a result of InfoBarClosed(), so we null out its pointer. + if (delegate_) { + delegate_->InfoBarClosed(); + delegate_ = NULL; + } + + // If the animation ran to completion, then we need to remove ourselves from + // the container. If the animation was interrupted, then the container will + // take care of removing us. + // TODO(rohitrao): UGH! This works for now, but should be cleaner. + if (finished) + [containerController_ removeController:self]; +} + +- (void)animationDidStop:(NSAnimation*)animation { + [self cleanUpAfterAnimation:NO]; +} + +- (void)animationDidEnd:(NSAnimation*)animation { + [self cleanUpAfterAnimation:YES]; +} + +// TODO(joth): This method factors out some common functionality between the +// various derived infobar classes, however the class hierarchy itself could +// use refactoring to reduce this duplication. http://crbug.com/38924 +- (void)setLabelToMessage:(NSString*)message + withLink:(NSString*)link + atOffset:(NSUInteger)linkOffset { + if (linkOffset == std::wstring::npos) { + // linkOffset == std::wstring::npos means the link should be right-aligned, + // which is not supported on Mac (http://crbug.com/47728). + NOTIMPLEMENTED(); + linkOffset = [message length]; + } + // Create an attributes dictionary for the entire message. We have + // to expicitly set the font the control's font. We also override + // the cursor to give us the normal cursor rather than the text + // insertion cursor. + NSMutableDictionary* linkAttributes = [NSMutableDictionary dictionary]; + [linkAttributes setObject:[NSCursor arrowCursor] + forKey:NSCursorAttributeName]; + NSFont* font = [NSFont labelFontOfSize: + [NSFont systemFontSizeForControlSize:NSRegularControlSize]]; + [linkAttributes setObject:font + forKey:NSFontAttributeName]; + + // Create the attributed string for the main message text. + scoped_nsobject<NSMutableAttributedString> infoText( + [[NSMutableAttributedString alloc] initWithString:message]); + [infoText.get() addAttributes:linkAttributes + range:NSMakeRange(0, [infoText.get() length])]; + // Add additional attributes to style the link text appropriately as + // well as linkify it. + [linkAttributes setObject:[NSColor blueColor] + forKey:NSForegroundColorAttributeName]; + [linkAttributes setObject:[NSNumber numberWithBool:YES] + forKey:NSUnderlineStyleAttributeName]; + [linkAttributes setObject:[NSCursor pointingHandCursor] + forKey:NSCursorAttributeName]; + [linkAttributes setObject:[NSNumber numberWithInt:NSSingleUnderlineStyle] + forKey:NSUnderlineStyleAttributeName]; + [linkAttributes setObject:[NSString string] // dummy value + forKey:NSLinkAttributeName]; + + // Insert the link text into the string at the appropriate offset. + scoped_nsobject<NSAttributedString> attributedString( + [[NSAttributedString alloc] initWithString:link + attributes:linkAttributes]); + [infoText.get() insertAttributedString:attributedString.get() + atIndex:linkOffset]; + // Update the label view with the new text. + [[label_.get() textStorage] setAttributedString:infoText]; +} + +@end + + +///////////////////////////////////////////////////////////////////////// +// AlertInfoBarController implementation + +@implementation AlertInfoBarController + +// Alert infobars have a text message. +- (void)addAdditionalControls { + // No buttons. + [self removeButtons]; + + // Insert the text. + AlertInfoBarDelegate* delegate = delegate_->AsAlertInfoBarDelegate(); + DCHECK(delegate); + [self setLabelToMessage:base::SysUTF16ToNSString(delegate->GetMessageText())]; +} + +@end + + +///////////////////////////////////////////////////////////////////////// +// LinkInfoBarController implementation + +@implementation LinkInfoBarController + +// Link infobars have a text message, of which part is linkified. We +// use an NSAttributedString to display styled text, and we set a +// NSLink attribute on the hyperlink portion of the message. Infobars +// use a custom NSTextField subclass, which allows us to override +// textView:clickedOnLink:atIndex: and intercept clicks. +// +- (void)addAdditionalControls { + // No buttons. + [self removeButtons]; + + LinkInfoBarDelegate* delegate = delegate_->AsLinkInfoBarDelegate(); + DCHECK(delegate); + size_t offset = std::wstring::npos; + string16 message = delegate->GetMessageTextWithOffset(&offset); + [self setLabelToMessage:base::SysUTF16ToNSString(message) + withLink:base::SysUTF16ToNSString(delegate->GetLinkText()) + atOffset:offset]; +} + +// Called when someone clicks on the link in the infobar. This method +// is called by the InfobarTextField on its delegate (the +// LinkInfoBarController). +- (void)linkClicked { + WindowOpenDisposition disposition = + event_utils::WindowOpenDispositionFromNSEvent([NSApp currentEvent]); + if (delegate_ && delegate_->AsLinkInfoBarDelegate()->LinkClicked(disposition)) + [self removeInfoBar]; +} + +@end + + +///////////////////////////////////////////////////////////////////////// +// ConfirmInfoBarController implementation + +@implementation ConfirmInfoBarController + +// Called when someone clicks on the "OK" button. +- (IBAction)ok:(id)sender { + if (delegate_ && delegate_->AsConfirmInfoBarDelegate()->Accept()) + [self removeInfoBar]; +} + +// Called when someone clicks on the "Cancel" button. +- (IBAction)cancel:(id)sender { + if (delegate_ && delegate_->AsConfirmInfoBarDelegate()->Cancel()) + [self removeInfoBar]; +} + +// Confirm infobars can have OK and/or cancel buttons, depending on +// the return value of GetButtons(). We create each button if +// required and position them to the left of the close button. +- (void)addAdditionalControls { + ConfirmInfoBarDelegate* delegate = delegate_->AsConfirmInfoBarDelegate(); + DCHECK(delegate); + int visibleButtons = delegate->GetButtons(); + + NSRect okButtonFrame = [okButton_ frame]; + NSRect cancelButtonFrame = [cancelButton_ frame]; + + DCHECK(NSMaxX(okButtonFrame) < NSMinX(cancelButtonFrame)) + << "Cancel button expected to be on the right of the Ok button in nib"; + + CGFloat rightEdge = NSMaxX(cancelButtonFrame); + CGFloat spaceBetweenButtons = + NSMinX(cancelButtonFrame) - NSMaxX(okButtonFrame); + CGFloat spaceBeforeButtons = + NSMinX(okButtonFrame) - NSMaxX([label_.get() frame]); + + // Update and position the Cancel button if needed. Otherwise, hide it. + if (visibleButtons & ConfirmInfoBarDelegate::BUTTON_CANCEL) { + [cancelButton_ setTitle:base::SysUTF16ToNSString( + delegate->GetButtonLabel(ConfirmInfoBarDelegate::BUTTON_CANCEL))]; + [GTMUILocalizerAndLayoutTweaker sizeToFitView:cancelButton_]; + cancelButtonFrame = [cancelButton_ frame]; + + // Position the cancel button to the left of the Close button. + cancelButtonFrame.origin.x = rightEdge - cancelButtonFrame.size.width; + [cancelButton_ setFrame:cancelButtonFrame]; + + // Update the rightEdge + rightEdge = NSMinX(cancelButtonFrame); + } else { + [cancelButton_ removeFromSuperview]; + } + + // Update and position the OK button if needed. Otherwise, hide it. + if (visibleButtons & ConfirmInfoBarDelegate::BUTTON_OK) { + [okButton_ setTitle:base::SysUTF16ToNSString( + delegate->GetButtonLabel(ConfirmInfoBarDelegate::BUTTON_OK))]; + [GTMUILocalizerAndLayoutTweaker sizeToFitView:okButton_]; + okButtonFrame = [okButton_ frame]; + + // If we had a Cancel button, leave space between the buttons. + if (visibleButtons & ConfirmInfoBarDelegate::BUTTON_CANCEL) { + rightEdge -= spaceBetweenButtons; + } + + // Position the OK button on our current right edge. + okButtonFrame.origin.x = rightEdge - okButtonFrame.size.width; + [okButton_ setFrame:okButtonFrame]; + + + // Update the rightEdge + rightEdge = NSMinX(okButtonFrame); + } else { + [okButton_ removeFromSuperview]; + } + + // If we had either button, leave space before the edge of the textfield. + if ((visibleButtons & ConfirmInfoBarDelegate::BUTTON_CANCEL) || + (visibleButtons & ConfirmInfoBarDelegate::BUTTON_OK)) { + rightEdge -= spaceBeforeButtons; + } + + NSRect frame = [label_.get() frame]; + DCHECK(rightEdge > NSMinX(frame)) + << "Need to make the xib larger to handle buttons with text this long"; + frame.size.width = rightEdge - NSMinX(frame); + [label_.get() setFrame:frame]; + + // Set the text and link. + NSString* message = base::SysUTF16ToNSString(delegate->GetMessageText()); + string16 link = delegate->GetLinkText(); + if (link.empty()) { + // Simple case: no link, so just set the message directly. + [self setLabelToMessage:message]; + } else { + // Inserting the link unintentionally causes the text to have a slightly + // different result to the simple case above: text is truncated on word + // boundaries (if needed) rather than elided with ellipses. + + // Add spacing between the label and the link. + message = [message stringByAppendingString:@" "]; + [self setLabelToMessage:message + withLink:base::SysUTF16ToNSString(link) + atOffset:[message length]]; + } +} + +// Called when someone clicks on the link in the infobar. This method +// is called by the InfobarTextField on its delegate (the +// LinkInfoBarController). +- (void)linkClicked { + WindowOpenDisposition disposition = + event_utils::WindowOpenDispositionFromNSEvent([NSApp currentEvent]); + if (delegate_ && + delegate_->AsConfirmInfoBarDelegate()->LinkClicked(disposition)) + [self removeInfoBar]; +} + +@end + + +////////////////////////////////////////////////////////////////////////// +// CreateInfoBar() implementations + +InfoBar* AlertInfoBarDelegate::CreateInfoBar() { + AlertInfoBarController* controller = + [[AlertInfoBarController alloc] initWithDelegate:this]; + return new InfoBar(controller); +} + +InfoBar* LinkInfoBarDelegate::CreateInfoBar() { + LinkInfoBarController* controller = + [[LinkInfoBarController alloc] initWithDelegate:this]; + return new InfoBar(controller); +} + +InfoBar* ConfirmInfoBarDelegate::CreateInfoBar() { + ConfirmInfoBarController* controller = + [[ConfirmInfoBarController alloc] initWithDelegate:this]; + return new InfoBar(controller); +} diff --git a/chrome/browser/ui/cocoa/infobar_controller_unittest.mm b/chrome/browser/ui/cocoa/infobar_controller_unittest.mm new file mode 100644 index 0000000..4dfcd51 --- /dev/null +++ b/chrome/browser/ui/cocoa/infobar_controller_unittest.mm @@ -0,0 +1,284 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#include "base/string_util.h" +#include "base/sys_string_conversions.h" +#include "chrome/browser/tab_contents/infobar_delegate.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/infobar_container_controller.h" +#import "chrome/browser/ui/cocoa/infobar_controller.h" +#include "chrome/browser/ui/cocoa/infobar_test_helper.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +@interface InfoBarController (ExposedForTesting) +- (NSString*)labelString; +- (NSRect)labelFrame; +@end + +@implementation InfoBarController (ExposedForTesting) +- (NSString*)labelString { + return [label_.get() string]; +} +- (NSRect)labelFrame { + return [label_.get() frame]; +} +@end + + +// Calls to removeDelegate: normally start an animation, which removes the +// infobar completely when finished. For unittesting purposes, we create a mock +// container which calls close: immediately, rather than kicking off an +// animation. +@interface InfoBarContainerTest : NSObject <InfoBarContainer> { + InfoBarController* controller_; +} +- (id)initWithController:(InfoBarController*)controller; +- (void)removeDelegate:(InfoBarDelegate*)delegate; +- (void)removeController:(InfoBarController*)controller; +@end + +@implementation InfoBarContainerTest +- (id)initWithController:(InfoBarController*)controller { + if ((self = [super init])) { + controller_ = controller; + } + return self; +} + +- (void)removeDelegate:(InfoBarDelegate*)delegate { + [controller_ close]; +} + +- (void)removeController:(InfoBarController*)controller { + DCHECK(controller_ == controller); + controller_ = nil; +} +@end + +namespace { + +/////////////////////////////////////////////////////////////////////////// +// Test fixtures + +class AlertInfoBarControllerTest : public CocoaTest { + public: + virtual void SetUp() { + CocoaTest::SetUp(); + + controller_.reset( + [[AlertInfoBarController alloc] initWithDelegate:&delegate_]); + container_.reset( + [[InfoBarContainerTest alloc] initWithController:controller_]); + [controller_ setContainerController:container_]; + [[test_window() contentView] addSubview:[controller_ view]]; + } + + protected: + MockAlertInfoBarDelegate delegate_; + scoped_nsobject<id> container_; + scoped_nsobject<AlertInfoBarController> controller_; +}; + +class LinkInfoBarControllerTest : public CocoaTest { + public: + virtual void SetUp() { + CocoaTest::SetUp(); + + controller_.reset( + [[LinkInfoBarController alloc] initWithDelegate:&delegate_]); + container_.reset( + [[InfoBarContainerTest alloc] initWithController:controller_]); + [controller_ setContainerController:container_]; + [[test_window() contentView] addSubview:[controller_ view]]; + } + + protected: + MockLinkInfoBarDelegate delegate_; + scoped_nsobject<id> container_; + scoped_nsobject<LinkInfoBarController> controller_; +}; + +class ConfirmInfoBarControllerTest : public CocoaTest { + public: + virtual void SetUp() { + CocoaTest::SetUp(); + + controller_.reset( + [[ConfirmInfoBarController alloc] initWithDelegate:&delegate_]); + container_.reset( + [[InfoBarContainerTest alloc] initWithController:controller_]); + [controller_ setContainerController:container_]; + [[test_window() contentView] addSubview:[controller_ view]]; + } + + protected: + MockConfirmInfoBarDelegate delegate_; + scoped_nsobject<id> container_; + scoped_nsobject<ConfirmInfoBarController> controller_; +}; + + +//////////////////////////////////////////////////////////////////////////// +// Tests + +TEST_VIEW(AlertInfoBarControllerTest, [controller_ view]); + +TEST_F(AlertInfoBarControllerTest, ShowAndDismiss) { + // Make sure someone looked at the message and icon. + EXPECT_TRUE(delegate_.message_text_accessed); + EXPECT_TRUE(delegate_.icon_accessed); + + // Check to make sure the infobar message was set properly. + EXPECT_EQ(kMockAlertInfoBarMessage, + base::SysNSStringToUTF8([controller_.get() labelString])); + + // Check that dismissing the infobar calls InfoBarClosed() on the delegate. + [controller_ dismiss:nil]; + EXPECT_TRUE(delegate_.closed); +} + +TEST_F(AlertInfoBarControllerTest, DeallocController) { + // Test that dealloc'ing the controller does not send an + // InfoBarClosed() message to the delegate. + controller_.reset(nil); + EXPECT_FALSE(delegate_.closed); +} + +TEST_F(AlertInfoBarControllerTest, ResizeView) { + NSRect originalLabelFrame = [controller_ labelFrame]; + + // Expand the view by 20 pixels and make sure the label frame changes + // accordingly. + const CGFloat width = 20; + NSRect newViewFrame = [[controller_ view] frame]; + newViewFrame.size.width += width; + [[controller_ view] setFrame:newViewFrame]; + + NSRect newLabelFrame = [controller_ labelFrame]; + EXPECT_EQ(NSWidth(newLabelFrame), NSWidth(originalLabelFrame) + width); +} + +TEST_VIEW(LinkInfoBarControllerTest, [controller_ view]); + +TEST_F(LinkInfoBarControllerTest, ShowAndDismiss) { + // Make sure someone looked at the message, link, and icon. + EXPECT_TRUE(delegate_.message_text_accessed); + EXPECT_TRUE(delegate_.link_text_accessed); + EXPECT_TRUE(delegate_.icon_accessed); + + // Check that dismissing the infobar calls InfoBarClosed() on the delegate. + [controller_ dismiss:nil]; + EXPECT_FALSE(delegate_.link_clicked); + EXPECT_TRUE(delegate_.closed); +} + +TEST_F(LinkInfoBarControllerTest, ShowAndClickLink) { + // Check that clicking on the link calls LinkClicked() on the + // delegate. It should also close the infobar. + [controller_ linkClicked]; + EXPECT_TRUE(delegate_.link_clicked); + EXPECT_TRUE(delegate_.closed); +} + +TEST_F(LinkInfoBarControllerTest, ShowAndClickLinkWithoutClosing) { + delegate_.closes_on_action = false; + + // Check that clicking on the link calls LinkClicked() on the + // delegate. It should not close the infobar. + [controller_ linkClicked]; + EXPECT_TRUE(delegate_.link_clicked); + EXPECT_FALSE(delegate_.closed); +} + +TEST_VIEW(ConfirmInfoBarControllerTest, [controller_ view]); + +TEST_F(ConfirmInfoBarControllerTest, ShowAndDismiss) { + // Make sure someone looked at the message, link, and icon. + EXPECT_TRUE(delegate_.message_text_accessed); + EXPECT_TRUE(delegate_.link_text_accessed); + EXPECT_TRUE(delegate_.icon_accessed); + + // Check to make sure the infobar message was set properly. + EXPECT_EQ(kMockConfirmInfoBarMessage, + base::SysNSStringToUTF8([controller_.get() labelString])); + + // Check that dismissing the infobar calls InfoBarClosed() on the delegate. + [controller_ dismiss:nil]; + EXPECT_FALSE(delegate_.ok_clicked); + EXPECT_FALSE(delegate_.cancel_clicked); + EXPECT_FALSE(delegate_.link_clicked); + EXPECT_TRUE(delegate_.closed); +} + +TEST_F(ConfirmInfoBarControllerTest, ShowAndClickOK) { + // Check that clicking the OK button calls Accept() and then closes + // the infobar. + [controller_ ok:nil]; + EXPECT_TRUE(delegate_.ok_clicked); + EXPECT_FALSE(delegate_.cancel_clicked); + EXPECT_FALSE(delegate_.link_clicked); + EXPECT_TRUE(delegate_.closed); +} + +TEST_F(ConfirmInfoBarControllerTest, ShowAndClickOKWithoutClosing) { + delegate_.closes_on_action = false; + + // Check that clicking the OK button calls Accept() but does not close + // the infobar. + [controller_ ok:nil]; + EXPECT_TRUE(delegate_.ok_clicked); + EXPECT_FALSE(delegate_.cancel_clicked); + EXPECT_FALSE(delegate_.link_clicked); + EXPECT_FALSE(delegate_.closed); +} + +TEST_F(ConfirmInfoBarControllerTest, ShowAndClickCancel) { + // Check that clicking the cancel button calls Cancel() and closes + // the infobar. + [controller_ cancel:nil]; + EXPECT_FALSE(delegate_.ok_clicked); + EXPECT_TRUE(delegate_.cancel_clicked); + EXPECT_FALSE(delegate_.link_clicked); + EXPECT_TRUE(delegate_.closed); +} + +TEST_F(ConfirmInfoBarControllerTest, ShowAndClickCancelWithoutClosing) { + delegate_.closes_on_action = false; + + // Check that clicking the cancel button calls Cancel() but does not close + // the infobar. + [controller_ cancel:nil]; + EXPECT_FALSE(delegate_.ok_clicked); + EXPECT_TRUE(delegate_.cancel_clicked); + EXPECT_FALSE(delegate_.link_clicked); + EXPECT_FALSE(delegate_.closed); +} + +TEST_F(ConfirmInfoBarControllerTest, ShowAndClickLink) { + // Check that clicking on the link calls LinkClicked() on the + // delegate. It should also close the infobar. + [controller_ linkClicked]; + EXPECT_FALSE(delegate_.ok_clicked); + EXPECT_FALSE(delegate_.cancel_clicked); + EXPECT_TRUE(delegate_.link_clicked); + EXPECT_TRUE(delegate_.closed); +} + +TEST_F(ConfirmInfoBarControllerTest, ShowAndClickLinkWithoutClosing) { + delegate_.closes_on_action = false; + + // Check that clicking on the link calls LinkClicked() on the + // delegate. It should not close the infobar. + [controller_ linkClicked]; + EXPECT_FALSE(delegate_.ok_clicked); + EXPECT_FALSE(delegate_.cancel_clicked); + EXPECT_TRUE(delegate_.link_clicked); + EXPECT_FALSE(delegate_.closed); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/infobar_gradient_view.h b/chrome/browser/ui/cocoa/infobar_gradient_view.h new file mode 100644 index 0000000..e0e0037 --- /dev/null +++ b/chrome/browser/ui/cocoa/infobar_gradient_view.h @@ -0,0 +1,19 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_INFOBAR_GRADIENT_VIEW_H_ +#define CHROME_BROWSER_UI_COCOA_INFOBAR_GRADIENT_VIEW_H_ +#pragma once + +#import "chrome/browser/ui/cocoa/vertical_gradient_view.h" + +#import <Cocoa/Cocoa.h> + +// A custom view that draws the background gradient for an infobar. +@interface InfoBarGradientView : VerticalGradientView { +} + +@end + +#endif // CHROME_BROWSER_UI_COCOA_INFOBAR_GRADIENT_VIEW_H_ diff --git a/chrome/browser/ui/cocoa/infobar_gradient_view.mm b/chrome/browser/ui/cocoa/infobar_gradient_view.mm new file mode 100644 index 0000000..cd2b1eeb --- /dev/null +++ b/chrome/browser/ui/cocoa/infobar_gradient_view.mm @@ -0,0 +1,70 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/infobar_gradient_view.h" + +#include "base/scoped_nsobject.h" +#import "chrome/browser/themes/browser_theme_provider.h" +#import "chrome/browser/ui/cocoa/themed_window.h" + +namespace { + +const double kBackgroundColorTop[3] = + {255.0 / 255.0, 242.0 / 255.0, 183.0 / 255.0}; +const double kBackgroundColorBottom[3] = + {250.0 / 255.0, 230.0 / 255.0, 145.0 / 255.0}; +} + +@implementation InfoBarGradientView + +- (id)initWithFrame:(NSRect)frameRect { + if ((self = [super initWithFrame:frameRect])) { + NSColor* startingColor = + [NSColor colorWithCalibratedRed:kBackgroundColorTop[0] + green:kBackgroundColorTop[1] + blue:kBackgroundColorTop[2] + alpha:1.0]; + NSColor* endingColor = + [NSColor colorWithCalibratedRed:kBackgroundColorBottom[0] + green:kBackgroundColorBottom[1] + blue:kBackgroundColorBottom[2] + alpha:1.0]; + scoped_nsobject<NSGradient> gradient( + [[NSGradient alloc] initWithStartingColor:startingColor + endingColor:endingColor]); + [self setGradient:gradient]; + } + return self; +} + +- (NSColor*)strokeColor { + ThemeProvider* themeProvider = [[self window] themeProvider]; + if (!themeProvider) + return [NSColor blackColor]; + + BOOL active = [[self window] isMainWindow]; + return themeProvider->GetNSColor( + active ? BrowserThemeProvider::COLOR_TOOLBAR_STROKE : + BrowserThemeProvider::COLOR_TOOLBAR_STROKE_INACTIVE, + true); +} + +- (BOOL)mouseDownCanMoveWindow { + return NO; +} + +// This view is intentionally not opaque because it overlaps with the findbar. + +- (BOOL)accessibilityIsIgnored { + return NO; +} + +- (id)accessibilityAttributeValue:(NSString*)attribute { + if ([attribute isEqual:NSAccessibilityRoleAttribute]) + return NSAccessibilityGroupRole; + + return [super accessibilityAttributeValue:attribute]; +} + +@end diff --git a/chrome/browser/ui/cocoa/infobar_gradient_view_unittest.mm b/chrome/browser/ui/cocoa/infobar_gradient_view_unittest.mm new file mode 100644 index 0000000..2e4d01b --- /dev/null +++ b/chrome/browser/ui/cocoa/infobar_gradient_view_unittest.mm @@ -0,0 +1,32 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/infobar_gradient_view.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" + +namespace { + +class InfoBarGradientViewTest : public CocoaTest { + public: + InfoBarGradientViewTest() { + NSRect frame = NSMakeRect(0, 0, 100, 30); + scoped_nsobject<InfoBarGradientView> view( + [[InfoBarGradientView alloc] initWithFrame:frame]); + view_ = view.get(); + [[test_window() contentView] addSubview:view_]; + } + + InfoBarGradientView* view_; // Weak. Retained by view hierarchy. +}; + +TEST_VIEW(InfoBarGradientViewTest, view_); + +// Assert that the view is non-opaque, because otherwise we will end +// up with findbar painting issues. +TEST_F(InfoBarGradientViewTest, AssertViewNonOpaque) { + EXPECT_FALSE([view_ isOpaque]); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/infobar_test_helper.h b/chrome/browser/ui/cocoa/infobar_test_helper.h new file mode 100644 index 0000000..d01a71b --- /dev/null +++ b/chrome/browser/ui/cocoa/infobar_test_helper.h @@ -0,0 +1,165 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/tab_contents/infobar_delegate.h" + +#include "base/utf_string_conversions.h" + +namespace { +const char kMockAlertInfoBarMessage[] = "MockAlertInfoBarMessage"; +const char kMockLinkInfoBarMessage[] = "MockLinkInfoBarMessage"; +const char kMockLinkInfoBarLink[] = "http://dev.chromium.org"; +const char kMockConfirmInfoBarMessage[] = "MockConfirmInfoBarMessage"; +} + +////////////////////////////////////////////////////////////////////////// +// Mock InfoBarDelgates + +class MockAlertInfoBarDelegate : public AlertInfoBarDelegate { + public: + explicit MockAlertInfoBarDelegate() + : AlertInfoBarDelegate(NULL), + message_text_accessed(false), + icon_accessed(false), + closed(false) { + } + + virtual string16 GetMessageText() const { + message_text_accessed = true; + return ASCIIToUTF16(kMockAlertInfoBarMessage); + } + + virtual SkBitmap* GetIcon() const { + icon_accessed = true; + return NULL; + } + + virtual void InfoBarClosed() { + closed = true; + } + + // These are declared mutable to get around const-ness issues. + mutable bool message_text_accessed; + mutable bool icon_accessed; + bool closed; +}; + +class MockLinkInfoBarDelegate : public LinkInfoBarDelegate { + public: + explicit MockLinkInfoBarDelegate() + : LinkInfoBarDelegate(NULL), + message_text_accessed(false), + link_text_accessed(false), + icon_accessed(false), + link_clicked(false), + closed(false), + closes_on_action(true) { + } + + virtual string16 GetMessageTextWithOffset(size_t* link_offset) const { + message_text_accessed = true; + return ASCIIToUTF16(kMockLinkInfoBarMessage); + } + + virtual string16 GetLinkText() const { + link_text_accessed = true; + return ASCIIToUTF16(kMockLinkInfoBarLink); + } + + virtual SkBitmap* GetIcon() const { + icon_accessed = true; + return NULL; + } + + virtual bool LinkClicked(WindowOpenDisposition disposition) { + link_clicked = true; + return closes_on_action; + } + + virtual void InfoBarClosed() { + closed = true; + } + + // These are declared mutable to get around const-ness issues. + mutable bool message_text_accessed; + mutable bool link_text_accessed; + mutable bool icon_accessed; + bool link_clicked; + bool closed; + + // Determines whether the infobar closes when an action is taken or not. + bool closes_on_action; +}; + +class MockConfirmInfoBarDelegate : public ConfirmInfoBarDelegate { + public: + explicit MockConfirmInfoBarDelegate() + : ConfirmInfoBarDelegate(NULL), + message_text_accessed(false), + link_text_accessed(false), + icon_accessed(false), + ok_clicked(false), + cancel_clicked(false), + link_clicked(false), + closed(false), + closes_on_action(true) { + } + + virtual int GetButtons() const { + return (BUTTON_OK | BUTTON_CANCEL); + } + + virtual string16 GetButtonLabel(InfoBarButton button) const { + if (button == BUTTON_OK) + return ASCIIToUTF16("OK"); + else + return ASCIIToUTF16("Cancel"); + } + + virtual bool Accept() { + ok_clicked = true; + return closes_on_action; + } + + virtual bool Cancel() { + cancel_clicked = true; + return closes_on_action; + } + + virtual string16 GetMessageText() const { + message_text_accessed = true; + return ASCIIToUTF16(kMockConfirmInfoBarMessage); + } + + virtual SkBitmap* GetIcon() const { + icon_accessed = true; + return NULL; + } + + virtual void InfoBarClosed() { + closed = true; + } + + virtual string16 GetLinkText() { + link_text_accessed = true; + return string16(); + } + + virtual bool LinkClicked(WindowOpenDisposition disposition) { + link_clicked = true; + return closes_on_action; + } + + // These are declared mutable to get around const-ness issues. + mutable bool message_text_accessed; + mutable bool link_text_accessed; + mutable bool icon_accessed; + bool ok_clicked; + bool cancel_clicked; + bool link_clicked; + bool closed; + + // Determines whether the infobar closes when an action is taken or not. + bool closes_on_action; +}; diff --git a/chrome/browser/ui/cocoa/install.sh b/chrome/browser/ui/cocoa/install.sh new file mode 100755 index 0000000..dc73fae --- /dev/null +++ b/chrome/browser/ui/cocoa/install.sh @@ -0,0 +1,123 @@ +#!/bin/bash -p + +# Copyright (c) 2010 The Chromium Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +# Called by the application to install in a new location. Generally, this +# means that the application is running from a disk image and wants to be +# copied to /Applications. The application, when running from the disk image, +# will call this script to perform the copy. +# +# This script will be run as root if the application determines that it would +# not otherwise have permission to perform the copy. +# +# When running as root, this script will be invoked with the real user ID set +# to the user's ID, but the effective user ID set to 0 (root). bash -p is +# used on the first line to prevent bash from setting the effective user ID to +# the real user ID (dropping root privileges). + +set -e + +# This script may run as root, so be paranoid about things like ${PATH}. +export PATH="/usr/bin:/usr/sbin:/bin:/sbin" + +# If running as root, output the pid to stdout before doing anything else. +# See chrome/browser/cocoa/authorization_util.h. +if [ ${EUID} -eq 0 ] ; then + echo "${$}" +fi + +if [ ${#} -ne 2 ] ; then + echo "usage: ${0} SRC DEST" >& 2 + exit 2 +fi + +SRC=${1} +DEST=${2} + +# Make sure that SRC is an absolute path and that it exists. +if [ -z "${SRC}" ] || [ "${SRC:0:1}" != "/" ] || [ ! -d "${SRC}" ] ; then + echo "${0}: source ${SRC} sanity check failed" >& 2 + exit 3 +fi + +# Make sure that DEST is an absolute path and that it doesn't yet exist. +if [ -z "${DEST}" ] || [ "${DEST:0:1}" != "/" ] || [ -e "${DEST}" ] ; then + echo "${0}: destination ${DEST} sanity check failed" >& 2 + exit 4 +fi + +# Do the copy. +rsync -lrpt "${SRC}/" "${DEST}" + +# The remaining steps are not considered critical. +set +e + +# Notify LaunchServices. +/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister "${DEST}" + +# If this script is not running as root and the application is installed +# somewhere under /Applications, try to make it writable by all admin users. +# This will allow other admin users to update the application from their own +# user Keystone instances even if the Keystone ticket is not promoted to +# system level. +# +# If the script is not running as root and the application is not installed +# under /Applications, it might not be in a system-wide location, and it +# probably won't be something that other users on the system are running, so +# err on the side of safety and don't make it group-writable. +# +# If this script is running as root, a Keystone ticket promotion is expected, +# and future updates can be expected to be applied as root, so +# admin-writeability is not a concern. Set the entire thing to be owned by +# root in that case, regardless of where it's installed, and drop any group +# and other write permission. +# +# If this script is running as a user that is not a member of the admin group, +# the chgrp operation will not succeed. Tolerate that case, because it's +# better than the alternative, which is to make the application +# world-writable. +CHMOD_MODE="a+rX,u+w,go-w" +if [ ${EUID} -ne 0 ] ; then + if [ "${DEST:0:14}" = "/Applications/" ] && + chgrp -Rh admin "${DEST}" >& /dev/null ; then + CHMOD_MODE="a+rX,ug+w,o-w" + fi +else + chown -Rh root:wheel "${DEST}" >& /dev/null +fi + +chmod -R "${CHMOD_MODE}" "${DEST}" >& /dev/null + +# On the Mac, or at least on HFS+, symbolic link permissions are significant, +# but chmod -R and -h can't be used together. Do another pass to fix the +# permissions on any symbolic links. +find "${DEST}" -type l -exec chmod -h "${CHMOD_MODE}" {} + >& /dev/null + +# Host OS version check, to be able to take advantage of features on newer +# systems and fall back to slow ways of doing things on older systems. +OS_VERSION=$(sw_vers -productVersion) +OS_MAJOR=$(sed -Ene 's/^([0-9]+).*/\1/p' <<< ${OS_VERSION}) +OS_MINOR=$(sed -Ene 's/^([0-9]+)\.([0-9]+).*/\2/p' <<< ${OS_VERSION}) + +# Because this script is launched by the application itself, the installation +# process inherits the quarantine bit (LSFileQuarantineEnabled). Any files or +# directories created during the update will be quarantined in that case, +# which may cause Launch Services to display quarantine UI. That's bad, +# especially if it happens when the outer .app launches a quarantined inner +# helper. Since the user approved the application launch if quarantined, it +# it can be assumed that the installed copy should not be quarantined. Use +# xattr to drop the quarantine attribute. +QUARANTINE_ATTR=com.apple.quarantine +if [ ${OS_MAJOR} -gt 10 ] || + ([ ${OS_MAJOR} -eq 10 ] && [ ${OS_MINOR} -ge 6 ]) ; then + # On 10.6, xattr supports -r for recursive operation. + xattr -d -r "${QUARANTINE_ATTR}" "${DEST}" >& /dev/null +else + # On earlier systems, xattr doesn't support -r, so run xattr via find. + find "${DEST}" -exec xattr -d "${QUARANTINE_ATTR}" {} + >& /dev/null +fi + +# Great success! +exit 0 diff --git a/chrome/browser/ui/cocoa/install_from_dmg.h b/chrome/browser/ui/cocoa/install_from_dmg.h new file mode 100644 index 0000000..0343cd0 --- /dev/null +++ b/chrome/browser/ui/cocoa/install_from_dmg.h @@ -0,0 +1,15 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_INSTALL_FROM_DMG_H_ +#define CHROME_BROWSER_UI_COCOA_INSTALL_FROM_DMG_H_ +#pragma once + +// If the application is running from a read-only disk image, prompts the user +// to install it to the hard drive. If the user approves, the application +// will be installed and launched, and MaybeInstallFromDiskImage will return +// true. In that case, the caller must exit expeditiously. +bool MaybeInstallFromDiskImage(); + +#endif // CHROME_BROWSER_UI_COCOA_INSTALL_FROM_DMG_H_ diff --git a/chrome/browser/ui/cocoa/install_from_dmg.mm b/chrome/browser/ui/cocoa/install_from_dmg.mm new file mode 100644 index 0000000..d18e257 --- /dev/null +++ b/chrome/browser/ui/cocoa/install_from_dmg.mm @@ -0,0 +1,438 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/install_from_dmg.h" + +#include <ApplicationServices/ApplicationServices.h> +#import <AppKit/AppKit.h> +#include <CoreFoundation/CoreFoundation.h> +#include <CoreServices/CoreServices.h> +#include <IOKit/IOKitLib.h> +#include <string.h> +#include <sys/param.h> +#include <sys/mount.h> + +#include "app/l10n_util.h" +#import "app/l10n_util_mac.h" +#include "base/basictypes.h" +#include "base/command_line.h" +#include "base/logging.h" +#import "base/mac_util.h" +#include "base/mac/scoped_nsautorelease_pool.h" +#include "chrome/browser/ui/cocoa/authorization_util.h" +#include "chrome/browser/ui/cocoa/scoped_authorizationref.h" +#import "chrome/browser/ui/cocoa/keystone_glue.h" +#include "grit/chromium_strings.h" +#include "grit/generated_resources.h" + +// When C++ exceptions are disabled, the C++ library defines |try| and +// |catch| so as to allow exception-expecting C++ code to build properly when +// language support for exceptions is not present. These macros interfere +// with the use of |@try| and |@catch| in Objective-C files such as this one. +// Undefine these macros here, after everything has been #included, since +// there will be no C++ uses and only Objective-C uses from this point on. +#undef try +#undef catch + +namespace { + +// Just like ScopedCFTypeRef but for io_object_t and subclasses. +template<typename IOT> +class scoped_ioobject { + public: + typedef IOT element_type; + + explicit scoped_ioobject(IOT object = NULL) + : object_(object) { + } + + ~scoped_ioobject() { + if (object_) + IOObjectRelease(object_); + } + + void reset(IOT object = NULL) { + if (object_) + IOObjectRelease(object_); + object_ = object; + } + + bool operator==(IOT that) const { + return object_ == that; + } + + bool operator!=(IOT that) const { + return object_ != that; + } + + operator IOT() const { + return object_; + } + + IOT get() const { + return object_; + } + + void swap(scoped_ioobject& that) { + IOT temp = that.object_; + that.object_ = object_; + object_ = temp; + } + + IOT release() { + IOT temp = object_; + object_ = NULL; + return temp; + } + + private: + IOT object_; + + DISALLOW_COPY_AND_ASSIGN(scoped_ioobject); +}; + +// Returns true if |path| is located on a read-only filesystem of a disk +// image. Returns false if not, or in the event of an error. +bool IsPathOnReadOnlyDiskImage(const char path[]) { + struct statfs statfs_buf; + if (statfs(path, &statfs_buf) != 0) { + PLOG(ERROR) << "statfs " << path; + return false; + } + + if (!(statfs_buf.f_flags & MNT_RDONLY)) { + // Not on a read-only filesystem. + return false; + } + + const char dev_root[] = "/dev/"; + const int dev_root_length = arraysize(dev_root) - 1; + if (strncmp(statfs_buf.f_mntfromname, dev_root, dev_root_length) != 0) { + // Not rooted at dev_root, no BSD name to search on. + return false; + } + + // BSD names in IOKit don't include dev_root. + const char* bsd_device_name = statfs_buf.f_mntfromname + dev_root_length; + + const mach_port_t master_port = kIOMasterPortDefault; + + // IOBSDNameMatching gives ownership of match_dict to the caller, but + // IOServiceGetMatchingServices will assume that reference. + CFMutableDictionaryRef match_dict = IOBSDNameMatching(master_port, + 0, + bsd_device_name); + if (!match_dict) { + LOG(ERROR) << "IOBSDNameMatching " << bsd_device_name; + return false; + } + + io_iterator_t iterator_ref; + kern_return_t kr = IOServiceGetMatchingServices(master_port, + match_dict, + &iterator_ref); + if (kr != KERN_SUCCESS) { + LOG(ERROR) << "IOServiceGetMatchingServices " << bsd_device_name + << ": kernel error " << kr; + return false; + } + scoped_ioobject<io_iterator_t> iterator(iterator_ref); + iterator_ref = NULL; + + // There needs to be exactly one matching service. + scoped_ioobject<io_service_t> filesystem_service(IOIteratorNext(iterator)); + if (!filesystem_service) { + LOG(ERROR) << "IOIteratorNext " << bsd_device_name << ": no service"; + return false; + } + scoped_ioobject<io_service_t> unexpected_service(IOIteratorNext(iterator)); + if (unexpected_service) { + LOG(ERROR) << "IOIteratorNext " << bsd_device_name << ": too many services"; + return false; + } + + iterator.reset(); + + const char disk_image_class[] = "IOHDIXController"; + + // This is highly unlikely. The filesystem service is expected to be of + // class IOMedia. Since the filesystem service's entire ancestor chain + // will be checked, though, check the filesystem service's class itself. + if (IOObjectConformsTo(filesystem_service, disk_image_class)) { + return true; + } + + kr = IORegistryEntryCreateIterator(filesystem_service, + kIOServicePlane, + kIORegistryIterateRecursively | + kIORegistryIterateParents, + &iterator_ref); + if (kr != KERN_SUCCESS) { + LOG(ERROR) << "IORegistryEntryCreateIterator " << bsd_device_name + << ": kernel error " << kr; + return false; + } + iterator.reset(iterator_ref); + iterator_ref = NULL; + + // Look at each of the filesystem service's ancestor services, beginning + // with the parent, iterating all the way up to the device tree's root. If + // any ancestor service matches the class used for disk images, the + // filesystem resides on a disk image. + for(scoped_ioobject<io_service_t> ancestor_service(IOIteratorNext(iterator)); + ancestor_service; + ancestor_service.reset(IOIteratorNext(iterator))) { + if (IOObjectConformsTo(ancestor_service, disk_image_class)) { + return true; + } + } + + // The filesystem does not reside on a disk image. + return false; +} + +// Returns true if the application is located on a read-only filesystem of a +// disk image. Returns false if not, or in the event of an error. +bool IsAppRunningFromReadOnlyDiskImage() { + return IsPathOnReadOnlyDiskImage( + [[[NSBundle mainBundle] bundlePath] fileSystemRepresentation]); +} + +// Shows a dialog asking the user whether or not to install from the disk +// image. Returns true if the user approves installation. +bool ShouldInstallDialog() { + NSString* title = l10n_util::GetNSStringFWithFixup( + IDS_INSTALL_FROM_DMG_TITLE, l10n_util::GetStringUTF16(IDS_PRODUCT_NAME)); + NSString* prompt = l10n_util::GetNSStringFWithFixup( + IDS_INSTALL_FROM_DMG_PROMPT, l10n_util::GetStringUTF16(IDS_PRODUCT_NAME)); + NSString* yes = l10n_util::GetNSStringWithFixup(IDS_INSTALL_FROM_DMG_YES); + NSString* no = l10n_util::GetNSStringWithFixup(IDS_INSTALL_FROM_DMG_NO); + + NSAlert* alert = [[[NSAlert alloc] init] autorelease]; + + [alert setAlertStyle:NSInformationalAlertStyle]; + [alert setMessageText:title]; + [alert setInformativeText:prompt]; + [alert addButtonWithTitle:yes]; + NSButton* cancel_button = [alert addButtonWithTitle:no]; + [cancel_button setKeyEquivalent:@"\e"]; + + NSInteger result = [alert runModal]; + + return result == NSAlertFirstButtonReturn; +} + +// Potentially shows an authorization dialog to request authentication to +// copy. If application_directory appears to be unwritable, attempts to +// obtain authorization, which may result in the display of the dialog. +// Returns NULL if authorization is not performed because it does not appear +// to be necessary because the user has permission to write to +// application_directory. Returns NULL if authorization fails. +AuthorizationRef MaybeShowAuthorizationDialog(NSString* application_directory) { + NSFileManager* file_manager = [NSFileManager defaultManager]; + if ([file_manager isWritableFileAtPath:application_directory]) { + return NULL; + } + + NSString* prompt = l10n_util::GetNSStringFWithFixup( + IDS_INSTALL_FROM_DMG_AUTHENTICATION_PROMPT, + l10n_util::GetStringUTF16(IDS_PRODUCT_NAME)); + return authorization_util::AuthorizationCreateToRunAsRoot( + reinterpret_cast<CFStringRef>(prompt)); +} + +// Invokes the installer program at installer_path to copy source_path to +// target_path and perform any additional on-disk bookkeeping needed to be +// able to launch target_path properly. If authorization_arg is non-NULL, +// function will assume ownership of it, will invoke the installer with that +// authorization reference, and will attempt Keystone ticket promotion. +bool InstallFromDiskImage(AuthorizationRef authorization_arg, + NSString* installer_path, + NSString* source_path, + NSString* target_path) { + scoped_AuthorizationRef authorization(authorization_arg); + authorization_arg = NULL; + int exit_status; + if (authorization) { + const char* installer_path_c = [installer_path fileSystemRepresentation]; + const char* source_path_c = [source_path fileSystemRepresentation]; + const char* target_path_c = [target_path fileSystemRepresentation]; + const char* arguments[] = {source_path_c, target_path_c, NULL}; + + OSStatus status = authorization_util::ExecuteWithPrivilegesAndWait( + authorization, + installer_path_c, + kAuthorizationFlagDefaults, + arguments, + NULL, // pipe + &exit_status); + if (status != errAuthorizationSuccess) { + LOG(ERROR) << "AuthorizationExecuteWithPrivileges install: " << status; + return false; + } + } else { + NSArray* arguments = [NSArray arrayWithObjects:source_path, + target_path, + nil]; + + NSTask* task; + @try { + task = [NSTask launchedTaskWithLaunchPath:installer_path + arguments:arguments]; + } @catch(NSException* exception) { + LOG(ERROR) << "+[NSTask launchedTaskWithLaunchPath:arguments:]: " + << [[exception description] UTF8String]; + return false; + } + + [task waitUntilExit]; + exit_status = [task terminationStatus]; + } + + if (exit_status != 0) { + LOG(ERROR) << "install.sh: exit status " << exit_status; + return false; + } + + if (authorization) { + // As long as an AuthorizationRef is available, promote the Keystone + // ticket. Inform KeystoneGlue of the new path to use. + KeystoneGlue* keystone_glue = [KeystoneGlue defaultKeystoneGlue]; + [keystone_glue setAppPath:target_path]; + [keystone_glue promoteTicketWithAuthorization:authorization.release() + synchronous:YES]; + } + + return true; +} + +// Launches the application at app_path. The arguments passed to app_path +// will be the same as the arguments used to invoke this process, except any +// arguments beginning with -psn_ will be stripped. +bool LaunchInstalledApp(NSString* app_path) { + const UInt8* app_path_c = + reinterpret_cast<const UInt8*>([app_path fileSystemRepresentation]); + FSRef app_fsref; + OSStatus err = FSPathMakeRef(app_path_c, &app_fsref, NULL); + if (err != noErr) { + LOG(ERROR) << "FSPathMakeRef: " << err; + return false; + } + + const std::vector<std::string>& argv = + CommandLine::ForCurrentProcess()->argv(); + NSMutableArray* arguments = + [NSMutableArray arrayWithCapacity:argv.size() - 1]; + // Start at argv[1]. LSOpenApplication adds its own argv[0] as the path of + // the launched executable. + for (size_t index = 1; index < argv.size(); ++index) { + std::string argument = argv[index]; + const char psn_flag[] = "-psn_"; + const int psn_flag_length = arraysize(psn_flag) - 1; + if (argument.compare(0, psn_flag_length, psn_flag) != 0) { + // Strip any -psn_ arguments, as they apply to a specific process. + [arguments addObject:[NSString stringWithUTF8String:argument.c_str()]]; + } + } + + struct LSApplicationParameters parameters = {0}; + parameters.flags = kLSLaunchDefaults; + parameters.application = &app_fsref; + parameters.argv = reinterpret_cast<CFArrayRef>(arguments); + + err = LSOpenApplication(¶meters, NULL); + if (err != noErr) { + LOG(ERROR) << "LSOpenApplication: " << err; + return false; + } + + return true; +} + +void ShowErrorDialog() { + NSString* title = l10n_util::GetNSStringWithFixup( + IDS_INSTALL_FROM_DMG_ERROR_TITLE); + NSString* error = l10n_util::GetNSStringFWithFixup( + IDS_INSTALL_FROM_DMG_ERROR, l10n_util::GetStringUTF16(IDS_PRODUCT_NAME)); + NSString* ok = l10n_util::GetNSStringWithFixup(IDS_OK); + + NSAlert* alert = [[[NSAlert alloc] init] autorelease]; + + [alert setAlertStyle:NSWarningAlertStyle]; + [alert setMessageText:title]; + [alert setInformativeText:error]; + [alert addButtonWithTitle:ok]; + + [alert runModal]; +} + +} // namespace + +bool MaybeInstallFromDiskImage() { + base::mac::ScopedNSAutoreleasePool autorelease_pool; + + if (!IsAppRunningFromReadOnlyDiskImage()) { + return false; + } + + NSArray* application_directories = + NSSearchPathForDirectoriesInDomains(NSApplicationDirectory, + NSLocalDomainMask, + YES); + if ([application_directories count] == 0) { + LOG(ERROR) << "NSSearchPathForDirectoriesInDomains: " + << "no local application directories"; + return false; + } + NSString* application_directory = [application_directories objectAtIndex:0]; + + NSFileManager* file_manager = [NSFileManager defaultManager]; + + BOOL is_directory; + if (![file_manager fileExistsAtPath:application_directory + isDirectory:&is_directory] || + !is_directory) { + VLOG(1) << "No application directory at " + << [application_directory UTF8String]; + return false; + } + + NSString* source_path = [[NSBundle mainBundle] bundlePath]; + NSString* application_name = [source_path lastPathComponent]; + NSString* target_path = + [application_directory stringByAppendingPathComponent:application_name]; + + if ([file_manager fileExistsAtPath:target_path]) { + VLOG(1) << "Something already exists at " << [target_path UTF8String]; + return false; + } + + NSString* installer_path = + [mac_util::MainAppBundle() pathForResource:@"install" ofType:@"sh"]; + if (!installer_path) { + VLOG(1) << "Could not locate install.sh"; + return false; + } + + if (!ShouldInstallDialog()) { + return false; + } + + scoped_AuthorizationRef authorization( + MaybeShowAuthorizationDialog(application_directory)); + // authorization will be NULL if it's deemed unnecessary or if + // authentication fails. In either case, try to install without privilege + // escalation. + + if (!InstallFromDiskImage(authorization.release(), + installer_path, + source_path, + target_path) || + !LaunchInstalledApp(target_path)) { + ShowErrorDialog(); + return false; + } + + return true; +} diff --git a/chrome/browser/ui/cocoa/instant_confirm_window_controller.h b/chrome/browser/ui/cocoa/instant_confirm_window_controller.h new file mode 100644 index 0000000..223c197 --- /dev/null +++ b/chrome/browser/ui/cocoa/instant_confirm_window_controller.h @@ -0,0 +1,43 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_INSTANT_CONFIRM_WINDOW_CONTROLLER_H_ +#define CHROME_BROWSER_UI_COCOA_INSTANT_CONFIRM_WINDOW_CONTROLLER_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#import "base/cocoa_protocols_mac.h" + +class Profile; + +// This controller manages a dialog that informs the user about Instant search. +// The recommended API is to not use this class directly, but instead to use +// the functions in //chrome/browser/instant/instant_confirm_dialog.h: +// void ShowInstantConfirmDialog[IfNecessary](gfx::NativeWindow* parent, ...) +// Which will attach the window to |parent| as a sheet. +@interface InstantConfirmWindowController : NSWindowController<NSWindowDelegate> +{ + @private + // The long description about Instant that needs to be sized-to-fit. + IBOutlet NSTextField* description_; + + Profile* profile_; // weak +} + +// Designated initializer. The controller will release itself on window close. +- (id)initWithProfile:(Profile*)profile; + +// Action for the "Learn more" link. +- (IBAction)learnMore:(id)sender; + +// The user has opted in to Instant. This enables the Instant preference. +- (IBAction)ok:(id)sender; + +// Closes the sheet without altering the preference value. +- (IBAction)cancel:(id)sender; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_INSTANT_CONFIRM_WINDOW_CONTROLLER_H_ diff --git a/chrome/browser/ui/cocoa/instant_confirm_window_controller.mm b/chrome/browser/ui/cocoa/instant_confirm_window_controller.mm new file mode 100644 index 0000000..a16f5e1 --- /dev/null +++ b/chrome/browser/ui/cocoa/instant_confirm_window_controller.mm @@ -0,0 +1,76 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/instant_confirm_window_controller.h" + +#include "base/logging.h" +#include "base/mac_util.h" +#include "chrome/browser/instant/instant_confirm_dialog.h" +#include "chrome/browser/instant/instant_controller.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/show_options_url.h" +#include "gfx/native_widget_types.h" +#include "googleurl/src/gurl.h" +#import "third_party/GTM/AppKit/GTMUILocalizerAndLayoutTweaker.h" + +namespace browser { + +void ShowInstantConfirmDialog(gfx::NativeWindow parent, Profile* profile) { + InstantConfirmWindowController* controller = + [[InstantConfirmWindowController alloc] initWithProfile:profile]; + [NSApp beginSheet:[controller window] + modalForWindow:parent + modalDelegate:nil + didEndSelector:NULL + contextInfo:NULL]; +} + +} // namespace browser + +@implementation InstantConfirmWindowController + +- (id)initWithProfile:(Profile*)profile { + NSString* nibPath = [mac_util::MainAppBundle() + pathForResource:@"InstantConfirm" + ofType:@"nib"]; + if ((self = [super initWithWindowNibPath:nibPath owner:self])) { + profile_ = profile; + } + return self; +} + +- (void)awakeFromNib { + DCHECK([self window]); + DCHECK_EQ(self, [[self window] delegate]); + + CGFloat delta = [GTMUILocalizerAndLayoutTweaker sizeToFitFixedWidthTextField: + description_]; + NSRect descriptionFrame = [description_ frame]; + descriptionFrame.origin.y -= delta; + [description_ setFrame:descriptionFrame]; + + NSRect frame = [[self window] frame]; + frame.size.height += delta; + [[self window] setFrame:frame display:YES]; +} + +- (void)windowWillClose:(NSNotification*)notif { + [self autorelease]; +} + +- (IBAction)learnMore:(id)sender { + browser::ShowOptionsURL(profile_, GURL(browser::kInstantLearnMoreURL)); +} + +- (IBAction)ok:(id)sender { + InstantController::Enable(profile_); + [self cancel:sender]; +} + +- (IBAction)cancel:(id)sender { + [NSApp endSheet:[self window]]; + [[self window] close]; +} + +@end diff --git a/chrome/browser/ui/cocoa/instant_confirm_window_controller_unittest.mm b/chrome/browser/ui/cocoa/instant_confirm_window_controller_unittest.mm new file mode 100644 index 0000000..71d8c9e --- /dev/null +++ b/chrome/browser/ui/cocoa/instant_confirm_window_controller_unittest.mm @@ -0,0 +1,36 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/instant_confirm_window_controller.h" + +#include "chrome/browser/instant/instant_confirm_dialog.h" +#import "chrome/browser/ui/cocoa/browser_test_helper.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" + +namespace { + +class InstantConfirmWindowControllerTest : public CocoaTest { + public: + InstantConfirmWindowControllerTest() : controller_(nil) {} + + BrowserTestHelper helper_; + InstantConfirmWindowController* controller_; // Weak. Owns self. +}; + +TEST_F(InstantConfirmWindowControllerTest, Init) { + controller_ = + [[InstantConfirmWindowController alloc] initWithProfile: + helper_.profile()]; + EXPECT_TRUE([controller_ window]); + [controller_ release]; +} + +TEST_F(InstantConfirmWindowControllerTest, Show) { + browser::ShowInstantConfirmDialog(test_window(), helper_.profile()); + controller_ = [[test_window() attachedSheet] windowController]; + EXPECT_TRUE(controller_); + [controller_ cancel:nil]; +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/js_modal_dialog_cocoa.h b/chrome/browser/ui/cocoa/js_modal_dialog_cocoa.h new file mode 100644 index 0000000..5005358 --- /dev/null +++ b/chrome/browser/ui/cocoa/js_modal_dialog_cocoa.h @@ -0,0 +1,48 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_JS_MODAL_DIALOG_COCOA_H_ +#define CHROME_BROWSER_UI_COCOA_JS_MODAL_DIALOG_COCOA_H_ +#pragma once + +#include "chrome/browser/native_app_modal_dialog.h" + +#include "base/logging.h" +#include "base/scoped_nsobject.h" +#include "base/scoped_ptr.h" + +#if __OBJC__ +@class NSAlert; +@class JavaScriptAppModalDialogHelper; +#else +class NSAlert; +class JavaScriptAppModalDialogHelper; +#endif + +class JSModalDialogCocoa : public NativeAppModalDialog { + public: + explicit JSModalDialogCocoa(JavaScriptAppModalDialog* dialog); + virtual ~JSModalDialogCocoa(); + + // Overridden from NativeAppModalDialog: + virtual int GetAppModalDialogButtons() const; + virtual void ShowAppModalDialog(); + virtual void ActivateAppModalDialog(); + virtual void CloseAppModalDialog(); + virtual void AcceptAppModalDialog(); + virtual void CancelAppModalDialog(); + + JavaScriptAppModalDialog* dialog() const { return dialog_.get(); } + + private: + scoped_ptr<JavaScriptAppModalDialog> dialog_; + + scoped_nsobject<JavaScriptAppModalDialogHelper> helper_; + NSAlert* alert_; // weak, owned by |helper_|. + + DISALLOW_COPY_AND_ASSIGN(JSModalDialogCocoa); +}; + +#endif // CHROME_BROWSER_UI_COCOA_JS_MODAL_DIALOG_COCOA_H_ + diff --git a/chrome/browser/ui/cocoa/js_modal_dialog_cocoa.mm b/chrome/browser/ui/cocoa/js_modal_dialog_cocoa.mm new file mode 100644 index 0000000..f604ce3 --- /dev/null +++ b/chrome/browser/ui/cocoa/js_modal_dialog_cocoa.mm @@ -0,0 +1,219 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/js_modal_dialog_cocoa.h" + +#import <Cocoa/Cocoa.h> + +#include "app/l10n_util_mac.h" +#include "app/message_box_flags.h" +#import "base/cocoa_protocols_mac.h" +#include "base/logging.h" +#include "base/sys_string_conversions.h" +#import "chrome/browser/chrome_browser_application_mac.h" +#include "chrome/browser/js_modal_dialog.h" +#include "grit/app_strings.h" +#include "grit/generated_resources.h" + +// Helper object that receives the notification that the dialog/sheet is +// going away. Is responsible for cleaning itself up. +@interface JavaScriptAppModalDialogHelper : NSObject<NSAlertDelegate> { + @private + NSAlert* alert_; + NSTextField* textField_; // WEAK; owned by alert_ +} + +- (NSAlert*)alert; +- (NSTextField*)textField; +- (void)alertDidEnd:(NSAlert *)alert + returnCode:(int)returnCode + contextInfo:(void*)contextInfo; + +@end + +@implementation JavaScriptAppModalDialogHelper + +- (NSAlert*)alert { + alert_ = [[NSAlert alloc] init]; + return alert_; +} + +- (NSTextField*)textField { + textField_ = [[NSTextField alloc] initWithFrame:NSMakeRect(0, 0, 300, 22)]; + [alert_ setAccessoryView:textField_]; + [textField_ release]; + + return textField_; +} + +- (void)dealloc { + [alert_ release]; + [super dealloc]; +} + +// |contextInfo| is the JSModalDialogCocoa that owns us. +- (void)alertDidEnd:(NSAlert*)alert + returnCode:(int)returnCode + contextInfo:(void*)contextInfo { + scoped_ptr<JSModalDialogCocoa> native_dialog( + reinterpret_cast<JSModalDialogCocoa*>(contextInfo)); + std::wstring input; + if (textField_) + input = base::SysNSStringToWide([textField_ stringValue]); + bool shouldSuppress = false; + if ([alert showsSuppressionButton]) + shouldSuppress = [[alert suppressionButton] state] == NSOnState; + switch (returnCode) { + case NSAlertFirstButtonReturn: { // OK + native_dialog->dialog()->OnAccept(input, shouldSuppress); + break; + } + case NSAlertSecondButtonReturn: { // Cancel + // If the user wants to stay on this page, stop quitting (if a quit is in + // progress). + if (native_dialog->dialog()->is_before_unload_dialog()) + chrome_browser_application_mac::CancelTerminate(); + native_dialog->dialog()->OnCancel(shouldSuppress); + break; + } + case NSRunStoppedResponse: { // Window was closed underneath us + // Need to call OnCancel() because there is some cleanup that needs + // to be done. It won't call back to the javascript since the + // JavaScriptAppModalDialog knows that the TabContents was destroyed. + native_dialog->dialog()->OnCancel(shouldSuppress); + break; + } + default: { + NOTREACHED(); + } + } +} +@end + +//////////////////////////////////////////////////////////////////////////////// +// JSModalDialogCocoa, public: + +JSModalDialogCocoa::JSModalDialogCocoa(JavaScriptAppModalDialog* dialog) + : dialog_(dialog), + helper_(NULL) { + // Determine the names of the dialog buttons based on the flags. "Default" + // is the OK button. "Other" is the cancel button. We don't use the + // "Alternate" button in NSRunAlertPanel. + NSString* default_button = l10n_util::GetNSStringWithFixup(IDS_APP_OK); + NSString* other_button = l10n_util::GetNSStringWithFixup(IDS_APP_CANCEL); + bool text_field = false; + bool one_button = false; + switch (dialog_->dialog_flags()) { + case MessageBoxFlags::kIsJavascriptAlert: + one_button = true; + break; + case MessageBoxFlags::kIsJavascriptConfirm: + if (dialog_->is_before_unload_dialog()) { + default_button = l10n_util::GetNSStringWithFixup( + IDS_BEFOREUNLOAD_MESSAGEBOX_OK_BUTTON_LABEL); + other_button = l10n_util::GetNSStringWithFixup( + IDS_BEFOREUNLOAD_MESSAGEBOX_CANCEL_BUTTON_LABEL); + } + break; + case MessageBoxFlags::kIsJavascriptPrompt: + text_field = true; + break; + + default: + NOTREACHED(); + } + + // Create a helper which will receive the sheet ended selector. It will + // delete itself when done. It doesn't need anything passed to its init + // as it will get a contextInfo parameter. + helper_.reset([[JavaScriptAppModalDialogHelper alloc] init]); + + // Show the modal dialog. + alert_ = [helper_ alert]; + NSTextField* field = nil; + if (text_field) { + field = [helper_ textField]; + [field setStringValue:base::SysWideToNSString( + dialog_->default_prompt_text())]; + } + [alert_ setDelegate:helper_]; + [alert_ setInformativeText:base::SysWideToNSString(dialog_->message_text())]; + [alert_ setMessageText:base::SysWideToNSString(dialog_->title())]; + [alert_ addButtonWithTitle:default_button]; + if (!one_button) { + NSButton* other = [alert_ addButtonWithTitle:other_button]; + [other setKeyEquivalent:@"\e"]; + } + if (dialog_->display_suppress_checkbox()) { + [alert_ setShowsSuppressionButton:YES]; + NSString* suppression_title = l10n_util::GetNSStringWithFixup( + IDS_JAVASCRIPT_MESSAGEBOX_SUPPRESS_OPTION); + [[alert_ suppressionButton] setTitle:suppression_title]; + } +} + +JSModalDialogCocoa::~JSModalDialogCocoa() { +} + +//////////////////////////////////////////////////////////////////////////////// +// JSModalDialogCocoa, NativeAppModalDialog implementation: + +int JSModalDialogCocoa::GetAppModalDialogButtons() const { + // From the above, it is the case that if there is 1 button, it is always the + // OK button. The second button, if it exists, is always the Cancel button. + int num_buttons = [[alert_ buttons] count]; + switch (num_buttons) { + case 1: + return MessageBoxFlags::DIALOGBUTTON_OK; + case 2: + return MessageBoxFlags::DIALOGBUTTON_OK | + MessageBoxFlags::DIALOGBUTTON_CANCEL; + default: + NOTREACHED(); + return 0; + } +} + +void JSModalDialogCocoa::ShowAppModalDialog() { + [alert_ + beginSheetModalForWindow:nil // nil here makes it app-modal + modalDelegate:helper_.get() + didEndSelector:@selector(alertDidEnd:returnCode:contextInfo:) + contextInfo:this]; + + if ([alert_ accessoryView]) + [[alert_ window] makeFirstResponder:[alert_ accessoryView]]; +} + +void JSModalDialogCocoa::ActivateAppModalDialog() { +} + +void JSModalDialogCocoa::CloseAppModalDialog() { + DCHECK([alert_ isKindOfClass:[NSAlert class]]); + + // Note: the call below will delete |this|, + // see JavaScriptAppModalDialogHelper's alertDidEnd. + [NSApp endSheet:[alert_ window]]; +} + +void JSModalDialogCocoa::AcceptAppModalDialog() { + NSButton* first = [[alert_ buttons] objectAtIndex:0]; + [first performClick:nil]; +} + +void JSModalDialogCocoa::CancelAppModalDialog() { + DCHECK([[alert_ buttons] count] >= 2); + NSButton* second = [[alert_ buttons] objectAtIndex:1]; + [second performClick:nil]; +} + +//////////////////////////////////////////////////////////////////////////////// +// NativeAppModalDialog, public: + +// static +NativeAppModalDialog* NativeAppModalDialog::CreateNativeJavaScriptPrompt( + JavaScriptAppModalDialog* dialog, + gfx::NativeWindow parent_window) { + return new JSModalDialogCocoa(dialog); +} diff --git a/chrome/browser/ui/cocoa/keystone_glue.h b/chrome/browser/ui/cocoa/keystone_glue.h new file mode 100644 index 0000000..c20bdb4 --- /dev/null +++ b/chrome/browser/ui/cocoa/keystone_glue.h @@ -0,0 +1,209 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_KEYSTONE_GLUE_H_ +#define CHROME_BROWSER_UI_COCOA_KEYSTONE_GLUE_H_ +#pragma once + +#include "base/string16.h" + +#if defined(__OBJC__) + +#import <Foundation/Foundation.h> + +#import "base/scoped_nsobject.h" +#include "chrome/browser/ui/cocoa/scoped_authorizationref.h" + +// Possible outcomes of various operations. A version may accompany some of +// these, but beware: a version is never required. For statuses that can be +// accompanied by a version, the comment indicates what version is referenced. +// A notification posted containing an asynchronous status will always be +// followed by a notification with a terminal status. +enum AutoupdateStatus { + kAutoupdateNone = 0, // no version (initial state only) + kAutoupdateRegistering, // no version (asynchronous operation in progress) + kAutoupdateRegistered, // no version + kAutoupdateChecking, // no version (asynchronous operation in progress) + kAutoupdateCurrent, // version of the running application + kAutoupdateAvailable, // version of the update that is available + kAutoupdateInstalling, // no version (asynchronous operation in progress) + kAutoupdateInstalled, // version of the update that was installed + kAutoupdatePromoting, // no version (asynchronous operation in progress) + kAutoupdatePromoted, // no version + kAutoupdateRegisterFailed, // no version + kAutoupdateCheckFailed, // no version + kAutoupdateInstallFailed, // no version + kAutoupdatePromoteFailed, // no version +}; + +// kAutoupdateStatusNotification is the name of the notification posted when +// -checkForUpdate and -installUpdate complete. This notification will be +// sent with with its sender object set to the KeystoneGlue instance sending +// the notification. Its userInfo dictionary will contain an AutoupdateStatus +// value as an intValue at key kAutoupdateStatusStatus. If a version is +// available (see AutoupdateStatus), it will be present at key +// kAutoupdateStatusVersion. +extern NSString* const kAutoupdateStatusNotification; +extern NSString* const kAutoupdateStatusStatus; +extern NSString* const kAutoupdateStatusVersion; + +namespace { + +enum BrandFileType { + kBrandFileTypeNotDetermined = 0, + kBrandFileTypeNone, + kBrandFileTypeUser, + kBrandFileTypeSystem, +}; + +} // namespace + +// KeystoneGlue is an adapter around the KSRegistration class, allowing it to +// be used without linking directly against its containing KeystoneRegistration +// framework. This is used in an environment where most builds (such as +// developer builds) don't want or need Keystone support and might not even +// have the framework available. Enabling Keystone support in an application +// that uses KeystoneGlue is as simple as dropping +// KeystoneRegistration.framework in the application's Frameworks directory +// and providing the relevant information in its Info.plist. KeystoneGlue +// requires that the KSUpdateURL key be set in the application's Info.plist, +// and that it contain a string identifying the update URL to be used by +// Keystone. + +@class KSRegistration; + +@interface KeystoneGlue : NSObject { + @protected + + // Data for Keystone registration + NSString* productID_; + NSString* appPath_; + NSString* url_; + NSString* version_; + NSString* channel_; // Logically: Dev, Beta, or Stable. + BrandFileType brandFileType_; + + // And the Keystone registration itself, with the active timer + KSRegistration* registration_; // strong + NSTimer* timer_; // strong + + // The most recent kAutoupdateStatusNotification notification posted. + scoped_nsobject<NSNotification> recentNotification_; + + // The authorization object, when it needs to persist because it's being + // carried across threads. + scoped_AuthorizationRef authorization_; + + // YES if a synchronous promotion operation is in progress (promotion during + // installation). + BOOL synchronousPromotion_; + + // YES if an update was ever successfully installed by -installUpdate. + BOOL updateSuccessfullyInstalled_; +} + +// Return the default Keystone Glue object. ++ (id)defaultKeystoneGlue; + +// Load KeystoneRegistration.framework if present, call into it to register +// with Keystone, and set up periodic activity pings. +- (void)registerWithKeystone; + +// -checkForUpdate launches a check for updates, and -installUpdate begins +// installing an available update. For each, status will be communicated via +// a kAutoupdateStatusNotification notification, and will also be available +// through -recentNotification. +- (void)checkForUpdate; +- (void)installUpdate; + +// Accessor for recentNotification_. Returns an autoreleased NSNotification. +- (NSNotification*)recentNotification; + +// Accessor for the kAutoupdateStatusStatus field of recentNotification_'s +// userInfo dictionary. +- (AutoupdateStatus)recentStatus; + +// Returns YES if an asynchronous operation is pending: if an update check or +// installation attempt is currently in progress. +- (BOOL)asyncOperationPending; + +// Returns YES if the application is running from a read-only filesystem, +// such as a disk image. +- (BOOL)isOnReadOnlyFilesystem; + +// -needsPromotion is YES if the application needs its ticket promoted to +// a system ticket. This will be YES when the application is on a user +// ticket and determines that the current user does not have sufficient +// permission to perform the update. +// +// -wantsPromotion is YES if the application wants its ticket promoted to +// a system ticket, even if it doesn't need it as determined by +// -needsPromotion. -wantsPromotion will always be YES if -needsPromotion is, +// and it will additionally be YES when the application is on a user ticket +// and appears to be installed in a system-wide location such as +// /Applications. +// +// Use -needsPromotion to decide whether to show any update UI at all. If +// it's YES, there's no sense in asking the user to "update now" because it +// will fail given the rights and permissions involved. On the other hand, +// when -needsPromotion is YES, the application can encourage the user to +// promote the ticket so that updates will work properly. +// +// Use -wantsPromotion to decide whether to allow the user to promote. The +// user shouldn't be nagged about promotion on the basis of -wantsPromotion, +// but if it's YES, the user should be allowed to promote the ticket. +- (BOOL)needsPromotion; +- (BOOL)wantsPromotion; + +// Promotes the Keystone ticket into the system store. System Keystone will +// be installed if necessary. If synchronous is NO, the promotion may occur +// in the background. synchronous should be YES for promotion during +// installation. The KeystoneGlue object assumes ownership of +// authorization_arg. +- (void)promoteTicketWithAuthorization:(AuthorizationRef)authorization_arg + synchronous:(BOOL)synchronous; + +// Requests authorization and calls -promoteTicketWithAuthorization: in +// asynchronous mode. +- (void)promoteTicket; + +// Sets a new value for appPath. Used during installation to point a ticket +// at the installed copy. +- (void)setAppPath:(NSString*)appPath; + +@end // @interface KeystoneGlue + +@interface KeystoneGlue(ExposedForTesting) + +// Load any params we need for configuring Keystone. +- (void)loadParameters; + +// Load the Keystone registration object. +// Return NO on failure. +- (BOOL)loadKeystoneRegistration; + +- (void)stopTimer; + +// Called when a checkForUpdate: notification completes. +- (void)checkForUpdateComplete:(NSNotification*)notification; + +// Called when an installUpdate: notification completes. +- (void)installUpdateComplete:(NSNotification*)notification; + +@end // @interface KeystoneGlue(ExposedForTesting) + +#endif // __OBJC__ + +// Functions that may be accessed from non-Objective-C C/C++ code. +namespace keystone_glue { + +// True if Keystone is enabled. +bool KeystoneEnabled(); + +// The version of the application currently installed on disk. +string16 CurrentlyInstalledVersion(); + +} // namespace keystone_glue + +#endif // CHROME_BROWSER_UI_COCOA_KEYSTONE_GLUE_H_ diff --git a/chrome/browser/ui/cocoa/keystone_glue.mm b/chrome/browser/ui/cocoa/keystone_glue.mm new file mode 100644 index 0000000..fa9924d --- /dev/null +++ b/chrome/browser/ui/cocoa/keystone_glue.mm @@ -0,0 +1,959 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/keystone_glue.h" + +#include <sys/param.h> +#include <sys/mount.h> + +#include <vector> + +#include "app/l10n_util.h" +#import "app/l10n_util_mac.h" +#include "base/logging.h" +#include "base/mac_util.h" +#include "base/mac/scoped_nsautorelease_pool.h" +#include "base/sys_string_conversions.h" +#import "base/worker_pool_mac.h" +#include "base/ref_counted.h" +#include "base/task.h" +#include "base/worker_pool.h" +#include "chrome/browser/ui/cocoa/authorization_util.h" +#include "chrome/common/chrome_constants.h" +#include "grit/chromium_strings.h" +#include "grit/generated_resources.h" + +namespace { + +// Provide declarations of the Keystone registration bits needed here. From +// KSRegistration.h. +typedef enum { + kKSPathExistenceChecker, +} KSExistenceCheckerType; + +typedef enum { + kKSRegistrationUserTicket, + kKSRegistrationSystemTicket, + kKSRegistrationDontKnowWhatKindOfTicket, +} KSRegistrationTicketType; + +NSString* const KSRegistrationVersionKey = @"Version"; +NSString* const KSRegistrationExistenceCheckerTypeKey = @"ExistenceCheckerType"; +NSString* const KSRegistrationExistenceCheckerStringKey = + @"ExistenceCheckerString"; +NSString* const KSRegistrationServerURLStringKey = @"URLString"; +NSString* const KSRegistrationPreserveTrustedTesterTokenKey = @"PreserveTTT"; +NSString* const KSRegistrationTagKey = @"Tag"; +NSString* const KSRegistrationTagPathKey = @"TagPath"; +NSString* const KSRegistrationTagKeyKey = @"TagKey"; +NSString* const KSRegistrationBrandPathKey = @"BrandPath"; +NSString* const KSRegistrationBrandKeyKey = @"BrandKey"; + +NSString* const KSRegistrationDidCompleteNotification = + @"KSRegistrationDidCompleteNotification"; +NSString* const KSRegistrationPromotionDidCompleteNotification = + @"KSRegistrationPromotionDidCompleteNotification"; + +NSString* const KSRegistrationCheckForUpdateNotification = + @"KSRegistrationCheckForUpdateNotification"; +NSString* KSRegistrationStatusKey = @"Status"; +NSString* KSRegistrationUpdateCheckErrorKey = @"Error"; + +NSString* const KSRegistrationStartUpdateNotification = + @"KSRegistrationStartUpdateNotification"; +NSString* const KSUpdateCheckSuccessfulKey = @"CheckSuccessful"; +NSString* const KSUpdateCheckSuccessfullyInstalledKey = + @"SuccessfullyInstalled"; + +NSString* const KSRegistrationRemoveExistingTag = @""; +#define KSRegistrationPreserveExistingTag nil + +// Constants for the brand file (uses an external file so it can survive updates +// to Chrome. + +#if defined(GOOGLE_CHROME_BUILD) +#define kBrandFileName @"Google Chrome Brand.plist"; +#elif defined(CHROMIUM_BUILD) +#define kBrandFileName @"Chromium Brand.plist"; +#else +#error Unknown branding +#endif + +// These directories are hardcoded in Keystone promotion preflight and the +// Keystone install script, so NSSearchPathForDirectoriesInDomains isn't used +// since the scripts couldn't use anything like that. +NSString* kBrandUserFile = @"~/Library/Google/" kBrandFileName; +NSString* kBrandSystemFile = @"/Library/Google/" kBrandFileName; + +NSString* UserBrandFilePath() { + return [kBrandUserFile stringByStandardizingPath]; +} +NSString* SystemBrandFilePath() { + return [kBrandSystemFile stringByStandardizingPath]; +} + +// Adaptor for scheduling an Objective-C method call on a |WorkerPool| +// thread. +// TODO(shess): Move this into workerpool_mac.h? +class PerformBridge : public base::RefCountedThreadSafe<PerformBridge> { + public: + + // Call |sel| on |target| with |arg| in a WorkerPool thread. + // |target| and |arg| are retained, |arg| may be |nil|. + static void PostPerform(id target, SEL sel, id arg) { + DCHECK(target); + DCHECK(sel); + + scoped_refptr<PerformBridge> op = new PerformBridge(target, sel, arg); + WorkerPool::PostTask( + FROM_HERE, NewRunnableMethod(op.get(), &PerformBridge::Run), true); + } + + // Convenience for the no-argument case. + static void PostPerform(id target, SEL sel) { + PostPerform(target, sel, nil); + } + + private: + // Allow RefCountedThreadSafe<> to delete. + friend class base::RefCountedThreadSafe<PerformBridge>; + + PerformBridge(id target, SEL sel, id arg) + : target_([target retain]), + sel_(sel), + arg_([arg retain]) { + } + + ~PerformBridge() {} + + // Happens on a WorkerPool thread. + void Run() { + base::mac::ScopedNSAutoreleasePool pool; + [target_ performSelector:sel_ withObject:arg_]; + } + + scoped_nsobject<id> target_; + SEL sel_; + scoped_nsobject<id> arg_; +}; + +} // namespace + +@interface KSRegistration : NSObject + ++ (id)registrationWithProductID:(NSString*)productID; + +- (BOOL)registerWithParameters:(NSDictionary*)args; + +- (BOOL)promoteWithParameters:(NSDictionary*)args + authorization:(AuthorizationRef)authorization; + +- (void)setActive; +- (void)checkForUpdate; +- (void)startUpdate; +- (KSRegistrationTicketType)ticketType; + +@end // @interface KSRegistration + +@interface KeystoneGlue(Private) + +// Returns the path to the application's Info.plist file. This returns the +// outer application bundle's Info.plist, not the framework's Info.plist. +- (NSString*)appInfoPlistPath; + +// Returns a dictionary containing parameters to be used for a KSRegistration +// -registerWithParameters: or -promoteWithParameters:authorization: call. +- (NSDictionary*)keystoneParameters; + +// Called when Keystone registration completes. +- (void)registrationComplete:(NSNotification*)notification; + +// Called periodically to announce activity by pinging the Keystone server. +- (void)markActive:(NSTimer*)timer; + +// Called when an update check or update installation is complete. Posts the +// kAutoupdateStatusNotification notification to the default notification +// center. +- (void)updateStatus:(AutoupdateStatus)status version:(NSString*)version; + +// Returns the version of the currently-installed application on disk. +- (NSString*)currentlyInstalledVersion; + +// These three methods are used to determine the version of the application +// currently installed on disk, compare that to the currently-running version, +// decide whether any updates have been installed, and call +// -updateStatus:version:. +// +// In order to check the version on disk, the installed application's +// Info.plist dictionary must be read; in order to see changes as updates are +// applied, the dictionary must be read each time, bypassing any caches such +// as the one that NSBundle might be maintaining. Reading files can be a +// blocking operation, and blocking operations are to be avoided on the main +// thread. I'm not quite sure what jank means, but I bet that a blocked main +// thread would cause some of it. +// +// -determineUpdateStatusAsync is called on the main thread to initiate the +// operation. It performs initial set-up work that must be done on the main +// thread and arranges for -determineUpdateStatus to be called on a work queue +// thread managed by WorkerPool. +// -determineUpdateStatus then reads the Info.plist, gets the version from the +// CFBundleShortVersionString key, and performs +// -determineUpdateStatusForVersion: on the main thread. +// -determineUpdateStatusForVersion: does the actual comparison of the version +// on disk with the running version and calls -updateStatus:version: with the +// results of its analysis. +- (void)determineUpdateStatusAsync; +- (void)determineUpdateStatus; +- (void)determineUpdateStatusForVersion:(NSString*)version; + +// Returns YES if registration_ is definitely on a user ticket. If definitely +// on a system ticket, or uncertain of ticket type (due to an older version +// of Keystone being used), returns NO. +- (BOOL)isUserTicket; + +// Called when ticket promotion completes. +- (void)promotionComplete:(NSNotification*)notification; + +// Changes the application's ownership and permissions so that all files are +// owned by root:wheel and all files and directories are writable only by +// root, but readable and executable as needed by everyone. +// -changePermissionsForPromotionAsync is called on the main thread by +// -promotionComplete. That routine calls +// -changePermissionsForPromotionWithTool: on a work queue thread. When done, +// -changePermissionsForPromotionComplete is called on the main thread. +- (void)changePermissionsForPromotionAsync; +- (void)changePermissionsForPromotionWithTool:(NSString*)toolPath; +- (void)changePermissionsForPromotionComplete; + +// Returns the brand file path to use for Keystone. +- (NSString*)brandFilePath; + +@end // @interface KeystoneGlue(Private) + +NSString* const kAutoupdateStatusNotification = @"AutoupdateStatusNotification"; +NSString* const kAutoupdateStatusStatus = @"status"; +NSString* const kAutoupdateStatusVersion = @"version"; + +namespace { + +NSString* const kChannelKey = @"KSChannelID"; +NSString* const kBrandKey = @"KSBrandID"; + +} // namespace + +@implementation KeystoneGlue + ++ (id)defaultKeystoneGlue { + static bool sTriedCreatingDefaultKeystoneGlue = false; + // TODO(jrg): use base::SingletonObjC<KeystoneGlue> + static KeystoneGlue* sDefaultKeystoneGlue = nil; // leaked + + if (!sTriedCreatingDefaultKeystoneGlue) { + sTriedCreatingDefaultKeystoneGlue = true; + + sDefaultKeystoneGlue = [[KeystoneGlue alloc] init]; + [sDefaultKeystoneGlue loadParameters]; + if (![sDefaultKeystoneGlue loadKeystoneRegistration]) { + [sDefaultKeystoneGlue release]; + sDefaultKeystoneGlue = nil; + } + } + return sDefaultKeystoneGlue; +} + +- (id)init { + if ((self = [super init])) { + NSNotificationCenter* center = [NSNotificationCenter defaultCenter]; + + [center addObserver:self + selector:@selector(registrationComplete:) + name:KSRegistrationDidCompleteNotification + object:nil]; + + [center addObserver:self + selector:@selector(promotionComplete:) + name:KSRegistrationPromotionDidCompleteNotification + object:nil]; + + [center addObserver:self + selector:@selector(checkForUpdateComplete:) + name:KSRegistrationCheckForUpdateNotification + object:nil]; + + [center addObserver:self + selector:@selector(installUpdateComplete:) + name:KSRegistrationStartUpdateNotification + object:nil]; + } + + return self; +} + +- (void)dealloc { + [productID_ release]; + [appPath_ release]; + [url_ release]; + [version_ release]; + [channel_ release]; + [registration_ release]; + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [super dealloc]; +} + +- (NSDictionary*)infoDictionary { + // Use [NSBundle mainBundle] to get the application's own bundle identifier + // and path, not the framework's. For auto-update, the application is + // what's significant here: it's used to locate the outermost part of the + // application for the existence checker and other operations that need to + // see the entire application bundle. + return [[NSBundle mainBundle] infoDictionary]; +} + +- (void)loadParameters { + NSBundle* appBundle = [NSBundle mainBundle]; + NSDictionary* infoDictionary = [self infoDictionary]; + + NSString* productID = [infoDictionary objectForKey:@"KSProductID"]; + if (productID == nil) { + productID = [appBundle bundleIdentifier]; + } + + NSString* appPath = [appBundle bundlePath]; + NSString* url = [infoDictionary objectForKey:@"KSUpdateURL"]; + NSString* version = [infoDictionary objectForKey:@"KSVersion"]; + + if (!productID || !appPath || !url || !version) { + // If parameters required for Keystone are missing, don't use it. + return; + } + + NSString* channel = [infoDictionary objectForKey:kChannelKey]; + // The stable channel has no tag. If updating to stable, remove the + // dev and beta tags since we've been "promoted". + if (channel == nil) + channel = KSRegistrationRemoveExistingTag; + + productID_ = [productID retain]; + appPath_ = [appPath retain]; + url_ = [url retain]; + version_ = [version retain]; + channel_ = [channel retain]; +} + +- (NSString*)brandFilePath { + DCHECK(version_ != nil) << "-loadParameters must be called first"; + + if (brandFileType_ == kBrandFileTypeNotDetermined) { + + // Default to none. + brandFileType_ = kBrandFileTypeNone; + + // Having a channel means Dev/Beta, so there is no brand code to go with + // those. + if ([channel_ length] == 0) { + + NSString* userBrandFile = UserBrandFilePath(); + NSString* systemBrandFile = SystemBrandFilePath(); + + NSFileManager* fm = [NSFileManager defaultManager]; + + // If there is a system brand file, use it. + if ([fm fileExistsAtPath:systemBrandFile]) { + // System + + // Use the system file that is there. + brandFileType_ = kBrandFileTypeSystem; + + // Clean up any old user level file. + if ([fm fileExistsAtPath:userBrandFile]) { + [fm removeItemAtPath:userBrandFile error:NULL]; + } + + } else { + // User + + NSDictionary* infoDictionary = [self infoDictionary]; + NSString* appBundleBrandID = [infoDictionary objectForKey:kBrandKey]; + + NSString* storedBrandID = nil; + if ([fm fileExistsAtPath:userBrandFile]) { + NSDictionary* storedBrandDict = + [NSDictionary dictionaryWithContentsOfFile:userBrandFile]; + storedBrandID = [storedBrandDict objectForKey:kBrandKey]; + } + + if ((appBundleBrandID != nil) && + (![storedBrandID isEqualTo:appBundleBrandID])) { + // App and store don't match, update store and use it. + NSDictionary* storedBrandDict = + [NSDictionary dictionaryWithObject:appBundleBrandID + forKey:kBrandKey]; + // If Keystone hasn't been installed yet, the location the brand file + // is written to won't exist, so manually create the directory. + NSString *userBrandFileDirectory = + [userBrandFile stringByDeletingLastPathComponent]; + if (![fm fileExistsAtPath:userBrandFileDirectory]) { + if (![fm createDirectoryAtPath:userBrandFileDirectory + withIntermediateDirectories:YES + attributes:nil + error:NULL]) { + LOG(ERROR) << "Failed to create the directory for the brand file"; + } + } + if ([storedBrandDict writeToFile:userBrandFile atomically:YES]) { + brandFileType_ = kBrandFileTypeUser; + } + } else if (storedBrandID) { + // Had stored brand, use it. + brandFileType_ = kBrandFileTypeUser; + } + } + } + + } + + NSString* result = nil; + switch (brandFileType_) { + case kBrandFileTypeUser: + result = UserBrandFilePath(); + break; + + case kBrandFileTypeSystem: + result = SystemBrandFilePath(); + break; + + case kBrandFileTypeNotDetermined: + NOTIMPLEMENTED(); + // Fall through + case kBrandFileTypeNone: + // Clear the value. + result = @""; + break; + + } + return result; +} + +- (BOOL)loadKeystoneRegistration { + if (!productID_ || !appPath_ || !url_ || !version_) + return NO; + + // Load the KeystoneRegistration framework bundle if present. It lives + // inside the framework, so use mac_util::MainAppBundle(); + NSString* ksrPath = + [[mac_util::MainAppBundle() privateFrameworksPath] + stringByAppendingPathComponent:@"KeystoneRegistration.framework"]; + NSBundle* ksrBundle = [NSBundle bundleWithPath:ksrPath]; + [ksrBundle load]; + + // Harness the KSRegistration class. + Class ksrClass = [ksrBundle classNamed:@"KSRegistration"]; + KSRegistration* ksr = [ksrClass registrationWithProductID:productID_]; + if (!ksr) + return NO; + + registration_ = [ksr retain]; + return YES; +} + +- (NSString*)appInfoPlistPath { + // NSBundle ought to have a way to access this path directly, but it + // doesn't. + return [[appPath_ stringByAppendingPathComponent:@"Contents"] + stringByAppendingPathComponent:@"Info.plist"]; +} + +- (NSDictionary*)keystoneParameters { + NSNumber* xcType = [NSNumber numberWithInt:kKSPathExistenceChecker]; + NSNumber* preserveTTToken = [NSNumber numberWithBool:YES]; + NSString* tagPath = [self appInfoPlistPath]; + + NSString* brandKey = kBrandKey; + NSString* brandPath = [self brandFilePath]; + + if ([brandPath length] == 0) { + // Brand path and brand key must be cleared together or ksadmin seems + // to throw an error. + brandKey = @""; + } + + return [NSDictionary dictionaryWithObjectsAndKeys: + version_, KSRegistrationVersionKey, + xcType, KSRegistrationExistenceCheckerTypeKey, + appPath_, KSRegistrationExistenceCheckerStringKey, + url_, KSRegistrationServerURLStringKey, + preserveTTToken, KSRegistrationPreserveTrustedTesterTokenKey, + channel_, KSRegistrationTagKey, + tagPath, KSRegistrationTagPathKey, + kChannelKey, KSRegistrationTagKeyKey, + brandPath, KSRegistrationBrandPathKey, + brandKey, KSRegistrationBrandKeyKey, + nil]; +} + +- (void)registerWithKeystone { + [self updateStatus:kAutoupdateRegistering version:nil]; + + NSDictionary* parameters = [self keystoneParameters]; + if (![registration_ registerWithParameters:parameters]) { + [self updateStatus:kAutoupdateRegisterFailed version:nil]; + return; + } + + // Upon completion, KSRegistrationDidCompleteNotification will be posted, + // and -registrationComplete: will be called. + + // Mark an active RIGHT NOW; don't wait an hour for the first one. + [registration_ setActive]; + + // Set up hourly activity pings. + timer_ = [NSTimer scheduledTimerWithTimeInterval:60 * 60 // One hour + target:self + selector:@selector(markActive:) + userInfo:registration_ + repeats:YES]; +} + +- (void)registrationComplete:(NSNotification*)notification { + NSDictionary* userInfo = [notification userInfo]; + if ([[userInfo objectForKey:KSRegistrationStatusKey] boolValue]) { + [self updateStatus:kAutoupdateRegistered version:nil]; + } else { + // Dump registration_? + [self updateStatus:kAutoupdateRegisterFailed version:nil]; + } +} + +- (void)stopTimer { + [timer_ invalidate]; +} + +- (void)markActive:(NSTimer*)timer { + KSRegistration* ksr = [timer userInfo]; + [ksr setActive]; +} + +- (void)checkForUpdate { + DCHECK(![self asyncOperationPending]); + + if (!registration_) { + [self updateStatus:kAutoupdateCheckFailed version:nil]; + return; + } + + [self updateStatus:kAutoupdateChecking version:nil]; + + [registration_ checkForUpdate]; + + // Upon completion, KSRegistrationCheckForUpdateNotification will be posted, + // and -checkForUpdateComplete: will be called. +} + +- (void)checkForUpdateComplete:(NSNotification*)notification { + NSDictionary* userInfo = [notification userInfo]; + + if ([[userInfo objectForKey:KSRegistrationUpdateCheckErrorKey] boolValue]) { + [self updateStatus:kAutoupdateCheckFailed version:nil]; + } else if ([[userInfo objectForKey:KSRegistrationStatusKey] boolValue]) { + // If an update is known to be available, go straight to + // -updateStatus:version:. It doesn't matter what's currently on disk. + NSString* version = [userInfo objectForKey:KSRegistrationVersionKey]; + [self updateStatus:kAutoupdateAvailable version:version]; + } else { + // If no updates are available, check what's on disk, because an update + // may have already been installed. This check happens on another thread, + // and -updateStatus:version: will be called on the main thread when done. + [self determineUpdateStatusAsync]; + } +} + +- (void)installUpdate { + DCHECK(![self asyncOperationPending]); + + if (!registration_) { + [self updateStatus:kAutoupdateInstallFailed version:nil]; + return; + } + + [self updateStatus:kAutoupdateInstalling version:nil]; + + [registration_ startUpdate]; + + // Upon completion, KSRegistrationStartUpdateNotification will be posted, + // and -installUpdateComplete: will be called. +} + +- (void)installUpdateComplete:(NSNotification*)notification { + NSDictionary* userInfo = [notification userInfo]; + + if (![[userInfo objectForKey:KSUpdateCheckSuccessfulKey] boolValue] || + ![[userInfo objectForKey:KSUpdateCheckSuccessfullyInstalledKey] + intValue]) { + [self updateStatus:kAutoupdateInstallFailed version:nil]; + } else { + updateSuccessfullyInstalled_ = YES; + + // Nothing in the notification dictionary reports the version that was + // installed. Figure it out based on what's on disk. + [self determineUpdateStatusAsync]; + } +} + +- (NSString*)currentlyInstalledVersion { + NSString* appInfoPlistPath = [self appInfoPlistPath]; + NSDictionary* infoPlist = + [NSDictionary dictionaryWithContentsOfFile:appInfoPlistPath]; + return [infoPlist objectForKey:@"CFBundleShortVersionString"]; +} + +// Runs on the main thread. +- (void)determineUpdateStatusAsync { + DCHECK([NSThread isMainThread]); + + PerformBridge::PostPerform(self, @selector(determineUpdateStatus)); +} + +// Runs on a thread managed by WorkerPool. +- (void)determineUpdateStatus { + DCHECK(![NSThread isMainThread]); + + NSString* version = [self currentlyInstalledVersion]; + + [self performSelectorOnMainThread:@selector(determineUpdateStatusForVersion:) + withObject:version + waitUntilDone:NO]; +} + +// Runs on the main thread. +- (void)determineUpdateStatusForVersion:(NSString*)version { + DCHECK([NSThread isMainThread]); + + AutoupdateStatus status; + if (updateSuccessfullyInstalled_) { + // If an update was successfully installed and this object saw it happen, + // then don't even bother comparing versions. + status = kAutoupdateInstalled; + } else { + NSString* currentVersion = + [NSString stringWithUTF8String:chrome::kChromeVersion]; + if (!version) { + // If the version on disk could not be determined, assume that + // whatever's running is current. + version = currentVersion; + status = kAutoupdateCurrent; + } else if ([version isEqualToString:currentVersion]) { + status = kAutoupdateCurrent; + } else { + // If the version on disk doesn't match what's currently running, an + // update must have been applied in the background, without this app's + // direct participation. Leave updateSuccessfullyInstalled_ alone + // because there's no direct knowledge of what actually happened. + status = kAutoupdateInstalled; + } + } + + [self updateStatus:status version:version]; +} + +- (void)updateStatus:(AutoupdateStatus)status version:(NSString*)version { + NSNumber* statusNumber = [NSNumber numberWithInt:status]; + NSMutableDictionary* dictionary = + [NSMutableDictionary dictionaryWithObject:statusNumber + forKey:kAutoupdateStatusStatus]; + if (version) { + [dictionary setObject:version forKey:kAutoupdateStatusVersion]; + } + + NSNotification* notification = + [NSNotification notificationWithName:kAutoupdateStatusNotification + object:self + userInfo:dictionary]; + recentNotification_.reset([notification retain]); + + [[NSNotificationCenter defaultCenter] postNotification:notification]; +} + +- (NSNotification*)recentNotification { + return [[recentNotification_ retain] autorelease]; +} + +- (AutoupdateStatus)recentStatus { + NSDictionary* dictionary = [recentNotification_ userInfo]; + return static_cast<AutoupdateStatus>( + [[dictionary objectForKey:kAutoupdateStatusStatus] intValue]); +} + +- (BOOL)asyncOperationPending { + AutoupdateStatus status = [self recentStatus]; + return status == kAutoupdateRegistering || + status == kAutoupdateChecking || + status == kAutoupdateInstalling || + status == kAutoupdatePromoting; +} + +- (BOOL)isUserTicket { + return [registration_ ticketType] == kKSRegistrationUserTicket; +} + +- (BOOL)isOnReadOnlyFilesystem { + const char* appPathC = [appPath_ fileSystemRepresentation]; + struct statfs statfsBuf; + + if (statfs(appPathC, &statfsBuf) != 0) { + PLOG(ERROR) << "statfs"; + // Be optimistic about the filesystem's writability. + return NO; + } + + return (statfsBuf.f_flags & MNT_RDONLY) != 0; +} + +- (BOOL)needsPromotion { + if (![self isUserTicket] || [self isOnReadOnlyFilesystem]) { + return NO; + } + + // Check the outermost bundle directory, the main executable path, and the + // framework directory. It may be enough to just look at the outermost + // bundle directory, but checking an interior file and directory can be + // helpful in case permissions are set differently only on the outermost + // directory. An interior file and directory are both checked because some + // file operations, such as Snow Leopard's Finder's copy operation when + // authenticating, may actually result in different ownership being applied + // to files and directories. + NSFileManager* fileManager = [NSFileManager defaultManager]; + NSString* executablePath = [[NSBundle mainBundle] executablePath]; + NSString* frameworkPath = [mac_util::MainAppBundle() bundlePath]; + return ![fileManager isWritableFileAtPath:appPath_] || + ![fileManager isWritableFileAtPath:executablePath] || + ![fileManager isWritableFileAtPath:frameworkPath]; +} + +- (BOOL)wantsPromotion { + // -needsPromotion checks these too, but this method doesn't necessarily + // return NO just becuase -needsPromotion returns NO, so another check is + // needed here. + if (![self isUserTicket] || [self isOnReadOnlyFilesystem]) { + return NO; + } + + if ([self needsPromotion]) { + return YES; + } + + return [appPath_ hasPrefix:@"/Applications/"]; +} + +- (void)promoteTicket { + if ([self asyncOperationPending] || ![self wantsPromotion]) { + // Because there are multiple ways of reaching promoteTicket that might + // not lock each other out, it may be possible to arrive here while an + // asynchronous operation is pending, or even after promotion has already + // occurred. Just quietly return without doing anything. + return; + } + + NSString* prompt = l10n_util::GetNSStringFWithFixup( + IDS_PROMOTE_AUTHENTICATION_PROMPT, + l10n_util::GetStringUTF16(IDS_PRODUCT_NAME)); + scoped_AuthorizationRef authorization( + authorization_util::AuthorizationCreateToRunAsRoot( + reinterpret_cast<CFStringRef>(prompt))); + if (!authorization.get()) { + return; + } + + [self promoteTicketWithAuthorization:authorization.release() synchronous:NO]; +} + +- (void)promoteTicketWithAuthorization:(AuthorizationRef)authorization_arg + synchronous:(BOOL)synchronous { + scoped_AuthorizationRef authorization(authorization_arg); + authorization_arg = NULL; + + if ([self asyncOperationPending]) { + // Starting a synchronous operation while an asynchronous one is pending + // could be trouble. + return; + } + if (!synchronous && ![self wantsPromotion]) { + // If operating synchronously, the call came from the installer, which + // means that a system ticket is required. Otherwise, only allow + // promotion if it's wanted. + return; + } + + synchronousPromotion_ = synchronous; + + [self updateStatus:kAutoupdatePromoting version:nil]; + + // TODO(mark): Remove when able! + // + // keystone_promote_preflight will copy the current brand information out to + // the system level so all users can share the data as part of the ticket + // promotion. + // + // It will also ensure that the Keystone system ticket store is in a usable + // state for all users on the system. Ideally, Keystone's installer or + // another part of Keystone would handle this. The underlying problem is + // http://b/2285921, and it causes http://b/2289908, which this workaround + // addresses. + // + // This is run synchronously, which isn't optimal, but + // -[KSRegistration promoteWithParameters:authorization:] is currently + // synchronous too, and this operation needs to happen before that one. + // + // TODO(mark): Make asynchronous. That only makes sense if the promotion + // operation itself is asynchronous too. http://b/2290009. Hopefully, + // the Keystone promotion code will just be changed to do what preflight + // now does, and then the preflight script can be removed instead. + // However, preflight operation (and promotion) should only be asynchronous + // if the synchronous parameter is NO. + NSString* preflightPath = + [mac_util::MainAppBundle() pathForResource:@"keystone_promote_preflight" + ofType:@"sh"]; + const char* preflightPathC = [preflightPath fileSystemRepresentation]; + const char* userBrandFile = NULL; + const char* systemBrandFile = NULL; + if (brandFileType_ == kBrandFileTypeUser) { + // Running with user level brand file, promote to the system level. + userBrandFile = [UserBrandFilePath() fileSystemRepresentation]; + systemBrandFile = [SystemBrandFilePath() fileSystemRepresentation]; + } + const char* arguments[] = {userBrandFile, systemBrandFile, NULL}; + + int exit_status; + OSStatus status = authorization_util::ExecuteWithPrivilegesAndWait( + authorization, + preflightPathC, + kAuthorizationFlagDefaults, + arguments, + NULL, // pipe + &exit_status); + if (status != errAuthorizationSuccess) { + LOG(ERROR) << "AuthorizationExecuteWithPrivileges preflight: " << status; + [self updateStatus:kAutoupdatePromoteFailed version:nil]; + return; + } + if (exit_status != 0) { + LOG(ERROR) << "keystone_promote_preflight status " << exit_status; + [self updateStatus:kAutoupdatePromoteFailed version:nil]; + return; + } + + // Hang on to the AuthorizationRef so that it can be used once promotion is + // complete. Do this before asking Keystone to promote the ticket, because + // -promotionComplete: may be called from inside the Keystone promotion + // call. + authorization_.swap(authorization); + + NSDictionary* parameters = [self keystoneParameters]; + + // If the brand file is user level, update parameters to point to the new + // system level file during promotion. + if (brandFileType_ == kBrandFileTypeUser) { + NSMutableDictionary* temp_parameters = + [[parameters mutableCopy] autorelease]; + [temp_parameters setObject:SystemBrandFilePath() + forKey:KSRegistrationBrandPathKey]; + parameters = temp_parameters; + } + + if (![registration_ promoteWithParameters:parameters + authorization:authorization_]) { + [self updateStatus:kAutoupdatePromoteFailed version:nil]; + authorization_.reset(); + return; + } + + // Upon completion, KSRegistrationPromotionDidCompleteNotification will be + // posted, and -promotionComplete: will be called. +} + +- (void)promotionComplete:(NSNotification*)notification { + NSDictionary* userInfo = [notification userInfo]; + if ([[userInfo objectForKey:KSRegistrationStatusKey] boolValue]) { + if (synchronousPromotion_) { + // Short-circuit: if performing a synchronous promotion, the promotion + // came from the installer, which already set the permissions properly. + // Rather than run a duplicate permission-changing operation, jump + // straight to "done." + [self changePermissionsForPromotionComplete]; + } else { + [self changePermissionsForPromotionAsync]; + } + } else { + authorization_.reset(); + [self updateStatus:kAutoupdatePromoteFailed version:nil]; + } +} + +- (void)changePermissionsForPromotionAsync { + // NSBundle is not documented as being thread-safe. Do NSBundle operations + // on the main thread before jumping over to a WorkerPool-managed + // thread to run the tool. + DCHECK([NSThread isMainThread]); + + SEL selector = @selector(changePermissionsForPromotionWithTool:); + NSString* toolPath = + [mac_util::MainAppBundle() pathForResource:@"keystone_promote_postflight" + ofType:@"sh"]; + + PerformBridge::PostPerform(self, selector, toolPath); +} + +- (void)changePermissionsForPromotionWithTool:(NSString*)toolPath { + const char* toolPathC = [toolPath fileSystemRepresentation]; + + const char* appPathC = [appPath_ fileSystemRepresentation]; + const char* arguments[] = {appPathC, NULL}; + + int exit_status; + OSStatus status = authorization_util::ExecuteWithPrivilegesAndWait( + authorization_, + toolPathC, + kAuthorizationFlagDefaults, + arguments, + NULL, // pipe + &exit_status); + if (status != errAuthorizationSuccess) { + LOG(ERROR) << "AuthorizationExecuteWithPrivileges postflight: " << status; + } else if (exit_status != 0) { + LOG(ERROR) << "keystone_promote_postflight status " << exit_status; + } + + SEL selector = @selector(changePermissionsForPromotionComplete); + [self performSelectorOnMainThread:selector + withObject:nil + waitUntilDone:NO]; +} + +- (void)changePermissionsForPromotionComplete { + authorization_.reset(); + + [self updateStatus:kAutoupdatePromoted version:nil]; +} + +- (void)setAppPath:(NSString*)appPath { + if (appPath != appPath_) { + [appPath_ release]; + appPath_ = [appPath copy]; + } +} + +@end // @implementation KeystoneGlue + +namespace keystone_glue { + +bool KeystoneEnabled() { + return [KeystoneGlue defaultKeystoneGlue] != nil; +} + +string16 CurrentlyInstalledVersion() { + KeystoneGlue* keystoneGlue = [KeystoneGlue defaultKeystoneGlue]; + NSString* version = [keystoneGlue currentlyInstalledVersion]; + return base::SysNSStringToUTF16(version); +} + +} // namespace keystone_glue diff --git a/chrome/browser/ui/cocoa/keystone_glue_unittest.mm b/chrome/browser/ui/cocoa/keystone_glue_unittest.mm new file mode 100644 index 0000000..c4a26f4 --- /dev/null +++ b/chrome/browser/ui/cocoa/keystone_glue_unittest.mm @@ -0,0 +1,184 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Foundation/Foundation.h> +#import <objc/objc-class.h> + +#import "chrome/browser/ui/cocoa/keystone_glue.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +@interface FakeGlueRegistration : NSObject +@end + + +@implementation FakeGlueRegistration + +// Send the notifications that a real KeystoneGlue object would send. + +- (void)checkForUpdate { + NSNumber* yesNumber = [NSNumber numberWithBool:YES]; + NSString* statusKey = @"Status"; + NSDictionary* dictionary = [NSDictionary dictionaryWithObject:yesNumber + forKey:statusKey]; + NSNotificationCenter* center = [NSNotificationCenter defaultCenter]; + [center postNotificationName:@"KSRegistrationCheckForUpdateNotification" + object:nil + userInfo:dictionary]; +} + +- (void)startUpdate { + NSNotificationCenter* center = [NSNotificationCenter defaultCenter]; + [center postNotificationName:@"KSRegistrationStartUpdateNotification" + object:nil]; +} + +@end + + +@interface FakeKeystoneGlue : KeystoneGlue { + @public + BOOL upToDate_; + NSString *latestVersion_; + BOOL successful_; + int installs_; +} + +- (void)fakeAboutWindowCallback:(NSNotification*)notification; +@end + + +@implementation FakeKeystoneGlue + +- (id)init { + if ((self = [super init])) { + // some lies + upToDate_ = YES; + latestVersion_ = @"foo bar"; + successful_ = YES; + installs_ = 1010101010; + + // Set up an observer that takes the notification that the About window + // listens for. + NSNotificationCenter* center = [NSNotificationCenter defaultCenter]; + [center addObserver:self + selector:@selector(fakeAboutWindowCallback:) + name:kAutoupdateStatusNotification + object:nil]; + } + return self; +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [super dealloc]; +} + +// For mocking +- (NSDictionary*)infoDictionary { + NSDictionary* dict = [NSDictionary dictionaryWithObjectsAndKeys: + @"http://foo.bar", @"KSUpdateURL", + @"com.google.whatever", @"KSProductID", + @"0.0.0.1", @"KSVersion", + nil]; + return dict; +} + +// For mocking +- (BOOL)loadKeystoneRegistration { + return YES; +} + +// Confirms certain things are happy +- (BOOL)dictReadCorrectly { + return ([url_ isEqual:@"http://foo.bar"] && + [productID_ isEqual:@"com.google.whatever"] && + [version_ isEqual:@"0.0.0.1"]); +} + +// Confirms certain things are happy +- (BOOL)hasATimer { + return timer_ ? YES : NO; +} + +- (void)addFakeRegistration { + registration_ = [[FakeGlueRegistration alloc] init]; +} + +- (void)fakeAboutWindowCallback:(NSNotification*)notification { + NSDictionary* dictionary = [notification userInfo]; + AutoupdateStatus status = static_cast<AutoupdateStatus>( + [[dictionary objectForKey:kAutoupdateStatusStatus] intValue]); + + if (status == kAutoupdateAvailable) { + upToDate_ = NO; + latestVersion_ = [dictionary objectForKey:kAutoupdateStatusVersion]; + } else if (status == kAutoupdateInstallFailed) { + successful_ = NO; + installs_ = 0; + } +} + +// Confirm we look like callbacks with nil NSNotifications +- (BOOL)confirmCallbacks { + return (!upToDate_ && + (latestVersion_ == nil) && + !successful_ && + (installs_ == 0)); +} + +@end + + +namespace { + +class KeystoneGlueTest : public PlatformTest { +}; + +// DISABLED because the mocking isn't currently working. +TEST_F(KeystoneGlueTest, DISABLED_BasicGlobalCreate) { + // Allow creation of a KeystoneGlue by mocking out a few calls + SEL ids = @selector(infoDictionary); + IMP oldInfoImp_ = [[KeystoneGlue class] instanceMethodForSelector:ids]; + IMP newInfoImp_ = [[FakeKeystoneGlue class] instanceMethodForSelector:ids]; + Method infoMethod_ = class_getInstanceMethod([KeystoneGlue class], ids); + method_setImplementation(infoMethod_, newInfoImp_); + + SEL lks = @selector(loadKeystoneRegistration); + IMP oldLoadImp_ = [[KeystoneGlue class] instanceMethodForSelector:lks]; + IMP newLoadImp_ = [[FakeKeystoneGlue class] instanceMethodForSelector:lks]; + Method loadMethod_ = class_getInstanceMethod([KeystoneGlue class], lks); + method_setImplementation(loadMethod_, newLoadImp_); + + KeystoneGlue *glue = [KeystoneGlue defaultKeystoneGlue]; + ASSERT_TRUE(glue); + + // Fix back up the class to the way we found it. + method_setImplementation(infoMethod_, oldInfoImp_); + method_setImplementation(loadMethod_, oldLoadImp_); +} + +// DISABLED because the mocking isn't currently working. +TEST_F(KeystoneGlueTest, DISABLED_BasicUse) { + FakeKeystoneGlue* glue = [[[FakeKeystoneGlue alloc] init] autorelease]; + [glue loadParameters]; + ASSERT_TRUE([glue dictReadCorrectly]); + + // Likely returns NO in the unit test, but call it anyway to make + // sure it doesn't crash. + [glue loadKeystoneRegistration]; + + // Confirm we start up an active timer + [glue registerWithKeystone]; + ASSERT_TRUE([glue hasATimer]); + [glue stopTimer]; + + // Brief exercise of callbacks + [glue addFakeRegistration]; + [glue checkForUpdate]; + [glue installUpdate]; + ASSERT_TRUE([glue confirmCallbacks]); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/keystone_infobar.h b/chrome/browser/ui/cocoa/keystone_infobar.h new file mode 100644 index 0000000..61596ac --- /dev/null +++ b/chrome/browser/ui/cocoa/keystone_infobar.h @@ -0,0 +1,24 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_KEYSTONE_INFOBAR_H_ +#define CHROME_BROWSER_UI_COCOA_KEYSTONE_INFOBAR_H_ +#pragma once + +class Profile; + +class KeystoneInfoBar { + public: + // If the application is Keystone-enabled and not on a read-only filesystem + // (capable of being auto-updated), and Keystone indicates that it needs + // ticket promotion, PromotionInfoBar displays an info bar asking the user + // to promote the ticket. The user will need to authenticate in order to + // gain authorization to perform the promotion. The info bar is not shown + // if its "don't ask" button was ever clicked, if the "don't check default + // browser" command-line flag is present, on the very first launch, or if + // another info bar is already showing in the active tab. + static void PromotionInfoBar(Profile* profile); +}; + +#endif // CHROME_BROWSER_UI_COCOA_KEYSTONE_INFOBAR_H_ diff --git a/chrome/browser/ui/cocoa/keystone_infobar.mm b/chrome/browser/ui/cocoa/keystone_infobar.mm new file mode 100644 index 0000000..60635d6 --- /dev/null +++ b/chrome/browser/ui/cocoa/keystone_infobar.mm @@ -0,0 +1,212 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/keystone_infobar.h" + +#import <AppKit/AppKit.h> + +#include <string> + +#include "app/l10n_util.h" +#include "app/resource_bundle.h" +#include "base/command_line.h" +#include "base/message_loop.h" +#include "base/task.h" +#include "chrome/browser/first_run/first_run.h" +#include "chrome/browser/prefs/pref_service.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/tab_contents/infobar_delegate.h" +#include "chrome/browser/tab_contents/navigation_controller.h" +#include "chrome/browser/tab_contents/tab_contents.h" +#include "chrome/browser/ui/browser.h" +#include "chrome/browser/ui/browser_list.h" +#import "chrome/browser/ui/cocoa/keystone_glue.h" +#include "chrome/common/chrome_switches.h" +#include "chrome/common/pref_names.h" +#include "grit/chromium_strings.h" +#include "grit/generated_resources.h" +#include "grit/theme_resources.h" + +class SkBitmap; + +namespace { + +class KeystonePromotionInfoBarDelegate : public ConfirmInfoBarDelegate { + public: + KeystonePromotionInfoBarDelegate(TabContents* tab_contents) + : ConfirmInfoBarDelegate(tab_contents), + profile_(tab_contents->profile()), + can_expire_(false), + ALLOW_THIS_IN_INITIALIZER_LIST(method_factory_(this)) { + const int kCanExpireOnNavigationAfterMilliseconds = 8 * 1000; + MessageLoop::current()->PostDelayedTask( + FROM_HERE, + method_factory_.NewRunnableMethod( + &KeystonePromotionInfoBarDelegate::SetCanExpire), + kCanExpireOnNavigationAfterMilliseconds); + } + + virtual ~KeystonePromotionInfoBarDelegate() {} + + // Inherited from InfoBarDelegate and overridden. + + virtual bool ShouldExpire( + const NavigationController::LoadCommittedDetails& details) { + return can_expire_; + } + + virtual void InfoBarClosed() { + delete this; + } + + // Inherited from AlertInfoBarDelegate and overridden. + + virtual string16 GetMessageText() const { + return l10n_util::GetStringFUTF16(IDS_PROMOTE_INFOBAR_TEXT, + l10n_util::GetStringUTF16(IDS_PRODUCT_NAME)); + } + + virtual SkBitmap* GetIcon() const { + return ResourceBundle::GetSharedInstance().GetBitmapNamed( + IDR_PRODUCT_ICON_32); + } + + // Inherited from ConfirmInfoBarDelegate and overridden. + + virtual int GetButtons() const { + return BUTTON_OK | BUTTON_CANCEL | BUTTON_OK_DEFAULT; + } + + virtual string16 GetButtonLabel(InfoBarButton button) const { + return button == BUTTON_OK ? + l10n_util::GetStringUTF16(IDS_PROMOTE_INFOBAR_PROMOTE_BUTTON) : + l10n_util::GetStringUTF16(IDS_PROMOTE_INFOBAR_DONT_ASK_BUTTON); + } + + virtual bool Accept() { + [[KeystoneGlue defaultKeystoneGlue] promoteTicket]; + return true; + } + + virtual bool Cancel() { + profile_->GetPrefs()->SetBoolean(prefs::kShowUpdatePromotionInfoBar, false); + return true; + } + + private: + // Sets this info bar to be able to expire. Called a predetermined amount + // of time after this object is created. + void SetCanExpire() { + can_expire_ = true; + } + + // The TabContents' profile. + Profile* profile_; // weak + + // Whether the info bar should be dismissed on the next navigation. + bool can_expire_; + + // Used to delay the expiration of the info bar. + ScopedRunnableMethodFactory<KeystonePromotionInfoBarDelegate> method_factory_; + + DISALLOW_COPY_AND_ASSIGN(KeystonePromotionInfoBarDelegate); +}; + +} // namespace + +@interface KeystonePromotionInfoBar : NSObject +- (void)checkAndShowInfoBarForProfile:(Profile*)profile; +- (void)updateStatus:(NSNotification*)notification; +- (void)removeObserver; +@end // @interface KeystonePromotionInfoBar + +@implementation KeystonePromotionInfoBar + +- (void)dealloc { + [self removeObserver]; + [super dealloc]; +} + +- (void)checkAndShowInfoBarForProfile:(Profile*)profile { + // If this is the first run, the user clicked the "don't ask again" button + // at some point in the past, or if the "don't ask about the default + // browser" command-line switch is present, bail out. That command-line + // switch is recycled here because it's likely that the set of users that + // don't want to be nagged about the default browser also don't want to be + // nagged about the update check. (Automated testers, I'm thinking of + // you...) + CommandLine* commandLine = CommandLine::ForCurrentProcess(); + if (FirstRun::IsChromeFirstRun() || + !profile->GetPrefs()->GetBoolean(prefs::kShowUpdatePromotionInfoBar) || + commandLine->HasSwitch(switches::kNoDefaultBrowserCheck)) { + return; + } + + // If there is no Keystone glue (maybe because this application isn't + // Keystone-enabled) or the application is on a read-only filesystem, + // doing anything related to auto-update is pointless. Bail out. + KeystoneGlue* keystoneGlue = [KeystoneGlue defaultKeystoneGlue]; + if (!keystoneGlue || [keystoneGlue isOnReadOnlyFilesystem]) { + return; + } + + // Stay alive as long as needed. This is balanced by a release in + // -updateStatus:. + [self retain]; + + AutoupdateStatus recentStatus = [keystoneGlue recentStatus]; + if (recentStatus == kAutoupdateNone || + recentStatus == kAutoupdateRegistering) { + NSNotificationCenter* center = [NSNotificationCenter defaultCenter]; + [center addObserver:self + selector:@selector(updateStatus:) + name:kAutoupdateStatusNotification + object:nil]; + } else { + [self updateStatus:[keystoneGlue recentNotification]]; + } +} + +- (void)updateStatus:(NSNotification*)notification { + NSDictionary* dictionary = [notification userInfo]; + AutoupdateStatus status = static_cast<AutoupdateStatus>( + [[dictionary objectForKey:kAutoupdateStatusStatus] intValue]); + + if (status == kAutoupdateNone || status == kAutoupdateRegistering) { + return; + } + + [self removeObserver]; + + if (status != kAutoupdateRegisterFailed && + [[KeystoneGlue defaultKeystoneGlue] needsPromotion]) { + Browser* browser = BrowserList::GetLastActive(); + if (browser) { + TabContents* tabContents = browser->GetSelectedTabContents(); + + // Only show if no other info bars are showing, because that's how the + // default browser info bar works. + if (tabContents && tabContents->infobar_delegate_count() == 0) { + tabContents->AddInfoBar( + new KeystonePromotionInfoBarDelegate(tabContents)); + } + } + } + + [self release]; +} + +- (void)removeObserver { + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +@end // @implementation KeystonePromotionInfoBar + +// static +void KeystoneInfoBar::PromotionInfoBar(Profile* profile) { + KeystonePromotionInfoBar* promotionInfoBar = + [[[KeystonePromotionInfoBar alloc] init] autorelease]; + + [promotionInfoBar checkAndShowInfoBarForProfile:profile]; +} diff --git a/chrome/browser/ui/cocoa/keystone_promote_postflight.sh b/chrome/browser/ui/cocoa/keystone_promote_postflight.sh new file mode 100755 index 0000000..44799f8 --- /dev/null +++ b/chrome/browser/ui/cocoa/keystone_promote_postflight.sh @@ -0,0 +1,55 @@ +#!/bin/bash -p + +# Copyright (c) 2009 The Chromium Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +# Called as root after Keystone ticket promotion to change the owner, group, +# and permissions on the application. The application bundle and its contents +# are set to owner root, group wheel, and to be writable only by root, but +# readable and executable (when appropriate) by everyone. +# +# Note that this script will be invoked with the real user ID set to the +# user's ID, but the effective user ID set to 0 (root). bash -p is used on +# the first line to prevent bash from setting the effective user ID to the +# real user ID (dropping root privileges). +# +# WARNING: This script is NOT currently run when the Keystone ticket is +# promoted during application installation directly from the disk image, +# because the installation process itself handles the same permission fix-ups +# that this script normally would. + +set -e + +# This script runs as root, so be paranoid about things like ${PATH}. +export PATH="/usr/bin:/usr/sbin:/bin:/sbin" + +# Output the pid to stdout before doing anything else. See +# chrome/browser/ui/cocoa/authorization_util.h. +echo "${$}" + +if [ ${#} -ne 1 ] ; then + echo "usage: ${0} APP" >& 2 + exit 2 +fi + +APP="${1}" + +# Make sure that APP is an absolute path and that it exists. +if [ -z "${APP}" ] || [ "${APP:0:1}" != "/" ] || [ ! -d "${APP}" ] ; then + echo "${0}: must provide an absolute path naming an extant directory" >& 2 + exit 3 +fi + +OWNER_GROUP="root:wheel" +chown -Rh "${OWNER_GROUP}" "${APP}" >& /dev/null + +CHMOD_MODE="a+rX,u+w,go-w" +chmod -R "${CHMOD_MODE}" "${APP}" >& /dev/null + +# On the Mac, or at least on HFS+, symbolic link permissions are significant, +# but chmod -R and -h can't be used together. Do another pass to fix the +# permissions on any symbolic links. +find "${APP}" -type l -exec chmod -h "${CHMOD_MODE}" {} + >& /dev/null + +exit 0 diff --git a/chrome/browser/ui/cocoa/keystone_promote_preflight.sh b/chrome/browser/ui/cocoa/keystone_promote_preflight.sh new file mode 100755 index 0000000..4bf31e8 --- /dev/null +++ b/chrome/browser/ui/cocoa/keystone_promote_preflight.sh @@ -0,0 +1,97 @@ +#!/bin/bash -p + +# Copyright (c) 2009 The Chromium Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +# Called as root before Keystone ticket promotion to ensure a suitable +# environment for Keystone installation. Ultimately, these features should be +# integrated directly into the Keystone installation. +# +# If the two branding paths are given, then the branding information is also +# copied and the permissions on the system branding file are set to be owned by +# root, but readable by anyone. +# +# Note that this script will be invoked with the real user ID set to the +# user's ID, but the effective user ID set to 0 (root). bash -p is used on +# the first line to prevent bash from setting the effective user ID to the +# real user ID (dropping root privileges). +# +# TODO(mark): Remove this script when able. See http://b/2285921 and +# http://b/2289908. + +set -e + +# This script runs as root, so be paranoid about things like ${PATH}. +export PATH="/usr/bin:/usr/sbin:/bin:/sbin" + +# Output the pid to stdout before doing anything else. See +# chrome/browser/cocoa/authorization_util.h. +echo "${$}" + +if [ ${#} -ne 0 ] && [ ${#} -ne 2 ] ; then + echo "usage: ${0} [USER_BRAND SYSTEM_BRAND]" >& 2 + exit 2 +fi + +if [ ${#} -eq 2 ] ; then + USER_BRAND="${1}" + SYSTEM_BRAND="${2}" + + # Make sure that USER_BRAND is an absolute path and that it exists. + if [ -z "${USER_BRAND}" ] || \ + [ "${USER_BRAND:0:1}" != "/" ] || \ + [ ! -f "${USER_BRAND}" ] ; then + echo "${0}: must provide an absolute path naming an existing user file" >& 2 + exit 3 + fi + + # Make sure that SYSTEM_BRAND is an absolute path. + if [ -z "${SYSTEM_BRAND}" ] || [ "${SYSTEM_BRAND:0:1}" != "/" ] ; then + echo "${0}: must provide an absolute path naming a system file" >& 2 + exit 4 + fi + + # Make sure the directory for the system brand file exists. + SYSTEM_BRAND_DIR=$(dirname "${SYSTEM_BRAND}") + if [ ! -e "${SYSTEM_BRAND_DIR}" ] ; then + mkdir -p "${SYSTEM_BRAND_DIR}" + # Permissions on this directory will be fixed up at the end of this script. + fi + + # Copy the brand file + cp "${USER_BRAND}" "${SYSTEM_BRAND}" >& /dev/null + + # Ensure the right ownership and permissions + chown "root:wheel" "${SYSTEM_BRAND}" >& /dev/null + chmod "a+r,u+w,go-w" "${SYSTEM_BRAND}" >& /dev/null + +fi + +OWNER_GROUP="root:admin" +CHMOD_MODE="a+rX,u+w,go-w" + +LIB_GOOG="/Library/Google" +if [ -d "${LIB_GOOG}" ] ; then + # Just work with the directory. Don't do anything recursively here, so as + # to leave other things in /Library/Google alone. + chown -h "${OWNER_GROUP}" "${LIB_GOOG}" >& /dev/null + chmod -h "${CHMOD_MODE}" "${LIB_GOOG}" >& /dev/null + + LIB_GOOG_GSU="${LIB_GOOG}/GoogleSoftwareUpdate" + if [ -d "${LIB_GOOG_GSU}" ] ; then + chown -Rh "${OWNER_GROUP}" "${LIB_GOOG_GSU}" >& /dev/null + chmod -R "${CHMOD_MODE}" "${LIB_GOOG_GSU}" >& /dev/null + + # On the Mac, or at least on HFS+, symbolic link permissions are + # significant, but chmod -R and -h can't be used together. Do another + # pass to fix the permissions on any symbolic links. + find "${LIB_GOOG_GSU}" -type l -exec chmod -h "${CHMOD_MODE}" {} + >& \ + /dev/null + + # TODO(mark): If GoogleSoftwareUpdate.bundle is missing, dump TicketStore + # too? + fi +fi + +exit 0 diff --git a/chrome/browser/ui/cocoa/keyword_editor_cocoa_controller.h b/chrome/browser/ui/cocoa/keyword_editor_cocoa_controller.h new file mode 100644 index 0000000..0934f0d --- /dev/null +++ b/chrome/browser/ui/cocoa/keyword_editor_cocoa_controller.h @@ -0,0 +1,117 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "app/table_model_observer.h" +#import "base/cocoa_protocols_mac.h" +#include "base/scoped_ptr.h" +#include "base/string16.h" +#include "chrome/browser/search_engines/edit_search_engine_controller.h" +#include "chrome/browser/search_engines/keyword_editor_controller.h" +#include "chrome/browser/search_engines/template_url_model_observer.h" +#include "chrome/browser/ui/cocoa/table_row_nsimage_cache.h" + +class EditSearchEngineControllerDelegate; +@class KeywordEditorCocoaController; +class Profile; +@class WindowSizeAutosaver; + +// Very thin bridge that simply pushes notifications from C++ to ObjC. +class KeywordEditorModelObserver : public TemplateURLModelObserver, + public EditSearchEngineControllerDelegate, + public TableModelObserver, + public TableRowNSImageCache::Table { + public: + explicit KeywordEditorModelObserver(KeywordEditorCocoaController* controller); + virtual ~KeywordEditorModelObserver(); + + // Notification that the template url model has changed in some way. + virtual void OnTemplateURLModelChanged(); + + // Invoked from the EditSearchEngineController when the user accepts the + // edits. NOTE: |template_url| is the value supplied to + // EditSearchEngineController's constructor, and may be NULL. A NULL value + // indicates a new TemplateURL should be created rather than modifying an + // existing TemplateURL. + virtual void OnEditedKeyword(const TemplateURL* template_url, + const string16& title, + const string16& keyword, + const std::string& url); + + // TableModelObserver overrides. Invalidate icon cache. + virtual void OnModelChanged(); + virtual void OnItemsChanged(int start, int length); + virtual void OnItemsAdded(int start, int length); + virtual void OnItemsRemoved(int start, int length); + + // TableRowNSImageCache::Table + virtual int RowCount() const; + virtual SkBitmap GetIcon(int row) const; + + // Lazily converts the image at the given row and caches it in |icon_cache_|. + NSImage* GetImageForRow(int row); + + private: + KeywordEditorCocoaController* controller_; + + TableRowNSImageCache icon_cache_; + + DISALLOW_COPY_AND_ASSIGN(KeywordEditorModelObserver); +}; + +// This controller manages a window with a table view of search engines. It +// acts as |tableView_|'s data source and delegate, feeding it data from the +// KeywordEditorController's |table_model()|. + +@interface KeywordEditorCocoaController : NSWindowController + <NSWindowDelegate, + NSTableViewDataSource, + NSTableViewDelegate> { + IBOutlet NSTableView* tableView_; + IBOutlet NSButton* addButton_; + IBOutlet NSButton* removeButton_; + IBOutlet NSButton* makeDefaultButton_; + + scoped_nsobject<NSTextFieldCell> groupCell_; + + Profile* profile_; // weak + scoped_ptr<KeywordEditorController> controller_; + scoped_ptr<KeywordEditorModelObserver> observer_; + + scoped_nsobject<WindowSizeAutosaver> sizeSaver_; +} +@property (nonatomic, readonly) KeywordEditorController* controller; + +// Show the keyword editor associated with the given profile (or the +// original profile if this is an incognito profile). If no keyword +// editor exists for this profile, create one and show it. Any +// resulting editor releases itself when closed. ++ (void)showKeywordEditor:(Profile*)profile; + +- (KeywordEditorController*)controller; + +// Message forwarded by KeywordEditorModelObserver. +- (void)modelChanged; + +- (IBAction)addKeyword:(id)sender; +- (IBAction)deleteKeyword:(id)sender; +- (IBAction)makeDefault:(id)sender; + +@end + +@interface KeywordEditorCocoaController (TestingAPI) + +// Instances of this class are managed, use +showKeywordEditor:. +- (id)initWithProfile:(Profile*)profile; + +// Returns a reference to the shared instance for the given profile, +// or nil if there is none. ++ (KeywordEditorCocoaController*)sharedInstanceForProfile:(Profile*)profile; + +// Converts a row index in our table view (which has group header rows) into +// one in the |controller_|'s model, which does not have them. +- (int)indexInModelForRow:(NSUInteger)row; + +@end diff --git a/chrome/browser/ui/cocoa/keyword_editor_cocoa_controller.mm b/chrome/browser/ui/cocoa/keyword_editor_cocoa_controller.mm new file mode 100644 index 0000000..109fcd9 --- /dev/null +++ b/chrome/browser/ui/cocoa/keyword_editor_cocoa_controller.mm @@ -0,0 +1,422 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#import "chrome/browser/ui/cocoa/keyword_editor_cocoa_controller.h" + +#import "base/mac_util.h" +#include "base/singleton.h" +#include "base/sys_string_conversions.h" +#include "chrome/browser/browser_process.h" +#include "chrome/browser/prefs/pref_service.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/search_engines/template_url_model.h" +#include "chrome/browser/search_engines/template_url_table_model.h" +#import "chrome/browser/ui/cocoa/edit_search_engine_cocoa_controller.h" +#import "chrome/browser/ui/cocoa/window_size_autosaver.h" +#include "chrome/common/pref_names.h" +#include "grit/generated_resources.h" +#include "skia/ext/skia_utils_mac.h" +#include "third_party/GTM/AppKit/GTMUILocalizerAndLayoutTweaker.h" +#include "third_party/skia/include/core/SkBitmap.h" + +namespace { + +const CGFloat kButtonBarHeight = 35.0; + +} // namespace + +@interface KeywordEditorCocoaController (Private) +- (void)adjustEditingButtons; +- (void)editKeyword:(id)sender; +- (int)indexInModelForRow:(NSUInteger)row; +@end + +// KeywordEditorModelObserver ------------------------------------------------- + +KeywordEditorModelObserver::KeywordEditorModelObserver( + KeywordEditorCocoaController* controller) + : controller_(controller), + icon_cache_(this) { +} + +KeywordEditorModelObserver::~KeywordEditorModelObserver() { +} + +// Notification that the template url model has changed in some way. +void KeywordEditorModelObserver::OnTemplateURLModelChanged() { + [controller_ modelChanged]; +} + +void KeywordEditorModelObserver::OnEditedKeyword( + const TemplateURL* template_url, + const string16& title, + const string16& keyword, + const std::string& url) { + KeywordEditorController* controller = [controller_ controller]; + if (template_url) { + controller->ModifyTemplateURL(template_url, title, keyword, url); + } else { + controller->AddTemplateURL(title, keyword, url); + } +} + +void KeywordEditorModelObserver::OnModelChanged() { + icon_cache_.OnModelChanged(); + [controller_ modelChanged]; +} + +void KeywordEditorModelObserver::OnItemsChanged(int start, int length) { + icon_cache_.OnItemsChanged(start, length); + [controller_ modelChanged]; +} + +void KeywordEditorModelObserver::OnItemsAdded(int start, int length) { + icon_cache_.OnItemsAdded(start, length); + [controller_ modelChanged]; +} + +void KeywordEditorModelObserver::OnItemsRemoved(int start, int length) { + icon_cache_.OnItemsRemoved(start, length); + [controller_ modelChanged]; +} + +int KeywordEditorModelObserver::RowCount() const { + return [controller_ controller]->table_model()->RowCount(); +} + +SkBitmap KeywordEditorModelObserver::GetIcon(int row) const { + return [controller_ controller]->table_model()->GetIcon(row); +} + +NSImage* KeywordEditorModelObserver::GetImageForRow(int row) { + return icon_cache_.GetImageForRow(row); +} + +// KeywordEditorCocoaController ----------------------------------------------- + +namespace { + +typedef std::map<Profile*,KeywordEditorCocoaController*> ProfileControllerMap; + +} // namespace + +@implementation KeywordEditorCocoaController + ++ (KeywordEditorCocoaController*)sharedInstanceForProfile:(Profile*)profile { + ProfileControllerMap* map = Singleton<ProfileControllerMap>::get(); + DCHECK(map != NULL); + ProfileControllerMap::iterator it = map->find(profile); + if (it != map->end()) { + return it->second; + } + return nil; +} + +// TODO(shess): The Windows code watches a single global window which +// is not distinguished by profile. This code could distinguish by +// profile by checking the controller's class and profile. ++ (void)showKeywordEditor:(Profile*)profile { + // http://crbug.com/23359 describes a case where this panel is + // opened from an incognito window, which can leave the panel + // holding onto a stale profile. Since the same panel is used + // either way, arrange to use the original profile instead. + profile = profile->GetOriginalProfile(); + + ProfileControllerMap* map = Singleton<ProfileControllerMap>::get(); + DCHECK(map != NULL); + ProfileControllerMap::iterator it = map->find(profile); + if (it == map->end()) { + // Since we don't currently support multiple profiles, this class + // has not been tested against them, so document that assumption. + DCHECK_EQ(map->size(), 0U); + + KeywordEditorCocoaController* controller = + [[self alloc] initWithProfile:profile]; + it = map->insert(std::make_pair(profile, controller)).first; + } + + [it->second showWindow:nil]; +} + +- (id)initWithProfile:(Profile*)profile { + DCHECK(profile); + NSString* nibpath = [mac_util::MainAppBundle() + pathForResource:@"KeywordEditor" + ofType:@"nib"]; + if ((self = [super initWithWindowNibPath:nibpath owner:self])) { + profile_ = profile; + controller_.reset(new KeywordEditorController(profile_)); + observer_.reset(new KeywordEditorModelObserver(self)); + controller_->table_model()->SetObserver(observer_.get()); + controller_->url_model()->AddObserver(observer_.get()); + groupCell_.reset([[NSTextFieldCell alloc] init]); + + if (g_browser_process && g_browser_process->local_state()) { + sizeSaver_.reset([[WindowSizeAutosaver alloc] + initWithWindow:[self window] + prefService:g_browser_process->local_state() + path:prefs::kKeywordEditorWindowPlacement]); + } + } + return self; +} + +- (void)dealloc { + controller_->table_model()->SetObserver(NULL); + controller_->url_model()->RemoveObserver(observer_.get()); + [tableView_ setDataSource:nil]; + observer_.reset(); + [super dealloc]; +} + +- (void)awakeFromNib { + // Make sure the button fits its label, but keep it the same height as the + // other two buttons. + [GTMUILocalizerAndLayoutTweaker sizeToFitView:makeDefaultButton_]; + NSSize size = [makeDefaultButton_ frame].size; + size.height = NSHeight([addButton_ frame]); + [makeDefaultButton_ setFrameSize:size]; + + [[self window] setAutorecalculatesContentBorderThickness:NO + forEdge:NSMinYEdge]; + [[self window] setContentBorderThickness:kButtonBarHeight + forEdge:NSMinYEdge]; + + [self adjustEditingButtons]; + [tableView_ setDoubleAction:@selector(editKeyword:)]; + [tableView_ setTarget:self]; +} + +// When the window closes, clean ourselves up. +- (void)windowWillClose:(NSNotification*)notif { + [self autorelease]; + + ProfileControllerMap* map = Singleton<ProfileControllerMap>::get(); + ProfileControllerMap::iterator it = map->find(profile_); + // It should not be possible for this to be missing. + // TODO(shess): Except that the unit test reaches in directly. + // Consider circling around and refactoring that. + //DCHECK(it != map->end()); + if (it != map->end()) { + map->erase(it); + } +} + +- (void)modelChanged { + [tableView_ reloadData]; + [self adjustEditingButtons]; +} + +- (KeywordEditorController*)controller { + return controller_.get(); +} + +- (void)sheetDidEnd:(NSWindow*)sheet + returnCode:(NSInteger)code + context:(void*)context { + [sheet orderOut:self]; +} + +- (IBAction)addKeyword:(id)sender { + // The controller will release itself when the window closes. + EditSearchEngineCocoaController* editor = + [[EditSearchEngineCocoaController alloc] initWithProfile:profile_ + delegate:observer_.get() + templateURL:NULL]; + [NSApp beginSheet:[editor window] + modalForWindow:[self window] + modalDelegate:self + didEndSelector:@selector(sheetDidEnd:returnCode:context:) + contextInfo:NULL]; +} + +- (void)editKeyword:(id)sender { + const NSInteger clickedRow = [tableView_ clickedRow]; + if (clickedRow < 0 || [self tableView:tableView_ isGroupRow:clickedRow]) + return; + const TemplateURL* url = controller_->GetTemplateURL( + [self indexInModelForRow:clickedRow]); + // The controller will release itself when the window closes. + EditSearchEngineCocoaController* editor = + [[EditSearchEngineCocoaController alloc] initWithProfile:profile_ + delegate:observer_.get() + templateURL:url]; + [NSApp beginSheet:[editor window] + modalForWindow:[self window] + modalDelegate:self + didEndSelector:@selector(sheetDidEnd:returnCode:context:) + contextInfo:NULL]; +} + +- (IBAction)deleteKeyword:(id)sender { + NSIndexSet* selection = [tableView_ selectedRowIndexes]; + DCHECK_GT([selection count], 0U); + NSUInteger index = [selection lastIndex]; + while (index != NSNotFound) { + controller_->RemoveTemplateURL([self indexInModelForRow:index]); + index = [selection indexLessThanIndex:index]; + } +} + +- (IBAction)makeDefault:(id)sender { + NSIndexSet* selection = [tableView_ selectedRowIndexes]; + DCHECK_EQ([selection count], 1U); + int row = [self indexInModelForRow:[selection firstIndex]]; + controller_->MakeDefaultTemplateURL(row); +} + +// Called when the user hits the escape key. Closes the window. +- (void)cancel:(id)sender { + [[self window] performClose:self]; +} + +// Table View Data Source ----------------------------------------------------- + +- (NSInteger)numberOfRowsInTableView:(NSTableView*)table { + int rowCount = controller_->table_model()->RowCount(); + int numGroups = controller_->table_model()->GetGroups().size(); + if ([self tableView:table isGroupRow:rowCount + numGroups - 1]) { + // Don't show a group header with no rows underneath it. + --numGroups; + } + return rowCount + numGroups; +} + +- (id)tableView:(NSTableView*)tv + objectValueForTableColumn:(NSTableColumn*)tableColumn + row:(NSInteger)row { + if ([self tableView:tv isGroupRow:row]) { + DCHECK(!tableColumn); + TableModel::Groups groups = controller_->table_model()->GetGroups(); + if (row == 0) { + return base::SysWideToNSString(groups[0].title); + } else { + return base::SysWideToNSString(groups[1].title); + } + } + + NSString* identifier = [tableColumn identifier]; + if ([identifier isEqualToString:@"name"]) { + // The name column is an NSButtonCell so we can have text and image in the + // same cell. As such, the "object value" for a button cell is either on + // or off, so we always return off so we don't act like a button. + return [NSNumber numberWithInt:NSOffState]; + } + if ([identifier isEqualToString:@"keyword"]) { + // The keyword object value is a normal string. + int index = [self indexInModelForRow:row]; + int columnID = IDS_SEARCH_ENGINES_EDITOR_KEYWORD_COLUMN; + std::wstring text = controller_->table_model()->GetText(index, columnID); + return base::SysWideToNSString(text); + } + + // And we shouldn't have any other columns... + NOTREACHED(); + return nil; +} + +// Table View Delegate -------------------------------------------------------- + +// When the selection in the table view changes, we need to adjust buttons. +- (void)tableViewSelectionDidChange:(NSNotification*)aNotification { + [self adjustEditingButtons]; +} + +// Disallow selection of the group header rows. +- (BOOL)tableView:(NSTableView*)table shouldSelectRow:(NSInteger)row { + return ![self tableView:table isGroupRow:row]; +} + +- (BOOL)tableView:(NSTableView*)table isGroupRow:(NSInteger)row { + int otherGroupRow = + controller_->table_model()->last_search_engine_index() + 1; + return (row == 0 || row == otherGroupRow); +} + +- (NSCell*)tableView:(NSTableView*)tableView + dataCellForTableColumn:(NSTableColumn*)tableColumn + row:(NSInteger)row { + static const CGFloat kCellFontSize = 12.0; + + // Check to see if we are a grouped row. + if ([self tableView:tableView isGroupRow:row]) { + DCHECK(!tableColumn); // This would violate the group row contract. + return groupCell_.get(); + } + + NSCell* cell = [tableColumn dataCellForRow:row]; + int offsetRow = [self indexInModelForRow:row]; + + // Set the favicon and title for the search engine in the name column. + if ([[tableColumn identifier] isEqualToString:@"name"]) { + DCHECK([cell isKindOfClass:[NSButtonCell class]]); + NSButtonCell* buttonCell = static_cast<NSButtonCell*>(cell); + std::wstring title = controller_->table_model()->GetText(offsetRow, + IDS_SEARCH_ENGINES_EDITOR_DESCRIPTION_COLUMN); + [buttonCell setTitle:base::SysWideToNSString(title)]; + [buttonCell setImage:observer_->GetImageForRow(offsetRow)]; + [buttonCell setRefusesFirstResponder:YES]; // Don't push in like a button. + [buttonCell setHighlightsBy:NSNoCellMask]; + } + + // The default search engine should be in bold font. + const TemplateURL* defaultEngine = + controller_->url_model()->GetDefaultSearchProvider(); + int rowIndex = controller_->table_model()->IndexOfTemplateURL(defaultEngine); + if (rowIndex == offsetRow) { + [cell setFont:[NSFont boldSystemFontOfSize:kCellFontSize]]; + } else { + [cell setFont:[NSFont systemFontOfSize:kCellFontSize]]; + } + return cell; +} + +// Private -------------------------------------------------------------------- + +// This function appropriately sets the enabled states on the table's editing +// buttons. +- (void)adjustEditingButtons { + NSIndexSet* selection = [tableView_ selectedRowIndexes]; + BOOL canRemove = ([selection count] > 0); + NSUInteger index = [selection firstIndex]; + + // Delete button. + while (canRemove && index != NSNotFound) { + int modelIndex = [self indexInModelForRow:index]; + const TemplateURL& url = + controller_->table_model()->GetTemplateURL(modelIndex); + if (!controller_->CanRemove(&url)) + canRemove = NO; + index = [selection indexGreaterThanIndex:index]; + } + [removeButton_ setEnabled:canRemove]; + + // Make default button. + if ([selection count] != 1) { + [makeDefaultButton_ setEnabled:NO]; + } else { + int row = [self indexInModelForRow:[selection firstIndex]]; + const TemplateURL& url = + controller_->table_model()->GetTemplateURL(row); + [makeDefaultButton_ setEnabled:controller_->CanMakeDefault(&url)]; + } +} + +// This converts a row index in our table view to an index in the model by +// computing the group offsets. +- (int)indexInModelForRow:(NSUInteger)row { + DCHECK_GT(row, 0U); + unsigned otherGroupId = + controller_->table_model()->last_search_engine_index() + 1; + DCHECK_NE(row, otherGroupId); + if (row >= otherGroupId) { + return row - 2; // Other group. + } else { + return row - 1; // Default group. + } +} + +@end diff --git a/chrome/browser/ui/cocoa/keyword_editor_cocoa_controller_unittest.mm b/chrome/browser/ui/cocoa/keyword_editor_cocoa_controller_unittest.mm new file mode 100644 index 0000000..eb5c264 --- /dev/null +++ b/chrome/browser/ui/cocoa/keyword_editor_cocoa_controller_unittest.mm @@ -0,0 +1,227 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/mac/scoped_nsautorelease_pool.h" +#include "base/scoped_nsobject.h" +#include "chrome/browser/search_engines/template_url.h" +#include "chrome/browser/search_engines/template_url_model.h" +#include "chrome/browser/ui/browser.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#include "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/keyword_editor_cocoa_controller.h" +#include "chrome/test/testing_profile.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +@interface FakeKeywordEditorController : KeywordEditorCocoaController { + @public + BOOL modelChanged_; +} +- (void)modelChanged; +- (BOOL)hasModelChanged; +- (KeywordEditorModelObserver*)observer; +@end + +@implementation FakeKeywordEditorController + +- (void)modelChanged { + modelChanged_ = YES; +} + +- (BOOL)hasModelChanged { + return modelChanged_; +} + +- (KeywordEditorModelObserver*)observer { + return observer_.get(); +} + +- (NSTableView*)tableView { + return tableView_; +} + +@end + +// TODO(rsesek): Figure out a good way to test this class (crbug.com/21640). + +namespace { + +class KeywordEditorCocoaControllerTest : public CocoaTest { + public: + virtual void SetUp() { + CocoaTest::SetUp(); + TestingProfile* profile = + static_cast<TestingProfile*>(browser_helper_.profile()); + profile->CreateTemplateURLModel(); + + controller_ = + [[FakeKeywordEditorController alloc] initWithProfile:profile]; + } + + virtual void TearDown() { + // Force the window to load so we hit |-awakeFromNib| to register as the + // window's delegate so that the controller can clean itself up in + // |-windowWillClose:|. + ASSERT_TRUE([controller_ window]); + + [controller_ close]; + CocoaTest::TearDown(); + } + + // Helper to count the keyword editors. + NSUInteger CountKeywordEditors() { + base::mac::ScopedNSAutoreleasePool pool; + NSUInteger count = 0; + for (NSWindow* window in [NSApp windows]) { + id controller = [window windowController]; + if ([controller isKindOfClass:[KeywordEditorCocoaController class]]) { + ++count; + } + } + return count; + } + + BrowserTestHelper browser_helper_; + FakeKeywordEditorController* controller_; +}; + +TEST_F(KeywordEditorCocoaControllerTest, TestModelChanged) { + EXPECT_FALSE([controller_ hasModelChanged]); + KeywordEditorModelObserver* observer = [controller_ observer]; + observer->OnTemplateURLModelChanged(); + EXPECT_TRUE([controller_ hasModelChanged]); +} + +// Test that +showKeywordEditor brings up the existing editor and +// creates one if needed. +TEST_F(KeywordEditorCocoaControllerTest, ShowKeywordEditor) { + // No outstanding editors. + Profile* profile(browser_helper_.profile()); + KeywordEditorCocoaController* sharedInstance = + [KeywordEditorCocoaController sharedInstanceForProfile:profile]; + EXPECT_TRUE(nil == sharedInstance); + EXPECT_EQ(CountKeywordEditors(), 0U); + + const NSUInteger initial_window_count([[NSApp windows] count]); + + // The window unwinds using -autorelease, so we need to introduce an + // autorelease pool to really test whether it went away or not. + { + base::mac::ScopedNSAutoreleasePool pool; + + // +showKeywordEditor: creates a new controller. + [KeywordEditorCocoaController showKeywordEditor:profile]; + sharedInstance = + [KeywordEditorCocoaController sharedInstanceForProfile:profile]; + EXPECT_TRUE(sharedInstance); + EXPECT_EQ(CountKeywordEditors(), 1U); + + // Another call doesn't create another controller. + [KeywordEditorCocoaController showKeywordEditor:profile]; + EXPECT_TRUE(sharedInstance == + [KeywordEditorCocoaController sharedInstanceForProfile:profile]); + EXPECT_EQ(CountKeywordEditors(), 1U); + + [sharedInstance close]; + } + + // No outstanding editors. + sharedInstance = + [KeywordEditorCocoaController sharedInstanceForProfile:profile]; + EXPECT_TRUE(nil == sharedInstance); + EXPECT_EQ(CountKeywordEditors(), 0U); + + // Windows we created should be gone. + EXPECT_EQ([[NSApp windows] count], initial_window_count); + + // Get a new editor, should be different from the previous one. + [KeywordEditorCocoaController showKeywordEditor:profile]; + KeywordEditorCocoaController* newSharedInstance = + [KeywordEditorCocoaController sharedInstanceForProfile:profile]; + EXPECT_TRUE(sharedInstance != newSharedInstance); + EXPECT_EQ(CountKeywordEditors(), 1U); + [newSharedInstance close]; +} + +TEST_F(KeywordEditorCocoaControllerTest, IndexInModelForRowMixed) { + [controller_ window]; // Force |-awakeFromNib|. + TemplateURLModel* template_model = [controller_ controller]->url_model(); + + // Add a default engine. + TemplateURL* t_url = new TemplateURL(); + t_url->SetURL("http://test1/{searchTerms}", 0, 0); + t_url->set_keyword(L"test1"); + t_url->set_short_name(L"Test1"); + t_url->set_show_in_default_list(true); + template_model->Add(t_url); + + // Add a non-default engine. + t_url = new TemplateURL(); + t_url->SetURL("http://test2/{searchTerms}", 0, 0); + t_url->set_keyword(L"test2"); + t_url->set_short_name(L"Test2"); + t_url->set_show_in_default_list(false); + template_model->Add(t_url); + + // Two headers with a single row underneath each. + NSTableView* table = [controller_ tableView]; + [table reloadData]; + ASSERT_EQ(4, [[controller_ tableView] numberOfRows]); + + // Index 0 is the group header, index 1 should be the first engine. + ASSERT_EQ(0, [controller_ indexInModelForRow:1]); + + // Index 2 should be the group header, so index 3 should be the non-default + // engine. + ASSERT_EQ(1, [controller_ indexInModelForRow:3]); + + ASSERT_TRUE([controller_ tableView:table isGroupRow:0]); + ASSERT_FALSE([controller_ tableView:table isGroupRow:1]); + ASSERT_TRUE([controller_ tableView:table isGroupRow:2]); + ASSERT_FALSE([controller_ tableView:table isGroupRow:3]); + + ASSERT_FALSE([controller_ tableView:table shouldSelectRow:0]); + ASSERT_TRUE([controller_ tableView:table shouldSelectRow:1]); + ASSERT_FALSE([controller_ tableView:table shouldSelectRow:2]); + ASSERT_TRUE([controller_ tableView:table shouldSelectRow:3]); +} + +TEST_F(KeywordEditorCocoaControllerTest, IndexInModelForDefault) { + [controller_ window]; // Force |-awakeFromNib|. + TemplateURLModel* template_model = [controller_ controller]->url_model(); + + // Add 2 default engines. + TemplateURL* t_url = new TemplateURL(); + t_url->SetURL("http://test1/{searchTerms}", 0, 0); + t_url->set_keyword(L"test1"); + t_url->set_short_name(L"Test1"); + t_url->set_show_in_default_list(true); + template_model->Add(t_url); + + t_url = new TemplateURL(); + t_url->SetURL("http://test2/{searchTerms}", 0, 0); + t_url->set_keyword(L"test2"); + t_url->set_short_name(L"Test2"); + t_url->set_show_in_default_list(true); + template_model->Add(t_url); + + // One header and two rows. + NSTableView* table = [controller_ tableView]; + [table reloadData]; + ASSERT_EQ(3, [[controller_ tableView] numberOfRows]); + + // Index 0 is the group header, index 1 should be the first engine. + ASSERT_EQ(0, [controller_ indexInModelForRow:1]); + ASSERT_EQ(1, [controller_ indexInModelForRow:2]); + + ASSERT_TRUE([controller_ tableView:table isGroupRow:0]); + ASSERT_FALSE([controller_ tableView:table isGroupRow:1]); + ASSERT_FALSE([controller_ tableView:table isGroupRow:2]); + + ASSERT_FALSE([controller_ tableView:table shouldSelectRow:0]); + ASSERT_TRUE([controller_ tableView:table shouldSelectRow:1]); + ASSERT_TRUE([controller_ tableView:table shouldSelectRow:2]); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/l10n_util.h b/chrome/browser/ui/cocoa/l10n_util.h new file mode 100644 index 0000000..bb26327 --- /dev/null +++ b/chrome/browser/ui/cocoa/l10n_util.h @@ -0,0 +1,32 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/string16.h" + +namespace cocoa_l10n_util { + +// Compare function for -[NSArray sortedArrayUsingFunction:context:] that +// sorts the views in Y order bottom up. |context| is ignored. +NSInteger CompareFrameY(id view1, id view2, void* context); + +// Helper for tweaking views. If view is a: +// checkbox, radio group or label: it gets a forced wrap at current size +// editable field: left as is +// anything else: do +[GTMUILocalizerAndLayoutTweaker sizeToFitView:] +NSSize WrapOrSizeToFit(NSView* view); + +// Walks views in top-down order, wraps each to their current width, and moves +// the latter ones down to prevent overlaps. Returns the vertical delta in view +// coordinates. +CGFloat VerticallyReflowGroup(NSArray* views); + +// Like |ReplaceStringPlaceholders(const string16&, const string16&, size_t*)|, +// but for a NSString formatString. +NSString* ReplaceNSStringPlaceholders(NSString* formatString, + const string16& a, + size_t* offset); + +} // namespace cocoa_l10n_util diff --git a/chrome/browser/ui/cocoa/l10n_util.mm b/chrome/browser/ui/cocoa/l10n_util.mm new file mode 100644 index 0000000..5ebb8c7 --- /dev/null +++ b/chrome/browser/ui/cocoa/l10n_util.mm @@ -0,0 +1,78 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/l10n_util.h" + +#include "base/string_util.h" +#include "base/sys_string_conversions.h" +#import "third_party/GTM/AppKit/GTMUILocalizerAndLayoutTweaker.h" + +namespace cocoa_l10n_util { + +NSInteger CompareFrameY(id view1, id view2, void* context) { + CGFloat y1 = NSMinY([view1 frame]); + CGFloat y2 = NSMinY([view2 frame]); + if (y1 < y2) + return NSOrderedAscending; + else if (y1 > y2) + return NSOrderedDescending; + else + return NSOrderedSame; +} + +NSSize WrapOrSizeToFit(NSView* view) { + if ([view isKindOfClass:[NSTextField class]]) { + NSTextField* textField = static_cast<NSTextField*>(view); + if ([textField isEditable]) + return NSZeroSize; + CGFloat heightChange = + [GTMUILocalizerAndLayoutTweaker sizeToFitFixedWidthTextField:textField]; + return NSMakeSize(0.0, heightChange); + } + if ([view isKindOfClass:[NSMatrix class]]) { + NSMatrix* radioGroup = static_cast<NSMatrix*>(view); + [GTMUILocalizerAndLayoutTweaker wrapRadioGroupForWidth:radioGroup]; + return [GTMUILocalizerAndLayoutTweaker sizeToFitView:view]; + } + if ([view isKindOfClass:[NSButton class]]) { + NSButton* button = static_cast<NSButton*>(view); + NSButtonCell* buttonCell = [button cell]; + // Decide it's a checkbox via showsStateBy and highlightsBy. + if (([buttonCell showsStateBy] == NSCellState) && + ([buttonCell highlightsBy] == NSCellState)) { + [GTMUILocalizerAndLayoutTweaker wrapButtonTitleForWidth:button]; + return [GTMUILocalizerAndLayoutTweaker sizeToFitView:view]; + } + } + return [GTMUILocalizerAndLayoutTweaker sizeToFitView:view]; +} + +CGFloat VerticallyReflowGroup(NSArray* views) { + views = [views sortedArrayUsingFunction:CompareFrameY + context:NULL]; + CGFloat localVerticalShift = 0; + for (NSInteger index = [views count] - 1; index >= 0; --index) { + NSView* view = [views objectAtIndex:index]; + + NSSize delta = WrapOrSizeToFit(view); + localVerticalShift += delta.height; + if (localVerticalShift) { + NSPoint origin = [view frame].origin; + origin.y -= localVerticalShift; + [view setFrameOrigin:origin]; + } + } + return localVerticalShift; +} + +NSString* ReplaceNSStringPlaceholders(NSString* formatString, + const string16& a, + size_t* offset) { + return base::SysUTF16ToNSString( + ReplaceStringPlaceholders(base::SysNSStringToUTF16(formatString), + a, + offset)); +} + +} // namespace cocoa_l10n_util diff --git a/chrome/browser/ui/cocoa/location_bar/autocomplete_text_field.h b/chrome/browser/ui/cocoa/location_bar/autocomplete_text_field.h new file mode 100644 index 0000000..e731c2c --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/autocomplete_text_field.h @@ -0,0 +1,144 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_AUTOCOMPLETE_TEXT_FIELD_H_ +#define CHROME_BROWSER_UI_COCOA_AUTOCOMPLETE_TEXT_FIELD_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "base/cocoa_protocols_mac.h" +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/styled_text_field.h" +#import "chrome/browser/ui/cocoa/url_drop_target.h" + +@class AutocompleteTextFieldCell; + +// AutocompleteTextField intercepts UI actions for forwarding to +// AutocompleteEditViewMac (*), and provides a custom look. It works +// together with AutocompleteTextFieldEditor (mostly for intercepting +// user actions) and AutocompleteTextFieldCell (mostly for custom +// drawing). +// +// For historical reasons, chrome/browser/autocomplete is the core +// implementation of the Omnibox. Chrome code seems to vary between +// autocomplete and Omnibox in describing this. +// +// (*) AutocompleteEditViewMac is a view in the MVC sense for the +// Chrome internals, though it's really more of a mish-mash of model, +// view, and controller. + +// Provides a hook so that we can call directly down to +// AutocompleteEditViewMac rather than traversing the delegate chain. +class AutocompleteTextFieldObserver { + public: + // Called before changing the selected range of the field. + virtual NSRange SelectionRangeForProposedRange(NSRange proposed_range) = 0; + + // Called when the control-key state changes while the field is + // first responder. + virtual void OnControlKeyChanged(bool pressed) = 0; + + // Called when the user pastes into the field. + virtual void OnPaste() = 0; + + // Return |true| if there is a selection to copy. + virtual bool CanCopy() = 0; + + // Clears the |pboard| and adds the field's current selection. + // Called when the user does a copy or drag. + virtual void CopyToPasteboard(NSPasteboard* pboard) = 0; + + // Returns true if the current clipboard text supports paste and go + // (or paste and search). + virtual bool CanPasteAndGo() = 0; + + // Returns the appropriate "Paste and Go" or "Paste and Search" + // context menu string, depending on what is currently in the + // clipboard. Must not be called unless CanPasteAndGo() returns + // true. + virtual int GetPasteActionStringId() = 0; + + // Called when the user initiates a "paste and go" or "paste and + // search" into the field. + virtual void OnPasteAndGo() = 0; + + // Called when the field's frame changes. + virtual void OnFrameChanged() = 0; + + // Called when the popup is no longer appropriate, such as when the + // field's window loses focus or a page action is clicked. + virtual void ClosePopup() = 0; + + // Called when the user begins editing the field, for every edit, + // and when the user is done editing the field. + virtual void OnDidBeginEditing() = 0; + virtual void OnDidChange() = 0; + virtual void OnDidEndEditing() = 0; + + // NSResponder translates certain keyboard actions into selectors + // passed to -doCommandBySelector:. The selector is forwarded here, + // return true if |cmd| is handled, false if the caller should + // handle it. + // TODO(shess): For now, I think having the code which makes these + // decisions closer to the other autocomplete code is worthwhile, + // since it calls a wide variety of methods which otherwise aren't + // clearly relevent to expose here. But consider pulling more of + // the AutocompleteEditViewMac calls up to here. + virtual bool OnDoCommandBySelector(SEL cmd) = 0; + + // Called whenever the autocomplete text field gets focused. + virtual void OnSetFocus(bool control_down) = 0; + + // Called whenever the autocomplete text field is losing focus. + virtual void OnKillFocus() = 0; + + protected: + virtual ~AutocompleteTextFieldObserver() {} +}; + +@interface AutocompleteTextField : StyledTextField<NSTextViewDelegate, + URLDropTarget> { + @private + // Undo manager for this text field. We use a specific instance rather than + // the standard undo manager in order to let us clear the undo stack at will. + scoped_nsobject<NSUndoManager> undoManager_; + + AutocompleteTextFieldObserver* observer_; // weak, owned by location bar. + + // Handles being a drag-and-drop target. + scoped_nsobject<URLDropTargetHandler> dropHandler_; + + // Holds current tooltip strings, to keep them from being dealloced. + scoped_nsobject<NSMutableArray> currentToolTips_; +} + +@property (nonatomic) AutocompleteTextFieldObserver* observer; + +// Convenience method to return the cell, casted appropriately. +- (AutocompleteTextFieldCell*)cell; + +// Superclass aborts editing before changing the string, which causes +// problems for undo. This version modifies the field editor's +// contents if the control is already being edited. +- (void)setAttributedStringValue:(NSAttributedString*)aString; + +// Clears the undo chain for this text field. +- (void)clearUndoChain; + +// Updates cursor and tooltip rects depending on the contents of the text field +// e.g. the security icon should have a default pointer shown on hover instead +// of an I-beam. +- (void)updateCursorAndToolTipRects; + +// Return the appropriate menu for any decoration under |event|. +- (NSMenu*)decorationMenuForEvent:(NSEvent*)event; + +// Retains |tooltip| (in |currentToolTips_|) and adds this tooltip +// via -[NSView addToolTipRect:owner:userData:]. +- (void)addToolTip:(NSString*)tooltip forRect:(NSRect)aRect; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_AUTOCOMPLETE_TEXT_FIELD_H_ diff --git a/chrome/browser/ui/cocoa/location_bar/autocomplete_text_field.mm b/chrome/browser/ui/cocoa/location_bar/autocomplete_text_field.mm new file mode 100644 index 0000000..33c34cf --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/autocomplete_text_field.mm @@ -0,0 +1,385 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/location_bar/autocomplete_text_field.h" + +#include "base/logging.h" +#import "chrome/browser/ui/cocoa/browser_window_controller.h" +#import "chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_cell.h" +#import "chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_editor.h" +#import "chrome/browser/ui/cocoa/toolbar_controller.h" +#import "chrome/browser/ui/cocoa/url_drop_target.h" +#import "chrome/browser/ui/cocoa/view_id_util.h" + +@implementation AutocompleteTextField + +@synthesize observer = observer_; + ++ (Class)cellClass { + return [AutocompleteTextFieldCell class]; +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [super dealloc]; +} + +- (void)awakeFromNib { + DCHECK([[self cell] isKindOfClass:[AutocompleteTextFieldCell class]]); + [[self cell] setTruncatesLastVisibleLine:YES]; + [[self cell] setLineBreakMode:NSLineBreakByTruncatingTail]; + currentToolTips_.reset([[NSMutableArray alloc] init]); +} + +- (void)flagsChanged:(NSEvent*)theEvent { + if (observer_) { + const bool controlFlag = ([theEvent modifierFlags]&NSControlKeyMask) != 0; + observer_->OnControlKeyChanged(controlFlag); + } +} + +- (AutocompleteTextFieldCell*)cell { + NSCell* cell = [super cell]; + if (!cell) + return nil; + + DCHECK([cell isKindOfClass:[AutocompleteTextFieldCell class]]); + return static_cast<AutocompleteTextFieldCell*>(cell); +} + +// Reroute events for the decoration area to the field editor. This +// will cause the cursor to be moved as close to the edge where the +// event was seen as possible. +// +// The reason for this code's existence is subtle. NSTextField +// implements text selection and editing in terms of a "field editor". +// This is an NSTextView which is installed as a subview of the +// control when the field becomes first responder. When the field +// editor is installed, it will get -mouseDown: events and handle +// them, rather than the text field - EXCEPT for the event which +// caused the change in first responder, or events which fall in the +// decorations outside the field editor's area. In that case, the +// default NSTextField code will setup the field editor all over +// again, which has the side effect of doing "select all" on the text. +// This effect can be observed with a normal NSTextField if you click +// in the narrow border area, and is only really a problem because in +// our case the focus ring surrounds decorations which look clickable. +// +// When the user first clicks on the field, after installing the field +// editor the default NSTextField code detects if the hit is in the +// field editor area, and if so sets the selection to {0,0} to clear +// the selection before forwarding the event to the field editor for +// processing (it will set the cursor position). This also starts the +// click-drag selection machinery. +// +// This code does the same thing for cases where the click was in the +// decoration area. This allows the user to click-drag starting from +// a decoration area and get the expected selection behaviour, +// likewise for multiple clicks in those areas. +- (void)mouseDown:(NSEvent*)theEvent { + // Close the popup before processing the event. This prevents the + // popup from being visible while a right-click context menu or + // page-action menu is visible. Also, it matches other platforms. + if (observer_) + observer_->ClosePopup(); + + // If the click was a Control-click, bring up the context menu. + // |NSTextField| handles these cases inconsistently if the field is + // not already first responder. + if (([theEvent modifierFlags] & NSControlKeyMask) != 0) { + NSText* editor = [self currentEditor]; + NSMenu* menu = [editor menuForEvent:theEvent]; + [NSMenu popUpContextMenu:menu withEvent:theEvent forView:editor]; + return; + } + + const NSPoint location = + [self convertPoint:[theEvent locationInWindow] fromView:nil]; + const NSRect bounds([self bounds]); + + AutocompleteTextFieldCell* cell = [self cell]; + const NSRect textFrame([cell textFrameForFrame:bounds]); + + // A version of the textFrame which extends across the field's + // entire width. + + const NSRect fullFrame(NSMakeRect(bounds.origin.x, textFrame.origin.y, + bounds.size.width, textFrame.size.height)); + + // If the mouse is in the editing area, or above or below where the + // editing area would be if we didn't add decorations, forward to + // NSTextField -mouseDown: because it does the right thing. The + // above/below test is needed because NSTextView treats mouse events + // above/below as select-to-end-in-that-direction, which makes + // things janky. + BOOL flipped = [self isFlipped]; + if (NSMouseInRect(location, textFrame, flipped) || + !NSMouseInRect(location, fullFrame, flipped)) { + [super mouseDown:theEvent]; + + // After the event has been handled, if the current event is a + // mouse up and no selection was created (the mouse didn't move), + // select the entire field. + // NOTE(shess): This does not interfere with single-clicking to + // place caret after a selection is made. An NSTextField only has + // a selection when it has a field editor. The field editor is an + // NSText subview, which will receive the -mouseDown: in that + // case, and this code will never fire. + NSText* editor = [self currentEditor]; + if (editor) { + NSEvent* currentEvent = [NSApp currentEvent]; + if ([currentEvent type] == NSLeftMouseUp && + ![editor selectedRange].length) { + [editor selectAll:nil]; + } + } + + return; + } + + // Give the cell a chance to intercept clicks in page-actions and + // other decorative items. + if ([cell mouseDown:theEvent inRect:bounds ofView:self]) { + return; + } + + NSText* editor = [self currentEditor]; + + // We should only be here if we accepted first-responder status and + // have a field editor. If one of these fires, it means some + // assumptions are being broken. + DCHECK(editor != nil); + DCHECK([editor isDescendantOf:self]); + + // -becomeFirstResponder does a select-all, which we don't want + // because it can lead to a dragged-text situation. Clear the + // selection (any valid empty selection will do). + [editor setSelectedRange:NSMakeRange(0, 0)]; + + // If the event is to the right of the editing area, scroll the + // field editor to the end of the content so that the selection + // doesn't initiate from somewhere in the middle of the text. + if (location.x > NSMaxX(textFrame)) { + [editor scrollRangeToVisible:NSMakeRange([[self stringValue] length], 0)]; + } + + [editor mouseDown:theEvent]; +} + +// Overridden to pass OnFrameChanged() notifications to |observer_|. +// Additionally, cursor and tooltip rects need to be updated. +- (void)setFrame:(NSRect)frameRect { + [super setFrame:frameRect]; + if (observer_) { + observer_->OnFrameChanged(); + } + [self updateCursorAndToolTipRects]; +} + +// Due to theming, parts of the field are transparent. +- (BOOL)isOpaque { + return NO; +} + +- (void)setAttributedStringValue:(NSAttributedString*)aString { + AutocompleteTextFieldEditor* editor = + static_cast<AutocompleteTextFieldEditor*>([self currentEditor]); + + if (!editor) { + [super setAttributedStringValue:aString]; + } else { + // The type of the field editor must be AutocompleteTextFieldEditor, + // otherwise things won't work. + DCHECK([editor isKindOfClass:[AutocompleteTextFieldEditor class]]); + + [editor setAttributedString:aString]; + } +} + +- (NSUndoManager*)undoManagerForTextView:(NSTextView*)textView { + if (!undoManager_.get()) + undoManager_.reset([[NSUndoManager alloc] init]); + return undoManager_.get(); +} + +- (void)clearUndoChain { + [undoManager_ removeAllActions]; +} + +- (NSRange)textView:(NSTextView *)aTextView + willChangeSelectionFromCharacterRange:(NSRange)oldRange + toCharacterRange:(NSRange)newRange { + if (observer_) + return observer_->SelectionRangeForProposedRange(newRange); + return newRange; +} + +- (void)addToolTip:(NSString*)tooltip forRect:(NSRect)aRect { + [currentToolTips_ addObject:tooltip]; + [self addToolTipRect:aRect owner:tooltip userData:nil]; +} + +// TODO(shess): -resetFieldEditorFrameIfNeeded is the place where +// changes to the cell layout should be flushed. LocationBarViewMac +// and ToolbarController are calling this routine directly, and I +// think they are probably wrong. +// http://crbug.com/40053 +- (void)updateCursorAndToolTipRects { + // This will force |resetCursorRects| to be called, as it is not to be called + // directly. + [[self window] invalidateCursorRectsForView:self]; + + // |removeAllToolTips| only removes those set on the current NSView, not any + // subviews. Unless more tooltips are added to this view, this should suffice + // in place of managing a set of NSToolTipTag objects. + [self removeAllToolTips]; + + // Reload the decoration tooltips. + [currentToolTips_ removeAllObjects]; + [[self cell] updateToolTipsInRect:[self bounds] ofView:self]; +} + +// NOTE(shess): http://crbug.com/19116 describes a weird bug which +// happens when the user runs a Print panel on Leopard. After that, +// spurious -controlTextDidBeginEditing notifications are sent when an +// NSTextField is firstResponder, even though -currentEditor on that +// field returns nil. That notification caused significant problems +// in AutocompleteEditViewMac. -textDidBeginEditing: was NOT being +// sent in those cases, so this approach doesn't have the problem. +- (void)textDidBeginEditing:(NSNotification*)aNotification { + [super textDidBeginEditing:aNotification]; + if (observer_) { + observer_->OnDidBeginEditing(); + } +} + +- (void)textDidEndEditing:(NSNotification *)aNotification { + [super textDidEndEditing:aNotification]; + if (observer_) { + observer_->OnDidEndEditing(); + } +} + +// When the window resigns, make sure the autocomplete popup is no +// longer visible, since the user's focus is elsewhere. +- (void)windowDidResignKey:(NSNotification*)notification { + DCHECK_EQ([self window], [notification object]); + if (observer_) + observer_->ClosePopup(); +} + +- (void)viewWillMoveToWindow:(NSWindow*)newWindow { + if ([self window]) { + NSNotificationCenter* nc = [NSNotificationCenter defaultCenter]; + [nc removeObserver:self + name:NSWindowDidResignKeyNotification + object:[self window]]; + } +} + +- (void)viewDidMoveToWindow { + if ([self window]) { + NSNotificationCenter* nc = [NSNotificationCenter defaultCenter]; + [nc addObserver:self + selector:@selector(windowDidResignKey:) + name:NSWindowDidResignKeyNotification + object:[self window]]; + // Only register for drops if not in a popup window. Lazily create the + // drop handler when the type of window is known. + BrowserWindowController* windowController = + [BrowserWindowController browserWindowControllerForView:self]; + if ([windowController isNormalWindow]) + dropHandler_.reset([[URLDropTargetHandler alloc] initWithView:self]); + } +} + +// NSTextField becomes first responder by installing a "field editor" +// subview. Clicks outside the field editor (such as a decoration) +// will attempt to make the field the first-responder again, which +// causes a select-all, even if the decoration handles the click. If +// the field editor is already in place, don't accept first responder +// again. This allows the selection to be unmodified if the click is +// handled by a decoration or context menu (|-mouseDown:| will still +// change it if appropriate). +- (BOOL)acceptsFirstResponder { + if ([self currentEditor]) { + DCHECK_EQ([self currentEditor], [[self window] firstResponder]); + return NO; + } + return [super acceptsFirstResponder]; +} + +// (Overridden from NSResponder) +- (BOOL)becomeFirstResponder { + BOOL doAccept = [super becomeFirstResponder]; + if (doAccept) { + [[BrowserWindowController browserWindowControllerForView:self] + lockBarVisibilityForOwner:self withAnimation:YES delay:NO]; + + // Tells the observer that we get the focus. + // But we can't call observer_->OnKillFocus() in resignFirstResponder:, + // because the first responder will be immediately set to the field editor + // when calling [super becomeFirstResponder], thus we won't receive + // resignFirstResponder: anymore when losing focus. + if (observer_) { + NSEvent* theEvent = [NSApp currentEvent]; + const bool controlDown = ([theEvent modifierFlags]&NSControlKeyMask) != 0; + observer_->OnSetFocus(controlDown); + } + } + return doAccept; +} + +// (Overridden from NSResponder) +- (BOOL)resignFirstResponder { + BOOL doResign = [super resignFirstResponder]; + if (doResign) { + [[BrowserWindowController browserWindowControllerForView:self] + releaseBarVisibilityForOwner:self withAnimation:YES delay:YES]; + } + return doResign; +} + +// (URLDropTarget protocol) +- (id<URLDropTargetController>)urlDropController { + BrowserWindowController* windowController = + [BrowserWindowController browserWindowControllerForView:self]; + return [windowController toolbarController]; +} + +// (URLDropTarget protocol) +- (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)sender { + // Make ourself the first responder, which will select the text to indicate + // that our contents would be replaced by a drop. + // TODO(viettrungluu): crbug.com/30809 -- this is a hack since it steals focus + // and doesn't return it. + [[self window] makeFirstResponder:self]; + return [dropHandler_ draggingEntered:sender]; +} + +// (URLDropTarget protocol) +- (NSDragOperation)draggingUpdated:(id<NSDraggingInfo>)sender { + return [dropHandler_ draggingUpdated:sender]; +} + +// (URLDropTarget protocol) +- (void)draggingExited:(id<NSDraggingInfo>)sender { + return [dropHandler_ draggingExited:sender]; +} + +// (URLDropTarget protocol) +- (BOOL)performDragOperation:(id<NSDraggingInfo>)sender { + return [dropHandler_ performDragOperation:sender]; +} + +- (NSMenu*)decorationMenuForEvent:(NSEvent*)event { + AutocompleteTextFieldCell* cell = [self cell]; + return [cell decorationMenuForEvent:event inRect:[self bounds] ofView:self]; +} + +- (ViewID)viewID { + return VIEW_ID_LOCATION_BAR; +} + +@end diff --git a/chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_cell.h b/chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_cell.h new file mode 100644 index 0000000..1306253 --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_cell.h @@ -0,0 +1,76 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include <vector> + +#import <Cocoa/Cocoa.h> + +#import "chrome/browser/ui/cocoa/styled_text_field_cell.h" + +@class AutocompleteTextField; +class LocationBarDecoration; + +// AutocompleteTextFieldCell extends StyledTextFieldCell to provide support for +// certain decorations to be applied to the field. These are the search hint +// ("Type to search" on the right-hand side), the keyword hint ("Press [Tab] to +// search Engine" on the right-hand side), and keyword mode ("Search Engine:" in +// a button-like token on the left-hand side). +@interface AutocompleteTextFieldCell : StyledTextFieldCell { + @private + // Decorations which live to the left and right of the text, ordered + // from outside in. Decorations are owned by |LocationBarViewMac|. + std::vector<LocationBarDecoration*> leftDecorations_; + std::vector<LocationBarDecoration*> rightDecorations_; +} + +// Clear |leftDecorations_| and |rightDecorations_|. +- (void)clearDecorations; + +// Add a new left-side decoration to the right of the existing +// left-side decorations. +- (void)addLeftDecoration:(LocationBarDecoration*)decoration; + +// Add a new right-side decoration to the left of the existing +// right-side decorations. +- (void)addRightDecoration:(LocationBarDecoration*)decoration; + +// The width available after accounting for decorations. +- (CGFloat)availableWidthInFrame:(const NSRect)frame; + +// Return the frame for |aDecoration| if the cell is in |cellFrame|. +// Returns |NSZeroRect| for decorations which are not currently +// visible. +- (NSRect)frameForDecoration:(const LocationBarDecoration*)aDecoration + inFrame:(NSRect)cellFrame; + +// Find the decoration under the event. |NULL| if |theEvent| is not +// over anything. +- (LocationBarDecoration*)decorationForEvent:(NSEvent*)theEvent + inRect:(NSRect)cellFrame + ofView:(AutocompleteTextField*)field; + +// Return the appropriate menu for any decorations under event. +// Returns nil if no menu is present for the decoration, or if the +// event is not over a decoration. +- (NSMenu*)decorationMenuForEvent:(NSEvent*)theEvent + inRect:(NSRect)cellFrame + ofView:(AutocompleteTextField*)controlView; + +// Called by |AutocompleteTextField| to let page actions intercept +// clicks. Returns |YES| if the click has been intercepted. +- (BOOL)mouseDown:(NSEvent*)theEvent + inRect:(NSRect)cellFrame + ofView:(AutocompleteTextField*)controlView; + +// Overridden from StyledTextFieldCell to include decorations adjacent +// to the text area which don't handle mouse clicks themselves. +// Keyword-search bubble, for instance. +- (NSRect)textCursorFrameForFrame:(NSRect)cellFrame; + +// Setup decoration tooltips on |controlView| by calling +// |-addToolTip:forRect:|. +- (void)updateToolTipsInRect:(NSRect)cellFrame + ofView:(AutocompleteTextField*)controlView; + +@end diff --git a/chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_cell.mm b/chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_cell.mm new file mode 100644 index 0000000..02c8a667 --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_cell.mm @@ -0,0 +1,402 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_cell.h" + +#include "base/logging.h" +#import "chrome/browser/ui/cocoa/image_utils.h" +#import "chrome/browser/ui/cocoa/location_bar/autocomplete_text_field.h" +#import "chrome/browser/ui/cocoa/location_bar/location_bar_decoration.h" + +namespace { + +const CGFloat kBaselineAdjust = 3.0; + +// Matches the clipping radius of |GradientButtonCell|. +const CGFloat kCornerRadius = 4.0; + +// How far to inset the left-hand decorations from the field's bounds. +const CGFloat kLeftDecorationXOffset = 5.0; + +// How far to inset the right-hand decorations from the field's bounds. +// TODO(shess): Why is this different from |kLeftDecorationXOffset|? +// |kDecorationOuterXOffset|? +const CGFloat kRightDecorationXOffset = 5.0; + +// The amount of padding on either side reserved for drawing +// decorations. [Views has |kItemPadding| == 3.] +const CGFloat kDecorationHorizontalPad = 3.0; + +// How long to wait for mouse-up on the location icon before assuming +// that the user wants to drag. +const NSTimeInterval kLocationIconDragTimeout = 0.25; + +// Calculate the positions for a set of decorations. |frame| is the +// overall frame to do layout in, |remaining_frame| will get the +// left-over space. |all_decorations| is the set of decorations to +// lay out, |decorations| will be set to the decorations which are +// visible and which fit, in the same order as |all_decorations|, +// while |decoration_frames| will be the corresponding frames. +// |x_edge| describes the edge to layout the decorations against +// (|NSMinXEdge| or |NSMaxXEdge|). |initial_padding| is the padding +// from the edge of |cell_frame| (|kDecorationHorizontalPad| is used +// between decorations). +void CalculatePositionsHelper( + NSRect frame, + const std::vector<LocationBarDecoration*>& all_decorations, + NSRectEdge x_edge, + CGFloat initial_padding, + std::vector<LocationBarDecoration*>* decorations, + std::vector<NSRect>* decoration_frames, + NSRect* remaining_frame) { + DCHECK(x_edge == NSMinXEdge || x_edge == NSMaxXEdge); + DCHECK_EQ(decorations->size(), decoration_frames->size()); + + // The outer-most decoration will be inset a bit further from the + // edge. + CGFloat padding = initial_padding; + + for (size_t i = 0; i < all_decorations.size(); ++i) { + if (all_decorations[i]->IsVisible()) { + NSRect padding_rect, available; + + // Peel off the outside padding. + NSDivideRect(frame, &padding_rect, &available, padding, x_edge); + + // Find out how large the decoration will be in the remaining + // space. + const CGFloat used_width = + all_decorations[i]->GetWidthForSpace(NSWidth(available)); + + if (used_width != LocationBarDecoration::kOmittedWidth) { + DCHECK_GT(used_width, 0.0); + NSRect decoration_frame; + + // Peel off the desired width, leaving the remainder in + // |frame|. + NSDivideRect(available, &decoration_frame, &frame, + used_width, x_edge); + + decorations->push_back(all_decorations[i]); + decoration_frames->push_back(decoration_frame); + DCHECK_EQ(decorations->size(), decoration_frames->size()); + + // Adjust padding for between decorations. + padding = kDecorationHorizontalPad; + } + } + } + + DCHECK_EQ(decorations->size(), decoration_frames->size()); + *remaining_frame = frame; +} + +// Helper function for calculating placement of decorations w/in the +// cell. |frame| is the cell's boundary rectangle, |remaining_frame| +// will get any space left after decorations are laid out (for text). +// |left_decorations| is a set of decorations for the left-hand side +// of the cell, |right_decorations| for the right-hand side. +// |decorations| will contain the resulting visible decorations, and +// |decoration_frames| will contain their frames in the same +// coordinates as |frame|. Decorations will be ordered left to right. +// As a convenience returns the index of the first right-hand +// decoration. +size_t CalculatePositionsInFrame( + NSRect frame, + const std::vector<LocationBarDecoration*>& left_decorations, + const std::vector<LocationBarDecoration*>& right_decorations, + std::vector<LocationBarDecoration*>* decorations, + std::vector<NSRect>* decoration_frames, + NSRect* remaining_frame) { + decorations->clear(); + decoration_frames->clear(); + + // Layout |left_decorations| against the LHS. + CalculatePositionsHelper(frame, left_decorations, + NSMinXEdge, kLeftDecorationXOffset, + decorations, decoration_frames, &frame); + DCHECK_EQ(decorations->size(), decoration_frames->size()); + + // Capture the number of visible left-hand decorations. + const size_t left_count = decorations->size(); + + // Layout |right_decorations| against the RHS. + CalculatePositionsHelper(frame, right_decorations, + NSMaxXEdge, kRightDecorationXOffset, + decorations, decoration_frames, &frame); + DCHECK_EQ(decorations->size(), decoration_frames->size()); + + // Reverse the right-hand decorations so that overall everything is + // sorted left to right. + std::reverse(decorations->begin() + left_count, decorations->end()); + std::reverse(decoration_frames->begin() + left_count, + decoration_frames->end()); + + *remaining_frame = frame; + return left_count; +} + +} // namespace + +@implementation AutocompleteTextFieldCell + +- (CGFloat)baselineAdjust { + return kBaselineAdjust; +} + +- (CGFloat)cornerRadius { + return kCornerRadius; +} + +- (BOOL)shouldDrawBezel { + return YES; +} + +- (void)clearDecorations { + leftDecorations_.clear(); + rightDecorations_.clear(); +} + +- (void)addLeftDecoration:(LocationBarDecoration*)decoration { + leftDecorations_.push_back(decoration); +} + +- (void)addRightDecoration:(LocationBarDecoration*)decoration { + rightDecorations_.push_back(decoration); +} + +- (CGFloat)availableWidthInFrame:(const NSRect)frame { + std::vector<LocationBarDecoration*> decorations; + std::vector<NSRect> decorationFrames; + NSRect textFrame; + CalculatePositionsInFrame(frame, leftDecorations_, rightDecorations_, + &decorations, &decorationFrames, &textFrame); + + return NSWidth(textFrame); +} + +- (NSRect)frameForDecoration:(const LocationBarDecoration*)aDecoration + inFrame:(NSRect)cellFrame { + // Short-circuit if the decoration is known to be not visible. + if (aDecoration && !aDecoration->IsVisible()) + return NSZeroRect; + + // Layout the decorations. + std::vector<LocationBarDecoration*> decorations; + std::vector<NSRect> decorationFrames; + NSRect textFrame; + CalculatePositionsInFrame(cellFrame, leftDecorations_, rightDecorations_, + &decorations, &decorationFrames, &textFrame); + + // Find our decoration and return the corresponding frame. + std::vector<LocationBarDecoration*>::const_iterator iter = + std::find(decorations.begin(), decorations.end(), aDecoration); + if (iter != decorations.end()) { + const size_t index = iter - decorations.begin(); + return decorationFrames[index]; + } + + // Decorations which are not visible should have been filtered out + // at the top, but return |NSZeroRect| rather than a 0-width rect + // for consistency. + NOTREACHED(); + return NSZeroRect; +} + +// Overriden to account for the decorations. +- (NSRect)textFrameForFrame:(NSRect)cellFrame { + // Get the frame adjusted for decorations. + std::vector<LocationBarDecoration*> decorations; + std::vector<NSRect> decorationFrames; + NSRect textFrame = [super textFrameForFrame:cellFrame]; + CalculatePositionsInFrame(textFrame, leftDecorations_, rightDecorations_, + &decorations, &decorationFrames, &textFrame); + + // NOTE: This function must closely match the logic in + // |-drawInteriorWithFrame:inView:|. + + return textFrame; +} + +- (NSRect)textCursorFrameForFrame:(NSRect)cellFrame { + std::vector<LocationBarDecoration*> decorations; + std::vector<NSRect> decorationFrames; + NSRect textFrame; + size_t left_count = + CalculatePositionsInFrame(cellFrame, leftDecorations_, rightDecorations_, + &decorations, &decorationFrames, &textFrame); + + // Determine the left-most extent for the i-beam cursor. + CGFloat minX = NSMinX(textFrame); + for (size_t index = left_count; index--; ) { + if (decorations[index]->AcceptsMousePress()) + break; + + // If at leftmost decoration, expand to edge of cell. + if (!index) { + minX = NSMinX(cellFrame); + } else { + minX = NSMinX(decorationFrames[index]) - kDecorationHorizontalPad; + } + } + + // Determine the right-most extent for the i-beam cursor. + CGFloat maxX = NSMaxX(textFrame); + for (size_t index = left_count; index < decorations.size(); ++index) { + if (decorations[index]->AcceptsMousePress()) + break; + + // If at rightmost decoration, expand to edge of cell. + if (index == decorations.size() - 1) { + maxX = NSMaxX(cellFrame); + } else { + maxX = NSMaxX(decorationFrames[index]) + kDecorationHorizontalPad; + } + } + + // I-beam cursor covers left-most to right-most. + return NSMakeRect(minX, NSMinY(textFrame), maxX - minX, NSHeight(textFrame)); +} + +- (void)drawInteriorWithFrame:(NSRect)cellFrame inView:(NSView*)controlView { + std::vector<LocationBarDecoration*> decorations; + std::vector<NSRect> decorationFrames; + NSRect workingFrame; + CalculatePositionsInFrame(cellFrame, leftDecorations_, rightDecorations_, + &decorations, &decorationFrames, &workingFrame); + + // Draw the decorations. + for (size_t i = 0; i < decorations.size(); ++i) { + if (decorations[i]) + decorations[i]->DrawInFrame(decorationFrames[i], controlView); + } + + // NOTE: This function must closely match the logic in + // |-textFrameForFrame:|. + + // Superclass draws text portion WRT original |cellFrame|. + [super drawInteriorWithFrame:cellFrame inView:controlView]; +} + +- (LocationBarDecoration*)decorationForEvent:(NSEvent*)theEvent + inRect:(NSRect)cellFrame + ofView:(AutocompleteTextField*)controlView +{ + const BOOL flipped = [controlView isFlipped]; + const NSPoint location = + [controlView convertPoint:[theEvent locationInWindow] fromView:nil]; + + std::vector<LocationBarDecoration*> decorations; + std::vector<NSRect> decorationFrames; + NSRect textFrame; + CalculatePositionsInFrame(cellFrame, leftDecorations_, rightDecorations_, + &decorations, &decorationFrames, &textFrame); + + for (size_t i = 0; i < decorations.size(); ++i) { + if (NSMouseInRect(location, decorationFrames[i], flipped)) + return decorations[i]; + } + + return NULL; +} + +- (NSMenu*)decorationMenuForEvent:(NSEvent*)theEvent + inRect:(NSRect)cellFrame + ofView:(AutocompleteTextField*)controlView { + LocationBarDecoration* decoration = + [self decorationForEvent:theEvent inRect:cellFrame ofView:controlView]; + if (decoration) + return decoration->GetMenu(); + return nil; +} + +- (BOOL)mouseDown:(NSEvent*)theEvent + inRect:(NSRect)cellFrame + ofView:(AutocompleteTextField*)controlView { + LocationBarDecoration* decoration = + [self decorationForEvent:theEvent inRect:cellFrame ofView:controlView]; + if (!decoration || !decoration->AcceptsMousePress()) + return NO; + + NSRect decorationRect = + [self frameForDecoration:decoration inFrame:cellFrame]; + + // If the decoration is draggable, then initiate a drag if the user + // drags or holds the mouse down for awhile. + if (decoration->IsDraggable()) { + NSDate* timeout = + [NSDate dateWithTimeIntervalSinceNow:kLocationIconDragTimeout]; + NSEvent* event = [NSApp nextEventMatchingMask:(NSLeftMouseDraggedMask | + NSLeftMouseUpMask) + untilDate:timeout + inMode:NSEventTrackingRunLoopMode + dequeue:YES]; + if (!event || [event type] == NSLeftMouseDragged) { + NSPasteboard* pboard = decoration->GetDragPasteboard(); + DCHECK(pboard); + + NSImage* image = decoration->GetDragImage(); + DCHECK(image); + + NSRect dragImageRect = decoration->GetDragImageFrame(decorationRect); + + // If the original click is not within |dragImageRect|, then + // center the image under the mouse. Otherwise, will drag from + // where the click was on the image. + const NSPoint mousePoint = + [controlView convertPoint:[theEvent locationInWindow] fromView:nil]; + if (!NSMouseInRect(mousePoint, dragImageRect, [controlView isFlipped])) { + dragImageRect.origin = + NSMakePoint(mousePoint.x - NSWidth(dragImageRect) / 2.0, + mousePoint.y - NSHeight(dragImageRect) / 2.0); + } + + // -[NSView dragImage:at:*] wants the images lower-left point, + // regardless of -isFlipped. Converting the rect to window base + // coordinates doesn't require any special-casing. Note that + // -[NSView dragFile:fromRect:*] takes a rect rather than a + // point, likely for this exact reason. + const NSPoint dragPoint = + [controlView convertRect:dragImageRect toView:nil].origin; + [[controlView window] dragImage:image + at:dragPoint + offset:NSZeroSize + event:theEvent + pasteboard:pboard + source:self + slideBack:YES]; + + return YES; + } + + // On mouse-up fall through to mouse-pressed case. + DCHECK_EQ([event type], NSLeftMouseUp); + } + + if (!decoration->OnMousePressed(decorationRect)) + return NO; + + return YES; +} + +- (NSDragOperation)draggingSourceOperationMaskForLocal:(BOOL)isLocal { + return NSDragOperationCopy; +} + +- (void)updateToolTipsInRect:(NSRect)cellFrame + ofView:(AutocompleteTextField*)controlView { + std::vector<LocationBarDecoration*> decorations; + std::vector<NSRect> decorationFrames; + NSRect textFrame; + CalculatePositionsInFrame(cellFrame, leftDecorations_, rightDecorations_, + &decorations, &decorationFrames, &textFrame); + + for (size_t i = 0; i < decorations.size(); ++i) { + NSString* tooltip = decorations[i]->GetToolTip(); + if ([tooltip length] > 0) + [controlView addToolTip:tooltip forRect:decorationFrames[i]]; + } +} + +@end diff --git a/chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_cell_unittest.mm b/chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_cell_unittest.mm new file mode 100644 index 0000000..1598cad --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_cell_unittest.mm @@ -0,0 +1,300 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "app/resource_bundle.h" +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/location_bar/autocomplete_text_field.h" +#import "chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_cell.h" +#import "chrome/browser/ui/cocoa/location_bar/ev_bubble_decoration.h" +#import "chrome/browser/ui/cocoa/location_bar/keyword_hint_decoration.h" +#import "chrome/browser/ui/cocoa/location_bar/location_bar_decoration.h" +#import "chrome/browser/ui/cocoa/location_bar/location_icon_decoration.h" +#import "chrome/browser/ui/cocoa/location_bar/selected_keyword_decoration.h" +#import "chrome/browser/ui/cocoa/location_bar/star_decoration.h" +#include "grit/theme_resources.h" +#include "testing/gmock/include/gmock/gmock.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" +#import "third_party/ocmock/OCMock/OCMock.h" + +using ::testing::Return; +using ::testing::StrictMock; +using ::testing::_; + +namespace { + +// Width of the field so that we don't have to ask |field_| for it all +// the time. +const CGFloat kWidth(300.0); + +// A narrow width for tests which test things that don't fit. +const CGFloat kNarrowWidth(5.0); + +class MockDecoration : public LocationBarDecoration { + public: + virtual CGFloat GetWidthForSpace(CGFloat width) { return 20.0; } + + MOCK_METHOD2(DrawInFrame, void(NSRect frame, NSView* control_view)); + MOCK_METHOD0(GetToolTip, NSString*()); +}; + +class AutocompleteTextFieldCellTest : public CocoaTest { + public: + AutocompleteTextFieldCellTest() { + // Make sure this is wide enough to play games with the cell + // decorations. + const NSRect frame = NSMakeRect(0, 0, kWidth, 30); + + scoped_nsobject<NSTextField> view( + [[NSTextField alloc] initWithFrame:frame]); + view_ = view.get(); + + scoped_nsobject<AutocompleteTextFieldCell> cell( + [[AutocompleteTextFieldCell alloc] initTextCell:@"Testing"]); + [cell setEditable:YES]; + [cell setBordered:YES]; + + [cell clearDecorations]; + mock_left_decoration_.SetVisible(false); + [cell addLeftDecoration:&mock_left_decoration_]; + mock_right_decoration0_.SetVisible(false); + mock_right_decoration1_.SetVisible(false); + [cell addRightDecoration:&mock_right_decoration0_]; + [cell addRightDecoration:&mock_right_decoration1_]; + + [view_ setCell:cell.get()]; + + [[test_window() contentView] addSubview:view_]; + } + + NSTextField* view_; + MockDecoration mock_left_decoration_; + MockDecoration mock_right_decoration0_; + MockDecoration mock_right_decoration1_; +}; + +// Basic view tests (AddRemove, Display). +TEST_VIEW(AutocompleteTextFieldCellTest, view_); + +// Test drawing, mostly to ensure nothing leaks or crashes. +// Flaky, disabled. Bug http://crbug.com/49522 +TEST_F(AutocompleteTextFieldCellTest, DISABLED_FocusedDisplay) { + [view_ display]; + + // Test focused drawing. + [test_window() makePretendKeyWindowAndSetFirstResponder:view_]; + [view_ display]; + [test_window() clearPretendKeyWindowAndFirstResponder]; + + // Test display of various cell configurations. + AutocompleteTextFieldCell* cell = + static_cast<AutocompleteTextFieldCell*>([view_ cell]); + + // Load available decorations and try drawing. To make sure that + // they are actually drawn, check that |GetWidthForSpace()| doesn't + // indicate that they should be omitted. + const CGFloat kVeryWide = 1000.0; + + SelectedKeywordDecoration selected_keyword_decoration([view_ font]); + selected_keyword_decoration.SetVisible(true); + selected_keyword_decoration.SetKeyword(std::wstring(L"Google"), false); + [cell addLeftDecoration:&selected_keyword_decoration]; + EXPECT_NE(selected_keyword_decoration.GetWidthForSpace(kVeryWide), + LocationBarDecoration::kOmittedWidth); + + // TODO(shess): This really wants a |LocationBarViewMac|, but only a + // few methods reference it, so this works well enough. But + // something better would be nice. + LocationIconDecoration location_icon_decoration(NULL); + location_icon_decoration.SetVisible(true); + location_icon_decoration.SetImage([NSImage imageNamed:@"NSApplicationIcon"]); + [cell addLeftDecoration:&location_icon_decoration]; + EXPECT_NE(location_icon_decoration.GetWidthForSpace(kVeryWide), + LocationBarDecoration::kOmittedWidth); + + EVBubbleDecoration ev_bubble_decoration(&location_icon_decoration, + [view_ font]); + ev_bubble_decoration.SetVisible(true); + ev_bubble_decoration.SetImage([NSImage imageNamed:@"NSApplicationIcon"]); + ev_bubble_decoration.SetLabel(@"Application"); + [cell addLeftDecoration:&ev_bubble_decoration]; + EXPECT_NE(ev_bubble_decoration.GetWidthForSpace(kVeryWide), + LocationBarDecoration::kOmittedWidth); + + StarDecoration star_decoration(NULL); + star_decoration.SetVisible(true); + [cell addRightDecoration:&star_decoration]; + EXPECT_NE(star_decoration.GetWidthForSpace(kVeryWide), + LocationBarDecoration::kOmittedWidth); + + KeywordHintDecoration keyword_hint_decoration([view_ font]); + keyword_hint_decoration.SetVisible(true); + keyword_hint_decoration.SetKeyword(std::wstring(L"google"), false); + [cell addRightDecoration:&keyword_hint_decoration]; + EXPECT_NE(keyword_hint_decoration.GetWidthForSpace(kVeryWide), + LocationBarDecoration::kOmittedWidth); + + // Make sure we're actually calling |DrawInFrame()|. + StrictMock<MockDecoration> mock_decoration; + mock_decoration.SetVisible(true); + [cell addLeftDecoration:&mock_decoration]; + EXPECT_CALL(mock_decoration, DrawInFrame(_, _)); + EXPECT_NE(mock_decoration.GetWidthForSpace(kVeryWide), + LocationBarDecoration::kOmittedWidth); + + [view_ display]; + + [cell clearDecorations]; +} + +TEST_F(AutocompleteTextFieldCellTest, TextFrame) { + AutocompleteTextFieldCell* cell = + static_cast<AutocompleteTextFieldCell*>([view_ cell]); + const NSRect bounds([view_ bounds]); + NSRect textFrame; + + // The cursor frame should stay the same throughout. + const NSRect cursorFrame([cell textCursorFrameForFrame:bounds]); + EXPECT_TRUE(NSEqualRects(cursorFrame, bounds)); + + // At default settings, everything goes to the text area. + textFrame = [cell textFrameForFrame:bounds]; + EXPECT_FALSE(NSIsEmptyRect(textFrame)); + EXPECT_TRUE(NSContainsRect(bounds, textFrame)); + EXPECT_EQ(NSMinX(bounds), NSMinX(textFrame)); + EXPECT_EQ(NSMaxX(bounds), NSMaxX(textFrame)); + EXPECT_TRUE(NSContainsRect(cursorFrame, textFrame)); + + // Decoration on the left takes up space. + mock_left_decoration_.SetVisible(true); + textFrame = [cell textFrameForFrame:bounds]; + EXPECT_FALSE(NSIsEmptyRect(textFrame)); + EXPECT_TRUE(NSContainsRect(bounds, textFrame)); + EXPECT_GT(NSMinX(textFrame), NSMinX(bounds)); + EXPECT_TRUE(NSContainsRect(cursorFrame, textFrame)); +} + +// The editor frame should be slightly inset from the text frame. +TEST_F(AutocompleteTextFieldCellTest, DrawingRectForBounds) { + AutocompleteTextFieldCell* cell = + static_cast<AutocompleteTextFieldCell*>([view_ cell]); + const NSRect bounds([view_ bounds]); + NSRect textFrame, drawingRect; + + textFrame = [cell textFrameForFrame:bounds]; + drawingRect = [cell drawingRectForBounds:bounds]; + EXPECT_FALSE(NSIsEmptyRect(drawingRect)); + EXPECT_TRUE(NSContainsRect(textFrame, NSInsetRect(drawingRect, 1, 1))); + + // Save the starting frame for after clear. + const NSRect originalDrawingRect = drawingRect; + + mock_left_decoration_.SetVisible(true); + textFrame = [cell textFrameForFrame:bounds]; + drawingRect = [cell drawingRectForBounds:bounds]; + EXPECT_FALSE(NSIsEmptyRect(drawingRect)); + EXPECT_TRUE(NSContainsRect(NSInsetRect(textFrame, 1, 1), drawingRect)); + + mock_right_decoration0_.SetVisible(true); + textFrame = [cell textFrameForFrame:bounds]; + drawingRect = [cell drawingRectForBounds:bounds]; + EXPECT_FALSE(NSIsEmptyRect(drawingRect)); + EXPECT_TRUE(NSContainsRect(NSInsetRect(textFrame, 1, 1), drawingRect)); + + mock_left_decoration_.SetVisible(false); + mock_right_decoration0_.SetVisible(false); + drawingRect = [cell drawingRectForBounds:bounds]; + EXPECT_FALSE(NSIsEmptyRect(drawingRect)); + EXPECT_TRUE(NSEqualRects(drawingRect, originalDrawingRect)); +} + +// Test that left decorations are at the correct edge of the cell. +TEST_F(AutocompleteTextFieldCellTest, LeftDecorationFrame) { + AutocompleteTextFieldCell* cell = + static_cast<AutocompleteTextFieldCell*>([view_ cell]); + const NSRect bounds = [view_ bounds]; + + mock_left_decoration_.SetVisible(true); + const NSRect decorationRect = + [cell frameForDecoration:&mock_left_decoration_ inFrame:bounds]; + EXPECT_FALSE(NSIsEmptyRect(decorationRect)); + EXPECT_TRUE(NSContainsRect(bounds, decorationRect)); + + // Decoration should be left of |drawingRect|. + const NSRect drawingRect = [cell drawingRectForBounds:bounds]; + EXPECT_GT(NSMinX(drawingRect), NSMinX(decorationRect)); + + // Decoration should be left of |textFrame|. + const NSRect textFrame = [cell textFrameForFrame:bounds]; + EXPECT_GT(NSMinX(textFrame), NSMinX(decorationRect)); +} + +// Test that right decorations are at the correct edge of the cell. +TEST_F(AutocompleteTextFieldCellTest, RightDecorationFrame) { + AutocompleteTextFieldCell* cell = + static_cast<AutocompleteTextFieldCell*>([view_ cell]); + const NSRect bounds = [view_ bounds]; + + mock_right_decoration0_.SetVisible(true); + mock_right_decoration1_.SetVisible(true); + + const NSRect decoration0Rect = + [cell frameForDecoration:&mock_right_decoration0_ inFrame:bounds]; + EXPECT_FALSE(NSIsEmptyRect(decoration0Rect)); + EXPECT_TRUE(NSContainsRect(bounds, decoration0Rect)); + + // Right-side decorations are ordered from rightmost to leftmost. + // Outer decoration (0) to right of inner decoration (1). + const NSRect decoration1Rect = + [cell frameForDecoration:&mock_right_decoration1_ inFrame:bounds]; + EXPECT_FALSE(NSIsEmptyRect(decoration1Rect)); + EXPECT_TRUE(NSContainsRect(bounds, decoration1Rect)); + EXPECT_LT(NSMinX(decoration1Rect), NSMinX(decoration0Rect)); + + // Decoration should be right of |drawingRect|. + const NSRect drawingRect = [cell drawingRectForBounds:bounds]; + EXPECT_LT(NSMinX(drawingRect), NSMinX(decoration1Rect)); + + // Decoration should be right of |textFrame|. + const NSRect textFrame = [cell textFrameForFrame:bounds]; + EXPECT_LT(NSMinX(textFrame), NSMinX(decoration1Rect)); +} + +// Verify -[AutocompleteTextFieldCell updateToolTipsInRect:ofView:]. +TEST_F(AutocompleteTextFieldCellTest, UpdateToolTips) { + NSString* tooltip = @"tooltip"; + + // Left decoration returns a tooltip, make sure it is called at + // least once. + mock_left_decoration_.SetVisible(true); + EXPECT_CALL(mock_left_decoration_, GetToolTip()) + .WillOnce(Return(tooltip)) + .WillRepeatedly(Return(tooltip)); + + // Right decoration returns no tooltip, make sure it is called at + // least once. + mock_right_decoration0_.SetVisible(true); + EXPECT_CALL(mock_right_decoration0_, GetToolTip()) + .WillOnce(Return((NSString*)nil)) + .WillRepeatedly(Return((NSString*)nil)); + + AutocompleteTextFieldCell* cell = + static_cast<AutocompleteTextFieldCell*>([view_ cell]); + const NSRect bounds = [view_ bounds]; + const NSRect leftDecorationRect = + [cell frameForDecoration:&mock_left_decoration_ inFrame:bounds]; + + // |controlView| gets the tooltip for the left decoration. + id controlView = [OCMockObject mockForClass:[AutocompleteTextField class]]; + [[controlView expect] addToolTip:tooltip forRect:leftDecorationRect]; + + [cell updateToolTipsInRect:bounds ofView:controlView]; + + [controlView verify]; +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_editor.h b/chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_editor.h new file mode 100644 index 0000000..905bc84 --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_editor.h @@ -0,0 +1,56 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/url_drop_target.h" + +@class AutocompleteTextField; +class AutocompleteTextFieldObserver; + +// AutocompleteTextFieldEditor customized the AutocompletTextField +// field editor (helper text-view used in editing). It intercepts UI +// events for forwarding to the core Omnibox code. It also undoes +// some of the effects of using styled text in the Omnibox (the text +// is styled but should not appear that way when copied to the +// pasteboard). + +// Field editor used for the autocomplete field. +@interface AutocompleteTextFieldEditor : NSTextView<URLDropTarget> { + // Handles being a drag-and-drop target. We handle DnD directly instead + // allowing the |AutocompletTextField| to handle it (by making an empty + // |-updateDragTypeRegistration|), since the latter results in a weird + // start-up time regression. + scoped_nsobject<URLDropTargetHandler> dropHandler_; + + scoped_nsobject<NSCharacterSet> forbiddenCharacters_; + + // Indicates if the field editor's interpretKeyEvents: method is being called. + // If it's YES, then we should postpone the call to the observer's + // OnDidChange() method after the field editor's interpretKeyEvents: method + // is finished, rather than calling it in textDidChange: method. Because the + // input method may update the marked text after inserting some text, but we + // need the observer be aware of the marked text as well. + BOOL interpretingKeyEvents_; + + // Indicates if the text has been changed by key events. + BOOL textChangedByKeyEvents_; +} + +// The delegate is always an AutocompleteTextField*. Override the superclass +// implementations to allow for proper typing. +- (AutocompleteTextField*)delegate; +- (void)setDelegate:(AutocompleteTextField*)delegate; + +// Sets attributed string programatically through the field editor's text +// storage object. +- (void)setAttributedString:(NSAttributedString*)aString; + +@end + +@interface AutocompleteTextFieldEditor(PrivateTestMethods) +- (AutocompleteTextFieldObserver*)observer; +- (void)pasteAndGo:sender; +@end diff --git a/chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_editor.mm b/chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_editor.mm new file mode 100644 index 0000000..1b52e70 --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_editor.mm @@ -0,0 +1,371 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_editor.h" + +#include "app/l10n_util_mac.h" +#include "base/string_util.h" +#include "grit/generated_resources.h" +#include "base/sys_string_conversions.h" +#include "chrome/app/chrome_command_ids.h" // IDC_* +#include "chrome/browser/browser_list.h" +#import "chrome/browser/ui/cocoa/browser_window_controller.h" +#import "chrome/browser/ui/cocoa/location_bar/autocomplete_text_field.h" +#import "chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_cell.h" +#import "chrome/browser/ui/cocoa/toolbar_controller.h" + +@implementation AutocompleteTextFieldEditor + +- (id)initWithFrame:(NSRect)frameRect { + if ((self = [super initWithFrame:frameRect])) { + dropHandler_.reset([[URLDropTargetHandler alloc] initWithView:self]); + + forbiddenCharacters_.reset([[NSCharacterSet controlCharacterSet] retain]); + } + return self; +} + +// If the entire field is selected, drag the same data as would be +// dragged from the field's location icon. In some cases the textual +// contents will not contain relevant data (for instance, "http://" is +// stripped from URLs). +- (BOOL)dragSelectionWithEvent:(NSEvent *)event + offset:(NSSize)mouseOffset + slideBack:(BOOL)slideBack { + AutocompleteTextFieldObserver* observer = [self observer]; + DCHECK(observer); + if (observer && observer->CanCopy()) { + NSPasteboard* pboard = [NSPasteboard pasteboardWithName:NSDragPboard]; + observer->CopyToPasteboard(pboard); + + NSPoint p; + NSImage* image = [self dragImageForSelectionWithEvent:event origin:&p]; + + [self dragImage:image + at:p + offset:mouseOffset + event:event + pasteboard:pboard + source:self + slideBack:slideBack]; + return YES; + } + return [super dragSelectionWithEvent:event + offset:mouseOffset + slideBack:slideBack]; +} + +- (void)copy:(id)sender { + AutocompleteTextFieldObserver* observer = [self observer]; + DCHECK(observer); + if (observer && observer->CanCopy()) + observer->CopyToPasteboard([NSPasteboard generalPasteboard]); +} + +- (void)cut:(id)sender { + [self copy:sender]; + [self delete:nil]; +} + +// This class assumes that the delegate is an AutocompleteTextField. +// Enforce that assumption. +- (AutocompleteTextField*)delegate { + AutocompleteTextField* delegate = + static_cast<AutocompleteTextField*>([super delegate]); + DCHECK(delegate == nil || + [delegate isKindOfClass:[AutocompleteTextField class]]); + return delegate; +} + +- (void)setDelegate:(AutocompleteTextField*)delegate { + DCHECK(delegate == nil || + [delegate isKindOfClass:[AutocompleteTextField class]]); + [super setDelegate:delegate]; +} + +// Convenience method for retrieving the observer from the delegate. +- (AutocompleteTextFieldObserver*)observer { + return [[self delegate] observer]; +} + +- (void)paste:(id)sender { + AutocompleteTextFieldObserver* observer = [self observer]; + DCHECK(observer); + if (observer) { + observer->OnPaste(); + } +} + +- (void)pasteAndGo:sender { + AutocompleteTextFieldObserver* observer = [self observer]; + DCHECK(observer); + if (observer) { + observer->OnPasteAndGo(); + } +} + +// We have rich text, but it shouldn't be modified by the user, so +// don't update the font panel. In theory, -setUsesFontPanel: should +// accomplish this, but that gets called frequently with YES when +// NSTextField and NSTextView synchronize their contents. That is +// probably unavoidable because in most cases having rich text in the +// field you probably would expect it to update the font panel. +- (void)updateFontPanel {} + +// No ruler bar, so don't update any of that state, either. +- (void)updateRuler {} + +- (NSMenu*)menuForEvent:(NSEvent*)event { + // Give the control a chance to provide page-action menus. + // NOTE: Note that page actions aren't even in the editor's + // boundaries! The Cocoa control implementation seems to do a + // blanket forward to here if nothing more specific is returned from + // the control and cell calls. + // TODO(shess): Determine if the page-action part of this can be + // moved to the cell. + NSMenu* actionMenu = [[self delegate] decorationMenuForEvent:event]; + if (actionMenu) + return actionMenu; + + NSMenu* menu = [[[NSMenu alloc] initWithTitle:@"TITLE"] autorelease]; + [menu addItemWithTitle:l10n_util::GetNSStringWithFixup(IDS_CUT) + action:@selector(cut:) + keyEquivalent:@""]; + [menu addItemWithTitle:l10n_util::GetNSStringWithFixup(IDS_COPY) + action:@selector(copy:) + keyEquivalent:@""]; + [menu addItemWithTitle:l10n_util::GetNSStringWithFixup(IDS_PASTE) + action:@selector(paste:) + keyEquivalent:@""]; + + // TODO(shess): If the control is not editable, should we show a + // greyed-out "Paste and Go"? + if ([self isEditable]) { + // Paste and go/search. + AutocompleteTextFieldObserver* observer = [self observer]; + DCHECK(observer); + if (observer && observer->CanPasteAndGo()) { + const int string_id = observer->GetPasteActionStringId(); + NSString* label = l10n_util::GetNSStringWithFixup(string_id); + + // TODO(rohitrao): If the clipboard is empty, should we show a + // greyed-out "Paste and Go" or nothing at all? + if ([label length]) { + [menu addItemWithTitle:label + action:@selector(pasteAndGo:) + keyEquivalent:@""]; + } + } + + NSString* label = l10n_util::GetNSStringWithFixup(IDS_EDIT_SEARCH_ENGINES); + DCHECK([label length]); + if ([label length]) { + [menu addItem:[NSMenuItem separatorItem]]; + NSMenuItem* item = [menu addItemWithTitle:label + action:@selector(commandDispatch:) + keyEquivalent:@""]; + [item setTag:IDC_EDIT_SEARCH_ENGINES]; + } + } + + return menu; +} + +// (Overridden from NSResponder) +- (BOOL)becomeFirstResponder { + BOOL doAccept = [super becomeFirstResponder]; + AutocompleteTextField* field = [self delegate]; + // Only lock visibility if we've been set up with a delegate (the text field). + if (doAccept && field) { + // Give the text field ownership of the visibility lock. (The first + // responder dance between the field and the field editor is a little + // weird.) + [[BrowserWindowController browserWindowControllerForView:field] + lockBarVisibilityForOwner:field withAnimation:YES delay:NO]; + } + return doAccept; +} + +// (Overridden from NSResponder) +- (BOOL)resignFirstResponder { + BOOL doResign = [super resignFirstResponder]; + AutocompleteTextField* field = [self delegate]; + // Only lock visibility if we've been set up with a delegate (the text field). + if (doResign && field) { + // Give the text field ownership of the visibility lock. + [[BrowserWindowController browserWindowControllerForView:field] + releaseBarVisibilityForOwner:field withAnimation:YES delay:YES]; + + AutocompleteTextFieldObserver* observer = [self observer]; + if (observer) + observer->OnKillFocus(); + } + return doResign; +} + +// (URLDropTarget protocol) +- (id<URLDropTargetController>)urlDropController { + BrowserWindowController* windowController = + [BrowserWindowController browserWindowControllerForView:self]; + return [windowController toolbarController]; +} + +// (URLDropTarget protocol) +- (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)sender { + // Make ourself the first responder (even though we're presumably already the + // first responder), which will select the text to indicate that our contents + // would be replaced by a drop. + [[self window] makeFirstResponder:self]; + return [dropHandler_ draggingEntered:sender]; +} + +// (URLDropTarget protocol) +- (NSDragOperation)draggingUpdated:(id<NSDraggingInfo>)sender { + return [dropHandler_ draggingUpdated:sender]; +} + +// (URLDropTarget protocol) +- (void)draggingExited:(id<NSDraggingInfo>)sender { + return [dropHandler_ draggingExited:sender]; +} + +// (URLDropTarget protocol) +- (BOOL)performDragOperation:(id<NSDraggingInfo>)sender { + return [dropHandler_ performDragOperation:sender]; +} + +// Prevent control characters from being entered into the Omnibox. +// This is invoked for keyboard entry, not for pasting. +- (void)insertText:(id)aString { + // This method is documented as received either |NSString| or + // |NSAttributedString|. The autocomplete code will restyle the + // results in any case, so simplify by always using |NSString|. + if ([aString isKindOfClass:[NSAttributedString class]]) + aString = [aString string]; + + // Repeatedly remove control characters. The loop will only ever + // execute at allwhen the user enters control characters (using + // Ctrl-Alt- or Ctrl-Q). Making this generally efficient would + // probably be a loss, since the input always seems to be a single + // character. + NSRange range = [aString rangeOfCharacterFromSet:forbiddenCharacters_]; + while (range.location != NSNotFound) { + aString = [aString stringByReplacingCharactersInRange:range withString:@""]; + range = [aString rangeOfCharacterFromSet:forbiddenCharacters_]; + } + DCHECK_EQ(range.length, 0U); + + // NOTE: If |aString| is empty, this intentionally replaces the + // selection with empty. This seems consistent with the case where + // the input contained a mixture of characters and the string ended + // up not empty. + [super insertText:aString]; +} + +- (void)setMarkedText:(id)aString selectedRange:(NSRange)selRange { + [super setMarkedText:aString selectedRange:selRange]; + + // Because the AutocompleteEditViewMac class treats marked text as content, + // we need to treat the change to marked text as content change as well. + [self didChangeText]; +} + +- (NSRange)selectionRangeForProposedRange:(NSRange)proposedSelRange + granularity:(NSSelectionGranularity)granularity { + AutocompleteTextFieldObserver* observer = [self observer]; + NSRange modifiedRange = [super selectionRangeForProposedRange:proposedSelRange + granularity:granularity]; + if (observer) + return observer->SelectionRangeForProposedRange(modifiedRange); + return modifiedRange; +} + + + + +- (void)setSelectedRange:(NSRange)charRange + affinity:(NSSelectionAffinity)affinity + stillSelecting:(BOOL)flag { + [super setSelectedRange:charRange affinity:affinity stillSelecting:flag]; + + // We're only interested in selection changes directly caused by keyboard + // input from the user. + if (interpretingKeyEvents_) + textChangedByKeyEvents_ = YES; +} + +- (void)interpretKeyEvents:(NSArray *)eventArray { + DCHECK(!interpretingKeyEvents_); + interpretingKeyEvents_ = YES; + textChangedByKeyEvents_ = NO; + [super interpretKeyEvents:eventArray]; + + AutocompleteTextFieldObserver* observer = [self observer]; + if (textChangedByKeyEvents_ && observer) + observer->OnDidChange(); + + DCHECK(interpretingKeyEvents_); + interpretingKeyEvents_ = NO; +} + +- (void)didChangeText { + [super didChangeText]; + + AutocompleteTextFieldObserver* observer = [self observer]; + if (observer) { + if (!interpretingKeyEvents_) + observer->OnDidChange(); + else + textChangedByKeyEvents_ = YES; + } +} + +- (void)doCommandBySelector:(SEL)cmd { + // TODO(shess): Review code for cases where we're fruitlessly attempting to + // work in spite of not having an observer. + AutocompleteTextFieldObserver* observer = [self observer]; + + if (observer && observer->OnDoCommandBySelector(cmd)) { + // The observer should already be aware of any changes to the text, so + // setting |textChangedByKeyEvents_| to NO to prevent its OnDidChange() + // method from being called unnecessarily. + textChangedByKeyEvents_ = NO; + return; + } + + // If the escape key was pressed and no revert happened and we're in + // fullscreen mode, make it resign key. + if (cmd == @selector(cancelOperation:)) { + BrowserWindowController* windowController = + [BrowserWindowController browserWindowControllerForView:self]; + if ([windowController isFullscreen]) { + [windowController focusTabContents]; + return; + } + } + + [super doCommandBySelector:cmd]; +} + +- (void)setAttributedString:(NSAttributedString*)aString { + NSTextStorage* textStorage = [self textStorage]; + DCHECK(textStorage); + [textStorage setAttributedString:aString]; + + // The text has been changed programmatically. The observer should know + // this change, so setting |textChangedByKeyEvents_| to NO to + // prevent its OnDidChange() method from being called unnecessarily. + textChangedByKeyEvents_ = NO; +} + +- (void)mouseDown:(NSEvent*)theEvent { + // Close the popup before processing the event. + AutocompleteTextFieldObserver* observer = [self observer]; + if (observer) + observer->ClosePopup(); + + [super mouseDown:theEvent]; +} + +@end diff --git a/chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_editor_unittest.mm b/chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_editor_unittest.mm new file mode 100644 index 0000000..e43b734 --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_editor_unittest.mm @@ -0,0 +1,297 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_editor.h" + +#include "base/scoped_nsobject.h" +#include "base/scoped_ptr.h" +#include "base/string_util.h" +#include "chrome/app/chrome_command_ids.h" // IDC_* +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_unittest_helper.h" +#import "chrome/browser/ui/cocoa/test_event_utils.h" +#include "grit/generated_resources.h" +#include "testing/gmock/include/gmock/gmock-matchers.h" +#include "testing/gtest/include/gtest/gtest.h" +#import "testing/gtest_mac.h" +#include "testing/platform_test.h" +#import "third_party/ocmock/OCMock/OCMock.h" + +using ::testing::Return; +using ::testing::ReturnArg; +using ::testing::StrictMock; +using ::testing::A; + +namespace { + +// TODO(shess): Very similar to AutocompleteTextFieldTest. Maybe +// those can be shared. + +class AutocompleteTextFieldEditorTest : public CocoaTest { + public: + virtual void SetUp() { + CocoaTest::SetUp(); + NSRect frame = NSMakeRect(0, 0, 50, 30); + scoped_nsobject<AutocompleteTextField> field( + [[AutocompleteTextField alloc] initWithFrame:frame]); + field_ = field.get(); + [field_ setStringValue:@"Testing"]; + [[test_window() contentView] addSubview:field_]; + + // Arrange for |field_| to get the right field editor. + window_delegate_.reset( + [[AutocompleteTextFieldWindowTestDelegate alloc] init]); + [test_window() setDelegate:window_delegate_.get()]; + + // Get the field editor setup. + [test_window() makePretendKeyWindowAndSetFirstResponder:field_]; + editor_ = static_cast<AutocompleteTextFieldEditor*>([field_ currentEditor]); + + EXPECT_TRUE(editor_); + EXPECT_TRUE([editor_ isKindOfClass:[AutocompleteTextFieldEditor class]]); + } + + AutocompleteTextFieldEditor* editor_; + AutocompleteTextField* field_; + scoped_nsobject<AutocompleteTextFieldWindowTestDelegate> window_delegate_; +}; + +// Disabled because it crashes sometimes. http://crbug.com/49522 +// Can't rename DISABLED_ because the TEST_VIEW macro prepends. +// http://crbug.com/53621 +#if 0 +TEST_VIEW(AutocompleteTextFieldEditorTest, field_); +#endif + +// Test that control characters are stripped from insertions. +TEST_F(AutocompleteTextFieldEditorTest, InsertStripsControlChars) { + // Sets a string in the field. + NSString* test_string = @"astring"; + [field_ setStringValue:test_string]; + [editor_ selectAll:nil]; + + [editor_ insertText:@"t"]; + EXPECT_NSEQ(@"t", [field_ stringValue]); + + [editor_ insertText:@"h"]; + EXPECT_NSEQ(@"th", [field_ stringValue]); + + // TAB doesn't get inserted. + [editor_ insertText:[NSString stringWithFormat:@"%c", 7]]; + EXPECT_NSEQ(@"th", [field_ stringValue]); + + // Newline doesn't get inserted. + [editor_ insertText:[NSString stringWithFormat:@"%c", 12]]; + EXPECT_NSEQ(@"th", [field_ stringValue]); + + // Multi-character strings get through. + [editor_ insertText:[NSString stringWithFormat:@"i%cs%c", 8, 127]]; + EXPECT_NSEQ(@"this", [field_ stringValue]); + + // Attempting to insert newline when everything is selected clears + // the field. + [editor_ selectAll:nil]; + [editor_ insertText:[NSString stringWithFormat:@"%c", 12]]; + EXPECT_NSEQ(@"", [field_ stringValue]); +} + +// Test that |delegate| can provide page action menus. +TEST_F(AutocompleteTextFieldEditorTest, PageActionMenus) { + // The event just needs to be something the mock can recognize. + NSEvent* event = + test_event_utils::MouseEventAtPoint(NSZeroPoint, NSRightMouseDown, 0); + + // Trivial menu which we can recognize and which doesn't look like + // the default editor context menu. + scoped_nsobject<id> menu([[NSMenu alloc] initWithTitle:@"Menu"]); + [menu addItemWithTitle:@"Go Fish" + action:@selector(goFish:) + keyEquivalent:@""]; + + // For OCMOCK_VALUE(). + BOOL yes = YES; + + // So that we don't have to mock the observer. + [editor_ setEditable:NO]; + + // The delegate's intercept point gets called, and results are + // propagated back. + { + id delegate = [OCMockObject mockForClass:[AutocompleteTextField class]]; + [[[delegate stub] andReturnValue:OCMOCK_VALUE(yes)] + isKindOfClass:[AutocompleteTextField class]]; + [[[delegate expect] andReturn:menu.get()] decorationMenuForEvent:event]; + [editor_ setDelegate:delegate]; + NSMenu* contextMenu = [editor_ menuForEvent:event]; + [delegate verify]; + [editor_ setDelegate:nil]; + + EXPECT_EQ(contextMenu, menu.get()); + } + + // If the delegate does not return any menu, the default menu is + // returned. + { + id delegate = [OCMockObject mockForClass:[AutocompleteTextField class]]; + [[[delegate stub] andReturnValue:OCMOCK_VALUE(yes)] + isKindOfClass:[AutocompleteTextField class]]; + [[[delegate expect] andReturn:nil] decorationMenuForEvent:event]; + [editor_ setDelegate:delegate]; + NSMenu* contextMenu = [editor_ menuForEvent:event]; + [delegate verify]; + [editor_ setDelegate:nil]; + + EXPECT_NE(contextMenu, menu.get()); + NSArray* items = [contextMenu itemArray]; + ASSERT_GT([items count], 0U); + EXPECT_EQ(@selector(cut:), [[items objectAtIndex:0] action]) + << "action is: " << sel_getName([[items objectAtIndex:0] action]); + } +} + +// Base class for testing AutocompleteTextFieldObserver messages. +class AutocompleteTextFieldEditorObserverTest + : public AutocompleteTextFieldEditorTest { + public: + virtual void SetUp() { + AutocompleteTextFieldEditorTest::SetUp(); + [field_ setObserver:&field_observer_]; + } + + virtual void TearDown() { + // Clear the observer so that we don't show output for + // uninteresting messages to the mock (for instance, if |field_| has + // focus at the end of the test). + [field_ setObserver:NULL]; + + AutocompleteTextFieldEditorTest::TearDown(); + } + + StrictMock<MockAutocompleteTextFieldObserver> field_observer_; +}; + +// Test that the field editor is linked in correctly. +TEST_F(AutocompleteTextFieldEditorTest, FirstResponder) { + EXPECT_EQ(editor_, [field_ currentEditor]); + EXPECT_TRUE([editor_ isDescendantOf:field_]); + EXPECT_EQ([editor_ delegate], field_); + EXPECT_EQ([editor_ observer], [field_ observer]); +} + +// Test drawing, mostly to ensure nothing leaks or crashes. +TEST_F(AutocompleteTextFieldEditorTest, Display) { + [field_ display]; + [editor_ display]; +} + +// Test that -paste: is correctly delegated to the observer. +TEST_F(AutocompleteTextFieldEditorObserverTest, Paste) { + EXPECT_CALL(field_observer_, OnPaste()); + [editor_ paste:nil]; +} + +// Test that -copy: is correctly delegated to the observer. +TEST_F(AutocompleteTextFieldEditorObserverTest, Copy) { + EXPECT_CALL(field_observer_, CanCopy()) + .WillOnce(Return(true)); + EXPECT_CALL(field_observer_, CopyToPasteboard(A<NSPasteboard*>())) + .Times(1); + [editor_ copy:nil]; +} + +// Test that -cut: is correctly delegated to the observer and clears +// the text field. +TEST_F(AutocompleteTextFieldEditorObserverTest, Cut) { + // Sets a string in the field. + NSString* test_string = @"astring"; + EXPECT_CALL(field_observer_, OnDidBeginEditing()); + EXPECT_CALL(field_observer_, OnDidChange()); + EXPECT_CALL(field_observer_, SelectionRangeForProposedRange(A<NSRange>())) + .WillRepeatedly(ReturnArg<0>()); + [editor_ setString:test_string]; + [editor_ selectAll:nil]; + + // Calls cut. + EXPECT_CALL(field_observer_, CanCopy()) + .WillOnce(Return(true)); + EXPECT_CALL(field_observer_, CopyToPasteboard(A<NSPasteboard*>())) + .Times(1); + [editor_ cut:nil]; + + // Check if the field is cleared. + ASSERT_EQ([[editor_ textStorage] length], 0U); +} + +// Test that -pasteAndGo: is correctly delegated to the observer. +TEST_F(AutocompleteTextFieldEditorObserverTest, PasteAndGo) { + EXPECT_CALL(field_observer_, OnPasteAndGo()); + [editor_ pasteAndGo:nil]; +} + +// Test that the menu is constructed correctly when CanPasteAndGo(). +TEST_F(AutocompleteTextFieldEditorObserverTest, CanPasteAndGoMenu) { + EXPECT_CALL(field_observer_, CanPasteAndGo()) + .WillOnce(Return(true)); + EXPECT_CALL(field_observer_, GetPasteActionStringId()) + .WillOnce(Return(IDS_PASTE_AND_GO)); + + NSMenu* menu = [editor_ menuForEvent:nil]; + NSArray* items = [menu itemArray]; + ASSERT_EQ([items count], 6U); + // TODO(shess): Check the titles, too? + NSUInteger i = 0; // Use an index to make future changes easier. + EXPECT_EQ([[items objectAtIndex:i++] action], @selector(cut:)); + EXPECT_EQ([[items objectAtIndex:i++] action], @selector(copy:)); + EXPECT_EQ([[items objectAtIndex:i++] action], @selector(paste:)); + EXPECT_EQ([[items objectAtIndex:i++] action], @selector(pasteAndGo:)); + EXPECT_TRUE([[items objectAtIndex:i++] isSeparatorItem]); + + EXPECT_EQ([[items objectAtIndex:i] action], @selector(commandDispatch:)); + EXPECT_EQ([[items objectAtIndex:i] tag], IDC_EDIT_SEARCH_ENGINES); + i++; +} + +// Test that the menu is constructed correctly when !CanPasteAndGo(). +TEST_F(AutocompleteTextFieldEditorObserverTest, CannotPasteAndGoMenu) { + EXPECT_CALL(field_observer_, CanPasteAndGo()) + .WillOnce(Return(false)); + + NSMenu* menu = [editor_ menuForEvent:nil]; + NSArray* items = [menu itemArray]; + ASSERT_EQ([items count], 5U); + // TODO(shess): Check the titles, too? + NSUInteger i = 0; // Use an index to make future changes easier. + EXPECT_EQ([[items objectAtIndex:i++] action], @selector(cut:)); + EXPECT_EQ([[items objectAtIndex:i++] action], @selector(copy:)); + EXPECT_EQ([[items objectAtIndex:i++] action], @selector(paste:)); + EXPECT_TRUE([[items objectAtIndex:i++] isSeparatorItem]); + + EXPECT_EQ([[items objectAtIndex:i] action], @selector(commandDispatch:)); + EXPECT_EQ([[items objectAtIndex:i] tag], IDC_EDIT_SEARCH_ENGINES); + i++; +} + +// Test that the menu is constructed correctly when field isn't +// editable. +TEST_F(AutocompleteTextFieldEditorObserverTest, CanPasteAndGoMenuNotEditable) { + [field_ setEditable:NO]; + [editor_ setEditable:NO]; + + // Never call these when not editable. + EXPECT_CALL(field_observer_, CanPasteAndGo()) + .Times(0); + EXPECT_CALL(field_observer_, GetPasteActionStringId()) + .Times(0); + + NSMenu* menu = [editor_ menuForEvent:nil]; + NSArray* items = [menu itemArray]; + ASSERT_EQ([items count], 3U); + // TODO(shess): Check the titles, too? + NSUInteger i = 0; // Use an index to make future changes easier. + EXPECT_EQ([[items objectAtIndex:i++] action], @selector(cut:)); + EXPECT_EQ([[items objectAtIndex:i++] action], @selector(copy:)); + EXPECT_EQ([[items objectAtIndex:i++] action], @selector(paste:)); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_unittest.mm b/chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_unittest.mm new file mode 100644 index 0000000..14d5476 --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_unittest.mm @@ -0,0 +1,792 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "app/resource_bundle.h" +#import "base/cocoa_protocols_mac.h" +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/location_bar/autocomplete_text_field.h" +#import "chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_cell.h" +#import "chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_editor.h" +#import "chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_unittest_helper.h" +#import "chrome/browser/ui/cocoa/location_bar/location_bar_decoration.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "grit/theme_resources.h" +#include "testing/gmock/include/gmock/gmock.h" +#include "testing/gtest/include/gtest/gtest.h" +#import "testing/gtest_mac.h" +#include "testing/platform_test.h" + +using ::testing::A; +using ::testing::InSequence; +using ::testing::Return; +using ::testing::ReturnArg; +using ::testing::StrictMock; +using ::testing::_; + +namespace { + +class MockDecoration : public LocationBarDecoration { + public: + virtual CGFloat GetWidthForSpace(CGFloat width) { return 20.0; } + + virtual void DrawInFrame(NSRect frame, NSView* control_view) { ; } + MOCK_METHOD0(AcceptsMousePress, bool()); + MOCK_METHOD1(OnMousePressed, bool(NSRect frame)); + MOCK_METHOD0(GetMenu, NSMenu*()); +}; + +// Mock up an incrementing event number. +NSUInteger eventNumber = 0; + +// Create an event of the indicated |type| at |point| within |view|. +// TODO(shess): Would be nice to have a MockApplication which provided +// nifty accessors to create these things and inject them. It could +// even provide functions for "Click and drag mouse from point A to +// point B". +NSEvent* Event(NSView* view, const NSPoint point, const NSEventType type, + const NSUInteger clickCount) { + NSWindow* window([view window]); + const NSPoint locationInWindow([view convertPoint:point toView:nil]); + const NSPoint location([window convertBaseToScreen:locationInWindow]); + return [NSEvent mouseEventWithType:type + location:location + modifierFlags:0 + timestamp:0 + windowNumber:[window windowNumber] + context:nil + eventNumber:eventNumber++ + clickCount:clickCount + pressure:0.0]; +} +NSEvent* Event(NSView* view, const NSPoint point, const NSEventType type) { + return Event(view, point, type, 1); +} + +// Width of the field so that we don't have to ask |field_| for it all +// the time. +static const CGFloat kWidth(300.0); + +class AutocompleteTextFieldTest : public CocoaTest { + public: + AutocompleteTextFieldTest() { + // Make sure this is wide enough to play games with the cell + // decorations. + NSRect frame = NSMakeRect(0, 0, kWidth, 30); + scoped_nsobject<AutocompleteTextField> field( + [[AutocompleteTextField alloc] initWithFrame:frame]); + field_ = field.get(); + [field_ setStringValue:@"Test test"]; + [[test_window() contentView] addSubview:field_]; + + AutocompleteTextFieldCell* cell = [field_ cell]; + [cell clearDecorations]; + + mock_left_decoration_.SetVisible(false); + [cell addLeftDecoration:&mock_left_decoration_]; + + mock_right_decoration_.SetVisible(false); + [cell addRightDecoration:&mock_right_decoration_]; + + window_delegate_.reset( + [[AutocompleteTextFieldWindowTestDelegate alloc] init]); + [test_window() setDelegate:window_delegate_.get()]; + } + + NSEvent* KeyDownEventWithFlags(NSUInteger flags) { + return [NSEvent keyEventWithType:NSKeyDown + location:NSZeroPoint + modifierFlags:flags + timestamp:0.0 + windowNumber:[test_window() windowNumber] + context:nil + characters:@"a" + charactersIgnoringModifiers:@"a" + isARepeat:NO + keyCode:'a']; + } + + // Helper to return the field-editor frame being used w/in |field_|. + NSRect EditorFrame() { + EXPECT_TRUE([field_ currentEditor]); + EXPECT_EQ([[field_ subviews] count], 1U); + if ([[field_ subviews] count] > 0) { + return [[[field_ subviews] objectAtIndex:0] frame]; + } else { + // Return something which won't work so the caller can soldier + // on. + return NSZeroRect; + } + } + + AutocompleteTextField* field_; + MockDecoration mock_left_decoration_; + MockDecoration mock_right_decoration_; + scoped_nsobject<AutocompleteTextFieldWindowTestDelegate> window_delegate_; +}; + +TEST_VIEW(AutocompleteTextFieldTest, field_); + +// Base class for testing AutocompleteTextFieldObserver messages. +class AutocompleteTextFieldObserverTest : public AutocompleteTextFieldTest { + public: + virtual void SetUp() { + AutocompleteTextFieldTest::SetUp(); + [field_ setObserver:&field_observer_]; + } + + virtual void TearDown() { + // Clear the observer so that we don't show output for + // uninteresting messages to the mock (for instance, if |field_| has + // focus at the end of the test). + [field_ setObserver:NULL]; + + AutocompleteTextFieldTest::TearDown(); + } + + StrictMock<MockAutocompleteTextFieldObserver> field_observer_; +}; + +// Test that we have the right cell class. +TEST_F(AutocompleteTextFieldTest, CellClass) { + EXPECT_TRUE([[field_ cell] isKindOfClass:[AutocompleteTextFieldCell class]]); +} + +// Test that becoming first responder sets things up correctly. +TEST_F(AutocompleteTextFieldTest, FirstResponder) { + EXPECT_EQ(nil, [field_ currentEditor]); + EXPECT_EQ([[field_ subviews] count], 0U); + [test_window() makePretendKeyWindowAndSetFirstResponder:field_]; + EXPECT_FALSE(nil == [field_ currentEditor]); + EXPECT_EQ([[field_ subviews] count], 1U); + EXPECT_TRUE([[field_ currentEditor] isDescendantOf:field_]); + + // Check that the window delegate is providing the right editor. + Class c = [AutocompleteTextFieldEditor class]; + EXPECT_TRUE([[field_ currentEditor] isKindOfClass:c]); +} + +TEST_F(AutocompleteTextFieldTest, AvailableDecorationWidth) { + // A fudge factor to account for how much space the border takes up. + // The test shouldn't be too dependent on the field's internals, but + // it also shouldn't let deranged cases fall through the cracks + // (like nothing available with no text, or everything available + // with some text). + const CGFloat kBorderWidth = 20.0; + + // With no contents, almost the entire width is available for + // decorations. + [field_ setStringValue:@""]; + CGFloat availableWidth = [field_ availableDecorationWidth]; + EXPECT_LE(availableWidth, kWidth); + EXPECT_GT(availableWidth, kWidth - kBorderWidth); + + // With minor contents, most of the remaining width is available for + // decorations. + NSDictionary* attributes = + [NSDictionary dictionaryWithObject:[field_ font] + forKey:NSFontAttributeName]; + NSString* string = @"Hello world"; + const NSSize size([string sizeWithAttributes:attributes]); + [field_ setStringValue:string]; + availableWidth = [field_ availableDecorationWidth]; + EXPECT_LE(availableWidth, kWidth - size.width); + EXPECT_GT(availableWidth, kWidth - size.width - kBorderWidth); + + // With huge contents, nothing at all is left for decorations. + string = @"A long string which is surely wider than field_ can hold."; + [field_ setStringValue:string]; + availableWidth = [field_ availableDecorationWidth]; + EXPECT_LT(availableWidth, 0.0); +} + +// Test drawing, mostly to ensure nothing leaks or crashes. +TEST_F(AutocompleteTextFieldTest, Display) { + [field_ display]; + + // Test focussed drawing. + [test_window() makePretendKeyWindowAndSetFirstResponder:field_]; + [field_ display]; + [test_window() clearPretendKeyWindowAndFirstResponder]; +} + +TEST_F(AutocompleteTextFieldObserverTest, FlagsChanged) { + InSequence dummy; // Call mock in exactly the order specified. + + // Test without Control key down, but some other modifier down. + EXPECT_CALL(field_observer_, OnControlKeyChanged(false)); + [field_ flagsChanged:KeyDownEventWithFlags(NSShiftKeyMask)]; + + // Test with Control key down. + EXPECT_CALL(field_observer_, OnControlKeyChanged(true)); + [field_ flagsChanged:KeyDownEventWithFlags(NSControlKeyMask)]; +} + +// This test is here rather than in the editor's tests because the +// field catches -flagsChanged: because it's on the responder chain, +// the field editor doesn't implement it. +TEST_F(AutocompleteTextFieldObserverTest, FieldEditorFlagsChanged) { + // Many of these methods try to change the selection. + EXPECT_CALL(field_observer_, SelectionRangeForProposedRange(A<NSRange>())) + .WillRepeatedly(ReturnArg<0>()); + + InSequence dummy; // Call mock in exactly the order specified. + EXPECT_CALL(field_observer_, OnSetFocus(false)); + [test_window() makePretendKeyWindowAndSetFirstResponder:field_]; + NSResponder* firstResponder = [[field_ window] firstResponder]; + EXPECT_EQ(firstResponder, [field_ currentEditor]); + + // Test without Control key down, but some other modifier down. + EXPECT_CALL(field_observer_, OnControlKeyChanged(false)); + [firstResponder flagsChanged:KeyDownEventWithFlags(NSShiftKeyMask)]; + + // Test with Control key down. + EXPECT_CALL(field_observer_, OnControlKeyChanged(true)); + [firstResponder flagsChanged:KeyDownEventWithFlags(NSControlKeyMask)]; +} + +// Frame size changes are propagated to |observer_|. +TEST_F(AutocompleteTextFieldObserverTest, FrameChanged) { + EXPECT_CALL(field_observer_, OnFrameChanged()); + NSRect frame = [field_ frame]; + frame.size.width += 10.0; + [field_ setFrame:frame]; +} + +// Test that the field editor gets the same bounds when focus is +// delivered by the standard focusing machinery, or by +// -resetFieldEditorFrameIfNeeded. +TEST_F(AutocompleteTextFieldTest, ResetFieldEditorBase) { + // Capture the editor frame resulting from the standard focus + // machinery. + [test_window() makePretendKeyWindowAndSetFirstResponder:field_]; + const NSRect baseEditorFrame = EditorFrame(); + + // A decoration should result in a strictly smaller editor frame. + mock_left_decoration_.SetVisible(true); + [field_ resetFieldEditorFrameIfNeeded]; + EXPECT_FALSE(NSEqualRects(baseEditorFrame, EditorFrame())); + EXPECT_TRUE(NSContainsRect(baseEditorFrame, EditorFrame())); + + // Removing the decoration and using -resetFieldEditorFrameIfNeeded + // should result in the same frame as the standard focus machinery. + mock_left_decoration_.SetVisible(false); + [field_ resetFieldEditorFrameIfNeeded]; + EXPECT_TRUE(NSEqualRects(baseEditorFrame, EditorFrame())); +} + +// Test that the field editor gets the same bounds when focus is +// delivered by the standard focusing machinery, or by +// -resetFieldEditorFrameIfNeeded, this time with a decoration +// pre-loaded. +TEST_F(AutocompleteTextFieldTest, ResetFieldEditorWithDecoration) { + AutocompleteTextFieldCell* cell = [field_ cell]; + + // Make sure decoration isn't already visible, then make it visible. + EXPECT_TRUE(NSIsEmptyRect([cell frameForDecoration:&mock_left_decoration_ + inFrame:[field_ bounds]])); + mock_left_decoration_.SetVisible(true); + EXPECT_FALSE(NSIsEmptyRect([cell frameForDecoration:&mock_left_decoration_ + inFrame:[field_ bounds]])); + + // Capture the editor frame resulting from the standard focus + // machinery. + + [test_window() makePretendKeyWindowAndSetFirstResponder:field_]; + const NSRect baseEditorFrame = EditorFrame(); + + // When the decoration is not visible the frame should be strictly larger. + mock_left_decoration_.SetVisible(false); + EXPECT_TRUE(NSIsEmptyRect([cell frameForDecoration:&mock_left_decoration_ + inFrame:[field_ bounds]])); + [field_ resetFieldEditorFrameIfNeeded]; + EXPECT_FALSE(NSEqualRects(baseEditorFrame, EditorFrame())); + EXPECT_TRUE(NSContainsRect(EditorFrame(), baseEditorFrame)); + + // When the decoration is visible, -resetFieldEditorFrameIfNeeded + // should result in the same frame as the standard focus machinery. + mock_left_decoration_.SetVisible(true); + EXPECT_FALSE(NSIsEmptyRect([cell frameForDecoration:&mock_left_decoration_ + inFrame:[field_ bounds]])); + + [field_ resetFieldEditorFrameIfNeeded]; + EXPECT_TRUE(NSEqualRects(baseEditorFrame, EditorFrame())); +} + +// Test that resetting the field editor bounds does not cause untoward +// messages to the field's observer. +TEST_F(AutocompleteTextFieldObserverTest, ResetFieldEditorContinuesEditing) { + // Many of these methods try to change the selection. + EXPECT_CALL(field_observer_, SelectionRangeForProposedRange(A<NSRange>())) + .WillRepeatedly(ReturnArg<0>()); + + EXPECT_CALL(field_observer_, OnSetFocus(false)); + // Becoming first responder doesn't begin editing. + [test_window() makePretendKeyWindowAndSetFirstResponder:field_]; + const NSRect baseEditorFrame = EditorFrame(); + NSTextView* editor = static_cast<NSTextView*>([field_ currentEditor]); + EXPECT_TRUE(nil != editor); + + // This should begin editing and indicate a change. + EXPECT_CALL(field_observer_, OnDidBeginEditing()); + EXPECT_CALL(field_observer_, OnDidChange()); + [editor shouldChangeTextInRange:NSMakeRange(0, 0) replacementString:@""]; + [editor didChangeText]; + + // No messages to |field_observer_| when the frame actually changes. + mock_left_decoration_.SetVisible(true); + [field_ resetFieldEditorFrameIfNeeded]; + EXPECT_FALSE(NSEqualRects(baseEditorFrame, EditorFrame())); +} + +// Clicking in a right-hand decoration which does not handle the mouse +// puts the caret rightmost. +TEST_F(AutocompleteTextFieldTest, ClickRightDecorationPutsCaretRightmost) { + // Decoration does not handle the mouse event, so the cell should + // process it. Called at least once. + EXPECT_CALL(mock_right_decoration_, AcceptsMousePress()) + .WillOnce(Return(false)) + .WillRepeatedly(Return(false)); + + // Set the decoration before becoming responder. + EXPECT_FALSE([field_ currentEditor]); + mock_right_decoration_.SetVisible(true); + + // Make first responder should select all. + [test_window() makePretendKeyWindowAndSetFirstResponder:field_]; + EXPECT_TRUE([field_ currentEditor]); + const NSRange allRange = NSMakeRange(0, [[field_ stringValue] length]); + EXPECT_TRUE(NSEqualRanges(allRange, [[field_ currentEditor] selectedRange])); + + // Generate a click on the decoration. + AutocompleteTextFieldCell* cell = [field_ cell]; + const NSRect bounds = [field_ bounds]; + const NSRect iconFrame = + [cell frameForDecoration:&mock_right_decoration_ inFrame:bounds]; + const NSPoint point = NSMakePoint(NSMidX(iconFrame), NSMidY(iconFrame)); + NSEvent* downEvent = Event(field_, point, NSLeftMouseDown); + NSEvent* upEvent = Event(field_, point, NSLeftMouseUp); + [NSApp postEvent:upEvent atStart:YES]; + [field_ mouseDown:downEvent]; + + // Selection should be a right-hand-side caret. + EXPECT_TRUE(NSEqualRanges(NSMakeRange([[field_ stringValue] length], 0), + [[field_ currentEditor] selectedRange])); +} + +// Clicking in a left-side decoration which doesn't handle the event +// puts the selection in the leftmost position. +TEST_F(AutocompleteTextFieldTest, ClickLeftDecorationPutsCaretLeftmost) { + // Decoration does not handle the mouse event, so the cell should + // process it. Called at least once. + EXPECT_CALL(mock_left_decoration_, AcceptsMousePress()) + .WillOnce(Return(false)) + .WillRepeatedly(Return(false)); + + // Set the decoration before becoming responder. + EXPECT_FALSE([field_ currentEditor]); + mock_left_decoration_.SetVisible(true); + + // Make first responder should select all. + [test_window() makePretendKeyWindowAndSetFirstResponder:field_]; + EXPECT_TRUE([field_ currentEditor]); + const NSRange allRange = NSMakeRange(0, [[field_ stringValue] length]); + EXPECT_TRUE(NSEqualRanges(allRange, [[field_ currentEditor] selectedRange])); + + // Generate a click on the decoration. + AutocompleteTextFieldCell* cell = [field_ cell]; + const NSRect bounds = [field_ bounds]; + const NSRect iconFrame = + [cell frameForDecoration:&mock_left_decoration_ inFrame:bounds]; + const NSPoint point = NSMakePoint(NSMidX(iconFrame), NSMidY(iconFrame)); + NSEvent* downEvent = Event(field_, point, NSLeftMouseDown); + NSEvent* upEvent = Event(field_, point, NSLeftMouseUp); + [NSApp postEvent:upEvent atStart:YES]; + [field_ mouseDown:downEvent]; + + // Selection should be a left-hand-side caret. + EXPECT_TRUE(NSEqualRanges(NSMakeRange(0, 0), + [[field_ currentEditor] selectedRange])); +} + +// Clicks not in the text area or the cell's decorations fall through +// to the editor. +TEST_F(AutocompleteTextFieldTest, ClickBorderSelectsAll) { + // Can't rely on the window machinery to make us first responder, + // here. + [test_window() makePretendKeyWindowAndSetFirstResponder:field_]; + EXPECT_TRUE([field_ currentEditor]); + + const NSPoint point(NSMakePoint(20.0, 1.0)); + NSEvent* downEvent(Event(field_, point, NSLeftMouseDown)); + NSEvent* upEvent(Event(field_, point, NSLeftMouseUp)); + [NSApp postEvent:upEvent atStart:YES]; + [field_ mouseDown:downEvent]; + + // Clicking in the narrow border area around a Cocoa NSTextField + // does a select-all. Regardless of whether this is a good call, it + // works as a test that things get passed down to the editor. + const NSRange selectedRange([[field_ currentEditor] selectedRange]); + EXPECT_EQ(selectedRange.location, 0U); + EXPECT_EQ(selectedRange.length, [[field_ stringValue] length]); +} + +// Single-click with no drag should setup a field editor and +// select all. +TEST_F(AutocompleteTextFieldTest, ClickSelectsAll) { + EXPECT_FALSE([field_ currentEditor]); + + const NSPoint point = NSMakePoint(20.0, NSMidY([field_ bounds])); + NSEvent* downEvent(Event(field_, point, NSLeftMouseDown)); + NSEvent* upEvent(Event(field_, point, NSLeftMouseUp)); + [NSApp postEvent:upEvent atStart:YES]; + [field_ mouseDown:downEvent]; + EXPECT_TRUE([field_ currentEditor]); + const NSRange selectedRange([[field_ currentEditor] selectedRange]); + EXPECT_EQ(selectedRange.location, 0U); + EXPECT_EQ(selectedRange.length, [[field_ stringValue] length]); +} + +// Click-drag selects text, not select all. +TEST_F(AutocompleteTextFieldTest, ClickDragSelectsText) { + EXPECT_FALSE([field_ currentEditor]); + + NSEvent* downEvent(Event(field_, NSMakePoint(20.0, 5.0), NSLeftMouseDown)); + NSEvent* upEvent(Event(field_, NSMakePoint(0.0, 5.0), NSLeftMouseUp)); + [NSApp postEvent:upEvent atStart:YES]; + [field_ mouseDown:downEvent]; + EXPECT_TRUE([field_ currentEditor]); + + // Expect this to have selected a prefix of the content. Mostly + // just don't want the select-all behavior. + const NSRange selectedRange([[field_ currentEditor] selectedRange]); + EXPECT_EQ(selectedRange.location, 0U); + EXPECT_LT(selectedRange.length, [[field_ stringValue] length]); +} + +// TODO(shess): Test that click/pause/click allows cursor placement. +// In this case the first click goes to the field, but the second +// click goes to the field editor, so the current testing pattern +// can't work. What really needs to happen is to push through the +// NSWindow event machinery so that we can say "two independent clicks +// at the same location have the right effect". Once that is done, it +// might make sense to revise the other tests to use the same +// machinery. + +// Double-click selects word, not select all. +TEST_F(AutocompleteTextFieldTest, DoubleClickSelectsWord) { + EXPECT_FALSE([field_ currentEditor]); + + const NSPoint point = NSMakePoint(20.0, NSMidY([field_ bounds])); + NSEvent* downEvent(Event(field_, point, NSLeftMouseDown, 1)); + NSEvent* upEvent(Event(field_, point, NSLeftMouseUp, 1)); + NSEvent* downEvent2(Event(field_, point, NSLeftMouseDown, 2)); + NSEvent* upEvent2(Event(field_, point, NSLeftMouseUp, 2)); + [NSApp postEvent:upEvent atStart:YES]; + [field_ mouseDown:downEvent]; + [NSApp postEvent:upEvent2 atStart:YES]; + [field_ mouseDown:downEvent2]; + EXPECT_TRUE([field_ currentEditor]); + + // Selected the first word. + const NSRange selectedRange([[field_ currentEditor] selectedRange]); + const NSRange spaceRange([[field_ stringValue] rangeOfString:@" "]); + EXPECT_GT(spaceRange.location, 0U); + EXPECT_LT(spaceRange.length, [[field_ stringValue] length]); + EXPECT_EQ(selectedRange.location, 0U); + EXPECT_EQ(selectedRange.length, spaceRange.location); +} + +TEST_F(AutocompleteTextFieldTest, TripleClickSelectsAll) { + EXPECT_FALSE([field_ currentEditor]); + + const NSPoint point(NSMakePoint(20.0, 5.0)); + NSEvent* downEvent(Event(field_, point, NSLeftMouseDown, 1)); + NSEvent* upEvent(Event(field_, point, NSLeftMouseUp, 1)); + NSEvent* downEvent2(Event(field_, point, NSLeftMouseDown, 2)); + NSEvent* upEvent2(Event(field_, point, NSLeftMouseUp, 2)); + NSEvent* downEvent3(Event(field_, point, NSLeftMouseDown, 3)); + NSEvent* upEvent3(Event(field_, point, NSLeftMouseUp, 3)); + [NSApp postEvent:upEvent atStart:YES]; + [field_ mouseDown:downEvent]; + [NSApp postEvent:upEvent2 atStart:YES]; + [field_ mouseDown:downEvent2]; + [NSApp postEvent:upEvent3 atStart:YES]; + [field_ mouseDown:downEvent3]; + EXPECT_TRUE([field_ currentEditor]); + + // Selected the first word. + const NSRange selectedRange([[field_ currentEditor] selectedRange]); + EXPECT_EQ(selectedRange.location, 0U); + EXPECT_EQ(selectedRange.length, [[field_ stringValue] length]); +} + +// Clicking a decoration should call decoration's OnMousePressed. +TEST_F(AutocompleteTextFieldTest, LeftDecorationMouseDown) { + // At this point, not focussed. + EXPECT_FALSE([field_ currentEditor]); + + mock_left_decoration_.SetVisible(true); + EXPECT_CALL(mock_left_decoration_, AcceptsMousePress()) + .WillRepeatedly(Return(true)); + + AutocompleteTextFieldCell* cell = [field_ cell]; + const NSRect iconFrame = + [cell frameForDecoration:&mock_left_decoration_ inFrame:[field_ bounds]]; + const NSPoint location = NSMakePoint(NSMidX(iconFrame), NSMidY(iconFrame)); + NSEvent* downEvent = Event(field_, location, NSLeftMouseDown, 1); + NSEvent* upEvent = Event(field_, location, NSLeftMouseUp, 1); + + // Since decorations can be dragged, the mouse-press is sent on + // mouse-up. + [NSApp postEvent:upEvent atStart:YES]; + + EXPECT_CALL(mock_left_decoration_, OnMousePressed(_)) + .WillOnce(Return(true)); + [field_ mouseDown:downEvent]; + + // Focus the field and test that handled clicks don't affect selection. + [test_window() makePretendKeyWindowAndSetFirstResponder:field_]; + EXPECT_TRUE([field_ currentEditor]); + const NSRange allRange = NSMakeRange(0, [[field_ stringValue] length]); + EXPECT_TRUE(NSEqualRanges(allRange, [[field_ currentEditor] selectedRange])); + + // Generate another click on the decoration. + downEvent = Event(field_, location, NSLeftMouseDown, 1); + upEvent = Event(field_, location, NSLeftMouseUp, 1); + [NSApp postEvent:upEvent atStart:YES]; + EXPECT_CALL(mock_left_decoration_, OnMousePressed(_)) + .WillOnce(Return(true)); + [field_ mouseDown:downEvent]; + + // The selection should not have changed. + EXPECT_TRUE(NSEqualRanges(allRange, [[field_ currentEditor] selectedRange])); + + // TODO(shess): Test that mouse drags are initiated if the next + // event is a drag, or if the mouse-up takes too long to arrive. + // IDEA: mock decoration to return a pasteboard which a mock + // AutocompleteTextField notes in -dragImage:*. +} + +// Clicking a decoration should call decoration's OnMousePressed. +TEST_F(AutocompleteTextFieldTest, RightDecorationMouseDown) { + // At this point, not focussed. + EXPECT_FALSE([field_ currentEditor]); + + mock_right_decoration_.SetVisible(true); + EXPECT_CALL(mock_right_decoration_, AcceptsMousePress()) + .WillRepeatedly(Return(true)); + + AutocompleteTextFieldCell* cell = [field_ cell]; + const NSRect bounds = [field_ bounds]; + const NSRect iconFrame = + [cell frameForDecoration:&mock_right_decoration_ inFrame:bounds]; + const NSPoint location = NSMakePoint(NSMidX(iconFrame), NSMidY(iconFrame)); + NSEvent* downEvent = Event(field_, location, NSLeftMouseDown, 1); + NSEvent* upEvent = Event(field_, location, NSLeftMouseUp, 1); + + // Since decorations can be dragged, the mouse-press is sent on + // mouse-up. + [NSApp postEvent:upEvent atStart:YES]; + + EXPECT_CALL(mock_right_decoration_, OnMousePressed(_)) + .WillOnce(Return(true)); + [field_ mouseDown:downEvent]; +} + +// Test that page action menus are properly returned. +// TODO(shess): Really, this should test that things are forwarded to +// the cell, and the cell tests should test that the right things are +// selected. It's easier to mock the event here, though. This code's +// event-mockers might be worth promoting to |test_event_utils.h| or +// |cocoa_test_helper.h|. +TEST_F(AutocompleteTextFieldTest, DecorationMenu) { + AutocompleteTextFieldCell* cell = [field_ cell]; + const NSRect bounds([field_ bounds]); + + const CGFloat edge = NSHeight(bounds) - 4.0; + const NSSize size = NSMakeSize(edge, edge); + scoped_nsobject<NSImage> image([[NSImage alloc] initWithSize:size]); + + scoped_nsobject<NSMenu> menu([[NSMenu alloc] initWithTitle:@"Menu"]); + + mock_left_decoration_.SetVisible(true); + mock_right_decoration_.SetVisible(true); + + // The item with a menu returns it. + NSRect actionFrame = [cell frameForDecoration:&mock_right_decoration_ + inFrame:bounds]; + NSPoint location = NSMakePoint(NSMidX(actionFrame), NSMidY(actionFrame)); + NSEvent* event = Event(field_, location, NSRightMouseDown, 1); + + // Check that the decoration is called, and the field returns the + // menu. + EXPECT_CALL(mock_right_decoration_, GetMenu()) + .WillOnce(Return(menu.get())); + NSMenu *decorationMenu = [field_ decorationMenuForEvent:event]; + EXPECT_EQ(decorationMenu, menu); + + // The item without a menu returns nil. + EXPECT_CALL(mock_left_decoration_, GetMenu()) + .WillOnce(Return(static_cast<NSMenu*>(nil))); + actionFrame = [cell frameForDecoration:&mock_left_decoration_ + inFrame:bounds]; + location = NSMakePoint(NSMidX(actionFrame), NSMidY(actionFrame)); + event = Event(field_, location, NSRightMouseDown, 1); + EXPECT_FALSE([field_ decorationMenuForEvent:event]); + + // Something not in an action returns nil. + location = NSMakePoint(NSMidX(bounds), NSMidY(bounds)); + event = Event(field_, location, NSRightMouseDown, 1); + EXPECT_FALSE([field_ decorationMenuForEvent:event]); +} + +// Verify that -setAttributedStringValue: works as expected when +// focussed or when not focussed. Our code mostly depends on about +// whether -stringValue works right. +TEST_F(AutocompleteTextFieldTest, SetAttributedStringBaseline) { + EXPECT_EQ(nil, [field_ currentEditor]); + + // So that we can set rich text. + [field_ setAllowsEditingTextAttributes:YES]; + + // Set an attribute different from the field's default so we can + // tell we got the same string out as we put in. + NSFont* font = [NSFont fontWithDescriptor:[[field_ font] fontDescriptor] + size:[[field_ font] pointSize] + 2]; + NSDictionary* attributes = + [NSDictionary dictionaryWithObject:font + forKey:NSFontAttributeName]; + NSString* const kString = @"This is a test"; + scoped_nsobject<NSAttributedString> attributedString( + [[NSAttributedString alloc] initWithString:kString + attributes:attributes]); + + // Check that what we get back looks like what we put in. + EXPECT_NSNE(kString, [field_ stringValue]); + [field_ setAttributedStringValue:attributedString]; + EXPECT_TRUE([[field_ attributedStringValue] + isEqualToAttributedString:attributedString]); + EXPECT_NSEQ(kString, [field_ stringValue]); + + // Try that again with focus. + [test_window() makePretendKeyWindowAndSetFirstResponder:field_]; + + EXPECT_TRUE([field_ currentEditor]); + + // Check that what we get back looks like what we put in. + [field_ setStringValue:@""]; + EXPECT_NSNE(kString, [field_ stringValue]); + [field_ setAttributedStringValue:attributedString]; + EXPECT_TRUE([[field_ attributedStringValue] + isEqualToAttributedString:attributedString]); + EXPECT_NSEQ(kString, [field_ stringValue]); +} + +// -setAttributedStringValue: shouldn't reset the undo state if things +// are being editted. +TEST_F(AutocompleteTextFieldTest, SetAttributedStringUndo) { + NSColor* redColor = [NSColor redColor]; + NSDictionary* attributes = + [NSDictionary dictionaryWithObject:redColor + forKey:NSForegroundColorAttributeName]; + NSString* const kString = @"This is a test"; + scoped_nsobject<NSAttributedString> attributedString( + [[NSAttributedString alloc] initWithString:kString + attributes:attributes]); + [test_window() makePretendKeyWindowAndSetFirstResponder:field_]; + EXPECT_TRUE([field_ currentEditor]); + NSTextView* editor = static_cast<NSTextView*>([field_ currentEditor]); + NSUndoManager* undoManager = [editor undoManager]; + EXPECT_TRUE(undoManager); + + // Nothing to undo, yet. + EXPECT_FALSE([undoManager canUndo]); + + // Starting an editing action creates an undoable item. + [editor shouldChangeTextInRange:NSMakeRange(0, 0) replacementString:@""]; + [editor didChangeText]; + EXPECT_TRUE([undoManager canUndo]); + + // -setStringValue: resets the editor's undo chain. + [field_ setStringValue:kString]; + EXPECT_FALSE([undoManager canUndo]); + + // Verify that -setAttributedStringValue: does not reset the + // editor's undo chain. + [field_ setStringValue:@""]; + [editor shouldChangeTextInRange:NSMakeRange(0, 0) replacementString:@""]; + [editor didChangeText]; + EXPECT_TRUE([undoManager canUndo]); + [field_ setAttributedStringValue:attributedString]; + EXPECT_TRUE([undoManager canUndo]); + + // Verify that calling -clearUndoChain clears the undo chain. + [field_ clearUndoChain]; + EXPECT_FALSE([undoManager canUndo]); +} + +TEST_F(AutocompleteTextFieldTest, EditorGetsCorrectUndoManager) { + [test_window() makePretendKeyWindowAndSetFirstResponder:field_]; + + NSTextView* editor = static_cast<NSTextView*>([field_ currentEditor]); + EXPECT_TRUE(editor); + EXPECT_EQ([field_ undoManagerForTextView:editor], [editor undoManager]); +} + +TEST_F(AutocompleteTextFieldObserverTest, SendsEditingMessages) { + // Many of these methods try to change the selection. + EXPECT_CALL(field_observer_, SelectionRangeForProposedRange(A<NSRange>())) + .WillRepeatedly(ReturnArg<0>()); + + EXPECT_CALL(field_observer_, OnSetFocus(false)); + // Becoming first responder doesn't begin editing. + [test_window() makePretendKeyWindowAndSetFirstResponder:field_]; + NSTextView* editor = static_cast<NSTextView*>([field_ currentEditor]); + EXPECT_TRUE(nil != editor); + + // This should begin editing and indicate a change. + EXPECT_CALL(field_observer_, OnDidBeginEditing()); + EXPECT_CALL(field_observer_, OnDidChange()); + [editor shouldChangeTextInRange:NSMakeRange(0, 0) replacementString:@""]; + [editor didChangeText]; + + // Further changes don't send the begin message. + EXPECT_CALL(field_observer_, OnDidChange()); + [editor shouldChangeTextInRange:NSMakeRange(0, 0) replacementString:@""]; + [editor didChangeText]; + + // -doCommandBySelector: should forward to observer via |field_|. + // TODO(shess): Test with a fake arrow-key event? + const SEL cmd = @selector(moveDown:); + EXPECT_CALL(field_observer_, OnDoCommandBySelector(cmd)) + .WillOnce(Return(true)); + [editor doCommandBySelector:cmd]; + + // Finished with the changes. + EXPECT_CALL(field_observer_, OnKillFocus()); + EXPECT_CALL(field_observer_, OnDidEndEditing()); + [test_window() clearPretendKeyWindowAndFirstResponder]; +} + +// Test that the resign-key notification is forwarded right, and that +// the notification is registered and unregistered when the view moves +// in and out of the window. +// TODO(shess): Should this test the key window for realz? That would +// be really annoying to whoever is running the tests. +TEST_F(AutocompleteTextFieldObserverTest, ClosePopupOnResignKey) { + EXPECT_CALL(field_observer_, ClosePopup()); + [test_window() resignKeyWindow]; + + scoped_nsobject<AutocompleteTextField> pin([field_ retain]); + [field_ removeFromSuperview]; + [test_window() resignKeyWindow]; + + [[test_window() contentView] addSubview:field_]; + EXPECT_CALL(field_observer_, ClosePopup()); + [test_window() resignKeyWindow]; +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_unittest_helper.h b/chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_unittest_helper.h new file mode 100644 index 0000000..bccaae1 --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_unittest_helper.h @@ -0,0 +1,58 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_AUTOCOMPLETE_TEXT_FIELD_UNITTEST_HELPER_H_ +#define CHROME_BROWSER_UI_COCOA_AUTOCOMPLETE_TEXT_FIELD_UNITTEST_HELPER_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#import "base/cocoa_protocols_mac.h" +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/location_bar/autocomplete_text_field.h" +#include "testing/gmock/include/gmock/gmock.h" + +@class AutocompleteTextFieldEditor; + +// Return the right field editor for AutocompleteTextField instance. + +@interface AutocompleteTextFieldWindowTestDelegate : + NSObject<NSWindowDelegate> { + scoped_nsobject<AutocompleteTextFieldEditor> editor_; +} +- (id)windowWillReturnFieldEditor:(NSWindow *)sender toObject:(id)anObject; +@end + +namespace { + +// Allow monitoring calls into AutocompleteTextField's observer. +// Being in a .h file with an anonymous namespace is strange, but this +// is here so the mock interface doesn't have to change in multiple +// places. + +// Any method you add here needs a unit test. You knew that. + +class MockAutocompleteTextFieldObserver : public AutocompleteTextFieldObserver { + public: + MOCK_METHOD1(SelectionRangeForProposedRange, NSRange(NSRange range)); + MOCK_METHOD1(OnControlKeyChanged, void(bool pressed)); + MOCK_METHOD0(CanCopy, bool()); + MOCK_METHOD1(CopyToPasteboard, void(NSPasteboard* pboard)); + MOCK_METHOD0(OnPaste, void()); + MOCK_METHOD0(CanPasteAndGo, bool()); + MOCK_METHOD0(GetPasteActionStringId, int()); + MOCK_METHOD0(OnPasteAndGo, void()); + MOCK_METHOD0(OnFrameChanged, void()); + MOCK_METHOD0(ClosePopup, void()); + MOCK_METHOD0(OnDidBeginEditing, void()); + MOCK_METHOD0(OnDidChange, void()); + MOCK_METHOD0(OnDidEndEditing, void()); + MOCK_METHOD1(OnDoCommandBySelector, bool(SEL cmd)); + MOCK_METHOD1(OnSetFocus, void(bool control_down)); + MOCK_METHOD0(OnKillFocus, void()); +}; + +} // namespace + +#endif // CHROME_BROWSER_UI_COCOA_AUTOCOMPLETE_TEXT_FIELD_UNITTEST_HELPER_H_ diff --git a/chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_unittest_helper.mm b/chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_unittest_helper.mm new file mode 100644 index 0000000..a2c5194 --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_unittest_helper.mm @@ -0,0 +1,29 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_unittest_helper.h" + +#import "chrome/browser/ui/cocoa/location_bar/autocomplete_text_field.h" +#import "chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_editor.h" +#include "testing/gtest/include/gtest/gtest.h" + +@implementation AutocompleteTextFieldWindowTestDelegate + +- (id)windowWillReturnFieldEditor:(NSWindow *)sender toObject:(id)anObject { + id editor = nil; + if ([anObject isKindOfClass:[AutocompleteTextField class]]) { + if (editor_ == nil) { + editor_.reset([[AutocompleteTextFieldEditor alloc] init]); + } + EXPECT_TRUE(editor_ != nil); + + // This needs to be called every time, otherwise notifications + // aren't sent correctly. + [editor_ setFieldEditor:YES]; + editor = editor_.get(); + } + return editor; +} + +@end diff --git a/chrome/browser/ui/cocoa/location_bar/bubble_decoration.h b/chrome/browser/ui/cocoa/location_bar/bubble_decoration.h new file mode 100644 index 0000000..234c254 --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/bubble_decoration.h @@ -0,0 +1,67 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_LOCATION_BAR_BUBBLE_DECORATION_H_ +#define CHROME_BROWSER_UI_COCOA_LOCATION_BAR_BUBBLE_DECORATION_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "base/gtest_prod_util.h" +#include "base/scoped_nsobject.h" +#include "chrome/browser/ui/cocoa/location_bar/location_bar_decoration.h" + +// Draws an outlined rounded rect, with an optional image to the left +// and an optional text label to the right. + +class BubbleDecoration : public LocationBarDecoration { + public: + // |font| will be used when drawing the label, and cannot be |nil|. + BubbleDecoration(NSFont* font); + ~BubbleDecoration(); + + // Setup the drawing parameters. + NSImage* GetImage(); + void SetImage(NSImage* image); + void SetLabel(NSString* label); + void SetColors(NSColor* border_color, + NSColor* background_color, + NSColor* text_color); + + // Implement |LocationBarDecoration|. + virtual void DrawInFrame(NSRect frame, NSView* control_view); + virtual CGFloat GetWidthForSpace(CGFloat width); + + protected: + // Helper returning bubble width for the given |image| and |label| + // assuming |font_| (for sizing text). Arguments can be nil. + CGFloat GetWidthForImageAndLabel(NSImage* image, NSString* label); + + // Helper to return where the image is drawn, for subclasses to drag + // from. |frame| is the decoration's frame in the containing cell. + NSRect GetImageRectInFrame(NSRect frame); + + private: + friend class SelectedKeywordDecorationTest; + FRIEND_TEST_ALL_PREFIXES(SelectedKeywordDecorationTest, + UsesPartialKeywordIfNarrow); + + // Contains font and color attribute for drawing |label_|. + scoped_nsobject<NSDictionary> attributes_; + + // Image drawn in the left side of the bubble. + scoped_nsobject<NSImage> image_; + + // Label to draw to right of image. Can be |nil|. + scoped_nsobject<NSString> label_; + + // Colors used to draw the bubble, should be set by the subclass + // constructor. + scoped_nsobject<NSColor> background_color_; + scoped_nsobject<NSColor> border_color_; + + DISALLOW_COPY_AND_ASSIGN(BubbleDecoration); +}; + +#endif // CHROME_BROWSER_UI_COCOA_LOCATION_BAR_BUBBLE_DECORATION_H_ diff --git a/chrome/browser/ui/cocoa/location_bar/bubble_decoration.mm b/chrome/browser/ui/cocoa/location_bar/bubble_decoration.mm new file mode 100644 index 0000000..b568639 --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/bubble_decoration.mm @@ -0,0 +1,158 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include <cmath> + +#import "chrome/browser/ui/cocoa/location_bar/bubble_decoration.h" + +#include "base/logging.h" +#import "chrome/browser/ui/cocoa/image_utils.h" + +namespace { + +// Padding between the icon/label and bubble edges. +const CGFloat kBubblePadding = 3.0; + +// The image needs to be in the same position as for the location +// icon, which implies that the bubble's padding in the Omnibox needs +// to differ from the location icon's. Indeed, that's how the views +// implementation handles the problem. This draws the bubble edge a +// little bit further left, which is easier but no less hacky. +const CGFloat kLeftSideOverdraw = 2.0; + +// Omnibox corner radius is |4.0|, this needs to look tight WRT that. +const CGFloat kBubbleCornerRadius = 2.0; + +// How far to inset the bubble from the top and bottom of the drawing +// frame. +// TODO(shess): Would be nicer to have the drawing code factor out the +// space outside the border, and perhaps the border. Then this could +// reflect the single pixel space w/in that. +const CGFloat kBubbleYInset = 4.0; + +} // namespace + +BubbleDecoration::BubbleDecoration(NSFont* font) { + DCHECK(font); + if (font) { + NSDictionary* attributes = + [NSDictionary dictionaryWithObject:font + forKey:NSFontAttributeName]; + attributes_.reset([attributes retain]); + } +} + +BubbleDecoration::~BubbleDecoration() { +} + +CGFloat BubbleDecoration::GetWidthForImageAndLabel(NSImage* image, + NSString* label) { + if (!image && !label) + return kOmittedWidth; + + const CGFloat image_width = image ? [image size].width : 0.0; + if (!label) + return kBubblePadding + image_width; + + // The bubble needs to take up an integral number of pixels. + // Generally -sizeWithAttributes: seems to overestimate rather than + // underestimate, so floor() seems to work better. + const CGFloat label_width = + std::floor([label sizeWithAttributes:attributes_].width); + return kBubblePadding + image_width + label_width; +} + +NSRect BubbleDecoration::GetImageRectInFrame(NSRect frame) { + NSRect imageRect = NSInsetRect(frame, 0.0, kBubbleYInset); + if (image_) { + // Center the image vertically. + const NSSize imageSize = [image_ size]; + imageRect.origin.y += + std::floor((NSHeight(frame) - imageSize.height) / 2.0); + imageRect.size = imageSize; + } + return imageRect; +} + +CGFloat BubbleDecoration::GetWidthForSpace(CGFloat width) { + const CGFloat all_width = GetWidthForImageAndLabel(image_, label_); + if (all_width <= width) + return all_width; + + const CGFloat image_width = GetWidthForImageAndLabel(image_, nil); + if (image_width <= width) + return image_width; + + return kOmittedWidth; +} + +void BubbleDecoration::DrawInFrame(NSRect frame, NSView* control_view) { + const NSRect decorationFrame = NSInsetRect(frame, 0.0, kBubbleYInset); + + // The inset is to put the border down the middle of the pixel. + NSRect bubbleFrame = NSInsetRect(decorationFrame, 0.5, 0.5); + bubbleFrame.origin.x -= kLeftSideOverdraw; + bubbleFrame.size.width += kLeftSideOverdraw; + NSBezierPath* path = + [NSBezierPath bezierPathWithRoundedRect:bubbleFrame + xRadius:kBubbleCornerRadius + yRadius:kBubbleCornerRadius]; + + [background_color_ setFill]; + [path fill]; + + [border_color_ setStroke]; + [path setLineWidth:1.0]; + [path stroke]; + + NSRect imageRect = decorationFrame; + if (image_) { + // Center the image vertically. + const NSSize imageSize = [image_ size]; + imageRect.origin.y += + std::floor((NSHeight(decorationFrame) - imageSize.height) / 2.0); + imageRect.size = imageSize; + [image_ drawInRect:imageRect + fromRect:NSZeroRect // Entire image + operation:NSCompositeSourceOver + fraction:1.0 + neverFlipped:YES]; + } else { + imageRect.size = NSZeroSize; + } + + if (label_) { + NSRect textRect = decorationFrame; + textRect.origin.x = NSMaxX(imageRect); + textRect.size.width = NSMaxX(decorationFrame) - NSMinX(textRect); + [label_ drawInRect:textRect withAttributes:attributes_]; + } +} + +NSImage* BubbleDecoration::GetImage() { + return image_; +} + +void BubbleDecoration::SetImage(NSImage* image) { + image_.reset([image retain]); +} + +void BubbleDecoration::SetLabel(NSString* label) { + // If the initializer was called with |nil|, then the code cannot + // process a label. + DCHECK(attributes_); + if (attributes_) + label_.reset([label copy]); +} + +void BubbleDecoration::SetColors(NSColor* border_color, + NSColor* background_color, + NSColor* text_color) { + border_color_.reset([border_color retain]); + background_color_.reset([background_color retain]); + + scoped_nsobject<NSMutableDictionary> attributes([attributes_ mutableCopy]); + [attributes setObject:text_color forKey:NSForegroundColorAttributeName]; + attributes_.reset([attributes copy]); +} diff --git a/chrome/browser/ui/cocoa/location_bar/content_setting_decoration.h b/chrome/browser/ui/cocoa/location_bar/content_setting_decoration.h new file mode 100644 index 0000000..07b1510 --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/content_setting_decoration.h @@ -0,0 +1,55 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_LOCATION_BAR_CONTENT_SETTING_DECORATION_H_ +#define CHROME_BROWSER_UI_COCOA_LOCATION_BAR_CONTENT_SETTING_DECORATION_H_ +#pragma once + +#include "base/scoped_ptr.h" +#import "chrome/browser/ui/cocoa/location_bar/image_decoration.h" +#include "chrome/common/content_settings_types.h" + +// ContentSettingDecoration is used to display the content settings +// images on the current page. + +class ContentSettingImageModel; +class LocationBarViewMac; +class Profile; +class TabContents; + +class ContentSettingDecoration : public ImageDecoration { + public: + ContentSettingDecoration(ContentSettingsType settings_type, + LocationBarViewMac* owner, + Profile* profile); + virtual ~ContentSettingDecoration(); + + // Updates the image and visibility state based on the supplied TabContents. + // Returns true if the decoration's visible state changed. + bool UpdateFromTabContents(TabContents* tab_contents); + + // Overridden from |LocationBarDecoration| + virtual bool AcceptsMousePress() { return true; } + virtual bool OnMousePressed(NSRect frame); + virtual NSString* GetToolTip(); + + private: + // Helper to get where the bubble point should land. Similar to + // |PageActionDecoration| or |StarDecoration| (|LocationBarViewMac| + // calls those). + NSPoint GetBubblePointInFrame(NSRect frame); + + void SetToolTip(NSString* tooltip); + + scoped_ptr<ContentSettingImageModel> content_setting_image_model_; + + LocationBarViewMac* owner_; // weak + Profile* profile_; // weak + + scoped_nsobject<NSString> tooltip_; + + DISALLOW_COPY_AND_ASSIGN(ContentSettingDecoration); +}; + +#endif // CHROME_BROWSER_UI_COCOA_LOCATION_BAR_CONTENT_SETTING_DECORATION_H_ diff --git a/chrome/browser/ui/cocoa/location_bar/content_setting_decoration.mm b/chrome/browser/ui/cocoa/location_bar/content_setting_decoration.mm new file mode 100644 index 0000000..c803d13 --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/content_setting_decoration.mm @@ -0,0 +1,109 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/location_bar/content_setting_decoration.h" + +#include "app/resource_bundle.h" +#include "base/sys_string_conversions.h" +#include "base/utf_string_conversions.h" +#include "chrome/browser/browser_list.h" +#include "chrome/browser/content_setting_image_model.h" +#include "chrome/browser/content_setting_bubble_model.h" +#include "chrome/browser/prefs/pref_service.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/tab_contents/tab_contents.h" +#import "chrome/browser/ui/cocoa/content_setting_bubble_cocoa.h" +#import "chrome/browser/ui/cocoa/location_bar/location_bar_view_mac.h" +#include "chrome/common/pref_names.h" +#include "net/base/net_util.h" + +namespace { + +// How far to offset up from the bottom of the view to get the top +// border of the popup 2px below the bottom of the Omnibox. +const CGFloat kPopupPointYOffset = 2.0; + +} // namespace + +ContentSettingDecoration::ContentSettingDecoration( + ContentSettingsType settings_type, + LocationBarViewMac* owner, + Profile* profile) + : content_setting_image_model_( + ContentSettingImageModel::CreateContentSettingImageModel( + settings_type)), + owner_(owner), + profile_(profile) { +} + +ContentSettingDecoration::~ContentSettingDecoration() { +} + +bool ContentSettingDecoration::UpdateFromTabContents( + TabContents* tab_contents) { + bool was_visible = IsVisible(); + int old_icon = content_setting_image_model_->get_icon(); + content_setting_image_model_->UpdateFromTabContents(tab_contents); + SetVisible(content_setting_image_model_->is_visible()); + bool decoration_changed = was_visible != IsVisible() || + old_icon != content_setting_image_model_->get_icon(); + if (IsVisible()) { + // TODO(thakis): We should use pdfs for these icons on OSX. + // http://crbug.com/35847 + ResourceBundle& rb = ResourceBundle::GetSharedInstance(); + SetImage(rb.GetNativeImageNamed(content_setting_image_model_->get_icon())); + SetToolTip(base::SysUTF8ToNSString( + content_setting_image_model_->get_tooltip())); + } + return decoration_changed; +} + +NSPoint ContentSettingDecoration::GetBubblePointInFrame(NSRect frame) { + const NSRect draw_frame = GetDrawRectInFrame(frame); + return NSMakePoint(NSMidX(draw_frame), + NSMaxY(draw_frame) - kPopupPointYOffset); +} + +bool ContentSettingDecoration::OnMousePressed(NSRect frame) { + // Get host. This should be shared on linux/win/osx medium-term. + TabContents* tabContents = + BrowserList::GetLastActive()->GetSelectedTabContents(); + if (!tabContents) + return true; + + GURL url = tabContents->GetURL(); + std::wstring displayHost; + net::AppendFormattedHost( + url, + UTF8ToWide(profile_->GetPrefs()->GetString(prefs::kAcceptLanguages)), + &displayHost, NULL, NULL); + + // Find point for bubble's arrow in screen coordinates. + // TODO(shess): |owner_| is only being used to fetch |field|. + // Consider passing in |control_view|. Or refactoring to be + // consistent with other decorations (which don't currently bring up + // their bubble directly). + AutocompleteTextField* field = owner_->GetAutocompleteTextField(); + NSPoint anchor = GetBubblePointInFrame(frame); + anchor = [field convertPoint:anchor toView:nil]; + anchor = [[field window] convertBaseToScreen:anchor]; + + // Open bubble. + ContentSettingBubbleModel* model = + ContentSettingBubbleModel::CreateContentSettingBubbleModel( + tabContents, profile_, + content_setting_image_model_->get_content_settings_type()); + [ContentSettingBubbleController showForModel:model + parentWindow:[field window] + anchoredAt:anchor]; + return true; +} + +NSString* ContentSettingDecoration::GetToolTip() { + return tooltip_.get(); +} + +void ContentSettingDecoration::SetToolTip(NSString* tooltip) { + tooltip_.reset([tooltip retain]); +} diff --git a/chrome/browser/ui/cocoa/location_bar/ev_bubble_decoration.h b/chrome/browser/ui/cocoa/location_bar/ev_bubble_decoration.h new file mode 100644 index 0000000..fb13de1 --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/ev_bubble_decoration.h @@ -0,0 +1,59 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_LOCATION_BAR_EV_BUBBLE_DECORATION_H_ +#define CHROME_BROWSER_UI_COCOA_LOCATION_BAR_EV_BUBBLE_DECORATION_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "chrome/browser/ui/cocoa/location_bar/bubble_decoration.h" + +// Draws the "Extended Validation SSL" bubble. This will be a lock +// icon plus a label from the certification, and will replace the +// location icon for URLs which have an EV cert. The |location_icon| +// is used to fulfill drag-related calls. + +// TODO(shess): Refactor to pull the |location_icon| functionality out +// into a distinct class like views |ClickHandler|. +// http://crbug.com/48866 + +class LocationIconDecoration; + +class EVBubbleDecoration : public BubbleDecoration { + public: + EVBubbleDecoration(LocationIconDecoration* location_icon, NSFont* font); + + // |GetWidthForSpace()| will set |full_label| as the label, if it + // fits, else it will set an elided version. + void SetFullLabel(NSString* full_label); + + // Get the point where the page info bubble should point within the + // decoration's frame, in the cell's coordinates. + NSPoint GetBubblePointInFrame(NSRect frame); + + // Implement |LocationBarDecoration|. + virtual CGFloat GetWidthForSpace(CGFloat width); + virtual bool IsDraggable(); + virtual NSPasteboard* GetDragPasteboard(); + virtual NSImage* GetDragImage(); + virtual NSRect GetDragImageFrame(NSRect frame) { + return GetImageRectInFrame(frame); + } + virtual bool OnMousePressed(NSRect frame); + virtual bool AcceptsMousePress() { return true; } + + private: + // Keeps a reference to the font for use when eliding. + scoped_nsobject<NSFont> font_; + + // The real label. BubbleDecoration's label may be elided. + scoped_nsobject<NSString> full_label_; + + LocationIconDecoration* location_icon_; // weak, owned by location bar. + + DISALLOW_COPY_AND_ASSIGN(EVBubbleDecoration); +}; + +#endif // CHROME_BROWSER_UI_COCOA_LOCATION_BAR_EV_BUBBLE_DECORATION_H_ diff --git a/chrome/browser/ui/cocoa/location_bar/ev_bubble_decoration.mm b/chrome/browser/ui/cocoa/location_bar/ev_bubble_decoration.mm new file mode 100644 index 0000000..ad384f9 --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/ev_bubble_decoration.mm @@ -0,0 +1,117 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/location_bar/ev_bubble_decoration.h" + +#include "app/text_elider.h" +#import "base/logging.h" +#include "base/sys_string_conversions.h" +#import "chrome/browser/ui/cocoa/image_utils.h" +#import "chrome/browser/ui/cocoa/location_bar/location_icon_decoration.h" +#include "gfx/font.h" + +namespace { + +// TODO(shess): In general, decorations that don't fit in the +// available space are omitted. This one never goes to omitted, it +// sticks at 150px, which AFAICT follows the Windows code. Since the +// Layout() code doesn't take this into account, it's possible the +// control could end up with display artifacts, though things still +// work (and don't crash). +// http://crbug.com/49822 + +// Minimum acceptable width for the ev bubble. +const CGFloat kMinElidedBubbleWidth = 150.0; + +// Maximum amount of available space to make the bubble, subject to +// |kMinElidedBubbleWidth|. +const float kMaxBubbleFraction = 0.5; + +// The info-bubble point should look like it points to the bottom of the lock +// icon. Determined with Pixie.app. +const CGFloat kPageInfoBubblePointYOffset = 6.0; + +// TODO(shess): This is ugly, find a better way. Using it right now +// so that I can crib from gtk and still be able to see that I'm using +// the same values easily. +NSColor* ColorWithRGBBytes(int rr, int gg, int bb) { + DCHECK_LE(rr, 255); + DCHECK_LE(bb, 255); + DCHECK_LE(gg, 255); + return [NSColor colorWithCalibratedRed:static_cast<float>(rr)/255.0 + green:static_cast<float>(gg)/255.0 + blue:static_cast<float>(bb)/255.0 + alpha:1.0]; +} + +} // namespace + +EVBubbleDecoration::EVBubbleDecoration( + LocationIconDecoration* location_icon, + NSFont* font) + : BubbleDecoration(font), + font_([font retain]), + location_icon_(location_icon) { + // Color tuples stolen from location_bar_view_gtk.cc. + NSColor* border_color = ColorWithRGBBytes(0x90, 0xc3, 0x90); + NSColor* background_color = ColorWithRGBBytes(0xef, 0xfc, 0xef); + NSColor* text_color = ColorWithRGBBytes(0x07, 0x95, 0x00); + SetColors(border_color, background_color, text_color); +} + +void EVBubbleDecoration::SetFullLabel(NSString* label) { + full_label_.reset([label retain]); + SetLabel(full_label_); +} + +NSPoint EVBubbleDecoration::GetBubblePointInFrame(NSRect frame) { + NSRect image_rect = GetImageRectInFrame(frame); + return NSMakePoint(NSMidX(image_rect), + NSMaxY(image_rect) - kPageInfoBubblePointYOffset); +} + +CGFloat EVBubbleDecoration::GetWidthForSpace(CGFloat width) { + // Limit with to not take up too much of the available width, but + // also don't let it shrink too much. + width = std::max(width * kMaxBubbleFraction, kMinElidedBubbleWidth); + + // Use the full label if it fits. + NSImage* image = GetImage(); + const CGFloat all_width = GetWidthForImageAndLabel(image, full_label_); + if (all_width <= width) { + SetLabel(full_label_); + return all_width; + } + + // Width left for laying out the label. + const CGFloat width_left = width - GetWidthForImageAndLabel(image, @""); + + // Middle-elide the label to fit |width_left|. This leaves the + // prefix and the trailing country code in place. + gfx::Font font(base::SysNSStringToWide([font_ fontName]), + [font_ pointSize]); + NSString* elided_label = base::SysUTF16ToNSString( + ElideText(base::SysNSStringToUTF16(full_label_), font, width_left, true)); + + // Use the elided label. + SetLabel(elided_label); + return GetWidthForImageAndLabel(image, elided_label); +} + +// Pass mouse operations through to location icon. +bool EVBubbleDecoration::IsDraggable() { + return location_icon_->IsDraggable(); +} + +NSPasteboard* EVBubbleDecoration::GetDragPasteboard() { + return location_icon_->GetDragPasteboard(); +} + +NSImage* EVBubbleDecoration::GetDragImage() { + return location_icon_->GetDragImage(); +} + +bool EVBubbleDecoration::OnMousePressed(NSRect frame) { + return location_icon_->OnMousePressed(frame); +} diff --git a/chrome/browser/ui/cocoa/location_bar/ev_bubble_decoration_unittest.mm b/chrome/browser/ui/cocoa/location_bar/ev_bubble_decoration_unittest.mm new file mode 100644 index 0000000..0506a72 --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/ev_bubble_decoration_unittest.mm @@ -0,0 +1,55 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#import "chrome/browser/ui/cocoa/location_bar/ev_bubble_decoration.h" + +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "testing/gtest/include/gtest/gtest.h" + +namespace { + +class EVBubbleDecorationTest : public CocoaTest { + public: + EVBubbleDecorationTest() + : decoration_(NULL, [NSFont userFontOfSize:12]) { + } + + EVBubbleDecoration decoration_; +}; + +// Test that the decoration gets smaller when there's not enough space +// to fit, within bounds. +TEST_F(EVBubbleDecorationTest, MiddleElide) { + NSString* kLongString = @"A very long string with spaces"; + const CGFloat kWide = 1000.0; // Wide enough to fit everything. + const CGFloat kNarrow = 10.0; // Too narrow for anything. + const CGFloat kMinimumWidth = 100.0; // Never should get this small. + + const NSSize kImageSize = NSMakeSize(20.0, 20.0); + scoped_nsobject<NSImage> image([[NSImage alloc] initWithSize:kImageSize]); + + decoration_.SetImage(image); + decoration_.SetFullLabel(kLongString); + + // Lots of space, decoration not omitted. + EXPECT_NE(decoration_.GetWidthForSpace(kWide), + LocationBarDecoration::kOmittedWidth); + + // If the available space is of the same magnitude as the required + // space, the decoration doesn't eat it all up. + const CGFloat long_width = decoration_.GetWidthForSpace(kWide); + EXPECT_NE(decoration_.GetWidthForSpace(long_width + 20.0), + LocationBarDecoration::kOmittedWidth); + EXPECT_LT(decoration_.GetWidthForSpace(long_width + 20.0), long_width); + + // If there is very little space, the decoration is still relatively + // big. + EXPECT_NE(decoration_.GetWidthForSpace(kNarrow), + LocationBarDecoration::kOmittedWidth); + EXPECT_GT(decoration_.GetWidthForSpace(kNarrow), kMinimumWidth); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/location_bar/image_decoration.h b/chrome/browser/ui/cocoa/location_bar/image_decoration.h new file mode 100644 index 0000000..c0bcfbf --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/image_decoration.h @@ -0,0 +1,36 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_LOCATION_BAR_IMAGE_DECORATION_H_ +#define CHROME_BROWSER_UI_COCOA_LOCATION_BAR_IMAGE_DECORATION_H_ +#pragma once + +#import "base/scoped_nsobject.h" +#include "chrome/browser/ui/cocoa/location_bar/location_bar_decoration.h" + +// |LocationBarDecoration| which sizes and draws itself according to +// an |NSImage|. + +class ImageDecoration : public LocationBarDecoration { + public: + ImageDecoration(); + virtual ~ImageDecoration(); + + NSImage* GetImage(); + void SetImage(NSImage* image); + + // Returns the part of |frame| the image is drawn in. + NSRect GetDrawRectInFrame(NSRect frame); + + // Implement |LocationBarDecoration|. + virtual CGFloat GetWidthForSpace(CGFloat width); + virtual void DrawInFrame(NSRect frame, NSView* control_view); + + private: + scoped_nsobject<NSImage> image_; + + DISALLOW_COPY_AND_ASSIGN(ImageDecoration); +}; + +#endif // CHROME_BROWSER_UI_COCOA_LOCATION_BAR_IMAGE_DECORATION_H_ diff --git a/chrome/browser/ui/cocoa/location_bar/image_decoration.mm b/chrome/browser/ui/cocoa/location_bar/image_decoration.mm new file mode 100644 index 0000000..8056a4e --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/image_decoration.mm @@ -0,0 +1,54 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include <cmath> + +#import "chrome/browser/ui/cocoa/location_bar/image_decoration.h" + +#import "chrome/browser/ui/cocoa/image_utils.h" + +ImageDecoration::ImageDecoration() { +} + +ImageDecoration::~ImageDecoration() { +} + +NSImage* ImageDecoration::GetImage() { + return image_; +} + +void ImageDecoration::SetImage(NSImage* image) { + image_.reset([image retain]); +} + +NSRect ImageDecoration::GetDrawRectInFrame(NSRect frame) { + NSImage* image = GetImage(); + if (!image) + return frame; + + // Center the image within the frame. + const CGFloat delta_height = NSHeight(frame) - [image size].height; + const CGFloat y_inset = std::floor(delta_height / 2.0); + const CGFloat delta_width = NSWidth(frame) - [image size].width; + const CGFloat x_inset = std::floor(delta_width / 2.0); + return NSInsetRect(frame, x_inset, y_inset); +} + +CGFloat ImageDecoration::GetWidthForSpace(CGFloat width) { + NSImage* image = GetImage(); + if (image) { + const CGFloat image_width = [image size].width; + if (image_width <= width) + return image_width; + } + return kOmittedWidth; +} + +void ImageDecoration::DrawInFrame(NSRect frame, NSView* control_view) { + [GetImage() drawInRect:GetDrawRectInFrame(frame) + fromRect:NSZeroRect // Entire image + operation:NSCompositeSourceOver + fraction:1.0 + neverFlipped:YES]; +} diff --git a/chrome/browser/ui/cocoa/location_bar/image_decoration_unittest.mm b/chrome/browser/ui/cocoa/location_bar/image_decoration_unittest.mm new file mode 100644 index 0000000..db69b0d --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/image_decoration_unittest.mm @@ -0,0 +1,55 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#import "chrome/browser/ui/cocoa/location_bar/image_decoration.h" + +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "testing/gtest/include/gtest/gtest.h" + +namespace { + +class ImageDecorationTest : public CocoaTest { + public: + ImageDecoration decoration_; +}; + +TEST_F(ImageDecorationTest, SetGetImage) { + EXPECT_FALSE(decoration_.GetImage()); + + const NSSize kImageSize = NSMakeSize(20.0, 20.0); + scoped_nsobject<NSImage> image([[NSImage alloc] initWithSize:kImageSize]); + + decoration_.SetImage(image); + EXPECT_EQ(decoration_.GetImage(), image); + + decoration_.SetImage(nil); + EXPECT_FALSE(decoration_.GetImage()); +} + +TEST_F(ImageDecorationTest, GetWidthForSpace) { + const CGFloat kWide = 100.0; + const CGFloat kNarrow = 10.0; + + // Decoration with no image is omitted. + EXPECT_EQ(decoration_.GetWidthForSpace(kWide), + LocationBarDecoration::kOmittedWidth); + + const NSSize kImageSize = NSMakeSize(20.0, 20.0); + scoped_nsobject<NSImage> image([[NSImage alloc] initWithSize:kImageSize]); + + // Decoration takes up the space of the image. + decoration_.SetImage(image); + EXPECT_EQ(decoration_.GetWidthForSpace(kWide), kImageSize.width); + + // If the image doesn't fit, decoration is omitted. + EXPECT_EQ(decoration_.GetWidthForSpace(kNarrow), + LocationBarDecoration::kOmittedWidth); +} + +// TODO(shess): It would be nice to test mouse clicks and dragging, +// but those are hard because they require a real |owner|. + +} // namespace diff --git a/chrome/browser/ui/cocoa/location_bar/instant_opt_in_controller.h b/chrome/browser/ui/cocoa/location_bar/instant_opt_in_controller.h new file mode 100644 index 0000000..97a6b3e --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/instant_opt_in_controller.h @@ -0,0 +1,43 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_LOCATION_BAR_INSTANT_OPT_IN_CONTROLLER_H_ +#define CHROME_BROWSER_UI_COCOA_LOCATION_BAR_INSTANT_OPT_IN_CONTROLLER_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +class Profile; + +// This delegate receives callbacks from the InstantOptInController when the OK +// and Cancel buttons are pushed. +class InstantOptInControllerDelegate { + public: + virtual void UserPressedOptIn(bool opt_in) = 0; + + protected: + virtual ~InstantOptInControllerDelegate() {} +}; + +// Manages an instant opt-in view, which is part of the omnibox popup. +@interface InstantOptInController : NSViewController { + @private + InstantOptInControllerDelegate* delegate_; // weak + + // Needed in order to localize text and resize to fit. + IBOutlet NSButton* okButton_; + IBOutlet NSButton* cancelButton_; + IBOutlet NSTextField* label_; +} + +// Designated initializer. +- (id)initWithDelegate:(InstantOptInControllerDelegate*)delegate; + +// Button actions. +- (IBAction)ok:(id)sender; +- (IBAction)cancel:(id)sender; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_LOCATION_BAR_INSTANT_OPT_IN_CONTROLLER_H_ diff --git a/chrome/browser/ui/cocoa/location_bar/instant_opt_in_controller.mm b/chrome/browser/ui/cocoa/location_bar/instant_opt_in_controller.mm new file mode 100644 index 0000000..17b0d15 --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/instant_opt_in_controller.mm @@ -0,0 +1,31 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/location_bar/instant_opt_in_controller.h" + +#include "base/mac_util.h" + +@implementation InstantOptInController + +- (id)initWithDelegate:(InstantOptInControllerDelegate*)delegate { + if ((self = [super initWithNibName:@"InstantOptIn" + bundle:mac_util::MainAppBundle()])) { + delegate_ = delegate; + } + return self; +} + +- (void)awakeFromNib { + // TODO(rohitrao): Translate and resize strings. +} + +- (IBAction)ok:(id)sender { + delegate_->UserPressedOptIn(true); +} + +- (IBAction)cancel:(id)sender { + delegate_->UserPressedOptIn(false); +} + +@end diff --git a/chrome/browser/ui/cocoa/location_bar/instant_opt_in_controller_unittest.mm b/chrome/browser/ui/cocoa/location_bar/instant_opt_in_controller_unittest.mm new file mode 100644 index 0000000..81ef513 --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/instant_opt_in_controller_unittest.mm @@ -0,0 +1,62 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/scoped_nsobject.h" +#include "base/scoped_ptr.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/location_bar/instant_opt_in_controller.h" +#include "testing/gmock/include/gmock/gmock.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +@interface InstantOptInController (ExposedForTesting) +- (NSButton*)okButton; +- (NSButton*)cancelButton; +@end + +@implementation InstantOptInController (ExposedForTesting) +- (NSButton*)okButton { + return okButton_; +} + +- (NSButton*)cancelButton { + return cancelButton_; +} +@end + + +namespace { + +class MockDelegate : public InstantOptInControllerDelegate { + public: + MOCK_METHOD1(UserPressedOptIn, void(bool opt_in)); +}; + +class InstantOptInControllerTest : public CocoaTest { + public: + void SetUp() { + CocoaTest::SetUp(); + + controller_.reset( + [[InstantOptInController alloc] initWithDelegate:&delegate_]); + + NSView* parent = [test_window() contentView]; + [parent addSubview:[controller_ view]]; + } + + MockDelegate delegate_; + scoped_nsobject<InstantOptInController> controller_; +}; + +TEST_F(InstantOptInControllerTest, OkButtonCallback) { + EXPECT_CALL(delegate_, UserPressedOptIn(true)); + [[controller_ okButton] performClick:nil]; +} + +TEST_F(InstantOptInControllerTest, CancelButtonCallback) { + EXPECT_CALL(delegate_, UserPressedOptIn(false)); + [[controller_ cancelButton] performClick:nil]; +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/location_bar/instant_opt_in_view.h b/chrome/browser/ui/cocoa/location_bar/instant_opt_in_view.h new file mode 100644 index 0000000..b9ef6bd --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/instant_opt_in_view.h @@ -0,0 +1,16 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_LOCATION_BAR_INSTANT_OPT_IN_VIEW_H_ +#define CHROME_BROWSER_UI_COCOA_LOCATION_BAR_INSTANT_OPT_IN_VIEW_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +// The instant opt in view that is embedded in the omnibox. Draws rounded +// bottom corners and a horizontal gray line at the top. +@interface InstantOptInView : NSView +@end + +#endif // CHROME_BROWSER_UI_COCOA_LOCATION_BAR_INSTANT_OPT_IN_VIEW_H_ diff --git a/chrome/browser/ui/cocoa/location_bar/instant_opt_in_view.mm b/chrome/browser/ui/cocoa/location_bar/instant_opt_in_view.mm new file mode 100644 index 0000000..06ca79d --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/instant_opt_in_view.mm @@ -0,0 +1,54 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include <algorithm> + +#import "chrome/browser/ui/cocoa/location_bar/instant_opt_in_view.h" +#import "third_party/GTM/AppKit/GTMNSBezierPath+RoundRect.h" + +namespace { +// How to round off the popup's corners. Goal is to match star and go +// buttons. +const CGFloat kPopupRoundingRadius = 3.5; + +// How far from the top of the view to place the horizontal line. +const CGFloat kHorizontalLineTopOffset = 2; + +// How far from the sides to inset the horizontal line. +const CGFloat kHorizontalLineInset = 2; +} + +@implementation InstantOptInView + +- (void)drawRect:(NSRect)rect { + // Round off the bottom corners only. + NSBezierPath* path = + [NSBezierPath gtm_bezierPathWithRoundRect:[self bounds] + topLeftCornerRadius:0 + topRightCornerRadius:0 + bottomLeftCornerRadius:kPopupRoundingRadius + bottomRightCornerRadius:kPopupRoundingRadius]; + + [NSGraphicsContext saveGraphicsState]; + [path addClip]; + + // Background is white. + [[NSColor whiteColor] set]; + NSRectFill(rect); + + // Draw a horizontal line 2 px down from the top of the view, inset at the + // sides by 2 px. + CGFloat lineY = NSMaxY([self bounds]) - kHorizontalLineTopOffset; + CGFloat minX = std::min(NSMinX([self bounds]) + kHorizontalLineInset, + NSMaxX([self bounds])); + CGFloat maxX = std::max(NSMaxX([self bounds]) - kHorizontalLineInset, + NSMinX([self bounds])); + + [[NSColor lightGrayColor] set]; + NSRectFill(NSMakeRect(minX, lineY, maxX - minX, 1)); + + [NSGraphicsContext restoreGraphicsState]; +} + +@end diff --git a/chrome/browser/ui/cocoa/location_bar/instant_opt_in_view_unittest.mm b/chrome/browser/ui/cocoa/location_bar/instant_opt_in_view_unittest.mm new file mode 100644 index 0000000..ce5ff48 --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/instant_opt_in_view_unittest.mm @@ -0,0 +1,26 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/location_bar/instant_opt_in_view.h" + +namespace { + +class InstantOptInViewTest : public CocoaTest { + public: + InstantOptInViewTest() { + NSRect content_frame = [[test_window() contentView] frame]; + scoped_nsobject<InstantOptInView> view( + [[InstantOptInView alloc] initWithFrame:content_frame]); + view_ = view.get(); + [[test_window() contentView] addSubview:view_]; + } + + InstantOptInView* view_; // Weak. Owned by the view hierarchy. +}; + +// Tests display, add/remove. +TEST_VIEW(InstantOptInViewTest, view_); + +} // namespace diff --git a/chrome/browser/ui/cocoa/location_bar/keyword_hint_decoration.h b/chrome/browser/ui/cocoa/location_bar/keyword_hint_decoration.h new file mode 100644 index 0000000..3b8c607 --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/keyword_hint_decoration.h @@ -0,0 +1,47 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_LOCATION_BAR_KEYWORD_HINT_DECORATION_H_ +#define CHROME_BROWSER_UI_COCOA_LOCATION_BAR_KEYWORD_HINT_DECORATION_H_ +#pragma once + +#include <string> + +#import "chrome/browser/ui/cocoa/location_bar/location_bar_decoration.h" + +#import "base/scoped_nsobject.h" + +// Draws the keyword hint, "Press [tab] to search <site>". + +class KeywordHintDecoration : public LocationBarDecoration { + public: + KeywordHintDecoration(NSFont* font); + virtual ~KeywordHintDecoration(); + + // Calculates the message to display and where to place the [tab] + // image. + void SetKeyword(const std::wstring& keyword, bool is_extension_keyword); + + // Implement |LocationBarDecoration|. + virtual void DrawInFrame(NSRect frame, NSView* control_view); + virtual CGFloat GetWidthForSpace(CGFloat width); + + private: + // Fetch and cache the [tab] image. + NSImage* GetHintImage(); + + // Attributes for drawing the hint string, such as font and color. + scoped_nsobject<NSDictionary> attributes_; + + // Cache for the [tab] image. + scoped_nsobject<NSImage> hint_image_; + + // The text to display to the left and right of the hint image. + scoped_nsobject<NSString> hint_prefix_; + scoped_nsobject<NSString> hint_suffix_; + + DISALLOW_COPY_AND_ASSIGN(KeywordHintDecoration); +}; + +#endif // CHROME_BROWSER_UI_COCOA_LOCATION_BAR_KEYWORD_HINT_DECORATION_H_ diff --git a/chrome/browser/ui/cocoa/location_bar/keyword_hint_decoration.mm b/chrome/browser/ui/cocoa/location_bar/keyword_hint_decoration.mm new file mode 100644 index 0000000..0b080319 --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/keyword_hint_decoration.mm @@ -0,0 +1,160 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include <cmath> + +#import "chrome/browser/ui/cocoa/location_bar/keyword_hint_decoration.h" + +#include "app/l10n_util.h" +#include "app/resource_bundle.h" +#include "base/logging.h" +#include "base/string_util.h" +#include "base/sys_string_conversions.h" +#import "chrome/browser/ui/cocoa/image_utils.h" +#include "grit/theme_resources.h" +#include "grit/generated_resources.h" +#include "skia/ext/skia_utils_mac.h" + +namespace { + +// How far to inset the hint text area from sides. +const CGFloat kHintTextYInset = 4.0; + +// How far to inset the hint image from sides. Lines baseline of text +// in image with baseline of prefix and suffix. +const CGFloat kHintImageYInset = 4.0; + +// Extra padding right and left of the image. +const CGFloat kHintImagePadding = 1.0; + +// Maxmimum of the available space to allow the hint to take over. +// Should leave enough so that the user has space to edit things. +const CGFloat kHintAvailableRatio = 2.0 / 3.0; + +// Helper to convert |s| to an |NSString|, trimming whitespace at +// ends. +NSString* TrimAndConvert(const std::wstring& s) { + std::wstring output; + TrimWhitespace(s, TRIM_ALL, &output); + return base::SysWideToNSString(output); +} + +} // namespace + +KeywordHintDecoration::KeywordHintDecoration(NSFont* font) { + NSColor* text_color = [NSColor lightGrayColor]; + NSDictionary* attributes = + [NSDictionary dictionaryWithObjectsAndKeys: + font, NSFontAttributeName, + text_color, NSForegroundColorAttributeName, + nil]; + attributes_.reset([attributes retain]); +} + +KeywordHintDecoration::~KeywordHintDecoration() { +} + +NSImage* KeywordHintDecoration::GetHintImage() { + if (!hint_image_) { + SkBitmap* skiaBitmap = ResourceBundle::GetSharedInstance(). + GetBitmapNamed(IDR_LOCATION_BAR_KEYWORD_HINT_TAB); + if (skiaBitmap) + hint_image_.reset([gfx::SkBitmapToNSImage(*skiaBitmap) retain]); + } + return hint_image_; +} + +void KeywordHintDecoration::SetKeyword(const std::wstring& short_name, + bool is_extension_keyword) { + // KEYWORD_HINT is a message like "Press [tab] to search <site>". + // [tab] is a parameter to be replaced by an image. "<site>" is + // derived from |short_name|. + std::vector<size_t> content_param_offsets; + int message_id = is_extension_keyword ? + IDS_OMNIBOX_EXTENSION_KEYWORD_HINT : IDS_OMNIBOX_KEYWORD_HINT; + const std::wstring keyword_hint( + l10n_util::GetStringF(message_id, + std::wstring(), short_name, + &content_param_offsets)); + + // Should always be 2 offsets, see the comment in + // location_bar_view.cc after IDS_OMNIBOX_KEYWORD_HINT fetch. + DCHECK_EQ(content_param_offsets.size(), 2U); + + // Where to put the [tab] image. + const size_t split = content_param_offsets.front(); + + // Trim the spaces from the edges (there is space in the image) and + // convert to |NSString|. + hint_prefix_.reset([TrimAndConvert(keyword_hint.substr(0, split)) retain]); + hint_suffix_.reset([TrimAndConvert(keyword_hint.substr(split)) retain]); +} + +CGFloat KeywordHintDecoration::GetWidthForSpace(CGFloat width) { + NSImage* image = GetHintImage(); + const CGFloat image_width = image ? [image size].width : 0.0; + + // AFAICT, on Windows the choices are "everything" if it fits, then + // "image only" if it fits. + + // Entirely too small to fit, omit. + if (width < image_width) + return kOmittedWidth; + + // Show the full hint if it won't take up too much space. The image + // needs to be placed at a pixel boundary, round the text widths so + // that any partially-drawn pixels don't look too close (or too + // far). + CGFloat full_width = + std::floor([hint_prefix_ sizeWithAttributes:attributes_].width + 0.5) + + kHintImagePadding + image_width + kHintImagePadding + + std::floor([hint_suffix_ sizeWithAttributes:attributes_].width + 0.5); + if (full_width <= width * kHintAvailableRatio) + return full_width; + + return image_width; +} + +void KeywordHintDecoration::DrawInFrame(NSRect frame, NSView* control_view) { + NSImage* image = GetHintImage(); + const CGFloat image_width = image ? [image size].width : 0.0; + + const bool draw_full = NSWidth(frame) > image_width; + + if (draw_full) { + NSRect prefix_rect = NSInsetRect(frame, 0.0, kHintTextYInset); + const CGFloat prefix_width = + [hint_prefix_ sizeWithAttributes:attributes_].width; + DCHECK_GE(NSWidth(prefix_rect), prefix_width); + [hint_prefix_ drawInRect:prefix_rect withAttributes:attributes_]; + + // The image should be drawn at a pixel boundary, round the prefix + // so that partial pixels aren't oddly close (or distant). + frame.origin.x += std::floor(prefix_width + 0.5) + kHintImagePadding; + frame.size.width -= std::floor(prefix_width + 0.5) + kHintImagePadding; + } + + NSRect image_rect = NSInsetRect(frame, 0.0, kHintImageYInset); + image_rect.size = [image size]; + [image drawInRect:image_rect + fromRect:NSZeroRect // Entire image + operation:NSCompositeSourceOver + fraction:1.0 + neverFlipped:YES]; + frame.origin.x += NSWidth(image_rect); + frame.size.width -= NSWidth(image_rect); + + if (draw_full) { + NSRect suffix_rect = NSInsetRect(frame, 0.0, kHintTextYInset); + const CGFloat suffix_width = + [hint_suffix_ sizeWithAttributes:attributes_].width; + + // Right-justify the text within the remaining space, so it + // doesn't get too close to the image relative to a following + // decoration. + suffix_rect.origin.x = NSMaxX(suffix_rect) - suffix_width; + DCHECK_GE(NSWidth(suffix_rect), suffix_width); + [hint_suffix_ drawInRect:suffix_rect withAttributes:attributes_]; + } +} diff --git a/chrome/browser/ui/cocoa/location_bar/keyword_hint_decoration_unittest.mm b/chrome/browser/ui/cocoa/location_bar/keyword_hint_decoration_unittest.mm new file mode 100644 index 0000000..bfcf454 --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/keyword_hint_decoration_unittest.mm @@ -0,0 +1,57 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#import "chrome/browser/ui/cocoa/location_bar/keyword_hint_decoration.h" + +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "testing/gtest/include/gtest/gtest.h" + +namespace { + +class KeywordHintDecorationTest : public CocoaTest { + public: + KeywordHintDecorationTest() + : decoration_(NULL) { + } + + KeywordHintDecoration decoration_; +}; + +TEST_F(KeywordHintDecorationTest, GetWidthForSpace) { + decoration_.SetVisible(true); + decoration_.SetKeyword(std::wstring(L"Google"), false); + + const CGFloat kVeryWide = 1000.0; + const CGFloat kFairlyWide = 100.0; // Estimate for full hint space. + const CGFloat kEditingSpace = 50.0; + + // Wider than the [tab] image when we have lots of space. + EXPECT_NE(decoration_.GetWidthForSpace(kVeryWide), + LocationBarDecoration::kOmittedWidth); + EXPECT_GE(decoration_.GetWidthForSpace(kVeryWide), kFairlyWide); + + // When there's not enough space for the text, trims to something + // narrower. + const CGFloat full_width = decoration_.GetWidthForSpace(kVeryWide); + const CGFloat not_wide_enough = full_width - 10.0; + EXPECT_NE(decoration_.GetWidthForSpace(not_wide_enough), + LocationBarDecoration::kOmittedWidth); + EXPECT_LT(decoration_.GetWidthForSpace(not_wide_enough), full_width); + + // Even trims when there's enough space for everything, but it would + // eat "too much". + EXPECT_NE(decoration_.GetWidthForSpace(full_width + kEditingSpace), + LocationBarDecoration::kOmittedWidth); + EXPECT_LT(decoration_.GetWidthForSpace(full_width + kEditingSpace), + full_width); + + // Omitted when not wide enough to fit even the image. + const CGFloat image_width = decoration_.GetWidthForSpace(not_wide_enough); + EXPECT_EQ(decoration_.GetWidthForSpace(image_width - 1.0), + LocationBarDecoration::kOmittedWidth); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/location_bar/location_bar_decoration.h b/chrome/browser/ui/cocoa/location_bar/location_bar_decoration.h new file mode 100644 index 0000000..5947edc --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/location_bar_decoration.h @@ -0,0 +1,89 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_LOCATION_BAR_LOCATION_BAR_DECORATION_H_ +#define CHROME_BROWSER_UI_COCOA_LOCATION_BAR_LOCATION_BAR_DECORATION_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#import "base/basictypes.h" + +// Base class for decorations at the left and right of the location +// bar. For instance, the location icon. + +// |LocationBarDecoration| and subclasses should approximately +// parallel the classes provided under views/location_bar/. The term +// "decoration" is used because "view" has strong connotations in +// Cocoa, and while these are view-like, they aren't views at all. +// Decorations are more like Cocoa cells, except implemented in C++ to +// allow more similarity to the other platform implementations. + +class LocationBarDecoration { + public: + LocationBarDecoration() + : visible_(false) { + } + virtual ~LocationBarDecoration() {} + + // Determines whether the decoration is visible. + virtual bool IsVisible() const { + return visible_; + } + virtual void SetVisible(bool visible) { + visible_ = visible; + } + + // Decorations can change their size to fit the available space. + // Returns the width the decoration will use in the space allotted, + // or |kOmittedWidth| if it should be omitted. + virtual CGFloat GetWidthForSpace(CGFloat width); + + // Draw the decoration in the frame provided. The frame will be + // generated from an earlier call to |GetWidthForSpace()|. + virtual void DrawInFrame(NSRect frame, NSView* control_view); + + // Returns the tooltip for this decoration, return |nil| for no tooltip. + virtual NSString* GetToolTip() { return nil; } + + // Decorations which do not accept mouse events are treated like the + // field's background for purposes of selecting text. When such + // decorations are adjacent to the text area, they will show the + // I-beam cursor. Decorations which do accept mouse events will get + // an arrow cursor when the mouse is over them. + virtual bool AcceptsMousePress() { return false; } + + // Determine if the item can act as a drag source. + virtual bool IsDraggable() { return false; } + + // The image to drag. + virtual NSImage* GetDragImage() { return nil; } + + // Return the place within the decoration's frame where the + // |GetDragImage()| comes from. This is used to make sure the image + // appears correctly under the mouse while dragging. |frame| + // matches the frame passed to |DrawInFrame()|. + virtual NSRect GetDragImageFrame(NSRect frame) { return NSZeroRect; } + + // The pasteboard to drag. + virtual NSPasteboard* GetDragPasteboard() { return nil; } + + // Called on mouse down. Return |false| to indicate that the press + // was not processed and should be handled by the cell. + virtual bool OnMousePressed(NSRect frame) { return false; } + + // Called to get the right-click menu, return |nil| for no menu. + virtual NSMenu* GetMenu() { return nil; } + + // Width returned by |GetWidthForSpace()| when the item should be + // omitted for this width; + static const CGFloat kOmittedWidth; + + private: + bool visible_; + + DISALLOW_COPY_AND_ASSIGN(LocationBarDecoration); +}; + +#endif // CHROME_BROWSER_UI_COCOA_LOCATION_BAR_LOCATION_BAR_DECORATION_H_ diff --git a/chrome/browser/ui/cocoa/location_bar/location_bar_decoration.mm b/chrome/browser/ui/cocoa/location_bar/location_bar_decoration.mm new file mode 100644 index 0000000..bbd9b0b --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/location_bar_decoration.mm @@ -0,0 +1,18 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/location_bar/location_bar_decoration.h" + +#include "base/logging.h" + +const CGFloat LocationBarDecoration::kOmittedWidth = 0.0; + +CGFloat LocationBarDecoration::GetWidthForSpace(CGFloat width) { + NOTREACHED(); + return kOmittedWidth; +} + +void LocationBarDecoration::DrawInFrame(NSRect frame, NSView* control_view) { + NOTREACHED(); +} diff --git a/chrome/browser/ui/cocoa/location_bar/location_bar_view_mac.h b/chrome/browser/ui/cocoa/location_bar/location_bar_view_mac.h new file mode 100644 index 0000000..7675165d --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/location_bar_view_mac.h @@ -0,0 +1,237 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_LOCATION_BAR_VIEW_MAC_H_ +#define CHROME_BROWSER_UI_COCOA_LOCATION_BAR_VIEW_MAC_H_ +#pragma once + +#include <string> + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#include "base/scoped_ptr.h" +#include "base/scoped_vector.h" +#include "chrome/browser/autocomplete/autocomplete_edit.h" +#include "chrome/browser/autocomplete/autocomplete_edit_view_mac.h" +#include "chrome/browser/extensions/image_loading_tracker.h" +#include "chrome/browser/first_run/first_run.h" +#include "chrome/browser/location_bar.h" +#include "chrome/browser/toolbar_model.h" +#include "chrome/common/content_settings_types.h" + +@class AutocompleteTextField; +class CommandUpdater; +class ContentSettingDecoration; +class ContentSettingImageModel; +class EVBubbleDecoration; +@class ExtensionPopupController; +class KeywordHintDecoration; +class LocationIconDecoration; +class PageActionDecoration; +class Profile; +class SelectedKeywordDecoration; +class SkBitmap; +class StarDecoration; +class ToolbarModel; + +// A C++ bridge class that represents the location bar UI element to +// the portable code. Wires up an AutocompleteEditViewMac instance to +// the location bar text field, which handles most of the work. + +class LocationBarViewMac : public AutocompleteEditController, + public LocationBar, + public LocationBarTesting, + public NotificationObserver { + public: + LocationBarViewMac(AutocompleteTextField* field, + CommandUpdater* command_updater, + ToolbarModel* toolbar_model, + Profile* profile, + Browser* browser); + virtual ~LocationBarViewMac(); + + // Overridden from LocationBar: + virtual void ShowFirstRunBubble(FirstRun::BubbleType bubble_type); + virtual void SetSuggestedText(const string16& text); + virtual std::wstring GetInputString() const; + virtual WindowOpenDisposition GetWindowOpenDisposition() const; + virtual PageTransition::Type GetPageTransition() const; + virtual void AcceptInput(); + virtual void FocusLocation(bool select_all); + virtual void FocusSearch(); + virtual void UpdateContentSettingsIcons(); + virtual void UpdatePageActions(); + virtual void InvalidatePageActions(); + virtual void SaveStateToContents(TabContents* contents); + virtual void Revert(); + virtual const AutocompleteEditView* location_entry() const { + return edit_view_.get(); + } + virtual AutocompleteEditView* location_entry() { + return edit_view_.get(); + } + virtual LocationBarTesting* GetLocationBarForTesting() { return this; } + + // Overridden from LocationBarTesting: + virtual int PageActionCount(); + virtual int PageActionVisibleCount(); + virtual ExtensionAction* GetPageAction(size_t index); + virtual ExtensionAction* GetVisiblePageAction(size_t index); + virtual void TestPageActionPressed(size_t index); + + // Set/Get the editable state of the field. + void SetEditable(bool editable); + bool IsEditable(); + + // Set the starred state of the bookmark star. + void SetStarred(bool starred); + + // Get the point on the star for the bookmark bubble to aim at. + NSPoint GetBookmarkBubblePoint() const; + + // Get the point in the security icon at which the page info bubble aims. + NSPoint GetPageInfoBubblePoint() const; + + // Get the point in the omnibox at which the first run bubble aims. + NSPoint GetFirstRunBubblePoint() const; + + // Updates the location bar. Resets the bar's permanent text and + // security style, and if |should_restore_state| is true, restores + // saved state from the tab (for tab switching). + void Update(const TabContents* tab, bool should_restore_state); + + // Layout the various decorations which live in the field. + void Layout(); + + // Returns the current TabContents. + TabContents* GetTabContents() const; + + // Sets preview_enabled_ for the PageActionImageView associated with this + // |page_action|. If |preview_enabled|, the location bar will display the + // PageAction icon even if it has not been activated by the extension. + // This is used by the ExtensionInstalledBubble to preview what the icon + // will look like for the user upon installation of the extension. + void SetPreviewEnabledPageAction(ExtensionAction* page_action, + bool preview_enabled); + + // Return |page_action|'s info-bubble point in window coordinates. + // This function should always be called with a visible page action. + // If |page_action| is not a page action or not visible, NOTREACHED() + // is called and this function returns |NSZeroPoint|. + NSPoint GetPageActionBubblePoint(ExtensionAction* page_action); + + // Get the blocked-popup content setting's frame in window + // coordinates. Used by the blocked-popup animation. Returns + // |NSZeroRect| if the relevant content setting decoration is not + // visible. + NSRect GetBlockedPopupRect() const; + + // AutocompleteEditController implementation. + virtual void OnAutocompleteWillClosePopup(); + virtual void OnAutocompleteLosingFocus(gfx::NativeView unused); + virtual void OnAutocompleteWillAccept(); + virtual bool OnCommitSuggestedText(const std::wstring& typed_text); + virtual void OnSetSuggestedSearchText(const string16& suggested_text); + virtual void OnPopupBoundsChanged(const gfx::Rect& bounds); + virtual void OnAutocompleteAccept(const GURL& url, + WindowOpenDisposition disposition, + PageTransition::Type transition, + const GURL& alternate_nav_url); + virtual void OnChanged(); + virtual void OnSelectionBoundsChanged(); + virtual void OnInputInProgress(bool in_progress); + virtual void OnKillFocus(); + virtual void OnSetFocus(); + virtual SkBitmap GetFavIcon() const; + virtual std::wstring GetTitle() const; + + NSImage* GetKeywordImage(const std::wstring& keyword); + + AutocompleteTextField* GetAutocompleteTextField() { return field_; } + + + // Overridden from NotificationObserver. + virtual void Observe(NotificationType type, + const NotificationSource& source, + const NotificationDetails& details); + + private: + // Posts |notification| to the default notification center. + void PostNotification(NSString* notification); + + // Return the decoration for |page_action|. + PageActionDecoration* GetPageActionDecoration(ExtensionAction* page_action); + + // Clear the page-action decorations. + void DeletePageActionDecorations(); + + // Re-generate the page-action decorations from the profile's + // extension service. + void RefreshPageActionDecorations(); + + // Updates visibility of the content settings icons based on the current + // tab contents state. + bool RefreshContentSettingsDecorations(); + + void ShowFirstRunBubbleInternal(FirstRun::BubbleType bubble_type); + + scoped_ptr<AutocompleteEditViewMac> edit_view_; + + CommandUpdater* command_updater_; // Weak, owned by Browser. + + AutocompleteTextField* field_; // owned by tab controller + + // When we get an OnAutocompleteAccept notification from the autocomplete + // edit, we save the input string so we can give it back to the browser on + // the LocationBar interface via GetInputString(). + std::wstring location_input_; + + // The user's desired disposition for how their input should be opened. + WindowOpenDisposition disposition_; + + // A decoration that shows an icon to the left of the address. + scoped_ptr<LocationIconDecoration> location_icon_decoration_; + + // A decoration that shows the keyword-search bubble on the left. + scoped_ptr<SelectedKeywordDecoration> selected_keyword_decoration_; + + // A decoration that shows a lock icon and ev-cert label in a bubble + // on the left. + scoped_ptr<EVBubbleDecoration> ev_bubble_decoration_; + + // Bookmark star right of page actions. + scoped_ptr<StarDecoration> star_decoration_; + + // Any installed Page Actions. + ScopedVector<PageActionDecoration> page_action_decorations_; + + // The content blocked decorations. + ScopedVector<ContentSettingDecoration> content_setting_decorations_; + + // Keyword hint decoration displayed on the right-hand side. + scoped_ptr<KeywordHintDecoration> keyword_hint_decoration_; + + Profile* profile_; + + Browser* browser_; + + ToolbarModel* toolbar_model_; // Weak, owned by Browser. + + // Whether or not to update the instant preview. + bool update_instant_; + + // The transition type to use for the navigation. + PageTransition::Type transition_; + + // Used to register for notifications received by NotificationObserver. + NotificationRegistrar registrar_; + + // Used to schedule a task for the first run info bubble. + ScopedRunnableMethodFactory<LocationBarViewMac> first_run_bubble_; + + DISALLOW_COPY_AND_ASSIGN(LocationBarViewMac); +}; + +#endif // CHROME_BROWSER_UI_COCOA_LOCATION_BAR_VIEW_MAC_H_ diff --git a/chrome/browser/ui/cocoa/location_bar/location_bar_view_mac.mm b/chrome/browser/ui/cocoa/location_bar/location_bar_view_mac.mm new file mode 100644 index 0000000..6fbac24 --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/location_bar_view_mac.mm @@ -0,0 +1,690 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/location_bar/location_bar_view_mac.h" + +#include "app/l10n_util_mac.h" +#include "app/resource_bundle.h" +#include "base/nsimage_cache_mac.h" +#include "base/stl_util-inl.h" +#include "base/string_util.h" +#include "base/sys_string_conversions.h" +#include "base/utf_string_conversions.h" +#include "chrome/app/chrome_command_ids.h" +#include "chrome/browser/alternate_nav_url_fetcher.h" +#import "chrome/browser/app_controller_mac.h" +#import "chrome/browser/autocomplete/autocomplete_edit_view_mac.h" +#import "chrome/browser/autocomplete/autocomplete_popup_model.h" +#include "chrome/browser/browser_list.h" +#include "chrome/browser/command_updater.h" +#include "chrome/browser/content_setting_image_model.h" +#include "chrome/browser/content_setting_bubble_model.h" +#include "chrome/browser/defaults.h" +#include "chrome/browser/extensions/extension_browser_event_router.h" +#include "chrome/browser/extensions/extensions_service.h" +#include "chrome/browser/extensions/extension_tabs_module.h" +#include "chrome/browser/instant/instant_controller.h" +#include "chrome/browser/location_bar_util.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/search_engines/template_url.h" +#include "chrome/browser/search_engines/template_url_model.h" +#include "chrome/browser/tab_contents/navigation_entry.h" +#include "chrome/browser/tab_contents/tab_contents.h" +#import "chrome/browser/ui/cocoa/content_setting_bubble_cocoa.h" +#include "chrome/browser/ui/cocoa/event_utils.h" +#import "chrome/browser/ui/cocoa/extensions/extension_action_context_menu.h" +#import "chrome/browser/ui/cocoa/extensions/extension_popup_controller.h" +#import "chrome/browser/ui/cocoa/first_run_bubble_controller.h" +#import "chrome/browser/ui/cocoa/location_bar/autocomplete_text_field.h" +#import "chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_cell.h" +#import "chrome/browser/ui/cocoa/location_bar/content_setting_decoration.h" +#import "chrome/browser/ui/cocoa/location_bar/ev_bubble_decoration.h" +#import "chrome/browser/ui/cocoa/location_bar/keyword_hint_decoration.h" +#import "chrome/browser/ui/cocoa/location_bar/location_icon_decoration.h" +#import "chrome/browser/ui/cocoa/location_bar/page_action_decoration.h" +#import "chrome/browser/ui/cocoa/location_bar/selected_keyword_decoration.h" +#import "chrome/browser/ui/cocoa/location_bar/star_decoration.h" +#include "chrome/common/extensions/extension.h" +#include "chrome/common/extensions/extension_action.h" +#include "chrome/common/extensions/extension_resource.h" +#include "chrome/common/notification_service.h" +#include "chrome/common/pref_names.h" +#include "net/base/net_util.h" +#include "grit/generated_resources.h" +#include "grit/theme_resources.h" +#include "skia/ext/skia_utils_mac.h" +#include "third_party/skia/include/core/SkBitmap.h" + +namespace { + +// Vertical space between the bottom edge of the location_bar and the first run +// bubble arrow point. +const static int kFirstRunBubbleYOffset = 1; + +} + +// TODO(shess): This code is mostly copied from the gtk +// implementation. Make sure it's all appropriate and flesh it out. + +LocationBarViewMac::LocationBarViewMac( + AutocompleteTextField* field, + CommandUpdater* command_updater, + ToolbarModel* toolbar_model, + Profile* profile, + Browser* browser) + : edit_view_(new AutocompleteEditViewMac(this, toolbar_model, profile, + command_updater, field)), + command_updater_(command_updater), + field_(field), + disposition_(CURRENT_TAB), + location_icon_decoration_(new LocationIconDecoration(this)), + selected_keyword_decoration_( + new SelectedKeywordDecoration( + AutocompleteEditViewMac::GetFieldFont())), + ev_bubble_decoration_( + new EVBubbleDecoration(location_icon_decoration_.get(), + AutocompleteEditViewMac::GetFieldFont())), + star_decoration_(new StarDecoration(command_updater)), + keyword_hint_decoration_( + new KeywordHintDecoration(AutocompleteEditViewMac::GetFieldFont())), + profile_(profile), + browser_(browser), + toolbar_model_(toolbar_model), + update_instant_(true), + transition_(PageTransition::TYPED), + first_run_bubble_(this) { + for (size_t i = 0; i < CONTENT_SETTINGS_NUM_TYPES; ++i) { + DCHECK_EQ(i, content_setting_decorations_.size()); + ContentSettingsType type = static_cast<ContentSettingsType>(i); + content_setting_decorations_.push_back( + new ContentSettingDecoration(type, this, profile_)); + } + + registrar_.Add(this, + NotificationType::EXTENSION_PAGE_ACTION_VISIBILITY_CHANGED, + NotificationService::AllSources()); +} + +LocationBarViewMac::~LocationBarViewMac() { + // Disconnect from cell in case it outlives us. + [[field_ cell] clearDecorations]; +} + +void LocationBarViewMac::ShowFirstRunBubble(FirstRun::BubbleType bubble_type) { + // We need the browser window to be shown before we can show the bubble, but + // we get called before that's happened. + Task* task = first_run_bubble_.NewRunnableMethod( + &LocationBarViewMac::ShowFirstRunBubbleInternal, bubble_type); + MessageLoop::current()->PostTask(FROM_HERE, task); +} + +void LocationBarViewMac::ShowFirstRunBubbleInternal( + FirstRun::BubbleType bubble_type) { + if (!field_ || ![field_ window]) + return; + + // The first run bubble's left edge should line up with the left edge of the + // omnibox. This is different from other bubbles, which line up at a point + // set by their top arrow. Because the BaseBubbleController adjusts the + // window origin left to account for the arrow spacing, the first run bubble + // moves the window origin right by this spacing, so that the + // BaseBubbleController will move it back to the correct position. + const NSPoint kOffset = NSMakePoint( + info_bubble::kBubbleArrowXOffset + info_bubble::kBubbleArrowWidth/2.0, + kFirstRunBubbleYOffset); + [FirstRunBubbleController showForView:field_ offset:kOffset profile:profile_]; +} + +std::wstring LocationBarViewMac::GetInputString() const { + return location_input_; +} + +void LocationBarViewMac::SetSuggestedText(const string16& text) { + edit_view_->SetSuggestText( + edit_view_->model()->UseVerbatimInstant() ? string16() : text); +} + +WindowOpenDisposition LocationBarViewMac::GetWindowOpenDisposition() const { + return disposition_; +} + +PageTransition::Type LocationBarViewMac::GetPageTransition() const { + return transition_; +} + +void LocationBarViewMac::AcceptInput() { + WindowOpenDisposition disposition = + event_utils::WindowOpenDispositionFromNSEvent([NSApp currentEvent]); + edit_view_->model()->AcceptInput(disposition, false); +} + +void LocationBarViewMac::FocusLocation(bool select_all) { + edit_view_->FocusLocation(select_all); +} + +void LocationBarViewMac::FocusSearch() { + edit_view_->SetForcedQuery(); +} + +void LocationBarViewMac::UpdateContentSettingsIcons() { + if (RefreshContentSettingsDecorations()) { + [field_ updateCursorAndToolTipRects]; + [field_ setNeedsDisplay:YES]; + } +} + +void LocationBarViewMac::UpdatePageActions() { + size_t count_before = page_action_decorations_.size(); + RefreshPageActionDecorations(); + Layout(); + if (page_action_decorations_.size() != count_before) { + NotificationService::current()->Notify( + NotificationType::EXTENSION_PAGE_ACTION_COUNT_CHANGED, + Source<LocationBar>(this), + NotificationService::NoDetails()); + } +} + +void LocationBarViewMac::InvalidatePageActions() { + size_t count_before = page_action_decorations_.size(); + DeletePageActionDecorations(); + Layout(); + if (page_action_decorations_.size() != count_before) { + NotificationService::current()->Notify( + NotificationType::EXTENSION_PAGE_ACTION_COUNT_CHANGED, + Source<LocationBar>(this), + NotificationService::NoDetails()); + } +} + +void LocationBarViewMac::SaveStateToContents(TabContents* contents) { + // TODO(shess): Why SaveStateToContents vs SaveStateToTab? + edit_view_->SaveStateToTab(contents); +} + +void LocationBarViewMac::Update(const TabContents* contents, + bool should_restore_state) { + bool star_enabled = browser_defaults::bookmarks_enabled && + [field_ isEditable] && !toolbar_model_->input_in_progress(); + command_updater_->UpdateCommandEnabled(IDC_BOOKMARK_PAGE, star_enabled); + star_decoration_->SetVisible(star_enabled); + RefreshPageActionDecorations(); + RefreshContentSettingsDecorations(); + // AutocompleteEditView restores state if the tab is non-NULL. + edit_view_->Update(should_restore_state ? contents : NULL); + OnChanged(); +} + +void LocationBarViewMac::OnAutocompleteWillClosePopup() { + if (!update_instant_) + return; + + InstantController* controller = browser_->instant(); + if (controller && !controller->commit_on_mouse_up()) + controller->DestroyPreviewContents(); + SetSuggestedText(string16()); +} + +void LocationBarViewMac::OnAutocompleteLosingFocus(gfx::NativeView unused) { + SetSuggestedText(string16()); + + InstantController* instant = browser_->instant(); + if (!instant) + return; + + if (!instant->is_active() || !instant->GetPreviewContents()) + return; + + // If |IsMouseDownFromActivate()| returns false, the RenderWidgetHostView did + // not receive a mouseDown event. Therefore, we should destroy the preview. + // Otherwise, the RWHV was clicked, so we commit the preview. + if (!instant->IsMouseDownFromActivate()) + instant->DestroyPreviewContents(); + else if (instant->IsShowingInstant()) + instant->SetCommitOnMouseUp(); + else + instant->CommitCurrentPreview(INSTANT_COMMIT_FOCUS_LOST); +} + +void LocationBarViewMac::OnAutocompleteWillAccept() { + update_instant_ = false; +} + +bool LocationBarViewMac::OnCommitSuggestedText(const std::wstring& typed_text) { + return edit_view_->CommitSuggestText(); +} + +void LocationBarViewMac::OnSetSuggestedSearchText( + const string16& suggested_text) { + SetSuggestedText(suggested_text); +} + +void LocationBarViewMac::OnPopupBoundsChanged(const gfx::Rect& bounds) { + InstantController* instant = browser_->instant(); + if (instant) + instant->SetOmniboxBounds(bounds); +} + +void LocationBarViewMac::OnAutocompleteAccept(const GURL& url, + WindowOpenDisposition disposition, + PageTransition::Type transition, + const GURL& alternate_nav_url) { + // WARNING: don't add an early return here. The calls after the if must + // happen. + if (url.is_valid()) { + location_input_ = UTF8ToWide(url.spec()); + disposition_ = disposition; + transition_ = transition; + + if (command_updater_) { + if (!alternate_nav_url.is_valid()) { + command_updater_->ExecuteCommand(IDC_OPEN_CURRENT_URL); + } else { + AlternateNavURLFetcher* fetcher = + new AlternateNavURLFetcher(alternate_nav_url); + // The AlternateNavURLFetcher will listen for the pending navigation + // notification that will be issued as a result of the "open URL." It + // will automatically install itself into that navigation controller. + command_updater_->ExecuteCommand(IDC_OPEN_CURRENT_URL); + if (fetcher->state() == AlternateNavURLFetcher::NOT_STARTED) { + // I'm not sure this should be reachable, but I'm not also sure enough + // that it shouldn't to stick in a NOTREACHED(). In any case, this is + // harmless. + delete fetcher; + } else { + // The navigation controller will delete the fetcher. + } + } + } + } + + if (browser_->instant()) + browser_->instant()->DestroyPreviewContents(); + + update_instant_ = true; +} + +void LocationBarViewMac::OnChanged() { + // Update the location-bar icon. + const int resource_id = edit_view_->GetIcon(); + NSImage* image = AutocompleteEditViewMac::ImageForResource(resource_id); + location_icon_decoration_->SetImage(image); + ev_bubble_decoration_->SetImage(image); + Layout(); + + InstantController* instant = browser_->instant(); + string16 suggested_text; + if (update_instant_ && instant && GetTabContents()) { + if (edit_view_->model()->user_input_in_progress() && + edit_view_->model()->popup_model()->IsOpen()) { + instant->Update + (browser_->GetSelectedTabContentsWrapper(), + edit_view_->model()->CurrentMatch(), + WideToUTF16(edit_view_->GetText()), + edit_view_->model()->UseVerbatimInstant(), + &suggested_text); + if (!instant->IsShowingInstant()) + edit_view_->model()->FinalizeInstantQuery(std::wstring()); + } else { + instant->DestroyPreviewContents(); + edit_view_->model()->FinalizeInstantQuery(std::wstring()); + } + } + + SetSuggestedText(suggested_text); +} + +void LocationBarViewMac::OnSelectionBoundsChanged() { + NOTIMPLEMENTED(); +} + +void LocationBarViewMac::OnInputInProgress(bool in_progress) { + toolbar_model_->set_input_in_progress(in_progress); + Update(NULL, false); +} + +void LocationBarViewMac::OnSetFocus() { + // Update the keyword and search hint states. + OnChanged(); +} + +void LocationBarViewMac::OnKillFocus() { + // Do nothing. +} + +SkBitmap LocationBarViewMac::GetFavIcon() const { + NOTIMPLEMENTED(); + return SkBitmap(); +} + +std::wstring LocationBarViewMac::GetTitle() const { + NOTIMPLEMENTED(); + return std::wstring(); +} + +void LocationBarViewMac::Revert() { + edit_view_->RevertAll(); +} + +// TODO(pamg): Change all these, here and for other platforms, to size_t. +int LocationBarViewMac::PageActionCount() { + return static_cast<int>(page_action_decorations_.size()); +} + +int LocationBarViewMac::PageActionVisibleCount() { + int result = 0; + for (size_t i = 0; i < page_action_decorations_.size(); ++i) { + if (page_action_decorations_[i]->IsVisible()) + ++result; + } + return result; +} + +TabContents* LocationBarViewMac::GetTabContents() const { + return browser_->GetSelectedTabContents(); +} + +PageActionDecoration* LocationBarViewMac::GetPageActionDecoration( + ExtensionAction* page_action) { + DCHECK(page_action); + for (size_t i = 0; i < page_action_decorations_.size(); ++i) { + if (page_action_decorations_[i]->page_action() == page_action) + return page_action_decorations_[i]; + } + // If |page_action| is the browser action of an extension, no element in + // |page_action_decorations_| will match. + NOTREACHED(); + return NULL; +} + +void LocationBarViewMac::SetPreviewEnabledPageAction( + ExtensionAction* page_action, bool preview_enabled) { + DCHECK(page_action); + TabContents* contents = GetTabContents(); + if (!contents) + return; + RefreshPageActionDecorations(); + Layout(); + + PageActionDecoration* decoration = GetPageActionDecoration(page_action); + DCHECK(decoration); + if (!decoration) + return; + + decoration->set_preview_enabled(preview_enabled); + decoration->UpdateVisibility(contents, + GURL(WideToUTF8(toolbar_model_->GetText()))); +} + +NSPoint LocationBarViewMac::GetPageActionBubblePoint( + ExtensionAction* page_action) { + PageActionDecoration* decoration = GetPageActionDecoration(page_action); + if (!decoration) + return NSZeroPoint; + + AutocompleteTextFieldCell* cell = [field_ cell]; + NSRect frame = [cell frameForDecoration:decoration inFrame:[field_ bounds]]; + DCHECK(!NSIsEmptyRect(frame)); + if (NSIsEmptyRect(frame)) + return NSZeroPoint; + + NSPoint bubble_point = decoration->GetBubblePointInFrame(frame); + return [field_ convertPoint:bubble_point toView:nil]; +} + +NSRect LocationBarViewMac::GetBlockedPopupRect() const { + const size_t kPopupIndex = CONTENT_SETTINGS_TYPE_POPUPS; + const LocationBarDecoration* decoration = + content_setting_decorations_[kPopupIndex]; + if (!decoration || !decoration->IsVisible()) + return NSZeroRect; + + AutocompleteTextFieldCell* cell = [field_ cell]; + const NSRect frame = [cell frameForDecoration:decoration + inFrame:[field_ bounds]]; + return [field_ convertRect:frame toView:nil]; +} + +ExtensionAction* LocationBarViewMac::GetPageAction(size_t index) { + if (index < page_action_decorations_.size()) + return page_action_decorations_[index]->page_action(); + NOTREACHED(); + return NULL; +} + +ExtensionAction* LocationBarViewMac::GetVisiblePageAction(size_t index) { + size_t current = 0; + for (size_t i = 0; i < page_action_decorations_.size(); ++i) { + if (page_action_decorations_[i]->IsVisible()) { + if (current == index) + return page_action_decorations_[i]->page_action(); + + ++current; + } + } + + NOTREACHED(); + return NULL; +} + +void LocationBarViewMac::TestPageActionPressed(size_t index) { + DCHECK_LT(index, page_action_decorations_.size()); + if (index < page_action_decorations_.size()) + page_action_decorations_[index]->OnMousePressed(NSZeroRect); +} + +void LocationBarViewMac::SetEditable(bool editable) { + [field_ setEditable:editable ? YES : NO]; + star_decoration_->SetVisible(browser_defaults::bookmarks_enabled && + editable && !toolbar_model_->input_in_progress()); + UpdatePageActions(); + Layout(); +} + +bool LocationBarViewMac::IsEditable() { + return [field_ isEditable] ? true : false; +} + +void LocationBarViewMac::SetStarred(bool starred) { + star_decoration_->SetStarred(starred); + + // TODO(shess): The field-editor frame and cursor rects should not + // change, here. + [field_ updateCursorAndToolTipRects]; + [field_ resetFieldEditorFrameIfNeeded]; + [field_ setNeedsDisplay:YES]; +} + +NSPoint LocationBarViewMac::GetBookmarkBubblePoint() const { + AutocompleteTextFieldCell* cell = [field_ cell]; + const NSRect frame = [cell frameForDecoration:star_decoration_.get() + inFrame:[field_ bounds]]; + const NSPoint point = star_decoration_->GetBubblePointInFrame(frame); + return [field_ convertPoint:point toView:nil]; +} + +NSPoint LocationBarViewMac::GetPageInfoBubblePoint() const { + AutocompleteTextFieldCell* cell = [field_ cell]; + if (ev_bubble_decoration_->IsVisible()) { + const NSRect frame = [cell frameForDecoration:ev_bubble_decoration_.get() + inFrame:[field_ bounds]]; + const NSPoint point = ev_bubble_decoration_->GetBubblePointInFrame(frame); + return [field_ convertPoint:point toView:nil]; + } else { + const NSRect frame = + [cell frameForDecoration:location_icon_decoration_.get() + inFrame:[field_ bounds]]; + const NSPoint point = + location_icon_decoration_->GetBubblePointInFrame(frame); + return [field_ convertPoint:point toView:nil]; + } +} + +NSImage* LocationBarViewMac::GetKeywordImage(const std::wstring& keyword) { + const TemplateURL* template_url = + profile_->GetTemplateURLModel()->GetTemplateURLForKeyword(keyword); + if (template_url && template_url->IsExtensionKeyword()) { + const SkBitmap& bitmap = profile_->GetExtensionsService()-> + GetOmniboxIcon(template_url->GetExtensionId()); + return gfx::SkBitmapToNSImage(bitmap); + } + + return AutocompleteEditViewMac::ImageForResource(IDR_OMNIBOX_SEARCH); +} + +void LocationBarViewMac::Observe(NotificationType type, + const NotificationSource& source, + const NotificationDetails& details) { + switch (type.value) { + case NotificationType::EXTENSION_PAGE_ACTION_VISIBILITY_CHANGED: { + TabContents* contents = GetTabContents(); + if (Details<TabContents>(contents) != details) + return; + + [field_ updateCursorAndToolTipRects]; + [field_ setNeedsDisplay:YES]; + break; + } + default: + NOTREACHED() << "Unexpected notification"; + break; + } +} + +void LocationBarViewMac::PostNotification(NSString* notification) { + [[NSNotificationCenter defaultCenter] postNotificationName:notification + object:[NSValue valueWithPointer:this]]; +} + +bool LocationBarViewMac::RefreshContentSettingsDecorations() { + const bool input_in_progress = toolbar_model_->input_in_progress(); + TabContents* tab_contents = + input_in_progress ? NULL : browser_->GetSelectedTabContents(); + bool icons_updated = false; + for (size_t i = 0; i < content_setting_decorations_.size(); ++i) { + icons_updated |= + content_setting_decorations_[i]->UpdateFromTabContents(tab_contents); + } + return icons_updated; +} + +void LocationBarViewMac::DeletePageActionDecorations() { + // TODO(shess): Deleting these decorations could result in the cell + // refering to them before things are laid out again. Meanwhile, at + // least fail safe. + [[field_ cell] clearDecorations]; + + page_action_decorations_.reset(); +} + +void LocationBarViewMac::RefreshPageActionDecorations() { + if (!IsEditable()) { + DeletePageActionDecorations(); + return; + } + + ExtensionsService* service = profile_->GetExtensionsService(); + if (!service) + return; + + std::vector<ExtensionAction*> page_actions; + for (size_t i = 0; i < service->extensions()->size(); ++i) { + if (service->extensions()->at(i)->page_action()) + page_actions.push_back(service->extensions()->at(i)->page_action()); + } + + // On startup we sometimes haven't loaded any extensions. This makes sure + // we catch up when the extensions (and any Page Actions) load. + if (page_actions.size() != page_action_decorations_.size()) { + DeletePageActionDecorations(); // Delete the old views (if any). + + for (size_t i = 0; i < page_actions.size(); ++i) { + page_action_decorations_.push_back( + new PageActionDecoration(this, profile_, page_actions[i])); + } + } + + if (page_action_decorations_.empty()) + return; + + TabContents* contents = GetTabContents(); + if (!contents) + return; + + GURL url = GURL(WideToUTF8(toolbar_model_->GetText())); + for (size_t i = 0; i < page_action_decorations_.size(); ++i) { + page_action_decorations_[i]->UpdateVisibility( + toolbar_model_->input_in_progress() ? NULL : contents, url); + } +} + +// TODO(shess): This function should over time grow to closely match +// the views Layout() function. +void LocationBarViewMac::Layout() { + AutocompleteTextFieldCell* cell = [field_ cell]; + + // Reset the left-hand decorations. + // TODO(shess): Shortly, this code will live somewhere else, like in + // the constructor. I am still wrestling with how best to deal with + // right-hand decorations, which are not a static set. + [cell clearDecorations]; + [cell addLeftDecoration:location_icon_decoration_.get()]; + [cell addLeftDecoration:selected_keyword_decoration_.get()]; + [cell addLeftDecoration:ev_bubble_decoration_.get()]; + [cell addRightDecoration:star_decoration_.get()]; + + // Note that display order is right to left. + for (size_t i = 0; i < page_action_decorations_.size(); ++i) { + [cell addRightDecoration:page_action_decorations_[i]]; + } + for (size_t i = 0; i < content_setting_decorations_.size(); ++i) { + [cell addRightDecoration:content_setting_decorations_[i]]; + } + + [cell addRightDecoration:keyword_hint_decoration_.get()]; + + // By default only the location icon is visible. + location_icon_decoration_->SetVisible(true); + selected_keyword_decoration_->SetVisible(false); + ev_bubble_decoration_->SetVisible(false); + keyword_hint_decoration_->SetVisible(false); + + // Get the keyword to use for keyword-search and hinting. + const std::wstring keyword(edit_view_->model()->keyword()); + std::wstring short_name; + bool is_extension_keyword = false; + if (!keyword.empty()) { + short_name = profile_->GetTemplateURLModel()-> + GetKeywordShortName(keyword, &is_extension_keyword); + } + + const bool is_keyword_hint = edit_view_->model()->is_keyword_hint(); + + if (!keyword.empty() && !is_keyword_hint) { + // Switch from location icon to keyword mode. + location_icon_decoration_->SetVisible(false); + selected_keyword_decoration_->SetVisible(true); + selected_keyword_decoration_->SetKeyword(short_name, is_extension_keyword); + selected_keyword_decoration_->SetImage(GetKeywordImage(keyword)); + } else if (toolbar_model_->GetSecurityLevel() == ToolbarModel::EV_SECURE) { + // Switch from location icon to show the EV bubble instead. + location_icon_decoration_->SetVisible(false); + ev_bubble_decoration_->SetVisible(true); + + std::wstring label(toolbar_model_->GetEVCertName()); + ev_bubble_decoration_->SetFullLabel(base::SysWideToNSString(label)); + } else if (!keyword.empty() && is_keyword_hint) { + keyword_hint_decoration_->SetKeyword(short_name, is_extension_keyword); + keyword_hint_decoration_->SetVisible(true); + } + + // These need to change anytime the layout changes. + // TODO(shess): Anytime the field editor might have changed, the + // cursor rects almost certainly should have changed. The tooltips + // might change even when the rects don't change. + [field_ resetFieldEditorFrameIfNeeded]; + [field_ updateCursorAndToolTipRects]; + + [field_ setNeedsDisplay:YES]; +} diff --git a/chrome/browser/ui/cocoa/location_bar/location_icon_decoration.h b/chrome/browser/ui/cocoa/location_bar/location_icon_decoration.h new file mode 100644 index 0000000..920d4d3 --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/location_icon_decoration.h @@ -0,0 +1,46 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_LOCATION_BAR_LOCATION_ICON_DECORATION_H_ +#define CHROME_BROWSER_UI_COCOA_LOCATION_BAR_LOCATION_ICON_DECORATION_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "chrome/browser/ui/cocoa/location_bar/image_decoration.h" + +class LocationBarViewMac; + +// LocationIconDecoration is used to display an icon to the left of +// the address. + +class LocationIconDecoration : public ImageDecoration { + public: + explicit LocationIconDecoration(LocationBarViewMac* owner); + virtual ~LocationIconDecoration(); + + // Allow dragging the current URL. + virtual bool IsDraggable(); + virtual NSPasteboard* GetDragPasteboard(); + virtual NSImage* GetDragImage() { return GetImage(); } + virtual NSRect GetDragImageFrame(NSRect frame) { + return GetDrawRectInFrame(frame); + } + + // Get the point where the page info bubble should point within the + // decoration's frame, in the |owner_|'s coordinates. + NSPoint GetBubblePointInFrame(NSRect frame); + + // Show the page info panel on click. + virtual bool OnMousePressed(NSRect frame); + virtual bool AcceptsMousePress() { return true; } + + private: + // The location bar view that owns us. + LocationBarViewMac* owner_; + + DISALLOW_COPY_AND_ASSIGN(LocationIconDecoration); +}; + +#endif // CHROME_BROWSER_UI_COCOA_LOCATION_BAR_LOCATION_ICON_DECORATION_H_ diff --git a/chrome/browser/ui/cocoa/location_bar/location_icon_decoration.mm b/chrome/browser/ui/cocoa/location_bar/location_icon_decoration.mm new file mode 100644 index 0000000..808a45f --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/location_icon_decoration.mm @@ -0,0 +1,72 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include <cmath> + +#import "chrome/browser/ui/cocoa/location_bar/location_icon_decoration.h" + +#include "base/sys_string_conversions.h" +#include "chrome/browser/tab_contents/tab_contents.h" +#import "chrome/browser/ui/cocoa/location_bar/location_bar_view_mac.h" +#import "third_party/mozilla/NSPasteboard+Utils.h" + +// The info-bubble point should look like it points to the bottom of the lock +// icon. Determined with Pixie.app. +const CGFloat kBubblePointYOffset = 2.0; + +LocationIconDecoration::LocationIconDecoration(LocationBarViewMac* owner) + : owner_(owner) { +} +LocationIconDecoration::~LocationIconDecoration() { +} + +bool LocationIconDecoration::IsDraggable() { + // Without a tab it will be impossible to get the information needed + // to perform a drag. + if (!owner_->GetTabContents()) + return false; + + // Do not drag if the user has been editing the location bar, or the + // location bar is at the NTP. + if (owner_->location_entry()->IsEditingOrEmpty()) + return false; + + return true; +} + +NSPasteboard* LocationIconDecoration::GetDragPasteboard() { + TabContents* tab = owner_->GetTabContents(); + DCHECK(tab); // See |IsDraggable()|. + + NSString* url = base::SysUTF8ToNSString(tab->GetURL().spec()); + NSString* title = base::SysUTF16ToNSString(tab->GetTitle()); + + NSPasteboard* pboard = [NSPasteboard pasteboardWithName:NSDragPboard]; + [pboard declareURLPasteboardWithAdditionalTypes:[NSArray array] + owner:nil]; + [pboard setDataForURL:url title:title]; + return pboard; +} + +NSPoint LocationIconDecoration::GetBubblePointInFrame(NSRect frame) { + const NSRect draw_frame = GetDrawRectInFrame(frame); + return NSMakePoint(NSMidX(draw_frame), + NSMaxY(draw_frame) - kBubblePointYOffset); +} + +bool LocationIconDecoration::OnMousePressed(NSRect frame) { + // Do not show page info if the user has been editing the location + // bar, or the location bar is at the NTP. + if (owner_->location_entry()->IsEditingOrEmpty()) + return true; + + TabContents* tab = owner_->GetTabContents(); + NavigationEntry* nav_entry = tab->controller().GetActiveEntry(); + if (!nav_entry) { + NOTREACHED(); + return true; + } + tab->ShowPageInfo(nav_entry->url(), nav_entry->ssl(), true); + return true; +} diff --git a/chrome/browser/ui/cocoa/location_bar/omnibox_popup_view.h b/chrome/browser/ui/cocoa/location_bar/omnibox_popup_view.h new file mode 100644 index 0000000..c549a49 --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/omnibox_popup_view.h @@ -0,0 +1,17 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_LOCATION_BAR_OMNIBOX_POPUP_VIEW_H_ +#define CHROME_BROWSER_UI_COCOA_LOCATION_BAR_OMNIBOX_POPUP_VIEW_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +// The content view for the omnibox popup. Supports up to two subviews (the +// AutocompleteMatrix containing autocomplete results and (optionally) an +// InstantOptInView. +@interface OmniboxPopupView : NSView +@end + +#endif // CHROME_BROWSER_UI_COCOA_LOCATION_BAR_OMNIBOX_POPUP_VIEW_H_ diff --git a/chrome/browser/ui/cocoa/location_bar/omnibox_popup_view.mm b/chrome/browser/ui/cocoa/location_bar/omnibox_popup_view.mm new file mode 100644 index 0000000..ef479e1 --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/omnibox_popup_view.mm @@ -0,0 +1,43 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/location_bar/omnibox_popup_view.h" + +#include "base/logging.h" + +@implementation OmniboxPopupView + +// If there is only one subview, it is sized to fill all available space. If +// there are two subviews, the second subview is placed at the bottom of the +// view, and the first subview is sized to fill all remaining space. +- (void)resizeSubviewsWithOldSize:(NSSize)oldBoundsSize { + NSArray* subviews = [self subviews]; + if ([subviews count] == 0) + return; + + DCHECK_LE([subviews count], 2U); + + NSRect availableSpace = [self bounds]; + + if ([subviews count] >= 2) { + NSView* instantView = [subviews objectAtIndex:1]; + CGFloat height = NSHeight([instantView frame]); + NSRect instantFrame = availableSpace; + instantFrame.size.height = height; + + availableSpace.origin.y = height; + availableSpace.size.height -= height; + [instantView setFrame:instantFrame]; + } + + if ([subviews count] >= 1) { + NSView* matrixView = [subviews objectAtIndex:0]; + if (NSHeight(availableSpace) < 0) + availableSpace.size.height = 0; + + [matrixView setFrame:availableSpace]; + } +} + +@end diff --git a/chrome/browser/ui/cocoa/location_bar/omnibox_popup_view_unittest.mm b/chrome/browser/ui/cocoa/location_bar/omnibox_popup_view_unittest.mm new file mode 100644 index 0000000..ac4be55 --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/omnibox_popup_view_unittest.mm @@ -0,0 +1,68 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/location_bar/omnibox_popup_view.h" + +namespace { + +class OmniboxPopupViewTest : public CocoaTest { + public: + OmniboxPopupViewTest() { + NSRect content_frame = [[test_window() contentView] frame]; + scoped_nsobject<OmniboxPopupView> view( + [[OmniboxPopupView alloc] initWithFrame:content_frame]); + view_ = view.get(); + [[test_window() contentView] addSubview:view_]; + } + + OmniboxPopupView* view_; // Weak. Owned by the view hierarchy. +}; + +// Tests display, add/remove. +TEST_VIEW(OmniboxPopupViewTest, view_); + +// A single subview should completely fill the popup view. +TEST_F(OmniboxPopupViewTest, ResizeWithOneSubview) { + scoped_nsobject<NSView> subview1([[NSView alloc] initWithFrame:NSZeroRect]); + + // Adding the subview should not change its frame. + [view_ addSubview:subview1]; + EXPECT_TRUE(NSEqualRects(NSZeroRect, [subview1 frame])); + + // Resizing the popup view should also resize the subview. + [view_ setFrame:NSMakeRect(0, 0, 100, 100)]; + EXPECT_TRUE(NSEqualRects([view_ bounds], [subview1 frame])); +} + +TEST_F(OmniboxPopupViewTest, ResizeWithTwoSubviews) { + const CGFloat height = 50; + NSRect initial = NSMakeRect(0, 0, 100, height); + + scoped_nsobject<NSView> subview1([[NSView alloc] initWithFrame:NSZeroRect]); + scoped_nsobject<NSView> subview2([[NSView alloc] initWithFrame:initial]); + [view_ addSubview:subview1]; + [view_ addSubview:subview2]; + + // Resize the popup view to be much larger than height. |subview2|'s height + // should stay the same, and |subview1| should resize to fill all available + // space. + [view_ setFrame:NSMakeRect(0, 0, 300, 4 * height)]; + EXPECT_EQ(NSWidth([view_ frame]), NSWidth([subview1 frame])); + EXPECT_EQ(NSWidth([view_ frame]), NSWidth([subview2 frame])); + EXPECT_EQ(height, NSHeight([subview2 frame])); + EXPECT_EQ(NSHeight([view_ frame]), + NSHeight([subview1 frame]) + NSHeight([subview2 frame])); + + // Now resize the popup view to be smaller than height. |subview2|'s height + // should stay the same, and |subview1|'s height should be zero, not negative. + [view_ setFrame:NSMakeRect(0, 0, 300, height - 10)]; + EXPECT_EQ(NSWidth([view_ frame]), NSWidth([subview1 frame])); + EXPECT_EQ(NSWidth([view_ frame]), NSWidth([subview2 frame])); + EXPECT_EQ(0, NSHeight([subview1 frame])); + EXPECT_EQ(height, NSHeight([subview2 frame])); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/location_bar/page_action_decoration.h b/chrome/browser/ui/cocoa/location_bar/page_action_decoration.h new file mode 100644 index 0000000..07cd94d --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/page_action_decoration.h @@ -0,0 +1,119 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_LOCATION_BAR_PAGE_ACTION_DECORATION_H_ +#define CHROME_BROWSER_UI_COCOA_LOCATION_BAR_PAGE_ACTION_DECORATION_H_ +#pragma once + +#include "chrome/browser/extensions/image_loading_tracker.h" +#import "chrome/browser/ui/cocoa/location_bar/image_decoration.h" +#include "googleurl/src/gurl.h" + +class ExtensionAction; +@class ExtensionActionContextMenu; +class LocationBarViewMac; +class Profile; +class TabContents; + +// PageActionDecoration is used to display the icon for a given Page +// Action and notify the extension when the icon is clicked. + +class PageActionDecoration : public ImageDecoration, + public ImageLoadingTracker::Observer, + public NotificationObserver { + public: + PageActionDecoration(LocationBarViewMac* owner, + Profile* profile, + ExtensionAction* page_action); + virtual ~PageActionDecoration(); + + ExtensionAction* page_action() { return page_action_; } + int current_tab_id() { return current_tab_id_; } + void set_preview_enabled(bool enabled) { preview_enabled_ = enabled; } + bool preview_enabled() const { return preview_enabled_; } + + // Overridden from |ImageLoadingTracker::Observer|. + virtual void OnImageLoaded( + SkBitmap* image, ExtensionResource resource, int index); + + // Called to notify the Page Action that it should determine whether + // to be visible or hidden. |contents| is the TabContents that is + // active, |url| is the current page URL. + void UpdateVisibility(TabContents* contents, const GURL& url); + + // Sets the tooltip for this Page Action image. + void SetToolTip(NSString* tooltip); + void SetToolTip(std::string tooltip); + + // Get the point where extension info bubbles should point within + // the given decoration frame. + NSPoint GetBubblePointInFrame(NSRect frame); + + // Overridden from |LocationBarDecoration| + virtual CGFloat GetWidthForSpace(CGFloat width); + virtual bool AcceptsMousePress() { return true; } + virtual bool OnMousePressed(NSRect frame); + virtual NSString* GetToolTip(); + virtual NSMenu* GetMenu(); + + protected: + // For unit testing only. + PageActionDecoration() : owner_(NULL), + profile_(NULL), + page_action_(NULL), + tracker_(this), + current_tab_id_(-1), + preview_enabled_(false) {} + + private: + // Overridden from NotificationObserver: + virtual void Observe(NotificationType type, + const NotificationSource& source, + const NotificationDetails& details); + + // The location bar view that owns us. + LocationBarViewMac* owner_; + + // The current profile (not owned by us). + Profile* profile_; + + // The Page Action that this view represents. The Page Action is not + // owned by us, it resides in the extension of this particular + // profile. + ExtensionAction* page_action_; + + // A cache of images the Page Actions might need to show, mapped by + // path. + typedef std::map<std::string, SkBitmap> PageActionMap; + PageActionMap page_action_icons_; + + // The object that is waiting for the image loading to complete + // asynchronously. + ImageLoadingTracker tracker_; + + // The tab id we are currently showing the icon for. + int current_tab_id_; + + // The URL we are currently showing the icon for. + GURL current_url_; + + // The string to show for a tooltip. + scoped_nsobject<NSString> tooltip_; + + // The context menu for the Page Action. + scoped_nsobject<ExtensionActionContextMenu> menu_; + + // This is used for post-install visual feedback. The page_action + // icon is briefly shown even if it hasn't been enabled by its + // extension. + bool preview_enabled_; + + // Used to register for notifications received by + // NotificationObserver. + NotificationRegistrar registrar_; + + DISALLOW_COPY_AND_ASSIGN(PageActionDecoration); +}; + +#endif // CHROME_BROWSER_UI_COCOA_LOCATION_BAR_PAGE_ACTION_DECORATION_H_ diff --git a/chrome/browser/ui/cocoa/location_bar/page_action_decoration.mm b/chrome/browser/ui/cocoa/location_bar/page_action_decoration.mm new file mode 100644 index 0000000..610815c --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/page_action_decoration.mm @@ -0,0 +1,251 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include <cmath> + +#import "chrome/browser/ui/cocoa/location_bar/page_action_decoration.h" + +#include "base/sys_string_conversions.h" +#include "chrome/browser/extensions/extension_browser_event_router.h" +#include "chrome/browser/extensions/extensions_service.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/tab_contents/tab_contents.h" +#import "chrome/browser/ui/cocoa/extensions/extension_action_context_menu.h" +#import "chrome/browser/ui/cocoa/extensions/extension_popup_controller.h" +#import "chrome/browser/ui/cocoa/location_bar/location_bar_view_mac.h" +#include "chrome/common/extensions/extension_action.h" +#include "chrome/common/extensions/extension_resource.h" +#include "skia/ext/skia_utils_mac.h" + +namespace { + +// Distance to offset the bubble pointer from the bottom of the max +// icon area of the decoration. This makes the popup's upper border +// 2px away from the omnibox's lower border (matches omnibox popup +// upper border). +const CGFloat kBubblePointYOffset = 2.0; + +} // namespace + +PageActionDecoration::PageActionDecoration( + LocationBarViewMac* owner, + Profile* profile, + ExtensionAction* page_action) + : owner_(NULL), + profile_(profile), + page_action_(page_action), + tracker_(this), + current_tab_id_(-1), + preview_enabled_(false) { + DCHECK(profile); + const Extension* extension = profile->GetExtensionsService()-> + GetExtensionById(page_action->extension_id(), false); + DCHECK(extension); + + // Load all the icons declared in the manifest. This is the contents of the + // icons array, plus the default_icon property, if any. + std::vector<std::string> icon_paths(*page_action->icon_paths()); + if (!page_action_->default_icon_path().empty()) + icon_paths.push_back(page_action_->default_icon_path()); + + for (std::vector<std::string>::iterator iter = icon_paths.begin(); + iter != icon_paths.end(); ++iter) { + tracker_.LoadImage(extension, extension->GetResource(*iter), + gfx::Size(Extension::kPageActionIconMaxSize, + Extension::kPageActionIconMaxSize), + ImageLoadingTracker::DONT_CACHE); + } + + registrar_.Add(this, NotificationType::EXTENSION_HOST_VIEW_SHOULD_CLOSE, + Source<Profile>(profile_)); + + // We set the owner last of all so that we can determine whether we are in + // the process of initializing this class or not. + owner_ = owner; +} + +PageActionDecoration::~PageActionDecoration() {} + +// Always |kPageActionIconMaxSize| wide. |ImageDecoration| draws the +// image centered. +CGFloat PageActionDecoration::GetWidthForSpace(CGFloat width) { + return Extension::kPageActionIconMaxSize; +} + +// Either notify listeners or show a popup depending on the Page +// Action. +bool PageActionDecoration::OnMousePressed(NSRect frame) { + if (current_tab_id_ < 0) { + NOTREACHED() << "No current tab."; + // We don't want other code to try and handle this click. Returning true + // prevents this by indicating that we handled it. + return true; + } + + if (page_action_->HasPopup(current_tab_id_)) { + // Anchor popup at the bottom center of the page action icon. + AutocompleteTextField* field = owner_->GetAutocompleteTextField(); + NSPoint anchor = GetBubblePointInFrame(frame); + anchor = [field convertPoint:anchor toView:nil]; + + const GURL popup_url(page_action_->GetPopupUrl(current_tab_id_)); + [ExtensionPopupController showURL:popup_url + inBrowser:BrowserList::GetLastActive() + anchoredAt:anchor + arrowLocation:info_bubble::kTopRight + devMode:NO]; + } else { + ExtensionBrowserEventRouter::GetInstance()->PageActionExecuted( + profile_, page_action_->extension_id(), page_action_->id(), + current_tab_id_, current_url_.spec(), + 1); + } + return true; +} + +void PageActionDecoration::OnImageLoaded( + SkBitmap* image, ExtensionResource resource, int index) { + // We loaded icons()->size() icons, plus one extra if the Page Action had + // a default icon. + int total_icons = static_cast<int>(page_action_->icon_paths()->size()); + if (!page_action_->default_icon_path().empty()) + total_icons++; + DCHECK(index < total_icons); + + // Map the index of the loaded image back to its name. If we ever get an + // index greater than the number of icons, it must be the default icon. + if (image) { + if (index < static_cast<int>(page_action_->icon_paths()->size())) + page_action_icons_[page_action_->icon_paths()->at(index)] = *image; + else + page_action_icons_[page_action_->default_icon_path()] = *image; + } + + // If we have no owner, that means this class is still being constructed and + // we should not UpdatePageActions, since it leads to the PageActions being + // destroyed again and new ones recreated (causing an infinite loop). + if (owner_) + owner_->UpdatePageActions(); +} + +void PageActionDecoration::UpdateVisibility(TabContents* contents, + const GURL& url) { + // Save this off so we can pass it back to the extension when the action gets + // executed. See PageActionDecoration::OnMousePressed. + current_tab_id_ = contents ? ExtensionTabUtil::GetTabId(contents) : -1; + current_url_ = url; + + bool visible = contents && + (preview_enabled_ || page_action_->GetIsVisible(current_tab_id_)); + if (visible) { + SetToolTip(page_action_->GetTitle(current_tab_id_)); + + // Set the image. + // It can come from three places. In descending order of priority: + // - The developer can set it dynamically by path or bitmap. It will be in + // page_action_->GetIcon(). + // - The developer can set it dynamically by index. It will be in + // page_action_->GetIconIndex(). + // - It can be set in the manifest by path. It will be in page_action_-> + // default_icon_path(). + + // First look for a dynamically set bitmap. + SkBitmap skia_icon = page_action_->GetIcon(current_tab_id_); + if (skia_icon.isNull()) { + int icon_index = page_action_->GetIconIndex(current_tab_id_); + std::string icon_path = (icon_index < 0) ? + page_action_->default_icon_path() : + page_action_->icon_paths()->at(icon_index); + if (!icon_path.empty()) { + PageActionMap::iterator iter = page_action_icons_.find(icon_path); + if (iter != page_action_icons_.end()) + skia_icon = iter->second; + } + } + if (!skia_icon.isNull()) { + SetImage(gfx::SkBitmapToNSImage(skia_icon)); + } else if (!GetImage()) { + // During install the action can be displayed before the icons + // have come in. Rather than deal with this in multiple places, + // provide a placeholder image. This will be replaced when an + // icon comes in. + const NSSize default_size = NSMakeSize(Extension::kPageActionIconMaxSize, + Extension::kPageActionIconMaxSize); + SetImage([[NSImage alloc] initWithSize:default_size]); + } + } + + if (IsVisible() != visible) { + SetVisible(visible); + NotificationService::current()->Notify( + NotificationType::EXTENSION_PAGE_ACTION_VISIBILITY_CHANGED, + Source<ExtensionAction>(page_action_), + Details<TabContents>(contents)); + } +} + +void PageActionDecoration::SetToolTip(NSString* tooltip) { + tooltip_.reset([tooltip retain]); +} + +void PageActionDecoration::SetToolTip(std::string tooltip) { + SetToolTip(tooltip.empty() ? nil : base::SysUTF8ToNSString(tooltip)); +} + +NSString* PageActionDecoration::GetToolTip() { + return tooltip_.get(); +} + +NSPoint PageActionDecoration::GetBubblePointInFrame(NSRect frame) { + // This is similar to |ImageDecoration::GetDrawRectInFrame()|, + // except that code centers the image, which can differ in size + // between actions. This centers the maximum image size, so the + // point will consistently be at the same y position. x position is + // easier (the middle of the centered image is the middle of the + // frame). + const CGFloat delta_height = + NSHeight(frame) - Extension::kPageActionIconMaxSize; + const CGFloat bottom_inset = std::ceil(delta_height / 2.0); + + // Return a point just below the bottom of the maximal drawing area. + return NSMakePoint(NSMidX(frame), + NSMaxY(frame) - bottom_inset + kBubblePointYOffset); +} + +NSMenu* PageActionDecoration::GetMenu() { + if (!profile_) + return nil; + ExtensionsService* service = profile_->GetExtensionsService(); + if (!service) + return nil; + const Extension* extension = service->GetExtensionById( + page_action_->extension_id(), false); + DCHECK(extension); + if (!extension) + return nil; + menu_.reset([[ExtensionActionContextMenu alloc] + initWithExtension:extension + profile:profile_ + extensionAction:page_action_]); + + return menu_.get(); +} + +void PageActionDecoration::Observe( + NotificationType type, + const NotificationSource& source, + const NotificationDetails& details) { + switch (type.value) { + case NotificationType::EXTENSION_HOST_VIEW_SHOULD_CLOSE: { + ExtensionPopupController* popup = [ExtensionPopupController popup]; + if (popup && ![popup isClosing]) + [popup close]; + + break; + } + default: + NOTREACHED() << "Unexpected notification"; + break; + } +} diff --git a/chrome/browser/ui/cocoa/location_bar/selected_keyword_decoration.h b/chrome/browser/ui/cocoa/location_bar/selected_keyword_decoration.h new file mode 100644 index 0000000..3c9cf309 --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/selected_keyword_decoration.h @@ -0,0 +1,42 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_LOCATION_BAR_SELECTED_KEYWORD_DECORATION_H_ +#define CHROME_BROWSER_UI_COCOA_LOCATION_BAR_SELECTED_KEYWORD_DECORATION_H_ +#pragma once + +#include <string> + +#import <Cocoa/Cocoa.h> + +#include "chrome/browser/ui/cocoa/location_bar/bubble_decoration.h" + +class SelectedKeywordDecoration : public BubbleDecoration { + public: + SelectedKeywordDecoration(NSFont* font); + + // Calculates appropriate full and partial label strings based on + // inputs. + void SetKeyword(const std::wstring& keyword, bool is_extension_keyword); + + // Determines what combination of labels and image will best fit + // within |width|, makes those current for |BubbleDecoration|, and + // return the resulting width. + virtual CGFloat GetWidthForSpace(CGFloat width); + + void SetImage(NSImage* image); + + private: + friend class SelectedKeywordDecorationTest; + FRIEND_TEST_ALL_PREFIXES(SelectedKeywordDecorationTest, + UsesPartialKeywordIfNarrow); + + scoped_nsobject<NSImage> search_image_; + scoped_nsobject<NSString> full_string_; + scoped_nsobject<NSString> partial_string_; + + DISALLOW_COPY_AND_ASSIGN(SelectedKeywordDecoration); +}; + +#endif // CHROME_BROWSER_UI_COCOA_LOCATION_BAR_SELECTED_KEYWORD_DECORATION_H_ diff --git a/chrome/browser/ui/cocoa/location_bar/selected_keyword_decoration.mm b/chrome/browser/ui/cocoa/location_bar/selected_keyword_decoration.mm new file mode 100644 index 0000000..0bdd8e15 --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/selected_keyword_decoration.mm @@ -0,0 +1,73 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/location_bar/selected_keyword_decoration.h" + +#include "app/l10n_util_mac.h" +#include "base/utf_string_conversions.h" +#import "chrome/browser/autocomplete/autocomplete_edit_view_mac.h" +#include "chrome/browser/location_bar_util.h" +#import "chrome/browser/ui/cocoa/image_utils.h" +#include "grit/theme_resources.h" +#include "grit/generated_resources.h" + +SelectedKeywordDecoration::SelectedKeywordDecoration(NSFont* font) + : BubbleDecoration(font) { + search_image_.reset([AutocompleteEditViewMac::ImageForResource( + IDR_KEYWORD_SEARCH_MAGNIFIER) retain]); + + // Matches the color of the highlighted line in the popup. + NSColor* background_color = [NSColor selectedControlColor]; + + // Match focus ring's inner color. + NSColor* border_color = + [[NSColor keyboardFocusIndicatorColor] colorWithAlphaComponent:0.5]; + SetColors(border_color, background_color, [NSColor blackColor]); +} + +CGFloat SelectedKeywordDecoration::GetWidthForSpace(CGFloat width) { + const CGFloat full_width = + GetWidthForImageAndLabel(search_image_, full_string_); + if (full_width <= width) { + BubbleDecoration::SetImage(search_image_); + SetLabel(full_string_); + return full_width; + } + + BubbleDecoration::SetImage(nil); + const CGFloat no_image_width = GetWidthForImageAndLabel(nil, full_string_); + if (no_image_width <= width || !partial_string_) { + SetLabel(full_string_); + return no_image_width; + } + + SetLabel(partial_string_); + return GetWidthForImageAndLabel(nil, partial_string_); +} + +void SelectedKeywordDecoration::SetKeyword(const std::wstring& short_name, + bool is_extension_keyword) { + const std::wstring min_name( + location_bar_util::CalculateMinString(short_name)); + const int message_id = is_extension_keyword ? + IDS_OMNIBOX_EXTENSION_KEYWORD_TEXT : IDS_OMNIBOX_KEYWORD_TEXT; + + // The text will be like "Search <name>:". "<name>" is a parameter + // derived from |short_name|. + full_string_.reset( + [l10n_util::GetNSStringF(message_id, WideToUTF16(short_name)) copy]); + + if (min_name.empty()) { + partial_string_.reset(); + } else { + partial_string_.reset( + [l10n_util::GetNSStringF(message_id, WideToUTF16(min_name)) copy]); + } +} + +void SelectedKeywordDecoration::SetImage(NSImage* image) { + if (image != search_image_) + search_image_.reset([image retain]); + BubbleDecoration::SetImage(image); +} diff --git a/chrome/browser/ui/cocoa/location_bar/selected_keyword_decoration_unittest.mm b/chrome/browser/ui/cocoa/location_bar/selected_keyword_decoration_unittest.mm new file mode 100644 index 0000000..5536fda --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/selected_keyword_decoration_unittest.mm @@ -0,0 +1,64 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/location_bar/selected_keyword_decoration.h" +#include "testing/gtest/include/gtest/gtest.h" +#import "testing/gtest_mac.h" + +namespace { + +// A wide width which should fit everything. +const CGFloat kWidth(300.0); + +// A narrow width for tests which test things that don't fit. +const CGFloat kNarrowWidth(5.0); + +} // namespace + +class SelectedKeywordDecorationTest : public CocoaTest { + public: + SelectedKeywordDecorationTest() + : decoration_([NSFont userFontOfSize:12]) { + } + + SelectedKeywordDecoration decoration_; +}; + +// Test that the cell correctly chooses the partial keyword if there's +// not enough room. +TEST_F(SelectedKeywordDecorationTest, UsesPartialKeywordIfNarrow) { + + const std::wstring kKeyword(L"Engine"); + NSString* const kFullString = @"Search Engine:"; + NSString* const kPartialString = @"Search En\u2026:"; // ellipses + + decoration_.SetKeyword(kKeyword, false); + + // Wide width chooses the full string and image. + const CGFloat all_width = decoration_.GetWidthForSpace(kWidth); + EXPECT_TRUE(decoration_.image_); + EXPECT_NSEQ(kFullString, decoration_.label_); + + // If not enough space to include the image, uses exactly the full + // string. + const CGFloat full_width = decoration_.GetWidthForSpace(all_width - 5.0); + EXPECT_LT(full_width, all_width); + EXPECT_FALSE(decoration_.image_); + EXPECT_NSEQ(kFullString, decoration_.label_); + + // Narrow width chooses the partial string. + const CGFloat partial_width = decoration_.GetWidthForSpace(kNarrowWidth); + EXPECT_LT(partial_width, full_width); + EXPECT_FALSE(decoration_.image_); + EXPECT_NSEQ(kPartialString, decoration_.label_); + + // Narrow doesn't choose partial string if there is not one. + decoration_.partial_string_.reset(); + decoration_.GetWidthForSpace(kNarrowWidth); + EXPECT_FALSE(decoration_.image_); + EXPECT_NSEQ(kFullString, decoration_.label_); +} diff --git a/chrome/browser/ui/cocoa/location_bar/star_decoration.h b/chrome/browser/ui/cocoa/location_bar/star_decoration.h new file mode 100644 index 0000000..0d12104 --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/star_decoration.h @@ -0,0 +1,44 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_LOCATION_BAR_STAR_DECORATION_H_ +#define CHROME_BROWSER_UI_COCOA_LOCATION_BAR_STAR_DECORATION_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "chrome/browser/ui/cocoa/location_bar/image_decoration.h" + +class CommandUpdater; + +// Star icon on the right side of the field. + +class StarDecoration : public ImageDecoration { + public: + explicit StarDecoration(CommandUpdater* command_updater); + virtual ~StarDecoration(); + + // Sets the image and tooltip based on |starred|. + void SetStarred(bool starred); + + // Get the point where the bookmark bubble should point within the + // decoration's frame. + NSPoint GetBubblePointInFrame(NSRect frame); + + // Implement |LocationBarDecoration|. + virtual bool AcceptsMousePress() { return true; } + virtual bool OnMousePressed(NSRect frame); + virtual NSString* GetToolTip(); + + private: + // For bringing up bookmark bar. + CommandUpdater* command_updater_; // Weak, owned by Browser. + + // The string to show for a tooltip. + scoped_nsobject<NSString> tooltip_; + + DISALLOW_COPY_AND_ASSIGN(StarDecoration); +}; + +#endif // CHROME_BROWSER_UI_COCOA_LOCATION_BAR_STAR_DECORATION_H_ diff --git a/chrome/browser/ui/cocoa/location_bar/star_decoration.mm b/chrome/browser/ui/cocoa/location_bar/star_decoration.mm new file mode 100644 index 0000000..2ac3450 --- /dev/null +++ b/chrome/browser/ui/cocoa/location_bar/star_decoration.mm @@ -0,0 +1,53 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/location_bar/star_decoration.h" + +#include "app/l10n_util_mac.h" +#include "chrome/app/chrome_command_ids.h" +#import "chrome/browser/autocomplete/autocomplete_edit_view_mac.h" +#include "chrome/browser/command_updater.h" +#include "grit/generated_resources.h" +#include "grit/theme_resources.h" + +namespace { + +// The info-bubble point should look like it points to the point +// between the star's lower tips. The popup should be where the +// Omnibox popup ends up (2px below field). Determined via Pixie.app +// magnification. +const CGFloat kStarPointYOffset = 2.0; + +} // namespace + +StarDecoration::StarDecoration(CommandUpdater* command_updater) + : command_updater_(command_updater) { + SetVisible(true); + SetStarred(false); +} + +StarDecoration::~StarDecoration() { +} + +void StarDecoration::SetStarred(bool starred) { + const int image_id = starred ? IDR_STAR_LIT : IDR_STAR; + const int tip_id = starred ? IDS_TOOLTIP_STARRED : IDS_TOOLTIP_STAR; + SetImage(AutocompleteEditViewMac::ImageForResource(image_id)); + tooltip_.reset([l10n_util::GetNSStringWithFixup(tip_id) retain]); +} + +NSPoint StarDecoration::GetBubblePointInFrame(NSRect frame) { + const NSRect draw_frame = GetDrawRectInFrame(frame); + return NSMakePoint(NSMidX(draw_frame), + NSMaxY(draw_frame) - kStarPointYOffset); +} + +bool StarDecoration::OnMousePressed(NSRect frame) { + command_updater_->ExecuteCommand(IDC_BOOKMARK_PAGE); + return true; +} + +NSString* StarDecoration::GetToolTip() { + return tooltip_.get(); +} diff --git a/chrome/browser/ui/cocoa/menu_button.h b/chrome/browser/ui/cocoa/menu_button.h new file mode 100644 index 0000000..d7b00f5 --- /dev/null +++ b/chrome/browser/ui/cocoa/menu_button.h @@ -0,0 +1,32 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_MENU_BUTTON_H_ +#define CHROME_BROWSER_UI_COCOA_MENU_BUTTON_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" + +// This a button which displays a user-provided menu "attached" below it upon +// being clicked or dragged (or clicked and held). It expects a +// |ClickHoldButtonCell| as cell. +@interface MenuButton : NSButton { + @private + IBOutlet NSMenu* attachedMenu_; + scoped_nsobject<NSPopUpButtonCell> popUpCell_; +} + +// The menu to display. Note that it should have no (i.e., a blank) title and +// that the 0-th entry should be blank (and won't be displayed). (This is +// because we use a pulldown list, for which Cocoa uses the 0-th item as "title" +// in the button. This might change if we ever switch to a pop-up. Our direct +// use of the given NSMenu object means that the one can set and use NSMenu's +// delegate as usual.) +@property(assign, nonatomic) NSMenu* attachedMenu; + +@end // @interface MenuButton + +#endif // CHROME_BROWSER_UI_COCOA_MENU_BUTTON_H_ diff --git a/chrome/browser/ui/cocoa/menu_button.mm b/chrome/browser/ui/cocoa/menu_button.mm new file mode 100644 index 0000000..d1b9e88 --- /dev/null +++ b/chrome/browser/ui/cocoa/menu_button.mm @@ -0,0 +1,122 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/menu_button.h" + +#include "base/logging.h" +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/clickhold_button_cell.h" + +@interface MenuButton (Private) + +- (void)resetToDefaults; +- (void)showMenu:(BOOL)isDragging; +- (void)clickShowMenu:(id)sender; +- (void)dragShowMenu:(id)sender; + +@end // @interface MenuButton (Private) + +@implementation MenuButton + +// Overrides: + ++ (Class)cellClass { + return [ClickHoldButtonCell class]; +} + +- (id)init { + if ((self = [super init])) + [self resetToDefaults]; + return self; +} + +- (id)initWithCoder:(NSCoder*)decoder { + if ((self = [super initWithCoder:decoder])) + [self resetToDefaults]; + return self; +} + +- (id)initWithFrame:(NSRect)frameRect { + if ((self = [super initWithFrame:frameRect])) + [self resetToDefaults]; + return self; +} + +// Accessors and mutators: + +@synthesize attachedMenu = attachedMenu_; + +@end // @implementation MenuButton + +@implementation MenuButton (Private) + +// Reset various settings of the button and its associated |ClickHoldButtonCell| +// to the standard state which provides reasonable defaults. +- (void)resetToDefaults { + ClickHoldButtonCell* cell = [self cell]; + DCHECK([cell isKindOfClass:[ClickHoldButtonCell class]]); + [cell setEnableClickHold:YES]; + [cell setClickHoldTimeout:0.0]; // Make menu trigger immediately. + [cell setAction:@selector(clickShowMenu:)]; + [cell setTarget:self]; + [cell setClickHoldAction:@selector(dragShowMenu:)]; + [cell setClickHoldTarget:self]; +} + +// Actually show the menu (in the correct location). |isDragging| indicates +// whether the mouse button is still down or not. +- (void)showMenu:(BOOL)isDragging { + if (![self attachedMenu]) { + LOG(WARNING) << "No menu available."; + if (isDragging) { + // If we're dragging, wait for mouse up. + [NSApp nextEventMatchingMask:NSLeftMouseUpMask + untilDate:[NSDate distantFuture] + inMode:NSEventTrackingRunLoopMode + dequeue:YES]; + } + return; + } + + // TODO(viettrungluu): Remove silly fudge factors (same ones as in + // delayedmenu_button.mm). + NSRect frame = [self convertRect:[self frame] + fromView:[self superview]]; + frame.origin.x -= 2.0; + frame.size.height += 10.0; + + // Make our pop-up button cell and set things up. This is, as of 10.5, the + // official Apple-recommended hack. Later, perhaps |-[NSMenu + // popUpMenuPositioningItem:atLocation:inView:]| may be a better option. + // However, using a pulldown has the benefit that Cocoa automatically places + // the menu correctly even when we're at the edge of the screen (including + // "dragging upwards" when the button is close to the bottom of the screen). + // A |scoped_nsobject| local variable cannot be used here because + // Accessibility on 10.5 grabs the NSPopUpButtonCell without retaining it, and + // uses it later. (This is fixed in 10.6.) + if (!popUpCell_.get()) { + popUpCell_.reset([[NSPopUpButtonCell alloc] initTextCell:@"" + pullsDown:YES]); + } + DCHECK(popUpCell_.get()); + [popUpCell_ setMenu:[self attachedMenu]]; + [popUpCell_ selectItem:nil]; + [popUpCell_ attachPopUpWithFrame:frame + inView:self]; + [popUpCell_ performClickWithFrame:frame + inView:self]; +} + +// Called when the button is clicked and released. (Shouldn't happen with +// timeout of 0, though there may be some strange pointing devices out there.) +- (void)clickShowMenu:(id)sender { + [self showMenu:NO]; +} + +// Called when the button is clicked and dragged/held. +- (void)dragShowMenu:(id)sender { + [self showMenu:YES]; +} + +@end // @implementation MenuButton (Private) diff --git a/chrome/browser/ui/cocoa/menu_button_unittest.mm b/chrome/browser/ui/cocoa/menu_button_unittest.mm new file mode 100644 index 0000000..ccfcb2c --- /dev/null +++ b/chrome/browser/ui/cocoa/menu_button_unittest.mm @@ -0,0 +1,50 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/clickhold_button_cell.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/menu_button.h" + +namespace { + +class MenuButtonTest : public CocoaTest { + public: + MenuButtonTest() { + NSRect frame = NSMakeRect(0, 0, 50, 30); + scoped_nsobject<MenuButton> button( + [[MenuButton alloc] initWithFrame:frame]); + button_ = button.get(); + scoped_nsobject<ClickHoldButtonCell> cell( + [[ClickHoldButtonCell alloc] initTextCell:@"Testing"]); + [button_ setCell:cell.get()]; + [[test_window() contentView] addSubview:button_]; + } + + MenuButton* button_; +}; + +TEST_VIEW(MenuButtonTest, button_); + +// Test assigning a menu, again mostly to ensure nothing leaks or crashes. +TEST_F(MenuButtonTest, MenuAssign) { + scoped_nsobject<NSMenu> menu([[NSMenu alloc] initWithTitle:@""]); + ASSERT_TRUE(menu.get()); + + [menu insertItemWithTitle:@"" action:nil keyEquivalent:@"" atIndex:0]; + [menu insertItemWithTitle:@"foo" action:nil keyEquivalent:@"" atIndex:1]; + [menu insertItemWithTitle:@"bar" action:nil keyEquivalent:@"" atIndex:2]; + [menu insertItemWithTitle:@"baz" action:nil keyEquivalent:@"" atIndex:3]; + + [button_ setAttachedMenu:menu]; + EXPECT_TRUE([button_ attachedMenu]); + + // TODO(viettrungluu): Display the menu. (The tough part is closing the menu, + // not opening it!) + + // Since |button_| doesn't retain menu, we should probably unset it here. + [button_ setAttachedMenu:nil]; +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/menu_controller.h b/chrome/browser/ui/cocoa/menu_controller.h new file mode 100644 index 0000000..c198c70 --- /dev/null +++ b/chrome/browser/ui/cocoa/menu_controller.h @@ -0,0 +1,67 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_MENU_CONTROLLER_H_ +#define CHROME_BROWSER_UI_COCOA_MENU_CONTROLLER_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" + +namespace menus { +class MenuModel; +} + +// A controller for the cross-platform menu model. The menu that's created +// has the tag and represented object set for each menu item. The object is a +// NSValue holding a pointer to the model for that level of the menu (to +// allow for hierarchical menus). The tag is the index into that model for +// that particular item. It is important that the model outlives this object +// as it only maintains weak references. +@interface MenuController : NSObject { + @protected + menus::MenuModel* model_; // weak + scoped_nsobject<NSMenu> menu_; + BOOL useWithPopUpButtonCell_; // If YES, 0th item is blank +} + +@property (nonatomic, assign) menus::MenuModel* model; +// Note that changing this will have no effect if you use +// |-initWithModel:useWithPopUpButtonCell:| or after the first call to |-menu|. +@property (nonatomic) BOOL useWithPopUpButtonCell; + +// NIB-based initializer. This does not create a menu. Clients can set the +// properties of the object and the menu will be created upon the first call to +// |-menu|. Note that the menu will be immutable after creation. +- (id)init; + +// Builds a NSMenu from the pre-built model (must not be nil). Changes made +// to the contents of the model after calling this will not be noticed. If +// the menu will be displayed by a NSPopUpButtonCell, it needs to be of a +// slightly different form (0th item is empty). Note this attribute of the menu +// cannot be changed after it has been created. +- (id)initWithModel:(menus::MenuModel*)model + useWithPopUpButtonCell:(BOOL)useWithCell; + +// Access to the constructed menu if the complex initializer was used. If the +// default initializer was used, then this will create the menu on first call. +- (NSMenu*)menu; + +@end + +// Exposed only for unit testing, do not call directly. +@interface MenuController (PrivateExposedForTesting) +- (BOOL)validateUserInterfaceItem:(id<NSValidatedUserInterfaceItem>)item; +@end + +// Protected methods that subclassers can override. +@interface MenuController (Protected) +- (void)addItemToMenu:(NSMenu*)menu + atIndex:(NSInteger)index + fromModel:(menus::MenuModel*)model + modelIndex:(int)modelIndex; +@end + +#endif // CHROME_BROWSER_UI_COCOA_MENU_CONTROLLER_H_ diff --git a/chrome/browser/ui/cocoa/menu_controller.mm b/chrome/browser/ui/cocoa/menu_controller.mm new file mode 100644 index 0000000..47f0c34 --- /dev/null +++ b/chrome/browser/ui/cocoa/menu_controller.mm @@ -0,0 +1,185 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/menu_controller.h" + +#include "app/l10n_util_mac.h" +#include "app/menus/accelerator_cocoa.h" +#include "app/menus/simple_menu_model.h" +#include "base/logging.h" +#include "base/sys_string_conversions.h" +#include "skia/ext/skia_utils_mac.h" +#include "third_party/skia/include/core/SkBitmap.h" + +@interface MenuController (Private) +- (NSMenu*)menuFromModel:(menus::MenuModel*)model; +- (void)addSeparatorToMenu:(NSMenu*)menu + atIndex:(int)index; +@end + +@implementation MenuController + +@synthesize model = model_; +@synthesize useWithPopUpButtonCell = useWithPopUpButtonCell_; + +- (id)init { + self = [super init]; + return self; +} + +- (id)initWithModel:(menus::MenuModel*)model + useWithPopUpButtonCell:(BOOL)useWithCell { + if ((self = [super init])) { + model_ = model; + useWithPopUpButtonCell_ = useWithCell; + [self menu]; + } + return self; +} + +- (void)dealloc { + model_ = NULL; + [super dealloc]; +} + +// Creates a NSMenu from the given model. If the model has submenus, this can +// be invoked recursively. +- (NSMenu*)menuFromModel:(menus::MenuModel*)model { + NSMenu* menu = [[[NSMenu alloc] initWithTitle:@""] autorelease]; + + // The indices may not always start at zero (the windows system menu is one + // example where this is used) so just make sure we can handle it. + // SimpleMenuModel currently always starts at 0. + int firstItemIndex = model->GetFirstItemIndex(menu); + DCHECK(firstItemIndex == 0); + const int count = model->GetItemCount(); + for (int index = firstItemIndex; index < firstItemIndex + count; index++) { + int modelIndex = index - firstItemIndex; + if (model->GetTypeAt(modelIndex) == menus::MenuModel::TYPE_SEPARATOR) { + [self addSeparatorToMenu:menu atIndex:index]; + } else { + [self addItemToMenu:menu atIndex:index fromModel:model + modelIndex:modelIndex]; + } + } + + return menu; +} + +// Adds a separator item at the given index. As the separator doesn't need +// anything from the model, this method doesn't need the model index as the +// other method below does. +- (void)addSeparatorToMenu:(NSMenu*)menu + atIndex:(int)index { + NSMenuItem* separator = [NSMenuItem separatorItem]; + [menu insertItem:separator atIndex:index]; +} + +// Adds an item or a hierarchical menu to the item at the |index|, +// associated with the entry in the model indentifed by |modelIndex|. +- (void)addItemToMenu:(NSMenu*)menu + atIndex:(NSInteger)index + fromModel:(menus::MenuModel*)model + modelIndex:(int)modelIndex { + NSString* label = + l10n_util::FixUpWindowsStyleLabel(model->GetLabelAt(modelIndex)); + scoped_nsobject<NSMenuItem> item( + [[NSMenuItem alloc] initWithTitle:label + action:@selector(itemSelected:) + keyEquivalent:@""]); + + // If the menu item has an icon, set it. + SkBitmap skiaIcon; + if (model->GetIconAt(modelIndex, &skiaIcon) && !skiaIcon.isNull()) { + NSImage* icon = gfx::SkBitmapToNSImage(skiaIcon); + if (icon) { + [item setImage:icon]; + } + } + + menus::MenuModel::ItemType type = model->GetTypeAt(modelIndex); + if (type == menus::MenuModel::TYPE_SUBMENU) { + // Recursively build a submenu from the sub-model at this index. + [item setTarget:nil]; + [item setAction:nil]; + menus::MenuModel* submenuModel = model->GetSubmenuModelAt(modelIndex); + NSMenu* submenu = + [self menuFromModel:(menus::SimpleMenuModel*)submenuModel]; + [item setSubmenu:submenu]; + } else { + // The MenuModel works on indexes so we can't just set the command id as the + // tag like we do in other menus. Also set the represented object to be + // the model so hierarchical menus check the correct index in the correct + // model. Setting the target to |self| allows this class to participate + // in validation of the menu items. + [item setTag:modelIndex]; + [item setTarget:self]; + NSValue* modelObject = [NSValue valueWithPointer:model]; + [item setRepresentedObject:modelObject]; // Retains |modelObject|. + menus::AcceleratorCocoa accelerator; + if (model->GetAcceleratorAt(modelIndex, &accelerator)) { + [item setKeyEquivalent:accelerator.characters()]; + [item setKeyEquivalentModifierMask:accelerator.modifiers()]; + } + } + [menu insertItem:item atIndex:index]; +} + +// Called before the menu is to be displayed to update the state (enabled, +// radio, etc) of each item in the menu. Also will update the title if +// the item is marked as "dynamic". +- (BOOL)validateUserInterfaceItem:(id<NSValidatedUserInterfaceItem>)item { + SEL action = [item action]; + if (action != @selector(itemSelected:)) + return NO; + + NSInteger modelIndex = [item tag]; + menus::MenuModel* model = + static_cast<menus::MenuModel*>( + [[(id)item representedObject] pointerValue]); + DCHECK(model); + if (model) { + BOOL checked = model->IsItemCheckedAt(modelIndex); + DCHECK([(id)item isKindOfClass:[NSMenuItem class]]); + [(id)item setState:(checked ? NSOnState : NSOffState)]; + [(id)item setHidden:(!model->IsVisibleAt(modelIndex))]; + if (model->IsLabelDynamicAt(modelIndex)) { + NSString* label = + l10n_util::FixUpWindowsStyleLabel(model->GetLabelAt(modelIndex)); + [(id)item setTitle:label]; + } + return model->IsEnabledAt(modelIndex); + } + return NO; +} + +// Called when the user chooses a particular menu item. |sender| is the menu +// item chosen. +- (void)itemSelected:(id)sender { + NSInteger modelIndex = [sender tag]; + menus::MenuModel* model = + static_cast<menus::MenuModel*>( + [[sender representedObject] pointerValue]); + DCHECK(model); + if (model) + model->ActivatedAt(modelIndex); +} + +- (NSMenu*)menu { + if (!menu_ && model_) { + menu_.reset([[self menuFromModel:model_] retain]); + // If this is to be used with a NSPopUpButtonCell, add an item at the 0th + // position that's empty. Doing it after the menu has been constructed won't + // complicate creation logic, and since the tags are model indexes, they + // are unaffected by the extra item. + if (useWithPopUpButtonCell_) { + scoped_nsobject<NSMenuItem> blankItem( + [[NSMenuItem alloc] initWithTitle:@"" action:nil keyEquivalent:@""]); + [menu_ insertItem:blankItem atIndex:0]; + } + } + return menu_.get(); +} + +@end diff --git a/chrome/browser/ui/cocoa/menu_controller_unittest.mm b/chrome/browser/ui/cocoa/menu_controller_unittest.mm new file mode 100644 index 0000000..9171adf --- /dev/null +++ b/chrome/browser/ui/cocoa/menu_controller_unittest.mm @@ -0,0 +1,197 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "app/menus/simple_menu_model.h" +#include "base/sys_string_conversions.h" +#include "base/utf_string_conversions.h" +#include "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "chrome/browser/ui/cocoa/menu_controller.h" +#include "grit/generated_resources.h" + +class MenuControllerTest : public CocoaTest { +}; + +// A menu delegate that counts the number of times certain things are called +// to make sure things are hooked up properly. +class Delegate : public menus::SimpleMenuModel::Delegate { + public: + Delegate() : execute_count_(0), enable_count_(0) { } + + virtual bool IsCommandIdChecked(int command_id) const { return false; } + virtual bool IsCommandIdEnabled(int command_id) const { + ++enable_count_; + return true; + } + virtual bool GetAcceleratorForCommandId( + int command_id, + menus::Accelerator* accelerator) { return false; } + virtual void ExecuteCommand(int command_id) { ++execute_count_; } + + int execute_count_; + mutable int enable_count_; +}; + +TEST_F(MenuControllerTest, EmptyMenu) { + Delegate delegate; + menus::SimpleMenuModel model(&delegate); + scoped_nsobject<MenuController> menu( + [[MenuController alloc] initWithModel:&model useWithPopUpButtonCell:NO]); + EXPECT_EQ([[menu menu] numberOfItems], 0); +} + +TEST_F(MenuControllerTest, BasicCreation) { + Delegate delegate; + menus::SimpleMenuModel model(&delegate); + model.AddItem(1, ASCIIToUTF16("one")); + model.AddItem(2, ASCIIToUTF16("two")); + model.AddItem(3, ASCIIToUTF16("three")); + model.AddSeparator(); + model.AddItem(4, ASCIIToUTF16("four")); + model.AddItem(5, ASCIIToUTF16("five")); + + scoped_nsobject<MenuController> menu( + [[MenuController alloc] initWithModel:&model useWithPopUpButtonCell:NO]); + EXPECT_EQ([[menu menu] numberOfItems], 6); + + // Check the title, tag, and represented object are correct for a random + // element. + NSMenuItem* itemTwo = [[menu menu] itemAtIndex:2]; + NSString* title = [itemTwo title]; + EXPECT_EQ(ASCIIToUTF16("three"), base::SysNSStringToUTF16(title)); + EXPECT_EQ([itemTwo tag], 2); + EXPECT_EQ([[itemTwo representedObject] pointerValue], &model); + + EXPECT_TRUE([[[menu menu] itemAtIndex:3] isSeparatorItem]); +} + +TEST_F(MenuControllerTest, Submenus) { + Delegate delegate; + menus::SimpleMenuModel model(&delegate); + model.AddItem(1, ASCIIToUTF16("one")); + menus::SimpleMenuModel submodel(&delegate); + submodel.AddItem(2, ASCIIToUTF16("sub-one")); + submodel.AddItem(3, ASCIIToUTF16("sub-two")); + submodel.AddItem(4, ASCIIToUTF16("sub-three")); + model.AddSubMenuWithStringId(5, IDS_ZOOM_MENU, &submodel); + model.AddItem(6, ASCIIToUTF16("three")); + + scoped_nsobject<MenuController> menu( + [[MenuController alloc] initWithModel:&model useWithPopUpButtonCell:NO]); + EXPECT_EQ([[menu menu] numberOfItems], 3); + + // Inspect the submenu to ensure it has correct properties. + NSMenu* submenu = [[[menu menu] itemAtIndex:1] submenu]; + EXPECT_TRUE(submenu); + EXPECT_EQ([submenu numberOfItems], 3); + + // Inspect one of the items to make sure it has the correct model as its + // represented object and the proper tag. + NSMenuItem* submenuItem = [submenu itemAtIndex:1]; + NSString* title = [submenuItem title]; + EXPECT_EQ(ASCIIToUTF16("sub-two"), base::SysNSStringToUTF16(title)); + EXPECT_EQ([submenuItem tag], 1); + EXPECT_EQ([[submenuItem representedObject] pointerValue], &submodel); + + // Make sure the item after the submenu is correct and its represented + // object is back to the top model. + NSMenuItem* item = [[menu menu] itemAtIndex:2]; + title = [item title]; + EXPECT_EQ(ASCIIToUTF16("three"), base::SysNSStringToUTF16(title)); + EXPECT_EQ([item tag], 2); + EXPECT_EQ([[item representedObject] pointerValue], &model); +} + +TEST_F(MenuControllerTest, EmptySubmenu) { + Delegate delegate; + menus::SimpleMenuModel model(&delegate); + model.AddItem(1, ASCIIToUTF16("one")); + menus::SimpleMenuModel submodel(&delegate); + model.AddSubMenuWithStringId(2, IDS_ZOOM_MENU, &submodel); + + scoped_nsobject<MenuController> menu( + [[MenuController alloc] initWithModel:&model useWithPopUpButtonCell:NO]); + EXPECT_EQ([[menu menu] numberOfItems], 2); +} + +TEST_F(MenuControllerTest, PopUpButton) { + Delegate delegate; + menus::SimpleMenuModel model(&delegate); + model.AddItem(1, ASCIIToUTF16("one")); + model.AddItem(2, ASCIIToUTF16("two")); + model.AddItem(3, ASCIIToUTF16("three")); + + // Menu should have an extra item inserted at position 0 that has an empty + // title. + scoped_nsobject<MenuController> menu( + [[MenuController alloc] initWithModel:&model useWithPopUpButtonCell:YES]); + EXPECT_EQ([[menu menu] numberOfItems], 4); + EXPECT_EQ(base::SysNSStringToUTF16([[[menu menu] itemAtIndex:0] title]), + string16()); + + // Make sure the tags are still correct (the index no longer matches the tag). + NSMenuItem* itemTwo = [[menu menu] itemAtIndex:2]; + EXPECT_EQ([itemTwo tag], 1); +} + +TEST_F(MenuControllerTest, Execute) { + Delegate delegate; + menus::SimpleMenuModel model(&delegate); + model.AddItem(1, ASCIIToUTF16("one")); + scoped_nsobject<MenuController> menu( + [[MenuController alloc] initWithModel:&model useWithPopUpButtonCell:NO]); + EXPECT_EQ([[menu menu] numberOfItems], 1); + + // Fake selecting the menu item, we expect the delegate to be told to execute + // a command. + NSMenuItem* item = [[menu menu] itemAtIndex:0]; + [[item target] performSelector:[item action] withObject:item]; + EXPECT_EQ(delegate.execute_count_, 1); +} + +void Validate(MenuController* controller, NSMenu* menu) { + for (int i = 0; i < [menu numberOfItems]; ++i) { + NSMenuItem* item = [menu itemAtIndex:i]; + [controller validateUserInterfaceItem:item]; + if ([item hasSubmenu]) + Validate(controller, [item submenu]); + } +} + +TEST_F(MenuControllerTest, Validate) { + Delegate delegate; + menus::SimpleMenuModel model(&delegate); + model.AddItem(1, ASCIIToUTF16("one")); + model.AddItem(2, ASCIIToUTF16("two")); + menus::SimpleMenuModel submodel(&delegate); + submodel.AddItem(2, ASCIIToUTF16("sub-one")); + model.AddSubMenuWithStringId(3, IDS_ZOOM_MENU, &submodel); + + scoped_nsobject<MenuController> menu( + [[MenuController alloc] initWithModel:&model useWithPopUpButtonCell:NO]); + EXPECT_EQ([[menu menu] numberOfItems], 3); + + Validate(menu.get(), [menu menu]); +} + +TEST_F(MenuControllerTest, DefaultInitializer) { + Delegate delegate; + menus::SimpleMenuModel model(&delegate); + model.AddItem(1, ASCIIToUTF16("one")); + model.AddItem(2, ASCIIToUTF16("two")); + model.AddItem(3, ASCIIToUTF16("three")); + + scoped_nsobject<MenuController> menu([[MenuController alloc] init]); + EXPECT_FALSE([menu menu]); + + [menu setModel:&model]; + [menu setUseWithPopUpButtonCell:NO]; + EXPECT_TRUE([menu menu]); + EXPECT_EQ(3, [[menu menu] numberOfItems]); + + // Check immutability. + model.AddItem(4, ASCIIToUTF16("four")); + EXPECT_EQ(3, [[menu menu] numberOfItems]); +} diff --git a/chrome/browser/ui/cocoa/menu_tracked_button.h b/chrome/browser/ui/cocoa/menu_tracked_button.h new file mode 100644 index 0000000..3ac9bd0 --- /dev/null +++ b/chrome/browser/ui/cocoa/menu_tracked_button.h @@ -0,0 +1,43 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_MENU_TRACKED_BUTTON_H_ +#define CHROME_BROWSER_UI_COCOA_MENU_TRACKED_BUTTON_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +// A MenuTrackedButton is meant to be used whenever a button is placed inside +// the custom view of an NSMenuItem. If the user opens the menu in a non-sticky +// fashion (i.e. clicks, holds, and drags) and then releases the mouse over +// a MenuTrackedButton, it will |-performClick:| itself. +// +// To create the hover state effects, there are two code paths. When the menu +// is opened sticky, a tracking rect produces mouse entered/exit events that +// allow for setting the cell's highlight property. When in a drag cycle, +// however, the only event received is |-mouseDragged:|. Therefore, a +// delayed selector is scheduled to poll the mouse location after each drag +// event. This checks if the user is still over the button after the drag +// events stop being sent, indicating either the user is hovering without +// movement or that the mouse is no longer over the receiver. +@interface MenuTrackedButton : NSButton { + @private + // If the button received a |-mouseEntered:| event. This short-circuits the + // custom drag tracking logic. + BOOL didEnter_; + + // Whether or not the user is in a click-drag-release event sequence. If so + // and this receives a |-mouseUp:|, then this will click itself. + BOOL tracking_; + + // In order to get hover effects when the menu is sticky-opened, a tracking + // rect needs to be installed on the button. + NSTrackingRectTag trackingTag_; +} + +@property (nonatomic, readonly, getter=isTracking) BOOL tracking; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_MENU_TRACKED_BUTTON_H_ diff --git a/chrome/browser/ui/cocoa/menu_tracked_button.mm b/chrome/browser/ui/cocoa/menu_tracked_button.mm new file mode 100644 index 0000000..37a79bb --- /dev/null +++ b/chrome/browser/ui/cocoa/menu_tracked_button.mm @@ -0,0 +1,118 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/menu_tracked_button.h" + +#include <cmath> + +@interface MenuTrackedButton (Private) +- (void)doHighlight:(BOOL)highlight; +- (void)checkMouseInRect; +- (NSRect)insetBounds; +- (BOOL)shouldHighlightOnHover; +@end + +@implementation MenuTrackedButton + +@synthesize tracking = tracking_; + +- (void)updateTrackingAreas { + [super updateTrackingAreas]; + [self removeTrackingRect:trackingTag_]; + trackingTag_ = [self addTrackingRect:NSInsetRect([self bounds], 1, 1) + owner:self + userData:NULL + assumeInside:NO]; +} + +- (void)viewDidMoveToWindow { + [self updateTrackingAreas]; + [self doHighlight:NO]; +} + +- (void)mouseEntered:(NSEvent*)theEvent { + if (!tracking_) { + didEnter_ = YES; + } + [self doHighlight:YES]; + [super mouseEntered:theEvent]; +} + +- (void)mouseExited:(NSEvent*)theEvent { + didEnter_ = NO; + tracking_ = NO; + [self doHighlight:NO]; + [super mouseExited:theEvent]; +} + +- (void)mouseDragged:(NSEvent*)theEvent { + tracking_ = !didEnter_; + + NSPoint point = [self convertPoint:[theEvent locationInWindow] fromView:nil]; + BOOL highlight = NSPointInRect(point, [self insetBounds]); + [self doHighlight:highlight]; + + // If tracking in non-sticky mode, poll the mouse cursor to see if it is still + // over the button and thus needs to be highlighted. The delay is the + // smallest that still produces the effect while minimizing jank. Smaller + // values make the selector fire too close to immediately/now for the mouse to + // have moved off the receiver, and larger values produce lag. + if (tracking_ && [self shouldHighlightOnHover]) { + [self performSelector:@selector(checkMouseInRect) + withObject:nil + afterDelay:0.05 + inModes:[NSArray arrayWithObject:NSEventTrackingRunLoopMode]]; + } + [super mouseDragged:theEvent]; +} + +- (void)mouseUp:(NSEvent*)theEvent { + [self doHighlight:NO]; + if (!tracking_) { + return [super mouseUp:theEvent]; + } + [self performClick:self]; + tracking_ = NO; +} + +- (void)doHighlight:(BOOL)highlight { + if (![self shouldHighlightOnHover]) { + return; + } + [[self cell] setHighlighted:highlight]; + [self setNeedsDisplay]; +} + +// Checks if the user's current mouse location is over this button. If it is, +// the user is merely hovering here. If it is not, then disable the highlight. +// If the menu is opened in non-sticky mode, the button does not receive enter/ +// exit mouse events and thus polling is necessary. +- (void)checkMouseInRect { + NSPoint point = [NSEvent mouseLocation]; + point = [[self window] convertScreenToBase:point]; + point = [self convertPoint:point fromView:nil]; + if (!NSPointInRect(point, [self insetBounds])) { + [self doHighlight:NO]; + } +} + +// Returns the bounds of the receiver slightly inset to avoid highlighting both +// buttons in a pair that overlap. +- (NSRect)insetBounds { + return NSInsetRect([self bounds], 2, 1); +} + +- (BOOL)shouldHighlightOnHover { + // Apple does not define NSAppKitVersionNumber10_5 when using the 10.5 SDK. + // The Internets have come up with this solution. + #ifndef NSAppKitVersionNumber10_5 + #define NSAppKitVersionNumber10_5 949 + #endif + + // There's a cell drawing bug in 10.5 that was fixed on 10.6. Hover states + // look terrible due to this, so disable highlighting on 10.5. + return std::floor(NSAppKitVersionNumber) > NSAppKitVersionNumber10_5; +} + +@end diff --git a/chrome/browser/ui/cocoa/menu_tracked_button_unittest.mm b/chrome/browser/ui/cocoa/menu_tracked_button_unittest.mm new file mode 100644 index 0000000..9b6f77a --- /dev/null +++ b/chrome/browser/ui/cocoa/menu_tracked_button_unittest.mm @@ -0,0 +1,117 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/menu_tracked_button.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +// This test does not test what you'd think it does. Testing around event +// tracking run loops is probably not worh the effort when the size of the +// helper MakeEvent() is larger than the class being tested. If we ever figure +// out a good way to test event tracking, this should be revisited. + +@interface MenuTrackedButtonTestReceiver : NSObject { + @public + BOOL didThat_; +} +- (void)doThat:(id)sender; +@end +@implementation MenuTrackedButtonTestReceiver +- (void)doThat:(id)sender { + didThat_ = YES; +} +@end + + +class MenuTrackedButtonTest : public CocoaTest { + public: + MenuTrackedButtonTest() : event_number_(0) {} + + void SetUp() { + listener_.reset([[MenuTrackedButtonTestReceiver alloc] init]); + button_.reset( + [[MenuTrackedButton alloc] initWithFrame:NSMakeRect(10, 10, 50, 50)]); + [[test_window() contentView] addSubview:button()]; + [button_ setTarget:listener()]; + [button_ setAction:@selector(doThat:)]; + } + + // Creates an event of |type|, with |location| in test_window()'s coordinates. + NSEvent* MakeEvent(NSEventType type, NSPoint location) { + NSTimeInterval now = [NSDate timeIntervalSinceReferenceDate]; + location = [test_window() convertBaseToScreen:location]; + if (type == NSMouseEntered || type == NSMouseExited) { + return [NSEvent enterExitEventWithType:type + location:location + modifierFlags:0 + timestamp:now + windowNumber:[test_window() windowNumber] + context:nil + eventNumber:event_number_++ + trackingNumber:0 + userData:nil]; + } else { + return [NSEvent mouseEventWithType:type + location:location + modifierFlags:0 + timestamp:now + windowNumber:[test_window() windowNumber] + context:nil + eventNumber:event_number_++ + clickCount:1 + pressure:1.0]; + } + } + + MenuTrackedButtonTestReceiver* listener() { return listener_.get(); } + NSButton* button() { return button_.get(); } + + scoped_nsobject<MenuTrackedButtonTestReceiver> listener_; + scoped_nsobject<MenuTrackedButton> button_; + NSInteger event_number_; +}; + +// User mouses over and then off. +TEST_F(MenuTrackedButtonTest, DISABLED_EnterExit) { + [NSApp postEvent:MakeEvent(NSMouseEntered, NSMakePoint(11, 11)) atStart:YES]; + [NSApp postEvent:MakeEvent(NSMouseExited, NSMakePoint(9, 9)) atStart:YES]; + EXPECT_FALSE(listener()->didThat_); +} + +// User mouses over, clicks, drags, and exits. +TEST_F(MenuTrackedButtonTest, DISABLED_EnterDragExit) { + [NSApp postEvent:MakeEvent(NSMouseEntered, NSMakePoint(11, 11)) atStart:YES]; + [NSApp postEvent:MakeEvent(NSLeftMouseDown, NSMakePoint(12, 12)) atStart:YES]; + [NSApp postEvent:MakeEvent(NSLeftMouseDragged, NSMakePoint(13, 11)) + atStart:YES]; + [NSApp postEvent:MakeEvent(NSLeftMouseDragged, NSMakePoint(13, 10)) + atStart:YES]; + [NSApp postEvent:MakeEvent(NSMouseExited, NSMakePoint(13, 9)) atStart:YES]; + EXPECT_FALSE(listener()->didThat_); +} + +// User mouses over, clicks, drags, and releases. +TEST_F(MenuTrackedButtonTest, DISABLED_EnterDragUp) { + [NSApp postEvent:MakeEvent(NSMouseEntered, NSMakePoint(11, 11)) atStart:YES]; + [NSApp postEvent:MakeEvent(NSLeftMouseDown, NSMakePoint(12, 12)) atStart:YES]; + [NSApp postEvent:MakeEvent(NSLeftMouseDragged, NSMakePoint(13, 13)) + atStart:YES]; + [NSApp postEvent:MakeEvent(NSLeftMouseUp, NSMakePoint(14, 14)) atStart:YES]; + EXPECT_TRUE(listener()->didThat_); +} + +// User drags in and releases. +TEST_F(MenuTrackedButtonTest, DISABLED_DragUp) { + [NSApp postEvent:MakeEvent(NSLeftMouseDragged, NSMakePoint(11, 11)) + atStart:YES]; + [NSApp postEvent:MakeEvent(NSLeftMouseDragged, NSMakePoint(12, 12)) + atStart:YES]; + [NSApp postEvent:MakeEvent(NSLeftMouseUp, NSMakePoint(13, 13)) + atStart:YES]; + EXPECT_TRUE(listener()->didThat_); +} diff --git a/chrome/browser/ui/cocoa/menu_tracked_root_view.h b/chrome/browser/ui/cocoa/menu_tracked_root_view.h new file mode 100644 index 0000000..c475783 --- /dev/null +++ b/chrome/browser/ui/cocoa/menu_tracked_root_view.h @@ -0,0 +1,25 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_MENU_TRACKED_ROOT_VIEW_H_ +#define CHROME_BROWSER_UI_COCOA_MENU_TRACKED_ROOT_VIEW_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +// An instance of MenuTrackedRootView should be the root of the view hierarchy +// of the custom view of NSMenuItems. If the user opens the menu in a non- +// sticky fashion (i.e. clicks, holds, and drags) and then releases the mouse +// over the menu item, it will cancel tracking on the |[menuItem_ menu]|. +@interface MenuTrackedRootView : NSView { + @private + // The menu item whose custom view's root view is an instance of this class. + NSMenuItem* menuItem_; // weak +} + +@property (assign, nonatomic) NSMenuItem* menuItem; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_MENU_TRACKED_ROOT_VIEW_H_ diff --git a/chrome/browser/ui/cocoa/menu_tracked_root_view.mm b/chrome/browser/ui/cocoa/menu_tracked_root_view.mm new file mode 100644 index 0000000..29dd93d --- /dev/null +++ b/chrome/browser/ui/cocoa/menu_tracked_root_view.mm @@ -0,0 +1,15 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/menu_tracked_root_view.h" + +@implementation MenuTrackedRootView + +@synthesize menuItem = menuItem_; + +- (void)mouseUp:(NSEvent*)theEvent { + [[menuItem_ menu] cancelTracking]; +} + +@end diff --git a/chrome/browser/ui/cocoa/menu_tracked_root_view_unittest.mm b/chrome/browser/ui/cocoa/menu_tracked_root_view_unittest.mm new file mode 100644 index 0000000..cb3eab1 --- /dev/null +++ b/chrome/browser/ui/cocoa/menu_tracked_root_view_unittest.mm @@ -0,0 +1,45 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/menu_tracked_root_view.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" +#import "third_party/ocmock/OCMock/OCMock.h" + +class MenuTrackedRootViewTest : public CocoaTest { + public: + void SetUp() { + CocoaTest::SetUp(); + view_.reset([[MenuTrackedRootView alloc] init]); + } + + scoped_nsobject<MenuTrackedRootView> view_; +}; + +TEST_F(MenuTrackedRootViewTest, MouseUp) { + id menu = [OCMockObject mockForClass:[NSMenu class]]; + [[menu expect] cancelTracking]; + + id menuItem = [OCMockObject mockForClass:[NSMenuItem class]]; + [[[menuItem stub] andReturn:menu] menu]; + + [view_ setMenuItem:menuItem]; + NSEvent* event = [NSEvent mouseEventWithType:NSLeftMouseUp + location:NSMakePoint(42, 42) + modifierFlags:0 + timestamp:0 + windowNumber:[test_window() windowNumber] + context:nil + eventNumber:1 + clickCount:1 + pressure:1.0]; + [view_ mouseUp:event]; + + [menu verify]; + [menuItem verify]; +} diff --git a/chrome/browser/ui/cocoa/multi_key_equivalent_button.h b/chrome/browser/ui/cocoa/multi_key_equivalent_button.h new file mode 100644 index 0000000..d87c739 --- /dev/null +++ b/chrome/browser/ui/cocoa/multi_key_equivalent_button.h @@ -0,0 +1,36 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_MULTI_KEY_EQUIVALENT_BUTTON_H_ +#define CHROME_BROWSER_UI_COCOA_MULTI_KEY_EQUIVALENT_BUTTON_H_ +#pragma once + +#import <AppKit/AppKit.h> + +#include <vector> + +struct KeyEquivalentAndModifierMask { + public: + KeyEquivalentAndModifierMask() : charCode(nil), mask(0) {} + NSString* charCode; + NSUInteger mask; +}; + +// MultiKeyEquivalentButton is an NSButton subclass that is capable of +// responding to additional key equivalents. It will respond to the ordinary +// NSButton key equivalent set by -setKeyEquivalent: and +// -setKeyEquivalentModifierMask:, and it will also respond to any additional +// equivalents provided to it in a KeyEquivalentAndModifierMask structure +// passed to -addKeyEquivalent:. + +@interface MultiKeyEquivalentButton : NSButton { + @private + std::vector<KeyEquivalentAndModifierMask> extraKeys_; +} + +- (void)addKeyEquivalent:(KeyEquivalentAndModifierMask)key; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_MULTI_KEY_EQUIVALENT_BUTTON_H_ diff --git a/chrome/browser/ui/cocoa/multi_key_equivalent_button.mm b/chrome/browser/ui/cocoa/multi_key_equivalent_button.mm new file mode 100644 index 0000000..cfe0a69 --- /dev/null +++ b/chrome/browser/ui/cocoa/multi_key_equivalent_button.mm @@ -0,0 +1,33 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/multi_key_equivalent_button.h" + +@implementation MultiKeyEquivalentButton + +- (void)addKeyEquivalent:(KeyEquivalentAndModifierMask)key { + extraKeys_.push_back(key); +} + +- (BOOL)performKeyEquivalent:(NSEvent*)event { + NSWindow* modalWindow = [NSApp modalWindow]; + NSWindow* window = [self window]; + + if ([self isEnabled] && + (!modalWindow || modalWindow == window || [window worksWhenModal])) { + for (size_t index = 0; index < extraKeys_.size(); ++index) { + KeyEquivalentAndModifierMask key = extraKeys_[index]; + if (key.charCode && + [key.charCode isEqualToString:[event charactersIgnoringModifiers]] && + ([event modifierFlags] & key.mask) == key.mask) { + [self performClick:self]; + return YES; + } + } + } + + return [super performKeyEquivalent:event]; +} + +@end diff --git a/chrome/browser/ui/cocoa/new_tab_button.h b/chrome/browser/ui/cocoa/new_tab_button.h new file mode 100644 index 0000000..9578cbf --- /dev/null +++ b/chrome/browser/ui/cocoa/new_tab_button.h @@ -0,0 +1,28 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_NEW_TAB_BUTTON +#define CHROME_BROWSER_UI_COCOA_NEW_TAB_BUTTON +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" + +// Overrides hit-test behavior to only accept clicks inside the image of the +// button, not just inside the bounding box. This could be abstracted to general +// use, but no other buttons are so irregularly shaped with respect to their +// bounding box. + +@interface NewTabButton : NSButton { + @private + scoped_nsobject<NSBezierPath> imagePath_; +} + +// Returns YES if the given point is over the button. |point| is in the +// superview's coordinate system. +- (BOOL)pointIsOverButton:(NSPoint)point; +@end + +#endif // CHROME_BROWSER_UI_COCOA_NEW_TAB_BUTTON diff --git a/chrome/browser/ui/cocoa/new_tab_button.mm b/chrome/browser/ui/cocoa/new_tab_button.mm new file mode 100644 index 0000000..6a97d3b --- /dev/null +++ b/chrome/browser/ui/cocoa/new_tab_button.mm @@ -0,0 +1,42 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/new_tab_button.h" + +@implementation NewTabButton + +// Approximate the shape. It doesn't need to be perfect. This will need to be +// updated if the size or shape of the icon ever changes. +// TODO(pinkerton): use a click mask image instead of hard-coding points. +- (NSBezierPath*)pathForButton { + if (imagePath_.get()) + return imagePath_.get(); + + // Cache the path as it doesn't change (the coordinates are local to this + // view). There's not much point making constants for these, as they are + // custom. + imagePath_.reset([[NSBezierPath bezierPath] retain]); + [imagePath_ moveToPoint:NSMakePoint(9, 7)]; + [imagePath_ lineToPoint:NSMakePoint(26, 7)]; + [imagePath_ lineToPoint:NSMakePoint(33, 23)]; + [imagePath_ lineToPoint:NSMakePoint(14, 23)]; + [imagePath_ lineToPoint:NSMakePoint(9, 7)]; + return imagePath_; +} + +- (BOOL)pointIsOverButton:(NSPoint)point { + NSPoint localPoint = [self convertPoint:point fromView:[self superview]]; + NSBezierPath* buttonPath = [self pathForButton]; + return [buttonPath containsPoint:localPoint]; +} + +// Override to only accept clicks within the bounds of the defined path, not +// the entire bounding box. |aPoint| is in the superview's coordinate system. +- (NSView*)hitTest:(NSPoint)aPoint { + if ([self pointIsOverButton:aPoint]) + return [super hitTest:aPoint]; + return nil; +} + +@end diff --git a/chrome/browser/ui/cocoa/notifications/balloon_controller.h b/chrome/browser/ui/cocoa/notifications/balloon_controller.h new file mode 100644 index 0000000..c60b9b9 --- /dev/null +++ b/chrome/browser/ui/cocoa/notifications/balloon_controller.h @@ -0,0 +1,98 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_NOTIFICATIONS_BALLOON_CONTROLLER_H_ +#define CHROME_BROWSER_UI_COCOA_NOTIFICATIONS_BALLOON_CONTROLLER_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "base/cocoa_protocols_mac.h" +#include "base/scoped_nsobject.h" +#include "base/scoped_ptr.h" + +class Balloon; +@class BalloonContentViewCocoa; +@class BalloonShelfViewCocoa; +class BalloonViewHost; +@class HoverImageButton; +@class MenuController; +class NotificationOptionsMenuModel; + +// The Balloon controller creates the view elements to display a +// notification balloon, resize it if the HTML contents of that +// balloon change, and move it when the collection of balloons is +// modified. +@interface BalloonController : NSWindowController<NSWindowDelegate> { + @private + // The balloon which represents the contents of this view. Weak pointer + // owned by the browser's NotificationUIManager. + Balloon* balloon_; + + // The view that contains the contents of the notification + IBOutlet BalloonContentViewCocoa* htmlContainer_; + + // The view that contains the controls of the notification + IBOutlet BalloonShelfViewCocoa* shelf_; + + // The close button. + IBOutlet NSButton* closeButton_; + + // Tracking region for the close button. + int closeButtonTrackingTag_; + + // The origin label. + IBOutlet NSTextField* originLabel_; + + // The options menu that appears when "options" is pressed. + IBOutlet HoverImageButton* optionsButton_; + scoped_ptr<NotificationOptionsMenuModel> menuModel_; + scoped_nsobject<MenuController> menuController_; + + // The host for the renderer of the HTML contents. + scoped_ptr<BalloonViewHost> htmlContents_; + + // The psn of the front application process. + ProcessSerialNumber frontProcessNum_; +} + +// Initialize with a balloon object containing the notification data. +- (id)initWithBalloon:(Balloon*)balloon; + +// Callback function for the close button. +- (IBAction)closeButtonPressed:(id)sender; + +// Callback function for the options button. +- (IBAction)optionsButtonPressed:(id)sender; + +// Callback function for the "revoke" option in the menu. +- (IBAction)permissionRevoked:(id)sender; + +// Closes the balloon. Can be called by the bridge or by the close +// button handler. +- (void)closeBalloon:(bool)byUser; + +// Update the contents of the balloon to match the notification. +- (void)updateContents; + +// Repositions the view to match the position and size of the balloon. +// Called by the bridge when the size changes. +- (void)repositionToBalloon; + +// The current size of the view, possibly subject to an animation completing. +- (int)desiredTotalWidth; +- (int)desiredTotalHeight; + +// The BalloonHost +- (BalloonViewHost*)getHost; + +// Handle the event if it is for the balloon. +- (BOOL)handleEvent:(NSEvent*)event; +@end + +@interface BalloonController (UnitTesting) +- (void)initializeHost; +@end + +#endif // CHROME_BROWSER_UI_COCOA_NOTIFICATIONS_BALLOON_CONTROLLER_H_ diff --git a/chrome/browser/ui/cocoa/notifications/balloon_controller.mm b/chrome/browser/ui/cocoa/notifications/balloon_controller.mm new file mode 100644 index 0000000..2a80512 --- /dev/null +++ b/chrome/browser/ui/cocoa/notifications/balloon_controller.mm @@ -0,0 +1,241 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/notifications/balloon_controller.h" + +#include "app/l10n_util.h" +#include "app/resource_bundle.h" +#import "base/cocoa_protocols_mac.h" +#include "base/mac_util.h" +#include "base/nsimage_cache_mac.h" +#import "base/scoped_nsobject.h" +#include "base/utf_string_conversions.h" +#include "chrome/browser/notifications/balloon.h" +#include "chrome/browser/notifications/desktop_notification_service.h" +#include "chrome/browser/notifications/notification.h" +#include "chrome/browser/notifications/notification_options_menu_model.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/renderer_host/render_view_host.h" +#import "chrome/browser/ui/cocoa/hover_image_button.h" +#import "chrome/browser/ui/cocoa/menu_controller.h" +#import "chrome/browser/ui/cocoa/notifications/balloon_view.h" +#include "chrome/browser/ui/cocoa/notifications/balloon_view_host_mac.h" +#include "grit/generated_resources.h" +#include "grit/theme_resources.h" + +namespace { + +// Margin, in pixels, between the notification frame and the contents +// of the notification. +const int kTopMargin = 1; +const int kBottomMargin = 2; +const int kLeftMargin = 2; +const int kRightMargin = 2; + +} // namespace + +@interface BalloonController (Private) +- (void)updateTrackingRect; +@end + +@implementation BalloonController + +- (id)initWithBalloon:(Balloon*)balloon { + NSString* nibpath = + [mac_util::MainAppBundle() pathForResource:@"Notification" + ofType:@"nib"]; + if ((self = [super initWithWindowNibPath:nibpath owner:self])) { + balloon_ = balloon; + [self initializeHost]; + menuModel_.reset(new NotificationOptionsMenuModel(balloon)); + menuController_.reset([[MenuController alloc] initWithModel:menuModel_.get() + useWithPopUpButtonCell:NO]); + } + return self; +} + +- (void)awakeFromNib { + DCHECK([self window]); + DCHECK_EQ(self, [[self window] delegate]); + + NSImage* image = nsimage_cache::ImageNamed(@"balloon_wrench.pdf"); + [optionsButton_ setDefaultImage:image]; + [optionsButton_ setDefaultOpacity:0.6]; + [optionsButton_ setHoverImage:image]; + [optionsButton_ setHoverOpacity:0.9]; + [optionsButton_ setPressedImage:image]; + [optionsButton_ setPressedOpacity:1.0]; + [[optionsButton_ cell] setHighlightsBy:NSNoCellMask]; + + NSString* sourceLabelText = l10n_util::GetNSStringF( + IDS_NOTIFICATION_BALLOON_SOURCE_LABEL, + balloon_->notification().display_source()); + [originLabel_ setStringValue:sourceLabelText]; + + // This condition is false in unit tests which have no RVH. + if (htmlContents_.get()) { + gfx::NativeView contents = htmlContents_->native_view(); + [contents setFrame:NSMakeRect(kLeftMargin, kTopMargin, 0, 0)]; + [[htmlContainer_ superview] addSubview:contents + positioned:NSWindowBelow + relativeTo:nil]; + } + + // Use the standard close button for a utility window. + closeButton_ = [NSWindow standardWindowButton:NSWindowCloseButton + forStyleMask:NSUtilityWindowMask]; + NSRect frame = [closeButton_ frame]; + [closeButton_ setFrame:NSMakeRect(6, 1, frame.size.width, frame.size.height)]; + [closeButton_ setTarget:self]; + [closeButton_ setAction:@selector(closeButtonPressed:)]; + [shelf_ addSubview:closeButton_]; + [self updateTrackingRect]; + + // Set the initial position without animating (the balloon should not + // yet be visible). + DCHECK(![[self window] isVisible]); + NSRect balloon_frame = NSMakeRect(balloon_->GetPosition().x(), + balloon_->GetPosition().y(), + [self desiredTotalWidth], + [self desiredTotalHeight]); + [[self window] setFrame:balloon_frame + display:NO]; +} + +- (void)updateTrackingRect { + if (closeButtonTrackingTag_) + [shelf_ removeTrackingRect:closeButtonTrackingTag_]; + + closeButtonTrackingTag_ = [shelf_ addTrackingRect:[closeButton_ frame] + owner:self + userData:nil + assumeInside:NO]; +} + +- (BOOL)handleEvent:(NSEvent*)event { + BOOL eventHandled = NO; + if ([event type] == NSLeftMouseDown) { + NSPoint mouse = [shelf_ convertPoint:[event locationInWindow] + fromView:nil]; + if (NSPointInRect(mouse, [closeButton_ frame])) { + [closeButton_ mouseDown:event]; + + // Bring back the front process that is deactivated when we click the + // close button. + if (frontProcessNum_.highLongOfPSN || frontProcessNum_.lowLongOfPSN) { + SetFrontProcessWithOptions(&frontProcessNum_, + kSetFrontProcessFrontWindowOnly); + frontProcessNum_.highLongOfPSN = 0; + frontProcessNum_.lowLongOfPSN = 0; + } + + eventHandled = YES; + } else if (NSPointInRect(mouse, [optionsButton_ frame])) { + [optionsButton_ mouseDown:event]; + eventHandled = YES; + } + } + return eventHandled; +} + +- (void) mouseEntered:(NSEvent*)event { + [[closeButton_ cell] setHighlighted:YES]; + + // Remember the current front process so that we can bring it back later. + if (!frontProcessNum_.highLongOfPSN && !frontProcessNum_.lowLongOfPSN) + GetFrontProcess(&frontProcessNum_); +} + +- (void) mouseExited:(NSEvent*)event { + [[closeButton_ cell] setHighlighted:NO]; + + frontProcessNum_.highLongOfPSN = 0; + frontProcessNum_.lowLongOfPSN = 0; +} + +- (IBAction)optionsButtonPressed:(id)sender { + [NSMenu popUpContextMenu:[menuController_ menu] + withEvent:[NSApp currentEvent] + forView:optionsButton_]; +} + +- (IBAction)permissionRevoked:(id)sender { + DesktopNotificationService* service = + balloon_->profile()->GetDesktopNotificationService(); + service->DenyPermission(balloon_->notification().origin_url()); +} + +- (IBAction)closeButtonPressed:(id)sender { + [self closeBalloon:YES]; + [self close]; +} + +- (void)close { + if (closeButtonTrackingTag_) + [shelf_ removeTrackingRect:closeButtonTrackingTag_]; + + [super close]; +} + +- (void)closeBalloon:(bool)byUser { + if (!balloon_) + return; + [self close]; + if (htmlContents_.get()) + htmlContents_->Shutdown(); + if (balloon_) + balloon_->OnClose(byUser); + balloon_ = NULL; +} + +- (void)updateContents { + DCHECK(htmlContents_.get()) << "BalloonView::Update called before Show"; + if (htmlContents_->render_view_host()) + htmlContents_->render_view_host()->NavigateToURL( + balloon_->notification().content_url()); +} + +- (void)repositionToBalloon { + DCHECK(balloon_); + int x = balloon_->GetPosition().x(); + int y = balloon_->GetPosition().y(); + int w = [self desiredTotalWidth]; + int h = [self desiredTotalHeight]; + + if (htmlContents_.get()) + htmlContents_->UpdateActualSize(balloon_->content_size()); + + [[[self window] animator] setFrame:NSMakeRect(x, y, w, h) + display:YES]; +} + +// Returns the total width the view should be to accommodate the balloon. +- (int)desiredTotalWidth { + return (balloon_ ? balloon_->content_size().width() : 0) + + kLeftMargin + kRightMargin; +} + +// Returns the total height the view should be to accommodate the balloon. +- (int)desiredTotalHeight { + return (balloon_ ? balloon_->content_size().height() : 0) + + kTopMargin + kBottomMargin + [shelf_ frame].size.height; +} + +// Returns the BalloonHost { +- (BalloonViewHost*) getHost { + return htmlContents_.get(); +} + +// Initializes the renderer host showing the HTML contents. +- (void)initializeHost { + htmlContents_.reset(new BalloonViewHost(balloon_)); + htmlContents_->Init(); +} + +// NSWindowDelegate notification. +- (void)windowWillClose:(NSNotification*)notif { + [self autorelease]; +} + +@end diff --git a/chrome/browser/ui/cocoa/notifications/balloon_controller_unittest.mm b/chrome/browser/ui/cocoa/notifications/balloon_controller_unittest.mm new file mode 100644 index 0000000..6cedbd4 --- /dev/null +++ b/chrome/browser/ui/cocoa/notifications/balloon_controller_unittest.mm @@ -0,0 +1,112 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/scoped_nsobject.h" +#include "base/utf_string_conversions.h" +#include "chrome/browser/notifications/balloon.h" +#include "chrome/browser/notifications/balloon_collection.h" +#include "chrome/browser/notifications/notification.h" +#include "chrome/browser/renderer_host/test/test_render_view_host.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#include "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "chrome/browser/ui/cocoa/notifications/balloon_controller.h" +#include "chrome/test/testing_profile.h" +#import "third_party/ocmock/OCMock/OCMock.h" + +// Subclass balloon controller and mock out the initialization of the RVH. +@interface TestBalloonController : BalloonController { +} +- (void)initializeHost; +@end + +@implementation TestBalloonController +- (void)initializeHost {} +@end + +namespace { + +// Use a dummy balloon collection for testing. +class MockBalloonCollection : public BalloonCollection { + virtual void Add(const Notification& notification, + Profile* profile) {} + virtual bool RemoveById(const std::string& id) { return false; } + virtual bool RemoveBySourceOrigin(const GURL& origin) { return false; } + virtual bool HasSpace() const { return true; } + virtual void ResizeBalloon(Balloon* balloon, const gfx::Size& size) {}; + virtual void DisplayChanged() {} + virtual void OnBalloonClosed(Balloon* source) {}; + virtual const Balloons& GetActiveBalloons() { + NOTREACHED(); + return balloons_; + } + private: + Balloons balloons_; +}; + +class BalloonControllerTest : public RenderViewHostTestHarness { + public: + BalloonControllerTest() : + ui_thread_(BrowserThread::UI, MessageLoop::current()), + io_thread_(BrowserThread::IO, MessageLoop::current()) { + } + + virtual void SetUp() { + RenderViewHostTestHarness::SetUp(); + CocoaTest::BootstrapCocoa(); + profile_.reset(new TestingProfile()); + profile_->CreateRequestContext(); + browser_.reset(new Browser(Browser::TYPE_NORMAL, profile_.get())); + collection_.reset(new MockBalloonCollection()); + } + + virtual void TearDown() { + MessageLoop::current()->RunAllPending(); + RenderViewHostTestHarness::TearDown(); + } + + protected: + BrowserThread ui_thread_; + BrowserThread io_thread_; + scoped_ptr<TestingProfile> profile_; + scoped_ptr<Browser> browser_; + scoped_ptr<BalloonCollection> collection_; +}; + +TEST_F(BalloonControllerTest, ShowAndCloseTest) { + Notification n(GURL("http://www.google.com"), GURL("http://www.google.com"), + ASCIIToUTF16("http://www.google.com"), string16(), + new NotificationObjectProxy(-1, -1, -1, false)); + scoped_ptr<Balloon> balloon( + new Balloon(n, profile_.get(), collection_.get())); + balloon->SetPosition(gfx::Point(1, 1), false); + balloon->set_content_size(gfx::Size(100, 100)); + + BalloonController* controller = + [[TestBalloonController alloc] initWithBalloon:balloon.get()]; + + [controller showWindow:nil]; + [controller closeBalloon:YES]; +} + +TEST_F(BalloonControllerTest, SizesTest) { + Notification n(GURL("http://www.google.com"), GURL("http://www.google.com"), + ASCIIToUTF16("http://www.google.com"), string16(), + new NotificationObjectProxy(-1, -1, -1, false)); + scoped_ptr<Balloon> balloon( + new Balloon(n, profile_.get(), collection_.get())); + balloon->SetPosition(gfx::Point(1, 1), false); + balloon->set_content_size(gfx::Size(100, 100)); + + BalloonController* controller = + [[TestBalloonController alloc] initWithBalloon:balloon.get()]; + + [controller showWindow:nil]; + + EXPECT_TRUE([controller desiredTotalWidth] > 100); + EXPECT_TRUE([controller desiredTotalHeight] > 100); + + [controller closeBalloon:YES]; +} + +} diff --git a/chrome/browser/ui/cocoa/notifications/balloon_view.h b/chrome/browser/ui/cocoa/notifications/balloon_view.h new file mode 100644 index 0000000..b742eaf --- /dev/null +++ b/chrome/browser/ui/cocoa/notifications/balloon_view.h @@ -0,0 +1,28 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_NOTIFICATIONS_BALLOON_VIEW_H_ +#define CHROME_BROWSER_UI_COCOA_NOTIFICATIONS_BALLOON_VIEW_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +@interface BalloonWindow : NSWindow { +} +@end + +// This view class draws a frame around the HTML contents of a +// notification balloon. +@interface BalloonContentViewCocoa : NSView { +} +@end + +// This view class draws the shelf of a notification balloon, +// containing the controls. +@interface BalloonShelfViewCocoa : NSView { +} +@end + + +#endif // CHROME_BROWSER_UI_COCOA_NOTIFICATIONS_BALLOON_VIEW_H_ diff --git a/chrome/browser/ui/cocoa/notifications/balloon_view.mm b/chrome/browser/ui/cocoa/notifications/balloon_view.mm new file mode 100644 index 0000000..e88331a --- /dev/null +++ b/chrome/browser/ui/cocoa/notifications/balloon_view.mm @@ -0,0 +1,84 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/notifications/balloon_view.h" + +#import <Cocoa/Cocoa.h> + +#include "base/basictypes.h" +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/notifications/balloon_controller.h" +#import "third_party/GTM/AppKit/GTMNSBezierPath+RoundRect.h" + +namespace { + +const int kRoundedCornerSize = 6; + +} // namespace + +@implementation BalloonWindow +- (id)initWithContentRect:(NSRect)contentRect + styleMask:(unsigned int)aStyle + backing:(NSBackingStoreType)bufferingType + defer:(BOOL)flag { + self = [super initWithContentRect:contentRect + styleMask:NSBorderlessWindowMask + backing:NSBackingStoreBuffered + defer:NO]; + if (self) { + [self setLevel:NSStatusWindowLevel]; + [self setOpaque:NO]; + [self setBackgroundColor:[NSColor clearColor]]; + } + return self; +} + +- (BOOL)canBecomeMainWindow { + return NO; +} + +- (void)sendEvent:(NSEvent*)event { + // We do not want to bring chrome window to foreground when we click on close + // or option button. To do this, we have to intercept the event. + BalloonController* delegate = + static_cast<BalloonController*>([self delegate]); + if (![delegate handleEvent:event]) { + [super sendEvent:event]; + } +} +@end + +@implementation BalloonShelfViewCocoa +- (void)drawRect:(NSRect)rect { + NSBezierPath* path = + [NSBezierPath gtm_bezierPathWithRoundRect:[self bounds] + topLeftCornerRadius:kRoundedCornerSize + topRightCornerRadius:kRoundedCornerSize + bottomLeftCornerRadius:0.0 + bottomRightCornerRadius:0.0]; + + [[NSColor colorWithCalibratedWhite:0.957 alpha:1.0] set]; + [path fill]; + + [[NSColor colorWithCalibratedWhite:0.8 alpha:1.0] set]; + NSPoint origin = [self bounds].origin; + [NSBezierPath strokeLineFromPoint:origin + toPoint:NSMakePoint(origin.x + NSWidth([self bounds]), origin.y)]; +} +@end + +@implementation BalloonContentViewCocoa +- (void)drawRect:(NSRect)rect { + rect = NSInsetRect([self bounds], 0.5, 0.5); + NSBezierPath* path = + [NSBezierPath gtm_bezierPathWithRoundRect:rect + topLeftCornerRadius:0.0 + topRightCornerRadius:0.0 + bottomLeftCornerRadius:kRoundedCornerSize + bottomRightCornerRadius:kRoundedCornerSize]; + [[NSColor whiteColor] set]; + [path setLineWidth:3]; + [path stroke]; +} +@end diff --git a/chrome/browser/ui/cocoa/notifications/balloon_view_bridge.h b/chrome/browser/ui/cocoa/notifications/balloon_view_bridge.h new file mode 100644 index 0000000..3dff871 --- /dev/null +++ b/chrome/browser/ui/cocoa/notifications/balloon_view_bridge.h @@ -0,0 +1,40 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_NOTIFICATIONS_BALLOON_VIEW_BRIDGE_H_ +#define CHROME_BROWSER_UI_COCOA_NOTIFICATIONS_BALLOON_VIEW_BRIDGE_H_ +#pragma once + +#include "chrome/browser/notifications/balloon.h" + +@class BalloonController; +class BalloonHost; +namespace gfx { +class Size; +} + +// Bridges from the cross-platform BalloonView interface to the Cocoa +// controller which will draw the view on screen. +class BalloonViewBridge : public BalloonView { + public: + BalloonViewBridge(); + ~BalloonViewBridge(); + + // BalloonView interface. + virtual void Show(Balloon* balloon); + virtual void Update(); + virtual void RepositionToBalloon(); + virtual void Close(bool by_user); + virtual gfx::Size GetSize() const; + virtual BalloonHost* GetHost() const; + + private: + // Weak pointer to the balloon controller which manages the UI. + // This object cleans itself up when its windows close. + BalloonController* controller_; + + DISALLOW_COPY_AND_ASSIGN(BalloonViewBridge); +}; + +#endif // CHROME_BROWSER_UI_COCOA_NOTIFICATIONS_BALLOON_VIEW_BRIDGE_H_ diff --git a/chrome/browser/ui/cocoa/notifications/balloon_view_bridge.mm b/chrome/browser/ui/cocoa/notifications/balloon_view_bridge.mm new file mode 100644 index 0000000..cea53b1 --- /dev/null +++ b/chrome/browser/ui/cocoa/notifications/balloon_view_bridge.mm @@ -0,0 +1,48 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/notifications/balloon_view_bridge.h" + +#include "chrome/browser/ui/cocoa/notifications/balloon_controller.h" +#import "chrome/browser/ui/cocoa/notifications/balloon_view_host_mac.h" +#include "gfx/size.h" + +#import <Cocoa/Cocoa.h> + +BalloonViewBridge::BalloonViewBridge() : + controller_(NULL) { +} + +BalloonViewBridge::~BalloonViewBridge() { +} + +void BalloonViewBridge::Close(bool by_user) { + [controller_ closeBalloon:by_user]; +} + +gfx::Size BalloonViewBridge::GetSize() const { + if (controller_) + return gfx::Size([controller_ desiredTotalWidth], + [controller_ desiredTotalHeight]); + else + return gfx::Size(); +} + +void BalloonViewBridge::RepositionToBalloon() { + [controller_ repositionToBalloon]; +} + +void BalloonViewBridge::Show(Balloon* balloon) { + controller_ = [[BalloonController alloc] initWithBalloon:balloon]; + [controller_ setShouldCascadeWindows:NO]; + [controller_ showWindow:nil]; +} + +BalloonHost* BalloonViewBridge::GetHost() const { + return [controller_ getHost]; +} + +void BalloonViewBridge::Update() { + [controller_ updateContents]; +} diff --git a/chrome/browser/ui/cocoa/notifications/balloon_view_host_mac.h b/chrome/browser/ui/cocoa/notifications/balloon_view_host_mac.h new file mode 100644 index 0000000..a7bff8f --- /dev/null +++ b/chrome/browser/ui/cocoa/notifications/balloon_view_host_mac.h @@ -0,0 +1,42 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_NOTIFICATIONS_BALLOON_VIEW_HOST_MAC_H_ +#define CHROME_BROWSER_UI_COCOA_NOTIFICATIONS_BALLOON_VIEW_HOST_MAC_H_ +#pragma once + +#include "chrome/browser/notifications/balloon_host.h" + +class RenderWidgetHostView; +class RenderWidgetHostViewMac; + +// BalloonViewHost class is a delegate to the renderer host for the HTML +// notification. When initialized it creates a new RenderViewHost and loads +// the contents of the toast into it. It also handles links within the toast, +// loading them into a new tab. +class BalloonViewHost : public BalloonHost { + public: + explicit BalloonViewHost(Balloon* balloon); + + ~BalloonViewHost(); + + // Changes the size of the balloon. + void UpdateActualSize(const gfx::Size& new_size); + + // Accessors. + gfx::NativeView native_view() const; + + protected: + virtual void InitRenderWidgetHostView(); + virtual RenderWidgetHostView* render_widget_host_view() const; + + private: + // The Mac-specific widget host view. This is owned by its native view, + // which this class frees in its destructor. + RenderWidgetHostViewMac* render_widget_host_view_; + + DISALLOW_COPY_AND_ASSIGN(BalloonViewHost); +}; + +#endif // CHROME_BROWSER_UI_COCOA_NOTIFICATIONS_BALLOON_VIEW_HOST_MAC_H_ diff --git a/chrome/browser/ui/cocoa/notifications/balloon_view_host_mac.mm b/chrome/browser/ui/cocoa/notifications/balloon_view_host_mac.mm new file mode 100644 index 0000000..1f2c916 --- /dev/null +++ b/chrome/browser/ui/cocoa/notifications/balloon_view_host_mac.mm @@ -0,0 +1,39 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/notifications/balloon_view_host_mac.h" + +#include "chrome/browser/renderer_host/render_view_host.h" +#include "chrome/browser/renderer_host/render_widget_host_view_mac.h" + +BalloonViewHost::BalloonViewHost(Balloon* balloon) + : BalloonHost(balloon) { +} + +BalloonViewHost::~BalloonViewHost() { + Shutdown(); +} + +void BalloonViewHost::UpdateActualSize(const gfx::Size& new_size) { + NSView* view = render_widget_host_view_->native_view(); + NSRect frame = [view frame]; + frame.size.width = new_size.width(); + frame.size.height = new_size.height(); + + [view setFrame:frame]; + [view setNeedsDisplay:YES]; +} + +gfx::NativeView BalloonViewHost::native_view() const { + return render_widget_host_view_->native_view(); +} + +void BalloonViewHost::InitRenderWidgetHostView() { + DCHECK(render_view_host_); + render_widget_host_view_ = new RenderWidgetHostViewMac(render_view_host_); +} + +RenderWidgetHostView* BalloonViewHost::render_widget_host_view() const { + return render_widget_host_view_; +} diff --git a/chrome/browser/ui/cocoa/nsimage_cache_unittest.mm b/chrome/browser/ui/cocoa/nsimage_cache_unittest.mm new file mode 100644 index 0000000..7f2e2fb --- /dev/null +++ b/chrome/browser/ui/cocoa/nsimage_cache_unittest.mm @@ -0,0 +1,75 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/file_path.h" +#include "base/mac_util.h" +#include "base/nsimage_cache_mac.h" +#include "base/path_service.h" +#include "chrome/common/chrome_constants.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +// This tests nsimage_cache, which lives in base/. The unit test is in +// chrome/ because it depends on having a built-up Chrome present. + +namespace { + +class NSImageCacheTest : public PlatformTest { + public: + NSImageCacheTest() { + // Look in the framework bundle for resources. + FilePath path; + PathService::Get(base::DIR_EXE, &path); + path = path.Append(chrome::kFrameworkName); + mac_util::SetOverrideAppBundlePath(path); + } + virtual ~NSImageCacheTest() { + mac_util::SetOverrideAppBundle(nil); + } +}; + +TEST_F(NSImageCacheTest, LookupFound) { + EXPECT_TRUE(nsimage_cache::ImageNamed(@"back_Template.pdf") != nil) + << "Failed to find the toolbar image?"; +} + +TEST_F(NSImageCacheTest, LookupCached) { + EXPECT_EQ(nsimage_cache::ImageNamed(@"back_Template.pdf"), + nsimage_cache::ImageNamed(@"back_Template.pdf")) + << "Didn't get the same NSImage back?"; +} + +TEST_F(NSImageCacheTest, LookupMiss) { + EXPECT_TRUE(nsimage_cache::ImageNamed(@"should_not.exist") == nil) + << "There shouldn't be an image with this name?"; +} + +TEST_F(NSImageCacheTest, LookupFoundAndClear) { + NSImage *first = nsimage_cache::ImageNamed(@"back_Template.pdf"); + // Hang on to the first image so that the second one doesn't get allocated + // in the same location by (bad) luck. + [[first retain] autorelease]; + EXPECT_TRUE(first != nil) + << "Failed to find the toolbar image?"; + nsimage_cache::Clear(); + NSImage *second = nsimage_cache::ImageNamed(@"back_Template.pdf"); + EXPECT_TRUE(second != nil) + << "Failed to find the toolbar image...again?"; + EXPECT_NE(second, first) + << "how'd we get the same image after a cache clear?"; +} + +TEST_F(NSImageCacheTest, AutoTemplating) { + NSImage *templateImage = nsimage_cache::ImageNamed(@"back_Template.pdf"); + EXPECT_TRUE([templateImage isTemplate] == YES) + << "Image ending in 'Template' should be marked as being a template"; + NSImage *nonTemplateImage = nsimage_cache::ImageNamed(@"aliasCursor.png"); + EXPECT_FALSE([nonTemplateImage isTemplate] == YES) + << "Image not ending in 'Template' should not be marked as being a " + "template"; +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/nsmenuitem_additions.h b/chrome/browser/ui/cocoa/nsmenuitem_additions.h new file mode 100644 index 0000000..830498a --- /dev/null +++ b/chrome/browser/ui/cocoa/nsmenuitem_additions.h @@ -0,0 +1,19 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_NSMENUITEM_ADDITIONS_H_ +#define CHROME_BROWSER_UI_COCOA_NSMENUITEM_ADDITIONS_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +@interface NSMenuItem(ChromeAdditions) + +// Returns true exactly if the menu item would fire if it would be put into +// a menu and then |menu performKeyEquivalent:event| was called. +- (BOOL)cr_firesForKeyEvent:(NSEvent*)event; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_NSMENUITEM_ADDITIONS_H_ diff --git a/chrome/browser/ui/cocoa/nsmenuitem_additions.mm b/chrome/browser/ui/cocoa/nsmenuitem_additions.mm new file mode 100644 index 0000000..90bb353 --- /dev/null +++ b/chrome/browser/ui/cocoa/nsmenuitem_additions.mm @@ -0,0 +1,103 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/nsmenuitem_additions.h" + +#include <Carbon/Carbon.h> + +#include "base/logging.h" + +@implementation NSMenuItem(ChromeAdditions) + +- (BOOL)cr_firesForKeyEvent:(NSEvent*)event { + DCHECK([event type] == NSKeyDown); + if (![self isEnabled]) + return NO; + + // In System Preferences->Keyboard->Keyboard Shortcuts, it is possible to add + // arbitrary keyboard shortcuts to applications. It is not documented how this + // works in detail, but |NSMenuItem| has a method |userKeyEquivalent| that + // sounds related. + // However, it looks like |userKeyEquivalent| is equal to |keyEquivalent| when + // a user shortcut is set in system preferences, i.e. Cocoa automatically + // sets/overwrites |keyEquivalent| as well. Hence, this method can ignore + // |userKeyEquivalent| and check |keyEquivalent| only. + + // Menu item key equivalents are nearly all stored without modifiers. The + // exception is shift, which is included in the key and not in the modifiers + // for printable characters (but not for stuff like arrow keys etc). + NSString* eventString = [event charactersIgnoringModifiers]; + NSUInteger eventModifiers = + [event modifierFlags] & NSDeviceIndependentModifierFlagsMask; + + if ([eventString length] == 0 || [[self keyEquivalent] length] == 0) + return NO; + + // Turns out esc never fires unless cmd or ctrl is down. + if ([event keyCode] == kVK_Escape && + (eventModifiers & (NSControlKeyMask | NSCommandKeyMask)) == 0) + return NO; + + // From the |NSMenuItem setKeyEquivalent:| documentation: + // + // If you want to specify the Backspace key as the key equivalent for a menu + // item, use a single character string with NSBackspaceCharacter (defined in + // NSText.h as 0x08) and for the Forward Delete key, use NSDeleteCharacter + // (defined in NSText.h as 0x7F). Note that these are not the same characters + // you get from an NSEvent key-down event when pressing those keys. + if ([[self keyEquivalent] characterAtIndex:0] == NSBackspaceCharacter + && [eventString characterAtIndex:0] == NSDeleteCharacter) { + unichar chr = NSBackspaceCharacter; + eventString = [NSString stringWithCharacters:&chr length:1]; + + // Make sure "shift" is not removed from modifiers below. + eventModifiers |= NSFunctionKeyMask; + } + if ([[self keyEquivalent] characterAtIndex:0] == NSDeleteCharacter && + [eventString characterAtIndex:0] == NSDeleteFunctionKey) { + unichar chr = NSDeleteCharacter; + eventString = [NSString stringWithCharacters:&chr length:1]; + + // Make sure "shift" is not removed from modifiers below. + eventModifiers |= NSFunctionKeyMask; + } + + // cmd-opt-a gives some weird char as characters and "a" as + // charactersWithoutModifiers with an US layout, but an "a" as characters and + // a weird char as "charactersWithoutModifiers" with a cyrillic layout. Oh, + // Cocoa! Instead of getting the current layout from Text Input Services, + // and then requesting the kTISPropertyUnicodeKeyLayoutData and looking in + // there, let's try a pragmatic hack. + if ([eventString characterAtIndex:0] > 0x7f && + [[event characters] length] > 0 && + [[event characters] characterAtIndex:0] <= 0x7f) + eventString = [event characters]; + + // When both |characters| and |charactersIgnoringModifiers| are ascii, we + // want to use |characters| if it's a character and + // |charactersIgnoringModifiers| else (on dvorak, cmd-shift-z should fire + // "cmd-:" instead of "cmd-;", but on dvorak-qwerty, cmd-shift-z should fire + // cmd-shift-z instead of cmd-:). + if ([eventString characterAtIndex:0] <= 0x7f && + [[event characters] length] > 0 && + [[event characters] characterAtIndex:0] <= 0x7f && + isalpha([[event characters] characterAtIndex:0])) + eventString = [event characters]; + + // Clear shift key for printable characters. + if ((eventModifiers & (NSNumericPadKeyMask | NSFunctionKeyMask)) == 0 && + [[self keyEquivalent] characterAtIndex:0] != '\r') + eventModifiers &= ~NSShiftKeyMask; + + // Clear all non-interesting modifiers + eventModifiers &= NSCommandKeyMask | + NSControlKeyMask | + NSAlternateKeyMask | + NSShiftKeyMask; + + return [eventString isEqualToString:[self keyEquivalent]] + && eventModifiers == [self keyEquivalentModifierMask]; +} + +@end diff --git a/chrome/browser/ui/cocoa/nsmenuitem_additions_unittest.mm b/chrome/browser/ui/cocoa/nsmenuitem_additions_unittest.mm new file mode 100644 index 0000000..ecbf07d --- /dev/null +++ b/chrome/browser/ui/cocoa/nsmenuitem_additions_unittest.mm @@ -0,0 +1,351 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/nsmenuitem_additions.h" + +#include <Carbon/Carbon.h> + +#include <ostream> + +#include "base/scoped_nsobject.h" +#include "base/sys_string_conversions.h" +#include "testing/gtest/include/gtest/gtest.h" + +NSEvent* KeyEvent(const NSUInteger modifierFlags, + NSString* chars, + NSString* charsNoMods, + const NSUInteger keyCode) { + return [NSEvent keyEventWithType:NSKeyDown + location:NSZeroPoint + modifierFlags:modifierFlags + timestamp:0.0 + windowNumber:0 + context:nil + characters:chars + charactersIgnoringModifiers:charsNoMods + isARepeat:NO + keyCode:keyCode]; +} + +NSMenuItem* MenuItem(NSString* equiv, NSUInteger mask) { + NSMenuItem* item = [[[NSMenuItem alloc] initWithTitle:@"" + action:NULL + keyEquivalent:@""] autorelease]; + [item setKeyEquivalent:equiv]; + [item setKeyEquivalentModifierMask:mask]; + return item; +} + +std::ostream& operator<<(std::ostream& out, NSObject* obj) { + return out << base::SysNSStringToUTF8([obj description]); +} + +std::ostream& operator<<(std::ostream& out, NSMenuItem* item) { + return out << "NSMenuItem " << base::SysNSStringToUTF8([item keyEquivalent]); +} + +void ExpectKeyFiresItemEq(bool result, NSEvent* key, NSMenuItem* item, + bool compareCocoa) { + EXPECT_EQ(result, [item cr_firesForKeyEvent:key]) << key << '\n' << item; + + // Make sure that Cocoa does in fact agree with our expectations. However, + // in some cases cocoa behaves weirdly (if you create e.g. a new event that + // contains all fields of the event that you get when hitting cmd-a with a + // russion keyboard layout, the copy won't fire a menu item that has cmd-a as + // key equivalent, even though the original event would) and isn't a good + // oracle function. + if (compareCocoa) { + scoped_nsobject<NSMenu> menu([[NSMenu alloc] initWithTitle:@"Menu!"]); + [menu setAutoenablesItems:NO]; + EXPECT_FALSE([menu performKeyEquivalent:key]); + [menu addItem:item]; + EXPECT_EQ(result, [menu performKeyEquivalent:key]) << key << '\n' << item; + } +} + +void ExpectKeyFiresItem( + NSEvent* key, NSMenuItem* item, bool compareCocoa = true) { + ExpectKeyFiresItemEq(true, key, item, compareCocoa); +} + +void ExpectKeyDoesntFireItem( + NSEvent* key, NSMenuItem* item, bool compareCocoa = true) { + ExpectKeyFiresItemEq(false, key, item, compareCocoa); +} + +TEST(NSMenuItemAdditionsTest, TestFiresForKeyEvent) { + // These test cases were built by writing a small test app that has a + // MainMenu.xib with a given key equivalent set in Interface Builder and a + // some code that prints both the key equivalent that fires a menu item and + // the menu item's key equivalent and modifier masks. I then pasted those + // below. This was done with a US layout, unless otherwise noted. In the + // comments, "z" always means the physical "z" key on a US layout no matter + // what character that key produces. + + NSMenuItem* item; + NSEvent* key; + unichar ch; + NSString* s; + + // Sanity + item = MenuItem(@"", 0); + EXPECT_TRUE([item isEnabled]); + + // a + key = KeyEvent(0x100, @"a", @"a", 0); + item = MenuItem(@"a", 0); + ExpectKeyFiresItem(key, item); + ExpectKeyDoesntFireItem(KeyEvent(0x20102, @"A", @"A", 0), item); + + // Disabled menu item + key = KeyEvent(0x100, @"a", @"a", 0); + item = MenuItem(@"a", 0); + [item setEnabled:NO]; + ExpectKeyDoesntFireItem(key, item, false); + + // shift-a + key = KeyEvent(0x20102, @"A", @"A", 0); + item = MenuItem(@"A", 0); + ExpectKeyFiresItem(key, item); + ExpectKeyDoesntFireItem(KeyEvent(0x100, @"a", @"a", 0), item); + + // cmd-opt-shift-a + key = KeyEvent(0x1a012a, @"\u00c5", @"A", 0); + item = MenuItem(@"A", 0x180000); + ExpectKeyFiresItem(key, item); + + // cmd-opt-a + key = KeyEvent(0x18012a, @"\u00e5", @"a", 0); + item = MenuItem(@"a", 0x180000); + ExpectKeyFiresItem(key, item); + + // cmd-= + key = KeyEvent(0x100110, @"=", @"=", 0x18); + item = MenuItem(@"=", 0x100000); + ExpectKeyFiresItem(key, item); + + // cmd-shift-= + key = KeyEvent(0x12010a, @"=", @"+", 0x18); + item = MenuItem(@"+", 0x100000); + ExpectKeyFiresItem(key, item); + + // Turns out Cocoa fires "+ 100108 + 18" if you hit cmd-= and the menu only + // has a cmd-+ shortcut. But that's transparent for |cr_firesForKeyEvent:|. + + // ctrl-3 + key = KeyEvent(0x40101, @"3", @"3", 0x14); + item = MenuItem(@"3", 0x40000); + ExpectKeyFiresItem(key, item); + + // return + key = KeyEvent(0, @"\r", @"\r", 0x24); + item = MenuItem(@"\r", 0); + ExpectKeyFiresItem(key, item); + + // shift-return + key = KeyEvent(0x20102, @"\r", @"\r", 0x24); + item = MenuItem(@"\r", 0x20000); + ExpectKeyFiresItem(key, item); + + // shift-left + ch = NSLeftArrowFunctionKey; + s = [NSString stringWithCharacters:&ch length:1]; + key = KeyEvent(0xa20102, s, s, 0x7b); + item = MenuItem(s, 0x20000); + ExpectKeyFiresItem(key, item); + + // shift-f1 (with a layout that needs the fn key down for f1) + ch = NSF1FunctionKey; + s = [NSString stringWithCharacters:&ch length:1]; + key = KeyEvent(0x820102, s, s, 0x7a); + item = MenuItem(s, 0x20000); + ExpectKeyFiresItem(key, item); + + // esc + // Turns out this doesn't fire. + key = KeyEvent(0x100, @"\e", @"\e", 0x35); + item = MenuItem(@"\e", 0); + ExpectKeyDoesntFireItem(key,item, false); + + // shift-esc + // Turns out this doesn't fire. + key = KeyEvent(0x20102, @"\e", @"\e", 0x35); + item = MenuItem(@"\e", 0x20000); + ExpectKeyDoesntFireItem(key,item, false); + + // cmd-esc + key = KeyEvent(0x100108, @"\e", @"\e", 0x35); + item = MenuItem(@"\e", 0x100000); + ExpectKeyFiresItem(key, item); + + // ctrl-esc + key = KeyEvent(0x40101, @"\e", @"\e", 0x35); + item = MenuItem(@"\e", 0x40000); + ExpectKeyFiresItem(key, item); + + // delete ("backspace") + key = KeyEvent(0x100, @"\x7f", @"\x7f", 0x33); + item = MenuItem(@"\x08", 0); + ExpectKeyFiresItem(key, item, false); + + // shift-delete + key = KeyEvent(0x20102, @"\x7f", @"\x7f", 0x33); + item = MenuItem(@"\x08", 0x20000); + ExpectKeyFiresItem(key, item, false); + + // forwarddelete (fn-delete / fn-backspace) + ch = NSDeleteFunctionKey; + s = [NSString stringWithCharacters:&ch length:1]; + key = KeyEvent(0x800100, s, s, 0x75); + item = MenuItem(@"\x7f", 0); + ExpectKeyFiresItem(key, item, false); + + // shift-forwarddelete (shift-fn-delete / shift-fn-backspace) + ch = NSDeleteFunctionKey; + s = [NSString stringWithCharacters:&ch length:1]; + key = KeyEvent(0x820102, s, s, 0x75); + item = MenuItem(@"\x7f", 0x20000); + ExpectKeyFiresItem(key, item, false); + + // fn-left + ch = NSHomeFunctionKey; + s = [NSString stringWithCharacters:&ch length:1]; + key = KeyEvent(0x800100, s, s, 0x73); + item = MenuItem(s, 0); + ExpectKeyFiresItem(key, item); + + // cmd-left + ch = NSLeftArrowFunctionKey; + s = [NSString stringWithCharacters:&ch length:1]; + key = KeyEvent(0xb00108, s, s, 0x7b); + item = MenuItem(s, 0x100000); + ExpectKeyFiresItem(key, item); + + // Hitting the "a" key with a russian keyboard layout -- does not fire + // a menu item that has "a" as key equiv. + key = KeyEvent(0x100, @"\u0444", @"\u0444", 0); + item = MenuItem(@"a", 0); + ExpectKeyDoesntFireItem(key,item); + + // cmd-a on a russion layout -- fires for a menu item with cmd-a as key equiv. + key = KeyEvent(0x100108, @"a", @"\u0444", 0); + item = MenuItem(@"a", 0x100000); + ExpectKeyFiresItem(key, item, false); + + // cmd-z on US layout + key = KeyEvent(0x100108, @"z", @"z", 6); + item = MenuItem(@"z", 0x100000); + ExpectKeyFiresItem(key, item); + + // cmd-y on german layout (has same keycode as cmd-z on us layout, shouldn't + // fire). + key = KeyEvent(0x100108, @"y", @"y", 6); + item = MenuItem(@"z", 0x100000); + ExpectKeyDoesntFireItem(key,item); + + // cmd-z on german layout + key = KeyEvent(0x100108, @"z", @"z", 0x10); + item = MenuItem(@"z", 0x100000); + ExpectKeyFiresItem(key, item); + + // fn-return (== enter) + key = KeyEvent(0x800100, @"\x3", @"\x3", 0x4c); + item = MenuItem(@"\r", 0); + ExpectKeyDoesntFireItem(key,item); + + // cmd-z on dvorak layout (so that the key produces ';') + key = KeyEvent(0x100108, @";", @";", 6); + ExpectKeyDoesntFireItem(key, MenuItem(@"z", 0x100000)); + ExpectKeyFiresItem(key, MenuItem(@";", 0x100000)); + + // cmd-z on dvorak qwerty layout (so that the key produces ';', but 'z' if + // cmd is down) + key = KeyEvent(0x100108, @"z", @";", 6); + ExpectKeyFiresItem(key, MenuItem(@"z", 0x100000), false); + ExpectKeyDoesntFireItem(key, MenuItem(@";", 0x100000), false); + + // cmd-shift-z on dvorak layout (so that we get a ':') + key = KeyEvent(0x12010a, @";", @":", 6); + ExpectKeyFiresItem(key, MenuItem(@":", 0x100000)); + ExpectKeyDoesntFireItem(key, MenuItem(@";", 0x100000)); + + // cmd-s with a serbian layout (just "s" produces something that looks a lot + // like "c" in some fonts, but is actually \u0441. cmd-s activates a menu item + // with key equivalent "s", not "c") + key = KeyEvent(0x100108, @"s", @"\u0441", 1); + ExpectKeyFiresItem(key, MenuItem(@"s", 0x100000), false); + ExpectKeyDoesntFireItem(key, MenuItem(@"c", 0x100000)); +} + +NSString* keyCodeToCharacter(NSUInteger keyCode, + EventModifiers modifiers, + TISInputSourceRef layout) { + CFDataRef uchr = (CFDataRef)TISGetInputSourceProperty( + layout, kTISPropertyUnicodeKeyLayoutData); + UCKeyboardLayout* keyLayout = (UCKeyboardLayout*)CFDataGetBytePtr(uchr); + + UInt32 deadKeyState = 0; + OSStatus err = noErr; + UniCharCount maxStringLength = 4, actualStringLength; + UniChar unicodeString[4]; + err = UCKeyTranslate(keyLayout, + (UInt16)keyCode, + kUCKeyActionDown, + modifiers, + LMGetKbdType(), + kUCKeyTranslateNoDeadKeysBit, + &deadKeyState, + maxStringLength, + &actualStringLength, + unicodeString); + assert(err == noErr); + + CFStringRef temp = CFStringCreateWithCharacters( + kCFAllocatorDefault, unicodeString, 1); + return [(NSString*)temp autorelease]; +} + +TEST(NSMenuItemAdditionsTest, TestMOnDifferentLayouts) { + // There's one key -- "m" -- that has the same keycode on most keyboard + // layouts. This function tests a menu item with cmd-m as key equivalent + // can be fired on all layouts. + NSMenuItem* item = MenuItem(@"m", 0x100000); + + NSDictionary* filter = [NSDictionary + dictionaryWithObject:(NSString*)kTISTypeKeyboardLayout + forKey:(NSString*)kTISPropertyInputSourceType]; + + // Docs say that including all layouts instead of just the active ones is + // slow, but there's no way around that. + NSArray* list = (NSArray*)TISCreateInputSourceList( + (CFDictionaryRef)filter, true); + for (id layout in list) { + TISInputSourceRef ref = (TISInputSourceRef)layout; + + NSUInteger keyCode = 0x2e; // "m" on a US layout and most other layouts. + + // On a few layouts, "m" has a different key code. + NSString* layoutId = (NSString*)TISGetInputSourceProperty( + ref, kTISPropertyInputSourceID); + if ([layoutId isEqualToString:@"com.apple.keylayout.Belgian"] || + [layoutId isEqualToString:@"com.apple.keylayout.French"] || + [layoutId isEqualToString:@"com.apple.keylayout.French-numerical"] || + [layoutId isEqualToString:@"com.apple.keylayout.Italian"]) { + keyCode = 0x29; + } else if ([layoutId isEqualToString:@"com.apple.keylayout.Turkish"]) { + keyCode = 0x28; + } else if ([layoutId isEqualToString:@"com.apple.keylayout.Dvorak-Left"]) { + keyCode = 0x16; + } else if ([layoutId isEqualToString:@"com.apple.keylayout.Dvorak-Right"]) { + keyCode = 0x1a; + } + + EventModifiers modifiers = cmdKey >> 8; + NSString* chars = keyCodeToCharacter(keyCode, modifiers, ref); + NSString* charsIgnoringMods = keyCodeToCharacter(keyCode, 0, ref); + NSEvent* key = KeyEvent(0x100000, chars, charsIgnoringMods, keyCode); + ExpectKeyFiresItem(key, item, false); + } + CFRelease(list); +} diff --git a/chrome/browser/ui/cocoa/nswindow_additions.h b/chrome/browser/ui/cocoa/nswindow_additions.h new file mode 100644 index 0000000..9d79464 --- /dev/null +++ b/chrome/browser/ui/cocoa/nswindow_additions.h @@ -0,0 +1,25 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_NSWINDOW_ADDITIONS_H_ +#define CHROME_BROWSER_UI_COCOA_NSWINDOW_ADDITIONS_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +// ID of a Space. Starts at 1. +typedef int CGSWorkspaceID; + +@interface NSWindow(ChromeAdditions) + +// Gets the Space that the window is currently on. YES on success, NO on +// failure. +- (BOOL)cr_workspace:(CGSWorkspaceID*)outWorkspace; + +// Moves the window to the given Space. YES on success, NO on failure. +- (BOOL)cr_moveToWorkspace:(CGSWorkspaceID)workspace; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_NSWINDOW_ADDITIONS_H_ diff --git a/chrome/browser/ui/cocoa/nswindow_additions.mm b/chrome/browser/ui/cocoa/nswindow_additions.mm new file mode 100644 index 0000000..f06af0d --- /dev/null +++ b/chrome/browser/ui/cocoa/nswindow_additions.mm @@ -0,0 +1,104 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/nswindow_additions.h" + +#include <dlfcn.h> + +#include "base/logging.h" + +typedef void* CGSConnectionID; +typedef int CGSWindowID; +typedef int CGSError; +typedef int CGSWorkspaceID; + +// These are private APIs we look up at run time. +typedef CGSConnectionID (*CGSDefaultConnectionFunc)(void); +typedef CGSError (*CGSGetWindowWorkspaceFunc)(const CGSConnectionID cid, + CGSWindowID wid, + CGSWorkspaceID* workspace); +typedef CGSError (*CGSMoveWorkspaceWindowListFunc)(const CGSConnectionID cid, + CGSWindowID* wids, + int count, + CGSWorkspaceID workspace); + +static CGSDefaultConnectionFunc sCGSDefaultConnection; +static CGSGetWindowWorkspaceFunc sCGSGetWindowWorkspace; +static CGSMoveWorkspaceWindowListFunc sCGSMoveWorkspaceWindowList; + +@implementation NSWindow(ChromeAdditions) + +// Looks up private Spaces APIs using dlsym. +- (BOOL)cr_initializeWorkspaceAPIs { + static BOOL shouldInitialize = YES; + if (shouldInitialize) { + shouldInitialize = NO; + + NSBundle* coreGraphicsBundle = + [NSBundle bundleWithIdentifier:@"com.apple.CoreGraphics"]; + NSString* coreGraphicsPath = [[coreGraphicsBundle bundlePath] + stringByAppendingPathComponent:@"CoreGraphics"]; + void* coreGraphicsLibrary = dlopen([coreGraphicsPath UTF8String], + RTLD_GLOBAL | RTLD_LAZY); + + if (coreGraphicsLibrary) { + sCGSDefaultConnection = + (CGSDefaultConnectionFunc)dlsym(coreGraphicsLibrary, + "_CGSDefaultConnection"); + if (!sCGSDefaultConnection) { + LOG(ERROR) << "Failed to lookup _CGSDefaultConnection API" << dlerror(); + } + sCGSGetWindowWorkspace = + (CGSGetWindowWorkspaceFunc)dlsym(coreGraphicsLibrary, + "CGSGetWindowWorkspace"); + if (!sCGSGetWindowWorkspace) { + LOG(ERROR) << "Failed to lookup CGSGetWindowWorkspace API" << dlerror(); + } + sCGSMoveWorkspaceWindowList = + (CGSMoveWorkspaceWindowListFunc)dlsym(coreGraphicsLibrary, + "CGSMoveWorkspaceWindowList"); + if (!sCGSMoveWorkspaceWindowList) { + LOG(ERROR) << "Failed to lookup CGSMoveWorkspaceWindowList API" + << dlerror(); + } + } else { + LOG(ERROR) << "Failed to load CoreGraphics lib" << dlerror(); + } + } + + return sCGSDefaultConnection != NULL && + sCGSGetWindowWorkspace != NULL && + sCGSMoveWorkspaceWindowList != NULL; +} + +- (BOOL)cr_workspace:(CGSWorkspaceID*)outWorkspace { + if (![self cr_initializeWorkspaceAPIs]) { + return NO; + } + + // If this ASSERT fails then consider using CGSDefaultConnectionForThread() + // instead of CGSDefaultConnection(). + DCHECK([NSThread isMainThread]); + CGSConnectionID cid = sCGSDefaultConnection(); + CGSWindowID wid = [self windowNumber]; + CGSError err = sCGSGetWindowWorkspace(cid, wid, outWorkspace); + return err == 0; +} + +- (BOOL)cr_moveToWorkspace:(CGSWorkspaceID)workspace { + if (![self cr_initializeWorkspaceAPIs]) { + return NO; + } + + // If this ASSERT fails then consider using CGSDefaultConnectionForThread() + // instead of CGSDefaultConnection(). + DCHECK([NSThread isMainThread]); + CGSConnectionID cid = sCGSDefaultConnection(); + CGSWindowID wid = [self windowNumber]; + // CGSSetWorkspaceForWindow doesn't seem to work for some reason. + CGSError err = sCGSMoveWorkspaceWindowList(cid, &wid, 1, workspace); + return err == 0; +} + +@end diff --git a/chrome/browser/ui/cocoa/objc_method_swizzle.h b/chrome/browser/ui/cocoa/objc_method_swizzle.h new file mode 100644 index 0000000..2b94832 --- /dev/null +++ b/chrome/browser/ui/cocoa/objc_method_swizzle.h @@ -0,0 +1,28 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_OBJC_METHOD_SWIZZLE_H_ +#define CHROME_BROWSER_UI_COCOA_OBJC_METHOD_SWIZZLE_H_ +#pragma once + +#import <objc/objc-class.h> + +// You should think twice every single time you use anything from this +// namespace. +namespace ObjcEvilDoers { + +// This is similar to class_getInstanceMethod(), except that it +// returns NULL if |aClass| does not directly implement |aSelector|. +Method GetImplementedInstanceMethod(Class aClass, SEL aSelector); + +// Exchanges the implementation of |originalSelector| and +// |alternateSelector| within |aClass|. Both selectors must be +// implemented directly by |aClass|, not inherited. The IMP returned +// is for |originalSelector| (for purposes of forwarding). +IMP SwizzleImplementedInstanceMethods( + Class aClass, const SEL originalSelector, const SEL alternateSelector); + +} // namespace ObjcEvilDoers + +#endif // CHROME_BROWSER_UI_COCOA_OBJC_METHOD_SWIZZLE_H_ diff --git a/chrome/browser/ui/cocoa/objc_method_swizzle.mm b/chrome/browser/ui/cocoa/objc_method_swizzle.mm new file mode 100644 index 0000000..34f88a5 --- /dev/null +++ b/chrome/browser/ui/cocoa/objc_method_swizzle.mm @@ -0,0 +1,59 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/objc_method_swizzle.h" + +#import "base/logging.h" +#import "base/scoped_nsobject.h" +#import "chrome/app/breakpad_mac.h" + +namespace ObjcEvilDoers { + +Method GetImplementedInstanceMethod(Class aClass, SEL aSelector) { + Method method = NULL; + unsigned int methodCount = 0; + Method* methodList = class_copyMethodList(aClass, &methodCount); + if (methodList) { + for (unsigned int i = 0; i < methodCount; ++i) { + if (method_getName(methodList[i]) == aSelector) { + method = methodList[i]; + break; + } + } + free(methodList); + } + return method; +} + +IMP SwizzleImplementedInstanceMethods( + Class aClass, const SEL originalSelector, const SEL alternateSelector) { + // The methods must both be implemented by the target class, not + // inherited from a superclass. + Method original = GetImplementedInstanceMethod(aClass, originalSelector); + Method alternate = GetImplementedInstanceMethod(aClass, alternateSelector); + DCHECK(original); + DCHECK(alternate); + if (!original || !alternate) { + return NULL; + } + + // The argument and return types must match exactly. + const char* originalTypes = method_getTypeEncoding(original); + const char* alternateTypes = method_getTypeEncoding(alternate); + DCHECK(originalTypes); + DCHECK(alternateTypes); + DCHECK(0 == strcmp(originalTypes, alternateTypes)); + if (!originalTypes || !alternateTypes || + strcmp(originalTypes, alternateTypes)) { + return NULL; + } + + IMP ret = method_getImplementation(original); + if (ret) { + method_exchangeImplementations(original, alternate); + } + return ret; +} + +} // namespace ObjcEvilDoers diff --git a/chrome/browser/ui/cocoa/objc_method_swizzle_unittest.mm b/chrome/browser/ui/cocoa/objc_method_swizzle_unittest.mm new file mode 100644 index 0000000..1641741 --- /dev/null +++ b/chrome/browser/ui/cocoa/objc_method_swizzle_unittest.mm @@ -0,0 +1,76 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/objc_method_swizzle.h" + +#include "base/scoped_nsobject.h" +#include "testing/gtest/include/gtest/gtest.h" + +@interface ObjcMethodSwizzleTest : NSObject +- (id)self; + +- (NSInteger)one; +- (NSInteger)two; +@end + +@implementation ObjcMethodSwizzleTest : NSObject +- (id)self { + return [super self]; +} + +- (NSInteger)one { + return 1; +} +- (NSInteger)two { + return 2; +} +@end + +@interface ObjcMethodSwizzleTest (ObjcMethodSwizzleTestCategory) +- (NSUInteger)hash; +@end + +@implementation ObjcMethodSwizzleTest (ObjcMethodSwizzleTestCategory) +- (NSUInteger)hash { + return [super hash]; +} +@end + +namespace ObjcEvilDoers { + +TEST(ObjcMethodSwizzleTest, GetImplementedInstanceMethod) { + EXPECT_EQ(class_getInstanceMethod([NSObject class], @selector(dealloc)), + GetImplementedInstanceMethod([NSObject class], @selector(dealloc))); + EXPECT_EQ(class_getInstanceMethod([NSObject class], @selector(self)), + GetImplementedInstanceMethod([NSObject class], @selector(self))); + EXPECT_EQ(class_getInstanceMethod([NSObject class], @selector(hash)), + GetImplementedInstanceMethod([NSObject class], @selector(hash))); + + Class testClass = [ObjcMethodSwizzleTest class]; + EXPECT_EQ(class_getInstanceMethod(testClass, @selector(self)), + GetImplementedInstanceMethod(testClass, @selector(self))); + EXPECT_NE(class_getInstanceMethod([NSObject class], @selector(self)), + class_getInstanceMethod(testClass, @selector(self))); + + EXPECT_TRUE(class_getInstanceMethod(testClass, @selector(dealloc))); + EXPECT_FALSE(GetImplementedInstanceMethod(testClass, @selector(dealloc))); +} + +TEST(ObjcMethodSwizzleTest, SwizzleImplementedInstanceMethods) { + scoped_nsobject<ObjcMethodSwizzleTest> object( + [[ObjcMethodSwizzleTest alloc] init]); + EXPECT_EQ([object one], 1); + EXPECT_EQ([object two], 2); + + Class testClass = [object class]; + SwizzleImplementedInstanceMethods(testClass, @selector(one), @selector(two)); + EXPECT_EQ([object one], 2); + EXPECT_EQ([object two], 1); + + SwizzleImplementedInstanceMethods(testClass, @selector(one), @selector(two)); + EXPECT_EQ([object one], 1); + EXPECT_EQ([object two], 2); +} + +} // namespace ObjcEvilDoers diff --git a/chrome/browser/ui/cocoa/objc_zombie.h b/chrome/browser/ui/cocoa/objc_zombie.h new file mode 100644 index 0000000..714409e --- /dev/null +++ b/chrome/browser/ui/cocoa/objc_zombie.h @@ -0,0 +1,39 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_NSOBJECT_ZOMBIE_H_ +#define CHROME_BROWSER_UI_COCOA_NSOBJECT_ZOMBIE_H_ +#pragma once + +#import <Foundation/Foundation.h> + +// You should think twice every single time you use anything from this +// namespace. +namespace ObjcEvilDoers { + +// Enable zombie object debugging. This implements a variant of Apple's +// NSZombieEnabled which can help expose use-after-free errors where messages +// are sent to freed Objective-C objects in production builds. +// +// Returns NO if it fails to enable. +// +// When |zombieAllObjects| is YES, all objects inheriting from +// NSObject become zombies on -dealloc. If NO, -shouldBecomeCrZombie +// is queried to determine whether to make the object a zombie. +// +// |zombieCount| controls how many zombies to store before freeing the +// oldest. Set to 0 to free objects immediately after making them +// zombies. +BOOL ZombieEnable(BOOL zombieAllObjects, size_t zombieCount); + +// Disable zombies. +void ZombieDisable(); + +} // namespace ObjcEvilDoers + +@interface NSObject (CrZombie) +- (BOOL)shouldBecomeCrZombie; +@end + +#endif // CHROME_BROWSER_UI_COCOA_NSOBJECT_ZOMBIE_H_ diff --git a/chrome/browser/ui/cocoa/objc_zombie.mm b/chrome/browser/ui/cocoa/objc_zombie.mm new file mode 100644 index 0000000..6802fd2 --- /dev/null +++ b/chrome/browser/ui/cocoa/objc_zombie.mm @@ -0,0 +1,414 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/objc_zombie.h" + +#include <dlfcn.h> +#include <mach-o/dyld.h> +#include <mach-o/nlist.h> + +#import <objc/objc-class.h> + +#include "base/lock.h" +#include "base/logging.h" +#import "chrome/app/breakpad_mac.h" +#import "chrome/browser/ui/cocoa/objc_method_swizzle.h" + +// Deallocated objects are re-classed as |CrZombie|. No superclass +// because then the class would have to override many/most of the +// inherited methods (|NSObject| is like a category magnet!). +@interface CrZombie { + Class isa; +} +@end + +// Objects with enough space are made into "fat" zombies, which +// directly remember which class they were until reallocated. +@interface CrFatZombie : CrZombie { + @public + Class wasa; +} +@end + +namespace { + +// |object_cxxDestruct()| is an Objective-C runtime function which +// traverses the object's class tree for ".cxxdestruct" methods which +// are run to call C++ destructors as part of |-dealloc|. The +// function is not public, so must be looked up using nlist. +typedef void DestructFn(id obj); +DestructFn* g_object_cxxDestruct = NULL; + +// The original implementation for |-[NSObject dealloc]|. +IMP g_originalDeallocIMP = NULL; + +// Classes which freed objects become. |g_fatZombieSize| is the +// minimum object size which can be made into a fat zombie (which can +// remember which class it was before free, even after falling off the +// treadmill). +Class g_zombieClass = Nil; // cached [CrZombie class] +Class g_fatZombieClass = Nil; // cached [CrFatZombie class] +size_t g_fatZombieSize = 0; + +// Whether to zombie all freed objects, or only those which return YES +// from |-shouldBecomeCrZombie|. +BOOL g_zombieAllObjects = NO; + +// Protects |g_zombieCount|, |g_zombieIndex|, and |g_zombies|. +Lock lock_; + +// How many zombies to keep before freeing, and the current head of +// the circular buffer. +size_t g_zombieCount = 0; +size_t g_zombieIndex = 0; + +typedef struct { + id object; // The zombied object. + Class wasa; // Value of |object->isa| before we replaced it. +} ZombieRecord; + +ZombieRecord* g_zombies = NULL; + +// Lookup the private |object_cxxDestruct| function and return a +// pointer to it. Returns |NULL| on failure. +DestructFn* LookupObjectCxxDestruct() { +#if ARCH_CPU_64_BITS + // TODO(shess): Port to 64-bit. I believe using struct nlist_64 + // will suffice. http://crbug.com/44021 . + NOTIMPLEMENTED(); + return NULL; +#endif + + struct nlist nl[3]; + bzero(&nl, sizeof(nl)); + + nl[0].n_un.n_name = (char*)"_object_cxxDestruct"; + + // My ability to calculate the base for offsets is apparently poor. + // Use |class_addIvar| as a known reference point. + nl[1].n_un.n_name = (char*)"_class_addIvar"; + + if (nlist("/usr/lib/libobjc.dylib", nl) < 0 || + nl[0].n_type == N_UNDF || nl[1].n_type == N_UNDF) + return NULL; + + return (DestructFn*)((char*)&class_addIvar - nl[1].n_value + nl[0].n_value); +} + +// Replacement |-dealloc| which turns objects into zombies and places +// them into |g_zombies| to be freed later. +void ZombieDealloc(id self, SEL _cmd) { + // This code should only be called when it is implementing |-dealloc|. + DCHECK_EQ(_cmd, @selector(dealloc)); + + // Use the original |-dealloc| if the object doesn't wish to be + // zombied. + if (!g_zombieAllObjects && ![self shouldBecomeCrZombie]) { + g_originalDeallocIMP(self, _cmd); + return; + } + + // Use the original |-dealloc| if |object_cxxDestruct| was never + // initialized, because otherwise C++ destructors won't be called. + // This case should be impossible, but doing it wrong would cause + // terrible problems. + DCHECK(g_object_cxxDestruct); + if (!g_object_cxxDestruct) { + g_originalDeallocIMP(self, _cmd); + return; + } + + Class wasa = object_getClass(self); + const size_t size = class_getInstanceSize(wasa); + + // Destroy the instance by calling C++ destructors and clearing it + // to something unlikely to work well if someone references it. + (*g_object_cxxDestruct)(self); + memset(self, '!', size); + + // If the instance is big enough, make it into a fat zombie and have + // it remember the old |isa|. Otherwise make it a regular zombie. + // Setting |isa| rather than using |object_setClass()| because that + // function is implemented with a memory barrier. The runtime's + // |_internal_object_dispose()| (in objc-class.m) does this, so it + // should be safe (messaging free'd objects shouldn't be expected to + // be thread-safe in the first place). + if (size >= g_fatZombieSize) { + self->isa = g_fatZombieClass; + static_cast<CrFatZombie*>(self)->wasa = wasa; + } else { + self->isa = g_zombieClass; + } + + // The new record to swap into |g_zombies|. If |g_zombieCount| is + // zero, then |self| will be freed immediately. + ZombieRecord zombieToFree = {self, wasa}; + + // Don't involve the lock when creating zombies without a treadmill. + if (g_zombieCount > 0) { + AutoLock pin(lock_); + + // Check the count again in a thread-safe manner. + if (g_zombieCount > 0) { + // Put the current object on the treadmill and keep the previous + // occupant. + std::swap(zombieToFree, g_zombies[g_zombieIndex]); + + // Bump the index forward. + g_zombieIndex = (g_zombieIndex + 1) % g_zombieCount; + } + } + + // Do the free out here to prevent any chance of deadlock. + if (zombieToFree.object) + free(zombieToFree.object); +} + +// Attempt to determine the original class of zombie |object|. +Class ZombieWasa(id object) { + // Fat zombies can hold onto their |wasa| past the point where the + // object was actually freed. Note that to arrive here at all, + // |object|'s memory must still be accessible. + if (object_getClass(object) == g_fatZombieClass) + return static_cast<CrFatZombie*>(object)->wasa; + + // For instances which weren't big enough to store |wasa|, check if + // the object is still on the treadmill. + AutoLock pin(lock_); + for (size_t i=0; i < g_zombieCount; ++i) { + if (g_zombies[i].object == object) + return g_zombies[i].wasa; + } + + return Nil; +} + +// Log a message to a freed object. |wasa| is the object's original +// class. |aSelector| is the selector which the calling code was +// attempting to send. |viaSelector| is the selector of the +// dispatch-related method which is being invoked to send |aSelector| +// (for instance, -respondsToSelector:). +void ZombieObjectCrash(id object, SEL aSelector, SEL viaSelector) { + Class wasa = ZombieWasa(object); + const char* wasaName = (wasa ? class_getName(wasa) : "<unknown>"); + NSString* aString = + [NSString stringWithFormat:@"Zombie <%s: %p> received -%s", + wasaName, object, sel_getName(aSelector)]; + if (viaSelector != NULL) { + const char* viaName = sel_getName(viaSelector); + aString = [aString stringByAppendingFormat:@" (via -%s)", viaName]; + } + + // Set a value for breakpad to report, then crash. + SetCrashKeyValue(@"zombie", aString); + LOG(ERROR) << [aString UTF8String]; + + // This is how about:crash is implemented. Using instead of + // |DebugUtil::BreakDebugger()| or |LOG(FATAL)| to make the top of + // stack more immediately obvious in crash dumps. + int* zero = NULL; + *zero = 0; +} + +// Initialize our globals, returning YES on success. +BOOL ZombieInit() { + static BOOL initialized = NO; + if (initialized) + return YES; + + Class rootClass = [NSObject class]; + + g_object_cxxDestruct = LookupObjectCxxDestruct(); + g_originalDeallocIMP = + class_getMethodImplementation(rootClass, @selector(dealloc)); + // objc_getClass() so CrZombie doesn't need +class. + g_zombieClass = objc_getClass("CrZombie"); + g_fatZombieClass = objc_getClass("CrFatZombie"); + g_fatZombieSize = class_getInstanceSize(g_fatZombieClass); + + if (!g_object_cxxDestruct || !g_originalDeallocIMP || + !g_zombieClass || !g_fatZombieClass) + return NO; + + initialized = YES; + return YES; +} + +} // namespace + +@implementation CrZombie + +// The Objective-C runtime needs to be able to call this successfully. ++ (void)initialize { +} + +// Any method not explicitly defined will end up here, forcing a +// crash. +- (id)forwardingTargetForSelector:(SEL)aSelector { + ZombieObjectCrash(self, aSelector, NULL); + return nil; +} + +// Override a few methods often used for dynamic dispatch to log the +// message the caller is attempting to send, rather than the utility +// method being used to send it. +- (BOOL)respondsToSelector:(SEL)aSelector { + ZombieObjectCrash(self, aSelector, _cmd); + return NO; +} + +- (id)performSelector:(SEL)aSelector { + ZombieObjectCrash(self, aSelector, _cmd); + return nil; +} + +- (id)performSelector:(SEL)aSelector withObject:(id)anObject { + ZombieObjectCrash(self, aSelector, _cmd); + return nil; +} + +- (id)performSelector:(SEL)aSelector + withObject:(id)anObject + withObject:(id)anotherObject { + ZombieObjectCrash(self, aSelector, _cmd); + return nil; +} + +- (void)performSelector:(SEL)aSelector + withObject:(id)anArgument + afterDelay:(NSTimeInterval)delay { + ZombieObjectCrash(self, aSelector, _cmd); +} + +@end + +@implementation CrFatZombie + +// This implementation intentionally left empty. + +@end + +@implementation NSObject (CrZombie) + +- (BOOL)shouldBecomeCrZombie { + return NO; +} + +@end + +namespace ObjcEvilDoers { + +BOOL ZombieEnable(BOOL zombieAllObjects, + size_t zombieCount) { + // Only allow enable/disable on the main thread, just to keep things + // simple. + CHECK([NSThread isMainThread]); + + if (!ZombieInit()) + return NO; + + g_zombieAllObjects = zombieAllObjects; + + // Replace the implementation of -[NSObject dealloc]. + Method m = class_getInstanceMethod([NSObject class], @selector(dealloc)); + if (!m) + return NO; + + const IMP prevDeallocIMP = method_setImplementation(m, (IMP)ZombieDealloc); + DCHECK(prevDeallocIMP == g_originalDeallocIMP || + prevDeallocIMP == (IMP)ZombieDealloc); + + // Grab the current set of zombies. This is thread-safe because + // only the main thread can change these. + const size_t oldCount = g_zombieCount; + ZombieRecord* oldZombies = g_zombies; + + { + AutoLock pin(lock_); + + // Save the old index in case zombies need to be transferred. + size_t oldIndex = g_zombieIndex; + + // Create the new zombie treadmill, disabling zombies in case of + // failure. + g_zombieIndex = 0; + g_zombieCount = zombieCount; + g_zombies = NULL; + if (g_zombieCount) { + g_zombies = + static_cast<ZombieRecord*>(calloc(g_zombieCount, sizeof(*g_zombies))); + if (!g_zombies) { + NOTREACHED(); + g_zombies = oldZombies; + g_zombieCount = oldCount; + g_zombieIndex = oldIndex; + ZombieDisable(); + return NO; + } + } + + // If the count is changing, allow some of the zombies to continue + // shambling forward. + const size_t sharedCount = std::min(oldCount, zombieCount); + if (sharedCount) { + // Get index of the first shared zombie. + oldIndex = (oldIndex + oldCount - sharedCount) % oldCount; + + for (; g_zombieIndex < sharedCount; ++ g_zombieIndex) { + DCHECK_LT(g_zombieIndex, g_zombieCount); + DCHECK_LT(oldIndex, oldCount); + std::swap(g_zombies[g_zombieIndex], oldZombies[oldIndex]); + oldIndex = (oldIndex + 1) % oldCount; + } + g_zombieIndex %= g_zombieCount; + } + } + + // Free the old treadmill and any remaining zombies. + if (oldZombies) { + for (size_t i = 0; i < oldCount; ++i) { + if (oldZombies[i].object) + free(oldZombies[i].object); + } + free(oldZombies); + } + + return YES; +} + +void ZombieDisable() { + // Only allow enable/disable on the main thread, just to keep things + // simple. + CHECK([NSThread isMainThread]); + + // |ZombieInit()| was never called. + if (!g_originalDeallocIMP) + return; + + // Put back the original implementation of -[NSObject dealloc]. + Method m = class_getInstanceMethod([NSObject class], @selector(dealloc)); + CHECK(m); + method_setImplementation(m, g_originalDeallocIMP); + + // Can safely grab this because it only happens on the main thread. + const size_t oldCount = g_zombieCount; + ZombieRecord* oldZombies = g_zombies; + + { + AutoLock pin(lock_); // In case any |-dealloc| are in-progress. + g_zombieCount = 0; + g_zombies = NULL; + } + + // Free any remaining zombies. + if (oldZombies) { + for (size_t i = 0; i < oldCount; ++i) { + if (oldZombies[i].object) + free(oldZombies[i].object); + } + free(oldZombies); + } +} + +} // namespace ObjcEvilDoers diff --git a/chrome/browser/ui/cocoa/page_info_bubble_controller.h b/chrome/browser/ui/cocoa/page_info_bubble_controller.h new file mode 100644 index 0000000..10909c6 --- /dev/null +++ b/chrome/browser/ui/cocoa/page_info_bubble_controller.h @@ -0,0 +1,47 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#include "base/scoped_ptr.h" +#include "chrome/browser/page_info_model.h" +#import "chrome/browser/ui/cocoa/base_bubble_controller.h" + +// This NSWindowController subclass manages the InfoBubbleWindow and view that +// are displayed when the user clicks the security lock icon. +@interface PageInfoBubbleController : BaseBubbleController { + @private + // The model that generates the content displayed by the controller. + scoped_ptr<PageInfoModel> model_; + + // Thin bridge that pushes model-changed notifications from C++ to Cocoa. + scoped_ptr<PageInfoModel::PageInfoModelObserver> bridge_; + + // The certificate ID for the page, 0 if the page is not over HTTPS. + int certID_; +} + +@property (nonatomic, assign) int certID; + +// Designated initializer. The new instance will take ownership of |model| and +// |bridge|. There should be a 1:1 mapping of models to bridges. The +// controller will release itself when the bubble is closed. +- (id)initWithPageInfoModel:(PageInfoModel*)model + modelObserver:(PageInfoModel::PageInfoModelObserver*)bridge + parentWindow:(NSWindow*)parentWindow; + +// Shows the certificate display window. Note that this will implicitly close +// the bubble because the certificate window will become key. The certificate +// information attaches itself as a sheet to the |parentWindow|. +- (IBAction)showCertWindow:(id)sender; + +// Opens the help center link that explains the contents of the page info. +- (IBAction)showHelpPage:(id)sender; + +@end + +@interface PageInfoBubbleController (ExposedForUnitTesting) +- (void)performLayout; +@end diff --git a/chrome/browser/ui/cocoa/page_info_bubble_controller.mm b/chrome/browser/ui/cocoa/page_info_bubble_controller.mm new file mode 100644 index 0000000..519c759 --- /dev/null +++ b/chrome/browser/ui/cocoa/page_info_bubble_controller.mm @@ -0,0 +1,461 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/page_info_bubble_controller.h" + +#include "app/l10n_util.h" +#include "app/l10n_util_mac.h" +#include "base/message_loop.h" +#include "base/sys_string_conversions.h" +#include "base/task.h" +#include "chrome/browser/browser_list.h" +#include "chrome/browser/cert_store.h" +#include "chrome/browser/certificate_viewer.h" +#include "chrome/browser/google/google_util.h" +#include "chrome/browser/profile.h" +#import "chrome/browser/ui/cocoa/browser_window_controller.h" +#import "chrome/browser/ui/cocoa/hyperlink_button_cell.h" +#import "chrome/browser/ui/cocoa/info_bubble_view.h" +#import "chrome/browser/ui/cocoa/info_bubble_window.h" +#import "chrome/browser/ui/cocoa/location_bar/location_bar_view_mac.h" +#include "chrome/common/url_constants.h" +#include "grit/generated_resources.h" +#include "grit/locale_settings.h" +#include "net/base/cert_status_flags.h" +#include "net/base/x509_certificate.h" +#import "third_party/GTM/AppKit/GTMUILocalizerAndLayoutTweaker.h" + +@interface PageInfoBubbleController (Private) +- (PageInfoModel*)model; +- (NSButton*)certificateButtonWithFrame:(NSRect)frame; +- (void)configureTextFieldAsLabel:(NSTextField*)textField; +- (CGFloat)addHeadlineViewForInfo:(const PageInfoModel::SectionInfo&)info + toSubviews:(NSMutableArray*)subviews + atPoint:(NSPoint)point; +- (CGFloat)addDescriptionViewForInfo:(const PageInfoModel::SectionInfo&)info + toSubviews:(NSMutableArray*)subviews + atPoint:(NSPoint)point; +- (CGFloat)addCertificateButtonToSubviews:(NSMutableArray*)subviews + atOffset:(CGFloat)offset; +- (void)addImageViewForInfo:(const PageInfoModel::SectionInfo&)info + toSubviews:(NSMutableArray*)subviews + atOffset:(CGFloat)offset; +- (CGFloat)addHelpButtonToSubviews:(NSMutableArray*)subviews + atOffset:(CGFloat)offset; +- (CGFloat)addSeparatorToSubviews:(NSMutableArray*)subviews + atOffset:(CGFloat)offset; +- (NSPoint)anchorPointForWindowWithHeight:(CGFloat)bubbleHeight + parentWindow:(NSWindow*)parent; +@end + +// This simple NSView subclass is used as the single subview of the page info +// bubble's window's contentView. Drawing is flipped so that layout of the +// sections is easier. Apple recommends flipping the coordinate origin when +// doing a lot of text layout because it's more natural. +@interface PageInfoContentView : NSView { +} +@end +@implementation PageInfoContentView +- (BOOL)isFlipped { + return YES; +} +@end + +namespace { + +// The width of the window, in view coordinates. The height will be determined +// by the content. +const NSInteger kWindowWidth = 380; + +// Spacing in between sections. +const NSInteger kVerticalSpacing = 10; + +// Padding along on the X-axis between the window frame and content. +const NSInteger kFramePadding = 10; + +// Spacing between the optional headline and description text views. +const NSInteger kHeadlineSpacing = 2; + +// Spacing between the image and the text. +const NSInteger kImageSpacing = 10; + +// Square size of the image. +const CGFloat kImageSize = 30; + +// The X position of the text fields. Variants for with and without an image. +const CGFloat kTextXPositionNoImage = kFramePadding; +const CGFloat kTextXPosition = kTextXPositionNoImage + kImageSize + + kImageSpacing; + +// Width of the text fields. +const CGFloat kTextWidth = kWindowWidth - (kImageSize + kImageSpacing + + kFramePadding * 2); + +// Bridge that listens for change notifications from the model. +class PageInfoModelBubbleBridge : public PageInfoModel::PageInfoModelObserver { + public: + PageInfoModelBubbleBridge() + : controller_(nil), + ALLOW_THIS_IN_INITIALIZER_LIST(task_factory_(this)) { + } + + // PageInfoModelObserver implementation. + virtual void ModelChanged() { + // Check to see if a layout has already been scheduled. + if (!task_factory_.empty()) + return; + + // Delay performing layout by a second so that all the animations from + // InfoBubbleWindow and origin updates from BaseBubbleController finish, so + // that we don't all race trying to change the frame's origin. + // + // Using ScopedRunnableMethodFactory is superior here to |-performSelector:| + // because it will not retain its target; if the child outlives its parent, + // zombies get left behind (http://crbug.com/59619). This will also cancel + // the scheduled Tasks if the controller (and thus this bridge) get + // destroyed before the message can be delivered. + MessageLoop::current()->PostDelayedTask(FROM_HERE, + task_factory_.NewRunnableMethod( + &PageInfoModelBubbleBridge::PerformLayout), + 1000 /* milliseconds */); + } + + // Sets the controller. + void set_controller(PageInfoBubbleController* controller) { + controller_ = controller; + } + + private: + void PerformLayout() { + [controller_ performLayout]; + } + + PageInfoBubbleController* controller_; // weak + + // Factory that vends RunnableMethod tasks for scheduling layout. + ScopedRunnableMethodFactory<PageInfoModelBubbleBridge> task_factory_; +}; + +} // namespace + +namespace browser { + +void ShowPageInfoBubble(gfx::NativeWindow parent, + Profile* profile, + const GURL& url, + const NavigationEntry::SSLStatus& ssl, + bool show_history) { + PageInfoModelBubbleBridge* bridge = new PageInfoModelBubbleBridge(); + PageInfoModel* model = + new PageInfoModel(profile, url, ssl, show_history, bridge); + PageInfoBubbleController* controller = + [[PageInfoBubbleController alloc] initWithPageInfoModel:model + modelObserver:bridge + parentWindow:parent]; + bridge->set_controller(controller); + [controller setCertID:ssl.cert_id()]; + [controller showWindow:nil]; +} + +} // namespace browser + +@implementation PageInfoBubbleController + +@synthesize certID = certID_; + +- (id)initWithPageInfoModel:(PageInfoModel*)model + modelObserver:(PageInfoModel::PageInfoModelObserver*)bridge + parentWindow:(NSWindow*)parentWindow { + // Use an arbitrary height because it will be changed by the bridge. + NSRect contentRect = NSMakeRect(0, 0, kWindowWidth, 0); + // Create an empty window into which content is placed. + scoped_nsobject<InfoBubbleWindow> window( + [[InfoBubbleWindow alloc] initWithContentRect:contentRect + styleMask:NSBorderlessWindowMask + backing:NSBackingStoreBuffered + defer:NO]); + + if ((self = [super initWithWindow:window.get() + parentWindow:parentWindow + anchoredAt:NSZeroPoint])) { + model_.reset(model); + bridge_.reset(bridge); + [[self bubble] setArrowLocation:info_bubble::kTopLeft]; + [self performLayout]; + } + return self; +} + +- (PageInfoModel*)model { + return model_.get(); +} + +- (IBAction)showCertWindow:(id)sender { + DCHECK(certID_ != 0); + ShowCertificateViewerByID([self parentWindow], certID_); +} + +- (IBAction)showHelpPage:(id)sender { + GURL url = google_util::AppendGoogleLocaleParam( + GURL(chrome::kPageInfoHelpCenterURL)); + Browser* browser = BrowserList::GetLastActive(); + browser->OpenURL(url, GURL(), NEW_FOREGROUND_TAB, PageTransition::LINK); +} + +// This will create the subviews for the page info window. The general layout +// is 2 or 3 boxed and titled sections, each of which has a status image to +// provide visual feedback and a description that explains it. The description +// text is usually only 1 or 2 lines, but can be much longer. At the bottom of +// the window is a button to view the SSL certificate, which is disabled if +// not using HTTPS. +- (void)performLayout { + // |offset| is the Y position that should be drawn at next. + CGFloat offset = kFramePadding + info_bubble::kBubbleArrowHeight; + + // Keep the new subviews in an array that gets replaced at the end. + NSMutableArray* subviews = [NSMutableArray array]; + + // The subviews will be attached to the PageInfoContentView, which has a + // flipped origin. This allows the code to build top-to-bottom. + const int sectionCount = model_->GetSectionCount(); + for (int i = 0; i < sectionCount; ++i) { + PageInfoModel::SectionInfo info = model_->GetSectionInfo(i); + + // Only certain sections have images. This affects the X position. + BOOL hasImage = model_->GetIconImage(info.icon_id) != nil; + CGFloat xPosition = (hasImage ? kTextXPosition : kTextXPositionNoImage); + + // Insert the image subview for sections that are appropriate. + CGFloat imageBaseline = offset + kImageSize; + if (hasImage) { + [self addImageViewForInfo:info toSubviews:subviews atOffset:offset]; + } + + // Add the title. + if (!info.headline.empty()) { + offset += [self addHeadlineViewForInfo:info + toSubviews:subviews + atPoint:NSMakePoint(xPosition, offset)]; + offset += kHeadlineSpacing; + } + + // Create the description of the state. + offset += [self addDescriptionViewForInfo:info + toSubviews:subviews + atPoint:NSMakePoint(xPosition, offset)]; + + if (info.type == PageInfoModel::SECTION_INFO_IDENTITY && certID_) { + offset += kVerticalSpacing; + offset += [self addCertificateButtonToSubviews:subviews atOffset:offset]; + } + + // If at this point the description and optional headline and button are + // not as tall as the image, adjust the offset by the difference. + CGFloat imageBaselineDelta = imageBaseline - offset; + if (imageBaselineDelta > 0) + offset += imageBaselineDelta; + + // Add the separators. + offset += kVerticalSpacing; + offset += [self addSeparatorToSubviews:subviews atOffset:offset]; + } + + // The last item at the bottom of the window is the help center link. + offset += [self addHelpButtonToSubviews:subviews atOffset:offset]; + offset += kVerticalSpacing; + + // Create the dummy view that uses flipped coordinates. + NSRect contentFrame = NSMakeRect(0, 0, kWindowWidth, offset); + scoped_nsobject<PageInfoContentView> contentView( + [[PageInfoContentView alloc] initWithFrame:contentFrame]); + [contentView setSubviews:subviews]; + + // Replace the window's content. + [[[self window] contentView] setSubviews: + [NSArray arrayWithObject:contentView]]; + + NSRect windowFrame = NSMakeRect(0, 0, kWindowWidth, offset); + windowFrame.size = [[[self window] contentView] convertSize:windowFrame.size + toView:nil]; + // Adjust the origin by the difference in height. + windowFrame.origin = [[self window] frame].origin; + windowFrame.origin.y -= NSHeight(windowFrame) - + NSHeight([[self window] frame]); + + // Resize the window. Only animate if the window is visible, otherwise it + // could be "growing" while it's opening, looking awkward. + [[self window] setFrame:windowFrame + display:YES + animate:[[self window] isVisible]]; + + NSPoint anchorPoint = + [self anchorPointForWindowWithHeight:NSHeight(windowFrame) + parentWindow:[self parentWindow]]; + [self setAnchorPoint:anchorPoint]; +} + +// Creates the button with a given |frame| that, when clicked, will show the +// SSL certificate information. +- (NSButton*)certificateButtonWithFrame:(NSRect)frame { + NSButton* certButton = [[[NSButton alloc] initWithFrame:frame] autorelease]; + [certButton setTitle: + l10n_util::GetNSStringWithFixup(IDS_PAGEINFO_CERT_INFO_BUTTON)]; + [certButton setButtonType:NSMomentaryPushInButton]; + [certButton setBezelStyle:NSRoundRectBezelStyle]; + [certButton setTarget:self]; + [certButton setAction:@selector(showCertWindow:)]; + [[certButton cell] setControlSize:NSSmallControlSize]; + NSFont* font = [NSFont systemFontOfSize: + [NSFont systemFontSizeForControlSize:NSSmallControlSize]]; + [[certButton cell] setFont:font]; + return certButton; +} + +// Sets proprties on the given |field| to act as the title or description labels +// in the bubble. +- (void)configureTextFieldAsLabel:(NSTextField*)textField { + [textField setEditable:NO]; + [textField setDrawsBackground:NO]; + [textField setBezeled:NO]; +} + +// Adds the title text field at the given x,y position, and returns the y +// position for the next element. +- (CGFloat)addHeadlineViewForInfo:(const PageInfoModel::SectionInfo&)info + toSubviews:(NSMutableArray*)subviews + atPoint:(NSPoint)point { + NSRect frame = NSMakeRect(point.x, point.y, kTextWidth, kImageSpacing); + scoped_nsobject<NSTextField> textField( + [[NSTextField alloc] initWithFrame:frame]); + [self configureTextFieldAsLabel:textField.get()]; + [textField setStringValue:base::SysUTF16ToNSString(info.headline)]; + NSFont* font = [NSFont boldSystemFontOfSize:[NSFont smallSystemFontSize]]; + [textField setFont:font]; + frame.size.height += + [GTMUILocalizerAndLayoutTweaker sizeToFitFixedWidthTextField: + textField]; + [textField setFrame:frame]; + [subviews addObject:textField.get()]; + return NSHeight(frame); +} + +// Adds the description text field at the given x,y position, and returns the y +// position for the next element. +- (CGFloat)addDescriptionViewForInfo:(const PageInfoModel::SectionInfo&)info + toSubviews:(NSMutableArray*)subviews + atPoint:(NSPoint)point { + NSRect frame = NSMakeRect(point.x, point.y, kTextWidth, kImageSize); + scoped_nsobject<NSTextField> textField( + [[NSTextField alloc] initWithFrame:frame]); + [self configureTextFieldAsLabel:textField.get()]; + [textField setStringValue:base::SysUTF16ToNSString(info.description)]; + [textField setFont:[NSFont labelFontOfSize:[NSFont smallSystemFontSize]]]; + + // If the text is oversized, resize the text field. + frame.size.height += + [GTMUILocalizerAndLayoutTweaker sizeToFitFixedWidthTextField: + textField]; + [subviews addObject:textField.get()]; + return NSHeight(frame); +} + +// Adds the certificate button at a pre-determined x position and the given y. +// Returns the y position for the next element. +- (CGFloat)addCertificateButtonToSubviews:(NSMutableArray*)subviews + atOffset:(CGFloat)offset { + // The certificate button should only be added if there is SSL information. + DCHECK(certID_); + + // Create the certificate button. The frame will be fixed up by GTM, so + // use arbitrary values. + NSRect frame = NSMakeRect(kTextXPosition, offset, 100, 14); + NSButton* certButton = [self certificateButtonWithFrame:frame]; + [subviews addObject:certButton]; + [GTMUILocalizerAndLayoutTweaker sizeToFitView:certButton]; + + // By default, assume that we don't have certificate information to show. + scoped_refptr<net::X509Certificate> cert; + CertStore::GetSharedInstance()->RetrieveCert(certID_, &cert); + + // Don't bother showing certificates if there isn't one. Gears runs + // with no OS root certificate. + if (!cert.get() || !cert->os_cert_handle()) { + // This should only ever happen in unit tests. + [certButton setEnabled:NO]; + } + + return NSHeight([certButton frame]); +} + +// Adds the state image at a pre-determined x position and the given y. This +// does not affect the next Y position because the image is placed next to +// a text field that is larger and accounts for the image's size. +- (void)addImageViewForInfo:(const PageInfoModel::SectionInfo&)info + toSubviews:(NSMutableArray*)subviews + atOffset:(CGFloat)offset { + NSRect frame = NSMakeRect(kFramePadding, offset, kImageSize, + kImageSize); + scoped_nsobject<NSImageView> imageView( + [[NSImageView alloc] initWithFrame:frame]); + [imageView setImageFrameStyle:NSImageFrameNone]; + [imageView setImage:model_->GetIconImage(info.icon_id)]; + [subviews addObject:imageView.get()]; +} + +// Adds the help center button that explains the icons. Returns the y position +// delta for the next offset. +- (CGFloat)addHelpButtonToSubviews:(NSMutableArray*)subviews + atOffset:(CGFloat)offset { + NSRect frame = NSMakeRect(kFramePadding, offset, 100, 10); + scoped_nsobject<NSButton> button([[NSButton alloc] initWithFrame:frame]); + NSString* string = + l10n_util::GetNSStringWithFixup(IDS_PAGE_INFO_HELP_CENTER_LINK); + scoped_nsobject<HyperlinkButtonCell> cell( + [[HyperlinkButtonCell alloc] initTextCell:string]); + [cell setControlSize:NSSmallControlSize]; + [button setCell:cell.get()]; + [button setButtonType:NSMomentaryPushInButton]; + [button setBezelStyle:NSRegularSquareBezelStyle]; + [button setTarget:self]; + [button setAction:@selector(showHelpPage:)]; + [subviews addObject:button.get()]; + + // Call size-to-fit to fixup for the localized string. + [GTMUILocalizerAndLayoutTweaker sizeToFitView:button.get()]; + return NSHeight([button frame]); +} + +// Adds a 1px separator between sections. Returns the y position delta for the +// next offset. +- (CGFloat)addSeparatorToSubviews:(NSMutableArray*)subviews + atOffset:(CGFloat)offset { + const CGFloat kSpacerHeight = 1.0; + NSRect frame = NSMakeRect(kFramePadding, offset, + kWindowWidth - 2 * kFramePadding, kSpacerHeight); + scoped_nsobject<NSBox> spacer([[NSBox alloc] initWithFrame:frame]); + [spacer setBoxType:NSBoxSeparator]; + [spacer setBorderType:NSLineBorder]; + [spacer setAlphaValue:0.2]; + [subviews addObject:spacer.get()]; + return kVerticalSpacing + kSpacerHeight; +} + +// Takes in the bubble's height and the parent window, which should be a +// BrowserWindow, and gets the proper anchor point for the bubble. The returned +// point is in screen coordinates. +- (NSPoint)anchorPointForWindowWithHeight:(CGFloat)bubbleHeight + parentWindow:(NSWindow*)parent { + BrowserWindowController* controller = [parent windowController]; + NSPoint origin = NSZeroPoint; + if ([controller isKindOfClass:[BrowserWindowController class]]) { + LocationBarViewMac* locationBar = [controller locationBarBridge]; + if (locationBar) { + NSPoint bubblePoint = locationBar->GetPageInfoBubblePoint(); + origin = [parent convertBaseToScreen:bubblePoint]; + } + } + return origin; +} + +@end diff --git a/chrome/browser/ui/cocoa/page_info_bubble_controller_unittest.mm b/chrome/browser/ui/cocoa/page_info_bubble_controller_unittest.mm new file mode 100644 index 0000000..50281fe --- /dev/null +++ b/chrome/browser/ui/cocoa/page_info_bubble_controller_unittest.mm @@ -0,0 +1,210 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "app/l10n_util.h" +#include "base/scoped_nsobject.h" +#include "base/string_util.h" +#include "base/string_number_conversions.h" +#include "base/sys_string_conversions.h" +#include "base/utf_string_conversions.h" +#include "chrome/browser/page_info_model.h" +#import "chrome/browser/ui/cocoa/hyperlink_button_cell.h" +#import "chrome/browser/ui/cocoa/page_info_bubble_controller.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "grit/generated_resources.h" + +namespace { + +class FakeModel : public PageInfoModel { + public: + FakeModel() : PageInfoModel() {} + + void AddSection(SectionStateIcon icon_id, + const string16& headline, + const string16& description, + SectionInfoType type) { + sections_.push_back(SectionInfo( + icon_id, headline, description, type)); + } +}; + +class FakeBridge : public PageInfoModel::PageInfoModelObserver { + public: + void ModelChanged() {} +}; + +class PageInfoBubbleControllerTest : public CocoaTest { + public: + PageInfoBubbleControllerTest() { + controller_ = nil; + model_ = new FakeModel(); + } + + virtual void TearDown() { + [controller_ close]; + CocoaTest::TearDown(); + } + + void CreateBubble() { + // The controller cleans up after itself when the window closes. + controller_ = + [[PageInfoBubbleController alloc] initWithPageInfoModel:model_ + modelObserver:NULL + parentWindow:test_window()]; + window_ = [controller_ window]; + [controller_ showWindow:nil]; + } + + // Checks the controller's window for the requisite subviews in the given + // numbers. + void CheckWindow(int text_count, + int image_count, + int spacer_count, + int button_count) { + // All windows have the help center link and a spacer for it. + int link_count = 1; + ++spacer_count; + + // The window's only immediate child is an invisible view that has a flipped + // coordinate origin. It is into this that all views get placed. + NSArray* windowSubviews = [[window_ contentView] subviews]; + EXPECT_EQ(1U, [windowSubviews count]); + NSArray* subviews = [[windowSubviews lastObject] subviews]; + + for (NSView* view in subviews) { + if ([view isKindOfClass:[NSTextField class]]) { + --text_count; + } else if ([view isKindOfClass:[NSImageView class]]) { + --image_count; + } else if ([view isKindOfClass:[NSBox class]]) { + --spacer_count; + } else if ([view isKindOfClass:[NSButton class]]) { + NSButton* button = static_cast<NSButton*>(view); + // Every window should have a single link button to the help page. + if ([[button cell] isKindOfClass:[HyperlinkButtonCell class]]) { + --link_count; + CheckButton(button, @selector(showHelpPage:)); + } else { + --button_count; + CheckButton(button, @selector(showCertWindow:)); + } + } else { + ADD_FAILURE() << "Unknown subview: " << [[view description] UTF8String]; + } + } + EXPECT_EQ(0, text_count); + EXPECT_EQ(0, image_count); + EXPECT_EQ(0, spacer_count); + EXPECT_EQ(0, button_count); + EXPECT_EQ(0, link_count); + EXPECT_EQ([window_ delegate], controller_); + } + + // Checks that a button is hooked up correctly. + void CheckButton(NSButton* button, SEL action) { + EXPECT_EQ(action, [button action]); + EXPECT_EQ(controller_, [button target]); + EXPECT_TRUE([button stringValue]); + } + + BrowserTestHelper helper_; + + PageInfoBubbleController* controller_; // Weak, owns self. + FakeModel* model_; // Weak, owned by controller. + NSWindow* window_; // Weak, owned by controller. +}; + + +TEST_F(PageInfoBubbleControllerTest, NoHistoryNoSecurity) { + model_->AddSection(PageInfoModel::ICON_STATE_ERROR, + string16(), + l10n_util::GetStringUTF16(IDS_PAGE_INFO_SECURITY_TAB_UNKNOWN_PARTY), + PageInfoModel::SECTION_INFO_IDENTITY); + model_->AddSection(PageInfoModel::ICON_STATE_ERROR, + string16(), + l10n_util::GetStringFUTF16( + IDS_PAGE_INFO_SECURITY_TAB_NOT_ENCRYPTED_CONNECTION_TEXT, + ASCIIToUTF16("google.com")), + PageInfoModel::SECTION_INFO_CONNECTION); + + CreateBubble(); + CheckWindow(/*text=*/2, /*image=*/2, /*spacer=*/1, /*button=*/0); +} + + +TEST_F(PageInfoBubbleControllerTest, HistoryNoSecurity) { + model_->AddSection(PageInfoModel::ICON_STATE_ERROR, + string16(), + l10n_util::GetStringUTF16(IDS_PAGE_INFO_SECURITY_TAB_UNKNOWN_PARTY), + PageInfoModel::SECTION_INFO_IDENTITY); + model_->AddSection(PageInfoModel::ICON_STATE_ERROR, + string16(), + l10n_util::GetStringFUTF16( + IDS_PAGE_INFO_SECURITY_TAB_NOT_ENCRYPTED_CONNECTION_TEXT, + ASCIIToUTF16("google.com")), + PageInfoModel::SECTION_INFO_CONNECTION); + + // In practice, the history information comes later because it's queried + // asynchronously, so replicate the double-build here. + CreateBubble(); + + model_->AddSection(PageInfoModel::ICON_STATE_ERROR, + l10n_util::GetStringUTF16(IDS_PAGE_INFO_SITE_INFO_TITLE), + l10n_util::GetStringUTF16( + IDS_PAGE_INFO_SECURITY_TAB_FIRST_VISITED_TODAY), + PageInfoModel::SECTION_INFO_FIRST_VISIT); + + [controller_ performLayout]; + + CheckWindow(/*text=*/4, /*image=*/3, /*spacer=*/2, /*button=*/0); +} + + +TEST_F(PageInfoBubbleControllerTest, NoHistoryMixedSecurity) { + model_->AddSection(PageInfoModel::ICON_STATE_OK, + string16(), + l10n_util::GetStringFUTF16( + IDS_PAGE_INFO_SECURITY_TAB_SECURE_IDENTITY, + ASCIIToUTF16("Goat Security Systems")), + PageInfoModel::SECTION_INFO_IDENTITY); + + // This string is super long and the text should overflow the default clip + // region (kImageSize). + string16 description = l10n_util::GetStringFUTF16( + IDS_PAGE_INFO_SECURITY_TAB_ENCRYPTED_SENTENCE_LINK, + l10n_util::GetStringFUTF16( + IDS_PAGE_INFO_SECURITY_TAB_ENCRYPTED_CONNECTION_TEXT, + ASCIIToUTF16("chrome.google.com"), + base::IntToString16(1024)), + l10n_util::GetStringUTF16( + IDS_PAGE_INFO_SECURITY_TAB_ENCRYPTED_INSECURE_CONTENT_WARNING)); + + model_->AddSection(PageInfoModel::ICON_STATE_OK, + string16(), + description, + PageInfoModel::SECTION_INFO_CONNECTION); + + + CreateBubble(); + [controller_ setCertID:1]; + [controller_ performLayout]; + + CheckWindow(/*text=*/2, /*image=*/2, /*spacer=*/1, /*button=*/1); + + // Look for the over-sized box. + NSString* targetDesc = base::SysUTF16ToNSString(description); + NSArray* subviews = [[window_ contentView] subviews]; + for (NSView* subview in subviews) { + if ([subview isKindOfClass:[NSTextField class]]) { + NSTextField* desc = static_cast<NSTextField*>(subview); + if ([[desc stringValue] isEqualToString:targetDesc]) { + // Typical box frame is ~55px, make sure this is extra large. + EXPECT_LT(75, NSHeight([desc frame])); + } + } + } +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/preferences_window_controller.h b/chrome/browser/ui/cocoa/preferences_window_controller.h new file mode 100644 index 0000000..cfec955 --- /dev/null +++ b/chrome/browser/ui/cocoa/preferences_window_controller.h @@ -0,0 +1,241 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_ptr.h" +#include "base/scoped_nsobject.h" +#include "chrome/browser/options_window.h" +#include "chrome/browser/prefs/pref_member.h" +#include "chrome/browser/prefs/pref_set_observer.h" +#include "chrome/browser/prefs/pref_change_registrar.h" + +namespace PreferencesWindowControllerInternal { +class PrefObserverBridge; +class ManagedPrefsBannerState; +} + +@class CustomHomePagesModel; +@class FontLanguageSettingsController; +class PrefService; +class Profile; +class ProfileSyncService; +@class SearchEngineListModel; +@class VerticalGradientView; +@class WindowSizeAutosaver; + +// A window controller that handles the preferences window. The bulk of the +// work is handled via Cocoa Bindings and getter/setter methods that wrap +// cross-platform PrefMember objects. When prefs change in the back-end +// (that is, outside of this UI), our observer receives a notification and can +// tickle the KVO to update the UI so we are always in sync. The bindings are +// specified in the nib file. Preferences are persisted into the back-end +// as they are changed in the UI, and are thus immediately available even while +// the window is still open. When the window closes, a notification is sent +// via the system NotificationCenter. This can be used as a signal to +// release this controller, as it's likely the client wants to enforce there +// only being one (we don't do that internally as it makes it very difficult +// to unit test). +@interface PreferencesWindowController : NSWindowController { + @private + Profile* profile_; // weak ref + OptionsPage initialPage_; + PrefService* prefs_; // weak ref - Obtained from profile_ for convenience. + // weak ref - Also obtained from profile_ for convenience. May be NULL. + ProfileSyncService* syncService_; + scoped_ptr<PreferencesWindowControllerInternal::PrefObserverBridge> + observer_; // Watches for pref changes. + PrefChangeRegistrar registrar_; // Manages pref change observer registration. + scoped_nsobject<WindowSizeAutosaver> sizeSaver_; + NSView* currentPrefsView_; // weak ref - current prefs page view. + scoped_ptr<PreferencesWindowControllerInternal::ManagedPrefsBannerState> + bannerState_; + BOOL managedPrefsBannerVisible_; + + IBOutlet NSToolbar* toolbar_; + IBOutlet VerticalGradientView* managedPrefsBannerView_; + IBOutlet NSImageView* managedPrefsBannerWarningImage_; + + // The views we'll rotate through + IBOutlet NSView* basicsView_; + IBOutlet NSView* personalStuffView_; + IBOutlet NSView* underTheHoodView_; + // The last page the user was on when they opened the Options window. + IntegerPrefMember lastSelectedPage_; + + // The groups of the Basics view for layout fixup. + IBOutlet NSArray* basicsGroupStartup_; + IBOutlet NSArray* basicsGroupHomePage_; + IBOutlet NSArray* basicsGroupToolbar_; + IBOutlet NSArray* basicsGroupSearchEngine_; + IBOutlet NSArray* basicsGroupDefaultBrowser_; + + // The groups of the Personal Stuff view for layout fixup. + IBOutlet NSArray* personalStuffGroupSync_; + IBOutlet NSArray* personalStuffGroupPasswords_; + IBOutlet NSArray* personalStuffGroupAutofill_; + IBOutlet NSArray* personalStuffGroupBrowserData_; + IBOutlet NSArray* personalStuffGroupThemes_; + + // Having two animations around is bad (they fight), so just use one. + scoped_nsobject<NSViewAnimation> animation_; + + IBOutlet NSArrayController* customPagesArrayController_; + + // Basics panel + IntegerPrefMember restoreOnStartup_; + scoped_nsobject<CustomHomePagesModel> customPagesSource_; + BooleanPrefMember newTabPageIsHomePage_; + StringPrefMember homepage_; + BooleanPrefMember showHomeButton_; + BooleanPrefMember instantEnabled_; + IBOutlet NSButton* instantCheckbox_; + IBOutlet NSTextField* instantExperiment_; + scoped_nsobject<SearchEngineListModel> searchEngineModel_; + // Used when creating a new home page url to make the new cell editable. + BOOL pendingSelectForEdit_; + BOOL restoreButtonsEnabled_; + BOOL restoreURLsEnabled_; + BOOL showHomeButtonEnabled_; + BOOL defaultSearchEngineEnabled_; + + // User Data panel + BooleanPrefMember askSavePasswords_; + BooleanPrefMember autoFillEnabled_; + IBOutlet NSButton* autoFillSettingsButton_; + IBOutlet NSButton* syncButton_; + IBOutlet NSButton* syncCustomizeButton_; + IBOutlet NSTextField* syncStatus_; + IBOutlet NSButton* syncLink_; + IBOutlet NSButton* privacyDashboardLink_; + scoped_nsobject<NSColor> syncStatusNoErrorBackgroundColor_; + scoped_nsobject<NSColor> syncLinkNoErrorBackgroundColor_; + scoped_nsobject<NSColor> syncErrorBackgroundColor_; + BOOL passwordManagerChoiceEnabled_; + BOOL passwordManagerButtonEnabled_; + BOOL autoFillSettingsButtonEnabled_; + + // Under the hood panel + IBOutlet NSView* underTheHoodContentView_; + IBOutlet NSScrollView* underTheHoodScroller_; + IBOutlet NSButton* contentSettingsButton_; + IBOutlet NSButton* clearDataButton_; + BooleanPrefMember alternateErrorPages_; + BooleanPrefMember useSuggest_; + BooleanPrefMember dnsPrefetch_; + BooleanPrefMember safeBrowsing_; + BooleanPrefMember metricsReporting_; + IBOutlet NSPathControl* downloadLocationControl_; + IBOutlet NSButton* downloadLocationButton_; + StringPrefMember defaultDownloadLocation_; + BooleanPrefMember askForSaveLocation_; + IBOutlet NSButton* resetFileHandlersButton_; + StringPrefMember autoOpenFiles_; + BooleanPrefMember translateEnabled_; + BooleanPrefMember tabsToLinks_; + FontLanguageSettingsController* fontLanguageSettings_; + StringPrefMember currentTheme_; + IBOutlet NSButton* enableLoggingCheckbox_; + scoped_ptr<PrefSetObserver> proxyPrefs_; + BOOL showAlternateErrorPagesEnabled_; + BOOL useSuggestEnabled_; + BOOL dnsPrefetchEnabled_; + BOOL safeBrowsingEnabled_; + BOOL metricsReportingEnabled_; + BOOL proxiesConfigureButtonEnabled_; +} + +// Designated initializer. |profile| should not be NULL. +- (id)initWithProfile:(Profile*)profile initialPage:(OptionsPage)initialPage; + +// Show the preferences window. +- (void)showPreferences:(id)sender; + +// Switch to the given preference page. +- (void)switchToPage:(OptionsPage)page animate:(BOOL)animate; + +// Enables or disables the restoreOnStartup elements +- (void) setEnabledStateOfRestoreOnStartup; + +// IBAction methods for responding to user actions. + +// Basics panel +- (IBAction)addHomepage:(id)sender; +- (IBAction)removeSelectedHomepages:(id)sender; +- (IBAction)useCurrentPagesAsHomepage:(id)sender; +- (IBAction)manageSearchEngines:(id)sender; +- (IBAction)toggleInstant:(id)sender; +- (IBAction)learnMoreAboutInstant:(id)sender; +- (IBAction)makeDefaultBrowser:(id)sender; + +// User Data panel +- (IBAction)doSyncAction:(id)sender; +- (IBAction)doSyncCustomize:(id)sender; +- (IBAction)doSyncReauthentication:(id)sender; +- (IBAction)showPrivacyDashboard:(id)sender; +- (IBAction)showSavedPasswords:(id)sender; +- (IBAction)showAutoFillSettings:(id)sender; +- (IBAction)importData:(id)sender; +- (IBAction)resetThemeToDefault:(id)sender; +- (IBAction)themesGallery:(id)sender; + +// Under the hood +- (IBAction)showContentSettings:(id)sender; +- (IBAction)clearData:(id)sender; +- (IBAction)privacyLearnMore:(id)sender; +- (IBAction)browseDownloadLocation:(id)sender; +- (IBAction)resetAutoOpenFiles:(id)sender; +- (IBAction)changeFontAndLanguageSettings:(id)sender; +- (IBAction)openProxyPreferences:(id)sender; +- (IBAction)showCertificates:(id)sender; +- (IBAction)resetToDefaults:(id)sender; + +// When a toolbar button is clicked +- (IBAction)toolbarButtonSelected:(id)sender; + +// Usable from cocoa bindings to hook up the custom home pages table. +@property (nonatomic, readonly) CustomHomePagesModel* customPagesSource; + +// Properties for the enabled state of various UI elements. Keep these ordered +// by occurrence on the dialog. +@property (nonatomic) BOOL restoreButtonsEnabled; +@property (nonatomic) BOOL restoreURLsEnabled; +@property (nonatomic) BOOL showHomeButtonEnabled; +@property (nonatomic) BOOL defaultSearchEngineEnabled; +@property (nonatomic) BOOL passwordManagerChoiceEnabled; +@property (nonatomic) BOOL passwordManagerButtonEnabled; +@property (nonatomic) BOOL autoFillSettingsButtonEnabled; +@property (nonatomic) BOOL showAlternateErrorPagesEnabled; +@property (nonatomic) BOOL useSuggestEnabled; +@property (nonatomic) BOOL dnsPrefetchEnabled; +@property (nonatomic) BOOL safeBrowsingEnabled; +@property (nonatomic) BOOL metricsReportingEnabled; +@property (nonatomic) BOOL proxiesConfigureButtonEnabled; +@end + +@interface PreferencesWindowController(Testing) + +- (IntegerPrefMember*)lastSelectedPage; +- (NSToolbar*)toolbar; +- (NSView*)basicsView; +- (NSView*)personalStuffView; +- (NSView*)underTheHoodView; + +// Converts the given OptionsPage value (which may be OPTIONS_PAGE_DEFAULT) +// into a concrete OptionsPage value. +- (OptionsPage)normalizePage:(OptionsPage)page; + +// Returns the toolbar item corresponding to the given page. Should be +// called only after awakeFromNib is. +- (NSToolbarItem*)getToolbarItemForPage:(OptionsPage)page; + +// Returns the (normalized) page corresponding to the given toolbar item. +// Should be called only after awakeFromNib is. +- (OptionsPage)getPageForToolbarItem:(NSToolbarItem*)toolbarItem; + +// Returns the view corresponding to the given page. Should be called +// only after awakeFromNib is. +- (NSView*)getPrefsViewForPage:(OptionsPage)page; + +@end diff --git a/chrome/browser/ui/cocoa/preferences_window_controller.mm b/chrome/browser/ui/cocoa/preferences_window_controller.mm new file mode 100644 index 0000000..de8459a --- /dev/null +++ b/chrome/browser/ui/cocoa/preferences_window_controller.mm @@ -0,0 +1,2184 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/preferences_window_controller.h" + +#include <algorithm> + +#include "app/l10n_util.h" +#include "app/l10n_util_mac.h" +#include "app/resource_bundle.h" +#include "base/logging.h" +#include "base/mac_util.h" +#include "base/mac/scoped_aedesc.h" +#include "base/string16.h" +#include "base/string_util.h" +#include "base/sys_string_conversions.h" +#include "chrome/browser/autofill/autofill_dialog.h" +#include "chrome/browser/autofill/autofill_type.h" +#include "chrome/browser/autofill/personal_data_manager.h" +#include "chrome/browser/browser_process.h" +#include "chrome/browser/download/download_manager.h" +#include "chrome/browser/download/download_prefs.h" +#include "chrome/browser/extensions/extensions_service.h" +#include "chrome/browser/google/google_util.h" +#include "chrome/browser/instant/instant_confirm_dialog.h" +#include "chrome/browser/instant/instant_controller.h" +#include "chrome/browser/metrics/metrics_service.h" +#include "chrome/browser/metrics/user_metrics.h" +#include "chrome/browser/net/url_fixer_upper.h" +#include "chrome/browser/options_util.h" +#include "chrome/browser/options_window.h" +#include "chrome/browser/policy/managed_prefs_banner_base.h" +#include "chrome/browser/prefs/pref_service.h" +#include "chrome/browser/prefs/session_startup_pref.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/safe_browsing/safe_browsing_service.h" +#include "chrome/browser/shell_integration.h" +#include "chrome/browser/show_options_url.h" +#include "chrome/browser/sync/profile_sync_service.h" +#include "chrome/browser/sync/sync_ui_util.h" +#include "chrome/browser/tab_contents/tab_contents.h" +#include "chrome/browser/ui/browser.h" +#include "chrome/browser/ui/browser_list.h" +#import "chrome/browser/ui/cocoa/clear_browsing_data_controller.h" +#import "chrome/browser/ui/cocoa/content_settings_dialog_controller.h" +#import "chrome/browser/ui/cocoa/custom_home_pages_model.h" +#import "chrome/browser/ui/cocoa/font_language_settings_controller.h" +#import "chrome/browser/ui/cocoa/import_settings_dialog.h" +#import "chrome/browser/ui/cocoa/keyword_editor_cocoa_controller.h" +#import "chrome/browser/ui/cocoa/l10n_util.h" +#import "chrome/browser/ui/cocoa/search_engine_list_model.h" +#import "chrome/browser/ui/cocoa/vertical_gradient_view.h" +#import "chrome/browser/ui/cocoa/window_size_autosaver.h" +#include "chrome/common/chrome_switches.h" +#include "chrome/common/notification_details.h" +#include "chrome/common/notification_observer.h" +#include "chrome/common/notification_type.h" +#include "chrome/common/pref_names.h" +#include "chrome/common/url_constants.h" +#include "chrome/installer/util/google_update_settings.h" +#include "grit/chromium_strings.h" +#include "grit/generated_resources.h" +#include "grit/locale_settings.h" +#include "grit/theme_resources.h" +#import "third_party/GTM/AppKit/GTMNSAnimation+Duration.h" +#import "third_party/GTM/AppKit/GTMUILocalizerAndLayoutTweaker.h" + +namespace { + +// Colors for the managed preferences warning banner. +static const double kBannerGradientColorTop[3] = + {255.0 / 255.0, 242.0 / 255.0, 183.0 / 255.0}; +static const double kBannerGradientColorBottom[3] = + {250.0 / 255.0, 230.0 / 255.0, 145.0 / 255.0}; +static const double kBannerStrokeColor = 135.0 / 255.0; + +// Tag id for retrieval via viewWithTag in NSView (from IB). +static const uint32 kBasicsStartupPageTableTag = 1000; + +bool IsNewTabUIURLString(const GURL& url) { + return url == GURL(chrome::kChromeUINewTabURL); +} + +// Helper that sizes two buttons to fit in a row keeping their spacing, returns +// the total horizontal size change. +CGFloat SizeToFitButtonPair(NSButton* leftButton, NSButton* rightButton) { + CGFloat widthShift = 0.0; + + NSSize delta = [GTMUILocalizerAndLayoutTweaker sizeToFitView:leftButton]; + DCHECK_EQ(delta.height, 0.0) << "Height changes unsupported"; + widthShift += delta.width; + + if (widthShift != 0.0) { + NSPoint origin = [rightButton frame].origin; + origin.x += widthShift; + [rightButton setFrameOrigin:origin]; + } + delta = [GTMUILocalizerAndLayoutTweaker sizeToFitView:rightButton]; + DCHECK_EQ(delta.height, 0.0) << "Height changes unsupported"; + widthShift += delta.width; + + return widthShift; +} + +// The different behaviors for the "pref group" auto sizing. +enum AutoSizeGroupBehavior { + kAutoSizeGroupBehaviorVerticalToFit, + kAutoSizeGroupBehaviorVerticalFirstToFit, + kAutoSizeGroupBehaviorHorizontalToFit, + kAutoSizeGroupBehaviorHorizontalFirstGrows, + kAutoSizeGroupBehaviorFirstTwoAsRowVerticalToFit +}; + +// Helper to tweak the layout of the "pref groups" and also ripple any height +// changes from one group to the next groups' origins. +// |views| is an ordered list of views with first being the label for the +// group and the rest being top down or left to right ordering of the views. +// The label is assumed to already be the same height as all the views it is +// next too. +CGFloat AutoSizeGroup(NSArray* views, AutoSizeGroupBehavior behavior, + CGFloat verticalShift) { + DCHECK_GE([views count], 2U) << "Should be at least a label and a control"; + NSTextField* label = [views objectAtIndex:0]; + DCHECK([label isKindOfClass:[NSTextField class]]) + << "First view should be the label for the group"; + + // Auto size the label to see if we need more vertical space for its localized + // string. + CGFloat labelHeightChange = + [GTMUILocalizerAndLayoutTweaker sizeToFitFixedWidthTextField:label]; + + CGFloat localVerticalShift = 0.0; + switch (behavior) { + case kAutoSizeGroupBehaviorVerticalToFit: { + // Walk bottom up doing the sizing and moves. + for (NSUInteger index = [views count] - 1; index > 0; --index) { + NSView* view = [views objectAtIndex:index]; + NSSize delta = cocoa_l10n_util::WrapOrSizeToFit(view); + DCHECK_GE(delta.height, 0.0) << "Should NOT shrink in height"; + if (localVerticalShift) { + NSPoint origin = [view frame].origin; + origin.y += localVerticalShift; + [view setFrameOrigin:origin]; + } + localVerticalShift += delta.height; + } + break; + } + case kAutoSizeGroupBehaviorVerticalFirstToFit: { + // Just size the top one. + NSView* view = [views objectAtIndex:1]; + NSSize delta = cocoa_l10n_util::WrapOrSizeToFit(view); + DCHECK_GE(delta.height, 0.0) << "Should NOT shrink in height"; + localVerticalShift += delta.height; + break; + } + case kAutoSizeGroupBehaviorHorizontalToFit: { + // Walk left to right doing the sizing and moves. + // NOTE: Don't worry about vertical, assume it always fits. + CGFloat horizontalShift = 0.0; + NSUInteger count = [views count]; + for (NSUInteger index = 1; index < count; ++index) { + NSView* view = [views objectAtIndex:index]; + NSSize delta = cocoa_l10n_util::WrapOrSizeToFit(view); + DCHECK_GE(delta.height, 0.0) << "Should NOT shrink in height"; + if (horizontalShift) { + NSPoint origin = [view frame].origin; + origin.x += horizontalShift; + [view setFrameOrigin:origin]; + } + horizontalShift += delta.width; + } + break; + } + case kAutoSizeGroupBehaviorHorizontalFirstGrows: { + // Walk right to left doing the sizing and moves, then apply the space + // collected into the first. + // NOTE: Don't worry about vertical, assume it always all fits. + CGFloat horizontalShift = 0.0; + for (NSUInteger index = [views count] - 1; index > 1; --index) { + NSView* view = [views objectAtIndex:index]; + NSSize delta = cocoa_l10n_util::WrapOrSizeToFit(view); + DCHECK_GE(delta.height, 0.0) << "Should NOT shrink in height"; + horizontalShift -= delta.width; + NSPoint origin = [view frame].origin; + origin.x += horizontalShift; + [view setFrameOrigin:origin]; + } + if (horizontalShift) { + NSView* view = [views objectAtIndex:1]; + NSSize delta = NSMakeSize(horizontalShift, 0.0); + [GTMUILocalizerAndLayoutTweaker + resizeViewWithoutAutoResizingSubViews:view + delta:delta]; + } + break; + } + case kAutoSizeGroupBehaviorFirstTwoAsRowVerticalToFit: { + // Start out like kAutoSizeGroupBehaviorVerticalToFit but don't do + // the first two. Then handle the two as a row, but apply any + // vertical shift. + // All but the first two (in the row); walk bottom up. + for (NSUInteger index = [views count] - 1; index > 2; --index) { + NSView* view = [views objectAtIndex:index]; + NSSize delta = cocoa_l10n_util::WrapOrSizeToFit(view); + DCHECK_GE(delta.height, 0.0) << "Should NOT shrink in height"; + if (localVerticalShift) { + NSPoint origin = [view frame].origin; + origin.y += localVerticalShift; + [view setFrameOrigin:origin]; + } + localVerticalShift += delta.height; + } + // Deal with the two for the horizontal row. Size the second one. + CGFloat horizontalShift = 0.0; + NSView* view = [views objectAtIndex:2]; + NSSize delta = cocoa_l10n_util::WrapOrSizeToFit(view); + DCHECK_GE(delta.height, 0.0) << "Should NOT shrink in height"; + horizontalShift -= delta.width; + NSPoint origin = [view frame].origin; + origin.x += horizontalShift; + if (localVerticalShift) { + origin.y += localVerticalShift; + } + [view setFrameOrigin:origin]; + // Now expand the first item in the row to consume the space opened up. + view = [views objectAtIndex:1]; + if (horizontalShift) { + NSSize delta = NSMakeSize(horizontalShift, 0.0); + [GTMUILocalizerAndLayoutTweaker + resizeViewWithoutAutoResizingSubViews:view + delta:delta]; + } + // And move it up by any amount needed from the previous items. + if (localVerticalShift) { + NSPoint origin = [view frame].origin; + origin.y += localVerticalShift; + [view setFrameOrigin:origin]; + } + break; + } + default: + NOTREACHED(); + break; + } + + // If the label grew more then the views, the other views get an extra shift. + // Otherwise, move the label to its top is aligned with the other views. + CGFloat nonLabelShift = 0.0; + if (labelHeightChange > localVerticalShift) { + // Since the lable is taller, centering the other views looks best, just + // shift the views by 1/2 of the size difference. + nonLabelShift = (labelHeightChange - localVerticalShift) / 2.0; + } else { + NSPoint origin = [label frame].origin; + origin.y += localVerticalShift - labelHeightChange; + [label setFrameOrigin:origin]; + } + + // Apply the input shift requested along with any the shift from label being + // taller then the rest of the group. + for (NSView* view in views) { + NSPoint origin = [view frame].origin; + origin.y += verticalShift; + if (view != label) { + origin.y += nonLabelShift; + } + [view setFrameOrigin:origin]; + } + + // Return how much the group grew. + return localVerticalShift + nonLabelShift; +} + +// Helper to remove a view and move everything above it down to take over the +// space. +void RemoveViewFromView(NSView* view, NSView* toRemove) { + // Sort bottom up so we can spin over what is above it. + NSArray* views = + [[view subviews] sortedArrayUsingFunction:cocoa_l10n_util::CompareFrameY + context:NULL]; + + // Find where |toRemove| was. + NSUInteger index = [views indexOfObject:toRemove]; + DCHECK_NE(index, NSNotFound); + NSUInteger count = [views count]; + CGFloat shrinkHeight = 0; + if (index < (count - 1)) { + // If we're not the topmost control, the amount to shift is the bottom of + // |toRemove| to the bottom of the view above it. + CGFloat shiftDown = + NSMinY([[views objectAtIndex:index + 1] frame]) - + NSMinY([toRemove frame]); + + // Now cycle over the views above it moving them down. + for (++index; index < count; ++index) { + NSView* view = [views objectAtIndex:index]; + NSPoint origin = [view frame].origin; + origin.y -= shiftDown; + [view setFrameOrigin:origin]; + } + + shrinkHeight = shiftDown; + } else if (index > 0) { + // If we're the topmost control, there's nothing to shift but we want to + // shrink until the top edge of the second-topmost control, unless it is + // actually higher than the topmost control (since we're sorting by the + // bottom edge). + shrinkHeight = std::max(0.f, + NSMaxY([toRemove frame]) - + NSMaxY([[views objectAtIndex:index - 1] frame])); + } + // If we only have one control, don't do any resizing (for now). + + // Remove |toRemove|. + [toRemove removeFromSuperview]; + + [GTMUILocalizerAndLayoutTweaker + resizeViewWithoutAutoResizingSubViews:view + delta:NSMakeSize(0, -shrinkHeight)]; +} + +// Simply removes all the views in |toRemove|. +void RemoveGroupFromView(NSView* view, NSArray* toRemove) { + for (NSView* viewToRemove in toRemove) { + RemoveViewFromView(view, viewToRemove); + } +} + +// Helper to tweak the layout of the "Under the Hood" content by autosizing all +// the views and moving things up vertically. Special case the two controls for +// download location as they are horizontal, and should fill the row. Special +// case "Content Settings" and "Clear browsing data" as they are horizontal as +// well. +CGFloat AutoSizeUnderTheHoodContent(NSView* view, + NSPathControl* downloadLocationControl, + NSButton* downloadLocationButton) { + CGFloat verticalShift = 0.0; + + // Loop bottom up through the views sizing and shifting. + NSArray* views = + [[view subviews] sortedArrayUsingFunction:cocoa_l10n_util::CompareFrameY + context:NULL]; + for (NSView* view in views) { + NSSize delta = cocoa_l10n_util::WrapOrSizeToFit(view); + DCHECK_GE(delta.height, 0.0) << "Should NOT shrink in height"; + if (verticalShift) { + NSPoint origin = [view frame].origin; + origin.y += verticalShift; + [view setFrameOrigin:origin]; + } + verticalShift += delta.height; + + // The Download Location controls go in a row with the button aligned to the + // right edge and the path control using all the rest of the space. + if (view == downloadLocationButton) { + NSPoint origin = [downloadLocationButton frame].origin; + origin.x -= delta.width; + [downloadLocationButton setFrameOrigin:origin]; + NSSize controlSize = [downloadLocationControl frame].size; + controlSize.width -= delta.width; + [downloadLocationControl setFrameSize:controlSize]; + } + } + + return verticalShift; +} + +} // namespace + +//------------------------------------------------------------------------- + +@interface PreferencesWindowController(Private) +// Callback when preferences are changed. |prefName| is the name of the +// pref that has changed. +- (void)prefChanged:(std::string*)prefName; +// Callback when sync state has changed. syncService_ needs to be +// queried to find out what happened. +- (void)syncStateChanged; +// Record the user performed a certain action and save the preferences. +- (void)recordUserAction:(const UserMetricsAction&) action; +- (void)registerPrefObservers; +- (void)configureInstant; + +// KVC setter methods. +- (void)setNewTabPageIsHomePageIndex:(NSInteger)val; +- (void)setHomepageURL:(NSString*)urlString; +- (void)setRestoreOnStartupIndex:(NSInteger)type; +- (void)setShowHomeButton:(BOOL)value; +- (void)setPasswordManagerEnabledIndex:(NSInteger)value; +- (void)setIsUsingDefaultTheme:(BOOL)value; +- (void)setShowAlternateErrorPages:(BOOL)value; +- (void)setUseSuggest:(BOOL)value; +- (void)setDnsPrefetch:(BOOL)value; +- (void)setSafeBrowsing:(BOOL)value; +- (void)setMetricsReporting:(BOOL)value; +- (void)setAskForSaveLocation:(BOOL)value; +- (void)setFileHandlerUIEnabled:(BOOL)value; +- (void)setTranslateEnabled:(BOOL)value; +- (void)setTabsToLinks:(BOOL)value; +- (void)displayPreferenceViewForPage:(OptionsPage)page + animate:(BOOL)animate; +- (void)resetSubViews; +- (void)initBannerStateForPage:(OptionsPage)page; + +// KVC getter methods. +- (BOOL)fileHandlerUIEnabled; +@end + +namespace PreferencesWindowControllerInternal { + +// A C++ class registered for changes in preferences. Bridges the +// notification back to the PWC. +class PrefObserverBridge : public NotificationObserver, + public ProfileSyncServiceObserver { + public: + PrefObserverBridge(PreferencesWindowController* controller) + : controller_(controller) {} + + virtual ~PrefObserverBridge() {} + + // Overridden from NotificationObserver: + virtual void Observe(NotificationType type, + const NotificationSource& source, + const NotificationDetails& details) { + if (type == NotificationType::PREF_CHANGED) + [controller_ prefChanged:Details<std::string>(details).ptr()]; + } + + // Overridden from ProfileSyncServiceObserver. + virtual void OnStateChanged() { + [controller_ syncStateChanged]; + } + + private: + PreferencesWindowController* controller_; // weak, owns us +}; + +// Tracks state for a managed prefs banner and triggers UI updates through the +// PreferencesWindowController as appropriate. +class ManagedPrefsBannerState : public policy::ManagedPrefsBannerBase { + public: + virtual ~ManagedPrefsBannerState() { } + + explicit ManagedPrefsBannerState(PreferencesWindowController* controller, + OptionsPage page, + PrefService* local_state, + PrefService* prefs) + : policy::ManagedPrefsBannerBase(local_state, prefs, page), + controller_(controller), + page_(page) { } + + BOOL IsVisible() { + return DetermineVisibility(); + } + + protected: + // Overridden from ManagedPrefsBannerBase. + virtual void OnUpdateVisibility() { + [controller_ switchToPage:page_ animate:YES]; + } + + private: + PreferencesWindowController* controller_; // weak, owns us + OptionsPage page_; // current options page +}; + +} // namespace PreferencesWindowControllerInternal + +@implementation PreferencesWindowController + +@synthesize restoreButtonsEnabled = restoreButtonsEnabled_; +@synthesize restoreURLsEnabled = restoreURLsEnabled_; +@synthesize showHomeButtonEnabled = showHomeButtonEnabled_; +@synthesize defaultSearchEngineEnabled = defaultSearchEngineEnabled_; +@synthesize passwordManagerChoiceEnabled = passwordManagerChoiceEnabled_; +@synthesize passwordManagerButtonEnabled = passwordManagerButtonEnabled_; +@synthesize autoFillSettingsButtonEnabled = autoFillSettingsButtonEnabled_; +@synthesize showAlternateErrorPagesEnabled = showAlternateErrorPagesEnabled_; +@synthesize useSuggestEnabled = useSuggestEnabled_; +@synthesize dnsPrefetchEnabled = dnsPrefetchEnabled_; +@synthesize safeBrowsingEnabled = safeBrowsingEnabled_; +@synthesize metricsReportingEnabled = metricsReportingEnabled_; +@synthesize proxiesConfigureButtonEnabled = proxiesConfigureButtonEnabled_; + +- (id)initWithProfile:(Profile*)profile initialPage:(OptionsPage)initialPage { + DCHECK(profile); + // Use initWithWindowNibPath:: instead of initWithWindowNibName: so we + // can override it in a unit test. + NSString* nibPath = [mac_util::MainAppBundle() + pathForResource:@"Preferences" + ofType:@"nib"]; + if ((self = [super initWithWindowNibPath:nibPath owner:self])) { + profile_ = profile->GetOriginalProfile(); + initialPage_ = initialPage; + prefs_ = profile->GetPrefs(); + DCHECK(prefs_); + observer_.reset( + new PreferencesWindowControllerInternal::PrefObserverBridge(self)); + + // Set up the model for the custom home page table. The KVO observation + // tells us when the number of items in the array changes. The normal + // observation tells us when one of the URLs of an item changes. + customPagesSource_.reset([[CustomHomePagesModel alloc] + initWithProfile:profile_]); + const SessionStartupPref startupPref = + SessionStartupPref::GetStartupPref(prefs_); + [customPagesSource_ setURLs:startupPref.urls]; + + // Set up the model for the default search popup. Register for notifications + // about when the model changes so we can update the selection in the view. + searchEngineModel_.reset( + [[SearchEngineListModel alloc] + initWithModel:profile->GetTemplateURLModel()]); + [[NSNotificationCenter defaultCenter] + addObserver:self + selector:@selector(searchEngineModelChanged:) + name:kSearchEngineListModelChangedNotification + object:searchEngineModel_.get()]; + + // This needs to be done before awakeFromNib: because the bindings set up + // in the nib rely on it. + [self registerPrefObservers]; + + // Use one animation so we can stop it if the user clicks quickly, and + // start the new animation. + animation_.reset([[NSViewAnimation alloc] init]); + // Make this the delegate so it can remove the old view at the end of the + // animation (once it is faded out). + [animation_ setDelegate:self]; + [animation_ setAnimationBlockingMode:NSAnimationNonblocking]; + + // TODO(akalin): handle incognito profiles? The windows version of this + // (in chrome/browser/views/options/content_page_view.cc) just does what + // we do below. + syncService_ = profile_->GetProfileSyncService(); + + // TODO(akalin): This color is taken from kSyncLabelErrorBgColor in + // content_page_view.cc. Either decomp that color out into a + // function/variable that is referenced by both this file and + // content_page_view.cc, or maybe pick a more suitable color. + syncErrorBackgroundColor_.reset( + [[NSColor colorWithDeviceRed:0xff/255.0 + green:0x9a/255.0 + blue:0x9a/255.0 + alpha:1.0] retain]); + + // Disable the |autoFillSettingsButton_| if we have no + // |personalDataManager|. + PersonalDataManager* personalDataManager = + profile_->GetPersonalDataManager(); + [autoFillSettingsButton_ setHidden:(personalDataManager == NULL)]; + bool autofill_disabled_by_policy = + autoFillEnabled_.IsManaged() && !autoFillEnabled_.GetValue(); + [self setAutoFillSettingsButtonEnabled:!autofill_disabled_by_policy]; + [self setPasswordManagerChoiceEnabled:!askSavePasswords_.IsManaged()]; + [self setPasswordManagerButtonEnabled: + !askSavePasswords_.IsManaged() || askSavePasswords_.GetValue()]; + + // Initialize the enabled state of the elements on the general tab. + [self setShowHomeButtonEnabled:!showHomeButton_.IsManaged()]; + [self setEnabledStateOfRestoreOnStartup]; + [self setDefaultSearchEngineEnabled:![searchEngineModel_ isDefaultManaged]]; + + // Initialize UI state for the advanced page. + [self setShowAlternateErrorPagesEnabled:!alternateErrorPages_.IsManaged()]; + [self setUseSuggestEnabled:!useSuggest_.IsManaged()]; + [self setDnsPrefetchEnabled:!dnsPrefetch_.IsManaged()]; + [self setSafeBrowsingEnabled:!safeBrowsing_.IsManaged()]; + [self setMetricsReportingEnabled:!metricsReporting_.IsManaged()]; + proxyPrefs_.reset( + PrefSetObserver::CreateProxyPrefSetObserver(prefs_, observer_.get())); + [self setProxiesConfigureButtonEnabled:!proxyPrefs_->IsManaged()]; + } + return self; +} + +- (void)awakeFromNib { + + // Validate some assumptions in debug builds. + + // "Basics", "Personal Stuff", and "Under the Hood" views should be the same + // width. They should be the same width so they are laid out to look as good + // as possible at that width with controls just having to wrap if their text + // is too long. + DCHECK_EQ(NSWidth([basicsView_ frame]), NSWidth([personalStuffView_ frame])) + << "Basics and Personal Stuff should be the same widths"; + DCHECK_EQ(NSWidth([basicsView_ frame]), NSWidth([underTheHoodView_ frame])) + << "Basics and Under the Hood should be the same widths"; + // "Under the Hood" content should always be skinnier than the scroller it + // goes into (we resize it). + DCHECK_LE(NSWidth([underTheHoodContentView_ frame]), + [underTheHoodScroller_ contentSize].width) + << "The Under the Hood content should be narrower than the content " + "of the scroller it goes into"; + +#if !defined(GOOGLE_CHROME_BUILD) + // "Enable logging" (breakpad and stats) is only in Google Chrome builds, + // remove the checkbox and slide everything above it down. + RemoveViewFromView(underTheHoodContentView_, enableLoggingCheckbox_); +#endif // !defined(GOOGLE_CHROME_BUILD) + + // There are four problem children within the groups: + // Basics - Default Browser + // Personal Stuff - Sync + // Personal Stuff - Themes + // Personal Stuff - Browser Data + // These four have buttons that with some localizations are wider then the + // view. So the four get manually laid out before doing the general work so + // the views/window can be made wide enough to fit them. The layout in the + // general pass is a noop for these buttons (since they are already sized). + + // Size the default browser button. + const NSUInteger kDefaultBrowserGroupCount = 3; + const NSUInteger kDefaultBrowserButtonIndex = 1; + DCHECK_EQ([basicsGroupDefaultBrowser_ count], kDefaultBrowserGroupCount) + << "Expected only two items in Default Browser group"; + NSButton* defaultBrowserButton = + [basicsGroupDefaultBrowser_ objectAtIndex:kDefaultBrowserButtonIndex]; + NSSize defaultBrowserChange = + [GTMUILocalizerAndLayoutTweaker sizeToFitView:defaultBrowserButton]; + DCHECK_EQ(defaultBrowserChange.height, 0.0) + << "Button should have been right height in nib"; + + [self configureInstant]; + + // Size the sync row. + CGFloat syncRowChange = SizeToFitButtonPair(syncButton_, + syncCustomizeButton_); + + // Size the themes row. + const NSUInteger kThemeGroupCount = 3; + const NSUInteger kThemeResetButtonIndex = 1; + const NSUInteger kThemeThemesButtonIndex = 2; + DCHECK_EQ([personalStuffGroupThemes_ count], kThemeGroupCount) + << "Expected only two items in Themes group"; + CGFloat themeRowChange = SizeToFitButtonPair( + [personalStuffGroupThemes_ objectAtIndex:kThemeResetButtonIndex], + [personalStuffGroupThemes_ objectAtIndex:kThemeThemesButtonIndex]); + + // Size the Privacy and Clear buttons that make a row in Under the Hood. + CGFloat privacyRowChange = SizeToFitButtonPair(contentSettingsButton_, + clearDataButton_); + // Under the Hood view is narrower (then the other panes) in the nib, subtract + // out the amount it was already going to grow to match the other panes when + // calculating how much the row needs things to grow. + privacyRowChange -= + ([underTheHoodScroller_ contentSize].width - + NSWidth([underTheHoodContentView_ frame])); + + // Find the most any row changed in size. + CGFloat maxWidthChange = std::max(defaultBrowserChange.width, syncRowChange); + maxWidthChange = std::max(maxWidthChange, themeRowChange); + maxWidthChange = std::max(maxWidthChange, privacyRowChange); + + // If any grew wider, make the views wider. If they all shrank, they fit the + // existing view widths, so no change is needed//. + if (maxWidthChange > 0.0) { + NSSize viewSize = [basicsView_ frame].size; + viewSize.width += maxWidthChange; + [basicsView_ setFrameSize:viewSize]; + viewSize = [personalStuffView_ frame].size; + viewSize.width += maxWidthChange; + [personalStuffView_ setFrameSize:viewSize]; + } + + // Now that we have the width needed for Basics and Personal Stuff, lay out + // those pages bottom up making sure the strings fit and moving things up as + // needed. + + CGFloat newWidth = NSWidth([basicsView_ frame]); + CGFloat verticalShift = 0.0; + verticalShift += AutoSizeGroup(basicsGroupDefaultBrowser_, + kAutoSizeGroupBehaviorVerticalFirstToFit, + verticalShift); + // TODO(rsesek/rohitrao): This is ugly, when the instant experiement is no + // longer displayed, please remove this code, the NSTextField and IBOutlet + // needed. + DCHECK(instantExperiment_ != nil); + if (verticalShift) { + // If the default browser moved things up, move the experiment field up + // also, it is not in the SearchEngine group due to its position on screen. + NSPoint origin = [instantExperiment_ frame].origin; + origin.y += verticalShift; + [instantExperiment_ setFrameOrigin:origin]; + } + // End TODO + verticalShift += AutoSizeGroup(basicsGroupSearchEngine_, + kAutoSizeGroupBehaviorFirstTwoAsRowVerticalToFit, + verticalShift); + verticalShift += AutoSizeGroup(basicsGroupToolbar_, + kAutoSizeGroupBehaviorVerticalToFit, + verticalShift); + verticalShift += AutoSizeGroup(basicsGroupHomePage_, + kAutoSizeGroupBehaviorVerticalToFit, + verticalShift); + verticalShift += AutoSizeGroup(basicsGroupStartup_, + kAutoSizeGroupBehaviorVerticalFirstToFit, + verticalShift); + [GTMUILocalizerAndLayoutTweaker + resizeViewWithoutAutoResizingSubViews:basicsView_ + delta:NSMakeSize(0.0, verticalShift)]; + + verticalShift = 0.0; + verticalShift += AutoSizeGroup(personalStuffGroupThemes_, + kAutoSizeGroupBehaviorHorizontalToFit, + verticalShift); + verticalShift += AutoSizeGroup(personalStuffGroupBrowserData_, + kAutoSizeGroupBehaviorVerticalToFit, + verticalShift); + verticalShift += AutoSizeGroup(personalStuffGroupAutofill_, + kAutoSizeGroupBehaviorVerticalToFit, + verticalShift); + verticalShift += AutoSizeGroup(personalStuffGroupPasswords_, + kAutoSizeGroupBehaviorVerticalToFit, + verticalShift); + // TODO(akalin): Here we rely on the initial contents of the sync + // group's text field/link field to be large enough to hold all + // possible messages so that we don't have to re-layout when sync + // state changes. This isn't perfect, since e.g. some sync messages + // use the user's e-mail address (which may be really long), and the + // link field is usually not shown (leaving a big empty space). + // Rethink sync preferences UI for Mac. + verticalShift += AutoSizeGroup(personalStuffGroupSync_, + kAutoSizeGroupBehaviorVerticalToFit, + verticalShift); + [GTMUILocalizerAndLayoutTweaker + resizeViewWithoutAutoResizingSubViews:personalStuffView_ + delta:NSMakeSize(0.0, verticalShift)]; + + if (syncService_) { + syncService_->AddObserver(observer_.get()); + // Update the controls according to the initial state. + [self syncStateChanged]; + } else { + // If sync is disabled we don't want to show the sync controls at all. + RemoveGroupFromView(personalStuffView_, personalStuffGroupSync_); + } + + // Make the window as wide as the views. + NSWindow* prefsWindow = [self window]; + NSView* prefsContentView = [prefsWindow contentView]; + NSRect frame = [prefsContentView convertRect:[prefsWindow frame] + fromView:nil]; + frame.size.width = newWidth; + frame = [prefsContentView convertRect:frame toView:nil]; + [prefsWindow setFrame:frame display:NO]; + + // The Under the Hood prefs is a scroller, it shouldn't get any border, so it + // gets resized to be as wide as the window ended up. + NSSize underTheHoodSize = [underTheHoodView_ frame].size; + underTheHoodSize.width = newWidth; + [underTheHoodView_ setFrameSize:underTheHoodSize]; + + // Widen the Under the Hood content so things can rewrap to the full width. + NSSize underTheHoodContentSize = [underTheHoodContentView_ frame].size; + underTheHoodContentSize.width = [underTheHoodScroller_ contentSize].width; + [underTheHoodContentView_ setFrameSize:underTheHoodContentSize]; + + // Now that Under the Hood is the right width, auto-size to the new width to + // get the final height. + verticalShift = AutoSizeUnderTheHoodContent(underTheHoodContentView_, + downloadLocationControl_, + downloadLocationButton_); + [GTMUILocalizerAndLayoutTweaker + resizeViewWithoutAutoResizingSubViews:underTheHoodContentView_ + delta:NSMakeSize(0.0, verticalShift)]; + underTheHoodContentSize = [underTheHoodContentView_ frame].size; + + // Put the Under the Hood content view into the scroller and scroll it to the + // top. + [underTheHoodScroller_ setDocumentView:underTheHoodContentView_]; + [underTheHoodContentView_ scrollPoint: + NSMakePoint(0, underTheHoodContentSize.height)]; + + ResourceBundle& rb = ResourceBundle::GetSharedInstance(); + NSImage* alertIcon = rb.GetNativeImageNamed(IDR_WARNING); + DCHECK(alertIcon); + [managedPrefsBannerWarningImage_ setImage:alertIcon]; + + [self initBannerStateForPage:initialPage_]; + [self switchToPage:initialPage_ animate:NO]; + + // Save/restore position based on prefs. + if (g_browser_process && g_browser_process->local_state()) { + sizeSaver_.reset([[WindowSizeAutosaver alloc] + initWithWindow:[self window] + prefService:g_browser_process->local_state() + path:prefs::kPreferencesWindowPlacement]); + } + + // Initialize the banner gradient and stroke color. + NSColor* bannerStartingColor = + [NSColor colorWithCalibratedRed:kBannerGradientColorTop[0] + green:kBannerGradientColorTop[1] + blue:kBannerGradientColorTop[2] + alpha:1.0]; + NSColor* bannerEndingColor = + [NSColor colorWithCalibratedRed:kBannerGradientColorBottom[0] + green:kBannerGradientColorBottom[1] + blue:kBannerGradientColorBottom[2] + alpha:1.0]; + scoped_nsobject<NSGradient> bannerGradient( + [[NSGradient alloc] initWithStartingColor:bannerStartingColor + endingColor:bannerEndingColor]); + [managedPrefsBannerView_ setGradient:bannerGradient]; + + NSColor* bannerStrokeColor = + [NSColor colorWithCalibratedWhite:kBannerStrokeColor + alpha:1.0]; + [managedPrefsBannerView_ setStrokeColor:bannerStrokeColor]; + + // Set accessibility related attributes. + NSTableView* tableView = [basicsView_ viewWithTag:kBasicsStartupPageTableTag]; + NSString* description = + l10n_util::GetNSStringWithFixup(IDS_OPTIONS_STARTUP_SHOW_PAGES); + [tableView accessibilitySetOverrideValue:description + forAttribute:NSAccessibilityDescriptionAttribute]; +} + +- (void)dealloc { + if (syncService_) { + syncService_->RemoveObserver(observer_.get()); + } + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [animation_ setDelegate:nil]; + [animation_ stopAnimation]; + [super dealloc]; +} + +// Xcode 3.1.x version of Interface Builder doesn't do a lot for editing +// toolbars in XIB. So the toolbar's delegate is set to the controller so it +// can tell the toolbar what items are selectable. +- (NSArray*)toolbarSelectableItemIdentifiers:(NSToolbar*)toolbar { + DCHECK(toolbar == toolbar_); + return [[toolbar_ items] valueForKey:@"itemIdentifier"]; +} + +// Register our interest in the preferences we're displaying so if anything +// else in the UI changes them we will be updated. +- (void)registerPrefObservers { + if (!prefs_) return; + + // Basics panel + registrar_.Init(prefs_); + registrar_.Add(prefs::kURLsToRestoreOnStartup, observer_.get()); + restoreOnStartup_.Init(prefs::kRestoreOnStartup, prefs_, observer_.get()); + newTabPageIsHomePage_.Init(prefs::kHomePageIsNewTabPage, + prefs_, observer_.get()); + homepage_.Init(prefs::kHomePage, prefs_, observer_.get()); + showHomeButton_.Init(prefs::kShowHomeButton, prefs_, observer_.get()); + instantEnabled_.Init(prefs::kInstantEnabled, prefs_, observer_.get()); + + // Personal Stuff panel + askSavePasswords_.Init(prefs::kPasswordManagerEnabled, + prefs_, observer_.get()); + autoFillEnabled_.Init(prefs::kAutoFillEnabled, prefs_, observer_.get()); + currentTheme_.Init(prefs::kCurrentThemeID, prefs_, observer_.get()); + + // Under the hood panel + alternateErrorPages_.Init(prefs::kAlternateErrorPagesEnabled, + prefs_, observer_.get()); + useSuggest_.Init(prefs::kSearchSuggestEnabled, prefs_, observer_.get()); + dnsPrefetch_.Init(prefs::kDnsPrefetchingEnabled, prefs_, observer_.get()); + safeBrowsing_.Init(prefs::kSafeBrowsingEnabled, prefs_, observer_.get()); + autoOpenFiles_.Init( + prefs::kDownloadExtensionsToOpen, prefs_, observer_.get()); + translateEnabled_.Init(prefs::kEnableTranslate, prefs_, observer_.get()); + tabsToLinks_.Init(prefs::kWebkitTabsToLinks, prefs_, observer_.get()); + + // During unit tests, there is no local state object, so we fall back to + // the prefs object (where we've explicitly registered this pref so we + // know it's there). + PrefService* local = g_browser_process->local_state(); + if (!local) + local = prefs_; + metricsReporting_.Init(prefs::kMetricsReportingEnabled, + local, observer_.get()); + defaultDownloadLocation_.Init(prefs::kDownloadDefaultDirectory, prefs_, + observer_.get()); + askForSaveLocation_.Init(prefs::kPromptForDownload, prefs_, observer_.get()); + + // We don't need to observe changes in this value. + lastSelectedPage_.Init(prefs::kOptionsWindowLastTabIndex, local, NULL); +} + +// Called when the window wants to be closed. +- (BOOL)windowShouldClose:(id)sender { + // Stop any animation and clear the delegate to avoid stale pointers. + [animation_ setDelegate:nil]; + [animation_ stopAnimation]; + + return YES; +} + +// Called when the user hits the escape key. Closes the window. +- (void)cancel:(id)sender { + [[self window] performClose:self]; +} + +// Record the user performed a certain action and save the preferences. +- (void)recordUserAction:(const UserMetricsAction &)action { + UserMetrics::RecordAction(action, profile_); + if (prefs_) + prefs_->ScheduleSavePersistentPrefs(); +} + +// Returns the set of keys that |key| depends on for its value so it can be +// re-computed when any of those change as well. ++ (NSSet*)keyPathsForValuesAffectingValueForKey:(NSString*)key { + NSSet* paths = [super keyPathsForValuesAffectingValueForKey:key]; + if ([key isEqualToString:@"isHomepageURLEnabled"]) { + paths = [paths setByAddingObject:@"newTabPageIsHomePageIndex"]; + paths = [paths setByAddingObject:@"homepageURL"]; + } else if ([key isEqualToString:@"restoreURLsEnabled"]) { + paths = [paths setByAddingObject:@"restoreOnStartupIndex"]; + } else if ([key isEqualToString:@"isHomepageChoiceEnabled"]) { + paths = [paths setByAddingObject:@"newTabPageIsHomePageIndex"]; + paths = [paths setByAddingObject:@"homepageURL"]; + } else if ([key isEqualToString:@"newTabPageIsHomePageIndex"]) { + paths = [paths setByAddingObject:@"homepageURL"]; + } else if ([key isEqualToString:@"hompageURL"]) { + paths = [paths setByAddingObject:@"newTabPageIsHomePageIndex"]; + } else if ([key isEqualToString:@"isDefaultBrowser"]) { + paths = [paths setByAddingObject:@"defaultBrowser"]; + } else if ([key isEqualToString:@"defaultBrowserTextColor"]) { + paths = [paths setByAddingObject:@"defaultBrowser"]; + } else if ([key isEqualToString:@"defaultBrowserText"]) { + paths = [paths setByAddingObject:@"defaultBrowser"]; + } + return paths; +} + +// Launch the Keychain Access app. +- (void)launchKeychainAccess { + NSString* const kKeychainBundleId = @"com.apple.keychainaccess"; + [[NSWorkspace sharedWorkspace] + launchAppWithBundleIdentifier:kKeychainBundleId + options:0L + additionalEventParamDescriptor:nil + launchIdentifier:nil]; +} + +//------------------------------------------------------------------------- +// Basics panel + +// Sets the home page preferences for kNewTabPageIsHomePage and kHomePage. If a +// blank or null-host URL is passed in we revert to using NewTab page +// as the Home page. Note: using SetValue() causes the observers not to fire, +// which is actually a good thing as we could end up in a state where setting +// the homepage to an empty url would automatically reset the prefs back to +// using the NTP, so we'd be never be able to change it. +- (void)setHomepage:(const GURL&)homepage { + if (IsNewTabUIURLString(homepage)) { + newTabPageIsHomePage_.SetValueIfNotManaged(true); + homepage_.SetValueIfNotManaged(std::string()); + } else if (!homepage.is_valid()) { + newTabPageIsHomePage_.SetValueIfNotManaged(true); + if (!homepage.has_host()) + homepage_.SetValueIfNotManaged(std::string()); + } else { + homepage_.SetValueIfNotManaged(homepage.spec()); + } +} + +// Callback when preferences are changed by someone modifying the prefs backend +// externally. |prefName| is the name of the pref that has changed. Unlike on +// Windows, we don't need to use this method for initializing, that's handled by +// Cocoa Bindings. +// Handles prefs for the "Basics" panel. +- (void)basicsPrefChanged:(std::string*)prefName { + if (*prefName == prefs::kRestoreOnStartup) { + const SessionStartupPref startupPref = + SessionStartupPref::GetStartupPref(prefs_); + [self setRestoreOnStartupIndex:startupPref.type]; + [self setEnabledStateOfRestoreOnStartup]; + } else if (*prefName == prefs::kURLsToRestoreOnStartup) { + [customPagesSource_ reloadURLs]; + [self setEnabledStateOfRestoreOnStartup]; + } else if (*prefName == prefs::kHomePageIsNewTabPage) { + NSInteger useNewTabPage = newTabPageIsHomePage_.GetValue() ? 0 : 1; + [self setNewTabPageIsHomePageIndex:useNewTabPage]; + } else if (*prefName == prefs::kHomePage) { + NSString* value = base::SysUTF8ToNSString(homepage_.GetValue()); + [self setHomepageURL:value]; + } else if (*prefName == prefs::kShowHomeButton) { + [self setShowHomeButton:showHomeButton_.GetValue() ? YES : NO]; + [self setShowHomeButtonEnabled:!showHomeButton_.IsManaged()]; + } else if (*prefName == prefs::kInstantEnabled) { + [self configureInstant]; + } +} + +// Returns the index of the selected cell in the "on startup" matrix based +// on the "restore on startup" pref. The ordering of the cells is in the +// same order as the pref. +- (NSInteger)restoreOnStartupIndex { + const SessionStartupPref pref = SessionStartupPref::GetStartupPref(prefs_); + return pref.type; +} + +// A helper function that takes the startup session type, grabs the URLs to +// restore, and saves it all in prefs. +- (void)saveSessionStartupWithType:(SessionStartupPref::Type)type { + SessionStartupPref pref; + pref.type = type; + pref.urls = [customPagesSource_.get() URLs]; + SessionStartupPref::SetStartupPref(prefs_, pref); +} + +// Sets the pref based on the index of the selected cell in the matrix and +// marks the appropriate user metric. +- (void)setRestoreOnStartupIndex:(NSInteger)type { + SessionStartupPref::Type startupType = + static_cast<SessionStartupPref::Type>(type); + switch (startupType) { + case SessionStartupPref::DEFAULT: + [self recordUserAction:UserMetricsAction("Options_Startup_Homepage")]; + break; + case SessionStartupPref::LAST: + [self recordUserAction:UserMetricsAction("Options_Startup_LastSession")]; + break; + case SessionStartupPref::URLS: + [self recordUserAction:UserMetricsAction("Options_Startup_Custom")]; + break; + default: + NOTREACHED(); + } + [self saveSessionStartupWithType:startupType]; +} + +// Enables or disables the restoreOnStartup elements +- (void) setEnabledStateOfRestoreOnStartup { + const SessionStartupPref startupPref = + SessionStartupPref::GetStartupPref(prefs_); + [self setRestoreButtonsEnabled:!SessionStartupPref::TypeIsManaged(prefs_)]; + [self setRestoreURLsEnabled:!SessionStartupPref::URLsAreManaged(prefs_) && + [self restoreOnStartupIndex] == SessionStartupPref::URLS]; +} + +// Getter for the |customPagesSource| property for bindings. +- (CustomHomePagesModel*)customPagesSource { + return customPagesSource_.get(); +} + +// Called when the selection in the table changes. If a flag is set indicating +// that we're waiting for a special select message, edit the cell. Otherwise +// just ignore it, we don't normally care. +- (void)tableViewSelectionDidChange:(NSNotification*)aNotification { + if (pendingSelectForEdit_) { + NSTableView* table = [aNotification object]; + NSUInteger selectedRow = [table selectedRow]; + [table editColumn:0 row:selectedRow withEvent:nil select:YES]; + pendingSelectForEdit_ = NO; + } +} + +// Called when the user hits the (+) button for adding a new homepage to the +// list. This will also attempt to make the new item editable so the user can +// just start typing. +- (IBAction)addHomepage:(id)sender { + [customPagesArrayController_ add:sender]; + + // When the new item is added to the model, the array controller will select + // it. We'll watch for that notification (because we are the table view's + // delegate) and then make the cell editable. Note that this can't be + // accomplished simply by subclassing the array controller's add method (I + // did try). The update of the table is asynchronous with the controller + // updating the model. + pendingSelectForEdit_ = YES; +} + +// Called when the user hits the (-) button for removing the selected items in +// the homepage table. The controller does all the work. +- (IBAction)removeSelectedHomepages:(id)sender { + [customPagesArrayController_ remove:sender]; +} + +// Add all entries for all open browsers with our profile. +- (IBAction)useCurrentPagesAsHomepage:(id)sender { + std::vector<GURL> urls; + for (BrowserList::const_iterator browserIter = BrowserList::begin(); + browserIter != BrowserList::end(); ++browserIter) { + Browser* browser = *browserIter; + if (browser->profile() != profile_) + continue; // Only want entries for open profile. + + for (int tabIndex = 0; tabIndex < browser->tab_count(); ++tabIndex) { + TabContents* tab = browser->GetTabContentsAt(tabIndex); + if (tab->ShouldDisplayURL()) { + const GURL url = browser->GetTabContentsAt(tabIndex)->GetURL(); + if (!url.is_empty()) + urls.push_back(url); + } + } + } + [customPagesSource_ setURLs:urls]; +} + +enum { kHomepageNewTabPage, kHomepageURL }; + +// Here's a table describing the desired characteristics of the homepage choice +// radio value, it's enabled state and the URL field enabled state. They depend +// on the values of the managed bits for homepage (m_hp) and +// homepageIsNewTabPage (m_ntp) preferences, as well as the value of the +// homepageIsNewTabPage preference (ntp) and whether the homepage preference +// is equal to the new tab page URL (hpisntp). +// +// m_hp m_ntp ntp hpisntp | choice value | choice enabled | URL field enabled +// -------------------------------------------------------------------------- +// 0 0 0 0 | homepage | 1 | 1 +// 0 0 0 1 | new tab page | 1 | 0 +// 0 0 1 0 | new tab page | 1 | 0 +// 0 0 1 1 | new tab page | 1 | 0 +// 0 1 0 0 | homepage | 0 | 1 +// 0 1 0 1 | homepage | 0 | 1 +// 0 1 1 0 | new tab page | 0 | 0 +// 0 1 1 1 | new tab page | 0 | 0 +// 1 0 0 0 | homepage | 1 | 0 +// 1 0 0 1 | new tab page | 0 | 0 +// 1 0 1 0 | new tab page | 1 | 0 +// 1 0 1 1 | new tab page | 0 | 0 +// 1 1 0 0 | homepage | 0 | 0 +// 1 1 0 1 | new tab page | 0 | 0 +// 1 1 1 0 | new tab page | 0 | 0 +// 1 1 1 1 | new tab page | 0 | 0 +// +// thus, we have: +// +// choice value is new tab page === ntp || (hpisntp && (m_hp || !m_ntp)) +// choice enabled === !m_ntp && !(m_hp && hpisntp) +// URL field enabled === !ntp && !mhp && !(hpisntp && !m_ntp) +// +// which also make sense if you think about them. + +// Checks whether the homepage URL refers to the new tab page. +- (BOOL)isHomepageNewTabUIURL { + return IsNewTabUIURLString(GURL(homepage_.GetValue().c_str())); +} + +// Returns the index of the selected cell in the "home page" marix based on +// the "new tab is home page" pref. Sadly, the ordering is reversed from the +// pref value. +- (NSInteger)newTabPageIsHomePageIndex { + return newTabPageIsHomePage_.GetValue() || + ([self isHomepageNewTabUIURL] && + (homepage_.IsManaged() || !newTabPageIsHomePage_.IsManaged())) ? + kHomepageNewTabPage : kHomepageURL; +} + +// Sets the pref based on the given index into the matrix and marks the +// appropriate user metric. +- (void)setNewTabPageIsHomePageIndex:(NSInteger)index { + bool useNewTabPage = index == kHomepageNewTabPage ? true : false; + if (useNewTabPage) { + [self recordUserAction:UserMetricsAction("Options_Homepage_UseNewTab")]; + } else { + [self recordUserAction:UserMetricsAction("Options_Homepage_UseURL")]; + if ([self isHomepageNewTabUIURL]) + homepage_.SetValueIfNotManaged(std::string()); + } + newTabPageIsHomePage_.SetValueIfNotManaged(useNewTabPage); +} + +// Check whether the new tab and URL homepage radios should be enabled, i.e. if +// the corresponding preference is not managed through configuration policy. +- (BOOL)isHomepageChoiceEnabled { + return !newTabPageIsHomePage_.IsManaged() && + !(homepage_.IsManaged() && [self isHomepageNewTabUIURL]); +} + +// Returns whether or not the homepage URL text field should be enabled +// based on if the new tab page is the home page. +- (BOOL)isHomepageURLEnabled { + return !newTabPageIsHomePage_.GetValue() && !homepage_.IsManaged() && + !([self isHomepageNewTabUIURL] && !newTabPageIsHomePage_.IsManaged()); +} + +// Returns the homepage URL. +- (NSString*)homepageURL { + NSString* value = base::SysUTF8ToNSString(homepage_.GetValue()); + return [self isHomepageNewTabUIURL] ? nil : value; +} + +// Sets the homepage URL to |urlString| with some fixing up. +- (void)setHomepageURL:(NSString*)urlString { + // If the text field contains a valid URL, sync it to prefs. We run it + // through the fixer upper to allow input like "google.com" to be converted + // to something valid ("http://google.com"). + std::string unfixedURL = urlString ? base::SysNSStringToUTF8(urlString) : + chrome::kChromeUINewTabURL; + [self setHomepage:URLFixerUpper::FixupURL(unfixedURL, std::string())]; +} + +// Returns whether the home button should be checked based on the preference. +- (BOOL)showHomeButton { + return showHomeButton_.GetValue() ? YES : NO; +} + +// Sets the backend pref for whether or not the home button should be displayed +// based on |value|. +- (void)setShowHomeButton:(BOOL)value { + if (value) + [self recordUserAction:UserMetricsAction( + "Options_Homepage_ShowHomeButton")]; + else + [self recordUserAction:UserMetricsAction( + "Options_Homepage_HideHomeButton")]; + showHomeButton_.SetValueIfNotManaged(value ? true : false); +} + +// Getter for the |searchEngineModel| property for bindings. +- (id)searchEngineModel { + return searchEngineModel_.get(); +} + +// Bindings for the search engine popup. We not binding directly to the model +// in order to siphon off the setter so we can record the metric. If we're +// doing it with one, might as well do it with both. +- (NSUInteger)searchEngineSelectedIndex { + return [searchEngineModel_ defaultIndex]; +} + +- (void)setSearchEngineSelectedIndex:(NSUInteger)index { + [self recordUserAction:UserMetricsAction("Options_SearchEngineChanged")]; + [searchEngineModel_ setDefaultIndex:index]; +} + +// Called when the search engine model changes. Update the selection in the +// popup by tickling the bindings with the new value. +- (void)searchEngineModelChanged:(NSNotification*)notify { + [self setSearchEngineSelectedIndex:[self searchEngineSelectedIndex]]; + [self setDefaultSearchEngineEnabled:![searchEngineModel_ isDefaultManaged]]; + +} + +- (IBAction)manageSearchEngines:(id)sender { + [KeywordEditorCocoaController showKeywordEditor:profile_]; +} + +- (IBAction)toggleInstant:(id)sender { + if (instantEnabled_.GetValue()) { + InstantController::Disable(profile_); + } else { + [instantCheckbox_ setState:NSOffState]; + browser::ShowInstantConfirmDialogIfNecessary([self window], profile_); + } +} + +// Sets the state of the Instant checkbox and adds the type information to the +// label. +- (void)configureInstant { + bool enabled = instantEnabled_.GetValue(); + NSInteger state = enabled ? NSOnState : NSOffState; + [instantCheckbox_ setState:state]; + + [instantExperiment_ setStringValue:@""]; +} + +- (IBAction)learnMoreAboutInstant:(id)sender { + browser::ShowOptionsURL(profile_, GURL(browser::kInstantLearnMoreURL)); +} + +// Called when the user clicks the button to make Chromium the default +// browser. Registers http and https. +- (IBAction)makeDefaultBrowser:(id)sender { + [self willChangeValueForKey:@"defaultBrowser"]; + + ShellIntegration::SetAsDefaultBrowser(); + [self recordUserAction:UserMetricsAction("Options_SetAsDefaultBrowser")]; + // If the user made Chrome the default browser, then he/she arguably wants + // to be notified when that changes. + prefs_->SetBoolean(prefs::kCheckDefaultBrowser, true); + + // Tickle KVO so that the UI updates. + [self didChangeValueForKey:@"defaultBrowser"]; +} + +// Returns the Chromium default browser state. +- (ShellIntegration::DefaultBrowserState)isDefaultBrowser { + return ShellIntegration::IsDefaultBrowser(); +} + +// Returns the text color of the "chromium is your default browser" text (green +// for yes, red for no). +- (NSColor*)defaultBrowserTextColor { + ShellIntegration::DefaultBrowserState state = [self isDefaultBrowser]; + return (state == ShellIntegration::IS_DEFAULT_BROWSER) ? + [NSColor colorWithCalibratedRed:0.0 green:135.0/255.0 blue:0 alpha:1.0] : + [NSColor colorWithCalibratedRed:135.0/255.0 green:0 blue:0 alpha:1.0]; +} + +// Returns the text for the "chromium is your default browser" string dependent +// on if Chromium actually is or not. +- (NSString*)defaultBrowserText { + ShellIntegration::DefaultBrowserState state = [self isDefaultBrowser]; + int stringId; + if (state == ShellIntegration::IS_DEFAULT_BROWSER) + stringId = IDS_OPTIONS_DEFAULTBROWSER_DEFAULT; + else if (state == ShellIntegration::NOT_DEFAULT_BROWSER) + stringId = IDS_OPTIONS_DEFAULTBROWSER_NOTDEFAULT; + else + stringId = IDS_OPTIONS_DEFAULTBROWSER_UNKNOWN; + string16 text = + l10n_util::GetStringFUTF16(stringId, + l10n_util::GetStringUTF16(IDS_PRODUCT_NAME)); + return base::SysUTF16ToNSString(text); +} + +//------------------------------------------------------------------------- +// User Data panel + +// Since passwords and forms are radio groups, 'enabled' is index 0 and +// 'disabled' is index 1. Yay. +const int kEnabledIndex = 0; +const int kDisabledIndex = 1; + +// Callback when preferences are changed. |prefName| is the name of the pref +// that has changed. Unlike on Windows, we don't need to use this method for +// initializing, that's handled by Cocoa Bindings. +// Handles prefs for the "Personal Stuff" panel. +- (void)userDataPrefChanged:(std::string*)prefName { + if (*prefName == prefs::kPasswordManagerEnabled) { + [self setPasswordManagerEnabledIndex:askSavePasswords_.GetValue() ? + kEnabledIndex : kDisabledIndex]; + [self setPasswordManagerChoiceEnabled:!askSavePasswords_.IsManaged()]; + [self setPasswordManagerButtonEnabled: + !askSavePasswords_.IsManaged() || askSavePasswords_.GetValue()]; + } + if (*prefName == prefs::kAutoFillEnabled) { + bool autofill_disabled_by_policy = + autoFillEnabled_.IsManaged() && !autoFillEnabled_.GetValue(); + [self setAutoFillSettingsButtonEnabled:!autofill_disabled_by_policy]; + } + if (*prefName == prefs::kCurrentThemeID) { + [self setIsUsingDefaultTheme:currentTheme_.GetValue().length() == 0]; + } +} + +// Called to launch the Keychain Access app to show the user's stored +// passwords. +- (IBAction)showSavedPasswords:(id)sender { + [self recordUserAction:UserMetricsAction("Options_ShowPasswordsExceptions")]; + [self launchKeychainAccess]; +} + +// Called to show the Auto Fill Settings dialog. +- (IBAction)showAutoFillSettings:(id)sender { + [self recordUserAction:UserMetricsAction("Options_ShowAutoFillSettings")]; + + PersonalDataManager* personalDataManager = profile_->GetPersonalDataManager(); + if (!personalDataManager) { + // Should not reach here because button is disabled when + // |personalDataManager| is NULL. + NOTREACHED(); + return; + } + + ShowAutoFillDialog(NULL, personalDataManager, profile_); +} + +// Called to import data from other browsers (Safari, Firefox, etc). +- (IBAction)importData:(id)sender { + UserMetrics::RecordAction(UserMetricsAction("Import_ShowDlg"), profile_); + [ImportSettingsDialogController showImportSettingsDialogForProfile:profile_]; +} + +- (IBAction)resetThemeToDefault:(id)sender { + [self recordUserAction:UserMetricsAction("Options_ThemesReset")]; + profile_->ClearTheme(); +} + +- (IBAction)themesGallery:(id)sender { + [self recordUserAction:UserMetricsAction("Options_ThemesGallery")]; + Browser* browser = BrowserList::GetLastActive(); + + if (!browser || !browser->GetSelectedTabContents()) + browser = Browser::Create(profile_); + browser->OpenThemeGalleryTabAndActivate(); +} + +// Called when the "stop syncing" confirmation dialog started by +// doSyncAction is finished. Stop syncing only If the user clicked +// OK. +- (void)stopSyncAlertDidEnd:(NSAlert*)alert + returnCode:(int)returnCode + contextInfo:(void*)contextInfo { + DCHECK(syncService_ && !syncService_->IsManaged()); + if (returnCode == NSAlertFirstButtonReturn) { + syncService_->DisableForUser(); + ProfileSyncService::SyncEvent(ProfileSyncService::STOP_FROM_OPTIONS); + } +} + +// Called when the user clicks the multi-purpose sync button in the +// "Personal Stuff" pane. +- (IBAction)doSyncAction:(id)sender { + DCHECK(syncService_ && !syncService_->IsManaged()); + if (syncService_->HasSyncSetupCompleted()) { + // If sync setup has completed that means the sync button was a + // "stop syncing" button. Bring up a confirmation dialog before + // actually stopping syncing (see stopSyncAlertDidEnd). + scoped_nsobject<NSAlert> alert([[NSAlert alloc] init]); + [alert addButtonWithTitle:l10n_util::GetNSStringWithFixup( + IDS_SYNC_STOP_SYNCING_CONFIRM_BUTTON_LABEL)]; + [alert addButtonWithTitle:l10n_util::GetNSStringWithFixup( + IDS_CANCEL)]; + [alert setMessageText:l10n_util::GetNSStringWithFixup( + IDS_SYNC_STOP_SYNCING_DIALOG_TITLE)]; + [alert setInformativeText:l10n_util::GetNSStringFWithFixup( + IDS_SYNC_STOP_SYNCING_EXPLANATION_LABEL, + l10n_util::GetStringUTF16(IDS_PRODUCT_NAME))]; + [alert setAlertStyle:NSWarningAlertStyle]; + const SEL kEndSelector = + @selector(stopSyncAlertDidEnd:returnCode:contextInfo:); + [alert beginSheetModalForWindow:[self window] + modalDelegate:self + didEndSelector:kEndSelector + contextInfo:NULL]; + } else { + // Otherwise, the sync button was a "sync my bookmarks" button. + // Kick off the sync setup process. + syncService_->ShowLoginDialog(NULL); + ProfileSyncService::SyncEvent(ProfileSyncService::START_FROM_OPTIONS); + } +} + +// Called when the user clicks on the link to the privacy dashboard. +- (IBAction)showPrivacyDashboard:(id)sender { + Browser* browser = BrowserList::GetLastActive(); + + if (!browser || !browser->GetSelectedTabContents()) + browser = Browser::Create(profile_); + browser->OpenPrivacyDashboardTabAndActivate(); +} + +// Called when the user clicks the "Customize Sync" button in the +// "Personal Stuff" pane. Spawns a dialog-modal sheet that cleans +// itself up on close. +- (IBAction)doSyncCustomize:(id)sender { + syncService_->ShowConfigure(NULL); +} + +- (IBAction)doSyncReauthentication:(id)sender { + DCHECK(syncService_ && !syncService_->IsManaged()); + syncService_->ShowLoginDialog(NULL); +} + +- (void)setPasswordManagerEnabledIndex:(NSInteger)value { + if (value == kEnabledIndex) + [self recordUserAction:UserMetricsAction( + "Options_PasswordManager_Enable")]; + else + [self recordUserAction:UserMetricsAction( + "Options_PasswordManager_Disable")]; + askSavePasswords_.SetValueIfNotManaged(value == kEnabledIndex ? true : false); +} + +- (NSInteger)passwordManagerEnabledIndex { + return askSavePasswords_.GetValue() ? kEnabledIndex : kDisabledIndex; +} + +- (void)setIsUsingDefaultTheme:(BOOL)value { + if (value) + [self recordUserAction:UserMetricsAction( + "Options_IsUsingDefaultTheme_Enable")]; + else + [self recordUserAction:UserMetricsAction( + "Options_IsUsingDefaultTheme_Disable")]; +} + +- (BOOL)isUsingDefaultTheme { + return currentTheme_.GetValue().length() == 0; +} + +//------------------------------------------------------------------------- +// Under the hood panel + +// Callback when preferences are changed. |prefName| is the name of the pref +// that has changed. Unlike on Windows, we don't need to use this method for +// initializing, that's handled by Cocoa Bindings. +// Handles prefs for the "Under the hood" panel. +- (void)underHoodPrefChanged:(std::string*)prefName { + if (*prefName == prefs::kAlternateErrorPagesEnabled) { + [self setShowAlternateErrorPages: + alternateErrorPages_.GetValue() ? YES : NO]; + [self setShowAlternateErrorPagesEnabled:!alternateErrorPages_.IsManaged()]; + } + else if (*prefName == prefs::kSearchSuggestEnabled) { + [self setUseSuggest:useSuggest_.GetValue() ? YES : NO]; + [self setUseSuggestEnabled:!useSuggest_.IsManaged()]; + } + else if (*prefName == prefs::kDnsPrefetchingEnabled) { + [self setDnsPrefetch:dnsPrefetch_.GetValue() ? YES : NO]; + [self setDnsPrefetchEnabled:!dnsPrefetch_.IsManaged()]; + } + else if (*prefName == prefs::kSafeBrowsingEnabled) { + [self setSafeBrowsing:safeBrowsing_.GetValue() ? YES : NO]; + [self setSafeBrowsingEnabled:!safeBrowsing_.IsManaged()]; + } + else if (*prefName == prefs::kMetricsReportingEnabled) { + [self setMetricsReporting:metricsReporting_.GetValue() ? YES : NO]; + [self setMetricsReportingEnabled:!metricsReporting_.IsManaged()]; + } + else if (*prefName == prefs::kDownloadDefaultDirectory) { + // Poke KVO. + [self willChangeValueForKey:@"defaultDownloadLocation"]; + [self didChangeValueForKey:@"defaultDownloadLocation"]; + } + else if (*prefName == prefs::kPromptForDownload) { + [self setAskForSaveLocation:askForSaveLocation_.GetValue() ? YES : NO]; + } + else if (*prefName == prefs::kEnableTranslate) { + [self setTranslateEnabled:translateEnabled_.GetValue() ? YES : NO]; + } + else if (*prefName == prefs::kWebkitTabsToLinks) { + [self setTabsToLinks:tabsToLinks_.GetValue() ? YES : NO]; + } + else if (*prefName == prefs::kDownloadExtensionsToOpen) { + // Poke KVC. + [self setFileHandlerUIEnabled:[self fileHandlerUIEnabled]]; + } + else if (proxyPrefs_->IsObserved(*prefName)) { + [self setProxiesConfigureButtonEnabled:!proxyPrefs_->IsManaged()]; + } +} + +// Set the new download path and notify the UI via KVO. +- (void)downloadPathPanelDidEnd:(NSOpenPanel*)panel + code:(NSInteger)returnCode + context:(void*)context { + if (returnCode == NSOKButton) { + [self recordUserAction:UserMetricsAction("Options_SetDownloadDirectory")]; + NSURL* path = [[panel URLs] lastObject]; // We only allow 1 item. + [self willChangeValueForKey:@"defaultDownloadLocation"]; + defaultDownloadLocation_.SetValue(base::SysNSStringToUTF8([path path])); + [self didChangeValueForKey:@"defaultDownloadLocation"]; + } +} + +// Bring up an open panel to allow the user to set a new downloads location. +- (void)browseDownloadLocation:(id)sender { + NSOpenPanel* panel = [NSOpenPanel openPanel]; + [panel setAllowsMultipleSelection:NO]; + [panel setCanChooseFiles:NO]; + [panel setCanChooseDirectories:YES]; + NSString* path = base::SysUTF8ToNSString(defaultDownloadLocation_.GetValue()); + [panel beginSheetForDirectory:path + file:nil + types:nil + modalForWindow:[self window] + modalDelegate:self + didEndSelector:@selector(downloadPathPanelDidEnd:code:context:) + contextInfo:NULL]; +} + +// Called to clear user's browsing data. This puts up an application-modal +// dialog to guide the user through clearing the data. +- (IBAction)clearData:(id)sender { + [ClearBrowsingDataController + showClearBrowsingDialogForProfile:profile_]; +} + +// Opens the "Content Settings" dialog. +- (IBAction)showContentSettings:(id)sender { + [ContentSettingsDialogController + showContentSettingsForType:CONTENT_SETTINGS_TYPE_DEFAULT + profile:profile_]; +} + +- (IBAction)privacyLearnMore:(id)sender { + GURL url = google_util::AppendGoogleLocaleParam( + GURL(chrome::kPrivacyLearnMoreURL)); + // We open a new browser window so the Options dialog doesn't get lost + // behind other windows. + browser::ShowOptionsURL(profile_, url); +} + +- (IBAction)resetAutoOpenFiles:(id)sender { + profile_->GetDownloadManager()->download_prefs()->ResetAutoOpen(); + [self recordUserAction:UserMetricsAction("Options_ResetAutoOpenFiles")]; +} + +- (IBAction)openProxyPreferences:(id)sender { + NSArray* itemsToOpen = [NSArray arrayWithObject:[NSURL fileURLWithPath: + @"/System/Library/PreferencePanes/Network.prefPane"]]; + + const char* proxyPrefCommand = "Proxies"; + base::mac::ScopedAEDesc<> openParams; + OSStatus status = AECreateDesc('ptru', + proxyPrefCommand, + strlen(proxyPrefCommand), + openParams.OutPointer()); + LOG_IF(ERROR, status != noErr) << "Failed to create open params: " << status; + + LSLaunchURLSpec launchSpec = { 0 }; + launchSpec.itemURLs = (CFArrayRef)itemsToOpen; + launchSpec.passThruParams = openParams; + launchSpec.launchFlags = kLSLaunchAsync | kLSLaunchDontAddToRecents; + LSOpenFromURLSpec(&launchSpec, NULL); +} + +// Returns whether the alternate error page checkbox should be checked based +// on the preference. +- (BOOL)showAlternateErrorPages { + return alternateErrorPages_.GetValue() ? YES : NO; +} + +// Sets the backend pref for whether or not the alternate error page checkbox +// should be displayed based on |value|. +- (void)setShowAlternateErrorPages:(BOOL)value { + if (value) + [self recordUserAction:UserMetricsAction( + "Options_LinkDoctorCheckbox_Enable")]; + else + [self recordUserAction:UserMetricsAction( + "Options_LinkDoctorCheckbox_Disable")]; + alternateErrorPages_.SetValueIfNotManaged(value ? true : false); +} + +// Returns whether the suggest checkbox should be checked based on the +// preference. +- (BOOL)useSuggest { + return useSuggest_.GetValue() ? YES : NO; +} + +// Sets the backend pref for whether or not the suggest checkbox should be +// displayed based on |value|. +- (void)setUseSuggest:(BOOL)value { + if (value) + [self recordUserAction:UserMetricsAction( + "Options_UseSuggestCheckbox_Enable")]; + else + [self recordUserAction:UserMetricsAction( + "Options_UseSuggestCheckbox_Disable")]; + useSuggest_.SetValueIfNotManaged(value ? true : false); +} + +// Returns whether the DNS prefetch checkbox should be checked based on the +// preference. +- (BOOL)dnsPrefetch { + return dnsPrefetch_.GetValue() ? YES : NO; +} + +// Sets the backend pref for whether or not the DNS prefetch checkbox should be +// displayed based on |value|. +- (void)setDnsPrefetch:(BOOL)value { + if (value) + [self recordUserAction:UserMetricsAction( + "Options_DnsPrefetchCheckbox_Enable")]; + else + [self recordUserAction:UserMetricsAction( + "Options_DnsPrefetchCheckbox_Disable")]; + dnsPrefetch_.SetValueIfNotManaged(value ? true : false); +} + +// Returns whether the safe browsing checkbox should be checked based on the +// preference. +- (BOOL)safeBrowsing { + return safeBrowsing_.GetValue() ? YES : NO; +} + +// Sets the backend pref for whether or not the safe browsing checkbox should be +// displayed based on |value|. +- (void)setSafeBrowsing:(BOOL)value { + if (value) + [self recordUserAction:UserMetricsAction( + "Options_SafeBrowsingCheckbox_Enable")]; + else + [self recordUserAction:UserMetricsAction( + "Options_SafeBrowsingCheckbox_Disable")]; + safeBrowsing_.SetValueIfNotManaged(value ? true : false); + SafeBrowsingService* safeBrowsingService = + g_browser_process->resource_dispatcher_host()->safe_browsing_service(); + MessageLoop::current()->PostTask( + FROM_HERE, + NewRunnableMethod(safeBrowsingService, + &SafeBrowsingService::OnEnable, + safeBrowsing_.GetValue())); +} + +// Returns whether the metrics reporting checkbox should be checked based on the +// preference. +- (BOOL)metricsReporting { + return metricsReporting_.GetValue() ? YES : NO; +} + +// Sets the backend pref for whether or not the metrics reporting checkbox +// should be displayed based on |value|. +- (void)setMetricsReporting:(BOOL)value { + if (value) + [self recordUserAction:UserMetricsAction( + "Options_MetricsReportingCheckbox_Enable")]; + else + [self recordUserAction:UserMetricsAction( + "Options_MetricsReportingCheckbox_Disable")]; + + // TODO(pinkerton): windows shows a dialog here telling the user they need to + // restart for this to take effect. http://crbug.com/34653 + metricsReporting_.SetValueIfNotManaged(value ? true : false); + + bool enabled = metricsReporting_.GetValue(); + GoogleUpdateSettings::SetCollectStatsConsent(enabled); + bool update_pref = GoogleUpdateSettings::GetCollectStatsConsent(); + if (enabled != update_pref) { + DVLOG(1) << "GENERAL SECTION: Unable to set crash report status to " + << enabled; + } + // Only change the pref if GoogleUpdateSettings::GetCollectStatsConsent + // succeeds. + enabled = update_pref; + + MetricsService* metrics = g_browser_process->metrics_service(); + DCHECK(metrics); + if (metrics) { + metrics->SetUserPermitsUpload(enabled); + if (enabled) + metrics->Start(); + else + metrics->Stop(); + } +} + +- (NSURL*)defaultDownloadLocation { + NSString* pathString = + base::SysUTF8ToNSString(defaultDownloadLocation_.GetValue()); + return [NSURL fileURLWithPath:pathString]; +} + +- (BOOL)askForSaveLocation { + return askForSaveLocation_.GetValue(); +} + +- (void)setAskForSaveLocation:(BOOL)value { + if (value) { + [self recordUserAction:UserMetricsAction( + "Options_AskForSaveLocation_Enable")]; + } else { + [self recordUserAction:UserMetricsAction( + "Options_AskForSaveLocation_Disable")]; + } + askForSaveLocation_.SetValue(value); +} + +- (BOOL)fileHandlerUIEnabled { + if (!profile_->GetDownloadManager()) // Not set in unit tests. + return NO; + return profile_->GetDownloadManager()->download_prefs()->IsAutoOpenUsed(); +} + +- (void)setFileHandlerUIEnabled:(BOOL)value { + [resetFileHandlersButton_ setEnabled:value]; +} + +- (BOOL)translateEnabled { + return translateEnabled_.GetValue(); +} + +- (void)setTranslateEnabled:(BOOL)value { + if (value) { + [self recordUserAction:UserMetricsAction("Options_Translate_Enable")]; + } else { + [self recordUserAction:UserMetricsAction("Options_Translate_Disable")]; + } + translateEnabled_.SetValue(value); +} + +- (BOOL)tabsToLinks { + return tabsToLinks_.GetValue(); +} + +- (void)setTabsToLinks:(BOOL)value { + if (value) { + [self recordUserAction:UserMetricsAction("Options_TabsToLinks_Enable")]; + } else { + [self recordUserAction:UserMetricsAction("Options_TabsToLinks_Disable")]; + } + tabsToLinks_.SetValue(value); +} + +- (void)fontAndLanguageEndSheet:(NSWindow*)sheet + returnCode:(NSInteger)returnCode + contextInfo:(void*)context { + [sheet close]; + [sheet orderOut:self]; + fontLanguageSettings_ = nil; +} + +- (IBAction)changeFontAndLanguageSettings:(id)sender { + // Intentionally leak the controller as it will clean itself up when the + // sheet closes. + fontLanguageSettings_ = + [[FontLanguageSettingsController alloc] initWithProfile:profile_]; + [NSApp beginSheet:[fontLanguageSettings_ window] + modalForWindow:[self window] + modalDelegate:self + didEndSelector:@selector(fontAndLanguageEndSheet:returnCode:contextInfo:) + contextInfo:nil]; +} + +// Called to launch the Keychain Access app to show the user's stored +// certificates. Note there's no way to script the app to auto-select the +// certificates. +- (IBAction)showCertificates:(id)sender { + [self recordUserAction:UserMetricsAction("Options_ManagerCerts")]; + [self launchKeychainAccess]; +} + +- (IBAction)resetToDefaults:(id)sender { + // The alert will clean itself up in the did-end selector. + NSAlert* alert = [[NSAlert alloc] init]; + [alert setMessageText:l10n_util::GetNSString(IDS_OPTIONS_RESET_MESSAGE)]; + NSButton* resetButton = [alert addButtonWithTitle: + l10n_util::GetNSString(IDS_OPTIONS_RESET_OKLABEL)]; + [resetButton setKeyEquivalent:@""]; + NSButton* cancelButton = [alert addButtonWithTitle: + l10n_util::GetNSString(IDS_OPTIONS_RESET_CANCELLABEL)]; + [cancelButton setKeyEquivalent:@"\r"]; + + [alert beginSheetModalForWindow:[self window] + modalDelegate:self + didEndSelector:@selector(resetToDefaults:returned:context:) + contextInfo:nil]; +} + +- (void)resetToDefaults:(NSAlert*)alert + returned:(NSInteger)code + context:(void*)context { + if (code == NSAlertFirstButtonReturn) { + OptionsUtil::ResetToDefaults(profile_); + } + [alert autorelease]; +} + +//------------------------------------------------------------------------- + +// Callback when preferences are changed. |prefName| is the name of the +// pref that has changed and should not be NULL. +- (void)prefChanged:(std::string*)prefName { + DCHECK(prefName); + if (!prefName) return; + [self basicsPrefChanged:prefName]; + [self userDataPrefChanged:prefName]; + [self underHoodPrefChanged:prefName]; +} + +// Callback when sync service state has changed. +// +// TODO(akalin): Decomp this out since a lot of it is copied from the +// Windows version. +// TODO(akalin): Change the background of the status label/link on error. +- (void)syncStateChanged { + DCHECK(syncService_); + + string16 statusLabel, linkLabel; + sync_ui_util::MessageType status = + sync_ui_util::GetStatusLabels(syncService_, &statusLabel, &linkLabel); + bool managed = syncService_->IsManaged(); + + [syncButton_ setEnabled:!syncService_->WizardIsVisible()]; + NSString* buttonLabel; + if (syncService_->HasSyncSetupCompleted()) { + buttonLabel = l10n_util::GetNSStringWithFixup( + IDS_SYNC_STOP_SYNCING_BUTTON_LABEL); + [syncCustomizeButton_ setHidden:false]; + } else if (syncService_->SetupInProgress()) { + buttonLabel = l10n_util::GetNSStringWithFixup( + IDS_SYNC_NTP_SETUP_IN_PROGRESS); + [syncCustomizeButton_ setHidden:true]; + } else { + buttonLabel = l10n_util::GetNSStringWithFixup( + IDS_SYNC_START_SYNC_BUTTON_LABEL); + [syncCustomizeButton_ setHidden:true]; + } + [syncCustomizeButton_ setEnabled:!managed]; + [syncButton_ setTitle:buttonLabel]; + [syncButton_ setEnabled:!managed]; + + [syncStatus_ setStringValue:base::SysUTF16ToNSString(statusLabel)]; + [syncLink_ setHidden:linkLabel.empty()]; + [syncLink_ setTitle:base::SysUTF16ToNSString(linkLabel)]; + [syncLink_ setEnabled:!managed]; + + NSButtonCell* syncLinkCell = static_cast<NSButtonCell*>([syncLink_ cell]); + if (!syncStatusNoErrorBackgroundColor_) { + DCHECK(!syncLinkNoErrorBackgroundColor_); + // We assume that the sync controls start off in a non-error + // state. + syncStatusNoErrorBackgroundColor_.reset( + [[syncStatus_ backgroundColor] retain]); + syncLinkNoErrorBackgroundColor_.reset( + [[syncLinkCell backgroundColor] retain]); + } + if (status == sync_ui_util::SYNC_ERROR) { + [syncStatus_ setBackgroundColor:syncErrorBackgroundColor_]; + [syncLinkCell setBackgroundColor:syncErrorBackgroundColor_]; + } else { + [syncStatus_ setBackgroundColor:syncStatusNoErrorBackgroundColor_]; + [syncLinkCell setBackgroundColor:syncLinkNoErrorBackgroundColor_]; + } +} + +// Show the preferences window. +- (IBAction)showPreferences:(id)sender { + [self showWindow:sender]; +} + +- (IBAction)toolbarButtonSelected:(id)sender { + DCHECK([sender isKindOfClass:[NSToolbarItem class]]); + OptionsPage page = [self getPageForToolbarItem:sender]; + [self displayPreferenceViewForPage:page animate:YES]; +} + +// Helper to update the window to display a preferences view for a page. +- (void)displayPreferenceViewForPage:(OptionsPage)page + animate:(BOOL)animate { + NSWindow* prefsWindow = [self window]; + + // Needs to go *after* the call to [self window], which triggers + // awakeFromNib if necessary. + NSView* prefsView = [self getPrefsViewForPage:page]; + NSView* contentView = [prefsWindow contentView]; + + // Make sure we aren't being told to display the same thing again. + if (currentPrefsView_ == prefsView && + managedPrefsBannerVisible_ == bannerState_->IsVisible()) { + return; + } + + // Remember new options page as current page. + if (page != OPTIONS_PAGE_DEFAULT) + lastSelectedPage_.SetValue(page); + + // Stop any running animation, and reset the subviews to the new state. We + // re-add any views we need for animation later. + [animation_ stopAnimation]; + NSView* oldPrefsView = currentPrefsView_; + currentPrefsView_ = prefsView; + [self resetSubViews]; + + // Update the banner state. + [self initBannerStateForPage:page]; + BOOL showBanner = bannerState_->IsVisible(); + + // Update the window title. + NSToolbarItem* toolbarItem = [self getToolbarItemForPage:page]; + [prefsWindow setTitle:[toolbarItem label]]; + + // Calculate new frames for the subviews. + NSRect prefsViewFrame = [prefsView frame]; + NSRect contentViewFrame = [contentView frame]; + NSRect bannerViewFrame = [managedPrefsBannerView_ frame]; + + // Determine what height the managed prefs banner will use. + CGFloat bannerViewHeight = showBanner ? NSHeight(bannerViewFrame) : 0.0; + + if (animate) { + // NSViewAnimation doesn't seem to honor subview resizing as it animates the + // Window's frame. So instead of trying to get the top in the right place, + // just set the origin where it should be at the end, and let the fade/size + // slide things into the right spot. + prefsViewFrame.origin.y = 0.0; + } else { + // The prefView is anchored to the top of its parent, so set its origin so + // that the top is where it should be. When the window's frame is set, the + // origin will be adjusted to keep it in the right spot. + prefsViewFrame.origin.y = NSHeight(contentViewFrame) - + NSHeight(prefsViewFrame) - bannerViewHeight; + } + bannerViewFrame.origin.y = NSHeight(prefsViewFrame); + bannerViewFrame.size.width = NSWidth(contentViewFrame); + [prefsView setFrame:prefsViewFrame]; + + // Figure out the size of the window. + NSRect windowFrame = [contentView convertRect:[prefsWindow frame] + fromView:nil]; + CGFloat titleToolbarHeight = + NSHeight(windowFrame) - NSHeight(contentViewFrame); + windowFrame.size.height = + NSHeight(prefsViewFrame) + titleToolbarHeight + bannerViewHeight; + DCHECK_GE(NSWidth(windowFrame), NSWidth(prefsViewFrame)) + << "Initial width set wasn't wide enough."; + windowFrame = [contentView convertRect:windowFrame toView:nil]; + windowFrame.origin.y = NSMaxY([prefsWindow frame]) - NSHeight(windowFrame); + + // Now change the size. + if (animate) { + NSMutableArray* animations = [NSMutableArray arrayWithCapacity:4]; + if (oldPrefsView != prefsView) { + // Fade between prefs views if they change. + [contentView addSubview:oldPrefsView + positioned:NSWindowBelow + relativeTo:nil]; + [animations addObject: + [NSDictionary dictionaryWithObjectsAndKeys: + oldPrefsView, NSViewAnimationTargetKey, + NSViewAnimationFadeOutEffect, NSViewAnimationEffectKey, + nil]]; + [animations addObject: + [NSDictionary dictionaryWithObjectsAndKeys: + prefsView, NSViewAnimationTargetKey, + NSViewAnimationFadeInEffect, NSViewAnimationEffectKey, + nil]]; + } else { + // Make sure the prefs pane ends up in the right position in case we + // manipulate the banner. + [animations addObject: + [NSDictionary dictionaryWithObjectsAndKeys: + prefsView, NSViewAnimationTargetKey, + [NSValue valueWithRect:prefsViewFrame], + NSViewAnimationEndFrameKey, + nil]]; + } + if (showBanner != managedPrefsBannerVisible_) { + // Slide the warning banner in or out of view. + [animations addObject: + [NSDictionary dictionaryWithObjectsAndKeys: + managedPrefsBannerView_, NSViewAnimationTargetKey, + [NSValue valueWithRect:bannerViewFrame], + NSViewAnimationEndFrameKey, + nil]]; + } + // Window resize animation. + [animations addObject: + [NSDictionary dictionaryWithObjectsAndKeys: + prefsWindow, NSViewAnimationTargetKey, + [NSValue valueWithRect:windowFrame], NSViewAnimationEndFrameKey, + nil]]; + [animation_ setViewAnimations:animations]; + // The default duration is 0.5s, which actually feels slow in here, so speed + // it up a bit. + [animation_ gtm_setDuration:0.2 + eventMask:NSLeftMouseUpMask]; + [animation_ startAnimation]; + } else { + // If not animating, odds are we don't want to display either (because it + // is initial window setup). + [prefsWindow setFrame:windowFrame display:NO]; + [managedPrefsBannerView_ setFrame:bannerViewFrame]; + } + + managedPrefsBannerVisible_ = showBanner; +} + +- (void)resetSubViews { + // Reset subviews to current prefs view and banner, remove any views that + // might have been left over from previous state or animation. + NSArray* subviews = [NSArray arrayWithObjects: + currentPrefsView_, managedPrefsBannerView_, nil]; + [[[self window] contentView] setSubviews:subviews]; + [[self window] setInitialFirstResponder:currentPrefsView_]; +} + +- (void)animationDidEnd:(NSAnimation*)animation { + DCHECK_EQ(animation_.get(), animation); + // Animation finished, reset subviews to current prefs view and the banner. + [self resetSubViews]; +} + +// Reinitializes the banner state tracker object to watch for managed bits of +// preferences relevant to the given options |page|. +- (void)initBannerStateForPage:(OptionsPage)page { + page = [self normalizePage:page]; + + // During unit tests, there is no local state object, so we fall back to + // the prefs object (where we've explicitly registered this pref so we + // know it's there). + PrefService* local = g_browser_process->local_state(); + if (!local) + local = prefs_; + bannerState_.reset( + new PreferencesWindowControllerInternal::ManagedPrefsBannerState( + self, page, local, prefs_)); +} + +- (void)switchToPage:(OptionsPage)page animate:(BOOL)animate { + [self displayPreferenceViewForPage:page animate:animate]; + NSToolbarItem* toolbarItem = [self getToolbarItemForPage:page]; + [toolbar_ setSelectedItemIdentifier:[toolbarItem itemIdentifier]]; +} + +// Called when the window is being closed. Send out a notification that the user +// is done editing preferences. Make sure there are no pending field editors +// by clearing the first responder. +- (void)windowWillClose:(NSNotification*)notification { + // Setting the first responder to the window ends any in-progress field + // editor. This will update the model appropriately so there's nothing left + // to do. + if (![[self window] makeFirstResponder:[self window]]) { + // We've hit a recalcitrant field editor, force it to go away. + [[self window] endEditingFor:nil]; + } + [self autorelease]; +} + +- (void)controlTextDidEndEditing:(NSNotification*)notification { + [customPagesSource_ validateURLs]; +} + +@end + +@implementation PreferencesWindowController(Testing) + +- (IntegerPrefMember*)lastSelectedPage { + return &lastSelectedPage_; +} + +- (NSToolbar*)toolbar { + return toolbar_; +} + +- (NSView*)basicsView { + return basicsView_; +} + +- (NSView*)personalStuffView { + return personalStuffView_; +} + +- (NSView*)underTheHoodView { + return underTheHoodView_; +} + +- (OptionsPage)normalizePage:(OptionsPage)page { + if (page == OPTIONS_PAGE_DEFAULT) { + // Get the last visited page from local state. + page = static_cast<OptionsPage>(lastSelectedPage_.GetValue()); + if (page == OPTIONS_PAGE_DEFAULT) { + page = OPTIONS_PAGE_GENERAL; + } + } + return page; +} + +- (NSToolbarItem*)getToolbarItemForPage:(OptionsPage)page { + NSUInteger pageIndex = (NSUInteger)[self normalizePage:page]; + NSArray* items = [toolbar_ items]; + NSUInteger itemCount = [items count]; + DCHECK_GE(pageIndex, 0U); + if (pageIndex >= itemCount) { + NOTIMPLEMENTED(); + pageIndex = 0; + } + DCHECK_GT(itemCount, 0U); + return [items objectAtIndex:pageIndex]; +} + +- (OptionsPage)getPageForToolbarItem:(NSToolbarItem*)toolbarItem { + // Tags are set in the nib file. + switch ([toolbarItem tag]) { + case 0: // Basics + return OPTIONS_PAGE_GENERAL; + case 1: // Personal Stuff + return OPTIONS_PAGE_CONTENT; + case 2: // Under the Hood + return OPTIONS_PAGE_ADVANCED; + default: + NOTIMPLEMENTED(); + return OPTIONS_PAGE_GENERAL; + } +} + +- (NSView*)getPrefsViewForPage:(OptionsPage)page { + // The views will be NULL if this is mistakenly called before awakeFromNib. + DCHECK(basicsView_); + DCHECK(personalStuffView_); + DCHECK(underTheHoodView_); + page = [self normalizePage:page]; + switch (page) { + case OPTIONS_PAGE_GENERAL: + return basicsView_; + case OPTIONS_PAGE_CONTENT: + return personalStuffView_; + case OPTIONS_PAGE_ADVANCED: + return underTheHoodView_; + case OPTIONS_PAGE_DEFAULT: + case OPTIONS_PAGE_COUNT: + LOG(DFATAL) << "Invalid page value " << page; + } + return basicsView_; +} + +@end diff --git a/chrome/browser/ui/cocoa/preferences_window_controller_unittest.mm b/chrome/browser/ui/cocoa/preferences_window_controller_unittest.mm new file mode 100644 index 0000000..91993c2 --- /dev/null +++ b/chrome/browser/ui/cocoa/preferences_window_controller_unittest.mm @@ -0,0 +1,240 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#import "base/scoped_nsobject.h" +#include "chrome/browser/options_window.h" +#import "chrome/browser/ui/cocoa/preferences_window_controller.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#include "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/custom_home_pages_model.h" +#include "chrome/common/pref_names.h" +#include "testing/gtest/include/gtest/gtest.h" +#import "testing/gtest_mac.h" +#include "testing/platform_test.h" + +// Helper Objective-C object that sets a BOOL when we get a particular +// callback from the prefs window. +@interface PrefsClosedObserver : NSObject { + @public + BOOL gotNotification_; +} +- (void)prefsWindowClosed:(NSNotification*)notify; +@end + +@implementation PrefsClosedObserver +- (void)prefsWindowClosed:(NSNotification*)notify { + gotNotification_ = YES; +} +@end + +namespace { + +class PrefsControllerTest : public CocoaTest { + public: + virtual void SetUp() { + CocoaTest::SetUp(); + // The metrics reporting pref is registerd on the local state object in + // real builds, but we don't have one of those for unit tests. Register + // it on prefs so we'll find it when we go looking. + PrefService* prefs = browser_helper_.profile()->GetPrefs(); + prefs->RegisterBooleanPref(prefs::kMetricsReportingEnabled, false); + + pref_controller_ = [[PreferencesWindowController alloc] + initWithProfile:browser_helper_.profile() + initialPage:OPTIONS_PAGE_DEFAULT]; + EXPECT_TRUE(pref_controller_); + } + + virtual void TearDown() { + [pref_controller_ close]; + CocoaTest::TearDown(); + } + + BrowserTestHelper browser_helper_; + PreferencesWindowController* pref_controller_; +}; + +// Test showing the preferences window and making sure it's visible, then +// making sure we get the notification when it's closed. +TEST_F(PrefsControllerTest, ShowAndClose) { + [pref_controller_ showPreferences:nil]; + EXPECT_TRUE([[pref_controller_ window] isVisible]); + + scoped_nsobject<PrefsClosedObserver> observer( + [[PrefsClosedObserver alloc] init]); + NSNotificationCenter* defaultCenter = [NSNotificationCenter defaultCenter]; + [defaultCenter addObserver:observer.get() + selector:@selector(prefsWindowClosed:) + name:NSWindowWillCloseNotification + object:[pref_controller_ window]]; + [[pref_controller_ window] performClose:observer]; + EXPECT_TRUE(observer.get()->gotNotification_); + [defaultCenter removeObserver:observer.get()]; + + // Prevent pref_controller_ from being closed again in TearDown() + pref_controller_ = nil; +} + +TEST_F(PrefsControllerTest, ValidateCustomHomePagesTable) { + // First, insert two valid URLs into the CustomHomePagesModel. + GURL url1("http://www.google.com/"); + GURL url2("http://maps.google.com/"); + std::vector<GURL> urls; + urls.push_back(url1); + urls.push_back(url2); + [[pref_controller_ customPagesSource] setURLs:urls]; + EXPECT_EQ(2U, [[pref_controller_ customPagesSource] countOfCustomHomePages]); + + // Now insert a bad (empty) URL into the model. + [[pref_controller_ customPagesSource] setURLStringEmptyAt:1]; + + // Send a notification to simulate the end of editing on a cell in the table + // which should trigger validation. + [pref_controller_ controlTextDidEndEditing:[NSNotification + notificationWithName:NSControlTextDidEndEditingNotification + object:nil]]; + EXPECT_EQ(1U, [[pref_controller_ customPagesSource] countOfCustomHomePages]); +} + +TEST_F(PrefsControllerTest, NormalizePage) { + EXPECT_EQ(OPTIONS_PAGE_GENERAL, + [pref_controller_ normalizePage:OPTIONS_PAGE_GENERAL]); + EXPECT_EQ(OPTIONS_PAGE_CONTENT, + [pref_controller_ normalizePage:OPTIONS_PAGE_CONTENT]); + EXPECT_EQ(OPTIONS_PAGE_ADVANCED, + [pref_controller_ normalizePage:OPTIONS_PAGE_ADVANCED]); + + [pref_controller_ lastSelectedPage]->SetValue(OPTIONS_PAGE_ADVANCED); + EXPECT_EQ(OPTIONS_PAGE_ADVANCED, + [pref_controller_ normalizePage:OPTIONS_PAGE_DEFAULT]); + + [pref_controller_ lastSelectedPage]->SetValue(OPTIONS_PAGE_DEFAULT); + EXPECT_EQ(OPTIONS_PAGE_GENERAL, + [pref_controller_ normalizePage:OPTIONS_PAGE_DEFAULT]); +} + +TEST_F(PrefsControllerTest, GetToolbarItemForPage) { + // Trigger awakeFromNib. + [pref_controller_ window]; + + NSArray* toolbarItems = [[pref_controller_ toolbar] items]; + EXPECT_EQ([toolbarItems objectAtIndex:0], + [pref_controller_ getToolbarItemForPage:OPTIONS_PAGE_GENERAL]); + EXPECT_EQ([toolbarItems objectAtIndex:1], + [pref_controller_ getToolbarItemForPage:OPTIONS_PAGE_CONTENT]); + EXPECT_EQ([toolbarItems objectAtIndex:2], + [pref_controller_ getToolbarItemForPage:OPTIONS_PAGE_ADVANCED]); + + [pref_controller_ lastSelectedPage]->SetValue(OPTIONS_PAGE_ADVANCED); + EXPECT_EQ([toolbarItems objectAtIndex:2], + [pref_controller_ getToolbarItemForPage:OPTIONS_PAGE_DEFAULT]); + + // Out-of-range argument. + EXPECT_EQ([toolbarItems objectAtIndex:0], + [pref_controller_ getToolbarItemForPage:OPTIONS_PAGE_COUNT]); +} + +TEST_F(PrefsControllerTest, GetPageForToolbarItem) { + scoped_nsobject<NSToolbarItem> toolbarItem( + [[NSToolbarItem alloc] initWithItemIdentifier:@""]); + [toolbarItem setTag:0]; + EXPECT_EQ(OPTIONS_PAGE_GENERAL, + [pref_controller_ getPageForToolbarItem:toolbarItem]); + [toolbarItem setTag:1]; + EXPECT_EQ(OPTIONS_PAGE_CONTENT, + [pref_controller_ getPageForToolbarItem:toolbarItem]); + [toolbarItem setTag:2]; + EXPECT_EQ(OPTIONS_PAGE_ADVANCED, + [pref_controller_ getPageForToolbarItem:toolbarItem]); + + // Out-of-range argument. + [toolbarItem setTag:3]; + EXPECT_EQ(OPTIONS_PAGE_GENERAL, + [pref_controller_ getPageForToolbarItem:toolbarItem]); +} + +TEST_F(PrefsControllerTest, GetPrefsViewForPage) { + // Trigger awakeFromNib. + [pref_controller_ window]; + + EXPECT_EQ([pref_controller_ basicsView], + [pref_controller_ getPrefsViewForPage:OPTIONS_PAGE_GENERAL]); + EXPECT_EQ([pref_controller_ personalStuffView], + [pref_controller_ getPrefsViewForPage:OPTIONS_PAGE_CONTENT]); + EXPECT_EQ([pref_controller_ underTheHoodView], + [pref_controller_ getPrefsViewForPage:OPTIONS_PAGE_ADVANCED]); + + [pref_controller_ lastSelectedPage]->SetValue(OPTIONS_PAGE_ADVANCED); + EXPECT_EQ([pref_controller_ underTheHoodView], + [pref_controller_ getPrefsViewForPage:OPTIONS_PAGE_DEFAULT]); +} + +TEST_F(PrefsControllerTest, SwitchToPage) { + // Trigger awakeFromNib. + NSWindow* window = [pref_controller_ window]; + + NSView* contentView = [window contentView]; + NSView* basicsView = [pref_controller_ basicsView]; + NSView* personalStuffView = [pref_controller_ personalStuffView]; + NSView* underTheHoodView = [pref_controller_ underTheHoodView]; + NSToolbar* toolbar = [pref_controller_ toolbar]; + NSToolbarItem* basicsToolbarItem = + [pref_controller_ getToolbarItemForPage:OPTIONS_PAGE_GENERAL]; + NSToolbarItem* personalStuffToolbarItem = + [pref_controller_ getToolbarItemForPage:OPTIONS_PAGE_CONTENT]; + NSToolbarItem* underTheHoodToolbarItem = + [pref_controller_ getToolbarItemForPage:OPTIONS_PAGE_ADVANCED]; + NSString* basicsIdentifier = [basicsToolbarItem itemIdentifier]; + NSString* personalStuffIdentifier = [personalStuffToolbarItem itemIdentifier]; + NSString* underTheHoodIdentifier = [underTheHoodToolbarItem itemIdentifier]; + IntegerPrefMember* lastSelectedPage = [pref_controller_ lastSelectedPage]; + + // Test without animation. + + [pref_controller_ switchToPage:OPTIONS_PAGE_GENERAL animate:NO]; + EXPECT_TRUE([basicsView isDescendantOf:contentView]); + EXPECT_FALSE([personalStuffView isDescendantOf:contentView]); + EXPECT_FALSE([underTheHoodView isDescendantOf:contentView]); + EXPECT_NSEQ(basicsIdentifier, [toolbar selectedItemIdentifier]); + EXPECT_EQ(OPTIONS_PAGE_GENERAL, lastSelectedPage->GetValue()); + EXPECT_NSEQ([basicsToolbarItem label], [window title]); + + [pref_controller_ switchToPage:OPTIONS_PAGE_CONTENT animate:NO]; + EXPECT_FALSE([basicsView isDescendantOf:contentView]); + EXPECT_TRUE([personalStuffView isDescendantOf:contentView]); + EXPECT_FALSE([underTheHoodView isDescendantOf:contentView]); + EXPECT_NSEQ([toolbar selectedItemIdentifier], personalStuffIdentifier); + EXPECT_EQ(OPTIONS_PAGE_CONTENT, lastSelectedPage->GetValue()); + EXPECT_NSEQ([personalStuffToolbarItem label], [window title]); + + [pref_controller_ switchToPage:OPTIONS_PAGE_ADVANCED animate:NO]; + EXPECT_FALSE([basicsView isDescendantOf:contentView]); + EXPECT_FALSE([personalStuffView isDescendantOf:contentView]); + EXPECT_TRUE([underTheHoodView isDescendantOf:contentView]); + EXPECT_NSEQ([toolbar selectedItemIdentifier], underTheHoodIdentifier); + EXPECT_EQ(OPTIONS_PAGE_ADVANCED, lastSelectedPage->GetValue()); + EXPECT_NSEQ([underTheHoodToolbarItem label], [window title]); + + // Test OPTIONS_PAGE_DEFAULT. + + lastSelectedPage->SetValue(OPTIONS_PAGE_CONTENT); + [pref_controller_ switchToPage:OPTIONS_PAGE_DEFAULT animate:NO]; + EXPECT_FALSE([basicsView isDescendantOf:contentView]); + EXPECT_TRUE([personalStuffView isDescendantOf:contentView]); + EXPECT_FALSE([underTheHoodView isDescendantOf:contentView]); + EXPECT_NSEQ(personalStuffIdentifier, [toolbar selectedItemIdentifier]); + EXPECT_EQ(OPTIONS_PAGE_CONTENT, lastSelectedPage->GetValue()); + EXPECT_NSEQ([personalStuffToolbarItem label], [window title]); + + // TODO(akalin): Figure out how to test animation; we'll need everything + // to stick around until the animation finishes. +} + +// TODO(akalin): Figure out how to test sync controls. +// TODO(akalin): Figure out how to test that sync controls are not shown +// when there isn't a sync service. + +} // namespace diff --git a/chrome/browser/ui/cocoa/previewable_contents_controller.h b/chrome/browser/ui/cocoa/previewable_contents_controller.h new file mode 100644 index 0000000..01643f0 --- /dev/null +++ b/chrome/browser/ui/cocoa/previewable_contents_controller.h @@ -0,0 +1,47 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_PREVIEWABLE_CONTENTS_CONTROLLER_H_ +#define CHROME_BROWSER_UI_COCOA_PREVIEWABLE_CONTENTS_CONTROLLER_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +class TabContents; + +// PreviewableContentsController manages the display of up to two tab contents +// views. It is primarily for use with Instant results. This class supports +// the notion of an "active" view vs. a "preview" tab contents view. +// +// The "active" view is a container view that can be retrieved using +// |-activeContainer|. Its contents are meant to be managed by an external +// class. +// +// The "preview" can be set using |-showPreview:| and |-hidePreview|. When a +// preview is set, the active view is hidden (but stays in the view hierarchy). +// When the preview is removed, the active view is reshown. +@interface PreviewableContentsController : NSViewController { + @private + // Container view for the "active" contents. + IBOutlet NSView* activeContainer_; + + // The preview TabContents. Will be NULL if no preview is currently showing. + TabContents* previewContents_; // weak +} + +@property(readonly, nonatomic) NSView* activeContainer; + +// Sets the current preview and installs its TabContentsView into the view +// hierarchy. Hides the active view. |preview| must not be NULL. +- (void)showPreview:(TabContents*)preview; + +// Closes the current preview and shows the active view. +- (void)hidePreview; + +// Returns YES if the preview contents is currently showing. +- (BOOL)isShowingPreview; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_PREVIEWABLE_CONTENTS_CONTROLLER_H_ diff --git a/chrome/browser/ui/cocoa/previewable_contents_controller.mm b/chrome/browser/ui/cocoa/previewable_contents_controller.mm new file mode 100644 index 0000000..2dfa146 --- /dev/null +++ b/chrome/browser/ui/cocoa/previewable_contents_controller.mm @@ -0,0 +1,52 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/previewable_contents_controller.h" + +#include "base/logging.h" +#include "base/mac_util.h" +#include "chrome/browser/tab_contents/tab_contents.h" + +@implementation PreviewableContentsController + +@synthesize activeContainer = activeContainer_; + +- (id)init { + if ((self = [super initWithNibName:@"PreviewableContents" + bundle:mac_util::MainAppBundle()])) { + } + return self; +} + +- (void)showPreview:(TabContents*)preview { + DCHECK(preview); + + // Remove any old preview contents before showing the new one. + if (previewContents_) + [previewContents_->GetNativeView() removeFromSuperview]; + + previewContents_ = preview; + NSView* previewView = previewContents_->GetNativeView(); + [previewView setFrame:[[self view] bounds]]; + + // Hide the active container and add the preview contents. + [activeContainer_ setHidden:YES]; + [[self view] addSubview:previewView]; +} + +- (void)hidePreview { + DCHECK(previewContents_); + + // Remove the preview contents and reshow the active container. + [previewContents_->GetNativeView() removeFromSuperview]; + [activeContainer_ setHidden:NO]; + + previewContents_ = nil; +} + +- (BOOL)isShowingPreview { + return previewContents_ != nil; +} + +@end diff --git a/chrome/browser/ui/cocoa/previewable_contents_controller_unittest.mm b/chrome/browser/ui/cocoa/previewable_contents_controller_unittest.mm new file mode 100644 index 0000000..a2d9263 --- /dev/null +++ b/chrome/browser/ui/cocoa/previewable_contents_controller_unittest.mm @@ -0,0 +1,34 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#import "base/scoped_nsobject.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#include "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/previewable_contents_controller.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +namespace { + +class PreviewableContentsControllerTest : public CocoaTest { + public: + virtual void SetUp() { + CocoaTest::SetUp(); + controller_.reset([[PreviewableContentsController alloc] init]); + [[test_window() contentView] addSubview:[controller_ view]]; + } + + scoped_nsobject<PreviewableContentsController> controller_; +}; + +TEST_VIEW(PreviewableContentsControllerTest, [controller_ view]) + +// TODO(rohitrao): Test showing and hiding the preview. This may require +// changing the interface to take in a TabContentsView* instead of a +// TabContents*. + +} // namespace + diff --git a/chrome/browser/ui/cocoa/reload_button.h b/chrome/browser/ui/cocoa/reload_button.h new file mode 100644 index 0000000..f955590 --- /dev/null +++ b/chrome/browser/ui/cocoa/reload_button.h @@ -0,0 +1,50 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_RELOAD_BUTTON_H_ +#define CHROME_BROWSER_UI_COCOA_RELOAD_BUTTON_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#import "base/scoped_nsobject.h" + +// NSButton subclass which defers certain state changes when the mouse +// is hovering over it. + +@interface ReloadButton : NSButton { + @private + // Tracks whether the mouse is hovering for purposes of not making + // unexpected state changes. + BOOL isMouseInside_; + scoped_nsobject<NSTrackingArea> trackingArea_; + + // Timer used when setting reload mode while the mouse is hovered. + scoped_nsobject<NSTimer> pendingReloadTimer_; +} + +// Returns YES if the mouse is currently inside the bounds. +- (BOOL)isMouseInside; + +// Update the tag, and the image and tooltip to match. If |anInt| +// matches the current tag, no action is taken. |anInt| must be +// either |IDC_STOP| or |IDC_RELOAD|. +- (void)updateTag:(NSInteger)anInt; + +// Update the button to be a reload button or stop button depending on +// |isLoading|. If |force|, always sets the indicated mode. If +// |!force|, and the mouse is over the button, defer the transition +// from stop button to reload button until the mouse has left the +// button, or until |pendingReloadTimer_| fires. This prevents an +// inadvertent click _just_ as the state changes. +- (void)setIsLoading:(BOOL)isLoading force:(BOOL)force; + +@end + +@interface ReloadButton (PrivateTestingMethods) ++ (void)setPendingReloadTimeout:(NSTimeInterval)seconds; +- (NSTrackingArea*)trackingArea; +@end + +#endif // CHROME_BROWSER_UI_COCOA_RELOAD_BUTTON_H_ diff --git a/chrome/browser/ui/cocoa/reload_button.mm b/chrome/browser/ui/cocoa/reload_button.mm new file mode 100644 index 0000000..84e7091 --- /dev/null +++ b/chrome/browser/ui/cocoa/reload_button.mm @@ -0,0 +1,168 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/reload_button.h" + +#include "app/l10n_util.h" +#include "app/l10n_util_mac.h" +#include "base/nsimage_cache_mac.h" +#include "chrome/app/chrome_command_ids.h" +#import "chrome/browser/ui/cocoa/gradient_button_cell.h" +#import "chrome/browser/ui/cocoa/view_id_util.h" +#include "grit/generated_resources.h" + +namespace { + +NSString* const kReloadImageName = @"reload_Template.pdf"; +NSString* const kStopImageName = @"stop_Template.pdf"; + +// Constant matches Windows. +NSTimeInterval kPendingReloadTimeout = 1.35; + +} // namespace + +@implementation ReloadButton + +- (void)dealloc { + if (trackingArea_) { + [self removeTrackingArea:trackingArea_]; + trackingArea_.reset(); + } + [super dealloc]; +} + +- (void)updateTrackingAreas { + // If the mouse is hovering when the tracking area is updated, the + // control could end up locked into inappropriate behavior for + // awhile, so unwind state. + if (isMouseInside_) + [self mouseExited:nil]; + + if (trackingArea_) { + [self removeTrackingArea:trackingArea_]; + trackingArea_.reset(); + } + trackingArea_.reset([[NSTrackingArea alloc] + initWithRect:[self bounds] + options:(NSTrackingMouseEnteredAndExited | + NSTrackingActiveInActiveApp) + owner:self + userInfo:nil]); + [self addTrackingArea:trackingArea_]; +} + +- (void)awakeFromNib { + [self updateTrackingAreas]; + + // Don't allow multi-clicks, because the user probably wouldn't ever + // want to stop+reload or reload+stop. + [self setIgnoresMultiClick:YES]; +} + +- (void)updateTag:(NSInteger)anInt { + if ([self tag] == anInt) + return; + + // Forcibly remove any stale tooltip which is being displayed. + [self removeAllToolTips]; + + [self setTag:anInt]; + if (anInt == IDC_RELOAD) { + [self setImage:nsimage_cache::ImageNamed(kReloadImageName)]; + [self setToolTip:l10n_util::GetNSStringWithFixup(IDS_TOOLTIP_RELOAD)]; + } else if (anInt == IDC_STOP) { + [self setImage:nsimage_cache::ImageNamed(kStopImageName)]; + [self setToolTip:l10n_util::GetNSStringWithFixup(IDS_TOOLTIP_STOP)]; + } else { + NOTREACHED(); + } +} + +- (void)setIsLoading:(BOOL)isLoading force:(BOOL)force { + // Can always transition to stop mode. Only transition to reload + // mode if forced or if the mouse isn't hovering. Otherwise, note + // that reload mode is desired and disable the button. + if (isLoading) { + pendingReloadTimer_.reset(); + [self updateTag:IDC_STOP]; + [self setEnabled:YES]; + } else if (force || ![self isMouseInside]) { + pendingReloadTimer_.reset(); + [self updateTag:IDC_RELOAD]; + + // This button's cell may not have received a mouseExited event, and + // therefore it could still think that the mouse is inside the button. Make + // sure the cell's sense of mouse-inside matches the local sense, to prevent + // drawing artifacts. + id cell = [self cell]; + if ([cell respondsToSelector:@selector(setMouseInside:animate:)]) + [cell setMouseInside:[self isMouseInside] animate:NO]; + [self setEnabled:YES]; + } else if ([self tag] == IDC_STOP && !pendingReloadTimer_) { + [self setEnabled:NO]; + pendingReloadTimer_.reset( + [[NSTimer scheduledTimerWithTimeInterval:kPendingReloadTimeout + target:self + selector:@selector(forceReloadState) + userInfo:nil + repeats:NO] retain]); + } +} + +- (void)forceReloadState { + [self setIsLoading:NO force:YES]; +} + +- (BOOL)sendAction:(SEL)theAction to:(id)theTarget { + if ([self tag] == IDC_STOP) { + // When the timer is started, the button is disabled, so this + // should not be possible. + DCHECK(!pendingReloadTimer_.get()); + + // When the stop is processed, immediately change to reload mode, + // even though the IPC still has to bounce off the renderer and + // back before the regular |-setIsLoaded:force:| will be called. + // [This is how views and gtk do it.] + const BOOL ret = [super sendAction:theAction to:theTarget]; + if (ret) + [self forceReloadState]; + return ret; + } + + return [super sendAction:theAction to:theTarget]; +} + +- (void)mouseEntered:(NSEvent*)theEvent { + isMouseInside_ = YES; +} + +- (void)mouseExited:(NSEvent*)theEvent { + isMouseInside_ = NO; + + // Reload mode was requested during the hover. + if (pendingReloadTimer_) + [self forceReloadState]; +} + +- (BOOL)isMouseInside { + return isMouseInside_; +} + +- (ViewID)viewID { + return VIEW_ID_RELOAD_BUTTON; +} + +@end // ReloadButton + +@implementation ReloadButton (Testing) + ++ (void)setPendingReloadTimeout:(NSTimeInterval)seconds { + kPendingReloadTimeout = seconds; +} + +- (NSTrackingArea*)trackingArea { + return trackingArea_; +} + +@end diff --git a/chrome/browser/ui/cocoa/reload_button_unittest.mm b/chrome/browser/ui/cocoa/reload_button_unittest.mm new file mode 100644 index 0000000..386a503 --- /dev/null +++ b/chrome/browser/ui/cocoa/reload_button_unittest.mm @@ -0,0 +1,259 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#import "chrome/browser/ui/cocoa/reload_button.h" + +#include "base/scoped_nsobject.h" +#include "chrome/app/chrome_command_ids.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/test_event_utils.h" +#import "testing/gtest_mac.h" +#include "testing/platform_test.h" +#import "third_party/ocmock/OCMock/OCMock.h" + +@protocol TargetActionMock <NSObject> +- (void)anAction:(id)sender; +@end + +namespace { + +class ReloadButtonTest : public CocoaTest { + public: + ReloadButtonTest() { + NSRect frame = NSMakeRect(0, 0, 20, 20); + scoped_nsobject<ReloadButton> button( + [[ReloadButton alloc] initWithFrame:frame]); + button_ = button.get(); + + // Set things up so unit tests have a reliable baseline. + [button_ setTag:IDC_RELOAD]; + [button_ awakeFromNib]; + + [[test_window() contentView] addSubview:button_]; + } + + ReloadButton* button_; +}; + +TEST_VIEW(ReloadButtonTest, button_) + +// Test that mouse-tracking is setup and does the right thing. +TEST_F(ReloadButtonTest, IsMouseInside) { + EXPECT_TRUE([[button_ trackingAreas] containsObject:[button_ trackingArea]]); + + EXPECT_FALSE([button_ isMouseInside]); + [button_ mouseEntered:nil]; + EXPECT_TRUE([button_ isMouseInside]); + [button_ mouseExited:nil]; +} + +// Verify that multiple clicks do not result in multiple messages to +// the target. +TEST_F(ReloadButtonTest, IgnoredMultiClick) { + id mock_target = [OCMockObject mockForProtocol:@protocol(TargetActionMock)]; + [button_ setTarget:mock_target]; + [button_ setAction:@selector(anAction:)]; + + // Expect the action once. + [[mock_target expect] anAction:button_]; + + const std::pair<NSEvent*,NSEvent*> click_one = + test_event_utils::MouseClickInView(button_, 1); + const std::pair<NSEvent*,NSEvent*> click_two = + test_event_utils::MouseClickInView(button_, 2); + [NSApp postEvent:click_one.second atStart:YES]; + [button_ mouseDown:click_one.first]; + [NSApp postEvent:click_two.second atStart:YES]; + [button_ mouseDown:click_two.first]; + + [button_ setTarget:nil]; +} + +TEST_F(ReloadButtonTest, UpdateTag) { + [button_ setTag:IDC_STOP]; + + [button_ updateTag:IDC_RELOAD]; + EXPECT_EQ(IDC_RELOAD, [button_ tag]); + NSImage* reloadImage = [button_ image]; + NSString* const reloadToolTip = [button_ toolTip]; + + [button_ updateTag:IDC_STOP]; + EXPECT_EQ(IDC_STOP, [button_ tag]); + NSImage* stopImage = [button_ image]; + NSString* const stopToolTip = [button_ toolTip]; + EXPECT_NSNE(reloadImage, stopImage); + EXPECT_NSNE(reloadToolTip, stopToolTip); + + [button_ updateTag:IDC_RELOAD]; + EXPECT_EQ(IDC_RELOAD, [button_ tag]); + EXPECT_NSEQ(reloadImage, [button_ image]); + EXPECT_NSEQ(reloadToolTip, [button_ toolTip]); +} + +// Test that when forcing the mode, it takes effect immediately, +// regardless of whether the mouse is hovering. +TEST_F(ReloadButtonTest, SetIsLoadingForce) { + EXPECT_FALSE([button_ isMouseInside]); + EXPECT_EQ(IDC_RELOAD, [button_ tag]); + + // Changes to stop immediately. + [button_ setIsLoading:YES force:YES]; + EXPECT_EQ(IDC_STOP, [button_ tag]); + + // Changes to reload immediately. + [button_ setIsLoading:NO force:YES]; + EXPECT_EQ(IDC_RELOAD, [button_ tag]); + + // Changes to stop immediately when the mouse is hovered, and + // doesn't change when the mouse exits. + [button_ mouseEntered:nil]; + EXPECT_TRUE([button_ isMouseInside]); + [button_ setIsLoading:YES force:YES]; + EXPECT_EQ(IDC_STOP, [button_ tag]); + [button_ mouseExited:nil]; + EXPECT_FALSE([button_ isMouseInside]); + EXPECT_EQ(IDC_STOP, [button_ tag]); + + // Changes to reload immediately when the mouse is hovered, and + // doesn't change when the mouse exits. + [button_ mouseEntered:nil]; + EXPECT_TRUE([button_ isMouseInside]); + [button_ setIsLoading:NO force:YES]; + EXPECT_EQ(IDC_RELOAD, [button_ tag]); + [button_ mouseExited:nil]; + EXPECT_FALSE([button_ isMouseInside]); + EXPECT_EQ(IDC_RELOAD, [button_ tag]); +} + +// Test that without force, stop mode is set immediately, but reload +// is affected by the hover status. +TEST_F(ReloadButtonTest, SetIsLoadingNoForceUnHover) { + EXPECT_FALSE([button_ isMouseInside]); + EXPECT_EQ(IDC_RELOAD, [button_ tag]); + + // Changes to stop immediately when the mouse is not hovering. + [button_ setIsLoading:YES force:NO]; + EXPECT_EQ(IDC_STOP, [button_ tag]); + + // Changes to reload immediately when the mouse is not hovering. + [button_ setIsLoading:NO force:NO]; + EXPECT_EQ(IDC_RELOAD, [button_ tag]); + + // Changes to stop immediately when the mouse is hovered, and + // doesn't change when the mouse exits. + [button_ mouseEntered:nil]; + EXPECT_TRUE([button_ isMouseInside]); + [button_ setIsLoading:YES force:NO]; + EXPECT_EQ(IDC_STOP, [button_ tag]); + [button_ mouseExited:nil]; + EXPECT_FALSE([button_ isMouseInside]); + EXPECT_EQ(IDC_STOP, [button_ tag]); + + // Does not change to reload immediately when the mouse is hovered, + // changes when the mouse exits. + [button_ mouseEntered:nil]; + EXPECT_TRUE([button_ isMouseInside]); + [button_ setIsLoading:NO force:NO]; + EXPECT_EQ(IDC_STOP, [button_ tag]); + [button_ mouseExited:nil]; + EXPECT_FALSE([button_ isMouseInside]); + EXPECT_EQ(IDC_RELOAD, [button_ tag]); +} + +// Test that without force, stop mode is set immediately, and reload +// will be set after a timeout. +// TODO(shess): Reenable, http://crbug.com/61485 +TEST_F(ReloadButtonTest, DISABLED_SetIsLoadingNoForceTimeout) { + // When the event loop first spins, some delayed tracking-area setup + // is done, which causes -mouseExited: to be called. Spin it at + // least once, and dequeue any pending events. + // TODO(shess): It would be more reasonable to have an MockNSTimer + // factory for the class to use, which this code could fire + // directly. + while ([NSApp nextEventMatchingMask:NSAnyEventMask + untilDate:nil + inMode:NSDefaultRunLoopMode + dequeue:YES]) { + } + + const NSTimeInterval kShortTimeout = 0.1; + [ReloadButton setPendingReloadTimeout:kShortTimeout]; + + EXPECT_FALSE([button_ isMouseInside]); + EXPECT_EQ(IDC_RELOAD, [button_ tag]); + + // Move the mouse into the button and press it. + [button_ mouseEntered:nil]; + EXPECT_TRUE([button_ isMouseInside]); + [button_ setIsLoading:YES force:NO]; + EXPECT_EQ(IDC_STOP, [button_ tag]); + + // Does not change to reload immediately when the mouse is hovered. + EXPECT_TRUE([button_ isMouseInside]); + [button_ setIsLoading:NO force:NO]; + EXPECT_TRUE([button_ isMouseInside]); + EXPECT_EQ(IDC_STOP, [button_ tag]); + EXPECT_TRUE([button_ isMouseInside]); + + // Spin event loop until the timeout passes. + NSDate* pastTimeout = [NSDate dateWithTimeIntervalSinceNow:2 * kShortTimeout]; + [NSApp nextEventMatchingMask:NSAnyEventMask + untilDate:pastTimeout + inMode:NSDefaultRunLoopMode + dequeue:NO]; + + // Mouse is still hovered, button is in reload mode. If the mouse + // is no longer hovered, see comment at top of function. + EXPECT_TRUE([button_ isMouseInside]); + EXPECT_EQ(IDC_RELOAD, [button_ tag]); +} + +// Test that pressing stop after reload mode has been requested +// doesn't forward the stop message. +TEST_F(ReloadButtonTest, StopAfterReloadSet) { + id mock_target = [OCMockObject mockForProtocol:@protocol(TargetActionMock)]; + [button_ setTarget:mock_target]; + [button_ setAction:@selector(anAction:)]; + + EXPECT_FALSE([button_ isMouseInside]); + + // Get to stop mode. + [button_ setIsLoading:YES force:YES]; + EXPECT_EQ(IDC_STOP, [button_ tag]); + EXPECT_TRUE([button_ isEnabled]); + + // Expect the action once. + [[mock_target expect] anAction:button_]; + + // Clicking in stop mode should send the action and transition to + // reload mode. + const std::pair<NSEvent*,NSEvent*> click = + test_event_utils::MouseClickInView(button_, 1); + [NSApp postEvent:click.second atStart:YES]; + [button_ mouseDown:click.first]; + EXPECT_EQ(IDC_RELOAD, [button_ tag]); + EXPECT_TRUE([button_ isEnabled]); + + // Get back to stop mode. + [button_ setIsLoading:YES force:YES]; + EXPECT_EQ(IDC_STOP, [button_ tag]); + EXPECT_TRUE([button_ isEnabled]); + + // If hover prevented reload mode immediately taking effect, clicks should do + // nothing, because the button should be disabled. + [button_ mouseEntered:nil]; + EXPECT_TRUE([button_ isMouseInside]); + [button_ setIsLoading:NO force:NO]; + EXPECT_EQ(IDC_STOP, [button_ tag]); + EXPECT_FALSE([button_ isEnabled]); + [NSApp postEvent:click.second atStart:YES]; + [button_ mouseDown:click.first]; + EXPECT_EQ(IDC_STOP, [button_ tag]); + + [button_ setTarget:nil]; +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/repost_form_warning_mac.h b/chrome/browser/ui/cocoa/repost_form_warning_mac.h new file mode 100644 index 0000000..a7ca8b2 --- /dev/null +++ b/chrome/browser/ui/cocoa/repost_form_warning_mac.h @@ -0,0 +1,40 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_REPOST_FORM_WARNING_MAC_H_ +#define CHROME_BROWSER_UI_COCOA_REPOST_FORM_WARNING_MAC_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_ptr.h" +#include "chrome/browser/ui/cocoa/constrained_window_mac.h" + +class RepostFormWarningController; + +// Displays a dialog that warns the user that they are about to resubmit +// a form. To show the dialog, call the |Create| method. It will open the +// dialog and then delete itself when the user dismisses the dialog. +class RepostFormWarningMac : public ConstrainedDialogDelegate { + public: + // Convenience method that creates a new |RepostFormWarningController| and + // then a new |RepostFormWarningMac| from that. + static RepostFormWarningMac* Create(NSWindow* parent, + TabContents* tab_contents); + + RepostFormWarningMac(NSWindow* parent, + RepostFormWarningController* controller); + + // ConstrainedWindowDelegateMacSystemSheet methods: + virtual void DeleteDelegate(); + + private: + virtual ~RepostFormWarningMac(); + + scoped_ptr<RepostFormWarningController> controller_; + + DISALLOW_COPY_AND_ASSIGN(RepostFormWarningMac); +}; + +#endif // CHROME_BROWSER_UI_COCOA_REPOST_FORM_WARNING_MAC_H_ diff --git a/chrome/browser/ui/cocoa/repost_form_warning_mac.mm b/chrome/browser/ui/cocoa/repost_form_warning_mac.mm new file mode 100644 index 0000000..71f292b --- /dev/null +++ b/chrome/browser/ui/cocoa/repost_form_warning_mac.mm @@ -0,0 +1,82 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/repost_form_warning_mac.h" + +#include "app/l10n_util_mac.h" +#include "base/scoped_nsobject.h" +#include "chrome/browser/repost_form_warning_controller.h" +#include "grit/generated_resources.h" + +// The delegate of the NSAlert used to display the dialog. Forwards the alert's +// completion event to the C++ class |RepostFormWarningController|. +@interface RepostDelegate : NSObject { + RepostFormWarningController* warning_; // weak +} +- (id)initWithWarning:(RepostFormWarningController*)warning; +- (void)alertDidEnd:(NSAlert*)alert + returnCode:(int)returnCode + contextInfo:(void*)contextInfo; +@end + +@implementation RepostDelegate +- (id)initWithWarning:(RepostFormWarningController*)warning { + if ((self = [super init])) { + warning_ = warning; + } + return self; +} + +- (void)alertDidEnd:(NSAlert*)alert + returnCode:(int)returnCode + contextInfo:(void*)contextInfo { + if (returnCode == NSAlertFirstButtonReturn) { + warning_->Continue(); + } else { + warning_->Cancel(); + } +} +@end + +RepostFormWarningMac* RepostFormWarningMac::Create(NSWindow* parent, + TabContents* tab_contents) { + return new RepostFormWarningMac( + parent, + new RepostFormWarningController(tab_contents)); +} + +RepostFormWarningMac::RepostFormWarningMac( + NSWindow* parent, + RepostFormWarningController* controller) + : ConstrainedWindowMacDelegateSystemSheet( + [[[RepostDelegate alloc] initWithWarning:controller] + autorelease], + @selector(alertDidEnd:returnCode:contextInfo:)), + controller_(controller) { + scoped_nsobject<NSAlert> alert([[NSAlert alloc] init]); + [alert setMessageText: + l10n_util::GetNSStringWithFixup(IDS_HTTP_POST_WARNING_TITLE)]; + [alert setInformativeText: + l10n_util::GetNSStringWithFixup(IDS_HTTP_POST_WARNING)]; + [alert addButtonWithTitle: + l10n_util::GetNSStringWithFixup(IDS_HTTP_POST_WARNING_RESEND)]; + [alert addButtonWithTitle: + l10n_util::GetNSStringWithFixup(IDS_CANCEL)]; + + set_sheet(alert); + + controller->Show(this); +} + +RepostFormWarningMac::~RepostFormWarningMac() { + NSWindow* window = [(NSAlert*)sheet() window]; + if (window && is_sheet_open()) { + [NSApp endSheet:window + returnCode:NSAlertSecondButtonReturn]; + } +} + +void RepostFormWarningMac::DeleteDelegate() { + delete this; +} diff --git a/chrome/browser/ui/cocoa/restart_browser.h b/chrome/browser/ui/cocoa/restart_browser.h new file mode 100644 index 0000000..27bdd35 --- /dev/null +++ b/chrome/browser/ui/cocoa/restart_browser.h @@ -0,0 +1,22 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_RESTART_BROWSER_H_ +#define CHROME_BROWSER_UI_COCOA_RESTART_BROWSER_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +// This is a functional match for chrome/browser/views/restart_message_box +// so any code that needs to ask for a browser restart has something like what +// the Windows code has. +namespace restart_browser { + +// Puts up an alert telling the user to restart their browser. The alert +// will be hung off |parent| or global otherise. +void RequestRestart(NSWindow* parent); + +} // namespace restart_browser + +#endif // CHROME_BROWSER_UI_COCOA_RESTART_BROWSER_H_ diff --git a/chrome/browser/ui/cocoa/restart_browser.mm b/chrome/browser/ui/cocoa/restart_browser.mm new file mode 100644 index 0000000..c88715a --- /dev/null +++ b/chrome/browser/ui/cocoa/restart_browser.mm @@ -0,0 +1,86 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/restart_browser.h" + +#include "app/l10n_util.h" +#include "app/l10n_util_mac.h" +#include "chrome/browser/browser_list.h" +#include "chrome/browser/browser_process.h" +#include "chrome/browser/prefs/pref_service.h" +#include "chrome/common/pref_names.h" +#include "grit/chromium_strings.h" +#include "grit/generated_resources.h" +#include "grit/app_strings.h" + +// Helper to clean up after the notification that the alert was dismissed. +@interface RestartHelper : NSObject { + @private + NSAlert* alert_; +} +- (NSAlert*)alert; +- (void)alertDidEnd:(NSAlert*)alert + returnCode:(int)returnCode + contextInfo:(void*)contextInfo; +@end + +@implementation RestartHelper + +- (NSAlert*)alert { + alert_ = [[NSAlert alloc] init]; + return alert_; +} + +- (void)dealloc { + [alert_ release]; + [super dealloc]; +} + +- (void)alertDidEnd:(NSAlert*)alert + returnCode:(int)returnCode + contextInfo:(void*)contextInfo { + if (returnCode == NSAlertFirstButtonReturn) { + // Nothing to do. User will restart later. + } else if (returnCode == NSAlertSecondButtonReturn) { + // Set the flag to restore state after the restart. + PrefService* pref_service = g_browser_process->local_state(); + pref_service->SetBoolean(prefs::kRestartLastSessionOnShutdown, true); + BrowserList::CloseAllBrowsersAndExit(); + } else { + NOTREACHED(); + } + [self autorelease]; +} + +@end + +namespace restart_browser { + +void RequestRestart(NSWindow* parent) { + NSString* title = + l10n_util::GetNSStringFWithFixup(IDS_PLEASE_RESTART_BROWSER, + l10n_util::GetStringUTF16(IDS_PRODUCT_NAME)); + NSString* text = + l10n_util::GetNSStringFWithFixup(IDS_UPDATE_RECOMMENDED, + l10n_util::GetStringUTF16(IDS_PRODUCT_NAME)); + NSString* notNowButtin = l10n_util::GetNSStringWithFixup(IDS_NOT_NOW); + NSString* restartButton = + l10n_util::GetNSStringWithFixup(IDS_RESTART_AND_UPDATE); + + RestartHelper* helper = [[RestartHelper alloc] init]; + + NSAlert* alert = [helper alert]; + [alert setAlertStyle:NSInformationalAlertStyle]; + [alert setMessageText:title]; + [alert setInformativeText:text]; + [alert addButtonWithTitle:notNowButtin]; + [alert addButtonWithTitle:restartButton]; + + [alert beginSheetModalForWindow:parent + modalDelegate:helper + didEndSelector:@selector(alertDidEnd:returnCode:contextInfo:) + contextInfo:nil]; +} + +} // namespace restart_browser diff --git a/chrome/browser/ui/cocoa/rwhvm_editcommand_helper.h b/chrome/browser/ui/cocoa/rwhvm_editcommand_helper.h new file mode 100644 index 0000000..92935a4 --- /dev/null +++ b/chrome/browser/ui/cocoa/rwhvm_editcommand_helper.h @@ -0,0 +1,72 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_RWHVM_EDITCOMMAND_HELPER_H_ +#define CHROME_BROWSER_UI_COCOA_RWHVM_EDITCOMMAND_HELPER_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "base/basictypes.h" +#include "base/hash_tables.h" +#include "base/gtest_prod_util.h" +#include "chrome/browser/renderer_host/render_widget_host_view_mac.h" + +// RenderWidgetHostViewMacEditCommandHelper is the real name of this class +// but that's too long, so we use a shorter version. +// +// This class mimics the behavior of WebKit's WebView class in a way that makes +// sense for Chrome. +// +// WebCore has the concept of "core commands", basically named actions such as +// "Select All" and "Move Cursor Left". The commands are executed using their +// string value by WebCore. +// +// This class is responsible for 2 things: +// 1. Provide an abstraction to determine the enabled/disabled state of menu +// items that correspond to edit commands. +// 2. Hook up a bunch of objc selectors to the RenderWidgetHostViewCocoa object. +// (note that this is not a misspelling of RenderWidgetHostViewMac, it's in +// fact a distinct object) When these selectors are called, the relevant +// edit command is executed in WebCore. +class RWHVMEditCommandHelper { + FRIEND_TEST_ALL_PREFIXES(RWHVMEditCommandHelperTest, + TestAddEditingSelectorsToClass); + FRIEND_TEST_ALL_PREFIXES(RWHVMEditCommandHelperTest, + TestEditingCommandDelivery); + + public: + RWHVMEditCommandHelper(); + + // Adds editing selectors to the objc class using the objc runtime APIs. + // Each selector is connected to a single c method which forwards the message + // to WebCore's ExecuteEditCommand() function. + // This method is idempotent. + // The class passed in must conform to the RenderWidgetHostViewMacOwner + // protocol. + void AddEditingSelectorsToClass(Class klass); + + // Is a given menu item currently enabled? + // SEL - the objc selector currently associated with an NSMenuItem. + // owner - An object we can retrieve a RenderWidgetHostViewMac from to + // determine the command states. + bool IsMenuItemEnabled(SEL item_action, + id<RenderWidgetHostViewMacOwner> owner); + + // Converts an editing selector into a command name that can be sent to + // webkit. + static NSString* CommandNameForSelector(SEL selector); + + protected: + // Gets a list of all the selectors that AddEditingSelectorsToClass adds to + // the aforementioned class. + // returns an array of NSStrings WITHOUT the trailing ':'s. + NSArray* GetEditSelectorNames(); + + private: + base::hash_set<std::string> edit_command_set_; + DISALLOW_COPY_AND_ASSIGN(RWHVMEditCommandHelper); +}; + +#endif // CHROME_BROWSER_UI_COCOA_RWHVM_EDITCOMMAND_HELPER_H_ diff --git a/chrome/browser/ui/cocoa/rwhvm_editcommand_helper.mm b/chrome/browser/ui/cocoa/rwhvm_editcommand_helper.mm new file mode 100644 index 0000000..0aec09e --- /dev/null +++ b/chrome/browser/ui/cocoa/rwhvm_editcommand_helper.mm @@ -0,0 +1,227 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/rwhvm_editcommand_helper.h" + +#import <objc/runtime.h> + +#include "chrome/browser/renderer_host/render_widget_host.h" +#import "chrome/browser/renderer_host/render_widget_host_view_mac.h" + +namespace { +// The names of all the objc selectors w/o ':'s added to an object by +// AddEditingSelectorsToClass(). +// +// This needs to be kept in Sync with WEB_COMMAND list in the WebKit tree at: +// WebKit/mac/WebView/WebHTMLView.mm . +const char* kEditCommands[] = { + "alignCenter", + "alignJustified", + "alignLeft", + "alignRight", + "copy", + "cut", + "delete", + "deleteBackward", + "deleteBackwardByDecomposingPreviousCharacter", + "deleteForward", + "deleteToBeginningOfLine", + "deleteToBeginningOfParagraph", + "deleteToEndOfLine", + "deleteToEndOfParagraph", + "deleteToMark", + "deleteWordBackward", + "deleteWordForward", + "ignoreSpelling", + "indent", + "insertBacktab", + "insertLineBreak", + "insertNewline", + "insertNewlineIgnoringFieldEditor", + "insertParagraphSeparator", + "insertTab", + "insertTabIgnoringFieldEditor", + "makeTextWritingDirectionLeftToRight", + "makeTextWritingDirectionNatural", + "makeTextWritingDirectionRightToLeft", + "moveBackward", + "moveBackwardAndModifySelection", + "moveDown", + "moveDownAndModifySelection", + "moveForward", + "moveForwardAndModifySelection", + "moveLeft", + "moveLeftAndModifySelection", + "moveParagraphBackwardAndModifySelection", + "moveParagraphForwardAndModifySelection", + "moveRight", + "moveRightAndModifySelection", + "moveToBeginningOfDocument", + "moveToBeginningOfDocumentAndModifySelection", + "moveToBeginningOfLine", + "moveToBeginningOfLineAndModifySelection", + "moveToBeginningOfParagraph", + "moveToBeginningOfParagraphAndModifySelection", + "moveToBeginningOfSentence", + "moveToBeginningOfSentenceAndModifySelection", + "moveToEndOfDocument", + "moveToEndOfDocumentAndModifySelection", + "moveToEndOfLine", + "moveToEndOfLineAndModifySelection", + "moveToEndOfParagraph", + "moveToEndOfParagraphAndModifySelection", + "moveToEndOfSentence", + "moveToEndOfSentenceAndModifySelection", + "moveUp", + "moveUpAndModifySelection", + "moveWordBackward", + "moveWordBackwardAndModifySelection", + "moveWordForward", + "moveWordForwardAndModifySelection", + "moveWordLeft", + "moveWordLeftAndModifySelection", + "moveWordRight", + "moveWordRightAndModifySelection", + "outdent", + "pageDown", + "pageDownAndModifySelection", + "pageUp", + "pageUpAndModifySelection", + "selectAll", + "selectLine", + "selectParagraph", + "selectSentence", + "selectToMark", + "selectWord", + "setMark", + "showGuessPanel", + "subscript", + "superscript", + "swapWithMark", + "transpose", + "underline", + "unscript", + "yank", + "yankAndSelect"}; + + +// This function is installed via the objc runtime as the implementation of all +// the various editing selectors. +// The objc runtime hookup occurs in +// RWHVMEditCommandHelper::AddEditingSelectorsToClass(). +// +// self - the object we're attached to; it must implement the +// RenderWidgetHostViewMacOwner protocol. +// _cmd - the selector that fired. +// sender - the id of the object that sent the message. +// +// The selector is translated into an edit comand and then forwarded down the +// pipeline to WebCore. +// The route the message takes is: +// RenderWidgetHostViewMac -> RenderViewHost -> +// | IPC | -> +// RenderView -> currently focused WebFrame. +// The WebFrame is in the Chrome glue layer and forwards the message to WebCore. +void EditCommandImp(id self, SEL _cmd, id sender) { + // Make sure |self| is the right type. + DCHECK([self conformsToProtocol:@protocol(RenderWidgetHostViewMacOwner)]); + + // SEL -> command name string. + NSString* command_name_ns = + RWHVMEditCommandHelper::CommandNameForSelector(_cmd); + std::string edit_command([command_name_ns UTF8String]); + + // Forward the edit command string down the pipeline. + RenderWidgetHostViewMac* rwhv = [(id<RenderWidgetHostViewMacOwner>)self + renderWidgetHostViewMac]; + DCHECK(rwhv); + + // The second parameter is the core command value which isn't used here. + rwhv->GetRenderWidgetHost()->ForwardEditCommand(edit_command, ""); +} + +} // namespace + +// Maps an objc-selector to a core command name. +// +// Returns the core command name (which is the selector name with the trailing +// ':' stripped in most cases). +// +// Adapted from a function by the same name in +// WebKit/mac/WebView/WebHTMLView.mm . +// Capitalized names are returned from this function, but that's simply +// matching WebHTMLView.mm. +NSString* RWHVMEditCommandHelper::CommandNameForSelector(SEL selector) { + if (selector == @selector(insertParagraphSeparator:) || + selector == @selector(insertNewlineIgnoringFieldEditor:)) + return @"InsertNewline"; + if (selector == @selector(insertTabIgnoringFieldEditor:)) + return @"InsertTab"; + if (selector == @selector(pageDown:)) + return @"MovePageDown"; + if (selector == @selector(pageDownAndModifySelection:)) + return @"MovePageDownAndModifySelection"; + if (selector == @selector(pageUp:)) + return @"MovePageUp"; + if (selector == @selector(pageUpAndModifySelection:)) + return @"MovePageUpAndModifySelection"; + + // Remove the trailing colon. + NSString* selector_str = NSStringFromSelector(selector); + int selector_len = [selector_str length]; + return [selector_str substringToIndex:selector_len - 1]; +} + +RWHVMEditCommandHelper::RWHVMEditCommandHelper() { + for (size_t i = 0; i < arraysize(kEditCommands); ++i) { + edit_command_set_.insert(kEditCommands[i]); + } +} + +// Dynamically adds Selectors to the aformentioned class. +void RWHVMEditCommandHelper::AddEditingSelectorsToClass(Class klass) { + for (size_t i = 0; i < arraysize(kEditCommands); ++i) { + // Append trailing ':' to command name to get selector name. + NSString* sel_str = [NSString stringWithFormat: @"%s:", kEditCommands[i]]; + + SEL edit_selector = NSSelectorFromString(sel_str); + // May want to use @encode() for the last parameter to this method. + // If class_addMethod fails we assume that all the editing selectors where + // added to the class. + // If a certain class already implements a method then class_addMethod + // returns NO, which we can safely ignore. + class_addMethod(klass, edit_selector, (IMP)EditCommandImp, "v@:@"); + } +} + +bool RWHVMEditCommandHelper::IsMenuItemEnabled(SEL item_action, + id<RenderWidgetHostViewMacOwner> owner) { + const char* selector_name = sel_getName(item_action); + // TODO(jeremy): The final form of this function will check state + // associated with the Browser. + + // For now just mark all edit commands as enabled. + NSString* selector_name_ns = [NSString stringWithUTF8String:selector_name]; + + // Remove trailing ':' + size_t str_len = [selector_name_ns length]; + selector_name_ns = [selector_name_ns substringToIndex:str_len - 1]; + std::string edit_command_name([selector_name_ns UTF8String]); + + // search for presence in set and return. + bool ret = edit_command_set_.find(edit_command_name) != + edit_command_set_.end(); + return ret; +} + +NSArray* RWHVMEditCommandHelper::GetEditSelectorNames() { + size_t num_edit_commands = arraysize(kEditCommands); + NSMutableArray* ret = [NSMutableArray arrayWithCapacity:num_edit_commands]; + + for (size_t i = 0; i < num_edit_commands; ++i) { + [ret addObject:[NSString stringWithUTF8String:kEditCommands[i]]]; + } + + return ret; +} diff --git a/chrome/browser/ui/cocoa/rwhvm_editcommand_helper_unittest.mm b/chrome/browser/ui/cocoa/rwhvm_editcommand_helper_unittest.mm new file mode 100644 index 0000000..776c400 --- /dev/null +++ b/chrome/browser/ui/cocoa/rwhvm_editcommand_helper_unittest.mm @@ -0,0 +1,172 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/rwhvm_editcommand_helper.h" + +#import <Cocoa/Cocoa.h> + +#include "chrome/browser/renderer_host/mock_render_process_host.h" +#include "chrome/browser/renderer_host/render_widget_host.h" +#include "chrome/test/testing_profile.h" +#include "testing/gmock/include/gmock/gmock.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +class RWHVMEditCommandHelperTest : public PlatformTest { +}; + +// Bare bones obj-c class for testing purposes. +@interface RWHVMEditCommandHelperTestClass : NSObject +@end + +@implementation RWHVMEditCommandHelperTestClass +@end + +// Class that owns a RenderWidgetHostViewMac. +@interface RenderWidgetHostViewMacOwner : + NSObject<RenderWidgetHostViewMacOwner> { + RenderWidgetHostViewMac* rwhvm_; +} + +- (id) initWithRenderWidgetHostViewMac:(RenderWidgetHostViewMac*)rwhvm; +@end + +@implementation RenderWidgetHostViewMacOwner + +- (id)initWithRenderWidgetHostViewMac:(RenderWidgetHostViewMac*)rwhvm { + if ((self = [super init])) { + rwhvm_ = rwhvm; + } + return self; +} + +- (RenderWidgetHostViewMac*)renderWidgetHostViewMac { + return rwhvm_; +} + +@end + + +namespace { + // Returns true if all the edit command names in the array are present + // in test_obj. + // edit_commands is a list of NSStrings, selector names are formed by + // appending a trailing ':' to the string. + bool CheckObjectRespondsToEditCommands(NSArray* edit_commands, id test_obj) { + for (NSString* edit_command_name in edit_commands) { + NSString* sel_str = [edit_command_name stringByAppendingString:@":"]; + if (![test_obj respondsToSelector:NSSelectorFromString(sel_str)]) { + return false; + } + } + + return true; + } +} // namespace + +// Create a Mock RenderWidget +class MockRenderWidgetHostEditCommandCounter : public RenderWidgetHost { + public: + MockRenderWidgetHostEditCommandCounter(RenderProcessHost* process, + int routing_id) : + RenderWidgetHost(process, routing_id) {} + + MOCK_METHOD2(ForwardEditCommand, void(const std::string&, + const std::string&)); +}; + + +// Tests that editing commands make it through the pipeline all the way to +// RenderWidgetHost. +TEST_F(RWHVMEditCommandHelperTest, TestEditingCommandDelivery) { + RWHVMEditCommandHelper helper; + NSArray* edit_command_strings = helper.GetEditSelectorNames(); + + // Set up a mock render widget and set expectations. + MessageLoopForUI message_loop; + TestingProfile profile; + MockRenderProcessHost mock_process(&profile); + MockRenderWidgetHostEditCommandCounter mock_render_widget(&mock_process, 0); + + size_t num_edit_commands = [edit_command_strings count]; + EXPECT_CALL(mock_render_widget, + ForwardEditCommand(testing::_, testing::_)).Times(num_edit_commands); + +// TODO(jeremy): Figure this out and reenable this test. +// For some bizarre reason this code doesn't work, running the code in the +// debugger confirms that the function is called with the correct parameters +// however gmock appears not to be able to match up parameters correctly. +// Disable for now till we can figure this out. +#if 0 + // Tell Mock object that we expect to recieve each edit command once. + std::string empty_str; + for (NSString* edit_command_name in edit_command_strings) { + std::string command([edit_command_name UTF8String]); + EXPECT_CALL(mock_render_widget, + ForwardEditCommand(command, empty_str)).Times(1); + } +#endif // 0 + + // RenderWidgetHostViewMac self destructs (RenderWidgetHostViewMacCocoa + // takes ownership) so no need to delete it ourselves. + RenderWidgetHostViewMac* rwhvm = new RenderWidgetHostViewMac( + &mock_render_widget); + + RenderWidgetHostViewMacOwner* rwhwvm_owner = + [[[RenderWidgetHostViewMacOwner alloc] + initWithRenderWidgetHostViewMac:rwhvm] autorelease]; + + helper.AddEditingSelectorsToClass([rwhwvm_owner class]); + + for (NSString* edit_command_name in edit_command_strings) { + NSString* sel_str = [edit_command_name stringByAppendingString:@":"]; + [rwhwvm_owner performSelector:NSSelectorFromString(sel_str) withObject:nil]; + } +} + +// Test RWHVMEditCommandHelper::AddEditingSelectorsToClass +TEST_F(RWHVMEditCommandHelperTest, TestAddEditingSelectorsToClass) { + RWHVMEditCommandHelper helper; + NSArray* edit_command_strings = helper.GetEditSelectorNames(); + ASSERT_GT([edit_command_strings count], 0U); + + // Create a class instance and add methods to the class. + RWHVMEditCommandHelperTestClass* test_obj = + [[[RWHVMEditCommandHelperTestClass alloc] init] autorelease]; + + // Check that edit commands aren't already attached to the object. + ASSERT_FALSE(CheckObjectRespondsToEditCommands(edit_command_strings, + test_obj)); + + helper.AddEditingSelectorsToClass([test_obj class]); + + // Check that all edit commands where added. + ASSERT_TRUE(CheckObjectRespondsToEditCommands(edit_command_strings, + test_obj)); + + // AddEditingSelectorsToClass() should be idempotent. + helper.AddEditingSelectorsToClass([test_obj class]); + + // Check that all edit commands are still there. + ASSERT_TRUE(CheckObjectRespondsToEditCommands(edit_command_strings, + test_obj)); +} + +// Test RWHVMEditCommandHelper::IsMenuItemEnabled. +TEST_F(RWHVMEditCommandHelperTest, TestMenuItemEnabling) { + RWHVMEditCommandHelper helper; + RenderWidgetHostViewMacOwner* rwhvm_owner = + [[[RenderWidgetHostViewMacOwner alloc] init] autorelease]; + + // The select all menu should always be enabled. + SEL select_all = NSSelectorFromString(@"selectAll:"); + ASSERT_TRUE(helper.IsMenuItemEnabled(select_all, rwhvm_owner)); + + // Random selectors should be enabled by the function. + SEL garbage_selector = NSSelectorFromString(@"randomGarbageSelector:"); + ASSERT_FALSE(helper.IsMenuItemEnabled(garbage_selector, rwhvm_owner)); + + // TODO(jeremy): Currently IsMenuItemEnabled just returns true for all edit + // selectors. Once we go past that we should do more extensive testing here. +} diff --git a/chrome/browser/ui/cocoa/sad_tab_controller.h b/chrome/browser/ui/cocoa/sad_tab_controller.h new file mode 100644 index 0000000..35e9aaf --- /dev/null +++ b/chrome/browser/ui/cocoa/sad_tab_controller.h @@ -0,0 +1,33 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_SAD_TAB_CONTROLLER_H_ +#define CHROME_BROWSER_UI_COCOA_SAD_TAB_CONTROLLER_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +class TabContents; + +// A controller class that manages the SadTabView (aka "Aw Snap" or crash page). +@interface SadTabController : NSViewController { + @private + TabContents* tabContents_; // Weak reference. +} + +// Designated initializer is initWithTabContents. +- (id)initWithTabContents:(TabContents*)someTabContents + superview:(NSView*)superview; + +// This action just calls the NSApp sendAction to get it into the standard +// Cocoa action processing. +- (IBAction)openLearnMoreAboutCrashLink:(id)sender; + +// Returns a weak reference to the TabContents whose TabContentsView created +// this SadTabController. +- (TabContents*)tabContents; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_SAD_TAB_CONTROLLER_H_ diff --git a/chrome/browser/ui/cocoa/sad_tab_controller.mm b/chrome/browser/ui/cocoa/sad_tab_controller.mm new file mode 100644 index 0000000..ba0b102 --- /dev/null +++ b/chrome/browser/ui/cocoa/sad_tab_controller.mm @@ -0,0 +1,48 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/sad_tab_controller.h" + +#include "base/mac_util.h" +#import "chrome/browser/ui/cocoa/sad_tab_view.h" + +@implementation SadTabController + +- (id)initWithTabContents:(TabContents*)someTabContents + superview:(NSView*)superview { + if ((self = [super initWithNibName:@"SadTab" + bundle:mac_util::MainAppBundle()])) { + tabContents_ = someTabContents; + + NSView* view = [self view]; + [superview addSubview:view]; + [view setFrame:[superview bounds]]; + } + + return self; +} + +- (void)awakeFromNib { + // If tab_contents_ is nil, ask view to remove link. + if (!tabContents_) { + SadTabView* sad_view = static_cast<SadTabView*>([self view]); + [sad_view removeLinkButton]; + } +} + +- (void)dealloc { + [[self view] removeFromSuperview]; + [super dealloc]; +} + +- (TabContents*)tabContents { + return tabContents_; +} + +- (void)openLearnMoreAboutCrashLink:(id)sender { + // Send the action up through the responder chain. + [NSApp sendAction:@selector(openLearnMoreAboutCrashLink:) to:nil from:self]; +} + +@end diff --git a/chrome/browser/ui/cocoa/sad_tab_controller_unittest.mm b/chrome/browser/ui/cocoa/sad_tab_controller_unittest.mm new file mode 100644 index 0000000..15839a8 --- /dev/null +++ b/chrome/browser/ui/cocoa/sad_tab_controller_unittest.mm @@ -0,0 +1,113 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/debug/debugger.h" +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/sad_tab_controller.h" +#import "chrome/browser/ui/cocoa/sad_tab_view.h" +#include "chrome/browser/renderer_host/test/test_render_view_host.h" +#include "chrome/browser/tab_contents/test_tab_contents.h" +#include "chrome/test/testing_profile.h" + +@interface SadTabView (ExposedForTesting) +// Implementation is below. +- (NSButton*)linkButton; +@end + +@implementation SadTabView (ExposedForTesting) +- (NSButton*)linkButton { + return linkButton_; +} +@end + +namespace { + +class SadTabControllerTest : public RenderViewHostTestHarness { + public: + SadTabControllerTest() : test_window_(nil) { + link_clicked_ = false; + } + + virtual void SetUp() { + RenderViewHostTestHarness::SetUp(); + // Inherting from RenderViewHostTestHarness means we can't inherit from + // from CocoaTest, so do a bootstrap and create test window. + CocoaTest::BootstrapCocoa(); + test_window_ = [[CocoaTestHelperWindow alloc] init]; + if (base::debug::BeingDebugged()) { + [test_window_ orderFront:nil]; + } else { + [test_window_ orderBack:nil]; + } + } + + virtual void TearDown() { + [test_window_ close]; + test_window_ = nil; + RenderViewHostTestHarness::TearDown(); + } + + // Creates the controller and adds its view to contents, caller has ownership. + SadTabController* CreateController() { + NSView* contentView = [test_window_ contentView]; + SadTabController* controller = + [[SadTabController alloc] initWithTabContents:contents() + superview:contentView]; + EXPECT_TRUE(controller); + NSView* view = [controller view]; + EXPECT_TRUE(view); + + return controller; + } + + NSButton* GetLinkButton(SadTabController* controller) { + SadTabView* view = static_cast<SadTabView*>([controller view]); + return ([view linkButton]); + } + + static bool link_clicked_; + CocoaTestHelperWindow* test_window_; +}; + +// static +bool SadTabControllerTest::link_clicked_; + +TEST_F(SadTabControllerTest, WithTabContents) { + scoped_nsobject<SadTabController> controller(CreateController()); + EXPECT_TRUE(controller); + NSButton* link = GetLinkButton(controller); + EXPECT_TRUE(link); +} + +TEST_F(SadTabControllerTest, WithoutTabContents) { + contents_.reset(); + scoped_nsobject<SadTabController> controller(CreateController()); + EXPECT_TRUE(controller); + NSButton* link = GetLinkButton(controller); + EXPECT_FALSE(link); +} + +TEST_F(SadTabControllerTest, ClickOnLink) { + scoped_nsobject<SadTabController> controller(CreateController()); + NSButton* link = GetLinkButton(controller); + EXPECT_TRUE(link); + EXPECT_FALSE(link_clicked_); + [link performClick:link]; + EXPECT_TRUE(link_clicked_); +} + +} // namespace + +@implementation NSApplication (SadTabControllerUnitTest) +// Add handler for the openLearnMoreAboutCrashLink: action to NSApp for testing +// purposes. Normally this would be sent up the responder tree correctly, but +// since tests run in the background, key window and main window are never set +// on NSApplication. Adding it to NSApplication directly removes the need for +// worrying about what the current window with focus is. +- (void)openLearnMoreAboutCrashLink:(id)sender { + SadTabControllerTest::link_clicked_ = true; +} + +@end diff --git a/chrome/browser/ui/cocoa/sad_tab_view.h b/chrome/browser/ui/cocoa/sad_tab_view.h new file mode 100644 index 0000000..0f304eb --- /dev/null +++ b/chrome/browser/ui/cocoa/sad_tab_view.h @@ -0,0 +1,36 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_SAD_TAB_VIEW_H_ +#define CHROME_BROWSER_UI_COCOA_SAD_TAB_VIEW_H_ +#pragma once + +#include "base/scoped_nsobject.h" +#include "chrome/browser/ui/cocoa/base_view.h" + +#import <Cocoa/Cocoa.h> + +@class HyperlinkButtonCell; + +// A view that displays the "sad tab" (aka crash page). +@interface SadTabView : BaseView { + @private + IBOutlet NSImageView* image_; + IBOutlet NSTextField* title_; + IBOutlet NSTextField* message_; + IBOutlet NSButton* linkButton_; + IBOutlet HyperlinkButtonCell* linkCell_; + + scoped_nsobject<NSColor> backgroundColor_; + NSSize messageSize_; +} + +// Designated initializer is -initWithFrame: . + +// Called by SadTabController to remove link button. +- (void)removeLinkButton; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_SAD_TAB_VIEW_H_ diff --git a/chrome/browser/ui/cocoa/sad_tab_view.mm b/chrome/browser/ui/cocoa/sad_tab_view.mm new file mode 100644 index 0000000..2c9f9e2 --- /dev/null +++ b/chrome/browser/ui/cocoa/sad_tab_view.mm @@ -0,0 +1,127 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/sad_tab_view.h" + +#include "app/resource_bundle.h" +#include "base/logging.h" +#import "chrome/browser/ui/cocoa/hyperlink_button_cell.h" +#include "grit/theme_resources.h" +#import "third_party/GTM/AppKit/GTMUILocalizerAndLayoutTweaker.h" + +// Offset above vertical middle of page where contents of page start. +static const CGFloat kSadTabOffset = -64; +// Padding between icon and title. +static const CGFloat kIconTitleSpacing = 20; +// Padding between title and message. +static const CGFloat kTitleMessageSpacing = 15; +// Padding between message and link. +static const CGFloat kMessageLinkSpacing = 15; +// Paddings on left and right of page. +static const CGFloat kTabHorzMargin = 13; + +@implementation SadTabView + +- (void)awakeFromNib { + // Load resource for image and set it. + ResourceBundle& rb = ResourceBundle::GetSharedInstance(); + NSImage* image = rb.GetNativeImageNamed(IDR_SAD_TAB); + DCHECK(image); + [image_ setImage:image]; + + // Set font for title. + NSFont* titleFont = [NSFont boldSystemFontOfSize:[NSFont systemFontSize]]; + [title_ setFont:titleFont]; + + // Set font for message. + NSFont* messageFont = [NSFont systemFontOfSize:[NSFont smallSystemFontSize]]; + [message_ setFont:messageFont]; + + // If necessary, set font and color for link. + if (linkButton_) { + [linkButton_ setFont:messageFont]; + [linkCell_ setTextColor:[NSColor whiteColor]]; + } + + // Initialize background color. + NSColor* backgroundColor = [[NSColor colorWithCalibratedRed:(35.0f/255.0f) + green:(48.0f/255.0f) + blue:(64.0f/255.0f) + alpha:1.0] retain]; + backgroundColor_.reset(backgroundColor); +} + +- (void)drawRect:(NSRect)dirtyRect { + // Paint background. + [backgroundColor_ set]; + NSRectFill(dirtyRect); +} + +- (void)resizeSubviewsWithOldSize:(NSSize)oldSize { + NSRect newBounds = [self bounds]; + CGFloat maxWidth = NSWidth(newBounds) - (kTabHorzMargin * 2); + BOOL callSizeToFit = (messageSize_.width == 0); + + // Set new frame origin for image. + NSRect iconFrame = [image_ frame]; + CGFloat iconX = (maxWidth - NSWidth(iconFrame)) / 2; + CGFloat iconY = + MIN(((NSHeight(newBounds) - NSHeight(iconFrame)) / 2) - kSadTabOffset, + NSHeight(newBounds) - NSHeight(iconFrame)); + iconX = floor(iconX); + iconY = floor(iconY); + [image_ setFrameOrigin:NSMakePoint(iconX, iconY)]; + + // Set new frame origin for title. + if (callSizeToFit) + [title_ sizeToFit]; + NSRect titleFrame = [title_ frame]; + CGFloat titleX = (maxWidth - NSWidth(titleFrame)) / 2; + CGFloat titleY = iconY - kIconTitleSpacing - NSHeight(titleFrame); + [title_ setFrameOrigin:NSMakePoint(titleX, titleY)]; + + // Set new frame for message, wrapping or unwrapping the text if necessary. + if (callSizeToFit) { + [message_ sizeToFit]; + messageSize_ = [message_ frame].size; + } + NSRect messageFrame = [message_ frame]; + if (messageSize_.width > maxWidth) { // Need to wrap message. + [message_ setFrameSize:NSMakeSize(maxWidth, messageSize_.height)]; + CGFloat heightChange = + [GTMUILocalizerAndLayoutTweaker sizeToFitFixedWidthTextField:message_]; + messageFrame.size.width = maxWidth; + messageFrame.size.height = messageSize_.height + heightChange; + messageFrame.origin.x = kTabHorzMargin; + } else { + if (!callSizeToFit) { + [message_ sizeToFit]; + messageFrame = [message_ frame]; + } + messageFrame.origin.x = (maxWidth - NSWidth(messageFrame)) / 2; + } + messageFrame.origin.y = + titleY - kTitleMessageSpacing - NSHeight(messageFrame); + [message_ setFrame:messageFrame]; + + if (linkButton_) { + if (callSizeToFit) + [linkButton_ sizeToFit]; + // Set new frame origin for link. + NSRect linkFrame = [linkButton_ frame]; + CGFloat linkX = (maxWidth - NSWidth(linkFrame)) / 2; + CGFloat linkY = + NSMinY(messageFrame) - kMessageLinkSpacing - NSHeight(linkFrame); + [linkButton_ setFrameOrigin:NSMakePoint(linkX, linkY)]; + } +} + +- (void)removeLinkButton { + if (linkButton_) { + [linkButton_ removeFromSuperview]; + linkButton_ = nil; + } +} + +@end diff --git a/chrome/browser/ui/cocoa/sad_tab_view_unittest.mm b/chrome/browser/ui/cocoa/sad_tab_view_unittest.mm new file mode 100644 index 0000000..2321dd3 --- /dev/null +++ b/chrome/browser/ui/cocoa/sad_tab_view_unittest.mm @@ -0,0 +1,25 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/sad_tab_view.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" + +namespace { + +class SadTabViewTest : public CocoaTest { + public: + SadTabViewTest() { + NSRect content_frame = [[test_window() contentView] frame]; + scoped_nsobject<SadTabView> view([[SadTabView alloc] + initWithFrame:content_frame]); + view_ = view.get(); + [[test_window() contentView] addSubview:view_]; + } + + SadTabView* view_; // Weak. Owned by the view hierarchy. +}; + +TEST_VIEW(SadTabViewTest, view_); + +} // namespace diff --git a/chrome/browser/ui/cocoa/scoped_authorizationref.h b/chrome/browser/ui/cocoa/scoped_authorizationref.h new file mode 100644 index 0000000..ae7edb3 --- /dev/null +++ b/chrome/browser/ui/cocoa/scoped_authorizationref.h @@ -0,0 +1,80 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_SCOPED_AUTHORIZATIONREF_H_ +#define CHROME_BROWSER_UI_COCOA_SCOPED_AUTHORIZATIONREF_H_ +#pragma once + +#include <Security/Authorization.h> + +#include "base/basictypes.h" +#include "base/compiler_specific.h" + +// scoped_AuthorizationRef maintains ownership of an AuthorizationRef. It is +// patterned after the scoped_ptr interface. + +class scoped_AuthorizationRef { + public: + explicit scoped_AuthorizationRef(AuthorizationRef authorization = NULL) + : authorization_(authorization) { + } + + ~scoped_AuthorizationRef() { + if (authorization_) { + AuthorizationFree(authorization_, kAuthorizationFlagDestroyRights); + } + } + + void reset(AuthorizationRef authorization = NULL) { + if (authorization_ != authorization) { + if (authorization_) { + AuthorizationFree(authorization_, kAuthorizationFlagDestroyRights); + } + authorization_ = authorization; + } + } + + bool operator==(AuthorizationRef that) const { + return authorization_ == that; + } + + bool operator!=(AuthorizationRef that) const { + return authorization_ != that; + } + + operator AuthorizationRef() const { + return authorization_; + } + + AuthorizationRef* operator&() { + return &authorization_; + } + + AuthorizationRef get() const { + return authorization_; + } + + void swap(scoped_AuthorizationRef& that) { + AuthorizationRef temp = that.authorization_; + that.authorization_ = authorization_; + authorization_ = temp; + } + + // scoped_AuthorizationRef::release() is like scoped_ptr<>::release. It is + // NOT a wrapper for AuthorizationFree(). To force a + // scoped_AuthorizationRef object to call AuthorizationFree(), use + // scoped_AuthorizaitonRef::reset(). + AuthorizationRef release() WARN_UNUSED_RESULT { + AuthorizationRef temp = authorization_; + authorization_ = NULL; + return temp; + } + + private: + AuthorizationRef authorization_; + + DISALLOW_COPY_AND_ASSIGN(scoped_AuthorizationRef); +}; + +#endif // CHROME_BROWSER_UI_COCOA_SCOPED_AUTHORIZATIONREF_H_ diff --git a/chrome/browser/ui/cocoa/search_engine_dialog_controller.h b/chrome/browser/ui/cocoa/search_engine_dialog_controller.h new file mode 100644 index 0000000..0cc06f2 --- /dev/null +++ b/chrome/browser/ui/cocoa/search_engine_dialog_controller.h @@ -0,0 +1,46 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include <vector> + +#import "base/ref_counted.h" +#import "base/scoped_nsobject.h" +#include "base/scoped_ptr.h" + +class Profile; +class SearchEngineDialogControllerBridge; +class TemplateURL; +class TemplateURLModel; + +// Class that acts as a controller for the search engine choice dialog. +@interface SearchEngineDialogController : NSWindowController { + @private + // Our current profile. + Profile* profile_; + + // If logos are to be displayed in random order. Used for UX testing. + bool randomize_; + + // Owned by the profile_. + TemplateURLModel* searchEnginesModel_; + + // Bridge to the C++ world. + scoped_refptr<SearchEngineDialogControllerBridge> bridge_; + + // Offered search engine choices. + std::vector<const TemplateURL*> choices_; + + IBOutlet NSImageView* headerImageView_; + IBOutlet NSView* searchEngineView_; +} + +@property(assign, nonatomic) Profile* profile; +@property(assign, nonatomic) bool randomize; + +// Properties for bindings. +@property(readonly) NSFont* mainLabelFont; + +@end diff --git a/chrome/browser/ui/cocoa/search_engine_dialog_controller.mm b/chrome/browser/ui/cocoa/search_engine_dialog_controller.mm new file mode 100644 index 0000000..6cf7911 --- /dev/null +++ b/chrome/browser/ui/cocoa/search_engine_dialog_controller.mm @@ -0,0 +1,285 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/search_engine_dialog_controller.h" + +#include <algorithm> + +#include "app/l10n_util_mac.h" +#include "app/resource_bundle.h" +#include "base/mac_util.h" +#include "base/nsimage_cache_mac.h" +#include "base/sys_string_conversions.h" +#include "base/time.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/search_engines/template_url.h" +#include "chrome/browser/search_engines/template_url_model.h" +#include "chrome/browser/search_engines/template_url_model_observer.h" +#include "grit/generated_resources.h" +#include "grit/theme_resources.h" +#import "third_party/GTM/AppKit/GTMUILocalizerAndLayoutTweaker.h" + +// Horizontal spacing between search engine choices. +const int kSearchEngineSpacing = 20; + +// Vertical spacing between the search engine logo and the button underneath. +const int kLogoButtonSpacing = 10; + +// Width of a label used in place of a logo. +const int kLogoLabelWidth = 170; + +// Height of a label used in place of a logo. +const int kLogoLabelHeight = 25; + +@interface SearchEngineDialogController (Private) +- (void)onTemplateURLModelChanged; +- (void)buildSearchEngineView; +- (NSView*)viewForSearchEngine:(const TemplateURL*)engine + atIndex:(size_t)index; +- (IBAction)searchEngineSelected:(id)sender; +@end + +class SearchEngineDialogControllerBridge : + public base::RefCounted<SearchEngineDialogControllerBridge>, + public TemplateURLModelObserver { + public: + SearchEngineDialogControllerBridge(SearchEngineDialogController* controller); + + // TemplateURLModelObserver + virtual void OnTemplateURLModelChanged(); + + private: + SearchEngineDialogController* controller_; +}; + +SearchEngineDialogControllerBridge::SearchEngineDialogControllerBridge( + SearchEngineDialogController* controller) : controller_(controller) { +} + +void SearchEngineDialogControllerBridge::OnTemplateURLModelChanged() { + [controller_ onTemplateURLModelChanged]; + MessageLoop::current()->QuitNow(); +} + +@implementation SearchEngineDialogController + +@synthesize profile = profile_; +@synthesize randomize = randomize_; + +- (id)init { + NSString* nibpath = + [mac_util::MainAppBundle() pathForResource:@"SearchEngineDialog" + ofType:@"nib"]; + self = [super initWithWindowNibPath:nibpath owner:self]; + if (self != nil) { + bridge_ = new SearchEngineDialogControllerBridge(self); + } + return self; +} + +- (void)dealloc { + [super dealloc]; +} + +- (IBAction)showWindow:(id)sender { + searchEnginesModel_ = profile_->GetTemplateURLModel(); + searchEnginesModel_->AddObserver(bridge_.get()); + + if (searchEnginesModel_->loaded()) { + MessageLoop::current()->PostTask( + FROM_HERE, + NewRunnableMethod( + bridge_.get(), + &SearchEngineDialogControllerBridge::OnTemplateURLModelChanged)); + } else { + searchEnginesModel_->Load(); + } + MessageLoop::current()->Run(); +} + +- (void)onTemplateURLModelChanged { + searchEnginesModel_->RemoveObserver(bridge_.get()); + + // Add the search engines in the search_engines_model_ to the buttons list. + // The first three will always be from prepopulated data. + std::vector<const TemplateURL*> templateUrls = + searchEnginesModel_->GetTemplateURLs(); + + // If we have fewer than two search engines, end the search engine dialog + // immediately, leaving the imported default search engine setting intact. + if (templateUrls.size() < 2) { + return; + } + + NSWindow* win = [self window]; + + [win setBackgroundColor:[NSColor whiteColor]]; + + NSImage* headerImage = ResourceBundle::GetSharedInstance(). + GetNativeImageNamed(IDR_SEARCH_ENGINE_DIALOG_TOP); + [headerImageView_ setImage:headerImage]; + + // Is the user's default search engine included in the first three + // prepopulated set? If not, we need to expand the dialog to include a fourth + // engine. + const TemplateURL* defaultSearchEngine = + searchEnginesModel_->GetDefaultSearchProvider(); + + std::vector<const TemplateURL*>::iterator engineIter = + templateUrls.begin(); + for (int i = 0; engineIter != templateUrls.end(); ++i, ++engineIter) { + if (i < 3) { + choices_.push_back(*engineIter); + } else { + if (*engineIter == defaultSearchEngine) + choices_.push_back(*engineIter); + } + } + + // Randomize the order of the logos if the option has been set. + if (randomize_) { + int seed = static_cast<int>(base::Time::Now().ToInternalValue()); + srand(seed); + std::random_shuffle(choices_.begin(), choices_.end()); + } + + [self buildSearchEngineView]; + + // Display the dialog. + NSInteger choice = [NSApp runModalForWindow:win]; + searchEnginesModel_->SetDefaultSearchProvider(choices_.at(choice)); +} + +- (void)buildSearchEngineView { + scoped_nsobject<NSMutableArray> searchEngineViews + ([[NSMutableArray alloc] init]); + + for (size_t i = 0; i < choices_.size(); ++i) + [searchEngineViews addObject:[self viewForSearchEngine:choices_.at(i) + atIndex:i]]; + + NSSize newOverallSize = NSZeroSize; + for (NSView* view in searchEngineViews.get()) { + NSRect engineFrame = [view frame]; + engineFrame.origin = NSMakePoint(newOverallSize.width, 0); + [searchEngineView_ addSubview:view]; + [view setFrame:engineFrame]; + newOverallSize = NSMakeSize( + newOverallSize.width + NSWidth(engineFrame) + kSearchEngineSpacing, + std::max(newOverallSize.height, NSHeight(engineFrame))); + } + newOverallSize.width -= kSearchEngineSpacing; + + // Resize the window to fit (and because it's bound on all sides it will + // resize the search engine view). + NSSize currentOverallSize = [searchEngineView_ bounds].size; + NSSize deltaSize = NSMakeSize( + newOverallSize.width - currentOverallSize.width, + newOverallSize.height - currentOverallSize.height); + NSSize windowDeltaSize = [searchEngineView_ convertSize:deltaSize toView:nil]; + NSRect windowFrame = [[self window] frame]; + windowFrame.size.width += windowDeltaSize.width; + windowFrame.size.height += windowDeltaSize.height; + [[self window] setFrame:windowFrame display:NO]; +} + +- (NSView*)viewForSearchEngine:(const TemplateURL*)engine + atIndex:(size_t)index { + bool useImages = false; +#if defined(GOOGLE_CHROME_BUILD) + useImages = true; +#endif + + // Make the engine identifier. + NSView* engineIdentifier = nil; // either the logo or the text label + + int logoId = engine->logo_id(); + if (useImages && logoId > 0) { + NSImage* logoImage = + ResourceBundle::GetSharedInstance().GetNativeImageNamed(logoId); + NSRect logoBounds = NSZeroRect; + logoBounds.size = [logoImage size]; + NSImageView* logoView = + [[[NSImageView alloc] initWithFrame:logoBounds] autorelease]; + [logoView setImage:logoImage]; + [logoView setEditable:NO]; + + // Tooltip text provides accessibility. + [logoView setToolTip:base::SysWideToNSString(engine->short_name())]; + engineIdentifier = logoView; + } else { + // No logo -- we must show a text label. + NSRect labelBounds = NSMakeRect(0, 0, kLogoLabelWidth, kLogoLabelHeight); + NSTextField* labelField = + [[[NSTextField alloc] initWithFrame:labelBounds] autorelease]; + [labelField setBezeled:NO]; + [labelField setEditable:NO]; + [labelField setSelectable:NO]; + + scoped_nsobject<NSMutableParagraphStyle> paragraphStyle( + [[NSMutableParagraphStyle alloc] init]); + [paragraphStyle setAlignment:NSCenterTextAlignment]; + NSDictionary* attrs = [NSDictionary dictionaryWithObjectsAndKeys: + [NSFont boldSystemFontOfSize:13], NSFontAttributeName, + paragraphStyle.get(), NSParagraphStyleAttributeName, + nil]; + + NSString* value = base::SysWideToNSString(engine->short_name()); + scoped_nsobject<NSAttributedString> attrValue( + [[NSAttributedString alloc] initWithString:value + attributes:attrs]); + + [labelField setAttributedStringValue:attrValue.get()]; + + engineIdentifier = labelField; + } + + // Make the "Choose" button. + scoped_nsobject<NSButton> chooseButton( + [[NSButton alloc] initWithFrame:NSMakeRect(0, 0, 100, 34)]); + [chooseButton setBezelStyle:NSRoundedBezelStyle]; + [[chooseButton cell] setFont:[NSFont systemFontOfSize: + [NSFont systemFontSizeForControlSize:NSRegularControlSize]]]; + [chooseButton setTitle:l10n_util::GetNSStringWithFixup(IDS_FR_SEARCH_CHOOSE)]; + [GTMUILocalizerAndLayoutTweaker sizeToFitView:chooseButton.get()]; + [chooseButton setTag:index]; + [chooseButton setTarget:self]; + [chooseButton setAction:@selector(searchEngineSelected:)]; + + // Put 'em together. + NSRect engineIdentifierFrame = [engineIdentifier frame]; + NSRect chooseButtonFrame = [chooseButton frame]; + + NSRect containingViewFrame = NSZeroRect; + containingViewFrame.size.width += engineIdentifierFrame.size.width; + containingViewFrame.size.height += engineIdentifierFrame.size.height; + containingViewFrame.size.height += kLogoButtonSpacing; + containingViewFrame.size.height += chooseButtonFrame.size.height; + + NSView* containingView = + [[[NSView alloc] initWithFrame:containingViewFrame] autorelease]; + + [containingView addSubview:engineIdentifier]; + engineIdentifierFrame.origin.y = + chooseButtonFrame.size.height + kLogoButtonSpacing; + [engineIdentifier setFrame:engineIdentifierFrame]; + + [containingView addSubview:chooseButton]; + chooseButtonFrame.origin.x = + int((containingViewFrame.size.width - chooseButtonFrame.size.width) / 2); + [chooseButton setFrame:chooseButtonFrame]; + + return containingView; +} + +- (NSFont*)mainLabelFont { + return [NSFont boldSystemFontOfSize:13]; +} + +- (IBAction)searchEngineSelected:(id)sender { + [[self window] close]; + [NSApp stopModalWithCode:[sender tag]]; +} + +@end diff --git a/chrome/browser/ui/cocoa/search_engine_list_model.h b/chrome/browser/ui/cocoa/search_engine_list_model.h new file mode 100644 index 0000000..f42fe35 --- /dev/null +++ b/chrome/browser/ui/cocoa/search_engine_list_model.h @@ -0,0 +1,48 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_SEARCH_ENGINE_LIST_MODEL_H_ +#define CHROME_BROWSER_UI_COCOA_SEARCH_ENGINE_LIST_MODEL_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#include "base/scoped_ptr.h" + +class TemplateURLModel; +class SearchEngineObserver; + +// The model for the "default search engine" combobox in preferences. Bridges +// between the cross-platform TemplateURLModel and Cocoa while watching for +// changes to the cross-platform model. + +@interface SearchEngineListModel : NSObject { + @private + TemplateURLModel* model_; // weak, owned by Profile + scoped_ptr<SearchEngineObserver> observer_; // watches for model changes + scoped_nsobject<NSArray> engines_; +} + +// Initialize with the given template model. +- (id)initWithModel:(TemplateURLModel*)model; + +// Returns an array of NSString's corresponding to the user-visible names of the +// search engines. +- (NSArray*)searchEngines; + +// The index into |-searchEngines| of the current default search engine. If +// there is no default search engine, the value is -1. The setter changes the +// back-end preference. +- (NSInteger)defaultIndex; +- (void)setDefaultIndex:(NSInteger)index; +// Return TRUE if the default is managed via policy. +- (BOOL)isDefaultManaged; +@end + +// Broadcast when the cross-platform model changes. This can be used to update +// any view state that may rely on the position of items in the list. +extern NSString* const kSearchEngineListModelChangedNotification; + +#endif // CHROME_BROWSER_UI_COCOA_SEARCH_ENGINE_LIST_MODEL_H_ diff --git a/chrome/browser/ui/cocoa/search_engine_list_model.mm b/chrome/browser/ui/cocoa/search_engine_list_model.mm new file mode 100644 index 0000000..b79c882 --- /dev/null +++ b/chrome/browser/ui/cocoa/search_engine_list_model.mm @@ -0,0 +1,136 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/search_engine_list_model.h" + +#include "base/sys_string_conversions.h" +#include "chrome/browser/search_engines/template_url.h" +#include "chrome/browser/search_engines/template_url_model.h" +#include "chrome/browser/search_engines/template_url_model_observer.h" + +NSString* const kSearchEngineListModelChangedNotification = + @"kSearchEngineListModelChangedNotification"; + +@interface SearchEngineListModel(Private) +- (void)buildEngineList; +@end + +// C++ bridge from TemplateURLModel to our Obj-C model. When it's told about +// model changes, notifies us to rebuild the list. +class SearchEngineObserver : public TemplateURLModelObserver { + public: + SearchEngineObserver(SearchEngineListModel* notify) + : notify_(notify) { } + virtual ~SearchEngineObserver() { }; + + private: + // TemplateURLModelObserver methods. + virtual void OnTemplateURLModelChanged() { [notify_ buildEngineList]; } + + SearchEngineListModel* notify_; // weak, owns us +}; + +@implementation SearchEngineListModel + +// The windows code allows for a NULL |model| and checks for it throughout +// the code, though I'm not sure why. We follow suit. +- (id)initWithModel:(TemplateURLModel*)model { + if ((self = [super init])) { + model_ = model; + if (model_) { + observer_.reset(new SearchEngineObserver(self)); + model_->Load(); + model_->AddObserver(observer_.get()); + [self buildEngineList]; + } + } + return self; +} + +- (void)dealloc { + if (model_) + model_->RemoveObserver(observer_.get()); + [super dealloc]; +} + +// Returns an array of NSString's corresponding to the user-visible names of the +// search engines. +- (NSArray*)searchEngines { + return engines_.get(); +} + +- (void)setSearchEngines:(NSArray*)engines { + engines_.reset([engines retain]); + + // Tell anyone who's listening that something has changed so they need to + // adjust the UI. + [[NSNotificationCenter defaultCenter] + postNotificationName:kSearchEngineListModelChangedNotification + object:nil]; +} + +// Walks the model and builds an array of NSStrings to display to the user. +// Assumes there is a non-NULL model. +- (void)buildEngineList { + scoped_nsobject<NSMutableArray> engines([[NSMutableArray alloc] init]); + + typedef std::vector<const TemplateURL*> TemplateURLs; + TemplateURLs modelURLs = model_->GetTemplateURLs(); + for (size_t i = 0; i < modelURLs.size(); ++i) { + if (modelURLs[i]->ShowInDefaultList()) + [engines addObject:base::SysWideToNSString(modelURLs[i]->short_name())]; + } + + [self setSearchEngines:engines.get()]; +} + +// The index into |-searchEngines| of the current default search engine. +// -1 if there is no default. +- (NSInteger)defaultIndex { + if (!model_) return -1; + + NSInteger index = 0; + const TemplateURL* defaultSearchProvider = model_->GetDefaultSearchProvider(); + if (defaultSearchProvider) { + typedef std::vector<const TemplateURL*> TemplateURLs; + TemplateURLs urls = model_->GetTemplateURLs(); + for (std::vector<const TemplateURL*>::iterator it = urls.begin(); + it != urls.end(); ++it) { + const TemplateURL* url = *it; + // Skip all the URLs not shown on the default list. + if (!url->ShowInDefaultList()) + continue; + if (url->id() == defaultSearchProvider->id()) + return index; + ++index; + } + } + return -1; +} + +- (void)setDefaultIndex:(NSInteger)index { + if (model_) { + typedef std::vector<const TemplateURL*> TemplateURLs; + TemplateURLs urls = model_->GetTemplateURLs(); + for (std::vector<const TemplateURL*>::iterator it = urls.begin(); + it != urls.end(); ++it) { + const TemplateURL* url = *it; + // Skip all the URLs not shown on the default list. + if (!url->ShowInDefaultList()) + continue; + if (0 == index) { + model_->SetDefaultSearchProvider(url); + return; + } + --index; + } + DCHECK(false); + } +} + +// Return TRUE if the default is managed via policy. +- (BOOL)isDefaultManaged { + return model_->is_default_search_managed(); +} +@end diff --git a/chrome/browser/ui/cocoa/search_engine_list_model_unittest.mm b/chrome/browser/ui/cocoa/search_engine_list_model_unittest.mm new file mode 100644 index 0000000..c95c75c --- /dev/null +++ b/chrome/browser/ui/cocoa/search_engine_list_model_unittest.mm @@ -0,0 +1,152 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/scoped_nsobject.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/search_engines/template_url.h" +#include "chrome/browser/search_engines/template_url_model.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#import "chrome/browser/ui/cocoa/search_engine_list_model.h" +#include "testing/gtest/include/gtest/gtest.h" +#import "testing/gtest_mac.h" +#include "testing/platform_test.h" + +// A helper for NSNotifications. Makes a note that it's been called back. +@interface SearchEngineListHelper : NSObject { + @public + BOOL sawNotification_; +} +@end + +@implementation SearchEngineListHelper +- (void)entryChanged:(NSNotification*)notify { + sawNotification_ = YES; +} +@end + +class SearchEngineListModelTest : public PlatformTest { + public: + SearchEngineListModelTest() { + // Build a fake set of template urls. + template_model_.reset(new TemplateURLModel(helper_.profile())); + TemplateURL* t_url = new TemplateURL(); + t_url->SetURL("http://www.google.com/?q={searchTerms}", 0, 0); + t_url->set_keyword(L"keyword"); + t_url->set_short_name(L"google"); + t_url->set_show_in_default_list(true); + template_model_->Add(t_url); + t_url = new TemplateURL(); + t_url->SetURL("http://www.google2.com/?q={searchTerms}", 0, 0); + t_url->set_keyword(L"keyword2"); + t_url->set_short_name(L"google2"); + t_url->set_show_in_default_list(true); + template_model_->Add(t_url); + EXPECT_EQ(template_model_->GetTemplateURLs().size(), 2U); + + model_.reset([[SearchEngineListModel alloc] + initWithModel:template_model_.get()]); + notification_helper_.reset([[SearchEngineListHelper alloc] init]); + [[NSNotificationCenter defaultCenter] + addObserver:notification_helper_.get() + selector:@selector(entryChanged:) + name:kSearchEngineListModelChangedNotification + object:nil]; + } + ~SearchEngineListModelTest() { + [[NSNotificationCenter defaultCenter] + removeObserver:notification_helper_.get()]; + } + + BrowserTestHelper helper_; + scoped_ptr<TemplateURLModel> template_model_; + scoped_nsobject<SearchEngineListModel> model_; + scoped_nsobject<SearchEngineListHelper> notification_helper_; +}; + +TEST_F(SearchEngineListModelTest, Init) { + scoped_nsobject<SearchEngineListModel> model( + [[SearchEngineListModel alloc] initWithModel:template_model_.get()]); +} + +TEST_F(SearchEngineListModelTest, Engines) { + NSArray* engines = [model_ searchEngines]; + EXPECT_EQ([engines count], 2U); +} + +TEST_F(SearchEngineListModelTest, Default) { + EXPECT_EQ([model_ defaultIndex], -1); + + [model_ setDefaultIndex:1]; + EXPECT_EQ([model_ defaultIndex], 1); + + // Add two more URLs, neither of which are shown in the default list. + TemplateURL* t_url = new TemplateURL(); + t_url->SetURL("http://www.google3.com/?q={searchTerms}", 0, 0); + t_url->set_keyword(L"keyword3"); + t_url->set_short_name(L"google3 not eligible"); + t_url->set_show_in_default_list(false); + template_model_->Add(t_url); + t_url = new TemplateURL(); + t_url->SetURL("http://www.google4.com/?q={searchTerms}", 0, 0); + t_url->set_keyword(L"keyword4"); + t_url->set_short_name(L"google4"); + t_url->set_show_in_default_list(false); + template_model_->Add(t_url); + + // Still should only have 2 engines and not these newly added ones. + EXPECT_EQ([[model_ searchEngines] count], 2U); + + // Since keyword3 is not in the default list, the 2nd index in the default + // keyword list should be keyword4. Test for http://crbug.com/21898. + template_model_->SetDefaultSearchProvider(t_url); + EXPECT_EQ([[model_ searchEngines] count], 3U); + EXPECT_EQ([model_ defaultIndex], 2); + + NSString* defaultString = [[model_ searchEngines] objectAtIndex:2]; + EXPECT_NSEQ(@"google4", defaultString); +} + +TEST_F(SearchEngineListModelTest, DefaultChosenFromUI) { + EXPECT_EQ([model_ defaultIndex], -1); + + [model_ setDefaultIndex:1]; + EXPECT_EQ([model_ defaultIndex], 1); + + // Add two more URLs, the first one not shown in the default list. + TemplateURL* t_url = new TemplateURL(); + t_url->SetURL("http://www.google3.com/?q={searchTerms}", 0, 0); + t_url->set_keyword(L"keyword3"); + t_url->set_short_name(L"google3 not eligible"); + t_url->set_show_in_default_list(false); + template_model_->Add(t_url); + t_url = new TemplateURL(); + t_url->SetURL("http://www.google4.com/?q={searchTerms}", 0, 0); + t_url->set_keyword(L"keyword4"); + t_url->set_short_name(L"google4"); + t_url->set_show_in_default_list(true); + template_model_->Add(t_url); + + // We should have 3 engines. + EXPECT_EQ([[model_ searchEngines] count], 3U); + + // Simulate the UI setting the default to the third entry. + [model_ setDefaultIndex:2]; + EXPECT_EQ([model_ defaultIndex], 2); + + // The default search provider should be google4. + EXPECT_EQ(template_model_->GetDefaultSearchProvider(), t_url); +} + +// Make sure that when the back-end model changes that we get a notification. +TEST_F(SearchEngineListModelTest, Notification) { + // Add one more item to force a notification. + TemplateURL* t_url = new TemplateURL(); + t_url->SetURL("http://www.google3.com/foo/bar", 0, 0); + t_url->set_keyword(L"keyword3"); + t_url->set_short_name(L"google3"); + t_url->set_show_in_default_list(true); + template_model_->Add(t_url); + + EXPECT_TRUE(notification_helper_.get()->sawNotification_); +} diff --git a/chrome/browser/ui/cocoa/shell_dialogs_mac.mm b/chrome/browser/ui/cocoa/shell_dialogs_mac.mm new file mode 100644 index 0000000..8dcaebed6 --- /dev/null +++ b/chrome/browser/ui/cocoa/shell_dialogs_mac.mm @@ -0,0 +1,417 @@ +// Copyright (c) 2006-2008 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/shell_dialogs.h" + +#import <Cocoa/Cocoa.h> +#include <CoreServices/CoreServices.h> + +#include <map> +#include <set> +#include <vector> + +#include "app/l10n_util_mac.h" +#import "base/cocoa_protocols_mac.h" +#include "base/file_util.h" +#include "base/logging.h" +#include "base/mac_util.h" +#include "base/mac/scoped_cftyperef.h" +#import "base/scoped_nsobject.h" +#include "base/sys_string_conversions.h" +#include "base/thread_restrictions.h" +#include "grit/generated_resources.h" + +static const int kFileTypePopupTag = 1234; + +class SelectFileDialogImpl; + +// A bridge class to act as the modal delegate to the save/open sheet and send +// the results to the C++ class. +@interface SelectFileDialogBridge : NSObject<NSOpenSavePanelDelegate> { + @private + SelectFileDialogImpl* selectFileDialogImpl_; // WEAK; owns us +} + +- (id)initWithSelectFileDialogImpl:(SelectFileDialogImpl*)s; +- (void)endedPanel:(NSSavePanel*)panel + withReturn:(int)returnCode + context:(void *)context; + +// NSSavePanel delegate method +- (BOOL)panel:(id)sender shouldShowFilename:(NSString *)filename; + +@end + +// Implementation of SelectFileDialog that shows Cocoa dialogs for choosing a +// file or folder. +class SelectFileDialogImpl : public SelectFileDialog { + public: + explicit SelectFileDialogImpl(Listener* listener); + virtual ~SelectFileDialogImpl(); + + // BaseShellDialog implementation. + virtual bool IsRunning(gfx::NativeWindow parent_window) const; + virtual void ListenerDestroyed(); + + // SelectFileDialog implementation. + // |params| is user data we pass back via the Listener interface. + virtual void SelectFile(Type type, + const string16& title, + const FilePath& default_path, + const FileTypeInfo* file_types, + int file_type_index, + const FilePath::StringType& default_extension, + gfx::NativeWindow owning_window, + void* params); + + // Callback from ObjC bridge. + void FileWasSelected(NSSavePanel* dialog, + NSWindow* parent_window, + bool was_cancelled, + bool is_multi, + const std::vector<FilePath>& files, + int index); + + bool ShouldEnableFilename(NSSavePanel* dialog, NSString* filename); + + struct SheetContext { + Type type; + NSWindow* owning_window; + }; + + private: + // Gets the accessory view for the save dialog. + NSView* GetAccessoryView(const FileTypeInfo* file_types, + int file_type_index); + + // The listener to be notified of selection completion. + Listener* listener_; + + // The bridge for results from Cocoa to return to us. + scoped_nsobject<SelectFileDialogBridge> bridge_; + + // A map from file dialogs to the |params| user data associated with them. + std::map<NSSavePanel*, void*> params_map_; + + // The set of all parent windows for which we are currently running dialogs. + std::set<NSWindow*> parents_; + + // A map from file dialogs to their types. + std::map<NSSavePanel*, Type> type_map_; + + DISALLOW_COPY_AND_ASSIGN(SelectFileDialogImpl); +}; + +// static +SelectFileDialog* SelectFileDialog::Create(Listener* listener) { + return new SelectFileDialogImpl(listener); +} + +SelectFileDialogImpl::SelectFileDialogImpl(Listener* listener) + : listener_(listener), + bridge_([[SelectFileDialogBridge alloc] + initWithSelectFileDialogImpl:this]) { +} + +SelectFileDialogImpl::~SelectFileDialogImpl() { + // Walk through the open dialogs and close them all. Use a temporary vector + // to hold the pointers, since we can't delete from the map as we're iterating + // through it. + std::vector<NSSavePanel*> panels; + for (std::map<NSSavePanel*, void*>::iterator it = params_map_.begin(); + it != params_map_.end(); ++it) { + panels.push_back(it->first); + } + + for (std::vector<NSSavePanel*>::iterator it = panels.begin(); + it != panels.end(); ++it) { + [(*it) cancel:nil]; + } +} + +bool SelectFileDialogImpl::IsRunning(gfx::NativeWindow parent_window) const { + return parents_.find(parent_window) != parents_.end(); +} + +void SelectFileDialogImpl::ListenerDestroyed() { + listener_ = NULL; +} + +void SelectFileDialogImpl::SelectFile( + Type type, + const string16& title, + const FilePath& default_path, + const FileTypeInfo* file_types, + int file_type_index, + const FilePath::StringType& default_extension, + gfx::NativeWindow owning_window, + void* params) { + DCHECK(type == SELECT_FOLDER || + type == SELECT_OPEN_FILE || + type == SELECT_OPEN_MULTI_FILE || + type == SELECT_SAVEAS_FILE); + parents_.insert(owning_window); + + // Note: we need to retain the dialog as owning_window can be null. + // (see http://crbug.com/29213) + NSSavePanel* dialog; + if (type == SELECT_SAVEAS_FILE) + dialog = [[NSSavePanel savePanel] retain]; + else + dialog = [[NSOpenPanel openPanel] retain]; + + if (!title.empty()) + [dialog setTitle:base::SysUTF16ToNSString(title)]; + + NSString* default_dir = nil; + NSString* default_filename = nil; + if (!default_path.empty()) { + // The file dialog is going to do a ton of stats anyway. Not much + // point in eliminating this one. + base::ThreadRestrictions::ScopedAllowIO allow_io; + if (file_util::DirectoryExists(default_path)) { + default_dir = base::SysUTF8ToNSString(default_path.value()); + } else { + default_dir = base::SysUTF8ToNSString(default_path.DirName().value()); + default_filename = + base::SysUTF8ToNSString(default_path.BaseName().value()); + } + } + + NSMutableArray* allowed_file_types = nil; + if (file_types) { + if (!file_types->extensions.empty()) { + allowed_file_types = [NSMutableArray array]; + for (size_t i=0; i < file_types->extensions.size(); ++i) { + const std::vector<FilePath::StringType>& ext_list = + file_types->extensions[i]; + for (size_t j=0; j < ext_list.size(); ++j) { + [allowed_file_types addObject:base::SysUTF8ToNSString(ext_list[j])]; + } + } + } + if (type == SELECT_SAVEAS_FILE) + [dialog setAllowedFileTypes:allowed_file_types]; + // else we'll pass it in when we run the open panel + + if (file_types->include_all_files) + [dialog setAllowsOtherFileTypes:YES]; + + if (!file_types->extension_description_overrides.empty()) { + NSView* accessory_view = GetAccessoryView(file_types, file_type_index); + [dialog setAccessoryView:accessory_view]; + } + } else { + // If no type info is specified, anything goes. + [dialog setAllowsOtherFileTypes:YES]; + } + + if (!default_extension.empty()) + [dialog setRequiredFileType:base::SysUTF8ToNSString(default_extension)]; + + params_map_[dialog] = params; + type_map_[dialog] = type; + + SheetContext* context = new SheetContext; + + // |context| should never be NULL, but we are seeing indications otherwise. + // |This CHECK is here to confirm if we are actually getting NULL + // ||context|s. http://crbug.com/58959 + CHECK(context); + context->type = type; + context->owning_window = owning_window; + + if (type == SELECT_SAVEAS_FILE) { + [dialog beginSheetForDirectory:default_dir + file:default_filename + modalForWindow:owning_window + modalDelegate:bridge_.get() + didEndSelector:@selector(endedPanel:withReturn:context:) + contextInfo:context]; + } else { + NSOpenPanel* open_dialog = (NSOpenPanel*)dialog; + + if (type == SELECT_OPEN_MULTI_FILE) + [open_dialog setAllowsMultipleSelection:YES]; + else + [open_dialog setAllowsMultipleSelection:NO]; + + if (type == SELECT_FOLDER) { + [open_dialog setCanChooseFiles:NO]; + [open_dialog setCanChooseDirectories:YES]; + [open_dialog setCanCreateDirectories:YES]; + NSString *prompt = l10n_util::GetNSString(IDS_SELECT_FOLDER_BUTTON_TITLE); + [open_dialog setPrompt:prompt]; + } else { + [open_dialog setCanChooseFiles:YES]; + [open_dialog setCanChooseDirectories:NO]; + } + + [open_dialog setDelegate:bridge_.get()]; + [open_dialog beginSheetForDirectory:default_dir + file:default_filename + types:allowed_file_types + modalForWindow:owning_window + modalDelegate:bridge_.get() + didEndSelector:@selector(endedPanel:withReturn:context:) + contextInfo:context]; + } +} + +void SelectFileDialogImpl::FileWasSelected(NSSavePanel* dialog, + NSWindow* parent_window, + bool was_cancelled, + bool is_multi, + const std::vector<FilePath>& files, + int index) { + void* params = params_map_[dialog]; + params_map_.erase(dialog); + parents_.erase(parent_window); + type_map_.erase(dialog); + + if (!listener_) + return; + + if (was_cancelled) { + listener_->FileSelectionCanceled(params); + } else { + if (is_multi) { + listener_->MultiFilesSelected(files, params); + } else { + listener_->FileSelected(files[0], index, params); + } + } +} + +NSView* SelectFileDialogImpl::GetAccessoryView(const FileTypeInfo* file_types, + int file_type_index) { + DCHECK(file_types); + scoped_nsobject<NSNib> nib ( + [[NSNib alloc] initWithNibNamed:@"SaveAccessoryView" + bundle:mac_util::MainAppBundle()]); + if (!nib) + return nil; + + NSArray* objects; + BOOL success = [nib instantiateNibWithOwner:nil + topLevelObjects:&objects]; + if (!success) + return nil; + [objects makeObjectsPerformSelector:@selector(release)]; + + // This is a one-object nib, but IB insists on creating a second object, the + // NSApplication. I don't know why. + size_t view_index = 0; + while (view_index < [objects count] && + ![[objects objectAtIndex:view_index] isKindOfClass:[NSView class]]) + ++view_index; + DCHECK(view_index < [objects count]); + NSView* accessory_view = [objects objectAtIndex:view_index]; + + NSPopUpButton* popup = [accessory_view viewWithTag:kFileTypePopupTag]; + DCHECK(popup); + + size_t type_count = file_types->extensions.size(); + for (size_t type = 0; type<type_count; ++type) { + NSString* type_description; + if (type < file_types->extension_description_overrides.size()) { + type_description = base::SysUTF16ToNSString( + file_types->extension_description_overrides[type]); + } else { + const std::vector<FilePath::StringType>& ext_list = + file_types->extensions[type]; + DCHECK(!ext_list.empty()); + NSString* type_extension = base::SysUTF8ToNSString(ext_list[0]); + base::mac::ScopedCFTypeRef<CFStringRef> uti( + UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, + (CFStringRef)type_extension, + NULL)); + base::mac::ScopedCFTypeRef<CFStringRef> description( + UTTypeCopyDescription(uti.get())); + + type_description = + [NSString stringWithString:(NSString*)description.get()]; + } + [popup addItemWithTitle:type_description]; + } + + [popup selectItemAtIndex:file_type_index-1]; // 1-based + return accessory_view; +} + +bool SelectFileDialogImpl::ShouldEnableFilename(NSSavePanel* dialog, + NSString* filename) { + // If this is a single open file dialog, disable selecting packages. + if (type_map_[dialog] != SELECT_OPEN_FILE) + return true; + + return ![[NSWorkspace sharedWorkspace] isFilePackageAtPath:filename]; +} + +@implementation SelectFileDialogBridge + +- (id)initWithSelectFileDialogImpl:(SelectFileDialogImpl*)s { + self = [super init]; + if (self != nil) { + selectFileDialogImpl_ = s; + } + return self; +} + +- (void)endedPanel:(NSSavePanel*)panel + withReturn:(int)returnCode + context:(void *)context { + // |context| should never be NULL, but we are seeing indications otherwise. + // |This CHECK is here to confirm if we are actually getting NULL + // ||context|s. http://crbug.com/58959 + CHECK(context); + + int index = 0; + SelectFileDialogImpl::SheetContext* context_struct = + (SelectFileDialogImpl::SheetContext*)context; + + SelectFileDialog::Type type = context_struct->type; + NSWindow* parentWindow = context_struct->owning_window; + delete context_struct; + + bool isMulti = type == SelectFileDialog::SELECT_OPEN_MULTI_FILE; + + std::vector<FilePath> paths; + bool did_cancel = returnCode == NSCancelButton; + if (!did_cancel) { + if (type == SelectFileDialog::SELECT_SAVEAS_FILE) { + paths.push_back(FilePath(base::SysNSStringToUTF8([panel filename]))); + + NSView* accessoryView = [panel accessoryView]; + if (accessoryView) { + NSPopUpButton* popup = [accessoryView viewWithTag:kFileTypePopupTag]; + if (popup) { + // File type indexes are 1-based. + index = [popup indexOfSelectedItem] + 1; + } + } else { + index = 1; + } + } else { + CHECK([panel isKindOfClass:[NSOpenPanel class]]); + NSArray* filenames = [static_cast<NSOpenPanel*>(panel) filenames]; + for (NSString* filename in filenames) + paths.push_back(FilePath(base::SysNSStringToUTF8(filename))); + } + } + + selectFileDialogImpl_->FileWasSelected(panel, + parentWindow, + did_cancel, + isMulti, + paths, + index); + [panel release]; +} + +- (BOOL)panel:(id)sender shouldShowFilename:(NSString *)filename { + return selectFileDialogImpl_->ShouldEnableFilename(sender, filename); +} + +@end diff --git a/chrome/browser/ui/cocoa/side_tab_strip_controller.h b/chrome/browser/ui/cocoa/side_tab_strip_controller.h new file mode 100644 index 0000000..07f5551 --- /dev/null +++ b/chrome/browser/ui/cocoa/side_tab_strip_controller.h @@ -0,0 +1,19 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#import "chrome/browser/ui/cocoa/tab_strip_controller.h" + +// A controller for the tab strip when side tabs are enabled. +// +// TODO(pinkerton): I'm expecting there are more things here that need +// overriding rather than just tweaking a couple of settings, so I'm creating +// a full-blown subclass. Clearly, very little is actually necessary at this +// point for it to work. + +@interface SideTabStripController : TabStripController { +} + +@end diff --git a/chrome/browser/ui/cocoa/side_tab_strip_controller.mm b/chrome/browser/ui/cocoa/side_tab_strip_controller.mm new file mode 100644 index 0000000..16a835f --- /dev/null +++ b/chrome/browser/ui/cocoa/side_tab_strip_controller.mm @@ -0,0 +1,33 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/side_tab_strip_controller.h" + +@implementation SideTabStripController + +// TODO(pinkerton): Still need to figure out several things: +// - new tab button placement and layout +// - animating tabs in and out +// - being able to drop a tab elsewhere besides the 1st position +// - how to load a different tab view nib for each tab. + +- (id)initWithView:(TabStripView*)view + switchView:(NSView*)switchView + browser:(Browser*)browser + delegate:(id<TabStripControllerDelegate>)delegate { + self = [super initWithView:view + switchView:switchView + browser:browser + delegate:delegate]; + if (self) { + // Side tabs have no indent since they are not sharing space with the + // window controls. + [self setIndentForControls:0.0]; + verticalLayout_ = YES; + } + return self; +} + +@end + diff --git a/chrome/browser/ui/cocoa/side_tab_strip_view.h b/chrome/browser/ui/cocoa/side_tab_strip_view.h new file mode 100644 index 0000000..9f8d056 --- /dev/null +++ b/chrome/browser/ui/cocoa/side_tab_strip_view.h @@ -0,0 +1,15 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#import "chrome/browser/ui/cocoa/tab_strip_view.h" + +// A class that handles drawing the background of the tab strip when side tabs +// are enabled. + +@interface SideTabStripView : TabStripView { +} + +@end diff --git a/chrome/browser/ui/cocoa/side_tab_strip_view.mm b/chrome/browser/ui/cocoa/side_tab_strip_view.mm new file mode 100644 index 0000000..2c60604 --- /dev/null +++ b/chrome/browser/ui/cocoa/side_tab_strip_view.mm @@ -0,0 +1,43 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/side_tab_strip_view.h" + +#include "base/scoped_nsobject.h" +#import "third_party/GTM/AppKit/GTMNSColor+Luminance.h" + +@implementation SideTabStripView + +- (void)drawBorder:(NSRect)bounds { + // Draw a border on the right side. + NSRect borderRect, contentRect; + NSDivideRect(bounds, &borderRect, &contentRect, 1, NSMaxXEdge); + [[NSColor colorWithCalibratedWhite:0.0 alpha:0.2] set]; + NSRectFillUsingOperation(borderRect, NSCompositeSourceOver); +} + +// Override to prevent double-clicks from minimizing the window. The side +// tab strip doesn't have that behavior (since it's in the window content +// area). +- (BOOL)doubleClickMinimizesWindow { + return NO; +} + +- (void)drawRect:(NSRect)rect { + // BOOL isKey = [[self window] isKeyWindow]; + NSColor* aColor = + [NSColor colorWithCalibratedRed:0.506 green:0.660 blue:0.985 alpha:1.000]; + NSColor* bColor = + [NSColor colorWithCalibratedRed:0.099 green:0.140 blue:0.254 alpha:1.000]; + scoped_nsobject<NSGradient> gradient( + [[NSGradient alloc] initWithStartingColor:aColor endingColor:bColor]); + + NSRect gradientRect = [self bounds]; + [gradient drawInRect:gradientRect angle:270.0]; + + // Draw borders and any drop feedback. + [super drawRect:rect]; +} + +@end diff --git a/chrome/browser/ui/cocoa/side_tab_strip_view_unittest.mm b/chrome/browser/ui/cocoa/side_tab_strip_view_unittest.mm new file mode 100644 index 0000000..ba7acc2 --- /dev/null +++ b/chrome/browser/ui/cocoa/side_tab_strip_view_unittest.mm @@ -0,0 +1,30 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/side_tab_strip_view.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +namespace { + +class SideTabStripViewTest : public CocoaTest { + public: + SideTabStripViewTest() { + NSRect frame = NSMakeRect(0, 0, 100, 30); + scoped_nsobject<SideTabStripView> view( + [[SideTabStripView alloc] initWithFrame:frame]); + view_ = view.get(); + [[test_window() contentView] addSubview:view_]; + } + + SideTabStripView* view_; +}; + +TEST_VIEW(SideTabStripViewTest, view_) + +} // namespace diff --git a/chrome/browser/ui/cocoa/sidebar_controller.h b/chrome/browser/ui/cocoa/sidebar_controller.h new file mode 100644 index 0000000..dcafddb --- /dev/null +++ b/chrome/browser/ui/cocoa/sidebar_controller.h @@ -0,0 +1,51 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_SIDEBAR_CONTROLLER_H_ +#define CHROME_BROWSER_UI_COCOA_SIDEBAR_CONTROLLER_H_ +#pragma once + +#import <Foundation/Foundation.h> + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/tab_contents_controller.h" + +@class NSSplitView; +@class NSView; + +class TabContents; + +// A class that handles updates of the sidebar view within a browser window. +// It swaps in the relevant sidebar contents for a given TabContents or removes +// the vew, if there's no sidebar contents to show. +@interface SidebarController : NSObject { + @private + // A view hosting sidebar contents. + scoped_nsobject<NSSplitView> splitView_; + + // Manages currently displayed sidebar contents. + scoped_nsobject<TabContentsController> contentsController_; +} + +- (id)initWithDelegate:(id<TabContentsControllerDelegate>)delegate; + +// This controller's view. +- (NSSplitView*)view; + +// The compiler seems to have trouble handling a function named "view" that +// returns an NSSplitView, so provide a differently-named method. +- (NSSplitView*)splitView; + +// Depending on |contents|'s state, decides whether the sidebar +// should be shown or hidden and adjusts its width (|delegate_| handles +// the actual resize). +- (void)updateSidebarForTabContents:(TabContents*)contents; + +// Call when the sidebar view is properly sized and the render widget host view +// should be put into the view hierarchy. +- (void)ensureContentsVisible; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_SIDEBAR_CONTROLLER_H_ diff --git a/chrome/browser/ui/cocoa/sidebar_controller.mm b/chrome/browser/ui/cocoa/sidebar_controller.mm new file mode 100644 index 0000000..f20deaa --- /dev/null +++ b/chrome/browser/ui/cocoa/sidebar_controller.mm @@ -0,0 +1,179 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/sidebar_controller.h" + +#include <algorithm> + +#include <Cocoa/Cocoa.h> + +#include "chrome/browser/browser_process.h" +#include "chrome/browser/prefs/pref_service.h" +#include "chrome/browser/sidebar/sidebar_manager.h" +#include "chrome/browser/tab_contents/tab_contents.h" +#include "chrome/browser/ui/browser.h" +#import "chrome/browser/ui/cocoa/view_id_util.h" +#include "chrome/common/pref_names.h" + +namespace { + +// By default sidebar width is 1/7th of the current page content width. +const CGFloat kDefaultSidebarWidthRatio = 1.0 / 7; + +// Never make the web part of the tab contents smaller than this (needed if the +// window is only a few pixels wide). +const int kMinWebWidth = 50; + +} // end namespace + + +@interface SidebarController (Private) +- (void)showSidebarContents:(TabContents*)sidebarContents; +- (void)resizeSidebarToNewWidth:(CGFloat)width; +@end + + +@implementation SidebarController + +- (id)initWithDelegate:(id<TabContentsControllerDelegate>)delegate { + if ((self = [super init])) { + splitView_.reset([[NSSplitView alloc] initWithFrame:NSZeroRect]); + [splitView_ setDividerStyle:NSSplitViewDividerStyleThin]; + [splitView_ setVertical:YES]; + [splitView_ setAutoresizingMask:NSViewWidthSizable|NSViewHeightSizable]; + [splitView_ setDelegate:self]; + + contentsController_.reset( + [[TabContentsController alloc] initWithContents:NULL + delegate:delegate]); + } + return self; +} + +- (void)dealloc { + [splitView_ setDelegate:nil]; + [super dealloc]; +} + +- (NSSplitView*)view { + return splitView_.get(); +} + +- (NSSplitView*)splitView { + return splitView_.get(); +} + +- (void)updateSidebarForTabContents:(TabContents*)contents { + // Get the active sidebar content. + if (SidebarManager::GetInstance() == NULL) // Happens in tests. + return; + + TabContents* sidebarContents = NULL; + if (contents && SidebarManager::IsSidebarAllowed()) { + SidebarContainer* activeSidebar = + SidebarManager::GetInstance()->GetActiveSidebarContainerFor(contents); + if (activeSidebar) + sidebarContents = activeSidebar->sidebar_contents(); + } + + TabContents* oldSidebarContents = [contentsController_ tabContents]; + if (oldSidebarContents == sidebarContents) + return; + + // Adjust sidebar view. + [self showSidebarContents:sidebarContents]; + + // Notify extensions. + SidebarManager::GetInstance()->NotifyStateChanges( + oldSidebarContents, sidebarContents); +} + +- (void)ensureContentsVisible { + [contentsController_ ensureContentsVisible]; +} + +- (void)showSidebarContents:(TabContents*)sidebarContents { + [contentsController_ ensureContentsSizeDoesNotChange]; + + NSArray* subviews = [splitView_ subviews]; + if (sidebarContents) { + DCHECK_GE([subviews count], 1u); + + // Native view is a TabContentsViewCocoa object, whose ViewID was + // set to VIEW_ID_TAB_CONTAINER initially, so change it to + // VIEW_ID_SIDE_BAR_CONTAINER here. + view_id_util::SetID( + sidebarContents->GetNativeView(), VIEW_ID_SIDE_BAR_CONTAINER); + + CGFloat sidebarWidth = 0; + if ([subviews count] == 1) { + // Load the default split offset. + sidebarWidth = g_browser_process->local_state()->GetInteger( + prefs::kExtensionSidebarWidth); + if (sidebarWidth < 0) { + // Initial load, set to default value. + sidebarWidth = + NSWidth([splitView_ frame]) * kDefaultSidebarWidthRatio; + } + [splitView_ addSubview:[contentsController_ view]]; + } else { + DCHECK_EQ([subviews count], 2u); + sidebarWidth = NSWidth([[subviews objectAtIndex:1] frame]); + } + + // Make sure |sidebarWidth| isn't too large or too small. + sidebarWidth = std::min(sidebarWidth, + NSWidth([splitView_ frame]) - kMinWebWidth); + DCHECK_GE(sidebarWidth, 0) << "kMinWebWidth needs to be smaller than " + << "smallest available tab contents space."; + sidebarWidth = std::max(static_cast<CGFloat>(0), sidebarWidth); + + [self resizeSidebarToNewWidth:sidebarWidth]; + } else { + if ([subviews count] > 1) { + NSView* oldSidebarContentsView = [subviews objectAtIndex:1]; + // Store split offset when hiding sidebar window only. + int sidebarWidth = NSWidth([oldSidebarContentsView frame]); + g_browser_process->local_state()->SetInteger( + prefs::kExtensionSidebarWidth, sidebarWidth); + [oldSidebarContentsView removeFromSuperview]; + [splitView_ adjustSubviews]; + } + } + + [contentsController_ changeTabContents:sidebarContents]; +} + +- (void)resizeSidebarToNewWidth:(CGFloat)width { + NSArray* subviews = [splitView_ subviews]; + + // It seems as if |-setPosition:ofDividerAtIndex:| should do what's needed, + // but I can't figure out how to use it. Manually resize web and sidebar. + // TODO(alekseys): either make setPosition:ofDividerAtIndex: work or to add a + // category on NSSplitView to handle manual resizing. + NSView* sidebarView = [subviews objectAtIndex:1]; + NSRect sidebarFrame = [sidebarView frame]; + sidebarFrame.size.width = width; + [sidebarView setFrame:sidebarFrame]; + + NSView* webView = [subviews objectAtIndex:0]; + NSRect webFrame = [webView frame]; + webFrame.size.width = + NSWidth([splitView_ frame]) - ([splitView_ dividerThickness] + width); + [webView setFrame:webFrame]; + + [splitView_ adjustSubviews]; +} + +// NSSplitViewDelegate protocol. +- (BOOL)splitView:(NSSplitView *)splitView + shouldAdjustSizeOfSubview:(NSView *)subview { + // Return NO for the sidebar view to indicate that it should not be resized + // automatically. The sidebar keeps the width set by the user. + if ([[splitView_ subviews] indexOfObject:subview] == 1) + return NO; + return YES; +} + +@end diff --git a/chrome/browser/ui/cocoa/simple_content_exceptions_window_controller.h b/chrome/browser/ui/cocoa/simple_content_exceptions_window_controller.h new file mode 100644 index 0000000..13daf99 --- /dev/null +++ b/chrome/browser/ui/cocoa/simple_content_exceptions_window_controller.h @@ -0,0 +1,38 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/cocoa_protocols_mac.h" +#include "base/scoped_ptr.h" +#include "chrome/browser/remove_rows_table_model.h" +#import "chrome/browser/ui/cocoa/table_model_array_controller.h" + +class RemoveRowsObserverBridge; + +// Controller for the geolocation exception dialog. +@interface SimpleContentExceptionsWindowController : NSWindowController + <NSWindowDelegate> { + @private + IBOutlet NSTableView* tableView_; + IBOutlet NSButton* removeButton_; + IBOutlet NSButton* removeAllButton_; + IBOutlet NSButton* doneButton_; + IBOutlet TableModelArrayController* arrayController_; + + scoped_ptr<RemoveRowsTableModel> model_; +} + +// Shows or makes frontmost the exceptions window. +// Changes made by the user in the window are persisted in |model|. +// Takes ownership of |model|. ++ (id)controllerWithTableModel:(RemoveRowsTableModel*)model; + +// Sets the minimum width of the sheet and resizes it if necessary. +- (void)setMinWidth:(CGFloat)minWidth; + +- (void)attachSheetTo:(NSWindow*)window; +- (IBAction)closeSheet:(id)sender; + +@end diff --git a/chrome/browser/ui/cocoa/simple_content_exceptions_window_controller.mm b/chrome/browser/ui/cocoa/simple_content_exceptions_window_controller.mm new file mode 100644 index 0000000..7beac9c --- /dev/null +++ b/chrome/browser/ui/cocoa/simple_content_exceptions_window_controller.mm @@ -0,0 +1,125 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/simple_content_exceptions_window_controller.h" + +#include "app/l10n_util_mac.h" +#include "app/table_model_observer.h" +#include "base/logging.h" +#import "base/mac_util.h" +#import "base/scoped_nsobject.h" +#include "base/sys_string_conversions.h" +#include "grit/generated_resources.h" +#include "third_party/GTM/AppKit/GTMUILocalizerAndLayoutTweaker.h" + +@interface SimpleContentExceptionsWindowController (Private) +- (id)initWithTableModel:(RemoveRowsTableModel*)model; +@end + +namespace { + +const CGFloat kButtonBarHeight = 35.0; + +SimpleContentExceptionsWindowController* g_exceptionWindow = nil; + +} // namespace + +@implementation SimpleContentExceptionsWindowController + ++ (id)controllerWithTableModel:(RemoveRowsTableModel*)model { + if (!g_exceptionWindow) { + g_exceptionWindow = [[SimpleContentExceptionsWindowController alloc] + initWithTableModel:model]; + } + return g_exceptionWindow; +} + +- (id)initWithTableModel:(RemoveRowsTableModel*)model { + NSString* nibpath = [mac_util::MainAppBundle() + pathForResource:@"SimpleContentExceptionsWindow" + ofType:@"nib"]; + if ((self = [super initWithWindowNibPath:nibpath owner:self])) { + model_.reset(model); + + // TODO(thakis): autoremember window rect. + // TODO(thakis): sorting support. + } + return self; +} + +- (void)awakeFromNib { + DCHECK([self window]); + DCHECK_EQ(self, [[self window] delegate]); + DCHECK(tableView_); + DCHECK(arrayController_); + + CGFloat minWidth = [[removeButton_ superview] bounds].size.width + + [[doneButton_ superview] bounds].size.width; + [[self window] setMinSize:NSMakeSize(minWidth, + [[self window] minSize].height)]; + NSDictionary* columns = [NSDictionary dictionaryWithObjectsAndKeys: + [NSNumber numberWithInt:IDS_EXCEPTIONS_HOSTNAME_HEADER], @"hostname", + [NSNumber numberWithInt:IDS_EXCEPTIONS_ACTION_HEADER], @"action", + nil]; + [arrayController_ bindToTableModel:model_.get() + withColumns:columns + groupTitleColumn:@"hostname"]; +} + +- (void)setMinWidth:(CGFloat)minWidth { + NSWindow* window = [self window]; + [window setMinSize:NSMakeSize(minWidth, [window minSize].height)]; + if ([window frame].size.width < minWidth) { + NSRect frame = [window frame]; + frame.size.width = minWidth; + [window setFrame:frame display:NO]; + } +} + +- (void)windowWillClose:(NSNotification*)notification { + g_exceptionWindow = nil; + [self autorelease]; +} + +// Let esc close the window. +- (void)cancel:(id)sender { + [self closeSheet:self]; +} + +- (void)keyDown:(NSEvent*)event { + NSString* chars = [event charactersIgnoringModifiers]; + if ([chars length] == 1) { + switch ([chars characterAtIndex:0]) { + case NSDeleteCharacter: + case NSDeleteFunctionKey: + // Delete deletes. + if ([[tableView_ selectedRowIndexes] count] > 0) + [arrayController_ remove:event]; + return; + } + } + [super keyDown:event]; +} + +- (void)attachSheetTo:(NSWindow*)window { + [NSApp beginSheet:[self window] + modalForWindow:window + modalDelegate:self + didEndSelector:@selector(sheetDidEnd:returnCode:contextInfo:) + contextInfo:nil]; +} + +- (void)sheetDidEnd:(NSWindow*)sheet + returnCode:(NSInteger)returnCode + contextInfo:(void*)context { + [sheet close]; + [sheet orderOut:self]; +} + +- (IBAction)closeSheet:(id)sender { + [NSApp endSheet:[self window]]; +} + + +@end diff --git a/chrome/browser/ui/cocoa/simple_content_exceptions_window_controller_unittest.mm b/chrome/browser/ui/cocoa/simple_content_exceptions_window_controller_unittest.mm new file mode 100644 index 0000000..58d1c84 --- /dev/null +++ b/chrome/browser/ui/cocoa/simple_content_exceptions_window_controller_unittest.mm @@ -0,0 +1,94 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/simple_content_exceptions_window_controller.h" + +#import <Cocoa/Cocoa.h> + +#import "base/scoped_nsobject.h" +#include "base/ref_counted.h" +#include "chrome/browser/content_settings/host_content_settings_map.h" +#include "chrome/browser/geolocation/geolocation_exceptions_table_model.h" +#include "chrome/browser/plugin_exceptions_table_model.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#include "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +@interface SimpleContentExceptionsWindowController (Testing) + +@property(readonly, nonatomic) TableModelArrayController* arrayController; + +@end + +@implementation SimpleContentExceptionsWindowController (Testing) + +- (TableModelArrayController*)arrayController { + return arrayController_; +} + +@end + + +namespace { + +class SimpleContentExceptionsWindowControllerTest : public CocoaTest { + public: + virtual void SetUp() { + CocoaTest::SetUp(); + TestingProfile* profile = browser_helper_.profile(); + geolocation_settings_ = new GeolocationContentSettingsMap(profile); + content_settings_ = new HostContentSettingsMap(profile); + } + + SimpleContentExceptionsWindowController* GetController() { + GeolocationExceptionsTableModel* model = // Freed by window controller. + new GeolocationExceptionsTableModel(geolocation_settings_.get()); + id controller = [SimpleContentExceptionsWindowController + controllerWithTableModel:model]; + [controller showWindow:nil]; + return controller; + } + + void ClickRemoveAll(SimpleContentExceptionsWindowController* controller) { + [controller.arrayController removeAll:nil]; + } + + protected: + BrowserTestHelper browser_helper_; + scoped_refptr<GeolocationContentSettingsMap> geolocation_settings_; + scoped_refptr<HostContentSettingsMap> content_settings_; +}; + +TEST_F(SimpleContentExceptionsWindowControllerTest, Construction) { + GeolocationExceptionsTableModel* model = // Freed by window controller. + new GeolocationExceptionsTableModel(geolocation_settings_.get()); + SimpleContentExceptionsWindowController* controller = + [SimpleContentExceptionsWindowController controllerWithTableModel:model]; + [controller showWindow:nil]; + [controller close]; // Should autorelease. +} + +TEST_F(SimpleContentExceptionsWindowControllerTest, ShowPluginExceptions) { + PluginExceptionsTableModel* model = // Freed by window controller. + new PluginExceptionsTableModel(content_settings_.get(), NULL); + SimpleContentExceptionsWindowController* controller = + [SimpleContentExceptionsWindowController controllerWithTableModel:model]; + [controller showWindow:nil]; + [controller close]; // Should autorelease. +} + +TEST_F(SimpleContentExceptionsWindowControllerTest, AddExistingEditAdd) { + geolocation_settings_->SetContentSetting( + GURL("http://myhost"), GURL(), CONTENT_SETTING_BLOCK); + + SimpleContentExceptionsWindowController* controller = GetController(); + ClickRemoveAll(controller); + + [controller close]; + + EXPECT_EQ(0u, geolocation_settings_->GetAllOriginsSettings().size()); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/speech_input_window_controller.h b/chrome/browser/ui/cocoa/speech_input_window_controller.h new file mode 100644 index 0000000..d68a30c --- /dev/null +++ b/chrome/browser/ui/cocoa/speech_input_window_controller.h @@ -0,0 +1,57 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_SPEECH_INPUT_WINDOW_CONTROLLER_H_ +#define CHROME_BROWSER_UI_COCOA_SPEECH_INPUT_WINDOW_CONTROLLER_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "chrome/browser/speech/speech_input_bubble.h" +#include "chrome/browser/ui/cocoa/base_bubble_controller.h" + +// Controller for the speech input bubble window. This bubble window gets +// displayed when the user starts speech input in a html input element. +@interface SpeechInputWindowController : BaseBubbleController { + @private + SpeechInputBubble::Delegate* delegate_; // weak. + + // References below are weak, being obtained from the nib. + IBOutlet NSImageView* iconImage_; + IBOutlet NSTextField* instructionLabel_; + IBOutlet NSButton* cancelButton_; + IBOutlet NSButton* tryAgainButton_; +} + +// Initialize the window. |anchoredAt| is in screen coordinates. +- (id)initWithParentWindow:(NSWindow*)parentWindow + delegate:(SpeechInputBubbleDelegate*)delegate + anchoredAt:(NSPoint)anchoredAt; + +// Handler for the cancel button. +- (IBAction)cancel:(id)sender; + +// Handler for the try again button. +- (IBAction)tryAgain:(id)sender; + +// Updates the UI with data related to the given display mode. +- (void)updateLayout:(SpeechInputBubbleBase::DisplayMode)mode + messageText:(const string16&)messageText; + +// Makes the speech input bubble visible on screen. +- (void)show; + +// Hides the speech input bubble away from screen. This does NOT release the +// controller and the window. +- (void)hide; + +// Sets the image to be displayed in the bubble's status ImageView. A future +// call to updateLayout may change the image. +// TODO(satish): Clean that up and move it into the platform independent +// SpeechInputBubbleBase class. +- (void)setImage:(NSImage*)image; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_SPEECH_INPUT_WINDOW_CONTROLLER_H_ diff --git a/chrome/browser/ui/cocoa/speech_input_window_controller.mm b/chrome/browser/ui/cocoa/speech_input_window_controller.mm new file mode 100644 index 0000000..c490bc1 --- /dev/null +++ b/chrome/browser/ui/cocoa/speech_input_window_controller.mm @@ -0,0 +1,188 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "speech_input_window_controller.h" + +#include "app/l10n_util_mac.h" +#include "app/resource_bundle.h" +#include "base/logging.h" +#include "base/mac_util.h" +#include "base/sys_string_conversions.h" + +#include "chrome/browser/ui/cocoa/info_bubble_view.h" +#include "grit/generated_resources.h" +#include "grit/theme_resources.h" +#import "skia/ext/skia_utils_mac.h" +#import "third_party/GTM/AppKit/GTMUILocalizerAndLayoutTweaker.h" + +const int kBubbleControlVerticalSpacing = 10; // Space between controls. +const int kBubbleHorizontalMargin = 5; // Space on either sides of controls. + +@interface SpeechInputWindowController (Private) +- (NSSize)calculateContentSize; +- (void)layout:(NSSize)size; +@end + +@implementation SpeechInputWindowController + +- (id)initWithParentWindow:(NSWindow*)parentWindow + delegate:(SpeechInputBubbleDelegate*)delegate + anchoredAt:(NSPoint)anchoredAt { + anchoredAt.y += info_bubble::kBubbleArrowHeight / 2.0; + if ((self = [super initWithWindowNibPath:@"SpeechInputBubble" + parentWindow:parentWindow + anchoredAt:anchoredAt])) { + DCHECK(delegate); + delegate_ = delegate; + + [self showWindow:nil]; + } + return self; +} + +- (void)awakeFromNib { + [super awakeFromNib]; + + NSWindow* window = [self window]; + [[self bubble] setArrowLocation:info_bubble::kTopLeft]; + + NSSize newSize = [self calculateContentSize]; + [[self bubble] setFrameSize:newSize]; + NSSize windowDelta = NSMakeSize( + newSize.width - NSWidth([[window contentView] bounds]), + newSize.height - NSHeight([[window contentView] bounds])); + windowDelta = [[window contentView] convertSize:windowDelta toView:nil]; + NSRect newFrame = [window frame]; + newFrame.size.width += windowDelta.width; + newFrame.size.height += windowDelta.height; + [window setFrame:newFrame display:NO]; + + [self layout:newSize]; // Layout all the child controls. +} + +- (IBAction)cancel:(id)sender { + delegate_->InfoBubbleButtonClicked(SpeechInputBubble::BUTTON_CANCEL); +} + +- (IBAction)tryAgain:(id)sender { + delegate_->InfoBubbleButtonClicked(SpeechInputBubble::BUTTON_TRY_AGAIN); +} + +// Calculate the window dimensions to reflect the sum height and max width of +// all controls, with appropriate spacing between and around them. The returned +// size is in view coordinates. +- (NSSize)calculateContentSize { + [GTMUILocalizerAndLayoutTweaker sizeToFitView:cancelButton_]; + [GTMUILocalizerAndLayoutTweaker sizeToFitView:tryAgainButton_]; + NSSize cancelSize = [cancelButton_ bounds].size; + NSSize tryAgainSize = [tryAgainButton_ bounds].size; + int newHeight = cancelSize.height + kBubbleControlVerticalSpacing; + int newWidth = cancelSize.width + tryAgainSize.width; + + if (![iconImage_ isHidden]) { + NSImage* icon = ResourceBundle::GetSharedInstance().GetNativeImageNamed( + IDR_SPEECH_INPUT_MIC_EMPTY); + NSSize size = [icon size]; + newHeight += size.height + kBubbleControlVerticalSpacing; + if (newWidth < size.width) + newWidth = size.width; + } else { + newHeight += kBubbleControlVerticalSpacing; + } + + [GTMUILocalizerAndLayoutTweaker sizeToFitFixedWidthTextField: + instructionLabel_]; + NSSize size = [instructionLabel_ bounds].size; + newHeight += size.height; + if (newWidth < size.width) + newWidth = size.width; + + return NSMakeSize(newWidth + 2 * kBubbleHorizontalMargin, + newHeight + 2 * kBubbleControlVerticalSpacing); +} + +// Position the controls within the given content area bounds. +- (void)layout:(NSSize)size { + int y = kBubbleControlVerticalSpacing; + + NSRect cancelRect = [cancelButton_ bounds]; + + if ([tryAgainButton_ isHidden]) { + cancelRect.origin.x = (size.width - NSWidth(cancelRect)) / 2; + } else { + NSRect tryAgainRect = [tryAgainButton_ bounds]; + cancelRect.origin.x = (size.width - NSWidth(cancelRect) - + NSWidth(tryAgainRect)) / 2; + tryAgainRect.origin.x = cancelRect.origin.x + NSWidth(cancelRect); + tryAgainRect.origin.y = y; + [tryAgainButton_ setFrame:tryAgainRect]; + } + cancelRect.origin.y = y; + [cancelButton_ setFrame:cancelRect]; + + y += NSHeight(cancelRect) + kBubbleControlVerticalSpacing; + + NSRect rect; + if (![iconImage_ isHidden]) { + rect = [iconImage_ bounds]; + rect.origin.x = (size.width - NSWidth(rect)) / 2; + rect.origin.y = y; + [iconImage_ setFrame:rect]; + y += rect.size.height + kBubbleControlVerticalSpacing; + } + + rect = [instructionLabel_ bounds]; + rect.origin.x = (size.width - NSWidth(rect)) / 2; + rect.origin.y = y; + [instructionLabel_ setFrame:rect]; +} + +- (void)updateLayout:(SpeechInputBubbleBase::DisplayMode)mode + messageText:(const string16&)messageText { + // Get the right set of controls to be visible. + if (mode == SpeechInputBubbleBase::DISPLAY_MODE_MESSAGE) { + [instructionLabel_ setStringValue:base::SysUTF16ToNSString(messageText)]; + [iconImage_ setHidden:YES]; + [tryAgainButton_ setHidden:NO]; + } else { + if (mode == SpeechInputBubbleBase::DISPLAY_MODE_RECORDING) { + [instructionLabel_ setStringValue:l10n_util::GetNSString( + IDS_SPEECH_INPUT_BUBBLE_HEADING)]; + NSImage* icon = ResourceBundle::GetSharedInstance().GetNativeImageNamed( + IDR_SPEECH_INPUT_MIC_EMPTY); + [iconImage_ setImage:icon]; + } else { + [instructionLabel_ setStringValue:l10n_util::GetNSString( + IDS_SPEECH_INPUT_BUBBLE_WORKING)]; + } + [iconImage_ setHidden:NO]; + [iconImage_ setNeedsDisplay:YES]; + [tryAgainButton_ setHidden:YES]; + } + + NSSize newSize = [self calculateContentSize]; + NSRect rect = [[self bubble] frame]; + rect.origin.y -= newSize.height - rect.size.height; + rect.size = newSize; + [[self bubble] setFrame:rect]; + [self layout:newSize]; +} + +- (void)windowWillClose:(NSNotification*)notification { + delegate_->InfoBubbleFocusChanged(); +} + +- (void)show { + [self showWindow:nil]; +} + +- (void)hide { + [[self window] orderOut:nil]; +} + +- (void)setImage:(NSImage*)image { + [iconImage_ setImage:image]; +} + +@end // implementation SpeechInputWindowController diff --git a/chrome/browser/ui/cocoa/ssl_client_certificate_selector.mm b/chrome/browser/ui/cocoa/ssl_client_certificate_selector.mm new file mode 100644 index 0000000..3f0a2f5 --- /dev/null +++ b/chrome/browser/ui/cocoa/ssl_client_certificate_selector.mm @@ -0,0 +1,195 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ssl_client_certificate_selector.h" + +#import <SecurityInterface/SFChooseIdentityPanel.h> + +#include <vector> + +#import "app/l10n_util_mac.h" +#include "base/logging.h" +#include "base/ref_counted.h" +#import "base/scoped_nsobject.h" +#include "base/string_util.h" +#include "base/sys_string_conversions.h" +#include "base/utf_string_conversions.h" +#include "chrome/browser/browser_thread.h" +#include "chrome/browser/ssl/ssl_client_auth_handler.h" +#include "chrome/browser/tab_contents/tab_contents.h" +#import "chrome/browser/ui/cocoa/constrained_window_mac.h" +#include "grit/generated_resources.h" +#include "net/base/x509_certificate.h" + +namespace { + +class ConstrainedSFChooseIdentityPanel + : public ConstrainedWindowMacDelegateSystemSheet { + public: + ConstrainedSFChooseIdentityPanel(SFChooseIdentityPanel* panel, + id delegate, SEL didEndSelector, + NSArray* identities, NSString* message) + : ConstrainedWindowMacDelegateSystemSheet(delegate, didEndSelector), + identities_([identities retain]), + message_([message retain]) { + set_sheet(panel); + } + + virtual ~ConstrainedSFChooseIdentityPanel() { + // As required by ConstrainedWindowMacDelegate, close the sheet if + // it's still open. + if (is_sheet_open()) { + [NSApp endSheet:sheet() + returnCode:NSFileHandlingPanelCancelButton]; + } + } + + // ConstrainedWindowMacDelegateSystemSheet implementation: + virtual void DeleteDelegate() { + delete this; + } + + // SFChooseIdentityPanel's beginSheetForWindow: method has more arguments + // than the usual one. Also pass the panel through contextInfo argument + // because the callback has the wrong signature. + virtual NSArray* GetSheetParameters(id delegate, SEL didEndSelector) { + return [NSArray arrayWithObjects: + [NSNull null], // window, must be [NSNull null] + delegate, + [NSValue valueWithPointer:didEndSelector], + [NSValue valueWithPointer:sheet()], + identities_.get(), + message_.get(), + nil]; + } + + private: + scoped_nsobject<NSArray> identities_; + scoped_nsobject<NSString> message_; + DISALLOW_COPY_AND_ASSIGN(ConstrainedSFChooseIdentityPanel); +}; + +} // namespace + +@interface SSLClientCertificateSelectorCocoa : NSObject { + @private + // The handler to report back to. + scoped_refptr<SSLClientAuthHandler> handler_; + // The certificate request we serve. + scoped_refptr<net::SSLCertRequestInfo> certRequestInfo_; + // The list of identities offered to the user. + scoped_nsobject<NSMutableArray> identities_; + // The corresponding list of certificates. + std::vector<scoped_refptr<net::X509Certificate> > certificates_; + // The currently open dialog. + ConstrainedWindow* window_; +} + +- (id)initWithHandler:(SSLClientAuthHandler*)handler + certRequestInfo:(net::SSLCertRequestInfo*)certRequestInfo; +- (void)displayDialog:(TabContents*)parent; +@end + +namespace browser { + +void ShowSSLClientCertificateSelector( + TabContents* parent, + net::SSLCertRequestInfo* cert_request_info, + SSLClientAuthHandler* delegate) { + // TODO(davidben): Implement a tab-modal dialog. + DCHECK(BrowserThread::CurrentlyOn(BrowserThread::UI)); + SSLClientCertificateSelectorCocoa* selector = + [[[SSLClientCertificateSelectorCocoa alloc] + initWithHandler:delegate + certRequestInfo:cert_request_info] autorelease]; + [selector displayDialog:parent]; +} + +} // namespace browser + +@implementation SSLClientCertificateSelectorCocoa + +- (id)initWithHandler:(SSLClientAuthHandler*)handler + certRequestInfo:(net::SSLCertRequestInfo*)certRequestInfo { + DCHECK(handler); + DCHECK(certRequestInfo); + if ((self = [super init])) { + handler_ = handler; + certRequestInfo_ = certRequestInfo; + window_ = NULL; + } + return self; +} + +- (void)sheetDidEnd:(NSWindow*)parent + returnCode:(NSInteger)returnCode + context:(void*)context { + DCHECK(context); + SFChooseIdentityPanel* panel = static_cast<SFChooseIdentityPanel*>(context); + + net::X509Certificate* cert = NULL; + if (returnCode == NSFileHandlingPanelOKButton) { + NSUInteger index = [identities_ indexOfObject:(id)[panel identity]]; + if (index != NSNotFound) + cert = certificates_[index]; + else + NOTREACHED(); + } + + // Finally, tell the backend which identity (or none) the user selected. + handler_->CertificateSelected(cert); + // Close the constrained window. + DCHECK(window_); + window_->CloseConstrainedWindow(); + + // Now that the panel has closed, release it. Note that the autorelease is + // needed. After this callback returns, the panel is still accessed, so a + // normal release crashes. + [panel autorelease]; +} + +- (void)displayDialog:(TabContents*)parent { + DCHECK(!window_); + // Create an array of CFIdentityRefs for the certificates: + size_t numCerts = certRequestInfo_->client_certs.size(); + identities_.reset([[NSMutableArray alloc] initWithCapacity:numCerts]); + for (size_t i = 0; i < numCerts; ++i) { + SecCertificateRef cert; + cert = certRequestInfo_->client_certs[i]->os_cert_handle(); + SecIdentityRef identity; + if (SecIdentityCreateWithCertificate(NULL, cert, &identity) == noErr) { + [identities_ addObject:(id)identity]; + CFRelease(identity); + certificates_.push_back(certRequestInfo_->client_certs[i]); + } + } + + // Get the message to display: + NSString* title = l10n_util::GetNSString(IDS_CLIENT_CERT_DIALOG_TITLE); + NSString* message = l10n_util::GetNSStringF( + IDS_CLIENT_CERT_DIALOG_TEXT, + ASCIIToUTF16(certRequestInfo_->host_and_port)); + + // Create and set up a system choose-identity panel. + SFChooseIdentityPanel* panel = [[SFChooseIdentityPanel alloc] init]; + [panel setInformativeText:message]; + [panel setDefaultButtonTitle:l10n_util::GetNSString(IDS_OK)]; + [panel setAlternateButtonTitle:l10n_util::GetNSString(IDS_CANCEL)]; + SecPolicyRef sslPolicy; + if (net::X509Certificate::CreateSSLClientPolicy(&sslPolicy) == noErr) { + [panel setPolicies:(id)sslPolicy]; + CFRelease(sslPolicy); + } + + window_ = + parent->CreateConstrainedDialog(new ConstrainedSFChooseIdentityPanel( + panel, self, + @selector(sheetDidEnd:returnCode:context:), + identities_, title)); + // Note: SFChooseIdentityPanel does not take a reference to itself while the + // sheet is open. Don't release the ownership claim until the sheet has ended + // in |-sheetDidEnd:returnCode:context:|. +} + +@end diff --git a/chrome/browser/ui/cocoa/status_bubble_mac.h b/chrome/browser/ui/cocoa/status_bubble_mac.h new file mode 100644 index 0000000..d32f67c --- /dev/null +++ b/chrome/browser/ui/cocoa/status_bubble_mac.h @@ -0,0 +1,172 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_STATUS_BUBBLE_MAC_H_ +#define CHROME_BROWSER_UI_COCOA_STATUS_BUBBLE_MAC_H_ +#pragma once + +#include <string> + +#import <Cocoa/Cocoa.h> +#import <QuartzCore/QuartzCore.h> + +#include "base/string16.h" +#include "base/task.h" +#include "chrome/browser/status_bubble.h" +#include "googleurl/src/gurl.h" + +class GURL; +class StatusBubbleMacTest; + +class StatusBubbleMac : public StatusBubble { + public: + // The various states that a status bubble may be in. Public for delegate + // access (for testing). + enum StatusBubbleState { + kBubbleHidden, // Fully hidden + kBubbleShowingTimer, // Waiting to fade in + kBubbleShowingFadeIn, // In a fade-in transition + kBubbleShown, // Fully visible + kBubbleHidingTimer, // Waiting to fade out + kBubbleHidingFadeOut // In a fade-out transition + }; + + StatusBubbleMac(NSWindow* parent, id delegate); + virtual ~StatusBubbleMac(); + + // StatusBubble implementation. + virtual void SetStatus(const string16& status); + virtual void SetURL(const GURL& url, const string16& languages); + virtual void Hide(); + virtual void MouseMoved(const gfx::Point& location, bool left_content); + virtual void UpdateDownloadShelfVisibility(bool visible); + + // Mac-specific method: Update the size and position of the status bubble to + // match the parent window. Safe to call even when the status bubble does not + // exist. + void UpdateSizeAndPosition(); + + // Mac-specific method: Change the parent window of the status bubble. Safe to + // call even when the status bubble does not exist. + void SwitchParentWindow(NSWindow* parent); + + // Delegate method called when a fade-in or fade-out transition has + // completed. This is public so that it may be visible to the CAAnimation + // delegate, which is an Objective-C object. + void AnimationDidStop(CAAnimation* animation, bool finished); + + // Expand the bubble to fit a URL too long for the standard bubble size. + void ExpandBubble(); + + private: + friend class StatusBubbleMacTest; + + // Setter for state_. Use this instead of writing to state_ directly so + // that state changes can be observed by unit tests. + void SetState(StatusBubbleState state); + + // Sets the bubble text for SetStatus and SetURL. + void SetText(const string16& text, bool is_url); + + // Construct the window/widget if it does not already exist. (Safe to call if + // it does.) + void Create(); + + // Attaches the status bubble window to its parent window. Safe to call even + // when already attached. + void Attach(); + + // Detaches the status bubble window from its parent window. + void Detach(); + + // Is the status bubble attached to the browser window? It should be attached + // when shown and during any fades, but should be detached when hidden. + bool is_attached() { return [window_ parentWindow] != nil; } + + // Begins fading the status bubble window in or out depending on the value + // of |show|. This must be called from the appropriate fade state, + // kBubbleShowingFadeIn or kBubbleHidingFadeOut, or from the appropriate + // fully-shown/hidden state, kBubbleShown or kBubbleHidden. This may be + // called at any point during a fade-in or fade-out; it is even possible to + // reverse a transition before it has completed. + void Fade(bool show); + + // One-shot timer operations to manage the delays associated with the + // kBubbleShowingTimer and kBubbleHidingTimer states. StartTimer and + // TimerFired must be called from one of these states. StartTimer may be + // called while the timer is still running; in that case, the timer will be + // reset. CancelTimer may be called from any state. + void StartTimer(int64 time_ms); + void CancelTimer(); + void TimerFired(); + + // Begin the process of showing or hiding the status bubble. These may be + // called from any state, and will take the appropriate action to initiate + // any state changes that may be needed. + void StartShowing(); + void StartHiding(); + + // Cancel the expansion timer. + void CancelExpandTimer(); + + // The timer factory used for show and hide delay timers. + ScopedRunnableMethodFactory<StatusBubbleMac> timer_factory_; + + // The timer factory used for the expansion delay timer. + ScopedRunnableMethodFactory<StatusBubbleMac> expand_timer_factory_; + + // Calculate the appropriate frame for the status bubble window. If + // |expanded_width|, use entire width of parent frame. + NSRect CalculateWindowFrame(bool expanded_width); + + // The window we attach ourselves to. + NSWindow* parent_; // WEAK + + // The object that we query about our vertical offset for positioning. + id delegate_; // WEAK + + // The window we own. + NSWindow* window_; + + // The status text we want to display when there are no URLs to display. + NSString* status_text_; + + // The url we want to display when there is no status text to display. + NSString* url_text_; + + // The status bubble's current state. Do not write to this field directly; + // use SetState(). + StatusBubbleState state_; + + // True if operations are to be performed immediately rather than waiting + // for delays and transitions. Normally false, this should only be set to + // true for testing. + bool immediate_; + + // True if the status bubble has been expanded. If the bubble is in the + // expanded state and encounters a new URL, change size immediately, + // with no hover delay. + bool is_expanded_; + + // The original, non-elided URL. + GURL url_; + + // Needs to be passed to ElideURL if the original URL string is wider than + // the standard bubble width. + string16 languages_; + + DISALLOW_COPY_AND_ASSIGN(StatusBubbleMac); +}; + +// Delegate interface +@interface NSObject(StatusBubbleDelegate) +// Called to query the delegate about the frame StatusBubble should position +// itself in. Frame is returned in the parent window coordinates. +- (NSRect)statusBubbleBaseFrame; + +// Called from SetState to notify the delegate of state changes. +- (void)statusBubbleWillEnterState:(StatusBubbleMac::StatusBubbleState)state; +@end + +#endif // CHROME_BROWSER_UI_COCOA_STATUS_BUBBLE_MAC_H_ diff --git a/chrome/browser/ui/cocoa/status_bubble_mac.mm b/chrome/browser/ui/cocoa/status_bubble_mac.mm new file mode 100644 index 0000000..6231e5d --- /dev/null +++ b/chrome/browser/ui/cocoa/status_bubble_mac.mm @@ -0,0 +1,705 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/status_bubble_mac.h" + +#include <limits> + +#include "app/text_elider.h" +#include "base/compiler_specific.h" +#include "base/message_loop.h" +#include "base/string_util.h" +#include "base/sys_string_conversions.h" +#include "base/utf_string_conversions.h" +#import "chrome/browser/ui/cocoa/bubble_view.h" +#include "gfx/point.h" +#include "net/base/net_util.h" +#import "third_party/GTM/AppKit/GTMNSAnimation+Duration.h" +#import "third_party/GTM/AppKit/GTMNSBezierPath+RoundRect.h" +#import "third_party/GTM/AppKit/GTMNSColor+Luminance.h" + +namespace { + +const int kWindowHeight = 18; + +// The width of the bubble in relation to the width of the parent window. +const CGFloat kWindowWidthPercent = 1.0 / 3.0; + +// How close the mouse can get to the infobubble before it starts sliding +// off-screen. +const int kMousePadding = 20; + +const int kTextPadding = 3; + +// The animation key used for fade-in and fade-out transitions. +NSString* const kFadeAnimationKey = @"alphaValue"; + +// The status bubble's maximum opacity, when fully faded in. +const CGFloat kBubbleOpacity = 1.0; + +// Delay before showing or hiding the bubble after a SetStatus or SetURL call. +const int64 kShowDelayMilliseconds = 80; +const int64 kHideDelayMilliseconds = 250; + +// How long each fade should last. +const NSTimeInterval kShowFadeInDurationSeconds = 0.120; +const NSTimeInterval kHideFadeOutDurationSeconds = 0.200; + +// The minimum representable time interval. This can be used as the value +// passed to +[NSAnimationContext setDuration:] to stop an in-progress +// animation as quickly as possible. +const NSTimeInterval kMinimumTimeInterval = + std::numeric_limits<NSTimeInterval>::min(); + +// How quickly the status bubble should expand, in seconds. +const CGFloat kExpansionDuration = 0.125; + +} // namespace + +@interface StatusBubbleAnimationDelegate : NSObject { + @private + StatusBubbleMac* statusBubble_; // weak; owns us indirectly +} + +- (id)initWithStatusBubble:(StatusBubbleMac*)statusBubble; + +// Invalidates this object so that no further calls will be made to +// statusBubble_. This should be called when statusBubble_ is released, to +// prevent attempts to call into the released object. +- (void)invalidate; + +// CAAnimation delegate method +- (void)animationDidStop:(CAAnimation*)animation finished:(BOOL)finished; +@end + +@implementation StatusBubbleAnimationDelegate + +- (id)initWithStatusBubble:(StatusBubbleMac*)statusBubble { + if ((self = [super init])) { + statusBubble_ = statusBubble; + } + + return self; +} + +- (void)invalidate { + statusBubble_ = NULL; +} + +- (void)animationDidStop:(CAAnimation*)animation finished:(BOOL)finished { + if (statusBubble_) + statusBubble_->AnimationDidStop(animation, finished ? true : false); +} + +@end + +StatusBubbleMac::StatusBubbleMac(NSWindow* parent, id delegate) + : ALLOW_THIS_IN_INITIALIZER_LIST(timer_factory_(this)), + ALLOW_THIS_IN_INITIALIZER_LIST(expand_timer_factory_(this)), + parent_(parent), + delegate_(delegate), + window_(nil), + status_text_(nil), + url_text_(nil), + state_(kBubbleHidden), + immediate_(false), + is_expanded_(false) { +} + +StatusBubbleMac::~StatusBubbleMac() { + Hide(); + + if (window_) { + [[[window_ animationForKey:kFadeAnimationKey] delegate] invalidate]; + Detach(); + [window_ release]; + window_ = nil; + } +} + +void StatusBubbleMac::SetStatus(const string16& status) { + Create(); + + SetText(status, false); +} + +void StatusBubbleMac::SetURL(const GURL& url, const string16& languages) { + url_ = url; + languages_ = languages; + + Create(); + + NSRect frame = [window_ frame]; + + // Reset frame size when bubble is hidden. + if (state_ == kBubbleHidden) { + is_expanded_ = false; + frame.size.width = NSWidth(CalculateWindowFrame(/*expand=*/false)); + [window_ setFrame:frame display:NO]; + } + + int text_width = static_cast<int>(NSWidth(frame) - + kBubbleViewTextPositionX - + kTextPadding); + + // Scale from view to window coordinates before eliding URL string. + NSSize scaled_width = NSMakeSize(text_width, 0); + scaled_width = [[parent_ contentView] convertSize:scaled_width fromView:nil]; + text_width = static_cast<int>(scaled_width.width); + NSFont* font = [[window_ contentView] font]; + gfx::Font font_chr(base::SysNSStringToWide([font fontName]), + [font pointSize]); + + string16 original_url_text = net::FormatUrl(url, UTF16ToUTF8(languages)); + string16 status = gfx::ElideUrl(url, font_chr, text_width, + UTF16ToWideHack(languages)); + + SetText(status, true); + + // In testing, don't use animation. When ExpandBubble is tested, it is + // called explicitly. + if (immediate_) + return; + else + CancelExpandTimer(); + + // If the bubble has been expanded, the user has already hovered over a link + // to trigger the expanded state. Don't wait to change the bubble in this + // case -- immediately expand or contract to fit the URL. + if (is_expanded_ && !url.is_empty()) { + ExpandBubble(); + } else if (original_url_text.length() > status.length()) { + MessageLoop::current()->PostDelayedTask(FROM_HERE, + expand_timer_factory_.NewRunnableMethod( + &StatusBubbleMac::ExpandBubble), kExpandHoverDelay); + } +} + +void StatusBubbleMac::SetText(const string16& text, bool is_url) { + // The status bubble allows the status and URL strings to be set + // independently. Whichever was set non-empty most recently will be the + // value displayed. When both are empty, the status bubble hides. + + NSString* text_ns = base::SysUTF16ToNSString(text); + + NSString** main; + NSString** backup; + + if (is_url) { + main = &url_text_; + backup = &status_text_; + } else { + main = &status_text_; + backup = &url_text_; + } + + // Don't return from this function early. It's important to make sure that + // all calls to StartShowing and StartHiding are made, so that all delays + // are observed properly. Specifically, if the state is currently + // kBubbleShowingTimer, the timer will need to be restarted even if + // [text_ns isEqualToString:*main] is true. + + [*main autorelease]; + *main = [text_ns retain]; + + bool show = true; + if ([*main length] > 0) + [[window_ contentView] setContent:*main]; + else if ([*backup length] > 0) + [[window_ contentView] setContent:*backup]; + else + show = false; + + if (show) + StartShowing(); + else + StartHiding(); +} + +void StatusBubbleMac::Hide() { + CancelTimer(); + CancelExpandTimer(); + is_expanded_ = false; + + bool fade_out = false; + if (state_ == kBubbleHidingFadeOut || state_ == kBubbleShowingFadeIn) { + SetState(kBubbleHidingFadeOut); + + if (!immediate_) { + // An animation is in progress. Cancel it by starting a new animation. + // Use kMinimumTimeInterval to set the opacity as rapidly as possible. + fade_out = true; + [NSAnimationContext beginGrouping]; + [[NSAnimationContext currentContext] setDuration:kMinimumTimeInterval]; + [[window_ animator] setAlphaValue:0.0]; + [NSAnimationContext endGrouping]; + } + } + + if (!fade_out) { + // No animation is in progress, so the opacity can be set directly. + [window_ setAlphaValue:0.0]; + SetState(kBubbleHidden); + } + + // Stop any width animation and reset the bubble size. + if (!immediate_) { + [NSAnimationContext beginGrouping]; + [[NSAnimationContext currentContext] setDuration:kMinimumTimeInterval]; + [[window_ animator] setFrame:CalculateWindowFrame(/*expand=*/false) + display:NO]; + [NSAnimationContext endGrouping]; + } else { + [window_ setFrame:CalculateWindowFrame(/*expand=*/false) display:NO]; + } + + [status_text_ release]; + status_text_ = nil; + [url_text_ release]; + url_text_ = nil; +} + +void StatusBubbleMac::MouseMoved( + const gfx::Point& location, bool left_content) { + if (left_content) + return; + + if (!window_) + return; + + // TODO(thakis): Use 'location' here instead of NSEvent. + NSPoint cursor_location = [NSEvent mouseLocation]; + --cursor_location.y; // docs say the y coord starts at 1 not 0; don't ask why + + // Bubble's base frame in |parent_| coordinates. + NSRect baseFrame; + if ([delegate_ respondsToSelector:@selector(statusBubbleBaseFrame)]) + baseFrame = [delegate_ statusBubbleBaseFrame]; + else + baseFrame = [[parent_ contentView] frame]; + + // Get the normal position of the frame. + NSRect window_frame = [window_ frame]; + window_frame.origin = [parent_ convertBaseToScreen:baseFrame.origin]; + + // Get the cursor position relative to the popup. + cursor_location.x -= NSMaxX(window_frame); + cursor_location.y -= NSMaxY(window_frame); + + + // If the mouse is in a position where we think it would move the + // status bubble, figure out where and how the bubble should be moved. + if (cursor_location.y < kMousePadding && + cursor_location.x < kMousePadding) { + int offset = kMousePadding - cursor_location.y; + + // Make the movement non-linear. + offset = offset * offset / kMousePadding; + + // When the mouse is entering from the right, we want the offset to be + // scaled by how horizontally far away the cursor is from the bubble. + if (cursor_location.x > 0) { + offset = offset * ((kMousePadding - cursor_location.x) / kMousePadding); + } + + bool isOnScreen = true; + NSScreen* screen = [window_ screen]; + if (screen && + NSMinY([screen visibleFrame]) > NSMinY(window_frame) - offset) { + isOnScreen = false; + } + + // If something is shown below tab contents (devtools, download shelf etc.), + // adjust the position to sit on top of it. + bool isAnyShelfVisible = NSMinY(baseFrame) > 0; + + if (isOnScreen && !isAnyShelfVisible) { + // Cap the offset and change the visual presentation of the bubble + // depending on where it ends up (so that rounded corners square off + // and mate to the edges of the tab content). + if (offset >= NSHeight(window_frame)) { + offset = NSHeight(window_frame); + [[window_ contentView] setCornerFlags: + kRoundedBottomLeftCorner | kRoundedBottomRightCorner]; + } else if (offset > 0) { + [[window_ contentView] setCornerFlags: + kRoundedTopRightCorner | kRoundedBottomLeftCorner | + kRoundedBottomRightCorner]; + } else { + [[window_ contentView] setCornerFlags:kRoundedTopRightCorner]; + } + window_frame.origin.y -= offset; + } else { + // Cannot move the bubble down without obscuring other content. + // Move it to the right instead. + [[window_ contentView] setCornerFlags:kRoundedTopLeftCorner]; + + // Subtract border width + bubble width. + window_frame.origin.x += NSWidth(baseFrame) - NSWidth(window_frame); + } + } else { + [[window_ contentView] setCornerFlags:kRoundedTopRightCorner]; + } + + [window_ setFrame:window_frame display:YES]; +} + +void StatusBubbleMac::UpdateDownloadShelfVisibility(bool visible) { +} + +void StatusBubbleMac::Create() { + if (window_) + return; + + // TODO(avi):fix this for RTL + NSRect window_rect = CalculateWindowFrame(/*expand=*/false); + // initWithContentRect has origin in screen coords and size in scaled window + // coordinates. + window_rect.size = + [[parent_ contentView] convertSize:window_rect.size fromView:nil]; + window_ = [[NSWindow alloc] initWithContentRect:window_rect + styleMask:NSBorderlessWindowMask + backing:NSBackingStoreBuffered + defer:YES]; + [window_ setMovableByWindowBackground:NO]; + [window_ setBackgroundColor:[NSColor clearColor]]; + [window_ setLevel:NSNormalWindowLevel]; + [window_ setOpaque:NO]; + [window_ setHasShadow:NO]; + + // We do not need to worry about the bubble outliving |parent_| because our + // teardown sequence in BWC guarantees that |parent_| outlives the status + // bubble and that the StatusBubble is torn down completely prior to the + // window going away. + scoped_nsobject<BubbleView> view( + [[BubbleView alloc] initWithFrame:NSZeroRect themeProvider:parent_]); + [window_ setContentView:view]; + + [window_ setAlphaValue:0.0]; + + // Set a delegate for the fade-in and fade-out transitions to be notified + // when fades are complete. The ownership model is for window_ to own + // animation_dictionary, which owns animation, which owns + // animation_delegate. + CAAnimation* animation = [[window_ animationForKey:kFadeAnimationKey] copy]; + [animation autorelease]; + StatusBubbleAnimationDelegate* animation_delegate = + [[StatusBubbleAnimationDelegate alloc] initWithStatusBubble:this]; + [animation_delegate autorelease]; + [animation setDelegate:animation_delegate]; + NSMutableDictionary* animation_dictionary = + [NSMutableDictionary dictionaryWithDictionary:[window_ animations]]; + [animation_dictionary setObject:animation forKey:kFadeAnimationKey]; + [window_ setAnimations:animation_dictionary]; + + // Don't |Attach()| since we don't know the appropriate state; let the + // |SetState()| call do that. + + [view setCornerFlags:kRoundedTopRightCorner]; + MouseMoved(gfx::Point(), false); +} + +void StatusBubbleMac::Attach() { + // This method may be called several times during the process of creating or + // showing a status bubble to attach the bubble to its parent window. + if (!is_attached()) { + [parent_ addChildWindow:window_ ordered:NSWindowAbove]; + UpdateSizeAndPosition(); + } +} + +void StatusBubbleMac::Detach() { + // This method may be called several times in the process of hiding or + // destroying a status bubble. + if (is_attached()) { + // Magic setFrame: See crbug.com/58506, and codereview.chromium.org/3573014 + [window_ setFrame:CalculateWindowFrame(/*expand=*/false) display:NO]; + [parent_ removeChildWindow:window_]; // See crbug.com/28107 ... + [window_ orderOut:nil]; // ... and crbug.com/29054. + } +} + +void StatusBubbleMac::AnimationDidStop(CAAnimation* animation, bool finished) { + DCHECK([NSThread isMainThread]); + DCHECK(state_ == kBubbleShowingFadeIn || state_ == kBubbleHidingFadeOut); + DCHECK(is_attached()); + + if (finished) { + // Because of the mechanism used to interrupt animations, this is never + // actually called with finished set to false. If animations ever become + // directly interruptible, the check will ensure that state_ remains + // properly synchronized. + if (state_ == kBubbleShowingFadeIn) { + DCHECK_EQ([[window_ animator] alphaValue], kBubbleOpacity); + SetState(kBubbleShown); + } else { + DCHECK_EQ([[window_ animator] alphaValue], 0.0); + SetState(kBubbleHidden); + } + } +} + +void StatusBubbleMac::SetState(StatusBubbleState state) { + // We must be hidden or attached, but not both. + DCHECK((state_ == kBubbleHidden) ^ is_attached()); + + if (state == state_) + return; + + if (state == kBubbleHidden) + Detach(); + else + Attach(); + + if ([delegate_ respondsToSelector:@selector(statusBubbleWillEnterState:)]) + [delegate_ statusBubbleWillEnterState:state]; + + state_ = state; +} + +void StatusBubbleMac::Fade(bool show) { + DCHECK([NSThread isMainThread]); + + StatusBubbleState fade_state = kBubbleShowingFadeIn; + StatusBubbleState target_state = kBubbleShown; + NSTimeInterval full_duration = kShowFadeInDurationSeconds; + CGFloat opacity = kBubbleOpacity; + + if (!show) { + fade_state = kBubbleHidingFadeOut; + target_state = kBubbleHidden; + full_duration = kHideFadeOutDurationSeconds; + opacity = 0.0; + } + + DCHECK(state_ == fade_state || state_ == target_state); + + if (state_ == target_state) + return; + + if (immediate_) { + [window_ setAlphaValue:opacity]; + SetState(target_state); + return; + } + + // If an incomplete transition has left the opacity somewhere between 0 and + // kBubbleOpacity, the fade rate is kept constant by shortening the duration. + NSTimeInterval duration = + full_duration * + fabs(opacity - [[window_ animator] alphaValue]) / kBubbleOpacity; + + // 0.0 will not cancel an in-progress animation. + if (duration == 0.0) + duration = kMinimumTimeInterval; + + // This will cancel an in-progress transition and replace it with this fade. + [NSAnimationContext beginGrouping]; + // Don't use the GTM additon for the "Steve" slowdown because this can happen + // async from user actions and the effects could be a surprise. + [[NSAnimationContext currentContext] setDuration:duration]; + [[window_ animator] setAlphaValue:opacity]; + [NSAnimationContext endGrouping]; +} + +void StatusBubbleMac::StartTimer(int64 delay_ms) { + DCHECK([NSThread isMainThread]); + DCHECK(state_ == kBubbleShowingTimer || state_ == kBubbleHidingTimer); + + if (immediate_) { + TimerFired(); + return; + } + + // There can only be one running timer. + CancelTimer(); + + MessageLoop::current()->PostDelayedTask( + FROM_HERE, + timer_factory_.NewRunnableMethod(&StatusBubbleMac::TimerFired), + delay_ms); +} + +void StatusBubbleMac::CancelTimer() { + DCHECK([NSThread isMainThread]); + + if (!timer_factory_.empty()) + timer_factory_.RevokeAll(); +} + +void StatusBubbleMac::TimerFired() { + DCHECK(state_ == kBubbleShowingTimer || state_ == kBubbleHidingTimer); + DCHECK([NSThread isMainThread]); + + if (state_ == kBubbleShowingTimer) { + SetState(kBubbleShowingFadeIn); + Fade(true); + } else { + SetState(kBubbleHidingFadeOut); + Fade(false); + } +} + +void StatusBubbleMac::StartShowing() { + // Note that |SetState()| will |Attach()| or |Detach()| as required. + + if (state_ == kBubbleHidden) { + // Arrange to begin fading in after a delay. + SetState(kBubbleShowingTimer); + StartTimer(kShowDelayMilliseconds); + } else if (state_ == kBubbleHidingFadeOut) { + // Cancel the fade-out in progress and replace it with a fade in. + SetState(kBubbleShowingFadeIn); + Fade(true); + } else if (state_ == kBubbleHidingTimer) { + // The bubble was already shown but was waiting to begin fading out. It's + // given a stay of execution. + SetState(kBubbleShown); + CancelTimer(); + } else if (state_ == kBubbleShowingTimer) { + // The timer was already running but nothing was showing yet. Reaching + // this point means that there is a new request to show something. Start + // over again by resetting the timer, effectively invalidating the earlier + // request. + StartTimer(kShowDelayMilliseconds); + } + + // If the state is kBubbleShown or kBubbleShowingFadeIn, leave everything + // alone. +} + +void StatusBubbleMac::StartHiding() { + if (state_ == kBubbleShown) { + // Arrange to begin fading out after a delay. + SetState(kBubbleHidingTimer); + StartTimer(kHideDelayMilliseconds); + } else if (state_ == kBubbleShowingFadeIn) { + // Cancel the fade-in in progress and replace it with a fade out. + SetState(kBubbleHidingFadeOut); + Fade(false); + } else if (state_ == kBubbleShowingTimer) { + // The bubble was already hidden but was waiting to begin fading in. Too + // bad, it won't get the opportunity now. + SetState(kBubbleHidden); + CancelTimer(); + } + + // If the state is kBubbleHidden, kBubbleHidingFadeOut, or + // kBubbleHidingTimer, leave everything alone. The timer is not reset as + // with kBubbleShowingTimer in StartShowing() because a subsequent request + // to hide something while one is already in flight does not invalidate the + // earlier request. +} + +void StatusBubbleMac::CancelExpandTimer() { + DCHECK([NSThread isMainThread]); + expand_timer_factory_.RevokeAll(); +} + +void StatusBubbleMac::ExpandBubble() { + // Calculate the width available for expanded and standard bubbles. + NSRect window_frame = CalculateWindowFrame(/*expand=*/true); + CGFloat max_bubble_width = NSWidth(window_frame); + CGFloat standard_bubble_width = + NSWidth(CalculateWindowFrame(/*expand=*/false)); + + // Generate the URL string that fits in the expanded bubble. + NSFont* font = [[window_ contentView] font]; + gfx::Font font_chr(base::SysNSStringToWide([font fontName]), + [font pointSize]); + string16 expanded_url = gfx::ElideUrl(url_, font_chr, + max_bubble_width, UTF16ToWideHack(languages_)); + + // Scale width from gfx::Font in view coordinates to window coordinates. + int required_width_for_string = + font_chr.GetStringWidth(UTF16ToWide(expanded_url)) + + kTextPadding * 2 + kBubbleViewTextPositionX; + NSSize scaled_width = NSMakeSize(required_width_for_string, 0); + scaled_width = [[parent_ contentView] convertSize:scaled_width toView:nil]; + required_width_for_string = scaled_width.width; + + // The expanded width must be at least as wide as the standard width, but no + // wider than the maximum width for its parent frame. + int expanded_bubble_width = + std::max(standard_bubble_width, + std::min(max_bubble_width, + static_cast<CGFloat>(required_width_for_string))); + + SetText(expanded_url, true); + is_expanded_ = true; + window_frame.size.width = expanded_bubble_width; + + // In testing, don't do any animation. + if (immediate_) { + [window_ setFrame:window_frame display:YES]; + return; + } + + NSRect actual_window_frame = [window_ frame]; + // Adjust status bubble origin if bubble was moved to the right. + // TODO(alekseys): fix for RTL. + if (NSMinX(actual_window_frame) > NSMinX(window_frame)) { + actual_window_frame.origin.x = + NSMaxX(actual_window_frame) - NSWidth(window_frame); + } + actual_window_frame.size.width = NSWidth(window_frame); + + // Do not expand if it's going to cover mouse location. + if (NSPointInRect([NSEvent mouseLocation], actual_window_frame)) + return; + + [NSAnimationContext beginGrouping]; + [[NSAnimationContext currentContext] setDuration:kExpansionDuration]; + [[window_ animator] setFrame:actual_window_frame display:YES]; + [NSAnimationContext endGrouping]; +} + +void StatusBubbleMac::UpdateSizeAndPosition() { + if (!window_) + return; + + [window_ setFrame:CalculateWindowFrame(/*expand=*/false) display:YES]; +} + +void StatusBubbleMac::SwitchParentWindow(NSWindow* parent) { + DCHECK(parent); + + // If not attached, just update our member variable and position. + if (!is_attached()) { + parent_ = parent; + [[window_ contentView] setThemeProvider:parent]; + UpdateSizeAndPosition(); + return; + } + + Detach(); + parent_ = parent; + [[window_ contentView] setThemeProvider:parent]; + Attach(); + UpdateSizeAndPosition(); +} + +NSRect StatusBubbleMac::CalculateWindowFrame(bool expanded_width) { + DCHECK(parent_); + + NSRect screenRect; + if ([delegate_ respondsToSelector:@selector(statusBubbleBaseFrame)]) { + screenRect = [delegate_ statusBubbleBaseFrame]; + screenRect.origin = [parent_ convertBaseToScreen:screenRect.origin]; + } else { + screenRect = [parent_ frame]; + } + + NSSize size = NSMakeSize(0, kWindowHeight); + size = [[parent_ contentView] convertSize:size toView:nil]; + + if (expanded_width) { + size.width = screenRect.size.width; + } else { + size.width = kWindowWidthPercent * screenRect.size.width; + } + + screenRect.size = size; + return screenRect; +} diff --git a/chrome/browser/ui/cocoa/status_bubble_mac_unittest.mm b/chrome/browser/ui/cocoa/status_bubble_mac_unittest.mm new file mode 100644 index 0000000..5aa895a --- /dev/null +++ b/chrome/browser/ui/cocoa/status_bubble_mac_unittest.mm @@ -0,0 +1,584 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#include "base/scoped_ptr.h" +#include "base/utf_string_conversions.h" +#import "chrome/browser/ui/cocoa/bubble_view.h" +#import "chrome/browser/ui/cocoa/browser_test_helper.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/status_bubble_mac.h" +#include "googleurl/src/gurl.h" +#include "testing/gtest/include/gtest/gtest.h" +#import "testing/gtest_mac.h" +#include "testing/platform_test.h" + +// The test delegate records all of the status bubble object's state +// transitions. +@interface StatusBubbleMacTestDelegate : NSObject { + @private + NSWindow* window_; // Weak. + NSPoint baseFrameOffset_; + std::vector<StatusBubbleMac::StatusBubbleState> states_; +} +- (id)initWithWindow:(NSWindow*)window; +- (void)forceBaseFrameOffset:(NSPoint)baseFrameOffset; +- (NSRect)statusBubbleBaseFrame; +- (void)statusBubbleWillEnterState:(StatusBubbleMac::StatusBubbleState)state; +@end +@implementation StatusBubbleMacTestDelegate +- (id)initWithWindow:(NSWindow*)window { + if ((self = [super init])) { + window_ = window; + baseFrameOffset_ = NSMakePoint(0, 0); + } + return self; +} +- (void)forceBaseFrameOffset:(NSPoint)baseFrameOffset { + baseFrameOffset_ = baseFrameOffset; +} +- (NSRect)statusBubbleBaseFrame { + NSView* contentView = [window_ contentView]; + NSRect baseFrame = [contentView convertRect:[contentView frame] toView:nil]; + if (baseFrameOffset_.x > 0 || baseFrameOffset_.y > 0) { + baseFrame = NSOffsetRect(baseFrame, baseFrameOffset_.x, baseFrameOffset_.y); + baseFrame.size.width -= baseFrameOffset_.x; + baseFrame.size.height -= baseFrameOffset_.y; + } + return baseFrame; +} +- (void)statusBubbleWillEnterState:(StatusBubbleMac::StatusBubbleState)state { + states_.push_back(state); +} +- (std::vector<StatusBubbleMac::StatusBubbleState>*)states { + return &states_; +} +@end + +// This class implements, for testing purposes, a subclass of |StatusBubbleMac| +// whose |MouseMoved()| method does nothing. (Ideally, we'd have a way of +// controlling the "mouse" location, but the current implementation of +// |StatusBubbleMac| uses |[NSEvent mouseLocation]| directly.) Without this, +// tests can be flaky since results may depend on the mouse location. +class StatusBubbleMacIgnoreMouseMoved : public StatusBubbleMac { + public: + StatusBubbleMacIgnoreMouseMoved(NSWindow* parent, id delegate) + : StatusBubbleMac(parent, delegate) {} + + virtual void MouseMoved(const gfx::Point& location, bool left_content) {} +}; + +class StatusBubbleMacTest : public CocoaTest { + public: + virtual void SetUp() { + CocoaTest::SetUp(); + NSWindow* window = test_window(); + EXPECT_TRUE(window); + delegate_.reset( + [[StatusBubbleMacTestDelegate alloc] initWithWindow: window]); + EXPECT_TRUE(delegate_.get()); + bubble_ = new StatusBubbleMacIgnoreMouseMoved(window, delegate_); + EXPECT_TRUE(bubble_); + + // Turn off delays and transitions for test mode. This doesn't just speed + // things along, it's actually required to get StatusBubbleMac to behave + // synchronously, because the tests here don't know how to wait for + // results. This allows these tests to be much more complete with a + // minimal loss of coverage and without any heinous rearchitecting. + bubble_->immediate_ = true; + + EXPECT_FALSE(bubble_->window_); // lazily creates window + } + + virtual void TearDown() { + // Not using a scoped_ptr because bubble must be deleted before calling + // TearDown to get rid of bubble's window. + delete bubble_; + CocoaTest::TearDown(); + } + + bool IsVisible() { + if (![bubble_->window_ isVisible]) + return false; + return [bubble_->window_ alphaValue] > 0.0; + } + NSString* GetText() { + return bubble_->status_text_; + } + NSString* GetURLText() { + return bubble_->url_text_; + } + NSString* GetBubbleViewText() { + BubbleView* bubbleView = [bubble_->window_ contentView]; + return [bubbleView content]; + } + NSWindow* GetWindow() { + return bubble_->window_; + } + NSWindow* GetParent() { + return bubble_->parent_; + } + StatusBubbleMac::StatusBubbleState GetState() { + return bubble_->state_; + } + void SetState(StatusBubbleMac::StatusBubbleState state) { + bubble_->SetState(state); + } + std::vector<StatusBubbleMac::StatusBubbleState>* States() { + return [delegate_ states]; + } + StatusBubbleMac::StatusBubbleState StateAt(int index) { + return (*States())[index]; + } + BrowserTestHelper browser_helper_; + scoped_nsobject<StatusBubbleMacTestDelegate> delegate_; + StatusBubbleMac* bubble_; // Strong. +}; + +TEST_F(StatusBubbleMacTest, SetStatus) { + bubble_->SetStatus(string16()); + bubble_->SetStatus(UTF8ToUTF16("This is a test")); + EXPECT_NSEQ(@"This is a test", GetText()); + EXPECT_TRUE(IsVisible()); + + // Set the status to the exact same thing again + bubble_->SetStatus(UTF8ToUTF16("This is a test")); + EXPECT_NSEQ(@"This is a test", GetText()); + + // Hide it + bubble_->SetStatus(string16()); + EXPECT_FALSE(IsVisible()); +} + +TEST_F(StatusBubbleMacTest, SetURL) { + bubble_->SetURL(GURL(), string16()); + EXPECT_FALSE(IsVisible()); + bubble_->SetURL(GURL("bad url"), string16()); + EXPECT_FALSE(IsVisible()); + bubble_->SetURL(GURL("http://"), string16()); + EXPECT_TRUE(IsVisible()); + EXPECT_NSEQ(@"http:", GetURLText()); + bubble_->SetURL(GURL("about:blank"), string16()); + EXPECT_TRUE(IsVisible()); + EXPECT_NSEQ(@"about:blank", GetURLText()); + bubble_->SetURL(GURL("foopy://"), string16()); + EXPECT_TRUE(IsVisible()); + EXPECT_NSEQ(@"foopy://", GetURLText()); + bubble_->SetURL(GURL("http://www.cnn.com"), string16()); + EXPECT_TRUE(IsVisible()); + EXPECT_NSEQ(@"www.cnn.com", GetURLText()); +} + +// Test hiding bubble that's already hidden. +TEST_F(StatusBubbleMacTest, Hides) { + bubble_->SetStatus(UTF8ToUTF16("Showing")); + EXPECT_TRUE(IsVisible()); + bubble_->Hide(); + EXPECT_FALSE(IsVisible()); + bubble_->Hide(); + EXPECT_FALSE(IsVisible()); +} + +// Test the "main"/"backup" behavior in StatusBubbleMac::SetText(). +TEST_F(StatusBubbleMacTest, SetStatusAndURL) { + EXPECT_FALSE(IsVisible()); + bubble_->SetStatus(UTF8ToUTF16("Status")); + EXPECT_TRUE(IsVisible()); + EXPECT_NSEQ(@"Status", GetBubbleViewText()); + bubble_->SetURL(GURL("http://www.nytimes.com"), string16()); + EXPECT_TRUE(IsVisible()); + EXPECT_NSEQ(@"www.nytimes.com", GetBubbleViewText()); + bubble_->SetURL(GURL(), string16()); + EXPECT_TRUE(IsVisible()); + EXPECT_NSEQ(@"Status", GetBubbleViewText()); + bubble_->SetStatus(string16()); + EXPECT_FALSE(IsVisible()); + bubble_->SetURL(GURL("http://www.nytimes.com"), string16()); + EXPECT_TRUE(IsVisible()); + EXPECT_NSEQ(@"www.nytimes.com", GetBubbleViewText()); + bubble_->SetStatus(UTF8ToUTF16("Status")); + EXPECT_TRUE(IsVisible()); + EXPECT_NSEQ(@"Status", GetBubbleViewText()); + bubble_->SetStatus(string16()); + EXPECT_TRUE(IsVisible()); + EXPECT_NSEQ(@"www.nytimes.com", GetBubbleViewText()); + bubble_->SetURL(GURL(), string16()); + EXPECT_FALSE(IsVisible()); +} + +// Test that the status bubble goes through the correct delay and fade states. +// The delay and fade duration are simulated and not actually experienced +// during the test because StatusBubbleMacTest sets immediate_ mode. +TEST_F(StatusBubbleMacTest, StateTransitions) { + // First, some sanity + + EXPECT_FALSE(IsVisible()); + EXPECT_EQ(StatusBubbleMac::kBubbleHidden, GetState()); + + States()->clear(); + EXPECT_TRUE(States()->empty()); + + bubble_->SetStatus(string16()); + EXPECT_FALSE(IsVisible()); + EXPECT_EQ(StatusBubbleMac::kBubbleHidden, GetState()); + EXPECT_TRUE(States()->empty()); // no change from initial kBubbleHidden state + + // Next, a few ordinary cases + + // Test StartShowing from kBubbleHidden + bubble_->SetStatus(UTF8ToUTF16("Status")); + EXPECT_TRUE(IsVisible()); + // Check GetState before checking States to make sure that all state + // transitions have been flushed to States. + EXPECT_EQ(StatusBubbleMac::kBubbleShown, GetState()); + EXPECT_EQ(3u, States()->size()); + EXPECT_EQ(StatusBubbleMac::kBubbleShowingTimer, StateAt(0)); + EXPECT_EQ(StatusBubbleMac::kBubbleShowingFadeIn, StateAt(1)); + EXPECT_EQ(StatusBubbleMac::kBubbleShown, StateAt(2)); + + // Test StartShowing from kBubbleShown with the same message + States()->clear(); + bubble_->SetStatus(UTF8ToUTF16("Status")); + EXPECT_TRUE(IsVisible()); + EXPECT_EQ(StatusBubbleMac::kBubbleShown, GetState()); + EXPECT_TRUE(States()->empty()); + + // Test StartShowing from kBubbleShown with a different message + bubble_->SetStatus(UTF8ToUTF16("New Status")); + EXPECT_TRUE(IsVisible()); + EXPECT_EQ(StatusBubbleMac::kBubbleShown, GetState()); + EXPECT_TRUE(States()->empty()); + + // Test StartHiding from kBubbleShown + bubble_->SetStatus(string16()); + EXPECT_FALSE(IsVisible()); + // Check GetState before checking States to make sure that all state + // transitions have been flushed to States. + EXPECT_EQ(StatusBubbleMac::kBubbleHidden, GetState()); + EXPECT_EQ(3u, States()->size()); + EXPECT_EQ(StatusBubbleMac::kBubbleHidingTimer, StateAt(0)); + EXPECT_EQ(StatusBubbleMac::kBubbleHidingFadeOut, StateAt(1)); + EXPECT_EQ(StatusBubbleMac::kBubbleHidden, StateAt(2)); + + // Test StartHiding from kBubbleHidden + States()->clear(); + bubble_->SetStatus(string16()); + EXPECT_FALSE(IsVisible()); + EXPECT_EQ(StatusBubbleMac::kBubbleHidden, GetState()); + EXPECT_TRUE(States()->empty()); + + // Now, the edge cases + + // Test StartShowing from kBubbleShowingTimer + bubble_->SetStatus(UTF8ToUTF16("Status")); + SetState(StatusBubbleMac::kBubbleShowingTimer); + [GetWindow() setAlphaValue:0.0]; + States()->clear(); + EXPECT_TRUE(States()->empty()); + bubble_->SetStatus(UTF8ToUTF16("Status")); + EXPECT_EQ(StatusBubbleMac::kBubbleShown, GetState()); + EXPECT_EQ(2u, States()->size()); + EXPECT_EQ(StatusBubbleMac::kBubbleShowingFadeIn, StateAt(0)); + EXPECT_EQ(StatusBubbleMac::kBubbleShown, StateAt(1)); + + // Test StartShowing from kBubbleShowingFadeIn + bubble_->SetStatus(UTF8ToUTF16("Status")); + SetState(StatusBubbleMac::kBubbleShowingFadeIn); + [GetWindow() setAlphaValue:0.5]; + States()->clear(); + EXPECT_TRUE(States()->empty()); + bubble_->SetStatus(UTF8ToUTF16("Status")); + // The actual state values can't be tested in immediate_ mode because + // the window wasn't actually fading in. Without immediate_ mode, + // expect kBubbleShown. + bubble_->SetStatus(string16()); // Go back to a deterministic state. + + // Test StartShowing from kBubbleHidingTimer + bubble_->SetStatus(string16()); + SetState(StatusBubbleMac::kBubbleHidingTimer); + [GetWindow() setAlphaValue:1.0]; + States()->clear(); + EXPECT_TRUE(States()->empty()); + bubble_->SetStatus(UTF8ToUTF16("Status")); + EXPECT_EQ(StatusBubbleMac::kBubbleShown, GetState()); + EXPECT_EQ(1u, States()->size()); + EXPECT_EQ(StatusBubbleMac::kBubbleShown, StateAt(0)); + + // Test StartShowing from kBubbleHidingFadeOut + bubble_->SetStatus(string16()); + SetState(StatusBubbleMac::kBubbleHidingFadeOut); + [GetWindow() setAlphaValue:0.5]; + States()->clear(); + EXPECT_TRUE(States()->empty()); + bubble_->SetStatus(UTF8ToUTF16("Status")); + EXPECT_EQ(StatusBubbleMac::kBubbleShown, GetState()); + EXPECT_EQ(2u, States()->size()); + EXPECT_EQ(StatusBubbleMac::kBubbleShowingFadeIn, StateAt(0)); + EXPECT_EQ(StatusBubbleMac::kBubbleShown, StateAt(1)); + + // Test StartHiding from kBubbleShowingTimer + bubble_->SetStatus(UTF8ToUTF16("Status")); + SetState(StatusBubbleMac::kBubbleShowingTimer); + [GetWindow() setAlphaValue:0.0]; + States()->clear(); + EXPECT_TRUE(States()->empty()); + bubble_->SetStatus(string16()); + EXPECT_EQ(StatusBubbleMac::kBubbleHidden, GetState()); + EXPECT_EQ(1u, States()->size()); + EXPECT_EQ(StatusBubbleMac::kBubbleHidden, StateAt(0)); + + // Test StartHiding from kBubbleShowingFadeIn + bubble_->SetStatus(UTF8ToUTF16("Status")); + SetState(StatusBubbleMac::kBubbleShowingFadeIn); + [GetWindow() setAlphaValue:0.5]; + States()->clear(); + EXPECT_TRUE(States()->empty()); + bubble_->SetStatus(string16()); + EXPECT_EQ(StatusBubbleMac::kBubbleHidden, GetState()); + EXPECT_EQ(2u, States()->size()); + EXPECT_EQ(StatusBubbleMac::kBubbleHidingFadeOut, StateAt(0)); + EXPECT_EQ(StatusBubbleMac::kBubbleHidden, StateAt(1)); + + // Test StartHiding from kBubbleHidingTimer + bubble_->SetStatus(string16()); + SetState(StatusBubbleMac::kBubbleHidingTimer); + [GetWindow() setAlphaValue:1.0]; + States()->clear(); + EXPECT_TRUE(States()->empty()); + bubble_->SetStatus(string16()); + // The actual state values can't be tested in immediate_ mode because + // the timer wasn't actually running. Without immediate_ mode, expect + // kBubbleHidingFadeOut and kBubbleHidden. + // Go back to a deterministic state. + bubble_->SetStatus(UTF8ToUTF16("Status")); + + // Test StartHiding from kBubbleHidingFadeOut + bubble_->SetStatus(string16()); + SetState(StatusBubbleMac::kBubbleHidingFadeOut); + [GetWindow() setAlphaValue:0.5]; + States()->clear(); + EXPECT_TRUE(States()->empty()); + bubble_->SetStatus(string16()); + // The actual state values can't be tested in immediate_ mode because + // the window wasn't actually fading out. Without immediate_ mode, expect + // kBubbleHidden. + // Go back to a deterministic state. + bubble_->SetStatus(UTF8ToUTF16("Status")); + + // Test Hide from kBubbleHidden + bubble_->SetStatus(string16()); + EXPECT_EQ(StatusBubbleMac::kBubbleHidden, GetState()); + States()->clear(); + EXPECT_TRUE(States()->empty()); + bubble_->Hide(); + EXPECT_EQ(StatusBubbleMac::kBubbleHidden, GetState()); + EXPECT_TRUE(States()->empty()); + + // Test Hide from kBubbleShowingTimer + bubble_->SetStatus(UTF8ToUTF16("Status")); + SetState(StatusBubbleMac::kBubbleShowingTimer); + [GetWindow() setAlphaValue:0.0]; + States()->clear(); + EXPECT_TRUE(States()->empty()); + bubble_->Hide(); + EXPECT_EQ(StatusBubbleMac::kBubbleHidden, GetState()); + EXPECT_EQ(1u, States()->size()); + EXPECT_EQ(StatusBubbleMac::kBubbleHidden, StateAt(0)); + + // Test Hide from kBubbleShowingFadeIn + bubble_->SetStatus(UTF8ToUTF16("Status")); + SetState(StatusBubbleMac::kBubbleShowingFadeIn); + [GetWindow() setAlphaValue:0.5]; + States()->clear(); + EXPECT_TRUE(States()->empty()); + bubble_->Hide(); + EXPECT_EQ(StatusBubbleMac::kBubbleHidden, GetState()); + EXPECT_EQ(2u, States()->size()); + EXPECT_EQ(StatusBubbleMac::kBubbleHidingFadeOut, StateAt(0)); + EXPECT_EQ(StatusBubbleMac::kBubbleHidden, StateAt(1)); + + // Test Hide from kBubbleShown + bubble_->SetStatus(UTF8ToUTF16("Status")); + States()->clear(); + EXPECT_TRUE(States()->empty()); + bubble_->Hide(); + EXPECT_EQ(StatusBubbleMac::kBubbleHidden, GetState()); + EXPECT_EQ(1u, States()->size()); + EXPECT_EQ(StatusBubbleMac::kBubbleHidden, StateAt(0)); + + // Test Hide from kBubbleHidingTimer + bubble_->SetStatus(UTF8ToUTF16("Status")); + SetState(StatusBubbleMac::kBubbleHidingTimer); + States()->clear(); + EXPECT_TRUE(States()->empty()); + bubble_->Hide(); + EXPECT_EQ(StatusBubbleMac::kBubbleHidden, GetState()); + EXPECT_EQ(1u, States()->size()); + EXPECT_EQ(StatusBubbleMac::kBubbleHidden, StateAt(0)); + + // Test Hide from kBubbleHidingFadeOut + bubble_->SetStatus(UTF8ToUTF16("Status")); + SetState(StatusBubbleMac::kBubbleHidingFadeOut); + [GetWindow() setAlphaValue:0.5]; + States()->clear(); + EXPECT_TRUE(States()->empty()); + bubble_->Hide(); + EXPECT_EQ(StatusBubbleMac::kBubbleHidden, GetState()); + EXPECT_EQ(1u, States()->size()); + EXPECT_EQ(StatusBubbleMac::kBubbleHidden, StateAt(0)); +} + +TEST_F(StatusBubbleMacTest, Delete) { + NSWindow* window = test_window(); + // Create and delete immediately. + StatusBubbleMac* bubble = new StatusBubbleMac(window, nil); + delete bubble; + + // Create then delete while visible. + bubble = new StatusBubbleMac(window, nil); + bubble->SetStatus(UTF8ToUTF16("showing")); + delete bubble; +} + +TEST_F(StatusBubbleMacTest, UpdateSizeAndPosition) { + // Test |UpdateSizeAndPosition()| when status bubble does not exist (shouldn't + // crash; shouldn't create window). + EXPECT_FALSE(GetWindow()); + bubble_->UpdateSizeAndPosition(); + EXPECT_FALSE(GetWindow()); + + // Create a status bubble (with contents) and call resize (without actually + // resizing); the frame size shouldn't change. + bubble_->SetStatus(UTF8ToUTF16("UpdateSizeAndPosition test")); + ASSERT_TRUE(GetWindow()); + NSRect rect_before = [GetWindow() frame]; + bubble_->UpdateSizeAndPosition(); + NSRect rect_after = [GetWindow() frame]; + EXPECT_TRUE(NSEqualRects(rect_before, rect_after)); + + // Move the window and call resize; only the origin should change. + NSWindow* window = test_window(); + ASSERT_TRUE(window); + NSRect frame = [window frame]; + rect_before = [GetWindow() frame]; + frame.origin.x += 10.0; // (fairly arbitrary nonzero value) + frame.origin.y += 10.0; // (fairly arbitrary nonzero value) + [window setFrame:frame display:YES]; + bubble_->UpdateSizeAndPosition(); + rect_after = [GetWindow() frame]; + EXPECT_NE(rect_before.origin.x, rect_after.origin.x); + EXPECT_NE(rect_before.origin.y, rect_after.origin.y); + EXPECT_EQ(rect_before.size.width, rect_after.size.width); + EXPECT_EQ(rect_before.size.height, rect_after.size.height); + + // Resize the window (without moving). The origin shouldn't change. The width + // should change (in the current implementation), but not the height. + frame = [window frame]; + rect_before = [GetWindow() frame]; + frame.size.width += 50.0; // (fairly arbitrary nonzero value) + frame.size.height += 50.0; // (fairly arbitrary nonzero value) + [window setFrame:frame display:YES]; + bubble_->UpdateSizeAndPosition(); + rect_after = [GetWindow() frame]; + EXPECT_EQ(rect_before.origin.x, rect_after.origin.x); + EXPECT_EQ(rect_before.origin.y, rect_after.origin.y); + EXPECT_NE(rect_before.size.width, rect_after.size.width); + EXPECT_EQ(rect_before.size.height, rect_after.size.height); +} + +TEST_F(StatusBubbleMacTest, MovingWindowUpdatesPosition) { + NSWindow* window = test_window(); + + // Show the bubble and make sure it has the same origin as |window|. + bubble_->SetStatus(UTF8ToUTF16("Showing")); + NSWindow* child = GetWindow(); + EXPECT_TRUE(NSEqualPoints([window frame].origin, [child frame].origin)); + + // Hide the bubble, move the window, and show it again. + bubble_->Hide(); + NSRect frame = [window frame]; + frame.origin.x += 50; + [window setFrame:frame display:YES]; + bubble_->SetStatus(UTF8ToUTF16("Reshowing")); + + // The bubble should reattach in the correct location. + child = GetWindow(); + EXPECT_TRUE(NSEqualPoints([window frame].origin, [child frame].origin)); +} + +TEST_F(StatusBubbleMacTest, StatuBubbleRespectsBaseFrameLimits) { + NSWindow* window = test_window(); + + // Show the bubble and make sure it has the same origin as |window|. + bubble_->SetStatus(UTF8ToUTF16("Showing")); + NSWindow* child = GetWindow(); + EXPECT_TRUE(NSEqualPoints([window frame].origin, [child frame].origin)); + + // Hide the bubble, change base frame offset, and show it again. + bubble_->Hide(); + + NSPoint baseFrameOffset = NSMakePoint(0, [window frame].size.height / 3); + EXPECT_GT(baseFrameOffset.y, 0); + [delegate_ forceBaseFrameOffset:baseFrameOffset]; + + bubble_->SetStatus(UTF8ToUTF16("Reshowing")); + + // The bubble should reattach in the correct location. + child = GetWindow(); + NSPoint expectedOrigin = [window frame].origin; + expectedOrigin.x += baseFrameOffset.x; + expectedOrigin.y += baseFrameOffset.y; + EXPECT_TRUE(NSEqualPoints(expectedOrigin, [child frame].origin)); +} + +TEST_F(StatusBubbleMacTest, ExpandBubble) { + NSWindow* window = test_window(); + ASSERT_TRUE(window); + NSRect window_frame = [window frame]; + window_frame.size.width = 600.0; + [window setFrame:window_frame display:YES]; + + // Check basic expansion + bubble_->SetStatus(UTF8ToUTF16("Showing")); + EXPECT_TRUE(IsVisible()); + bubble_->SetURL(GURL("http://www.battersbox.com/peter_paul_and_mary.html"), + string16()); + EXPECT_TRUE([GetURLText() hasSuffix:@"\u2026"]); + bubble_->ExpandBubble(); + EXPECT_TRUE(IsVisible()); + EXPECT_NSEQ(@"www.battersbox.com/peter_paul_and_mary.html", GetURLText()); + bubble_->Hide(); + + // Make sure bubble resets after hide. + bubble_->SetStatus(UTF8ToUTF16("Showing")); + bubble_->SetURL(GURL("http://www.snickersnee.com/pioneer_fishstix.html"), + string16()); + EXPECT_TRUE([GetURLText() hasSuffix:@"\u2026"]); + // ...and that it expands again properly. + bubble_->ExpandBubble(); + EXPECT_NSEQ(@"www.snickersnee.com/pioneer_fishstix.html", GetURLText()); + // ...again, again! + bubble_->SetURL(GURL("http://www.battersbox.com/peter_paul_and_mary.html"), + string16()); + bubble_->ExpandBubble(); + EXPECT_NSEQ(@"www.battersbox.com/peter_paul_and_mary.html", GetURLText()); + bubble_->Hide(); + + window_frame = [window frame]; + window_frame.size.width = 300.0; + [window setFrame:window_frame display:YES]; + + // Very long URL's will be cut off even in the expanded state. + bubble_->SetStatus(UTF8ToUTF16("Showing")); + const char veryLongUrl[] = + "http://www.diewahrscheinlichlaengstepralinederwelt.com/duuuuplo.html"; + bubble_->SetURL(GURL(veryLongUrl), string16()); + EXPECT_TRUE([GetURLText() hasSuffix:@"\u2026"]); + bubble_->ExpandBubble(); + EXPECT_TRUE([GetURLText() hasSuffix:@"\u2026"]); +} + + diff --git a/chrome/browser/ui/cocoa/status_icons/status_icon_mac.h b/chrome/browser/ui/cocoa/status_icons/status_icon_mac.h new file mode 100644 index 0000000..c414ec90 --- /dev/null +++ b/chrome/browser/ui/cocoa/status_icons/status_icon_mac.h @@ -0,0 +1,44 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_STATUS_ICONS_STATUS_ICON_MAC_H_ +#define CHROME_BROWSER_UI_COCOA_STATUS_ICONS_STATUS_ICON_MAC_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#include "base/string16.h" +#include "chrome/browser/status_icons/status_icon.h" + +class SkBitmap; +@class NSStatusItem; +@class StatusItemController; + +class StatusIconMac : public StatusIcon { + public: + StatusIconMac(); + virtual ~StatusIconMac(); + + // Overridden from StatusIcon + virtual void SetImage(const SkBitmap& image); + virtual void SetPressedImage(const SkBitmap& image); + virtual void SetToolTip(const string16& tool_tip); + + protected: + // Overridden from StatusIcon. + virtual void UpdatePlatformContextMenu(menus::MenuModel* menu); + + private: + // Getter for item_ that allows lazy initialization. + NSStatusItem* item(); + scoped_nsobject<NSStatusItem> item_; + + scoped_nsobject<StatusItemController> controller_; + + DISALLOW_COPY_AND_ASSIGN(StatusIconMac); +}; + + +#endif // CHROME_BROWSER_UI_COCOA_STATUS_ICONS_STATUS_ICON_MAC_H_ diff --git a/chrome/browser/ui/cocoa/status_icons/status_icon_mac.mm b/chrome/browser/ui/cocoa/status_icons/status_icon_mac.mm new file mode 100644 index 0000000..3f3d5a1 --- /dev/null +++ b/chrome/browser/ui/cocoa/status_icons/status_icon_mac.mm @@ -0,0 +1,82 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/status_icons/status_icon_mac.h" + +#include "base/logging.h" +#include "base/sys_string_conversions.h" +#include "skia/ext/skia_utils_mac.h" +#include "third_party/skia/include/core/SkBitmap.h" + +@interface StatusItemController : NSObject { + StatusIconMac* statusIcon_; // weak +} +- initWithIcon:(StatusIconMac*)icon; +- (void)handleClick:(id)sender; + +@end // @interface StatusItemController + +@implementation StatusItemController + +- (id)initWithIcon:(StatusIconMac*)icon { + statusIcon_ = icon; + return self; +} + +- (void)handleClick:(id)sender { + // Pass along the click notification to our owner. + DCHECK(statusIcon_); + statusIcon_->DispatchClickEvent(); +} + +@end + +StatusIconMac::StatusIconMac() + : item_(NULL) { + controller_.reset([[StatusItemController alloc] initWithIcon:this]); +} + +StatusIconMac::~StatusIconMac() { + // Remove the status item from the status bar. + if (item_) + [[NSStatusBar systemStatusBar] removeStatusItem:item_]; +} + +NSStatusItem* StatusIconMac::item() { + if (!item_.get()) { + // Create a new status item. + item_.reset([[[NSStatusBar systemStatusBar] + statusItemWithLength:NSSquareStatusItemLength] retain]); + [item_ setEnabled:YES]; + [item_ setTarget:controller_]; + [item_ setAction:@selector(handleClick:)]; + [item_ setHighlightMode:YES]; + } + return item_.get(); +} + +void StatusIconMac::SetImage(const SkBitmap& bitmap) { + if (!bitmap.isNull()) { + NSImage* image = gfx::SkBitmapToNSImage(bitmap); + if (image) + [item() setImage:image]; + } +} + +void StatusIconMac::SetPressedImage(const SkBitmap& bitmap) { + if (!bitmap.isNull()) { + NSImage* image = gfx::SkBitmapToNSImage(bitmap); + if (image) + [item() setAlternateImage:image]; + } +} + +void StatusIconMac::SetToolTip(const string16& tool_tip) { + [item() setToolTip:base::SysUTF16ToNSString(tool_tip)]; +} + +void StatusIconMac::UpdatePlatformContextMenu(menus::MenuModel* menu) { + // TODO(atwilson): Add support for context menus for Mac when actually needed + // (not yet used by anything) - http://crbug.com/37375. +} diff --git a/chrome/browser/ui/cocoa/status_icons/status_icon_mac_unittest.mm b/chrome/browser/ui/cocoa/status_icons/status_icon_mac_unittest.mm new file mode 100644 index 0000000..45d1950 --- /dev/null +++ b/chrome/browser/ui/cocoa/status_icons/status_icon_mac_unittest.mm @@ -0,0 +1,30 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "app/resource_bundle.h" +#include "base/string_util.h" +#include "base/utf_string_conversions.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "chrome/browser/ui/cocoa/status_icons/status_icon_mac.h" +#include "grit/browser_resources.h" +#include "grit/theme_resources.h" +#include "testing/gmock/include/gmock/gmock.h" +#include "testing/gtest/include/gtest/gtest.h" + +class SkBitmap; + +class StatusIconMacTest : public CocoaTest { +}; + +TEST_F(StatusIconMacTest, Create) { + // Create an icon, set the tool tip, then shut it down (checks for leaks). + scoped_ptr<StatusIcon> icon(new StatusIconMac()); + SkBitmap* bitmap = ResourceBundle::GetSharedInstance().GetBitmapNamed( + IDR_STATUS_TRAY_ICON); + icon->SetImage(*bitmap); + SkBitmap* pressed = ResourceBundle::GetSharedInstance().GetBitmapNamed( + IDR_STATUS_TRAY_ICON_PRESSED); + icon->SetPressedImage(*pressed); + icon->SetToolTip(ASCIIToUTF16("tool tip")); +} diff --git a/chrome/browser/ui/cocoa/status_icons/status_tray_mac.h b/chrome/browser/ui/cocoa/status_icons/status_tray_mac.h new file mode 100644 index 0000000..0b8326f --- /dev/null +++ b/chrome/browser/ui/cocoa/status_icons/status_tray_mac.h @@ -0,0 +1,24 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_STATUS_ICONS_STATUS_TRAY_MAC_H_ +#define CHROME_BROWSER_UI_COCOA_STATUS_ICONS_STATUS_TRAY_MAC_H_ +#pragma once + +#include "chrome/browser/status_icons/status_tray.h" + +class StatusTrayMac : public StatusTray { + public: + StatusTrayMac(); + + protected: + // Factory method for creating a status icon. + virtual StatusIcon* CreatePlatformStatusIcon(); + + private: + DISALLOW_COPY_AND_ASSIGN(StatusTrayMac); +}; + +#endif // CHROME_BROWSER_UI_COCOA_STATUS_ICONS_STATUS_TRAY_MAC_H_ + diff --git a/chrome/browser/ui/cocoa/status_icons/status_tray_mac.mm b/chrome/browser/ui/cocoa/status_icons/status_tray_mac.mm new file mode 100644 index 0000000..5d6c3e2 --- /dev/null +++ b/chrome/browser/ui/cocoa/status_icons/status_tray_mac.mm @@ -0,0 +1,18 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/status_icons/status_tray_mac.h" + +#include "chrome/browser/ui/cocoa/status_icons/status_icon_mac.h" + +StatusTray* StatusTray::Create() { + return new StatusTrayMac(); +} + +StatusTrayMac::StatusTrayMac() { +} + +StatusIcon* StatusTrayMac::CreatePlatformStatusIcon() { + return new StatusIconMac(); +} diff --git a/chrome/browser/ui/cocoa/styled_text_field.h b/chrome/browser/ui/cocoa/styled_text_field.h new file mode 100644 index 0000000..68a65b7 --- /dev/null +++ b/chrome/browser/ui/cocoa/styled_text_field.h @@ -0,0 +1,29 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +@class StyledTextFieldCell; + +// An implementation of NSTextField that is designed to work with +// StyledTextFieldCell. Provides methods to redraw the field when cell +// decorations have changed and overrides |mouseDown:| to properly handle clicks +// in sections of the cell with decorations. +@interface StyledTextField : NSTextField { +} + +// Repositions and redraws the field editor. Call this method when the cell's +// text frame has changed (whenever changing cell decorations). +- (void)resetFieldEditorFrameIfNeeded; + +// Returns the amount of the field's width which is not being taken up +// by the text contents. May be negative if the contents are large +// enough to scroll. +- (CGFloat)availableDecorationWidth; + +@end + +@interface StyledTextField (ExposedForTesting) +- (StyledTextFieldCell*)styledTextFieldCell; +@end diff --git a/chrome/browser/ui/cocoa/styled_text_field.mm b/chrome/browser/ui/cocoa/styled_text_field.mm new file mode 100644 index 0000000..31dd3a7 --- /dev/null +++ b/chrome/browser/ui/cocoa/styled_text_field.mm @@ -0,0 +1,61 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/styled_text_field.h" + +#include "base/logging.h" +#import "chrome/browser/ui/cocoa/styled_text_field_cell.h" + +@implementation StyledTextField + +- (StyledTextFieldCell*)styledTextFieldCell { + DCHECK([[self cell] isKindOfClass:[StyledTextFieldCell class]]); + return static_cast<StyledTextFieldCell*>([self cell]); +} + +// Cocoa text fields are edited by placing an NSTextView as subview, +// positioned by the cell's -editWithFrame:inView:... method. Using +// the standard -makeFirstResponder: machinery to reposition the field +// editor results in resetting the field editor's editing state, which +// AutocompleteEditViewMac monitors. This causes problems because +// editing can require the field editor to be repositioned, which +// could disrupt editing. This code repositions the subview directly, +// which causes no editing-state changes. +- (void)resetFieldEditorFrameIfNeeded { + // No action if not editing. + NSText* editor = [self currentEditor]; + if (!editor) { + return; + } + + // When editing, we should have exactly one subview, which is a + // clipview containing the editor (for purposes of scrolling). + NSArray* subviews = [self subviews]; + DCHECK_EQ([subviews count], 1U); + DCHECK([editor isDescendantOf:self]); + if ([subviews count] == 0) { + return; + } + + // If the frame is already right, don't make any visible changes. + StyledTextFieldCell* cell = [self styledTextFieldCell]; + const NSRect frame([cell drawingRectForBounds:[self bounds]]); + NSView* subview = [subviews objectAtIndex:0]; + if (NSEqualRects(frame, [subview frame])) { + return; + } + + [subview setFrame:frame]; + + // Make sure the selection remains visible. + [editor scrollRangeToVisible:[editor selectedRange]]; +} + +- (CGFloat)availableDecorationWidth { + NSAttributedString* as = [self attributedStringValue]; + const NSSize size([as size]); + const NSRect bounds([self bounds]); + return NSWidth(bounds) - size.width; +} +@end diff --git a/chrome/browser/ui/cocoa/styled_text_field_cell.h b/chrome/browser/ui/cocoa/styled_text_field_cell.h new file mode 100644 index 0000000..55fed3c --- /dev/null +++ b/chrome/browser/ui/cocoa/styled_text_field_cell.h @@ -0,0 +1,58 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_STYLED_TEXT_FIELD_CELL_H_ +#define CHROME_BROWSER_UI_COCOA_STYLED_TEXT_FIELD_CELL_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +typedef enum { + StyledTextFieldCellRoundedAll = 0, + StyledTextFieldCellRoundedLeft = 1 +} StyledTextFieldCellRoundedFlags; + +// StyledTextFieldCell customizes the look of the standard Cocoa text field. +// The border and focus ring are modified, as is the font baseline. Subclasses +// can override |drawInteriorWithFrame:inView:| to provide custom drawing for +// decorations, but they must make sure to call the superclass' implementation +// with a modified frame after performing any custom drawing. + +@interface StyledTextFieldCell : NSTextFieldCell { +} + +@end + +// Methods intended to be overridden by subclasses, not part of the public API +// and should not be called outside of subclasses. +@interface StyledTextFieldCell (ProtectedMethods) + +// Return the portion of the cell to show the text cursor over. The default +// implementation returns the full |cellFrame|. Subclasses should override this +// method if they add any decorations. +- (NSRect)textCursorFrameForFrame:(NSRect)cellFrame; + +// Return the portion of the cell to use for text display. This corresponds to +// the frame with our added decorations sliced off. The default implementation +// returns the full |cellFrame|, as by default there are no decorations. +// Subclasses should override this method if they add any decorations. +- (NSRect)textFrameForFrame:(NSRect)cellFrame; + +// Baseline adjust for the text in this cell. Defaults to 0. Subclasses should +// override as needed. +- (CGFloat)baselineAdjust; + +// Radius of the corners of the field. Defaults to square corners (0.0). +- (CGFloat)cornerRadius; + +// Which corners of the field to round. Defaults to RoundedAll. +- (StyledTextFieldCellRoundedFlags)roundedFlags; + +// Returns YES if a light themed bezel should be drawn under the text field. +// Default implementation returns NO. +- (BOOL)shouldDrawBezel; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_STYLED_TEXT_FIELD_CELL_H_ diff --git a/chrome/browser/ui/cocoa/styled_text_field_cell.mm b/chrome/browser/ui/cocoa/styled_text_field_cell.mm new file mode 100644 index 0000000..56b0cf3 --- /dev/null +++ b/chrome/browser/ui/cocoa/styled_text_field_cell.mm @@ -0,0 +1,217 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/styled_text_field_cell.h" + +#include "app/resource_bundle.h" +#include "base/logging.h" +#include "chrome/browser/themes/browser_theme_provider.h" +#import "chrome/browser/ui/cocoa/themed_window.h" +#include "gfx/font.h" +#include "grit/theme_resources.h" +#import "third_party/GTM/AppKit/GTMNSBezierPath+RoundRect.h" + +namespace { + +NSBezierPath* RectPathWithInset(StyledTextFieldCellRoundedFlags roundedFlags, + const NSRect frame, + const CGFloat inset, + const CGFloat outerRadius) { + NSRect insetFrame = NSInsetRect(frame, inset, inset); + + if (outerRadius > 0.0) { + CGFloat leftRadius = outerRadius - inset; + CGFloat rightRadius = + (roundedFlags == StyledTextFieldCellRoundedLeft) ? 0 : leftRadius; + + return [NSBezierPath gtm_bezierPathWithRoundRect:insetFrame + topLeftCornerRadius:leftRadius + topRightCornerRadius:rightRadius + bottomLeftCornerRadius:leftRadius + bottomRightCornerRadius:rightRadius]; + } else { + return [NSBezierPath bezierPathWithRect:insetFrame]; + } +} + +// Similar to |NSRectFill()|, additionally sets |color| as the fill +// color. |outerRadius| greater than 0.0 uses rounded corners, with +// inset backed out of the radius. +void FillRectWithInset(StyledTextFieldCellRoundedFlags roundedFlags, + const NSRect frame, + const CGFloat inset, + const CGFloat outerRadius, + NSColor* color) { + NSBezierPath* path = + RectPathWithInset(roundedFlags, frame, inset, outerRadius); + [color setFill]; + [path fill]; +} + +// Similar to |NSFrameRectWithWidth()|, additionally sets |color| as +// the stroke color (as opposed to the fill color). |outerRadius| +// greater than 0.0 uses rounded corners, with inset backed out of the +// radius. +void FrameRectWithInset(StyledTextFieldCellRoundedFlags roundedFlags, + const NSRect frame, + const CGFloat inset, + const CGFloat outerRadius, + const CGFloat lineWidth, + NSColor* color) { + const CGFloat finalInset = inset + (lineWidth / 2.0); + NSBezierPath* path = + RectPathWithInset(roundedFlags, frame, finalInset, outerRadius); + [color setStroke]; + [path setLineWidth:lineWidth]; + [path stroke]; +} + +// TODO(shess): Maybe we need a |cocoa_util.h|? +class ScopedSaveGraphicsState { + public: + ScopedSaveGraphicsState() + : context_([NSGraphicsContext currentContext]) { + [context_ saveGraphicsState]; + } + explicit ScopedSaveGraphicsState(NSGraphicsContext* context) + : context_(context) { + [context_ saveGraphicsState]; + } + ~ScopedSaveGraphicsState() { + [context_ restoreGraphicsState]; + } + +private: + NSGraphicsContext* context_; +}; + +} // namespace + +@implementation StyledTextFieldCell + +- (CGFloat)baselineAdjust { + return 0.0; +} + +- (CGFloat)cornerRadius { + return 0.0; +} + +- (StyledTextFieldCellRoundedFlags)roundedFlags { + return StyledTextFieldCellRoundedAll; +} + +- (BOOL)shouldDrawBezel { + return NO; +} + +// Returns the same value as textCursorFrameForFrame, but does not call it +// directly to avoid potential infinite loops. +- (NSRect)textFrameForFrame:(NSRect)cellFrame { + return NSInsetRect(cellFrame, 0, [self baselineAdjust]); +} + +// Returns the same value as textFrameForFrame, but does not call it directly to +// avoid potential infinite loops. +- (NSRect)textCursorFrameForFrame:(NSRect)cellFrame { + return NSInsetRect(cellFrame, 0, [self baselineAdjust]); +} + +// Override to show the I-beam cursor only in the area given by +// |textCursorFrameForFrame:|. +- (void)resetCursorRect:(NSRect)cellFrame inView:(NSView *)controlView { + [super resetCursorRect:[self textCursorFrameForFrame:cellFrame] + inView:controlView]; +} + +// For NSTextFieldCell this is the area within the borders. For our +// purposes, we count the info decorations as being part of the +// border. +- (NSRect)drawingRectForBounds:(NSRect)theRect { + return [super drawingRectForBounds:[self textFrameForFrame:theRect]]; +} + +// TODO(shess): This code is manually drawing the cell's border area, +// but otherwise the cell assumes -setBordered:YES for purposes of +// calculating things like the editing area. This is probably +// incorrect. I know that this affects -drawingRectForBounds:. +- (void)drawWithFrame:(NSRect)cellFrame inView:(NSView*)controlView { + DCHECK([controlView isFlipped]); + StyledTextFieldCellRoundedFlags roundedFlags = [self roundedFlags]; + + // TODO(shess): This inset is also reflected by |kFieldVisualInset| + // in autocomplete_popup_view_mac.mm. + const NSRect frame = NSInsetRect(cellFrame, 0, 1); + const CGFloat radius = [self cornerRadius]; + + // Paint button background image if there is one (otherwise the border won't + // look right). + BrowserThemeProvider* themeProvider = + static_cast<BrowserThemeProvider*>([[controlView window] themeProvider]); + if (themeProvider) { + NSColor* backgroundImageColor = + themeProvider->GetNSImageColorNamed(IDR_THEME_BUTTON_BACKGROUND, false); + if (backgroundImageColor) { + // Set the phase to match window. + NSRect trueRect = [controlView convertRect:cellFrame toView:nil]; + NSPoint midPoint = NSMakePoint(NSMinX(trueRect), NSMaxY(trueRect)); + [[NSGraphicsContext currentContext] setPatternPhase:midPoint]; + + // NOTE(shess): This seems like it should be using a 0.0 inset, + // but AFAICT using a 0.5 inset is important in mixing the + // toolbar background and the omnibox background. + FillRectWithInset(roundedFlags, frame, 0.5, radius, backgroundImageColor); + } + + // Draw the outer stroke (over the background). + BOOL active = [[controlView window] isMainWindow]; + NSColor* strokeColor = themeProvider->GetNSColor( + active ? BrowserThemeProvider::COLOR_TOOLBAR_BUTTON_STROKE : + BrowserThemeProvider::COLOR_TOOLBAR_BUTTON_STROKE_INACTIVE, + true); + FrameRectWithInset(roundedFlags, frame, 0.0, radius, 1.0, strokeColor); + } + + // Fill interior with background color. + FillRectWithInset(roundedFlags, frame, 1.0, radius, [self backgroundColor]); + + // Draw the shadow. For the rounded-rect case, the shadow needs to + // slightly turn in at the corners. |shadowFrame| is at the same + // midline as the inner border line on the top and left, but at the + // outer border line on the bottom and right. The clipping change + // will clip the bottom and right edges (and corner). + { + ScopedSaveGraphicsState state; + [RectPathWithInset(roundedFlags, frame, 1.0, radius) addClip]; + const NSRect shadowFrame = NSOffsetRect(frame, 0.5, 0.5); + NSColor* shadowShade = [NSColor colorWithCalibratedWhite:0.0 alpha:0.05]; + FrameRectWithInset(roundedFlags, shadowFrame, 0.5, radius - 0.5, + 1.0, shadowShade); + } + + // Draw optional bezel below bottom stroke. + if ([self shouldDrawBezel] && themeProvider && + themeProvider->UsingDefaultTheme()) { + + [themeProvider->GetNSColor( + BrowserThemeProvider::COLOR_TOOLBAR_BEZEL, true) set]; + NSRect bezelRect = NSMakeRect(cellFrame.origin.x, + NSMaxY(cellFrame) - 0.5, + NSWidth(cellFrame), + 1.0); + bezelRect = NSInsetRect(bezelRect, radius - 0.5, 0.0); + NSRectFill(bezelRect); + } + + // Draw the focus ring if needed. + if ([self showsFirstResponder]) { + NSColor* color = + [[NSColor keyboardFocusIndicatorColor] colorWithAlphaComponent:0.5]; + FrameRectWithInset(roundedFlags, frame, 0.0, radius, 2.0, color); + } + + [self drawInteriorWithFrame:cellFrame inView:controlView]; +} + +@end diff --git a/chrome/browser/ui/cocoa/styled_text_field_cell_unittest.mm b/chrome/browser/ui/cocoa/styled_text_field_cell_unittest.mm new file mode 100644 index 0000000..2df3c65 --- /dev/null +++ b/chrome/browser/ui/cocoa/styled_text_field_cell_unittest.mm @@ -0,0 +1,93 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/styled_text_field_cell.h" +#import "chrome/browser/ui/cocoa/styled_text_field_test_helper.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +namespace { + +// Width of the field so that we don't have to ask |field_| for it all +// the time. +const CGFloat kWidth(300.0); + +class StyledTextFieldCellTest : public CocoaTest { + public: + StyledTextFieldCellTest() { + // Make sure this is wide enough to play games with the cell + // decorations. + const NSRect frame = NSMakeRect(0, 0, kWidth, 30); + + scoped_nsobject<StyledTextFieldTestCell> cell( + [[StyledTextFieldTestCell alloc] initTextCell:@"Testing"]); + cell_ = cell.get(); + [cell_ setEditable:YES]; + [cell_ setBordered:YES]; + + scoped_nsobject<NSTextField> view( + [[NSTextField alloc] initWithFrame:frame]); + view_ = view.get(); + [view_ setCell:cell_]; + + [[test_window() contentView] addSubview:view_]; + } + + NSTextField* view_; + StyledTextFieldTestCell* cell_; +}; + +// Basic view tests (AddRemove, Display). +TEST_VIEW(StyledTextFieldCellTest, view_); + +// Test drawing, mostly to ensure nothing leaks or crashes. +TEST_F(StyledTextFieldCellTest, FocusedDisplay) { + [view_ display]; + + // Test focused drawing. + [test_window() makePretendKeyWindowAndSetFirstResponder:view_]; + [view_ display]; + [test_window() clearPretendKeyWindowAndFirstResponder]; + + // Test display of various cell configurations. + [cell_ setLeftMargin:5]; + [view_ display]; + + [cell_ setRightMargin:15]; + [view_ display]; +} + +// The editor frame should be slightly inset from the text frame. +TEST_F(StyledTextFieldCellTest, DrawingRectForBounds) { + const NSRect bounds = [view_ bounds]; + NSRect textFrame = [cell_ textFrameForFrame:bounds]; + NSRect drawingRect = [cell_ drawingRectForBounds:bounds]; + + EXPECT_FALSE(NSIsEmptyRect(drawingRect)); + EXPECT_TRUE(NSContainsRect(textFrame, NSInsetRect(drawingRect, 1, 1))); + + [cell_ setLeftMargin:10]; + textFrame = [cell_ textFrameForFrame:bounds]; + drawingRect = [cell_ drawingRectForBounds:bounds]; + EXPECT_FALSE(NSIsEmptyRect(drawingRect)); + EXPECT_TRUE(NSContainsRect(textFrame, NSInsetRect(drawingRect, 1, 1))); + + [cell_ setRightMargin:20]; + textFrame = [cell_ textFrameForFrame:bounds]; + drawingRect = [cell_ drawingRectForBounds:bounds]; + EXPECT_FALSE(NSIsEmptyRect(drawingRect)); + EXPECT_TRUE(NSContainsRect(NSInsetRect(textFrame, 1, 1), drawingRect)); + + [cell_ setLeftMargin:0]; + textFrame = [cell_ textFrameForFrame:bounds]; + drawingRect = [cell_ drawingRectForBounds:bounds]; + EXPECT_FALSE(NSIsEmptyRect(drawingRect)); + EXPECT_TRUE(NSContainsRect(NSInsetRect(textFrame, 1, 1), drawingRect)); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/styled_text_field_test_helper.h b/chrome/browser/ui/cocoa/styled_text_field_test_helper.h new file mode 100644 index 0000000..eb90cbf --- /dev/null +++ b/chrome/browser/ui/cocoa/styled_text_field_test_helper.h @@ -0,0 +1,16 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> +#import "chrome/browser/ui/cocoa/styled_text_field_cell.h" + +// Subclass of StyledTextFieldCell that allows you to slice off sections on the +// left and right of the cell. +@interface StyledTextFieldTestCell : StyledTextFieldCell { + CGFloat leftMargin_; + CGFloat rightMargin_; +} +@property (nonatomic, assign) CGFloat leftMargin; +@property (nonatomic, assign) CGFloat rightMargin; +@end diff --git a/chrome/browser/ui/cocoa/styled_text_field_test_helper.mm b/chrome/browser/ui/cocoa/styled_text_field_test_helper.mm new file mode 100644 index 0000000..20f566d --- /dev/null +++ b/chrome/browser/ui/cocoa/styled_text_field_test_helper.mm @@ -0,0 +1,18 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> +#import "chrome/browser/ui/cocoa/styled_text_field_test_helper.h" + +@implementation StyledTextFieldTestCell +@synthesize leftMargin = leftMargin_; +@synthesize rightMargin = rightMargin_; + +- (NSRect)textFrameForFrame:(NSRect)frame { + NSRect textFrame = [super textFrameForFrame:frame]; + textFrame.origin.x += leftMargin_; + textFrame.size.width -= (leftMargin_ + rightMargin_); + return textFrame; +} +@end diff --git a/chrome/browser/ui/cocoa/styled_text_field_unittest.mm b/chrome/browser/ui/cocoa/styled_text_field_unittest.mm new file mode 100644 index 0000000..dfaaa5fa --- /dev/null +++ b/chrome/browser/ui/cocoa/styled_text_field_unittest.mm @@ -0,0 +1,198 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#import "base/cocoa_protocols_mac.h" +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/styled_text_field.h" +#import "chrome/browser/ui/cocoa/styled_text_field_cell.h" +#import "chrome/browser/ui/cocoa/styled_text_field_test_helper.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "testing/gtest/include/gtest/gtest.h" +#import "third_party/ocmock/OCMock/OCMock.h" + +namespace { + +// Width of the field so that we don't have to ask |field_| for it all +// the time. +static const CGFloat kWidth(300.0); + +class StyledTextFieldTest : public CocoaTest { + public: + StyledTextFieldTest() { + // Make sure this is wide enough to play games with the cell + // decorations. + NSRect frame = NSMakeRect(0, 0, kWidth, 30); + + scoped_nsobject<StyledTextFieldTestCell> cell( + [[StyledTextFieldTestCell alloc] initTextCell:@"Testing"]); + cell_ = cell.get(); + [cell_ setEditable:YES]; + [cell_ setBordered:YES]; + + scoped_nsobject<StyledTextField> field( + [[StyledTextField alloc] initWithFrame:frame]); + field_ = field.get(); + [field_ setCell:cell_]; + + [[test_window() contentView] addSubview:field_]; + } + + // Helper to return the field-editor frame being used w/in |field_|. + NSRect EditorFrame() { + EXPECT_TRUE([field_ currentEditor]); + EXPECT_EQ([[field_ subviews] count], 1U); + if ([[field_ subviews] count] > 0) { + return [[[field_ subviews] objectAtIndex:0] frame]; + } else { + // Return something which won't work so the caller can soldier + // on. + return NSZeroRect; + } + } + + StyledTextField* field_; + StyledTextFieldTestCell* cell_; +}; + +// Basic view tests (AddRemove, Display). +TEST_VIEW(StyledTextFieldTest, field_); + +// Test that we get the same cell from -cell and +// -styledTextFieldCell. +TEST_F(StyledTextFieldTest, Cell) { + StyledTextFieldCell* cell = [field_ styledTextFieldCell]; + EXPECT_EQ(cell, [field_ cell]); + EXPECT_TRUE(cell != nil); +} + +// Test that becoming first responder sets things up correctly. +TEST_F(StyledTextFieldTest, FirstResponder) { + EXPECT_EQ(nil, [field_ currentEditor]); + EXPECT_EQ([[field_ subviews] count], 0U); + [test_window() makePretendKeyWindowAndSetFirstResponder:field_]; + EXPECT_FALSE(nil == [field_ currentEditor]); + EXPECT_EQ([[field_ subviews] count], 1U); + EXPECT_TRUE([[field_ currentEditor] isDescendantOf:field_]); +} + +TEST_F(StyledTextFieldTest, AvailableDecorationWidth) { + // A fudge factor to account for how much space the border takes up. + // The test shouldn't be too dependent on the field's internals, but + // it also shouldn't let deranged cases fall through the cracks + // (like nothing available with no text, or everything available + // with some text). + const CGFloat kBorderWidth = 20.0; + + // With no contents, almost the entire width is available for + // decorations. + [field_ setStringValue:@""]; + CGFloat availableWidth = [field_ availableDecorationWidth]; + EXPECT_LE(availableWidth, kWidth); + EXPECT_GT(availableWidth, kWidth - kBorderWidth); + + // With minor contents, most of the remaining width is available for + // decorations. + NSDictionary* attributes = + [NSDictionary dictionaryWithObject:[field_ font] + forKey:NSFontAttributeName]; + NSString* string = @"Hello world"; + const NSSize size([string sizeWithAttributes:attributes]); + [field_ setStringValue:string]; + availableWidth = [field_ availableDecorationWidth]; + EXPECT_LE(availableWidth, kWidth - size.width); + EXPECT_GT(availableWidth, kWidth - size.width - kBorderWidth); + + // With huge contents, nothing at all is left for decorations. + string = @"A long string which is surely wider than field_ can hold."; + [field_ setStringValue:string]; + availableWidth = [field_ availableDecorationWidth]; + EXPECT_LT(availableWidth, 0.0); +} + +// Test drawing, mostly to ensure nothing leaks or crashes. +TEST_F(StyledTextFieldTest, Display) { + [field_ display]; + + // Test focused drawing. + [test_window() makePretendKeyWindowAndSetFirstResponder:field_]; + [field_ display]; +} + +// Test that the field editor gets the same bounds when focus is delivered by +// the standard focusing machinery, or by -resetFieldEditorFrameIfNeeded. +TEST_F(StyledTextFieldTest, ResetFieldEditorBase) { + // Capture the editor frame resulting from the standard focus machinery. + [test_window() makePretendKeyWindowAndSetFirstResponder:field_]; + const NSRect baseEditorFrame(EditorFrame()); + + // Setting a hint should result in a strictly smaller editor frame. + EXPECT_EQ(0, [cell_ leftMargin]); + EXPECT_EQ(0, [cell_ rightMargin]); + [cell_ setLeftMargin:10]; + [field_ resetFieldEditorFrameIfNeeded]; + EXPECT_FALSE(NSEqualRects(baseEditorFrame, EditorFrame())); + EXPECT_TRUE(NSContainsRect(baseEditorFrame, EditorFrame())); + + // Resetting the margin and using -resetFieldEditorFrameIfNeeded should result + // in the same frame as the standard focus machinery. + [cell_ setLeftMargin:0]; + [field_ resetFieldEditorFrameIfNeeded]; + EXPECT_TRUE(NSEqualRects(baseEditorFrame, EditorFrame())); +} + +// Test that the field editor gets the same bounds when focus is delivered by +// the standard focusing machinery, or by -resetFieldEditorFrameIfNeeded. +TEST_F(StyledTextFieldTest, ResetFieldEditorLeftMargin) { + const CGFloat kLeftMargin = 20; + + // Start the cell off with a non-zero left margin. + [cell_ setLeftMargin:kLeftMargin]; + [cell_ setRightMargin:0]; + + // Capture the editor frame resulting from the standard focus machinery. + [test_window() makePretendKeyWindowAndSetFirstResponder:field_]; + const NSRect baseEditorFrame(EditorFrame()); + + // Clearing the margin should result in a strictly larger editor frame. + [cell_ setLeftMargin:0]; + [field_ resetFieldEditorFrameIfNeeded]; + EXPECT_FALSE(NSEqualRects(baseEditorFrame, EditorFrame())); + EXPECT_TRUE(NSContainsRect(EditorFrame(), baseEditorFrame)); + + // Setting the same margin and using -resetFieldEditorFrameIfNeeded should + // result in the same frame as the standard focus machinery. + [cell_ setLeftMargin:kLeftMargin]; + [field_ resetFieldEditorFrameIfNeeded]; + EXPECT_TRUE(NSEqualRects(baseEditorFrame, EditorFrame())); +} + +// Test that the field editor gets the same bounds when focus is delivered by +// the standard focusing machinery, or by -resetFieldEditorFrameIfNeeded. +TEST_F(StyledTextFieldTest, ResetFieldEditorRightMargin) { + const CGFloat kRightMargin = 20; + + // Start the cell off with a non-zero right margin. + [cell_ setLeftMargin:0]; + [cell_ setRightMargin:kRightMargin]; + + // Capture the editor frame resulting from the standard focus machinery. + [test_window() makePretendKeyWindowAndSetFirstResponder:field_]; + const NSRect baseEditorFrame(EditorFrame()); + + // Clearing the margin should result in a strictly larger editor frame. + [cell_ setRightMargin:0]; + [field_ resetFieldEditorFrameIfNeeded]; + EXPECT_FALSE(NSEqualRects(baseEditorFrame, EditorFrame())); + EXPECT_TRUE(NSContainsRect(EditorFrame(), baseEditorFrame)); + + // Setting the same margin and using -resetFieldEditorFrameIfNeeded should + // result in the same frame as the standard focus machinery. + [cell_ setRightMargin:kRightMargin]; + [field_ resetFieldEditorFrameIfNeeded]; + EXPECT_TRUE(NSEqualRects(baseEditorFrame, EditorFrame())); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/tab_contents_controller.h b/chrome/browser/ui/cocoa/tab_contents_controller.h new file mode 100644 index 0000000..f821ea4 --- /dev/null +++ b/chrome/browser/ui/cocoa/tab_contents_controller.h @@ -0,0 +1,75 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_TAB_CONTENTS_CONTROLLER_H_ +#define CHROME_BROWSER_UI_COCOA_TAB_CONTENTS_CONTROLLER_H_ +#pragma once + +#include <Cocoa/Cocoa.h> + +#include "base/scoped_ptr.h" + +class TabContents; +class TabContentsNotificationBridge; +@class TabContentsController; + +// The interface for the tab contents view controller's delegate. + +@protocol TabContentsControllerDelegate + +// Tells the delegate when the tab contents view's frame is about to change. +- (void)tabContentsViewFrameWillChange:(TabContentsController*)source + frameRect:(NSRect)frameRect; + +@end + +// A class that controls the TabContents view. It manages displaying the +// native view for a given TabContents. +// Note that just creating the class does not display the view. We defer +// inserting it until the box is the correct size to avoid multiple resize +// messages to the renderer. You must call |-ensureContentsVisible| to display +// the render widget host view. + +@interface TabContentsController : NSViewController { + @private + TabContents* contents_; // weak + // Delegate to be notified about size changes. + id<TabContentsControllerDelegate> delegate_; // weak + scoped_ptr<TabContentsNotificationBridge> tabContentsBridge_; +} +@property(readonly, nonatomic) TabContents* tabContents; + +- (id)initWithContents:(TabContents*)contents + delegate:(id<TabContentsControllerDelegate>)delegate; + +// Call when the tab contents is about to be replaced with the currently +// selected tab contents to do not trigger unnecessary content relayout. +- (void)ensureContentsSizeDoesNotChange; + +// Call when the tab view is properly sized and the render widget host view +// should be put into the view hierarchy. +- (void)ensureContentsVisible; + +// Call to change the underlying tab contents object. View is not changed, +// call |-ensureContentsVisible| to display the |newContents|'s render widget +// host view. +- (void)changeTabContents:(TabContents*)newContents; + +// Called when the tab contents is the currently selected tab and is about to be +// removed from the view hierarchy. +- (void)willBecomeUnselectedTab; + +// Called when the tab contents is about to be put into the view hierarchy as +// the selected tab. Handles things such as ensuring the toolbar is correctly +// enabled. +- (void)willBecomeSelectedTab; + +// Called when the tab contents is updated in some non-descript way (the +// notification from the model isn't specific). |updatedContents| could reflect +// an entirely new tab contents object. +- (void)tabDidChange:(TabContents*)updatedContents; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_TAB_CONTENTS_CONTROLLER_H_ diff --git a/chrome/browser/ui/cocoa/tab_contents_controller.mm b/chrome/browser/ui/cocoa/tab_contents_controller.mm new file mode 100644 index 0000000..c7b5cf8 --- /dev/null +++ b/chrome/browser/ui/cocoa/tab_contents_controller.mm @@ -0,0 +1,212 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/tab_contents_controller.h" + +#include "base/mac_util.h" +#include "base/scoped_nsobject.h" +#include "chrome/browser/renderer_host/render_view_host.h" +#include "chrome/browser/renderer_host/render_widget_host_view.h" +#include "chrome/browser/tab_contents/navigation_controller.h" +#include "chrome/browser/tab_contents/tab_contents.h" +#include "chrome/common/notification_details.h" +#include "chrome/common/notification_observer.h" +#include "chrome/common/notification_registrar.h" +#include "chrome/common/notification_source.h" +#include "chrome/common/notification_type.h" + + +@interface TabContentsController(Private) +// Forwards frame update to |delegate_| (ResizeNotificationView calls it). +- (void)tabContentsViewFrameWillChange:(NSRect)frameRect; +// Notification from TabContents (forwarded by TabContentsNotificationBridge). +- (void)tabContentsRenderViewHostChanged:(RenderViewHost*)oldHost + newHost:(RenderViewHost*)newHost; +@end + + +// A supporting C++ bridge object to register for TabContents notifications. + +class TabContentsNotificationBridge : public NotificationObserver { + public: + explicit TabContentsNotificationBridge(TabContentsController* controller); + + // Overriden from NotificationObserver. + virtual void Observe(NotificationType type, + const NotificationSource& source, + const NotificationDetails& details); + // Register for |contents|'s notifications, remove all prior registrations. + void ChangeTabContents(TabContents* contents); + private: + NotificationRegistrar registrar_; + TabContentsController* controller_; // weak, owns us +}; + +TabContentsNotificationBridge::TabContentsNotificationBridge( + TabContentsController* controller) + : controller_(controller) { +} + +void TabContentsNotificationBridge::Observe( + NotificationType type, + const NotificationSource& source, + const NotificationDetails& details) { + if (type == NotificationType::RENDER_VIEW_HOST_CHANGED) { + RenderViewHostSwitchedDetails* switched_details = + Details<RenderViewHostSwitchedDetails>(details).ptr(); + [controller_ tabContentsRenderViewHostChanged:switched_details->old_host + newHost:switched_details->new_host]; + } else { + NOTREACHED(); + } +} + +void TabContentsNotificationBridge::ChangeTabContents(TabContents* contents) { + registrar_.RemoveAll(); + if (contents) { + registrar_.Add(this, + NotificationType::RENDER_VIEW_HOST_CHANGED, + Source<NavigationController>(&contents->controller())); + } +} + + +// A custom view that notifies |controller| that view's frame is changing. + +@interface ResizeNotificationView : NSView { + TabContentsController* controller_; +} +- (id)initWithController:(TabContentsController*)controller; +@end + +@implementation ResizeNotificationView + +- (id)initWithController:(TabContentsController*)controller { + if ((self = [super initWithFrame:NSZeroRect])) { + controller_ = controller; + } + return self; +} + +- (void)setFrame:(NSRect)frameRect { + [controller_ tabContentsViewFrameWillChange:frameRect]; + [super setFrame:frameRect]; +} + +@end + + +@implementation TabContentsController +@synthesize tabContents = contents_; + +- (id)initWithContents:(TabContents*)contents + delegate:(id<TabContentsControllerDelegate>)delegate { + if ((self = [super initWithNibName:nil bundle:nil])) { + contents_ = contents; + delegate_ = delegate; + tabContentsBridge_.reset(new TabContentsNotificationBridge(self)); + tabContentsBridge_->ChangeTabContents(contents); + } + return self; +} + +- (void)dealloc { + // make sure our contents have been removed from the window + [[self view] removeFromSuperview]; + [super dealloc]; +} + +- (void)loadView { + scoped_nsobject<ResizeNotificationView> view( + [[ResizeNotificationView alloc] initWithController:self]); + [view setAutoresizingMask:NSViewHeightSizable|NSViewWidthSizable]; + [self setView:view]; +} + +- (void)ensureContentsSizeDoesNotChange { + if (contents_) { + NSView* contentsContainer = [self view]; + NSArray* subviews = [contentsContainer subviews]; + if ([subviews count] > 0) + [contents_->GetNativeView() setAutoresizingMask:NSViewNotSizable]; + } +} + +// Call when the tab view is properly sized and the render widget host view +// should be put into the view hierarchy. +- (void)ensureContentsVisible { + if (!contents_) + return; + NSView* contentsContainer = [self view]; + NSArray* subviews = [contentsContainer subviews]; + NSView* contentsNativeView = contents_->GetNativeView(); + + NSRect contentsNativeViewFrame = [contentsContainer frame]; + contentsNativeViewFrame.origin = NSZeroPoint; + + [delegate_ tabContentsViewFrameWillChange:self + frameRect:contentsNativeViewFrame]; + + // Native view is resized to the actual size before it becomes visible + // to avoid flickering. + [contentsNativeView setFrame:contentsNativeViewFrame]; + if ([subviews count] == 0) { + [contentsContainer addSubview:contentsNativeView]; + } else if ([subviews objectAtIndex:0] != contentsNativeView) { + [contentsContainer replaceSubview:[subviews objectAtIndex:0] + with:contentsNativeView]; + } + // Restore autoresizing properties possibly stripped by + // ensureContentsSizeDoesNotChange call. + [contentsNativeView setAutoresizingMask:NSViewWidthSizable| + NSViewHeightSizable]; +} + +- (void)changeTabContents:(TabContents*)newContents { + contents_ = newContents; + tabContentsBridge_->ChangeTabContents(contents_); +} + +- (void)tabContentsViewFrameWillChange:(NSRect)frameRect { + [delegate_ tabContentsViewFrameWillChange:self frameRect:frameRect]; +} + +- (void)tabContentsRenderViewHostChanged:(RenderViewHost*)oldHost + newHost:(RenderViewHost*)newHost { + if (oldHost && newHost && oldHost->view() && newHost->view()) { + newHost->view()->set_reserved_contents_rect( + oldHost->view()->reserved_contents_rect()); + } else { + [delegate_ tabContentsViewFrameWillChange:self + frameRect:[[self view] frame]]; + } +} + +- (void)willBecomeUnselectedTab { + // The RWHV is ripped out of the view hierarchy on tab switches, so it never + // formally resigns first responder status. Handle this by explicitly sending + // a Blur() message to the renderer, but only if the RWHV currently has focus. + RenderViewHost* rvh = [self tabContents]->render_view_host(); + if (rvh && rvh->view() && rvh->view()->HasFocus()) + rvh->Blur(); +} + +- (void)willBecomeSelectedTab { + // Do not explicitly call Focus() here, as the RWHV may not actually have + // focus (for example, if the omnibox has focus instead). The TabContents + // logic will restore focus to the appropriate view. +} + +- (void)tabDidChange:(TabContents*)updatedContents { + // Calling setContentView: here removes any first responder status + // the view may have, so avoid changing the view hierarchy unless + // the view is different. + if ([self tabContents] != updatedContents) { + [self changeTabContents:updatedContents]; + if ([self tabContents]) + [self ensureContentsVisible]; + } +} + +@end diff --git a/chrome/browser/ui/cocoa/tab_controller.h b/chrome/browser/ui/cocoa/tab_controller.h new file mode 100644 index 0000000..c85bb62 --- /dev/null +++ b/chrome/browser/ui/cocoa/tab_controller.h @@ -0,0 +1,113 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_TAB_CONTROLLER_H_ +#define CHROME_BROWSER_UI_COCOA_TAB_CONTROLLER_H_ +#pragma once + +#import <Cocoa/Cocoa.h> +#include "chrome/browser/tab_menu_model.h" +#import "chrome/browser/ui/cocoa/hover_close_button.h" + +// The loading/waiting state of the tab. +enum TabLoadingState { + kTabDone, + kTabLoading, + kTabWaiting, + kTabCrashed, +}; + +@class MenuController; +namespace TabControllerInternal { +class MenuDelegate; +} +@class TabView; +@protocol TabControllerTarget; + +// A class that manages a single tab in the tab strip. Set its target/action +// to be sent a message when the tab is selected by the user clicking. Setting +// the |loading| property to YES visually indicates that this tab is currently +// loading content via a spinner. +// +// The tab has the notion of an "icon view" which can be used to display +// identifying characteristics such as a favicon, or since it's a full-fledged +// view, something with state and animation such as a throbber for illustrating +// progress. The default in the nib is an image view so nothing special is +// required if that's all you need. + +@interface TabController : NSViewController { + @private + IBOutlet NSView* iconView_; + IBOutlet NSTextField* titleView_; + IBOutlet HoverCloseButton* closeButton_; + + NSRect originalIconFrame_; // frame of iconView_ as loaded from nib + BOOL isIconShowing_; // last state of iconView_ in updateVisibility + + BOOL app_; + BOOL mini_; + BOOL pinned_; + BOOL selected_; + TabLoadingState loadingState_; + CGFloat iconTitleXOffset_; // between left edges of icon and title + CGFloat titleCloseWidthOffset_; // between right edges of icon and close btn. + id<TabControllerTarget> target_; // weak, where actions are sent + SEL action_; // selector sent when tab is selected by clicking + scoped_ptr<TabMenuModel> contextMenuModel_; + scoped_ptr<TabControllerInternal::MenuDelegate> contextMenuDelegate_; + scoped_nsobject<MenuController> contextMenuController_; +} + +@property(assign, nonatomic) TabLoadingState loadingState; + +@property(assign, nonatomic) SEL action; +@property(assign, nonatomic) BOOL app; +@property(assign, nonatomic) BOOL mini; +@property(assign, nonatomic) BOOL pinned; +@property(assign, nonatomic) BOOL selected; +@property(assign, nonatomic) id target; + +// Minimum and maximum allowable tab width. The minimum width does not show +// the icon or the close button. The selected tab always has at least a close +// button so it has a different minimum width. ++ (CGFloat)minTabWidth; ++ (CGFloat)maxTabWidth; ++ (CGFloat)minSelectedTabWidth; ++ (CGFloat)miniTabWidth; ++ (CGFloat)appTabWidth; + +// The view associated with this controller, pre-casted as a TabView +- (TabView*)tabView; + +// Closes the associated TabView by relaying the message to |target_| to +// perform the close. +- (IBAction)closeTab:(id)sender; + +// Replace the current icon view with the given view. |iconView| will be +// resized to the size of the current icon view. +- (void)setIconView:(NSView*)iconView; +- (NSView*)iconView; + +// Called by the tabs to determine whether we are in rapid (tab) closure mode. +// In this mode, we handle clicks slightly differently due to animation. +// Ideally, tabs would know about their own animation and wouldn't need this. +- (BOOL)inRapidClosureMode; + +// Updates the visibility of certain subviews, such as the icon and close +// button, based on criteria such as the tab's selected state and its current +// width. +- (void)updateVisibility; + +// Update the title color to match the tabs current state. +- (void)updateTitleColor; +@end + +@interface TabController(TestingAPI) +- (NSString*)toolTip; +- (int)iconCapacity; +- (BOOL)shouldShowIcon; +- (BOOL)shouldShowCloseButton; +@end // TabController(TestingAPI) + +#endif // CHROME_BROWSER_UI_COCOA_TAB_CONTROLLER_H_ diff --git a/chrome/browser/ui/cocoa/tab_controller.mm b/chrome/browser/ui/cocoa/tab_controller.mm new file mode 100644 index 0000000..7a6f675 --- /dev/null +++ b/chrome/browser/ui/cocoa/tab_controller.mm @@ -0,0 +1,313 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "app/l10n_util_mac.h" +#include "base/mac_util.h" +#import "chrome/browser/themes/browser_theme_provider.h" +#import "chrome/browser/ui/cocoa/menu_controller.h" +#import "chrome/browser/ui/cocoa/tab_controller.h" +#import "chrome/browser/ui/cocoa/tab_controller_target.h" +#import "chrome/browser/ui/cocoa/tab_view.h" +#import "chrome/browser/ui/cocoa/themed_window.h" +#import "chrome/common/extensions/extension.h" +#include "grit/generated_resources.h" + +@implementation TabController + +@synthesize action = action_; +@synthesize app = app_; +@synthesize loadingState = loadingState_; +@synthesize mini = mini_; +@synthesize pinned = pinned_; +@synthesize target = target_; + +namespace TabControllerInternal { + +// A C++ delegate that handles enabling/disabling menu items and handling when +// a menu command is chosen. Also fixes up the menu item label for "pin/unpin +// tab". +class MenuDelegate : public menus::SimpleMenuModel::Delegate { + public: + explicit MenuDelegate(id<TabControllerTarget> target, TabController* owner) + : target_(target), + owner_(owner) {} + + // Overridden from menus::SimpleMenuModel::Delegate + virtual bool IsCommandIdChecked(int command_id) const { return false; } + virtual bool IsCommandIdEnabled(int command_id) const { + TabStripModel::ContextMenuCommand command = + static_cast<TabStripModel::ContextMenuCommand>(command_id); + return [target_ isCommandEnabled:command forController:owner_]; + } + virtual bool GetAcceleratorForCommandId( + int command_id, + menus::Accelerator* accelerator) { return false; } + virtual void ExecuteCommand(int command_id) { + TabStripModel::ContextMenuCommand command = + static_cast<TabStripModel::ContextMenuCommand>(command_id); + [target_ commandDispatch:command forController:owner_]; + } + + private: + id<TabControllerTarget> target_; // weak + TabController* owner_; // weak, owns me +}; + +} // TabControllerInternal namespace + +// The min widths match the windows values and are sums of left + right +// padding, of which we have no comparable constants (we draw using paths, not +// images). The selected tab width includes the close button width. ++ (CGFloat)minTabWidth { return 31; } ++ (CGFloat)minSelectedTabWidth { return 46; } ++ (CGFloat)maxTabWidth { return 220; } ++ (CGFloat)miniTabWidth { return 53; } ++ (CGFloat)appTabWidth { return 66; } + +- (TabView*)tabView { + return static_cast<TabView*>([self view]); +} + +- (id)init { + self = [super initWithNibName:@"TabView" bundle:mac_util::MainAppBundle()]; + if (self != nil) { + isIconShowing_ = YES; + NSNotificationCenter* defaultCenter = [NSNotificationCenter defaultCenter]; + [defaultCenter addObserver:self + selector:@selector(viewResized:) + name:NSViewFrameDidChangeNotification + object:[self view]]; + [defaultCenter addObserver:self + selector:@selector(themeChangedNotification:) + name:kBrowserThemeDidChangeNotification + object:nil]; + } + return self; +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [[self tabView] setController:nil]; + [super dealloc]; +} + +// The internals of |-setSelected:| but doesn't check if we're already set +// to |selected|. Pass the selection change to the subviews that need it and +// mark ourselves as needing a redraw. +- (void)internalSetSelected:(BOOL)selected { + selected_ = selected; + TabView* tabView = static_cast<TabView*>([self view]); + DCHECK([tabView isKindOfClass:[TabView class]]); + [tabView setState:selected]; + [tabView cancelAlert]; + [self updateVisibility]; + [self updateTitleColor]; +} + +// Called when the tab's nib is done loading and all outlets are hooked up. +- (void)awakeFromNib { + // Remember the icon's frame, so that if the icon is ever removed, a new + // one can later replace it in the proper location. + originalIconFrame_ = [iconView_ frame]; + + // When the icon is removed, the title expands to the left to fill the space + // left by the icon. When the close button is removed, the title expands to + // the right to fill its space. These are the amounts to expand and contract + // titleView_ under those conditions. + NSRect titleFrame = [titleView_ frame]; + iconTitleXOffset_ = NSMinX(titleFrame) - NSMinX(originalIconFrame_); + titleCloseWidthOffset_ = NSMaxX([closeButton_ frame]) - NSMaxX(titleFrame); + + [self internalSetSelected:selected_]; +} + +// Called when Cocoa wants to display the context menu. Lazily instantiate +// the menu based off of the cross-platform model. Re-create the menu and +// model every time to get the correct labels and enabling. +- (NSMenu*)menu { + contextMenuDelegate_.reset( + new TabControllerInternal::MenuDelegate(target_, self)); + contextMenuModel_.reset(new TabMenuModel(contextMenuDelegate_.get(), + [self pinned])); + contextMenuController_.reset( + [[MenuController alloc] initWithModel:contextMenuModel_.get() + useWithPopUpButtonCell:NO]); + return [contextMenuController_ menu]; +} + +- (IBAction)closeTab:(id)sender { + if ([[self target] respondsToSelector:@selector(closeTab:)]) { + [[self target] performSelector:@selector(closeTab:) + withObject:[self view]]; + } +} + +- (void)setTitle:(NSString*)title { + [[self view] setToolTip:title]; + if ([self mini] && ![self selected]) { + TabView* tabView = static_cast<TabView*>([self view]); + DCHECK([tabView isKindOfClass:[TabView class]]); + [tabView startAlert]; + } + [super setTitle:title]; +} + +- (void)setSelected:(BOOL)selected { + if (selected_ != selected) + [self internalSetSelected:selected]; +} + +- (BOOL)selected { + return selected_; +} + +- (void)setIconView:(NSView*)iconView { + [iconView_ removeFromSuperview]; + iconView_ = iconView; + if ([self app]) { + NSRect appIconFrame = [iconView frame]; + appIconFrame.origin = originalIconFrame_.origin; + // Center the icon. + appIconFrame.origin.x = ([TabController appTabWidth] - + NSWidth(appIconFrame)) / 2.0; + [iconView setFrame:appIconFrame]; + } else { + [iconView_ setFrame:originalIconFrame_]; + } + // Ensure that the icon is suppressed if no icon is set or if the tab is too + // narrow to display one. + [self updateVisibility]; + + if (iconView_) + [[self view] addSubview:iconView_]; +} + +- (NSView*)iconView { + return iconView_; +} + +- (NSString*)toolTip { + return [[self view] toolTip]; +} + +// Return a rough approximation of the number of icons we could fit in the +// tab. We never actually do this, but it's a helpful guide for determining +// how much space we have available. +- (int)iconCapacity { + CGFloat width = NSMaxX([closeButton_ frame]) - NSMinX(originalIconFrame_); + CGFloat iconWidth = NSWidth(originalIconFrame_); + + return width / iconWidth; +} + +// Returns YES if we should show the icon. When tabs get too small, we clip +// the favicon before the close button for selected tabs, and prefer the +// favicon for unselected tabs. The icon can also be suppressed more directly +// by clearing iconView_. +- (BOOL)shouldShowIcon { + if (!iconView_) + return NO; + + if ([self mini]) + return YES; + + int iconCapacity = [self iconCapacity]; + if ([self selected]) + return iconCapacity >= 2; + return iconCapacity >= 1; +} + +// Returns YES if we should be showing the close button. The selected tab +// always shows the close button. +- (BOOL)shouldShowCloseButton { + if ([self mini]) + return NO; + return ([self selected] || [self iconCapacity] >= 3); +} + +- (void)updateVisibility { + // iconView_ may have been replaced or it may be nil, so [iconView_ isHidden] + // won't work. Instead, the state of the icon is tracked separately in + // isIconShowing_. + BOOL oldShowIcon = isIconShowing_ ? YES : NO; + BOOL newShowIcon = [self shouldShowIcon] ? YES : NO; + + [iconView_ setHidden:newShowIcon ? NO : YES]; + isIconShowing_ = newShowIcon; + + // If the tab is a mini-tab, hide the title. + [titleView_ setHidden:[self mini]]; + + BOOL oldShowCloseButton = [closeButton_ isHidden] ? NO : YES; + BOOL newShowCloseButton = [self shouldShowCloseButton] ? YES : NO; + + [closeButton_ setHidden:newShowCloseButton ? NO : YES]; + + // Adjust the title view based on changes to the icon's and close button's + // visibility. + NSRect titleFrame = [titleView_ frame]; + + if (oldShowIcon != newShowIcon) { + // Adjust the left edge of the title view according to the presence or + // absence of the icon view. + + if (newShowIcon) { + titleFrame.origin.x += iconTitleXOffset_; + titleFrame.size.width -= iconTitleXOffset_; + } else { + titleFrame.origin.x -= iconTitleXOffset_; + titleFrame.size.width += iconTitleXOffset_; + } + } + + if (oldShowCloseButton != newShowCloseButton) { + // Adjust the right edge of the title view according to the presence or + // absence of the close button. + if (newShowCloseButton) + titleFrame.size.width -= titleCloseWidthOffset_; + else + titleFrame.size.width += titleCloseWidthOffset_; + } + + [titleView_ setFrame:titleFrame]; +} + +- (void)updateTitleColor { + NSColor* titleColor = nil; + ThemeProvider* theme = [[[self view] window] themeProvider]; + if (theme && ![self selected]) { + titleColor = + theme->GetNSColor(BrowserThemeProvider::COLOR_BACKGROUND_TAB_TEXT, + true); + } + // Default to the selected text color unless told otherwise. + if (theme && !titleColor) { + titleColor = theme->GetNSColor(BrowserThemeProvider::COLOR_TAB_TEXT, + true); + } + [titleView_ setTextColor:titleColor ? titleColor : [NSColor textColor]]; +} + +// Called when our view is resized. If it gets too small, start by hiding +// the close button and only show it if tab is selected. Eventually, hide the +// icon as well. We know that this is for our view because we only registered +// for notifications from our specific view. +- (void)viewResized:(NSNotification*)info { + [self updateVisibility]; +} + +- (void)themeChangedNotification:(NSNotification*)notification { + [self updateTitleColor]; +} + +// Called by the tabs to determine whether we are in rapid (tab) closure mode. +- (BOOL)inRapidClosureMode { + if ([[self target] respondsToSelector:@selector(inRapidClosureMode)]) { + return [[self target] performSelector:@selector(inRapidClosureMode)] ? + YES : NO; + } + return NO; +} + +@end diff --git a/chrome/browser/ui/cocoa/tab_controller_target.h b/chrome/browser/ui/cocoa/tab_controller_target.h new file mode 100644 index 0000000..6eec01a --- /dev/null +++ b/chrome/browser/ui/cocoa/tab_controller_target.h @@ -0,0 +1,27 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_TAB_CONTROLLER_TARGET_H_ +#define CHROME_BROWSER_UI_COCOA_TAB_CONTROLLER_TARGET_H_ +#pragma once + +#include "chrome/browser/tabs/tab_strip_model.h" + +@class TabController; + +// A protocol to be implemented by a TabController's target. +@protocol TabControllerTarget +- (void)selectTab:(id)sender; +- (void)closeTab:(id)sender; + +// Dispatch context menu commands for the given tab controller. +- (void)commandDispatch:(TabStripModel::ContextMenuCommand)command + forController:(TabController*)controller; +// Returns YES if the specificed command should be enabled for the given +// controller. +- (BOOL)isCommandEnabled:(TabStripModel::ContextMenuCommand)command + forController:(TabController*)controller; +@end + +#endif // CHROME_BROWSER_UI_COCOA_TAB_CONTROLLER_TARGET_H_ diff --git a/chrome/browser/ui/cocoa/tab_controller_unittest.mm b/chrome/browser/ui/cocoa/tab_controller_unittest.mm new file mode 100644 index 0000000..e9bafe9 --- /dev/null +++ b/chrome/browser/ui/cocoa/tab_controller_unittest.mm @@ -0,0 +1,268 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#import "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/tab_controller.h" +#import "chrome/browser/ui/cocoa/tab_controller_target.h" +#include "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "testing/gtest/include/gtest/gtest.h" +#import "testing/gtest_mac.h" +#include "testing/platform_test.h" + +// Implements the target interface for the tab, which gets sent messages when +// the tab is clicked on by the user and when its close box is clicked. +@interface TabControllerTestTarget : NSObject<TabControllerTarget> { + @private + bool selected_; + bool closed_; +} +- (bool)selected; +- (bool)closed; +@end + +@implementation TabControllerTestTarget +- (bool)selected { + return selected_; +} +- (bool)closed { + return closed_; +} +- (void)selectTab:(id)sender { + selected_ = true; +} +- (void)closeTab:(id)sender { + closed_ = true; +} +- (void)mouseTimer:(NSTimer*)timer { + // Fire the mouseUp to break the TabView drag loop. + NSEvent* current = [NSApp currentEvent]; + NSWindow* window = [timer userInfo]; + NSEvent* up = [NSEvent mouseEventWithType:NSLeftMouseUp + location:[current locationInWindow] + modifierFlags:0 + timestamp:[current timestamp] + windowNumber:[window windowNumber] + context:nil + eventNumber:0 + clickCount:1 + pressure:1.0]; + [window postEvent:up atStart:YES]; +} +- (void)commandDispatch:(TabStripModel::ContextMenuCommand)command + forController:(TabController*)controller { +} +- (BOOL)isCommandEnabled:(TabStripModel::ContextMenuCommand)command + forController:(TabController*)controller { + return NO; +} +@end + +namespace { + +// The dragging code in TabView makes heavy use of autorelease pools so +// inherit from CocoaTest to have one created for us. +class TabControllerTest : public CocoaTest { + public: + TabControllerTest() { } +}; + +// Tests creating the controller, sticking it in a window, and removing it. +TEST_F(TabControllerTest, Creation) { + NSWindow* window = test_window(); + scoped_nsobject<TabController> controller([[TabController alloc] init]); + [[window contentView] addSubview:[controller view]]; + EXPECT_TRUE([controller tabView]); + EXPECT_EQ([[controller view] window], window); + [[controller view] display]; // Test drawing to ensure nothing leaks/crashes. + [[controller view] removeFromSuperview]; +} + +// Tests sending it a close message and ensuring that the target/action get +// called. Mimics the user clicking on the close button in the tab. +TEST_F(TabControllerTest, Close) { + NSWindow* window = test_window(); + scoped_nsobject<TabController> controller([[TabController alloc] init]); + [[window contentView] addSubview:[controller view]]; + + scoped_nsobject<TabControllerTestTarget> target( + [[TabControllerTestTarget alloc] init]); + EXPECT_FALSE([target closed]); + [controller setTarget:target]; + EXPECT_EQ(target.get(), [controller target]); + + [controller closeTab:nil]; + EXPECT_TRUE([target closed]); + + [[controller view] removeFromSuperview]; +} + +// Tests setting the |selected| property via code. +TEST_F(TabControllerTest, APISelection) { + NSWindow* window = test_window(); + scoped_nsobject<TabController> controller([[TabController alloc] init]); + [[window contentView] addSubview:[controller view]]; + + EXPECT_FALSE([controller selected]); + [controller setSelected:YES]; + EXPECT_TRUE([controller selected]); + + [[controller view] removeFromSuperview]; +} + +// Tests that setting the title of a tab sets the tooltip as well. +TEST_F(TabControllerTest, ToolTip) { + NSWindow* window = test_window(); + + scoped_nsobject<TabController> controller([[TabController alloc] init]); + [[window contentView] addSubview:[controller view]]; + + EXPECT_TRUE([[controller toolTip] length] == 0); + NSString *tooltip_string = @"Some text to use as a tab title"; + [controller setTitle:tooltip_string]; + EXPECT_NSEQ(tooltip_string, [controller toolTip]); +} + +// Tests setting the |loading| property via code. +TEST_F(TabControllerTest, Loading) { + NSWindow* window = test_window(); + scoped_nsobject<TabController> controller([[TabController alloc] init]); + [[window contentView] addSubview:[controller view]]; + + EXPECT_EQ(kTabDone, [controller loadingState]); + [controller setLoadingState:kTabWaiting]; + EXPECT_EQ(kTabWaiting, [controller loadingState]); + [controller setLoadingState:kTabLoading]; + EXPECT_EQ(kTabLoading, [controller loadingState]); + [controller setLoadingState:kTabDone]; + EXPECT_EQ(kTabDone, [controller loadingState]); + + [[controller view] removeFromSuperview]; +} + +// Tests selecting the tab with the mouse click and ensuring the target/action +// get called. +TEST_F(TabControllerTest, UserSelection) { + NSWindow* window = test_window(); + + // Create a tab at a known location in the window that we can click on + // to activate selection. + scoped_nsobject<TabController> controller([[TabController alloc] init]); + [[window contentView] addSubview:[controller view]]; + NSRect frame = [[controller view] frame]; + frame.size.width = [TabController minTabWidth]; + frame.origin = NSMakePoint(0, 0); + [[controller view] setFrame:frame]; + + // Set the target and action. + scoped_nsobject<TabControllerTestTarget> target( + [[TabControllerTestTarget alloc] init]); + EXPECT_FALSE([target selected]); + [controller setTarget:target]; + [controller setAction:@selector(selectTab:)]; + EXPECT_EQ(target.get(), [controller target]); + EXPECT_EQ(@selector(selectTab:), [controller action]); + + // In order to track a click, we have to fake a mouse down and a mouse + // up, but the down goes into a tight drag loop. To break the loop, we have + // to fire a timer that sends a mouse up event while the "drag" is ongoing. + [NSTimer scheduledTimerWithTimeInterval:0.1 + target:target.get() + selector:@selector(mouseTimer:) + userInfo:window + repeats:NO]; + NSEvent* current = [NSApp currentEvent]; + NSPoint click_point = NSMakePoint(frame.size.width / 2, + frame.size.height / 2); + NSEvent* down = [NSEvent mouseEventWithType:NSLeftMouseDown + location:click_point + modifierFlags:0 + timestamp:[current timestamp] + windowNumber:[window windowNumber] + context:nil + eventNumber:0 + clickCount:1 + pressure:1.0]; + [[controller view] mouseDown:down]; + + // Check our target was told the tab got selected. + EXPECT_TRUE([target selected]); + + [[controller view] removeFromSuperview]; +} + +TEST_F(TabControllerTest, IconCapacity) { + NSWindow* window = test_window(); + scoped_nsobject<TabController> controller([[TabController alloc] init]); + [[window contentView] addSubview:[controller view]]; + int cap = [controller iconCapacity]; + EXPECT_GE(cap, 1); + + NSRect frame = [[controller view] frame]; + frame.size.width += 500; + [[controller view] setFrame:frame]; + int newcap = [controller iconCapacity]; + EXPECT_GT(newcap, cap); +} + +TEST_F(TabControllerTest, ShouldShowIcon) { + NSWindow* window = test_window(); + scoped_nsobject<TabController> controller([[TabController alloc] init]); + [[window contentView] addSubview:[controller view]]; + int cap = [controller iconCapacity]; + EXPECT_GT(cap, 0); + + // Tab is minimum width, both icon and close box should be hidden. + NSRect frame = [[controller view] frame]; + frame.size.width = [TabController minTabWidth]; + [[controller view] setFrame:frame]; + EXPECT_FALSE([controller shouldShowIcon]); + EXPECT_FALSE([controller shouldShowCloseButton]); + + // Setting the icon when tab is at min width should not show icon (bug 18359). + scoped_nsobject<NSView> newIcon( + [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 16, 16)]); + [controller setIconView:newIcon.get()]; + EXPECT_TRUE([newIcon isHidden]); + + // Tab is at selected minimum width. Since it's selected, the close box + // should be visible. + [controller setSelected:YES]; + frame = [[controller view] frame]; + frame.size.width = [TabController minSelectedTabWidth]; + [[controller view] setFrame:frame]; + EXPECT_FALSE([controller shouldShowIcon]); + EXPECT_TRUE([newIcon isHidden]); + EXPECT_TRUE([controller shouldShowCloseButton]); + + // Test expanding the tab to max width and ensure the icon and close box + // get put back, even when de-selected. + frame.size.width = [TabController maxTabWidth]; + [[controller view] setFrame:frame]; + EXPECT_TRUE([controller shouldShowIcon]); + EXPECT_FALSE([newIcon isHidden]); + EXPECT_TRUE([controller shouldShowCloseButton]); + [controller setSelected:NO]; + EXPECT_TRUE([controller shouldShowIcon]); + EXPECT_TRUE([controller shouldShowCloseButton]); + + cap = [controller iconCapacity]; + EXPECT_GT(cap, 0); +} + +TEST_F(TabControllerTest, Menu) { + NSWindow* window = test_window(); + scoped_nsobject<TabController> controller([[TabController alloc] init]); + [[window contentView] addSubview:[controller view]]; + int cap = [controller iconCapacity]; + EXPECT_GT(cap, 0); + + // Asking the view for its menu should yield a valid menu. + NSMenu* menu = [[controller view] menu]; + EXPECT_TRUE(menu); + EXPECT_GT([menu numberOfItems], 0); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/tab_strip_controller.h b/chrome/browser/ui/cocoa/tab_strip_controller.h new file mode 100644 index 0000000..631f9cf --- /dev/null +++ b/chrome/browser/ui/cocoa/tab_strip_controller.h @@ -0,0 +1,259 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_TAB_STRIP_CONTROLLER_H_ +#define CHROME_BROWSER_UI_COCOA_TAB_STRIP_CONTROLLER_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#include "base/scoped_ptr.h" +#import "chrome/browser/ui/cocoa/tab_contents_controller.h" +#import "chrome/browser/ui/cocoa/tab_controller_target.h" +#import "chrome/browser/ui/cocoa/url_drop_target.h" +#import "third_party/GTM/AppKit/GTMWindowSheetController.h" + +@class NewTabButton; +@class TabContentsController; +@class TabView; +@class TabStripView; + +class Browser; +class ConstrainedWindowMac; +class TabStripModelObserverBridge; +class TabStripModel; +class TabContents; +class ToolbarModel; + +// The interface for the tab strip controller's delegate. +// Delegating TabStripModelObserverBridge's events (in lieu of directly +// subscribing to TabStripModelObserverBridge events, as TabStripController +// does) is necessary to guarantee a proper order of subviews layout updates, +// otherwise it might trigger unnesessary content relayout, UI flickering etc. +@protocol TabStripControllerDelegate + +// Stripped down version of TabStripModelObserverBridge:selectTabWithContents. +- (void)onSelectTabWithContents:(TabContents*)contents; + +// Stripped down version of TabStripModelObserverBridge:tabReplacedWithContents. +- (void)onReplaceTabWithContents:(TabContents*)contents; + +// Stripped down version of TabStripModelObserverBridge:tabChangedWithContents. +- (void)onSelectedTabChange:(TabStripModelObserver::TabChangeType)change; + +// Stripped down version of TabStripModelObserverBridge:tabDetachedWithContents. +- (void)onTabDetachedWithContents:(TabContents*)contents; + +@end + +// A class that handles managing the tab strip in a browser window. It uses +// a supporting C++ bridge object to register for notifications from the +// TabStripModel. The Obj-C part of this class handles drag and drop and all +// the other Cocoa-y aspects. +// +// For a full description of the design, see +// http://www.chromium.org/developers/design-documents/tab-strip-mac +@interface TabStripController : + NSObject<TabControllerTarget, + URLDropTargetController, + GTMWindowSheetControllerDelegate, + TabContentsControllerDelegate> { + @protected + // YES if tabs are to be laid out vertically instead of horizontally. + BOOL verticalLayout_; + + @private + scoped_nsobject<TabStripView> tabStripView_; + NSView* switchView_; // weak + scoped_nsobject<NSView> dragBlockingView_; // avoid bad window server drags + NewTabButton* newTabButton_; // weak, obtained from the nib. + + // Tracks the newTabButton_ for rollovers. + scoped_nsobject<NSTrackingArea> newTabTrackingArea_; + scoped_ptr<TabStripModelObserverBridge> bridge_; + Browser* browser_; // weak + TabStripModel* tabStripModel_; // weak + // Delegate that is informed about tab state changes. + id<TabStripControllerDelegate> delegate_; // weak + + // YES if the new tab button is currently displaying the hover image (if the + // mouse is currently over the button). + BOOL newTabButtonShowingHoverImage_; + + // Access to the TabContentsControllers (which own the parent view + // for the toolbar and associated tab contents) given an index. Call + // |indexFromModelIndex:| to convert a |tabStripModel_| index to a + // |tabContentsArray_| index. Do NOT assume that the indices of + // |tabStripModel_| and this array are identical, this is e.g. not true while + // tabs are animating closed (closed tabs are removed from |tabStripModel_| + // immediately, but from |tabContentsArray_| only after their close animation + // has completed). + scoped_nsobject<NSMutableArray> tabContentsArray_; + // An array of TabControllers which manage the actual tab views. See note + // above |tabContentsArray_|. |tabContentsArray_| and |tabArray_| always + // contain objects belonging to the same tabs at the same indices. + scoped_nsobject<NSMutableArray> tabArray_; + + // Set of TabControllers that are currently animating closed. + scoped_nsobject<NSMutableSet> closingControllers_; + + // These values are only used during a drag, and override tab positioning. + TabView* placeholderTab_; // weak. Tab being dragged + NSRect placeholderFrame_; // Frame to use + CGFloat placeholderStretchiness_; // Vertical force shown by streching tab. + NSRect droppedTabFrame_; // Initial frame of a dropped tab, for animation. + // Frame targets for all the current views. + // target frames are used because repeated requests to [NSView animator]. + // aren't coalesced, so we store frames to avoid redundant calls. + scoped_nsobject<NSMutableDictionary> targetFrames_; + NSRect newTabTargetFrame_; + // If YES, do not show the new tab button during layout. + BOOL forceNewTabButtonHidden_; + // YES if we've successfully completed the initial layout. When this is + // NO, we probably don't want to do any animation because we're just coming + // into being. + BOOL initialLayoutComplete_; + + // Width available for resizing the tabs (doesn't include the new tab + // button). Used to restrict the available width when closing many tabs at + // once to prevent them from resizing to fit the full width. If the entire + // width should be used, this will have a value of |kUseFullAvailableWidth|. + float availableResizeWidth_; + // A tracking area that's the size of the tab strip used to be notified + // when the mouse moves in the tab strip + scoped_nsobject<NSTrackingArea> trackingArea_; + TabView* hoveredTab_; // weak. Tab that the mouse is hovering over + + // Array of subviews which are permanent (and which should never be removed), + // such as the new-tab button, but *not* the tabs themselves. + scoped_nsobject<NSMutableArray> permanentSubviews_; + + // The default favicon, so we can use one copy for all buttons. + scoped_nsobject<NSImage> defaultFavIcon_; + + // The amount by which to indent the tabs on the left (to make room for the + // red/yellow/green buttons). + CGFloat indentForControls_; + + // Manages per-tab sheets. + scoped_nsobject<GTMWindowSheetController> sheetController_; + + // Is the mouse currently inside the strip; + BOOL mouseInside_; +} + +@property(nonatomic) CGFloat indentForControls; + +// Initialize the controller with a view and browser that contains +// everything else we'll need. |switchView| is the view whose contents get +// "switched" every time the user switches tabs. The children of this view +// will be released, so if you want them to stay around, make sure +// you have retained them. +// |delegate| is the one listening to filtered TabStripModelObserverBridge's +// events (see TabStripControllerDelegate for more details). +- (id)initWithView:(TabStripView*)view + switchView:(NSView*)switchView + browser:(Browser*)browser + delegate:(id<TabStripControllerDelegate>)delegate; + +// Return the view for the currently selected tab. +- (NSView*)selectedTabView; + +// Set the frame of the selected tab, also updates the internal frame dict. +- (void)setFrameOfSelectedTab:(NSRect)frame; + +// Move the given tab at index |from| in this window to the location of the +// current placeholder. +- (void)moveTabFromIndex:(NSInteger)from; + +// Drop a given TabContents at the location of the current placeholder. If there +// is no placeholder, it will go at the end. Used when dragging from another +// window when we don't have access to the TabContents as part of our strip. +// |frame| is in the coordinate system of the tab strip view and represents +// where the user dropped the new tab so it can be animated into its correct +// location when the tab is added to the model. If the tab was pinned in its +// previous window, setting |pinned| to YES will propagate that state to the +// new window. Mini-tabs are either app or pinned tabs; the app state is stored +// by the |contents|, but the |pinned| state is the caller's responsibility. +- (void)dropTabContents:(TabContentsWrapper*)contents + withFrame:(NSRect)frame + asPinnedTab:(BOOL)pinned; + +// Returns the index of the subview |view|. Returns -1 if not present. Takes +// closing tabs into account such that this index will correctly match the tab +// model. If |view| is in the process of closing, returns -1, as closing tabs +// are no longer in the model. +- (NSInteger)modelIndexForTabView:(NSView*)view; + +// Return the view at a given index. +- (NSView*)viewAtIndex:(NSUInteger)index; + +// Return the number of tab views in the tab strip. It's same as number of tabs +// in the model, except when a tab is closing, which will be counted in views +// count, but no longer in the model. +- (NSUInteger)viewsCount; + +// Set the placeholder for a dragged tab, allowing the |frame| and |strechiness| +// to be specified. This causes this tab to be rendered in an arbitrary position +- (void)insertPlaceholderForTab:(TabView*)tab + frame:(NSRect)frame + yStretchiness:(CGFloat)yStretchiness; + +// Returns whether or not |tab| can still be fully seen in the tab strip or if +// its current position would cause it be obscured by things such as the edge +// of the window or the window decorations. Returns YES only if the entire tab +// is visible. +- (BOOL)isTabFullyVisible:(TabView*)tab; + +// Show or hide the new tab button. The button is hidden immediately, but +// waits until the next call to |-layoutTabs| to show it again. +- (void)showNewTabButton:(BOOL)show; + +// Force the tabs to rearrange themselves to reflect the current model. +- (void)layoutTabs; + +// Are we in rapid (tab) closure mode? I.e., is a full layout deferred (while +// the user closes tabs)? Needed to overcome missing clicks during rapid tab +// closure. +- (BOOL)inRapidClosureMode; + +// Returns YES if the user is allowed to drag tabs on the strip at this moment. +// For example, this returns NO if there are any pending tab close animtations. +- (BOOL)tabDraggingAllowed; + +// Default height for tabs. ++ (CGFloat)defaultTabHeight; + +// Default indentation for tabs (see |indentForControls_|). ++ (CGFloat)defaultIndentForControls; + +// Returns the (lazily created) window sheet controller of this window. Used +// for the per-tab sheets. +- (GTMWindowSheetController*)sheetController; + +// Destroys the window sheet controller of this window, if it exists. The sheet +// controller can be recreated by a subsequent call to |-sheetController|. Must +// not be called if any sheets are currently open. +// TODO(viettrungluu): This is temporary code needed to allow sheets to work +// (read: not crash) in fullscreen mode. Once GTMWindowSheetController is +// modified to support moving sheets between windows, this code can go away. +// http://crbug.com/19093. +- (void)destroySheetController; + +// Returns the currently active TabContentsController. +- (TabContentsController*)activeTabContentsController; + + // See comments in browser_window_controller.h for documentation about these + // functions. +- (void)attachConstrainedWindow:(ConstrainedWindowMac*)window; +- (void)removeConstrainedWindow:(ConstrainedWindowMac*)window; + +@end + +// Notification sent when the number of tabs changes. The object will be this +// controller. +extern NSString* const kTabStripNumberOfTabsChanged; + +#endif // CHROME_BROWSER_UI_COCOA_TAB_STRIP_CONTROLLER_H_ diff --git a/chrome/browser/ui/cocoa/tab_strip_controller.mm b/chrome/browser/ui/cocoa/tab_strip_controller.mm new file mode 100644 index 0000000..7a34c58 --- /dev/null +++ b/chrome/browser/ui/cocoa/tab_strip_controller.mm @@ -0,0 +1,1879 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/tab_strip_controller.h" + +#import <QuartzCore/QuartzCore.h> + +#include <limits> +#include <string> + +#include "app/l10n_util.h" +#include "app/resource_bundle.h" +#include "base/mac_util.h" +#include "base/nsimage_cache_mac.h" +#include "base/sys_string_conversions.h" +#include "chrome/app/chrome_command_ids.h" +#include "chrome/browser/find_bar.h" +#include "chrome/browser/find_bar_controller.h" +#include "chrome/browser/metrics/user_metrics.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/debugger/devtools_window.h" +#include "chrome/browser/net/url_fixer_upper.h" +#include "chrome/browser/sidebar/sidebar_container.h" +#include "chrome/browser/sidebar/sidebar_manager.h" +#include "chrome/browser/tab_contents/navigation_controller.h" +#include "chrome/browser/tab_contents/navigation_entry.h" +#include "chrome/browser/tab_contents/tab_contents.h" +#include "chrome/browser/tab_contents/tab_contents_view.h" +#include "chrome/browser/tab_contents_wrapper.h" +#include "chrome/browser/tabs/tab_strip_model.h" +#include "chrome/browser/ui/browser.h" +#include "chrome/browser/ui/browser_navigator.h" +#import "chrome/browser/ui/cocoa/browser_window_controller.h" +#import "chrome/browser/ui/cocoa/constrained_window_mac.h" +#import "chrome/browser/ui/cocoa/new_tab_button.h" +#import "chrome/browser/ui/cocoa/tab_strip_view.h" +#import "chrome/browser/ui/cocoa/tab_contents_controller.h" +#import "chrome/browser/ui/cocoa/tab_controller.h" +#import "chrome/browser/ui/cocoa/tab_strip_model_observer_bridge.h" +#import "chrome/browser/ui/cocoa/tab_view.h" +#import "chrome/browser/ui/cocoa/throbber_view.h" +#include "grit/app_resources.h" +#include "grit/generated_resources.h" +#include "grit/theme_resources.h" +#include "skia/ext/skia_utils_mac.h" +#import "third_party/GTM/AppKit/GTMNSAnimation+Duration.h" + +NSString* const kTabStripNumberOfTabsChanged = @"kTabStripNumberOfTabsChanged"; + +namespace { + +// The images names used for different states of the new tab button. +NSString* const kNewTabHoverImage = @"newtab_h.pdf"; +NSString* const kNewTabImage = @"newtab.pdf"; +NSString* const kNewTabPressedImage = @"newtab_p.pdf"; + +// A value to indicate tab layout should use the full available width of the +// view. +const CGFloat kUseFullAvailableWidth = -1.0; + +// The amount by which tabs overlap. +const CGFloat kTabOverlap = 20.0; + +// The width and height for a tab's icon. +const CGFloat kIconWidthAndHeight = 16.0; + +// The amount by which the new tab button is offset (from the tabs). +const CGFloat kNewTabButtonOffset = 8.0; + +// The amount by which to shrink the tab strip (on the right) when the +// incognito badge is present. +const CGFloat kIncognitoBadgeTabStripShrink = 18; + +// Time (in seconds) in which tabs animate to their final position. +const NSTimeInterval kAnimationDuration = 0.125; + +// Helper class for doing NSAnimationContext calls that takes a bool to disable +// all the work. Useful for code that wants to conditionally animate. +class ScopedNSAnimationContextGroup { + public: + explicit ScopedNSAnimationContextGroup(bool animate) + : animate_(animate) { + if (animate_) { + [NSAnimationContext beginGrouping]; + } + } + + ~ScopedNSAnimationContextGroup() { + if (animate_) { + [NSAnimationContext endGrouping]; + } + } + + void SetCurrentContextDuration(NSTimeInterval duration) { + if (animate_) { + [[NSAnimationContext currentContext] gtm_setDuration:duration + eventMask:NSLeftMouseUpMask]; + } + } + + void SetCurrentContextShortestDuration() { + if (animate_) { + // The minimum representable time interval. This used to stop an + // in-progress animation as quickly as possible. + const NSTimeInterval kMinimumTimeInterval = + std::numeric_limits<NSTimeInterval>::min(); + // Directly set the duration to be short, avoiding the Steve slowmotion + // ettect the gtm_setDuration: provides. + [[NSAnimationContext currentContext] setDuration:kMinimumTimeInterval]; + } + } + +private: + bool animate_; + DISALLOW_COPY_AND_ASSIGN(ScopedNSAnimationContextGroup); +}; + +} // namespace + +@interface TabStripController (Private) +- (void)installTrackingArea; +- (void)addSubviewToPermanentList:(NSView*)aView; +- (void)regenerateSubviewList; +- (NSInteger)indexForContentsView:(NSView*)view; +- (void)updateFavIconForContents:(TabContents*)contents + atIndex:(NSInteger)modelIndex; +- (void)layoutTabsWithAnimation:(BOOL)animate + regenerateSubviews:(BOOL)doUpdate; +- (void)animationDidStopForController:(TabController*)controller + finished:(BOOL)finished; +- (NSInteger)indexFromModelIndex:(NSInteger)index; +- (NSInteger)numberOfOpenTabs; +- (NSInteger)numberOfOpenMiniTabs; +- (NSInteger)numberOfOpenNonMiniTabs; +- (void)mouseMoved:(NSEvent*)event; +- (void)setTabTrackingAreasEnabled:(BOOL)enabled; +- (void)droppingURLsAt:(NSPoint)point + givesIndex:(NSInteger*)index + disposition:(WindowOpenDisposition*)disposition; +- (void)setNewTabButtonHoverState:(BOOL)showHover; +@end + +// A simple view class that prevents the Window Server from dragging the area +// behind tabs. Sometimes core animation confuses it. Unfortunately, it can also +// falsely pick up clicks during rapid tab closure, so we have to account for +// that. +@interface TabStripControllerDragBlockingView : NSView { + TabStripController* controller_; // weak; owns us +} + +- (id)initWithFrame:(NSRect)frameRect + controller:(TabStripController*)controller; +@end + +@implementation TabStripControllerDragBlockingView +- (BOOL)mouseDownCanMoveWindow {return NO;} +- (void)drawRect:(NSRect)rect {} + +- (id)initWithFrame:(NSRect)frameRect + controller:(TabStripController*)controller { + if ((self = [super initWithFrame:frameRect])) + controller_ = controller; + return self; +} + +// In "rapid tab closure" mode (i.e., the user is clicking close tab buttons in +// rapid succession), the animations confuse Cocoa's hit testing (which appears +// to use cached results, among other tricks), so this view can somehow end up +// getting a mouse down event. Thus we do an explicit hit test during rapid tab +// closure, and if we find that we got a mouse down we shouldn't have, we send +// it off to the appropriate view. +- (void)mouseDown:(NSEvent*)event { + if ([controller_ inRapidClosureMode]) { + NSView* superview = [self superview]; + NSPoint hitLocation = + [[superview superview] convertPoint:[event locationInWindow] + fromView:nil]; + NSView* hitView = [superview hitTest:hitLocation]; + if (hitView != self) { + [hitView mouseDown:event]; + return; + } + } + [super mouseDown:event]; +} +@end + +#pragma mark - + +// A delegate, owned by the CAAnimation system, that is alerted when the +// animation to close a tab is completed. Calls back to the given tab strip +// to let it know that |controller_| is ready to be removed from the model. +// Since we only maintain weak references, the tab strip must call -invalidate: +// to prevent the use of dangling pointers. +@interface TabCloseAnimationDelegate : NSObject { + @private + TabStripController* strip_; // weak; owns us indirectly + TabController* controller_; // weak +} + +// Will tell |strip| when the animation for |controller|'s view has completed. +// These should not be nil, and will not be retained. +- (id)initWithTabStrip:(TabStripController*)strip + tabController:(TabController*)controller; + +// Invalidates this object so that no further calls will be made to +// |strip_|. This should be called when |strip_| is released, to +// prevent attempts to call into the released object. +- (void)invalidate; + +// CAAnimation delegate method +- (void)animationDidStop:(CAAnimation*)animation finished:(BOOL)finished; + +@end + +@implementation TabCloseAnimationDelegate + +- (id)initWithTabStrip:(TabStripController*)strip + tabController:(TabController*)controller { + if ((self == [super init])) { + DCHECK(strip && controller); + strip_ = strip; + controller_ = controller; + } + return self; +} + +- (void)invalidate { + strip_ = nil; + controller_ = nil; +} + +- (void)animationDidStop:(CAAnimation*)animation finished:(BOOL)finished { + [strip_ animationDidStopForController:controller_ finished:finished]; +} + +@end + +#pragma mark - + +// In general, there is a one-to-one correspondence between TabControllers, +// TabViews, TabContentsControllers, and the TabContents in the TabStripModel. +// In the steady-state, the indices line up so an index coming from the model +// is directly mapped to the same index in the parallel arrays holding our +// views and controllers. This is also true when new tabs are created (even +// though there is a small period of animation) because the tab is present +// in the model while the TabView is animating into place. As a result, nothing +// special need be done to handle "new tab" animation. +// +// This all goes out the window with the "close tab" animation. The animation +// kicks off in |-tabDetachedWithContents:atIndex:| with the notification that +// the tab has been removed from the model. The simplest solution at this +// point would be to remove the views and controllers as well, however once +// the TabView is removed from the view list, the tab z-order code takes care of +// removing it from the tab strip and we'll get no animation. That means if +// there is to be any visible animation, the TabView needs to stay around until +// its animation is complete. In order to maintain consistency among the +// internal parallel arrays, this means all structures are kept around until +// the animation completes. At this point, though, the model and our internal +// structures are out of sync: the indices no longer line up. As a result, +// there is a concept of a "model index" which represents an index valid in +// the TabStripModel. During steady-state, the "model index" is just the same +// index as our parallel arrays (as above), but during tab close animations, +// it is different, offset by the number of tabs preceding the index which +// are undergoing tab closing animation. As a result, the caller needs to be +// careful to use the available conversion routines when accessing the internal +// parallel arrays (e.g., -indexFromModelIndex:). Care also needs to be taken +// during tab layout to ignore closing tabs in the total width calculations and +// in individual tab positioning (to avoid moving them right back to where they +// were). +// +// In order to prevent actions being taken on tabs which are closing, the tab +// itself gets marked as such so it no longer will send back its select action +// or allow itself to be dragged. In addition, drags on the tab strip as a +// whole are disabled while there are tabs closing. + +@implementation TabStripController + +@synthesize indentForControls = indentForControls_; + +- (id)initWithView:(TabStripView*)view + switchView:(NSView*)switchView + browser:(Browser*)browser + delegate:(id<TabStripControllerDelegate>)delegate { + DCHECK(view && switchView && browser && delegate); + if ((self = [super init])) { + tabStripView_.reset([view retain]); + switchView_ = switchView; + browser_ = browser; + tabStripModel_ = browser_->tabstrip_model(); + delegate_ = delegate; + bridge_.reset(new TabStripModelObserverBridge(tabStripModel_, self)); + tabContentsArray_.reset([[NSMutableArray alloc] init]); + tabArray_.reset([[NSMutableArray alloc] init]); + + // Important note: any non-tab subviews not added to |permanentSubviews_| + // (see |-addSubviewToPermanentList:|) will be wiped out. + permanentSubviews_.reset([[NSMutableArray alloc] init]); + + ResourceBundle& rb = ResourceBundle::GetSharedInstance(); + defaultFavIcon_.reset([rb.GetNativeImageNamed(IDR_DEFAULT_FAVICON) retain]); + + [self setIndentForControls:[[self class] defaultIndentForControls]]; + + // TODO(viettrungluu): WTF? "For some reason, if the view is present in the + // nib a priori, it draws correctly. If we create it in code and add it to + // the tab view, it draws with all sorts of crazy artifacts." + newTabButton_ = [view newTabButton]; + [self addSubviewToPermanentList:newTabButton_]; + [newTabButton_ setTarget:nil]; + [newTabButton_ setAction:@selector(commandDispatch:)]; + [newTabButton_ setTag:IDC_NEW_TAB]; + // Set the images from code because Cocoa fails to find them in our sub + // bundle during tests. + [newTabButton_ setImage:nsimage_cache::ImageNamed(kNewTabImage)]; + [newTabButton_ + setAlternateImage:nsimage_cache::ImageNamed(kNewTabPressedImage)]; + newTabButtonShowingHoverImage_ = NO; + newTabTrackingArea_.reset( + [[NSTrackingArea alloc] initWithRect:[newTabButton_ bounds] + options:(NSTrackingMouseEnteredAndExited | + NSTrackingActiveAlways) + owner:self + userInfo:nil]); + [newTabButton_ addTrackingArea:newTabTrackingArea_.get()]; + targetFrames_.reset([[NSMutableDictionary alloc] init]); + + dragBlockingView_.reset( + [[TabStripControllerDragBlockingView alloc] initWithFrame:NSZeroRect + controller:self]); + [self addSubviewToPermanentList:dragBlockingView_]; + + newTabTargetFrame_ = NSMakeRect(0, 0, 0, 0); + availableResizeWidth_ = kUseFullAvailableWidth; + + closingControllers_.reset([[NSMutableSet alloc] init]); + + // Install the permanent subviews. + [self regenerateSubviewList]; + + // Watch for notifications that the tab strip view has changed size so + // we can tell it to layout for the new size. + [[NSNotificationCenter defaultCenter] + addObserver:self + selector:@selector(tabViewFrameChanged:) + name:NSViewFrameDidChangeNotification + object:tabStripView_]; + + trackingArea_.reset([[NSTrackingArea alloc] + initWithRect:NSZeroRect // Ignored by NSTrackingInVisibleRect + options:NSTrackingMouseEnteredAndExited | + NSTrackingMouseMoved | + NSTrackingActiveAlways | + NSTrackingInVisibleRect + owner:self + userInfo:nil]); + [tabStripView_ addTrackingArea:trackingArea_.get()]; + + // Check to see if the mouse is currently in our bounds so we can + // enable the tracking areas. Otherwise we won't get hover states + // or tab gradients if we load the window up under the mouse. + NSPoint mouseLoc = [[view window] mouseLocationOutsideOfEventStream]; + mouseLoc = [view convertPoint:mouseLoc fromView:nil]; + if (NSPointInRect(mouseLoc, [view bounds])) { + [self setTabTrackingAreasEnabled:YES]; + mouseInside_ = YES; + } + + // Set accessibility descriptions. http://openradar.appspot.com/7496255 + NSString* description = l10n_util::GetNSStringWithFixup(IDS_ACCNAME_NEWTAB); + [[newTabButton_ cell] + accessibilitySetOverrideValue:description + forAttribute:NSAccessibilityDescriptionAttribute]; + + // Controller may have been (re-)created by switching layout modes, which + // means the tab model is already fully formed with tabs. Need to walk the + // list and create the UI for each. + const int existingTabCount = tabStripModel_->count(); + const TabContentsWrapper* selection = + tabStripModel_->GetSelectedTabContents(); + for (int i = 0; i < existingTabCount; ++i) { + TabContentsWrapper* currentContents = tabStripModel_->GetTabContentsAt(i); + [self insertTabWithContents:currentContents + atIndex:i + inForeground:NO]; + if (selection == currentContents) { + // Must manually force a selection since the model won't send + // selection messages in this scenario. + [self selectTabWithContents:currentContents + previousContents:NULL + atIndex:i + userGesture:NO]; + } + } + // Don't lay out the tabs until after the controller has been fully + // constructed. The |verticalLayout_| flag has not been initialized by + // subclasses at this point, which would cause layout to potentially use + // the wrong mode. + if (existingTabCount) { + [self performSelectorOnMainThread:@selector(layoutTabs) + withObject:nil + waitUntilDone:NO]; + } + } + return self; +} + +- (void)dealloc { + if (trackingArea_.get()) + [tabStripView_ removeTrackingArea:trackingArea_.get()]; + + [newTabButton_ removeTrackingArea:newTabTrackingArea_.get()]; + // Invalidate all closing animations so they don't call back to us after + // we're gone. + for (TabController* controller in closingControllers_.get()) { + NSView* view = [controller view]; + [[[view animationForKey:@"frameOrigin"] delegate] invalidate]; + } + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [super dealloc]; +} + ++ (CGFloat)defaultTabHeight { + return 25.0; +} + ++ (CGFloat)defaultIndentForControls { + // Default indentation leaves enough room so tabs don't overlap with the + // window controls. + return 64.0; +} + +// Finds the TabContentsController associated with the given index into the tab +// model and swaps out the sole child of the contentArea to display its +// contents. +- (void)swapInTabAtIndex:(NSInteger)modelIndex { + DCHECK(modelIndex >= 0 && modelIndex < tabStripModel_->count()); + NSInteger index = [self indexFromModelIndex:modelIndex]; + TabContentsController* controller = [tabContentsArray_ objectAtIndex:index]; + + // Resize the new view to fit the window. Calling |view| may lazily + // instantiate the TabContentsController from the nib. Until we call + // |-ensureContentsVisible|, the controller doesn't install the RWHVMac into + // the view hierarchy. This is in order to avoid sending the renderer a + // spurious default size loaded from the nib during the call to |-view|. + NSView* newView = [controller view]; + + // Turns content autoresizing off, so removing and inserting views won't + // trigger unnecessary content relayout. + [controller ensureContentsSizeDoesNotChange]; + + // Remove the old view from the view hierarchy. We know there's only one + // child of |switchView_| because we're the one who put it there. There + // may not be any children in the case of a tab that's been closed, in + // which case there's no swapping going on. + NSArray* subviews = [switchView_ subviews]; + if ([subviews count]) { + NSView* oldView = [subviews objectAtIndex:0]; + // Set newView frame to the oldVew frame to prevent NSSplitView hosting + // sidebar and tab content from resizing sidebar's content view. + // ensureContentsVisible (see below) sets content size and autoresizing + // properties. + [newView setFrame:[oldView frame]]; + [switchView_ replaceSubview:oldView with:newView]; + } else { + [newView setFrame:[switchView_ bounds]]; + [switchView_ addSubview:newView]; + } + + // New content is in place, delegate should adjust itself accordingly. + [delegate_ onSelectTabWithContents:[controller tabContents]]; + + // It also restores content autoresizing properties. + [controller ensureContentsVisible]; + + // Make sure the new tabs's sheets are visible (necessary when a background + // tab opened a sheet while it was in the background and now becomes active). + TabContentsWrapper* newTab = tabStripModel_->GetTabContentsAt(modelIndex); + DCHECK(newTab); + if (newTab) { + TabContents::ConstrainedWindowList::iterator it, end; + end = newTab->tab_contents()->constrained_window_end(); + NSWindowController* controller = [[newView window] windowController]; + DCHECK([controller isKindOfClass:[BrowserWindowController class]]); + + for (it = newTab->tab_contents()->constrained_window_begin(); + it != end; + ++it) { + ConstrainedWindow* constrainedWindow = *it; + static_cast<ConstrainedWindowMac*>(constrainedWindow)->Realize( + static_cast<BrowserWindowController*>(controller)); + } + } + + // Tell per-tab sheet manager about currently selected tab. + if (sheetController_.get()) { + [sheetController_ setActiveView:newView]; + } +} + +// Create a new tab view and set its cell correctly so it draws the way we want +// it to. It will be sized and positioned by |-layoutTabs| so there's no need to +// set the frame here. This also creates the view as hidden, it will be +// shown during layout. +- (TabController*)newTab { + TabController* controller = [[[TabController alloc] init] autorelease]; + [controller setTarget:self]; + [controller setAction:@selector(selectTab:)]; + [[controller view] setHidden:YES]; + + return controller; +} + +// (Private) Returns the number of open tabs in the tab strip. This is the +// number of TabControllers we know about (as there's a 1-to-1 mapping from +// these controllers to a tab) less the number of closing tabs. +- (NSInteger)numberOfOpenTabs { + return static_cast<NSInteger>(tabStripModel_->count()); +} + +// (Private) Returns the number of open, mini-tabs. +- (NSInteger)numberOfOpenMiniTabs { + // Ask the model for the number of mini tabs. Note that tabs which are in + // the process of closing (i.e., whose controllers are in + // |closingControllers_|) have already been removed from the model. + return tabStripModel_->IndexOfFirstNonMiniTab(); +} + +// (Private) Returns the number of open, non-mini tabs. +- (NSInteger)numberOfOpenNonMiniTabs { + NSInteger number = [self numberOfOpenTabs] - [self numberOfOpenMiniTabs]; + DCHECK_GE(number, 0); + return number; +} + +// Given an index into the tab model, returns the index into the tab controller +// or tab contents controller array accounting for tabs that are currently +// closing. For example, if there are two tabs in the process of closing before +// |index|, this returns |index| + 2. If there are no closing tabs, this will +// return |index|. +- (NSInteger)indexFromModelIndex:(NSInteger)index { + DCHECK(index >= 0); + if (index < 0) + return index; + + NSInteger i = 0; + for (TabController* controller in tabArray_.get()) { + if ([closingControllers_ containsObject:controller]) { + DCHECK([(TabView*)[controller view] isClosing]); + ++index; + } + if (i == index) // No need to check anything after, it has no effect. + break; + ++i; + } + return index; +} + + +// Returns the index of the subview |view|. Returns -1 if not present. Takes +// closing tabs into account such that this index will correctly match the tab +// model. If |view| is in the process of closing, returns -1, as closing tabs +// are no longer in the model. +- (NSInteger)modelIndexForTabView:(NSView*)view { + NSInteger index = 0; + for (TabController* current in tabArray_.get()) { + // If |current| is closing, skip it. + if ([closingControllers_ containsObject:current]) + continue; + else if ([current view] == view) + return index; + ++index; + } + return -1; +} + +// Returns the index of the contents subview |view|. Returns -1 if not present. +// Takes closing tabs into account such that this index will correctly match the +// tab model. If |view| is in the process of closing, returns -1, as closing +// tabs are no longer in the model. +- (NSInteger)modelIndexForContentsView:(NSView*)view { + NSInteger index = 0; + NSInteger i = 0; + for (TabContentsController* current in tabContentsArray_.get()) { + // If the TabController corresponding to |current| is closing, skip it. + TabController* controller = [tabArray_ objectAtIndex:i]; + if ([closingControllers_ containsObject:controller]) { + ++i; + continue; + } else if ([current view] == view) { + return index; + } + ++index; + ++i; + } + return -1; +} + + +// Returns the view at the given index, using the array of TabControllers to +// get the associated view. Returns nil if out of range. +- (NSView*)viewAtIndex:(NSUInteger)index { + if (index >= [tabArray_ count]) + return NULL; + return [[tabArray_ objectAtIndex:index] view]; +} + +- (NSUInteger)viewsCount { + return [tabArray_ count]; +} + +// Called when the user clicks a tab. Tell the model the selection has changed, +// which feeds back into us via a notification. +- (void)selectTab:(id)sender { + DCHECK([sender isKindOfClass:[NSView class]]); + int index = [self modelIndexForTabView:sender]; + if (tabStripModel_->ContainsIndex(index)) + tabStripModel_->SelectTabContentsAt(index, true); +} + +// Called when the user closes a tab. Asks the model to close the tab. |sender| +// is the TabView that is potentially going away. +- (void)closeTab:(id)sender { + DCHECK([sender isKindOfClass:[TabView class]]); + if ([hoveredTab_ isEqual:sender]) { + hoveredTab_ = nil; + } + + NSInteger index = [self modelIndexForTabView:sender]; + if (!tabStripModel_->ContainsIndex(index)) + return; + + TabContentsWrapper* contents = tabStripModel_->GetTabContentsAt(index); + if (contents) + UserMetrics::RecordAction(UserMetricsAction("CloseTab_Mouse"), + contents->tab_contents()->profile()); + const NSInteger numberOfOpenTabs = [self numberOfOpenTabs]; + if (numberOfOpenTabs > 1) { + bool isClosingLastTab = index == numberOfOpenTabs - 1; + if (!isClosingLastTab) { + // Limit the width available for laying out tabs so that tabs are not + // resized until a later time (when the mouse leaves the tab strip). + // However, if the tab being closed is a pinned tab, break out of + // rapid-closure mode since the mouse is almost guaranteed not to be over + // the closebox of the adjacent tab (due to the difference in widths). + // TODO(pinkerton): re-visit when handling tab overflow. + // http://crbug.com/188 + if (tabStripModel_->IsTabPinned(index)) { + availableResizeWidth_ = kUseFullAvailableWidth; + } else { + NSView* penultimateTab = [self viewAtIndex:numberOfOpenTabs - 2]; + availableResizeWidth_ = NSMaxX([penultimateTab frame]); + } + } else { + // If the rightmost tab is closed, change the available width so that + // another tab's close button lands below the cursor (assuming the tabs + // are currently below their maximum width and can grow). + NSView* lastTab = [self viewAtIndex:numberOfOpenTabs - 1]; + availableResizeWidth_ = NSMaxX([lastTab frame]); + } + tabStripModel_->CloseTabContentsAt( + index, + TabStripModel::CLOSE_USER_GESTURE | + TabStripModel::CLOSE_CREATE_HISTORICAL_TAB); + } else { + // Use the standard window close if this is the last tab + // this prevents the tab from being removed from the model until after + // the window dissapears + [[tabStripView_ window] performClose:nil]; + } +} + +// Dispatch context menu commands for the given tab controller. +- (void)commandDispatch:(TabStripModel::ContextMenuCommand)command + forController:(TabController*)controller { + int index = [self modelIndexForTabView:[controller view]]; + if (tabStripModel_->ContainsIndex(index)) + tabStripModel_->ExecuteContextMenuCommand(index, command); +} + +// Returns YES if the specificed command should be enabled for the given +// controller. +- (BOOL)isCommandEnabled:(TabStripModel::ContextMenuCommand)command + forController:(TabController*)controller { + int index = [self modelIndexForTabView:[controller view]]; + if (!tabStripModel_->ContainsIndex(index)) + return NO; + return tabStripModel_->IsContextMenuCommandEnabled(index, command) ? YES : NO; +} + +- (void)insertPlaceholderForTab:(TabView*)tab + frame:(NSRect)frame + yStretchiness:(CGFloat)yStretchiness { + placeholderTab_ = tab; + placeholderFrame_ = frame; + placeholderStretchiness_ = yStretchiness; + [self layoutTabsWithAnimation:initialLayoutComplete_ regenerateSubviews:NO]; +} + +- (BOOL)isTabFullyVisible:(TabView*)tab { + NSRect frame = [tab frame]; + return NSMinX(frame) >= [self indentForControls] && + NSMaxX(frame) <= NSMaxX([tabStripView_ frame]); +} + +- (void)showNewTabButton:(BOOL)show { + forceNewTabButtonHidden_ = show ? NO : YES; + if (forceNewTabButtonHidden_) + [newTabButton_ setHidden:YES]; +} + +// Lay out all tabs in the order of their TabContentsControllers, which matches +// the ordering in the TabStripModel. This call isn't that expensive, though +// it is O(n) in the number of tabs. Tabs will animate to their new position +// if the window is visible and |animate| is YES. +// TODO(pinkerton): Note this doesn't do too well when the number of min-sized +// tabs would cause an overflow. http://crbug.com/188 +- (void)layoutTabsWithAnimation:(BOOL)animate + regenerateSubviews:(BOOL)doUpdate { + DCHECK([NSThread isMainThread]); + if (![tabArray_ count]) + return; + + const CGFloat kMaxTabWidth = [TabController maxTabWidth]; + const CGFloat kMinTabWidth = [TabController minTabWidth]; + const CGFloat kMinSelectedTabWidth = [TabController minSelectedTabWidth]; + const CGFloat kMiniTabWidth = [TabController miniTabWidth]; + const CGFloat kAppTabWidth = [TabController appTabWidth]; + + NSRect enclosingRect = NSZeroRect; + ScopedNSAnimationContextGroup mainAnimationGroup(animate); + mainAnimationGroup.SetCurrentContextDuration(kAnimationDuration); + + // Update the current subviews and their z-order if requested. + if (doUpdate) + [self regenerateSubviewList]; + + // Compute the base width of tabs given how much room we're allowed. Note that + // mini-tabs have a fixed width. We may not be able to use the entire width + // if the user is quickly closing tabs. This may be negative, but that's okay + // (taken care of by |MAX()| when calculating tab sizes). + CGFloat availableSpace = 0; + if (verticalLayout_) { + availableSpace = NSHeight([tabStripView_ bounds]); + } else { + if ([self inRapidClosureMode]) { + availableSpace = availableResizeWidth_; + } else { + availableSpace = NSWidth([tabStripView_ frame]); + // Account for the new tab button and the incognito badge. + availableSpace -= NSWidth([newTabButton_ frame]) + kNewTabButtonOffset; + if (browser_->profile()->IsOffTheRecord()) + availableSpace -= kIncognitoBadgeTabStripShrink; + } + availableSpace -= [self indentForControls]; + } + + // This may be negative, but that's okay (taken care of by |MAX()| when + // calculating tab sizes). "mini" tabs in horizontal mode just get a special + // section, they don't change size. + CGFloat availableSpaceForNonMini = availableSpace; + if (!verticalLayout_) { + availableSpaceForNonMini -= + [self numberOfOpenMiniTabs] * (kMiniTabWidth - kTabOverlap); + } + + // Initialize |nonMiniTabWidth| in case there aren't any non-mini-tabs; this + // value shouldn't actually be used. + CGFloat nonMiniTabWidth = kMaxTabWidth; + const NSInteger numberOfOpenNonMiniTabs = [self numberOfOpenNonMiniTabs]; + if (!verticalLayout_ && numberOfOpenNonMiniTabs) { + // Find the width of a non-mini-tab. This only applies to horizontal + // mode. Add in the amount we "get back" from the tabs overlapping. + availableSpaceForNonMini += (numberOfOpenNonMiniTabs - 1) * kTabOverlap; + + // Divide up the space between the non-mini-tabs. + nonMiniTabWidth = availableSpaceForNonMini / numberOfOpenNonMiniTabs; + + // Clamp the width between the max and min. + nonMiniTabWidth = MAX(MIN(nonMiniTabWidth, kMaxTabWidth), kMinTabWidth); + } + + BOOL visible = [[tabStripView_ window] isVisible]; + + CGFloat offset = [self indentForControls]; + NSUInteger i = 0; + bool hasPlaceholderGap = false; + for (TabController* tab in tabArray_.get()) { + // Ignore a tab that is going through a close animation. + if ([closingControllers_ containsObject:tab]) + continue; + + BOOL isPlaceholder = [[tab view] isEqual:placeholderTab_]; + NSRect tabFrame = [[tab view] frame]; + tabFrame.size.height = [[self class] defaultTabHeight] + 1; + if (verticalLayout_) { + tabFrame.origin.y = availableSpace - tabFrame.size.height - offset; + tabFrame.origin.x = 0; + } else { + tabFrame.origin.y = 0; + tabFrame.origin.x = offset; + } + // If the tab is hidden, we consider it a new tab. We make it visible + // and animate it in. + BOOL newTab = [[tab view] isHidden]; + if (newTab) { + [[tab view] setHidden:NO]; + } + + if (isPlaceholder) { + // Move the current tab to the correct location instantly. + // We need a duration or else it doesn't cancel an inflight animation. + ScopedNSAnimationContextGroup localAnimationGroup(animate); + localAnimationGroup.SetCurrentContextShortestDuration(); + if (verticalLayout_) + tabFrame.origin.y = availableSpace - tabFrame.size.height - offset; + else + tabFrame.origin.x = placeholderFrame_.origin.x; + // TODO(alcor): reenable this + //tabFrame.size.height += 10.0 * placeholderStretchiness_; + id target = animate ? [[tab view] animator] : [tab view]; + [target setFrame:tabFrame]; + + // Store the frame by identifier to aviod redundant calls to animator. + NSValue* identifier = [NSValue valueWithPointer:[tab view]]; + [targetFrames_ setObject:[NSValue valueWithRect:tabFrame] + forKey:identifier]; + continue; + } + + if (placeholderTab_ && !hasPlaceholderGap) { + const CGFloat placeholderMin = + verticalLayout_ ? NSMinY(placeholderFrame_) : + NSMinX(placeholderFrame_); + if (verticalLayout_) { + if (NSMidY(tabFrame) > placeholderMin) { + hasPlaceholderGap = true; + offset += NSHeight(placeholderFrame_); + tabFrame.origin.y = availableSpace - tabFrame.size.height - offset; + } + } else { + // If the left edge is to the left of the placeholder's left, but the + // mid is to the right of it slide over to make space for it. + if (NSMidX(tabFrame) > placeholderMin) { + hasPlaceholderGap = true; + offset += NSWidth(placeholderFrame_); + offset -= kTabOverlap; + tabFrame.origin.x = offset; + } + } + } + + // Set the width. Selected tabs are slightly wider when things get really + // small and thus we enforce a different minimum width. + tabFrame.size.width = [tab mini] ? + ([tab app] ? kAppTabWidth : kMiniTabWidth) : nonMiniTabWidth; + if ([tab selected]) + tabFrame.size.width = MAX(tabFrame.size.width, kMinSelectedTabWidth); + + // Animate a new tab in by putting it below the horizon unless told to put + // it in a specific location (i.e., from a drop). + // TODO(pinkerton): figure out vertical tab animations. + if (newTab && visible && animate) { + if (NSEqualRects(droppedTabFrame_, NSZeroRect)) { + [[tab view] setFrame:NSOffsetRect(tabFrame, 0, -NSHeight(tabFrame))]; + } else { + [[tab view] setFrame:droppedTabFrame_]; + droppedTabFrame_ = NSZeroRect; + } + } + + // Check the frame by identifier to avoid redundant calls to animator. + id frameTarget = visible && animate ? [[tab view] animator] : [tab view]; + NSValue* identifier = [NSValue valueWithPointer:[tab view]]; + NSValue* oldTargetValue = [targetFrames_ objectForKey:identifier]; + if (!oldTargetValue || + !NSEqualRects([oldTargetValue rectValue], tabFrame)) { + [frameTarget setFrame:tabFrame]; + [targetFrames_ setObject:[NSValue valueWithRect:tabFrame] + forKey:identifier]; + } + + enclosingRect = NSUnionRect(tabFrame, enclosingRect); + + if (verticalLayout_) { + offset += NSHeight(tabFrame); + } else { + offset += NSWidth(tabFrame); + offset -= kTabOverlap; + } + i++; + } + + // Hide the new tab button if we're explicitly told to. It may already + // be hidden, doing it again doesn't hurt. Otherwise position it + // appropriately, showing it if necessary. + if (forceNewTabButtonHidden_) { + [newTabButton_ setHidden:YES]; + } else { + NSRect newTabNewFrame = [newTabButton_ frame]; + // We've already ensured there's enough space for the new tab button + // so we don't have to check it against the available space. We do need + // to make sure we put it after any placeholder. + newTabNewFrame.origin = NSMakePoint(offset, 0); + newTabNewFrame.origin.x = MAX(newTabNewFrame.origin.x, + NSMaxX(placeholderFrame_)) + + kNewTabButtonOffset; + if ([tabContentsArray_ count]) + [newTabButton_ setHidden:NO]; + + if (!NSEqualRects(newTabTargetFrame_, newTabNewFrame)) { + // Set the new tab button image correctly based on where the cursor is. + NSWindow* window = [tabStripView_ window]; + NSPoint currentMouse = [window mouseLocationOutsideOfEventStream]; + currentMouse = [tabStripView_ convertPoint:currentMouse fromView:nil]; + + BOOL shouldShowHover = [newTabButton_ pointIsOverButton:currentMouse]; + [self setNewTabButtonHoverState:shouldShowHover]; + + // Move the new tab button into place. We want to animate the new tab + // button if it's moving to the left (closing a tab), but not when it's + // moving to the right (inserting a new tab). If moving right, we need + // to use a very small duration to make sure we cancel any in-flight + // animation to the left. + if (visible && animate) { + ScopedNSAnimationContextGroup localAnimationGroup(true); + BOOL movingLeft = NSMinX(newTabNewFrame) < NSMinX(newTabTargetFrame_); + if (!movingLeft) { + localAnimationGroup.SetCurrentContextShortestDuration(); + } + [[newTabButton_ animator] setFrame:newTabNewFrame]; + newTabTargetFrame_ = newTabNewFrame; + } else { + [newTabButton_ setFrame:newTabNewFrame]; + newTabTargetFrame_ = newTabNewFrame; + } + } + } + + [dragBlockingView_ setFrame:enclosingRect]; + + // Mark that we've successfully completed layout of at least one tab. + initialLayoutComplete_ = YES; +} + +// When we're told to layout from the public API we usually want to animate, +// except when it's the first time. +- (void)layoutTabs { + [self layoutTabsWithAnimation:initialLayoutComplete_ regenerateSubviews:YES]; +} + +// Handles setting the title of the tab based on the given |contents|. Uses +// a canned string if |contents| is NULL. +- (void)setTabTitle:(NSViewController*)tab withContents:(TabContents*)contents { + NSString* titleString = nil; + if (contents) + titleString = base::SysUTF16ToNSString(contents->GetTitle()); + if (![titleString length]) { + titleString = l10n_util::GetNSString(IDS_BROWSER_WINDOW_MAC_TAB_UNTITLED); + } + [tab setTitle:titleString]; +} + +// Called when a notification is received from the model to insert a new tab +// at |modelIndex|. +- (void)insertTabWithContents:(TabContentsWrapper*)contents + atIndex:(NSInteger)modelIndex + inForeground:(bool)inForeground { + DCHECK(contents); + DCHECK(modelIndex == TabStripModel::kNoTab || + tabStripModel_->ContainsIndex(modelIndex)); + + // Take closing tabs into account. + NSInteger index = [self indexFromModelIndex:modelIndex]; + + // Make a new tab. Load the contents of this tab from the nib and associate + // the new controller with |contents| so it can be looked up later. + scoped_nsobject<TabContentsController> contentsController( + [[TabContentsController alloc] initWithContents:contents->tab_contents() + delegate:self]); + [tabContentsArray_ insertObject:contentsController atIndex:index]; + + // Make a new tab and add it to the strip. Keep track of its controller. + TabController* newController = [self newTab]; + [newController setMini:tabStripModel_->IsMiniTab(modelIndex)]; + [newController setPinned:tabStripModel_->IsTabPinned(modelIndex)]; + [newController setApp:tabStripModel_->IsAppTab(modelIndex)]; + [tabArray_ insertObject:newController atIndex:index]; + NSView* newView = [newController view]; + + // Set the originating frame to just below the strip so that it animates + // upwards as it's being initially layed out. Oddly, this works while doing + // something similar in |-layoutTabs| confuses the window server. + [newView setFrame:NSOffsetRect([newView frame], + 0, -[[self class] defaultTabHeight])]; + + [self setTabTitle:newController withContents:contents->tab_contents()]; + + // If a tab is being inserted, we can again use the entire tab strip width + // for layout. + availableResizeWidth_ = kUseFullAvailableWidth; + + // We don't need to call |-layoutTabs| if the tab will be in the foreground + // because it will get called when the new tab is selected by the tab model. + // Whenever |-layoutTabs| is called, it'll also add the new subview. + if (!inForeground) { + [self layoutTabs]; + } + + // During normal loading, we won't yet have a favicon and we'll get + // subsequent state change notifications to show the throbber, but when we're + // dragging a tab out into a new window, we have to put the tab's favicon + // into the right state up front as we won't be told to do it from anywhere + // else. + [self updateFavIconForContents:contents->tab_contents() atIndex:modelIndex]; + + // Send a broadcast that the number of tabs have changed. + [[NSNotificationCenter defaultCenter] + postNotificationName:kTabStripNumberOfTabsChanged + object:self]; +} + +// Called when a notification is received from the model to select a particular +// tab. Swaps in the toolbar and content area associated with |newContents|. +- (void)selectTabWithContents:(TabContentsWrapper*)newContents + previousContents:(TabContentsWrapper*)oldContents + atIndex:(NSInteger)modelIndex + userGesture:(bool)wasUserGesture { + // Take closing tabs into account. + NSInteger index = [self indexFromModelIndex:modelIndex]; + + if (oldContents) { + int oldModelIndex = + browser_->GetIndexOfController(&(oldContents->controller())); + if (oldModelIndex != -1) { // When closing a tab, the old tab may be gone. + NSInteger oldIndex = [self indexFromModelIndex:oldModelIndex]; + TabContentsController* oldController = + [tabContentsArray_ objectAtIndex:oldIndex]; + [oldController willBecomeUnselectedTab]; + oldContents->view()->StoreFocus(); + oldContents->tab_contents()->WasHidden(); + } + } + + // De-select all other tabs and select the new tab. + int i = 0; + for (TabController* current in tabArray_.get()) { + [current setSelected:(i == index) ? YES : NO]; + ++i; + } + + // Tell the new tab contents it is about to become the selected tab. Here it + // can do things like make sure the toolbar is up to date. + TabContentsController* newController = + [tabContentsArray_ objectAtIndex:index]; + [newController willBecomeSelectedTab]; + + // Relayout for new tabs and to let the selected tab grow to be larger in + // size than surrounding tabs if the user has many. This also raises the + // selected tab to the top. + [self layoutTabs]; + + // Swap in the contents for the new tab. + [self swapInTabAtIndex:modelIndex]; + + if (newContents) { + newContents->tab_contents()->DidBecomeSelected(); + newContents->view()->RestoreFocus(); + + if (newContents->tab_contents()->find_ui_active()) + browser_->GetFindBarController()->find_bar()->SetFocusAndSelection(); + } +} + +- (void)tabReplacedWithContents:(TabContentsWrapper*)newContents + previousContents:(TabContentsWrapper*)oldContents + atIndex:(NSInteger)modelIndex { + NSInteger index = [self indexFromModelIndex:modelIndex]; + TabContentsController* oldController = + [tabContentsArray_ objectAtIndex:index]; + DCHECK_EQ(oldContents->tab_contents(), [oldController tabContents]); + + // Simply create a new TabContentsController for |newContents| and place it + // into the array, replacing |oldContents|. A TabSelectedAt notification will + // follow, at which point we will install the new view. + scoped_nsobject<TabContentsController> newController( + [[TabContentsController alloc] + initWithContents:newContents->tab_contents() + delegate:self]); + + // Bye bye, |oldController|. + [tabContentsArray_ replaceObjectAtIndex:index withObject:newController]; + + [delegate_ onReplaceTabWithContents:newContents->tab_contents()]; + + // Fake a tab changed notification to force tab titles and favicons to update. + [self tabChangedWithContents:newContents + atIndex:modelIndex + changeType:TabStripModelObserver::ALL]; +} + +// Remove all knowledge about this tab and its associated controller, and remove +// the view from the strip. +- (void)removeTab:(TabController*)controller { + NSUInteger index = [tabArray_ indexOfObject:controller]; + + // Release the tab contents controller so those views get destroyed. This + // will remove all the tab content Cocoa views from the hierarchy. A + // subsequent "select tab" notification will follow from the model. To + // tell us what to swap in in its absence. + [tabContentsArray_ removeObjectAtIndex:index]; + + // Remove the view from the tab strip. + NSView* tab = [controller view]; + [tab removeFromSuperview]; + + // Remove ourself as an observer. + [[NSNotificationCenter defaultCenter] + removeObserver:self + name:NSViewDidUpdateTrackingAreasNotification + object:tab]; + + // Clear the tab controller's target. + // TODO(viettrungluu): [crbug.com/23829] Find a better way to handle the tab + // controller's target. + [controller setTarget:nil]; + + if ([hoveredTab_ isEqual:tab]) + hoveredTab_ = nil; + + NSValue* identifier = [NSValue valueWithPointer:tab]; + [targetFrames_ removeObjectForKey:identifier]; + + // Once we're totally done with the tab, delete its controller + [tabArray_ removeObjectAtIndex:index]; +} + +// Called by the CAAnimation delegate when the tab completes the closing +// animation. +- (void)animationDidStopForController:(TabController*)controller + finished:(BOOL)finished { + [closingControllers_ removeObject:controller]; + [self removeTab:controller]; +} + +// Save off which TabController is closing and tell its view's animator +// where to move the tab to. Registers a delegate to call back when the +// animation is complete in order to remove the tab from the model. +- (void)startClosingTabWithAnimation:(TabController*)closingTab { + DCHECK([NSThread isMainThread]); + // Save off the controller into the set of animating tabs. This alerts + // the layout method to not do anything with it and allows us to correctly + // calculate offsets when working with indices into the model. + [closingControllers_ addObject:closingTab]; + + // Mark the tab as closing. This prevents it from generating any drags or + // selections while it's animating closed. + [(TabView*)[closingTab view] setClosing:YES]; + + // Register delegate (owned by the animation system). + NSView* tabView = [closingTab view]; + CAAnimation* animation = [[tabView animationForKey:@"frameOrigin"] copy]; + [animation autorelease]; + scoped_nsobject<TabCloseAnimationDelegate> delegate( + [[TabCloseAnimationDelegate alloc] initWithTabStrip:self + tabController:closingTab]); + [animation setDelegate:delegate.get()]; // Retains delegate. + NSMutableDictionary* animationDictionary = + [NSMutableDictionary dictionaryWithDictionary:[tabView animations]]; + [animationDictionary setObject:animation forKey:@"frameOrigin"]; + [tabView setAnimations:animationDictionary]; + + // Periscope down! Animate the tab. + NSRect newFrame = [tabView frame]; + newFrame = NSOffsetRect(newFrame, 0, -newFrame.size.height); + ScopedNSAnimationContextGroup animationGroup(true); + animationGroup.SetCurrentContextDuration(kAnimationDuration); + [[tabView animator] setFrame:newFrame]; +} + +// Called when a notification is received from the model that the given tab +// has gone away. Start an animation then force a layout to put everything +// in motion. +- (void)tabDetachedWithContents:(TabContentsWrapper*)contents + atIndex:(NSInteger)modelIndex { + // Take closing tabs into account. + NSInteger index = [self indexFromModelIndex:modelIndex]; + + TabController* tab = [tabArray_ objectAtIndex:index]; + if (tabStripModel_->count() > 0) { + [self startClosingTabWithAnimation:tab]; + [self layoutTabs]; + } else { + [self removeTab:tab]; + } + + // Send a broadcast that the number of tabs have changed. + [[NSNotificationCenter defaultCenter] + postNotificationName:kTabStripNumberOfTabsChanged + object:self]; + + [delegate_ onTabDetachedWithContents:contents->tab_contents()]; +} + +// A helper routine for creating an NSImageView to hold the fav icon or app icon +// for |contents|. +- (NSImageView*)iconImageViewForContents:(TabContents*)contents { + BOOL isApp = contents->is_app(); + NSImage* image = nil; + if (isApp) { + SkBitmap* icon = contents->GetExtensionAppIcon(); + if (icon) + image = gfx::SkBitmapToNSImage(*icon); + } else { + image = gfx::SkBitmapToNSImage(contents->GetFavIcon()); + } + + // Either we don't have a valid favicon or there was some issue converting it + // from an SkBitmap. Either way, just show the default. + if (!image) + image = defaultFavIcon_.get(); + NSRect frame = NSMakeRect(0, 0, kIconWidthAndHeight, kIconWidthAndHeight); + NSImageView* view = [[[NSImageView alloc] initWithFrame:frame] autorelease]; + [view setImage:image]; + return view; +} + +// Updates the current loading state, replacing the icon view with a favicon, +// a throbber, the default icon, or nothing at all. +- (void)updateFavIconForContents:(TabContents*)contents + atIndex:(NSInteger)modelIndex { + if (!contents) + return; + + static NSImage* throbberWaitingImage = + [ResourceBundle::GetSharedInstance().GetNativeImageNamed( + IDR_THROBBER_WAITING) retain]; + static NSImage* throbberLoadingImage = + [ResourceBundle::GetSharedInstance().GetNativeImageNamed(IDR_THROBBER) + retain]; + static NSImage* sadFaviconImage = + [ResourceBundle::GetSharedInstance().GetNativeImageNamed(IDR_SAD_FAVICON) + retain]; + + // Take closing tabs into account. + NSInteger index = [self indexFromModelIndex:modelIndex]; + TabController* tabController = [tabArray_ objectAtIndex:index]; + + bool oldHasIcon = [tabController iconView] != nil; + bool newHasIcon = contents->ShouldDisplayFavIcon() || + tabStripModel_->IsMiniTab(modelIndex); // Always show icon if mini. + + TabLoadingState oldState = [tabController loadingState]; + TabLoadingState newState = kTabDone; + NSImage* throbberImage = nil; + if (contents->is_crashed()) { + newState = kTabCrashed; + newHasIcon = true; + } else if (contents->waiting_for_response()) { + newState = kTabWaiting; + throbberImage = throbberWaitingImage; + } else if (contents->is_loading()) { + newState = kTabLoading; + throbberImage = throbberLoadingImage; + } + + if (oldState != newState) + [tabController setLoadingState:newState]; + + // While loading, this function is called repeatedly with the same state. + // To avoid expensive unnecessary view manipulation, only make changes when + // the state is actually changing. When loading is complete (kTabDone), + // every call to this function is significant. + if (newState == kTabDone || oldState != newState || + oldHasIcon != newHasIcon) { + NSView* iconView = nil; + if (newHasIcon) { + if (newState == kTabDone) { + iconView = [self iconImageViewForContents:contents]; + } else if (newState == kTabCrashed) { + NSImage* oldImage = [[self iconImageViewForContents:contents] image]; + NSRect frame = + NSMakeRect(0, 0, kIconWidthAndHeight, kIconWidthAndHeight); + iconView = [ThrobberView toastThrobberViewWithFrame:frame + beforeImage:oldImage + afterImage:sadFaviconImage]; + } else { + NSRect frame = + NSMakeRect(0, 0, kIconWidthAndHeight, kIconWidthAndHeight); + iconView = [ThrobberView filmstripThrobberViewWithFrame:frame + image:throbberImage]; + } + } + + [tabController setIconView:iconView]; + } +} + +// Called when a notification is received from the model that the given tab +// has been updated. |loading| will be YES when we only want to update the +// throbber state, not anything else about the (partially) loading tab. +- (void)tabChangedWithContents:(TabContentsWrapper*)contents + atIndex:(NSInteger)modelIndex + changeType:(TabStripModelObserver::TabChangeType)change { + // Take closing tabs into account. + NSInteger index = [self indexFromModelIndex:modelIndex]; + + if (modelIndex == tabStripModel_->selected_index()) + [delegate_ onSelectedTabChange:change]; + + if (change == TabStripModelObserver::TITLE_NOT_LOADING) { + // TODO(sky): make this work. + // We'll receive another notification of the change asynchronously. + return; + } + + TabController* tabController = [tabArray_ objectAtIndex:index]; + + if (change != TabStripModelObserver::LOADING_ONLY) + [self setTabTitle:tabController withContents:contents->tab_contents()]; + + [self updateFavIconForContents:contents->tab_contents() atIndex:modelIndex]; + + TabContentsController* updatedController = + [tabContentsArray_ objectAtIndex:index]; + [updatedController tabDidChange:contents->tab_contents()]; +} + +// Called when a tab is moved (usually by drag&drop). Keep our parallel arrays +// in sync with the tab strip model. It can also be pinned/unpinned +// simultaneously, so we need to take care of that. +- (void)tabMovedWithContents:(TabContentsWrapper*)contents + fromIndex:(NSInteger)modelFrom + toIndex:(NSInteger)modelTo { + // Take closing tabs into account. + NSInteger from = [self indexFromModelIndex:modelFrom]; + NSInteger to = [self indexFromModelIndex:modelTo]; + + scoped_nsobject<TabContentsController> movedTabContentsController( + [[tabContentsArray_ objectAtIndex:from] retain]); + [tabContentsArray_ removeObjectAtIndex:from]; + [tabContentsArray_ insertObject:movedTabContentsController.get() + atIndex:to]; + scoped_nsobject<TabController> movedTabController( + [[tabArray_ objectAtIndex:from] retain]); + DCHECK([movedTabController isKindOfClass:[TabController class]]); + [tabArray_ removeObjectAtIndex:from]; + [tabArray_ insertObject:movedTabController.get() atIndex:to]; + + // The tab moved, which means that the mini-tab state may have changed. + if (tabStripModel_->IsMiniTab(modelTo) != [movedTabController mini]) + [self tabMiniStateChangedWithContents:contents atIndex:modelTo]; + + [self layoutTabs]; +} + +// Called when a tab is pinned or unpinned without moving. +- (void)tabMiniStateChangedWithContents:(TabContentsWrapper*)contents + atIndex:(NSInteger)modelIndex { + // Take closing tabs into account. + NSInteger index = [self indexFromModelIndex:modelIndex]; + + TabController* tabController = [tabArray_ objectAtIndex:index]; + DCHECK([tabController isKindOfClass:[TabController class]]); + + // Don't do anything if the change was already picked up by the move event. + if (tabStripModel_->IsMiniTab(modelIndex) == [tabController mini]) + return; + + [tabController setMini:tabStripModel_->IsMiniTab(modelIndex)]; + [tabController setPinned:tabStripModel_->IsTabPinned(modelIndex)]; + [tabController setApp:tabStripModel_->IsAppTab(modelIndex)]; + [self updateFavIconForContents:contents->tab_contents() atIndex:modelIndex]; + // If the tab is being restored and it's pinned, the mini state is set after + // the tab has already been rendered, so re-layout the tabstrip. In all other + // cases, the state is set before the tab is rendered so this isn't needed. + [self layoutTabs]; +} + +- (void)setFrameOfSelectedTab:(NSRect)frame { + NSView* view = [self selectedTabView]; + NSValue* identifier = [NSValue valueWithPointer:view]; + [targetFrames_ setObject:[NSValue valueWithRect:frame] + forKey:identifier]; + [view setFrame:frame]; +} + +- (NSView*)selectedTabView { + int selectedIndex = tabStripModel_->selected_index(); + // Take closing tabs into account. They can't ever be selected. + selectedIndex = [self indexFromModelIndex:selectedIndex]; + return [self viewAtIndex:selectedIndex]; +} + +// Find the model index based on the x coordinate of the placeholder. If there +// is no placeholder, this returns the end of the tab strip. Closing tabs are +// not considered in computing the index. +- (int)indexOfPlaceholder { + double placeholderX = placeholderFrame_.origin.x; + int index = 0; + int location = 0; + // Use |tabArray_| here instead of the tab strip count in order to get the + // correct index when there are closing tabs to the left of the placeholder. + const int count = [tabArray_ count]; + while (index < count) { + // Ignore closing tabs for simplicity. The only drawback of this is that + // if the placeholder is placed right before one or several contiguous + // currently closing tabs, the associated TabController will start at the + // end of the closing tabs. + if ([closingControllers_ containsObject:[tabArray_ objectAtIndex:index]]) { + index++; + continue; + } + NSView* curr = [self viewAtIndex:index]; + // The placeholder tab works by changing the frame of the tab being dragged + // to be the bounds of the placeholder, so we need to skip it while we're + // iterating, otherwise we'll end up off by one. Note This only effects + // dragging to the right, not to the left. + if (curr == placeholderTab_) { + index++; + continue; + } + if (placeholderX <= NSMinX([curr frame])) + break; + index++; + location++; + } + return location; +} + +// Move the given tab at index |from| in this window to the location of the +// current placeholder. +- (void)moveTabFromIndex:(NSInteger)from { + int toIndex = [self indexOfPlaceholder]; + tabStripModel_->MoveTabContentsAt(from, toIndex, true); +} + +// Drop a given TabContents at the location of the current placeholder. If there +// is no placeholder, it will go at the end. Used when dragging from another +// window when we don't have access to the TabContents as part of our strip. +// |frame| is in the coordinate system of the tab strip view and represents +// where the user dropped the new tab so it can be animated into its correct +// location when the tab is added to the model. If the tab was pinned in its +// previous window, setting |pinned| to YES will propagate that state to the +// new window. Mini-tabs are either app or pinned tabs; the app state is stored +// by the |contents|, but the |pinned| state is the caller's responsibility. +- (void)dropTabContents:(TabContentsWrapper*)contents + withFrame:(NSRect)frame + asPinnedTab:(BOOL)pinned { + int modelIndex = [self indexOfPlaceholder]; + + // Mark that the new tab being created should start at |frame|. It will be + // reset as soon as the tab has been positioned. + droppedTabFrame_ = frame; + + // Insert it into this tab strip. We want it in the foreground and to not + // inherit the current tab's group. + tabStripModel_->InsertTabContentsAt( + modelIndex, contents, + TabStripModel::ADD_SELECTED | (pinned ? TabStripModel::ADD_PINNED : 0)); +} + +// Called when the tab strip view changes size. As we only registered for +// changes on our view, we know it's only for our view. Layout w/out +// animations since they are blocked by the resize nested runloop. We need +// the views to adjust immediately. Neither the tabs nor their z-order are +// changed, so we don't need to update the subviews. +- (void)tabViewFrameChanged:(NSNotification*)info { + [self layoutTabsWithAnimation:NO regenerateSubviews:NO]; +} + +// Called when the tracking areas for any given tab are updated. This allows +// the individual tabs to update their hover states correctly. +// Only generates the event if the cursor is in the tab strip. +- (void)tabUpdateTracking:(NSNotification*)notification { + DCHECK([[notification object] isKindOfClass:[TabView class]]); + DCHECK(mouseInside_); + NSWindow* window = [tabStripView_ window]; + NSPoint location = [window mouseLocationOutsideOfEventStream]; + if (NSPointInRect(location, [tabStripView_ frame])) { + NSEvent* mouseEvent = [NSEvent mouseEventWithType:NSMouseMoved + location:location + modifierFlags:0 + timestamp:0 + windowNumber:[window windowNumber] + context:nil + eventNumber:0 + clickCount:0 + pressure:0]; + [self mouseMoved:mouseEvent]; + } +} + +- (BOOL)inRapidClosureMode { + return availableResizeWidth_ != kUseFullAvailableWidth; +} + +// Disable tab dragging when there are any pending animations. +- (BOOL)tabDraggingAllowed { + return [closingControllers_ count] == 0; +} + +- (void)mouseMoved:(NSEvent*)event { + // Use hit test to figure out what view we are hovering over. + NSView* targetView = [tabStripView_ hitTest:[event locationInWindow]]; + + // Set the new tab button hover state iff the mouse is over the button. + BOOL shouldShowHoverImage = [targetView isKindOfClass:[NewTabButton class]]; + [self setNewTabButtonHoverState:shouldShowHoverImage]; + + TabView* tabView = (TabView*)targetView; + if (![tabView isKindOfClass:[TabView class]]) { + if ([[tabView superview] isKindOfClass:[TabView class]]) { + tabView = (TabView*)[targetView superview]; + } else { + tabView = nil; + } + } + + if (hoveredTab_ != tabView) { + [hoveredTab_ mouseExited:nil]; // We don't pass event because moved events + [tabView mouseEntered:nil]; // don't have valid tracking areas + hoveredTab_ = tabView; + } else { + [hoveredTab_ mouseMoved:event]; + } +} + +- (void)mouseEntered:(NSEvent*)event { + NSTrackingArea* area = [event trackingArea]; + if ([area isEqual:trackingArea_]) { + mouseInside_ = YES; + [self setTabTrackingAreasEnabled:YES]; + [self mouseMoved:event]; + } +} + +// Called when the tracking area is in effect which means we're tracking to +// see if the user leaves the tab strip with their mouse. When they do, +// reset layout to use all available width. +- (void)mouseExited:(NSEvent*)event { + NSTrackingArea* area = [event trackingArea]; + if ([area isEqual:trackingArea_]) { + mouseInside_ = NO; + [self setTabTrackingAreasEnabled:NO]; + availableResizeWidth_ = kUseFullAvailableWidth; + [hoveredTab_ mouseExited:event]; + hoveredTab_ = nil; + [self layoutTabs]; + } else if ([area isEqual:newTabTrackingArea_]) { + // If the mouse is moved quickly enough, it is possible for the mouse to + // leave the tabstrip without sending any mouseMoved: messages at all. + // Since this would result in the new tab button incorrectly staying in the + // hover state, disable the hover image on every mouse exit. + [self setNewTabButtonHoverState:NO]; + } +} + +// Enable/Disable the tracking areas for the tabs. They are only enabled +// when the mouse is in the tabstrip. +- (void)setTabTrackingAreasEnabled:(BOOL)enabled { + NSNotificationCenter* defaultCenter = [NSNotificationCenter defaultCenter]; + for (TabController* controller in tabArray_.get()) { + TabView* tabView = [controller tabView]; + if (enabled) { + // Set self up to observe tabs so hover states will be correct. + [defaultCenter addObserver:self + selector:@selector(tabUpdateTracking:) + name:NSViewDidUpdateTrackingAreasNotification + object:tabView]; + } else { + [defaultCenter removeObserver:self + name:NSViewDidUpdateTrackingAreasNotification + object:tabView]; + } + [tabView setTrackingEnabled:enabled]; + } +} + +// Sets the new tab button's image based on the current hover state. Does +// nothing if the hover state is already correct. +- (void)setNewTabButtonHoverState:(BOOL)shouldShowHover { + if (shouldShowHover && !newTabButtonShowingHoverImage_) { + newTabButtonShowingHoverImage_ = YES; + [newTabButton_ setImage:nsimage_cache::ImageNamed(kNewTabHoverImage)]; + } else if (!shouldShowHover && newTabButtonShowingHoverImage_) { + newTabButtonShowingHoverImage_ = NO; + [newTabButton_ setImage:nsimage_cache::ImageNamed(kNewTabImage)]; + } +} + +// Adds the given subview to (the end of) the list of permanent subviews +// (specified from bottom up). These subviews will always be below the +// transitory subviews (tabs). |-regenerateSubviewList| must be called to +// effectuate the addition. +- (void)addSubviewToPermanentList:(NSView*)aView { + if (aView) + [permanentSubviews_ addObject:aView]; +} + +// Update the subviews, keeping the permanent ones (or, more correctly, putting +// in the ones listed in permanentSubviews_), and putting in the current tabs in +// the correct z-order. Any current subviews which is neither in the permanent +// list nor a (current) tab will be removed. So if you add such a subview, you +// should call |-addSubviewToPermanentList:| (or better yet, call that and then +// |-regenerateSubviewList| to actually add it). +- (void)regenerateSubviewList { + // Remove self as an observer from all the old tabs before a new set of + // potentially different tabs is put in place. + [self setTabTrackingAreasEnabled:NO]; + + // Subviews to put in (in bottom-to-top order), beginning with the permanent + // ones. + NSMutableArray* subviews = [NSMutableArray arrayWithArray:permanentSubviews_]; + + NSView* selectedTabView = nil; + // Go through tabs in reverse order, since |subviews| is bottom-to-top. + for (TabController* tab in [tabArray_ reverseObjectEnumerator]) { + NSView* tabView = [tab view]; + if ([tab selected]) { + DCHECK(!selectedTabView); + selectedTabView = tabView; + } else { + [subviews addObject:tabView]; + } + } + if (selectedTabView) { + [subviews addObject:selectedTabView]; + } + [tabStripView_ setSubviews:subviews]; + [self setTabTrackingAreasEnabled:mouseInside_]; +} + +// Get the index and disposition for a potential URL(s) drop given a point (in +// the |TabStripView|'s coordinates). It considers only the x-coordinate of the +// given point. If it's in the "middle" of a tab, it drops on that tab. If it's +// to the left, it inserts to the left, and similarly for the right. +- (void)droppingURLsAt:(NSPoint)point + givesIndex:(NSInteger*)index + disposition:(WindowOpenDisposition*)disposition { + // Proportion of the tab which is considered the "middle" (and causes things + // to drop on that tab). + const double kMiddleProportion = 0.5; + const double kLRProportion = (1.0 - kMiddleProportion) / 2.0; + + DCHECK(index && disposition); + NSInteger i = 0; + for (TabController* tab in tabArray_.get()) { + NSView* view = [tab view]; + DCHECK([view isKindOfClass:[TabView class]]); + + // Recall that |-[NSView frame]| is in its superview's coordinates, so a + // |TabView|'s frame is in the coordinates of the |TabStripView| (which + // matches the coordinate system of |point|). + NSRect frame = [view frame]; + + // Modify the frame to make it "unoverlapped". + frame.origin.x += kTabOverlap / 2.0; + frame.size.width -= kTabOverlap; + if (frame.size.width < 1.0) + frame.size.width = 1.0; // try to avoid complete failure + + // Drop in a new tab to the left of tab |i|? + if (point.x < (frame.origin.x + kLRProportion * frame.size.width)) { + *index = i; + *disposition = NEW_FOREGROUND_TAB; + return; + } + + // Drop on tab |i|? + if (point.x <= (frame.origin.x + + (1.0 - kLRProportion) * frame.size.width)) { + *index = i; + *disposition = CURRENT_TAB; + return; + } + + // (Dropping in a new tab to the right of tab |i| will be taken care of in + // the next iteration.) + i++; + } + + // If we've made it here, we want to append a new tab to the end. + *index = -1; + *disposition = NEW_FOREGROUND_TAB; +} + +// (URLDropTargetController protocol) +- (void)dropURLs:(NSArray*)urls inView:(NSView*)view at:(NSPoint)point { + DCHECK_EQ(view, tabStripView_.get()); + + if ([urls count] < 1) { + NOTREACHED(); + return; + } + + //TODO(viettrungluu): dropping multiple URLs. + if ([urls count] > 1) + NOTIMPLEMENTED(); + + // Get the first URL and fix it up. + GURL url(URLFixerUpper::FixupURL( + base::SysNSStringToUTF8([urls objectAtIndex:0]), std::string())); + + // Get the index and disposition. + NSInteger index; + WindowOpenDisposition disposition; + [self droppingURLsAt:point + givesIndex:&index + disposition:&disposition]; + + // Either insert a new tab or open in a current tab. + switch (disposition) { + case NEW_FOREGROUND_TAB: { + UserMetrics::RecordAction(UserMetricsAction("Tab_DropURLBetweenTabs"), + browser_->profile()); + browser::NavigateParams params(browser_, url, PageTransition::TYPED); + params.disposition = disposition; + params.tabstrip_index = index; + params.tabstrip_add_types = + TabStripModel::ADD_SELECTED | TabStripModel::ADD_FORCE_INDEX; + browser::Navigate(¶ms); + break; + } + case CURRENT_TAB: + UserMetrics::RecordAction(UserMetricsAction("Tab_DropURLOnTab"), + browser_->profile()); + tabStripModel_->GetTabContentsAt(index) + ->tab_contents()->OpenURL(url, GURL(), CURRENT_TAB, + PageTransition::TYPED); + tabStripModel_->SelectTabContentsAt(index, true); + break; + default: + NOTIMPLEMENTED(); + } +} + +// (URLDropTargetController protocol) +- (void)indicateDropURLsInView:(NSView*)view at:(NSPoint)point { + DCHECK_EQ(view, tabStripView_.get()); + + // The minimum y-coordinate at which one should consider place the arrow. + const CGFloat arrowBaseY = 25; + + NSInteger index; + WindowOpenDisposition disposition; + [self droppingURLsAt:point + givesIndex:&index + disposition:&disposition]; + + NSPoint arrowPos = NSMakePoint(0, arrowBaseY); + if (index == -1) { + // Append a tab at the end. + DCHECK(disposition == NEW_FOREGROUND_TAB); + NSInteger lastIndex = [tabArray_ count] - 1; + NSRect overRect = [[[tabArray_ objectAtIndex:lastIndex] view] frame]; + arrowPos.x = overRect.origin.x + overRect.size.width - kTabOverlap / 2.0; + } else { + NSRect overRect = [[[tabArray_ objectAtIndex:index] view] frame]; + switch (disposition) { + case NEW_FOREGROUND_TAB: + // Insert tab (to the left of the given tab). + arrowPos.x = overRect.origin.x + kTabOverlap / 2.0; + break; + case CURRENT_TAB: + // Overwrite the given tab. + arrowPos.x = overRect.origin.x + overRect.size.width / 2.0; + break; + default: + NOTREACHED(); + } + } + + [tabStripView_ setDropArrowPosition:arrowPos]; + [tabStripView_ setDropArrowShown:YES]; + [tabStripView_ setNeedsDisplay:YES]; +} + +// (URLDropTargetController protocol) +- (void)hideDropURLsIndicatorInView:(NSView*)view { + DCHECK_EQ(view, tabStripView_.get()); + + if ([tabStripView_ dropArrowShown]) { + [tabStripView_ setDropArrowShown:NO]; + [tabStripView_ setNeedsDisplay:YES]; + } +} + +- (GTMWindowSheetController*)sheetController { + if (!sheetController_.get()) + sheetController_.reset([[GTMWindowSheetController alloc] + initWithWindow:[switchView_ window] delegate:self]); + return sheetController_.get(); +} + +- (void)destroySheetController { + // Make sure there are no open sheets. + DCHECK_EQ(0U, [[sheetController_ viewsWithAttachedSheets] count]); + sheetController_.reset(); +} + +// TabContentsControllerDelegate protocol. +- (void)tabContentsViewFrameWillChange:(TabContentsController*)source + frameRect:(NSRect)frameRect { + id<TabContentsControllerDelegate> controller = + [[switchView_ window] windowController]; + [controller tabContentsViewFrameWillChange:source frameRect:frameRect]; +} + +- (TabContentsController*)activeTabContentsController { + int modelIndex = tabStripModel_->selected_index(); + if (modelIndex < 0) + return nil; + NSInteger index = [self indexFromModelIndex:modelIndex]; + if (index < 0 || + index >= (NSInteger)[tabContentsArray_ count]) + return nil; + return [tabContentsArray_ objectAtIndex:index]; +} + +- (void)gtm_systemRequestsVisibilityForView:(NSView*)view { + // This implementation is required by GTMWindowSheetController. + + // Raise window... + [[switchView_ window] makeKeyAndOrderFront:self]; + + // ...and raise a tab with a sheet. + NSInteger index = [self modelIndexForContentsView:view]; + DCHECK(index >= 0); + if (index >= 0) + tabStripModel_->SelectTabContentsAt(index, false /* not a user gesture */); +} + +- (void)attachConstrainedWindow:(ConstrainedWindowMac*)window { + // TODO(thakis, avi): Figure out how to make this work when tabs are dragged + // out or if fullscreen mode is toggled. + + // View hierarchy of the contents view: + // NSView -- switchView, same for all tabs + // +- NSView -- TabContentsController's view + // +- TabContentsViewCocoa + // Changing it? Do not forget to modify removeConstrainedWindow too. + // We use the TabContentsController's view in |swapInTabAtIndex|, so we have + // to pass it to the sheet controller here. + NSView* tabContentsView = [window->owner()->GetNativeView() superview]; + window->delegate()->RunSheet([self sheetController], tabContentsView); + + // TODO(avi, thakis): GTMWindowSheetController has no api to move tabsheets + // between windows. Until then, we have to prevent having to move a tabsheet + // between windows, e.g. no tearing off of tabs. + NSInteger modelIndex = [self modelIndexForContentsView:tabContentsView]; + NSInteger index = [self indexFromModelIndex:modelIndex]; + BrowserWindowController* controller = + (BrowserWindowController*)[[switchView_ window] windowController]; + DCHECK(controller != nil); + DCHECK(index >= 0); + if (index >= 0) { + [controller setTab:[self viewAtIndex:index] isDraggable:NO]; + } +} + +- (void)removeConstrainedWindow:(ConstrainedWindowMac*)window { + NSView* tabContentsView = [window->owner()->GetNativeView() superview]; + + // TODO(avi, thakis): GTMWindowSheetController has no api to move tabsheets + // between windows. Until then, we have to prevent having to move a tabsheet + // between windows, e.g. no tearing off of tabs. + NSInteger modelIndex = [self modelIndexForContentsView:tabContentsView]; + NSInteger index = [self indexFromModelIndex:modelIndex]; + BrowserWindowController* controller = + (BrowserWindowController*)[[switchView_ window] windowController]; + DCHECK(index >= 0); + if (index >= 0) { + [controller setTab:[self viewAtIndex:index] isDraggable:YES]; + } +} + +@end diff --git a/chrome/browser/ui/cocoa/tab_strip_controller_unittest.mm b/chrome/browser/ui/cocoa/tab_strip_controller_unittest.mm new file mode 100644 index 0000000..19a781c --- /dev/null +++ b/chrome/browser/ui/cocoa/tab_strip_controller_unittest.mm @@ -0,0 +1,177 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#import "chrome/browser/browser_window.h" +#include "chrome/browser/tab_contents/tab_contents.h" +#include "chrome/browser/tab_contents_wrapper.h" +#include "chrome/browser/renderer_host/site_instance.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/new_tab_button.h" +#import "chrome/browser/ui/cocoa/tab_strip_controller.h" +#import "chrome/browser/ui/cocoa/tab_strip_view.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +@interface TestTabStripControllerDelegate : + NSObject<TabStripControllerDelegate> { +} +@end + +@implementation TestTabStripControllerDelegate +- (void)onSelectTabWithContents:(TabContents*)contents { +} +- (void)onReplaceTabWithContents:(TabContents*)contents { +} +- (void)onSelectedTabChange:(TabStripModelObserver::TabChangeType)change { +} +- (void)onTabDetachedWithContents:(TabContents*)contents { +} +@end + +namespace { + +// Stub model delegate +class TestTabStripDelegate : public TabStripModelDelegate { + public: + virtual TabContentsWrapper* AddBlankTab(bool foreground) { + return NULL; + } + virtual TabContentsWrapper* AddBlankTabAt(int index, bool foreground) { + return NULL; + } + virtual Browser* CreateNewStripWithContents(TabContentsWrapper* contents, + const gfx::Rect& window_bounds, + const DockInfo& dock_info, + bool maximize) { + return NULL; + } + virtual void ContinueDraggingDetachedTab(TabContentsWrapper* contents, + const gfx::Rect& window_bounds, + const gfx::Rect& tab_bounds) { + } + virtual int GetDragActions() const { + return 0; + } + virtual TabContentsWrapper* CreateTabContentsForURL( + const GURL& url, + const GURL& referrer, + Profile* profile, + PageTransition::Type transition, + bool defer_load, + SiteInstance* instance) const { + return NULL; + } + virtual bool CanDuplicateContentsAt(int index) { return true; } + virtual void DuplicateContentsAt(int index) { } + virtual void CloseFrameAfterDragSession() { } + virtual void CreateHistoricalTab(TabContentsWrapper* contents) { } + virtual bool RunUnloadListenerBeforeClosing(TabContentsWrapper* contents) { + return true; + } + virtual bool CanRestoreTab() { + return true; + } + virtual void RestoreTab() {} + + virtual bool CanCloseContentsAt(int index) { return true; } + + virtual bool CanBookmarkAllTabs() const { return false; } + + virtual bool CanCloseTab() const { return true; } + + virtual void BookmarkAllTabs() {} + + virtual bool UseVerticalTabs() const { return false; } + + virtual void ToggleUseVerticalTabs() {} + + virtual bool LargeIconsPermitted() const { return true; } +}; + +class TabStripControllerTest : public CocoaTest { + public: + TabStripControllerTest() { + Browser* browser = browser_helper_.browser(); + BrowserWindow* browser_window = browser_helper_.CreateBrowserWindow(); + NSWindow* window = browser_window->GetNativeHandle(); + NSView* parent = [window contentView]; + NSRect content_frame = [parent frame]; + + // Create the "switch view" (view that gets changed out when a tab + // switches). + NSRect switch_frame = NSMakeRect(0, 0, content_frame.size.width, 500); + scoped_nsobject<NSView> switch_view( + [[NSView alloc] initWithFrame:switch_frame]); + [parent addSubview:switch_view.get()]; + + // Create the tab strip view. It's expected to have a child button in it + // already as the "new tab" button so create that too. + NSRect strip_frame = NSMakeRect(0, NSMaxY(switch_frame), + content_frame.size.width, 30); + scoped_nsobject<TabStripView> tab_strip( + [[TabStripView alloc] initWithFrame:strip_frame]); + [parent addSubview:tab_strip.get()]; + NSRect button_frame = NSMakeRect(0, 0, 15, 15); + scoped_nsobject<NewTabButton> new_tab_button( + [[NewTabButton alloc] initWithFrame:button_frame]); + [tab_strip addSubview:new_tab_button.get()]; + [tab_strip setNewTabButton:new_tab_button.get()]; + + delegate_.reset(new TestTabStripDelegate()); + model_ = browser->tabstrip_model(); + controller_delegate_.reset([TestTabStripControllerDelegate alloc]); + controller_.reset([[TabStripController alloc] + initWithView:static_cast<TabStripView*>(tab_strip.get()) + switchView:switch_view.get() + browser:browser + delegate:controller_delegate_.get()]); + } + + virtual void TearDown() { + browser_helper_.CloseBrowserWindow(); + // The call to CocoaTest::TearDown() deletes the Browser and TabStripModel + // objects, so we first have to delete the controller, which refers to them. + controller_.reset(nil); + model_ = NULL; + CocoaTest::TearDown(); + } + + BrowserTestHelper browser_helper_; + scoped_ptr<TestTabStripDelegate> delegate_; + TabStripModel* model_; + scoped_nsobject<TestTabStripControllerDelegate> controller_delegate_; + scoped_nsobject<TabStripController> controller_; +}; + +// Test adding and removing tabs and making sure that views get added to +// the tab strip. +TEST_F(TabStripControllerTest, AddRemoveTabs) { + EXPECT_TRUE(model_->empty()); + SiteInstance* instance = + SiteInstance::CreateSiteInstance(browser_helper_.profile()); + TabContentsWrapper* tab_contents = + Browser::TabContentsFactory(browser_helper_.profile(), instance, + MSG_ROUTING_NONE, NULL, NULL); + model_->AppendTabContents(tab_contents, true); + EXPECT_EQ(model_->count(), 1); +} + +TEST_F(TabStripControllerTest, SelectTab) { + // TODO(pinkerton): Implement http://crbug.com/10899 +} + +TEST_F(TabStripControllerTest, RearrangeTabs) { + // TODO(pinkerton): Implement http://crbug.com/10899 +} + +// Test that changing the number of tabs broadcasts a +// kTabStripNumberOfTabsChanged notifiction. +TEST_F(TabStripControllerTest, Notifications) { + // TODO(pinkerton): Implement http://crbug.com/10899 +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/tab_strip_model_observer_bridge.h b/chrome/browser/ui/cocoa/tab_strip_model_observer_bridge.h new file mode 100644 index 0000000..534fcfb --- /dev/null +++ b/chrome/browser/ui/cocoa/tab_strip_model_observer_bridge.h @@ -0,0 +1,85 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_TAB_STRIP_MODEL_OBSERVER_BRIDGE_H_ +#define CHROME_BROWSER_UI_COCOA_TAB_STRIP_MODEL_OBSERVER_BRIDGE_H_ +#pragma once + +#import <Foundation/Foundation.h> + +#include "chrome/browser/tabs/tab_strip_model_observer.h" + +class TabContentsWrapper; +class TabStripModel; + +// A C++ bridge class to handle receiving notifications from the C++ tab strip +// model. When the caller allocates a bridge, it automatically registers for +// notifications from |model| and passes messages to |controller| via the +// informal protocol below. The owner of this object is responsible for deleting +// it (and thus unhooking notifications) before |controller| is destroyed. +class TabStripModelObserverBridge : public TabStripModelObserver { + public: + TabStripModelObserverBridge(TabStripModel* model, id controller); + virtual ~TabStripModelObserverBridge(); + + // Overridden from TabStripModelObserver + virtual void TabInsertedAt(TabContentsWrapper* contents, + int index, + bool foreground); + virtual void TabClosingAt(TabStripModel* tab_strip_model, + TabContentsWrapper* contents, + int index); + virtual void TabDetachedAt(TabContentsWrapper* contents, int index); + virtual void TabSelectedAt(TabContentsWrapper* old_contents, + TabContentsWrapper* new_contents, + int index, + bool user_gesture); + virtual void TabMoved(TabContentsWrapper* contents, + int from_index, + int to_index); + virtual void TabChangedAt(TabContentsWrapper* contents, int index, + TabChangeType change_type); + virtual void TabReplacedAt(TabContentsWrapper* old_contents, + TabContentsWrapper* new_contents, + int index); + virtual void TabMiniStateChanged(TabContentsWrapper* contents, int index); + virtual void TabStripEmpty(); + virtual void TabStripModelDeleted(); + + private: + id controller_; // weak, owns me + TabStripModel* model_; // weak, owned by Browser +}; + +// A collection of methods which can be selectively implemented by any +// Cocoa object to receive updates about changes to a tab strip model. It is +// ok to not implement them, the calling code checks before calling. +@interface NSObject(TabStripModelBridge) +- (void)insertTabWithContents:(TabContentsWrapper*)contents + atIndex:(NSInteger)index + inForeground:(bool)inForeground; +- (void)tabClosingWithContents:(TabContentsWrapper*)contents + atIndex:(NSInteger)index; +- (void)tabDetachedWithContents:(TabContentsWrapper*)contents + atIndex:(NSInteger)index; +- (void)selectTabWithContents:(TabContentsWrapper*)newContents + previousContents:(TabContentsWrapper*)oldContents + atIndex:(NSInteger)index + userGesture:(bool)wasUserGesture; +- (void)tabMovedWithContents:(TabContentsWrapper*)contents + fromIndex:(NSInteger)from + toIndex:(NSInteger)to; +- (void)tabChangedWithContents:(TabContentsWrapper*)contents + atIndex:(NSInteger)index + changeType:(TabStripModelObserver::TabChangeType)change; +- (void)tabReplacedWithContents:(TabContentsWrapper*)newContents + previousContents:(TabContentsWrapper*)oldContents + atIndex:(NSInteger)index; +- (void)tabMiniStateChangedWithContents:(TabContentsWrapper*)contents + atIndex:(NSInteger)index; +- (void)tabStripEmpty; +- (void)tabStripModelDeleted; +@end + +#endif // CHROME_BROWSER_UI_COCOA_TAB_STRIP_MODEL_OBSERVER_BRIDGE_H_ diff --git a/chrome/browser/ui/cocoa/tab_strip_model_observer_bridge.mm b/chrome/browser/ui/cocoa/tab_strip_model_observer_bridge.mm new file mode 100644 index 0000000..a998d23 --- /dev/null +++ b/chrome/browser/ui/cocoa/tab_strip_model_observer_bridge.mm @@ -0,0 +1,118 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/tab_strip_model_observer_bridge.h" + +#include "chrome/browser/tabs/tab_strip_model.h" + +TabStripModelObserverBridge::TabStripModelObserverBridge(TabStripModel* model, + id controller) + : controller_(controller), model_(model) { + DCHECK(model && controller); + // Register to be a listener on the model so we can get updates and tell + // |controller_| about them in the future. + model_->AddObserver(this); +} + +TabStripModelObserverBridge::~TabStripModelObserverBridge() { + // Remove ourselves from receiving notifications. + model_->RemoveObserver(this); +} + +void TabStripModelObserverBridge::TabInsertedAt(TabContentsWrapper* contents, + int index, + bool foreground) { + if ([controller_ respondsToSelector: + @selector(insertTabWithContents:atIndex:inForeground:)]) { + [controller_ insertTabWithContents:contents + atIndex:index + inForeground:foreground]; + } +} + +void TabStripModelObserverBridge::TabClosingAt(TabStripModel* tab_strip_model, + TabContentsWrapper* contents, + int index) { + if ([controller_ respondsToSelector: + @selector(tabClosingWithContents:atIndex:)]) { + [controller_ tabClosingWithContents:contents atIndex:index]; + } +} + +void TabStripModelObserverBridge::TabDetachedAt(TabContentsWrapper* contents, + int index) { + if ([controller_ respondsToSelector: + @selector(tabDetachedWithContents:atIndex:)]) { + [controller_ tabDetachedWithContents:contents atIndex:index]; + } +} + +void TabStripModelObserverBridge::TabSelectedAt( + TabContentsWrapper* old_contents, + TabContentsWrapper* new_contents, + int index, + bool user_gesture) { + if ([controller_ respondsToSelector: + @selector(selectTabWithContents:previousContents:atIndex: + userGesture:)]) { + [controller_ selectTabWithContents:new_contents + previousContents:old_contents + atIndex:index + userGesture:user_gesture]; + } +} + +void TabStripModelObserverBridge::TabMoved(TabContentsWrapper* contents, + int from_index, + int to_index) { + if ([controller_ respondsToSelector: + @selector(tabMovedWithContents:fromIndex:toIndex:)]) { + [controller_ tabMovedWithContents:contents + fromIndex:from_index + toIndex:to_index]; + } +} + +void TabStripModelObserverBridge::TabChangedAt(TabContentsWrapper* contents, + int index, + TabChangeType change_type) { + if ([controller_ respondsToSelector: + @selector(tabChangedWithContents:atIndex:changeType:)]) { + [controller_ tabChangedWithContents:contents + atIndex:index + changeType:change_type]; + } +} + +void TabStripModelObserverBridge::TabReplacedAt( + TabContentsWrapper* old_contents, + TabContentsWrapper* new_contents, + int index) { + if ([controller_ respondsToSelector: + @selector(tabReplacedWithContents:previousContents:atIndex:)]) { + [controller_ tabReplacedWithContents:new_contents + previousContents:old_contents + atIndex:index]; + } else { + TabChangedAt(new_contents, index, ALL); + } +} + +void TabStripModelObserverBridge::TabMiniStateChanged( + TabContentsWrapper* contents, int index) { + if ([controller_ respondsToSelector: + @selector(tabMiniStateChangedWithContents:atIndex:)]) { + [controller_ tabMiniStateChangedWithContents:contents atIndex:index]; + } +} + +void TabStripModelObserverBridge::TabStripEmpty() { + if ([controller_ respondsToSelector:@selector(tabStripEmpty)]) + [controller_ tabStripEmpty]; +} + +void TabStripModelObserverBridge::TabStripModelDeleted() { + if ([controller_ respondsToSelector:@selector(tabStripModelDeleted)]) + [controller_ tabStripModelDeleted]; +} diff --git a/chrome/browser/ui/cocoa/tab_strip_view.h b/chrome/browser/ui/cocoa/tab_strip_view.h new file mode 100644 index 0000000..f504ff4 --- /dev/null +++ b/chrome/browser/ui/cocoa/tab_strip_view.h @@ -0,0 +1,48 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_TAB_STRIP_VIEW_H_ +#define CHROME_BROWSER_UI_COCOA_TAB_STRIP_VIEW_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/url_drop_target.h" + +@class NewTabButton; + +// A view class that handles rendering the tab strip and drops of URLS with +// a positioning locator for drop feedback. + +@interface TabStripView : NSView<URLDropTarget> { + @private + NSTimeInterval lastMouseUp_; + + // Handles being a drag-and-drop target. + scoped_nsobject<URLDropTargetHandler> dropHandler_; + + // Weak; the following come from the nib. + NewTabButton* newTabButton_; + + // Whether the drop-indicator arrow is shown, and if it is, the coordinate of + // its tip. + BOOL dropArrowShown_; + NSPoint dropArrowPosition_; +} + +@property(assign, nonatomic) IBOutlet NewTabButton* newTabButton; +@property(assign, nonatomic) BOOL dropArrowShown; +@property(assign, nonatomic) NSPoint dropArrowPosition; + +@end + +// Protected methods subclasses can override to alter behavior. Clients should +// not call these directly. +@interface TabStripView(Protected) +- (void)drawBottomBorder:(NSRect)bounds; +- (BOOL)doubleClickMinimizesWindow; +@end + +#endif // CHROME_BROWSER_UI_COCOA_TAB_STRIP_VIEW_H_ diff --git a/chrome/browser/ui/cocoa/tab_strip_view.mm b/chrome/browser/ui/cocoa/tab_strip_view.mm new file mode 100644 index 0000000..2456362 --- /dev/null +++ b/chrome/browser/ui/cocoa/tab_strip_view.mm @@ -0,0 +1,211 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/tab_strip_view.h" + +#include "base/logging.h" +#include "base/mac_util.h" +#include "chrome/browser/themes/browser_theme_provider.h" +#import "chrome/browser/ui/cocoa/browser_window_controller.h" +#import "chrome/browser/ui/cocoa/tab_strip_controller.h" +#import "chrome/browser/ui/cocoa/view_id_util.h" + +@implementation TabStripView + +@synthesize newTabButton = newTabButton_; +@synthesize dropArrowShown = dropArrowShown_; +@synthesize dropArrowPosition = dropArrowPosition_; + +- (id)initWithFrame:(NSRect)frame { + self = [super initWithFrame:frame]; + if (self) { + // Set lastMouseUp_ = -1000.0 so that timestamp-lastMouseUp_ is big unless + // lastMouseUp_ has been reset. + lastMouseUp_ = -1000.0; + + // Register to be an URL drop target. + dropHandler_.reset([[URLDropTargetHandler alloc] initWithView:self]); + } + return self; +} + +// Draw bottom border (a dark border and light highlight). Each tab is +// responsible for mimicking this bottom border, unless it's the selected +// tab. +- (void)drawBorder:(NSRect)bounds { + NSRect borderRect, contentRect; + + borderRect = bounds; + borderRect.origin.y = 1; + borderRect.size.height = 1; + [[NSColor colorWithCalibratedWhite:0.0 alpha:0.2] set]; + NSRectFillUsingOperation(borderRect, NSCompositeSourceOver); + NSDivideRect(bounds, &borderRect, &contentRect, 1, NSMinYEdge); + + BrowserThemeProvider* themeProvider = + static_cast<BrowserThemeProvider*>([[self window] themeProvider]); + if (!themeProvider) + return; + + NSColor* bezelColor = themeProvider->GetNSColor( + themeProvider->UsingDefaultTheme() ? + BrowserThemeProvider::COLOR_TOOLBAR_BEZEL : + BrowserThemeProvider::COLOR_TOOLBAR, true); + [bezelColor set]; + NSRectFill(borderRect); + NSRectFillUsingOperation(borderRect, NSCompositeSourceOver); +} + +- (void)drawRect:(NSRect)rect { + NSRect boundsRect = [self bounds]; + + [self drawBorder:boundsRect]; + + // Draw drop-indicator arrow (if appropriate). + // TODO(viettrungluu): this is all a stop-gap measure. + if ([self dropArrowShown]) { + // Programmer art: an arrow parametrized by many knobs. Note that the arrow + // points downwards (so understand "width" and "height" accordingly). + + // How many (pixels) to inset on the top/bottom. + const CGFloat kArrowTopInset = 1.5; + const CGFloat kArrowBottomInset = 1; + + // What proportion of the vertical space is dedicated to the arrow tip, + // i.e., (arrow tip height)/(amount of vertical space). + const CGFloat kArrowTipProportion = 0.5; + + // This is a slope, i.e., (arrow tip height)/(0.5 * arrow tip width). + const CGFloat kArrowTipSlope = 1.2; + + // What proportion of the arrow tip width is the stem, i.e., (stem + // width)/(arrow tip width). + const CGFloat kArrowStemProportion = 0.33; + + NSPoint arrowTipPos = [self dropArrowPosition]; + arrowTipPos.y += kArrowBottomInset; // Inset on the bottom. + + // Height we have to work with (insetting on the top). + CGFloat availableHeight = + NSMaxY(boundsRect) - arrowTipPos.y - kArrowTopInset; + DCHECK(availableHeight >= 5); + + // Based on the knobs above, calculate actual dimensions which we'll need + // for drawing. + CGFloat arrowTipHeight = kArrowTipProportion * availableHeight; + CGFloat arrowTipWidth = 2 * arrowTipHeight / kArrowTipSlope; + CGFloat arrowStemHeight = availableHeight - arrowTipHeight; + CGFloat arrowStemWidth = kArrowStemProportion * arrowTipWidth; + CGFloat arrowStemInset = (arrowTipWidth - arrowStemWidth) / 2; + + // The line width is arbitrary, but our path really should be mitered. + NSBezierPath* arrow = [NSBezierPath bezierPath]; + [arrow setLineJoinStyle:NSMiterLineJoinStyle]; + [arrow setLineWidth:1]; + + // Define the arrow's shape! We start from the tip and go clockwise. + [arrow moveToPoint:arrowTipPos]; + [arrow relativeLineToPoint:NSMakePoint(-arrowTipWidth / 2, arrowTipHeight)]; + [arrow relativeLineToPoint:NSMakePoint(arrowStemInset, 0)]; + [arrow relativeLineToPoint:NSMakePoint(0, arrowStemHeight)]; + [arrow relativeLineToPoint:NSMakePoint(arrowStemWidth, 0)]; + [arrow relativeLineToPoint:NSMakePoint(0, -arrowStemHeight)]; + [arrow relativeLineToPoint:NSMakePoint(arrowStemInset, 0)]; + [arrow closePath]; + + // Draw and fill the arrow. + [[NSColor colorWithCalibratedWhite:0 alpha:0.67] set]; + [arrow stroke]; + [[NSColor colorWithCalibratedWhite:1 alpha:0.67] setFill]; + [arrow fill]; + } +} + +// YES if a double-click in the background of the tab strip minimizes the +// window. +- (BOOL)doubleClickMinimizesWindow { + return YES; +} + +// We accept first mouse so clicks onto close/zoom/miniaturize buttons and +// title bar double-clicks are properly detected even when the window is in the +// background. +- (BOOL)acceptsFirstMouse:(NSEvent*)event { + return YES; +} + +// Trap double-clicks and make them miniaturize the browser window. +- (void)mouseUp:(NSEvent*)event { + // Bail early if double-clicks are disabled. + if (![self doubleClickMinimizesWindow]) { + [super mouseUp:event]; + return; + } + + NSInteger clickCount = [event clickCount]; + NSTimeInterval timestamp = [event timestamp]; + + // Double-clicks on Zoom/Close/Mininiaturize buttons shouldn't cause + // miniaturization. For those, we miss the first click but get the second + // (with clickCount == 2!). We thus check that we got a first click shortly + // before (measured up-to-up) a double-click. Cocoa doesn't have a documented + // way of getting the proper interval (= (double-click-threshold) + + // (drag-threshold); the former is Carbon GetDblTime()/60.0 or + // com.apple.mouse.doubleClickThreshold [undocumented]). So we hard-code + // "short" as 0.8 seconds. (Measuring up-to-up isn't enough to properly + // detect double-clicks, but we're actually using Cocoa for that.) + if (clickCount == 2 && (timestamp - lastMouseUp_) < 0.8) { + if (mac_util::ShouldWindowsMiniaturizeOnDoubleClick()) + [[self window] performMiniaturize:self]; + } else { + [super mouseUp:event]; + } + + // If clickCount is 0, the drag threshold was passed. + lastMouseUp_ = (clickCount == 1) ? timestamp : -1000.0; +} + +// (URLDropTarget protocol) +- (id<URLDropTargetController>)urlDropController { + BrowserWindowController* windowController = [[self window] windowController]; + DCHECK([windowController isKindOfClass:[BrowserWindowController class]]); + return [windowController tabStripController]; +} + +// (URLDropTarget protocol) +- (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)sender { + return [dropHandler_ draggingEntered:sender]; +} + +// (URLDropTarget protocol) +- (NSDragOperation)draggingUpdated:(id<NSDraggingInfo>)sender { + return [dropHandler_ draggingUpdated:sender]; +} + +// (URLDropTarget protocol) +- (void)draggingExited:(id<NSDraggingInfo>)sender { + return [dropHandler_ draggingExited:sender]; +} + +// (URLDropTarget protocol) +- (BOOL)performDragOperation:(id<NSDraggingInfo>)sender { + return [dropHandler_ performDragOperation:sender]; +} + +- (BOOL)accessibilityIsIgnored { + return NO; +} + +- (id)accessibilityAttributeValue:(NSString*)attribute { + if ([attribute isEqual:NSAccessibilityRoleAttribute]) + return NSAccessibilityGroupRole; + + return [super accessibilityAttributeValue:attribute]; +} + +- (ViewID)viewID { + return VIEW_ID_TAB_STRIP; +} + +@end diff --git a/chrome/browser/ui/cocoa/tab_strip_view_unittest.mm b/chrome/browser/ui/cocoa/tab_strip_view_unittest.mm new file mode 100644 index 0000000..2fde99ce --- /dev/null +++ b/chrome/browser/ui/cocoa/tab_strip_view_unittest.mm @@ -0,0 +1,30 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/tab_strip_view.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +namespace { + +class TabStripViewTest : public CocoaTest { + public: + TabStripViewTest() { + NSRect frame = NSMakeRect(0, 0, 100, 30); + scoped_nsobject<TabStripView> view( + [[TabStripView alloc] initWithFrame:frame]); + view_ = view.get(); + [[test_window() contentView] addSubview:view_]; + } + + TabStripView* view_; +}; + +TEST_VIEW(TabStripViewTest, view_) + +} // namespace diff --git a/chrome/browser/ui/cocoa/tab_view.h b/chrome/browser/ui/cocoa/tab_view.h new file mode 100644 index 0000000..f351650 --- /dev/null +++ b/chrome/browser/ui/cocoa/tab_view.h @@ -0,0 +1,134 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_TAB_VIEW_H_ +#define CHROME_BROWSER_UI_COCOA_TAB_VIEW_H_ +#pragma once + +#import <Cocoa/Cocoa.h> +#include <ApplicationServices/ApplicationServices.h> + +#include <map> + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/background_gradient_view.h" +#import "chrome/browser/ui/cocoa/hover_close_button.h" + +namespace tabs { + +// Nomenclature: +// Tabs _glow_ under two different circumstances, when they are _hovered_ (by +// the mouse) and when they are _alerted_ (to show that the tab's title has +// changed). + +// The state of alerting (to show a title change on an unselected, pinned tab). +// This is more complicated than a simple on/off since we want to allow the +// alert glow to go through a full rise-hold-fall cycle to avoid flickering (or +// always holding). +enum AlertState { + kAlertNone = 0, // Obj-C initializes to this. + kAlertRising, + kAlertHolding, + kAlertFalling +}; + +} // namespace tabs + +@class TabController, TabWindowController; + +// A view that handles the event tracking (clicking and dragging) for a tab +// on the tab strip. Relies on an associated TabController to provide a +// target/action for selecting the tab. + +@interface TabView : BackgroundGradientView { + @private + IBOutlet TabController* controller_; + // TODO(rohitrao): Add this button to a CoreAnimation layer so we can fade it + // in and out on mouseovers. + IBOutlet HoverCloseButton* closeButton_; + + // See awakeFromNib for purpose. + scoped_nsobject<HoverCloseButton> closeButtonRetainer_; + + BOOL closing_; + + // Tracking area for close button mouseover images. + scoped_nsobject<NSTrackingArea> closeTrackingArea_; + + BOOL isMouseInside_; // Is the mouse hovering over? + tabs::AlertState alertState_; + + CGFloat hoverAlpha_; // How strong the hover glow is. + NSTimeInterval hoverHoldEndTime_; // When the hover glow will begin dimming. + + CGFloat alertAlpha_; // How strong the alert glow is. + NSTimeInterval alertHoldEndTime_; // When the hover glow will begin dimming. + + NSTimeInterval lastGlowUpdate_; // Time either glow was last updated. + + NSPoint hoverPoint_; // Current location of hover in view coords. + + // All following variables are valid for the duration of a drag. + // These are released on mouseUp: + BOOL moveWindowOnDrag_; // Set if the only tab of a window is dragged. + BOOL tabWasDragged_; // Has the tab been dragged? + BOOL draggingWithinTabStrip_; // Did drag stay in the current tab strip? + BOOL chromeIsVisible_; + + NSTimeInterval tearTime_; // Time since tear happened + NSPoint tearOrigin_; // Origin of the tear rect + NSPoint dragOrigin_; // Origin point of the drag + // TODO(alcor): these references may need to be strong to avoid crashes + // due to JS closing windows + TabWindowController* sourceController_; // weak. controller starting the drag + NSWindow* sourceWindow_; // weak. The window starting the drag + NSRect sourceWindowFrame_; + NSRect sourceTabFrame_; + + TabWindowController* draggedController_; // weak. Controller being dragged. + NSWindow* dragWindow_; // weak. The window being dragged + NSWindow* dragOverlay_; // weak. The overlay being dragged + // Cache workspace IDs per-drag because computing them on 10.5 with + // CGWindowListCreateDescriptionFromArray is expensive. + // resetDragControllers clears this cache. + // + // TODO(davidben): When 10.5 becomes unsupported, remove this. + std::map<CGWindowID, int> workspaceIDCache_; + + TabWindowController* targetController_; // weak. Controller being targeted + NSCellStateValue state_; +} + +@property(assign, nonatomic) NSCellStateValue state; +@property(assign, nonatomic) CGFloat hoverAlpha; +@property(assign, nonatomic) CGFloat alertAlpha; + +// Determines if the tab is in the process of animating closed. It may still +// be visible on-screen, but should not respond to/initiate any events. Upon +// setting to NO, clears the target/action of the close button to prevent +// clicks inside it from sending messages. +@property(assign, nonatomic, getter=isClosing) BOOL closing; + +// Enables/Disables tracking regions for the tab. +- (void)setTrackingEnabled:(BOOL)enabled; + +// Begin showing an "alert" glow (shown to call attention to an unselected +// pinned tab whose title changed). +- (void)startAlert; + +// Stop showing the "alert" glow; this won't immediately wipe out any glow, but +// will make it fade away. +- (void)cancelAlert; + +@end + +// The TabController |controller_| is not the only owner of this view. If the +// controller is released before this view, then we could be hanging onto a +// garbage pointer. To prevent this, the TabController uses this interface to +// clear the |controller_| pointer when it is dying. +@interface TabView (TabControllerInterface) +- (void)setController:(TabController*)controller; +@end + +#endif // CHROME_BROWSER_UI_COCOA_TAB_VIEW_H_ diff --git a/chrome/browser/ui/cocoa/tab_view.mm b/chrome/browser/ui/cocoa/tab_view.mm new file mode 100644 index 0000000..58659ca --- /dev/null +++ b/chrome/browser/ui/cocoa/tab_view.mm @@ -0,0 +1,1057 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/tab_view.h" + +#include "base/logging.h" +#import "base/mac_util.h" +#include "base/mac/scoped_cftyperef.h" +#include "base/nsimage_cache_mac.h" +#include "chrome/browser/accessibility/browser_accessibility_state.h" +#include "chrome/browser/themes/browser_theme_provider.h" +#import "chrome/browser/ui/cocoa/tab_controller.h" +#import "chrome/browser/ui/cocoa/tab_window_controller.h" +#import "chrome/browser/ui/cocoa/themed_window.h" +#import "chrome/browser/ui/cocoa/view_id_util.h" +#include "grit/theme_resources.h" + +namespace { + +// Constants for inset and control points for tab shape. +const CGFloat kInsetMultiplier = 2.0/3.0; +const CGFloat kControlPoint1Multiplier = 1.0/3.0; +const CGFloat kControlPoint2Multiplier = 3.0/8.0; + +// The amount of time in seconds during which each type of glow increases, holds +// steady, and decreases, respectively. +const NSTimeInterval kHoverShowDuration = 0.2; +const NSTimeInterval kHoverHoldDuration = 0.02; +const NSTimeInterval kHoverHideDuration = 0.4; +const NSTimeInterval kAlertShowDuration = 0.4; +const NSTimeInterval kAlertHoldDuration = 0.4; +const NSTimeInterval kAlertHideDuration = 0.4; + +// The default time interval in seconds between glow updates (when +// increasing/decreasing). +const NSTimeInterval kGlowUpdateInterval = 0.025; + +const CGFloat kTearDistance = 36.0; +const NSTimeInterval kTearDuration = 0.333; + +// This is used to judge whether the mouse has moved during rapid closure; if it +// has moved less than the threshold, we want to close the tab. +const CGFloat kRapidCloseDist = 2.5; + +} // namespace + +@interface TabView(Private) + +- (void)resetLastGlowUpdateTime; +- (NSTimeInterval)timeElapsedSinceLastGlowUpdate; +- (void)adjustGlowValue; +// TODO(davidben): When we stop supporting 10.5, this can be removed. +- (int)getWorkspaceID:(NSWindow*)window useCache:(BOOL)useCache; +- (NSBezierPath*)bezierPathForRect:(NSRect)rect; + +@end // TabView(Private) + +@implementation TabView + +@synthesize state = state_; +@synthesize hoverAlpha = hoverAlpha_; +@synthesize alertAlpha = alertAlpha_; +@synthesize closing = closing_; + +- (id)initWithFrame:(NSRect)frame { + self = [super initWithFrame:frame]; + if (self) { + [self setShowsDivider:NO]; + // TODO(alcor): register for theming + } + return self; +} + +- (void)awakeFromNib { + [self setShowsDivider:NO]; + + // It is desirable for us to remove the close button from the cocoa hierarchy, + // so that VoiceOver does not encounter it. + // TODO(dtseng): crbug.com/59978. + // Retain in case we remove it from its superview. + closeButtonRetainer_.reset([closeButton_ retain]); + if (Singleton<BrowserAccessibilityState>::get()->IsAccessibleBrowser()) { + // The superview gives up ownership of the closeButton here. + [closeButton_ removeFromSuperview]; + } +} + +- (void)dealloc { + // Cancel any delayed requests that may still be pending (drags or hover). + [NSObject cancelPreviousPerformRequestsWithTarget:self]; + [super dealloc]; +} + +// Called to obtain the context menu for when the user hits the right mouse +// button (or control-clicks). (Note that -rightMouseDown: is *not* called for +// control-click.) +- (NSMenu*)menu { + if ([self isClosing]) + return nil; + + // Sheets, being window-modal, should block contextual menus. For some reason + // they do not. Disallow them ourselves. + if ([[self window] attachedSheet]) + return nil; + + return [controller_ menu]; +} + +// Overridden so that mouse clicks come to this view (the parent of the +// hierarchy) first. We want to handle clicks and drags in this class and +// leave the background button for display purposes only. +- (BOOL)acceptsFirstMouse:(NSEvent*)theEvent { + return YES; +} + +- (void)mouseEntered:(NSEvent*)theEvent { + isMouseInside_ = YES; + [self resetLastGlowUpdateTime]; + [self adjustGlowValue]; +} + +- (void)mouseMoved:(NSEvent*)theEvent { + hoverPoint_ = [self convertPoint:[theEvent locationInWindow] + fromView:nil]; + [self setNeedsDisplay:YES]; +} + +- (void)mouseExited:(NSEvent*)theEvent { + isMouseInside_ = NO; + hoverHoldEndTime_ = + [NSDate timeIntervalSinceReferenceDate] + kHoverHoldDuration; + [self resetLastGlowUpdateTime]; + [self adjustGlowValue]; +} + +- (void)setTrackingEnabled:(BOOL)enabled { + [closeButton_ setTrackingEnabled:enabled]; +} + +// Determines which view a click in our frame actually hit. It's either this +// view or our child close button. +- (NSView*)hitTest:(NSPoint)aPoint { + NSPoint viewPoint = [self convertPoint:aPoint fromView:[self superview]]; + NSRect frame = [self frame]; + + // Reduce the width of the hit rect slightly to remove the overlap + // between adjacent tabs. The drawing code in TabCell has the top + // corners of the tab inset by height*2/3, so we inset by half of + // that here. This doesn't completely eliminate the overlap, but it + // works well enough. + NSRect hitRect = NSInsetRect(frame, frame.size.height / 3.0f, 0); + if (![closeButton_ isHidden]) + if (NSPointInRect(viewPoint, [closeButton_ frame])) return closeButton_; + if (NSPointInRect(aPoint, hitRect)) return self; + return nil; +} + +// Returns |YES| if this tab can be torn away into a new window. +- (BOOL)canBeDragged { + if ([self isClosing]) + return NO; + NSWindowController* controller = [sourceWindow_ windowController]; + if ([controller isKindOfClass:[TabWindowController class]]) { + TabWindowController* realController = + static_cast<TabWindowController*>(controller); + return [realController isTabDraggable:self]; + } + return YES; +} + +// Returns an array of controllers that could be a drop target, ordered front to +// back. It has to be of the appropriate class, and visible (obviously). Note +// that the window cannot be a target for itself. +- (NSArray*)dropTargetsForController:(TabWindowController*)dragController { + NSMutableArray* targets = [NSMutableArray array]; + NSWindow* dragWindow = [dragController window]; + for (NSWindow* window in [NSApp orderedWindows]) { + if (window == dragWindow) continue; + if (![window isVisible]) continue; + // Skip windows on the wrong space. + if ([window respondsToSelector:@selector(isOnActiveSpace)]) { + if (![window performSelector:@selector(isOnActiveSpace)]) + continue; + } else { + // TODO(davidben): When we stop supporting 10.5, this can be + // removed. + // + // We don't cache the workspace of |dragWindow| because it may + // move around spaces. + if ([self getWorkspaceID:dragWindow useCache:NO] != + [self getWorkspaceID:window useCache:YES]) + continue; + } + NSWindowController* controller = [window windowController]; + if ([controller isKindOfClass:[TabWindowController class]]) { + TabWindowController* realController = + static_cast<TabWindowController*>(controller); + if ([realController canReceiveFrom:dragController]) + [targets addObject:controller]; + } + } + return targets; +} + +// Call to clear out transient weak references we hold during drags. +- (void)resetDragControllers { + draggedController_ = nil; + dragWindow_ = nil; + dragOverlay_ = nil; + sourceController_ = nil; + sourceWindow_ = nil; + targetController_ = nil; + workspaceIDCache_.clear(); +} + +// Sets whether the window background should be visible or invisible when +// dragging a tab. The background should be invisible when the mouse is over a +// potential drop target for the tab (the tab strip). It should be visible when +// there's no drop target so the window looks more fully realized and ready to +// become a stand-alone window. +- (void)setWindowBackgroundVisibility:(BOOL)shouldBeVisible { + if (chromeIsVisible_ == shouldBeVisible) + return; + + // There appears to be a race-condition in CoreAnimation where if we use + // animators to set the alpha values, we can't guarantee that we cancel them. + // This has the side effect of sometimes leaving the dragged window + // translucent or invisible. As a result, don't animate the alpha change. + [[draggedController_ overlayWindow] setAlphaValue:1.0]; + if (targetController_) { + [dragWindow_ setAlphaValue:0.0]; + [[draggedController_ overlayWindow] setHasShadow:YES]; + [[targetController_ window] makeMainWindow]; + } else { + [dragWindow_ setAlphaValue:0.5]; + [[draggedController_ overlayWindow] setHasShadow:NO]; + [[draggedController_ window] makeMainWindow]; + } + chromeIsVisible_ = shouldBeVisible; +} + +// Handle clicks and drags in this button. We get here because we have +// overridden acceptsFirstMouse: and the click is within our bounds. +- (void)mouseDown:(NSEvent*)theEvent { + if ([self isClosing]) + return; + + NSPoint downLocation = [theEvent locationInWindow]; + + // Record the state of the close button here, because selecting the tab will + // unhide it. + BOOL closeButtonActive = [closeButton_ isHidden] ? NO : YES; + + // During the tab closure animation (in particular, during rapid tab closure), + // we may get incorrectly hit with a mouse down. If it should have gone to the + // close button, we send it there -- it should then track the mouse, so we + // don't have to worry about mouse ups. + if (closeButtonActive && [controller_ inRapidClosureMode]) { + NSPoint hitLocation = [[self superview] convertPoint:downLocation + fromView:nil]; + if ([self hitTest:hitLocation] == closeButton_) { + [closeButton_ mouseDown:theEvent]; + return; + } + } + + // Fire the action to select the tab. + if ([[controller_ target] respondsToSelector:[controller_ action]]) + [[controller_ target] performSelector:[controller_ action] + withObject:self]; + + [self resetDragControllers]; + + // Resolve overlay back to original window. + sourceWindow_ = [self window]; + if ([sourceWindow_ isKindOfClass:[NSPanel class]]) { + sourceWindow_ = [sourceWindow_ parentWindow]; + } + + sourceWindowFrame_ = [sourceWindow_ frame]; + sourceTabFrame_ = [self frame]; + sourceController_ = [sourceWindow_ windowController]; + tabWasDragged_ = NO; + tearTime_ = 0.0; + draggingWithinTabStrip_ = YES; + chromeIsVisible_ = NO; + + // If there's more than one potential window to be a drop target, we want to + // treat a drag of a tab just like dragging around a tab that's already + // detached. Note that unit tests might have |-numberOfTabs| reporting zero + // since the model won't be fully hooked up. We need to be prepared for that + // and not send them into the "magnetic" codepath. + NSArray* targets = [self dropTargetsForController:sourceController_]; + moveWindowOnDrag_ = + ([sourceController_ numberOfTabs] < 2 && ![targets count]) || + ![self canBeDragged] || + ![sourceController_ tabDraggingAllowed]; + // If we are dragging a tab, a window with a single tab should immediately + // snap off and not drag within the tab strip. + if (!moveWindowOnDrag_) + draggingWithinTabStrip_ = [sourceController_ numberOfTabs] > 1; + + dragOrigin_ = [NSEvent mouseLocation]; + + // If the tab gets torn off, the tab controller will be removed from the tab + // strip and then deallocated. This will also result in *us* being + // deallocated. Both these are bad, so we prevent this by retaining the + // controller. + scoped_nsobject<TabController> controller([controller_ retain]); + + // Because we move views between windows, we need to handle the event loop + // ourselves. Ideally we should use the standard event loop. + while (1) { + theEvent = + [NSApp nextEventMatchingMask:NSLeftMouseUpMask | NSLeftMouseDraggedMask + untilDate:[NSDate distantFuture] + inMode:NSDefaultRunLoopMode dequeue:YES]; + NSEventType type = [theEvent type]; + if (type == NSLeftMouseDragged) { + [self mouseDragged:theEvent]; + } else if (type == NSLeftMouseUp) { + NSPoint upLocation = [theEvent locationInWindow]; + CGFloat dx = upLocation.x - downLocation.x; + CGFloat dy = upLocation.y - downLocation.y; + + // During rapid tab closure (mashing tab close buttons), we may get hit + // with a mouse down. As long as the mouse up is over the close button, + // and the mouse hasn't moved too much, we close the tab. + if (closeButtonActive && + (dx*dx + dy*dy) <= kRapidCloseDist*kRapidCloseDist && + [controller inRapidClosureMode]) { + NSPoint hitLocation = + [[self superview] convertPoint:[theEvent locationInWindow] + fromView:nil]; + if ([self hitTest:hitLocation] == closeButton_) { + [controller closeTab:self]; + break; + } + } + + [self mouseUp:theEvent]; + break; + } else { + // TODO(viettrungluu): [crbug.com/23830] We can receive right-mouse-ups + // (and maybe even others?) for reasons I don't understand. So we + // explicitly check for both events we're expecting, and log others. We + // should figure out what's going on. + LOG(WARNING) << "Spurious event received of type " << type << "."; + } + } +} + +- (void)mouseDragged:(NSEvent*)theEvent { + // Special-case this to keep the logic below simpler. + if (moveWindowOnDrag_) { + if ([sourceController_ windowMovementAllowed]) { + NSPoint thisPoint = [NSEvent mouseLocation]; + NSPoint origin = sourceWindowFrame_.origin; + origin.x += (thisPoint.x - dragOrigin_.x); + origin.y += (thisPoint.y - dragOrigin_.y); + [sourceWindow_ setFrameOrigin:NSMakePoint(origin.x, origin.y)]; + } // else do nothing. + return; + } + + // First, go through the magnetic drag cycle. We break out of this if + // "stretchiness" ever exceeds a set amount. + tabWasDragged_ = YES; + + if (draggingWithinTabStrip_) { + NSPoint thisPoint = [NSEvent mouseLocation]; + CGFloat stretchiness = thisPoint.y - dragOrigin_.y; + stretchiness = copysign(sqrtf(fabs(stretchiness))/sqrtf(kTearDistance), + stretchiness) / 2.0; + CGFloat offset = thisPoint.x - dragOrigin_.x; + if (fabsf(offset) > 100) stretchiness = 0; + [sourceController_ insertPlaceholderForTab:self + frame:NSOffsetRect(sourceTabFrame_, + offset, 0) + yStretchiness:stretchiness]; + // Check that we haven't pulled the tab too far to start a drag. This + // can include either pulling it too far down, or off the side of the tab + // strip that would cause it to no longer be fully visible. + BOOL stillVisible = [sourceController_ isTabFullyVisible:self]; + CGFloat tearForce = fabs(thisPoint.y - dragOrigin_.y); + if ([sourceController_ tabTearingAllowed] && + (tearForce > kTearDistance || !stillVisible)) { + draggingWithinTabStrip_ = NO; + // When you finally leave the strip, we treat that as the origin. + dragOrigin_.x = thisPoint.x; + } else { + // Still dragging within the tab strip, wait for the next drag event. + return; + } + } + + // Do not start dragging until the user has "torn" the tab off by + // moving more than 3 pixels. + NSDate* targetDwellDate = nil; // The date this target was first chosen. + + NSPoint thisPoint = [NSEvent mouseLocation]; + + // Iterate over possible targets checking for the one the mouse is in. + // If the tab is just in the frame, bring the window forward to make it + // easier to drop something there. If it's in the tab strip, set the new + // target so that it pops into that window. We can't cache this because we + // need the z-order to be correct. + NSArray* targets = [self dropTargetsForController:draggedController_]; + TabWindowController* newTarget = nil; + for (TabWindowController* target in targets) { + NSRect windowFrame = [[target window] frame]; + if (NSPointInRect(thisPoint, windowFrame)) { + [[target window] orderFront:self]; + NSRect tabStripFrame = [[target tabStripView] frame]; + tabStripFrame.origin = [[target window] + convertBaseToScreen:tabStripFrame.origin]; + if (NSPointInRect(thisPoint, tabStripFrame)) { + newTarget = target; + } + break; + } + } + + // If we're now targeting a new window, re-layout the tabs in the old + // target and reset how long we've been hovering over this new one. + if (targetController_ != newTarget) { + targetDwellDate = [NSDate date]; + [targetController_ removePlaceholder]; + targetController_ = newTarget; + if (!newTarget) { + tearTime_ = [NSDate timeIntervalSinceReferenceDate]; + tearOrigin_ = [dragWindow_ frame].origin; + } + } + + // Create or identify the dragged controller. + if (!draggedController_) { + // Get rid of any placeholder remaining in the original source window. + [sourceController_ removePlaceholder]; + + // Detach from the current window and put it in a new window. If there are + // no more tabs remaining after detaching, the source window is about to + // go away (it's been autoreleased) so we need to ensure we don't reference + // it any more. In that case the new controller becomes our source + // controller. + draggedController_ = [sourceController_ detachTabToNewWindow:self]; + dragWindow_ = [draggedController_ window]; + [dragWindow_ setAlphaValue:0.0]; + if (![sourceController_ hasLiveTabs]) { + sourceController_ = draggedController_; + sourceWindow_ = dragWindow_; + } + + // If dragging the tab only moves the current window, do not show overlay + // so that sheets stay on top of the window. + // Bring the target window to the front and make sure it has a border. + [dragWindow_ setLevel:NSFloatingWindowLevel]; + [dragWindow_ setHasShadow:YES]; + [dragWindow_ orderFront:nil]; + [dragWindow_ makeMainWindow]; + [draggedController_ showOverlay]; + dragOverlay_ = [draggedController_ overlayWindow]; + // Force the new tab button to be hidden. We'll reset it on mouse up. + [draggedController_ showNewTabButton:NO]; + tearTime_ = [NSDate timeIntervalSinceReferenceDate]; + tearOrigin_ = sourceWindowFrame_.origin; + } + + // TODO(pinkerton): http://crbug.com/25682 demonstrates a way to get here by + // some weird circumstance that doesn't first go through mouseDown:. We + // really shouldn't go any farther. + if (!draggedController_ || !sourceController_) + return; + + // When the user first tears off the window, we want slide the window to + // the current mouse location (to reduce the jarring appearance). We do this + // by calling ourselves back with additional mouseDragged calls (not actual + // events). |tearProgress| is a normalized measure of how far through this + // tear "animation" (of length kTearDuration) we are and has values [0..1]. + // We use sqrt() so the animation is non-linear (slow down near the end + // point). + NSTimeInterval tearProgress = + [NSDate timeIntervalSinceReferenceDate] - tearTime_; + tearProgress /= kTearDuration; // Normalize. + tearProgress = sqrtf(MAX(MIN(tearProgress, 1.0), 0.0)); + + // Move the dragged window to the right place on the screen. + NSPoint origin = sourceWindowFrame_.origin; + origin.x += (thisPoint.x - dragOrigin_.x); + origin.y += (thisPoint.y - dragOrigin_.y); + + if (tearProgress < 1) { + // If the tear animation is not complete, call back to ourself with the + // same event to animate even if the mouse isn't moving. We need to make + // sure these get cancelled in mouseUp:. + [NSObject cancelPreviousPerformRequestsWithTarget:self]; + [self performSelector:@selector(mouseDragged:) + withObject:theEvent + afterDelay:1.0f/30.0f]; + + // Set the current window origin based on how far we've progressed through + // the tear animation. + origin.x = (1 - tearProgress) * tearOrigin_.x + tearProgress * origin.x; + origin.y = (1 - tearProgress) * tearOrigin_.y + tearProgress * origin.y; + } + + if (targetController_) { + // In order to "snap" two windows of different sizes together at their + // toolbar, we can't just use the origin of the target frame. We also have + // to take into consideration the difference in height. + NSRect targetFrame = [[targetController_ window] frame]; + NSRect sourceFrame = [dragWindow_ frame]; + origin.y = NSMinY(targetFrame) + + (NSHeight(targetFrame) - NSHeight(sourceFrame)); + } + [dragWindow_ setFrameOrigin:NSMakePoint(origin.x, origin.y)]; + + // If we're not hovering over any window, make the window fully + // opaque. Otherwise, find where the tab might be dropped and insert + // a placeholder so it appears like it's part of that window. + if (targetController_) { + if (![[targetController_ window] isKeyWindow]) { + // && ([targetDwellDate timeIntervalSinceNow] < -REQUIRED_DWELL)) { + [[targetController_ window] orderFront:nil]; + targetDwellDate = nil; + } + + // Compute where placeholder should go and insert it into the + // destination tab strip. + TabView* draggedTabView = (TabView*)[draggedController_ selectedTabView]; + NSRect tabFrame = [draggedTabView frame]; + tabFrame.origin = [dragWindow_ convertBaseToScreen:tabFrame.origin]; + tabFrame.origin = [[targetController_ window] + convertScreenToBase:tabFrame.origin]; + tabFrame = [[targetController_ tabStripView] + convertRect:tabFrame fromView:nil]; + [targetController_ insertPlaceholderForTab:self + frame:tabFrame + yStretchiness:0]; + [targetController_ layoutTabs]; + } else { + [dragWindow_ makeKeyAndOrderFront:nil]; + } + + // Adjust the visibility of the window background. If there is a drop target, + // we want to hide the window background so the tab stands out for + // positioning. If not, we want to show it so it looks like a new window will + // be realized. + BOOL chromeShouldBeVisible = targetController_ == nil; + [self setWindowBackgroundVisibility:chromeShouldBeVisible]; +} + +- (void)mouseUp:(NSEvent*)theEvent { + // The drag/click is done. If the user dragged the mouse, finalize the drag + // and clean up. + + // Special-case this to keep the logic below simpler. + if (moveWindowOnDrag_) + return; + + // Cancel any delayed -mouseDragged: requests that may still be pending. + [NSObject cancelPreviousPerformRequestsWithTarget:self]; + + // TODO(pinkerton): http://crbug.com/25682 demonstrates a way to get here by + // some weird circumstance that doesn't first go through mouseDown:. We + // really shouldn't go any farther. + if (!sourceController_) + return; + + // We are now free to re-display the new tab button in the window we're + // dragging. It will show when the next call to -layoutTabs (which happens + // indrectly by several of the calls below, such as removing the placeholder). + [draggedController_ showNewTabButton:YES]; + + if (draggingWithinTabStrip_) { + if (tabWasDragged_) { + // Move tab to new location. + DCHECK([sourceController_ numberOfTabs]); + TabWindowController* dropController = sourceController_; + [dropController moveTabView:[dropController selectedTabView] + fromController:nil]; + } + } else if (targetController_) { + // Move between windows. If |targetController_| is nil, we're not dropping + // into any existing window. + NSView* draggedTabView = [draggedController_ selectedTabView]; + [targetController_ moveTabView:draggedTabView + fromController:draggedController_]; + // Force redraw to avoid flashes of old content before returning to event + // loop. + [[targetController_ window] display]; + [targetController_ showWindow:nil]; + [draggedController_ removeOverlay]; + } else { + // Only move the window around on screen. Make sure it's set back to + // normal state (fully opaque, has shadow, has key, etc). + [draggedController_ removeOverlay]; + // Don't want to re-show the window if it was closed during the drag. + if ([dragWindow_ isVisible]) { + [dragWindow_ setAlphaValue:1.0]; + [dragOverlay_ setHasShadow:NO]; + [dragWindow_ setHasShadow:YES]; + [dragWindow_ makeKeyAndOrderFront:nil]; + } + [[draggedController_ window] setLevel:NSNormalWindowLevel]; + [draggedController_ removePlaceholder]; + } + [sourceController_ removePlaceholder]; + chromeIsVisible_ = YES; + + [self resetDragControllers]; +} + +- (void)otherMouseUp:(NSEvent*)theEvent { + if ([self isClosing]) + return; + + // Support middle-click-to-close. + if ([theEvent buttonNumber] == 2) { + // |-hitTest:| takes a location in the superview's coordinates. + NSPoint upLocation = + [[self superview] convertPoint:[theEvent locationInWindow] + fromView:nil]; + // If the mouse up occurred in our view or over the close button, then + // close. + if ([self hitTest:upLocation]) + [controller_ closeTab:self]; + } +} + +- (void)drawRect:(NSRect)dirtyRect { + NSGraphicsContext* context = [NSGraphicsContext currentContext]; + [context saveGraphicsState]; + + BrowserThemeProvider* themeProvider = + static_cast<BrowserThemeProvider*>([[self window] themeProvider]); + [context setPatternPhase:[[self window] themePatternPhase]]; + + NSRect rect = [self bounds]; + NSBezierPath* path = [self bezierPathForRect:rect]; + + BOOL selected = [self state]; + // Don't draw the window/tab bar background when selected, since the tab + // background overlay drawn over it (see below) will be fully opaque. + BOOL hasBackgroundImage = NO; + if (!selected) { + // ThemeProvider::HasCustomImage is true only if the theme provides the + // image. However, even if the theme doesn't provide a tab background, the + // theme machinery will make one if given a frame image. See + // BrowserThemePack::GenerateTabBackgroundImages for details. + hasBackgroundImage = themeProvider && + (themeProvider->HasCustomImage(IDR_THEME_TAB_BACKGROUND) || + themeProvider->HasCustomImage(IDR_THEME_FRAME)); + + NSColor* backgroundImageColor = hasBackgroundImage ? + themeProvider->GetNSImageColorNamed(IDR_THEME_TAB_BACKGROUND, true) : + nil; + + if (backgroundImageColor) { + [backgroundImageColor set]; + [path fill]; + } else { + // Use the window's background color rather than |[NSColor + // windowBackgroundColor]|, which gets confused by the fullscreen window. + // (The result is the same for normal, non-fullscreen windows.) + [[[self window] backgroundColor] set]; + [path fill]; + [[NSColor colorWithCalibratedWhite:1.0 alpha:0.3] set]; + [path fill]; + } + } + + [context saveGraphicsState]; + [path addClip]; + + // Use the same overlay for the selected state and for hover and alert glows; + // for the selected state, it's fully opaque. + CGFloat hoverAlpha = [self hoverAlpha]; + CGFloat alertAlpha = [self alertAlpha]; + if (selected || hoverAlpha > 0 || alertAlpha > 0) { + // Draw the selected background / glow overlay. + [context saveGraphicsState]; + CGContextRef cgContext = static_cast<CGContextRef>([context graphicsPort]); + CGContextBeginTransparencyLayer(cgContext, 0); + if (!selected) { + // The alert glow overlay is like the selected state but at most at most + // 80% opaque. The hover glow brings up the overlay's opacity at most 50%. + CGFloat backgroundAlpha = 0.8 * alertAlpha; + backgroundAlpha += (1 - backgroundAlpha) * 0.5 * hoverAlpha; + CGContextSetAlpha(cgContext, backgroundAlpha); + } + [path addClip]; + [context saveGraphicsState]; + [super drawBackground]; + [context restoreGraphicsState]; + + // Draw a mouse hover gradient for the default themes. + if (!selected && hoverAlpha > 0) { + if (themeProvider && !hasBackgroundImage) { + scoped_nsobject<NSGradient> glow([NSGradient alloc]); + [glow initWithStartingColor:[NSColor colorWithCalibratedWhite:1.0 + alpha:1.0 * hoverAlpha] + endingColor:[NSColor colorWithCalibratedWhite:1.0 + alpha:0.0]]; + + NSPoint point = hoverPoint_; + point.y = NSHeight(rect); + [glow drawFromCenter:point + radius:0.0 + toCenter:point + radius:NSWidth(rect) / 3.0 + options:NSGradientDrawsBeforeStartingLocation]; + + [glow drawInBezierPath:path relativeCenterPosition:hoverPoint_]; + } + } + + CGContextEndTransparencyLayer(cgContext); + [context restoreGraphicsState]; + } + + BOOL active = [[self window] isKeyWindow] || [[self window] isMainWindow]; + CGFloat borderAlpha = selected ? (active ? 0.3 : 0.2) : 0.2; + NSColor* borderColor = [NSColor colorWithDeviceWhite:0.0 alpha:borderAlpha]; + NSColor* highlightColor = themeProvider ? themeProvider->GetNSColor( + themeProvider->UsingDefaultTheme() ? + BrowserThemeProvider::COLOR_TOOLBAR_BEZEL : + BrowserThemeProvider::COLOR_TOOLBAR, true) : nil; + + // Draw the top inner highlight within the currently selected tab if using + // the default theme. + if (selected && themeProvider && themeProvider->UsingDefaultTheme()) { + NSAffineTransform* highlightTransform = [NSAffineTransform transform]; + [highlightTransform translateXBy:1.0 yBy:-1.0]; + scoped_nsobject<NSBezierPath> highlightPath([path copy]); + [highlightPath transformUsingAffineTransform:highlightTransform]; + [highlightColor setStroke]; + [highlightPath setLineWidth:1.0]; + [highlightPath stroke]; + highlightTransform = [NSAffineTransform transform]; + [highlightTransform translateXBy:-2.0 yBy:0.0]; + [highlightPath transformUsingAffineTransform:highlightTransform]; + [highlightPath stroke]; + } + + [context restoreGraphicsState]; + + // Draw the top stroke. + [context saveGraphicsState]; + [borderColor set]; + [path setLineWidth:1.0]; + [path stroke]; + [context restoreGraphicsState]; + + // Mimic the tab strip's bottom border, which consists of a dark border + // and light highlight. + if (!selected) { + [path addClip]; + NSRect borderRect = rect; + borderRect.origin.y = 1; + borderRect.size.height = 1; + [borderColor set]; + NSRectFillUsingOperation(borderRect, NSCompositeSourceOver); + + borderRect.origin.y = 0; + [highlightColor set]; + NSRectFillUsingOperation(borderRect, NSCompositeSourceOver); + } + + [context restoreGraphicsState]; +} + +- (void)viewDidMoveToWindow { + [super viewDidMoveToWindow]; + if ([self window]) { + [controller_ updateTitleColor]; + } +} + +- (void)setClosing:(BOOL)closing { + closing_ = closing; // Safe because the property is nonatomic. + // When closing, ensure clicks to the close button go nowhere. + if (closing) { + [closeButton_ setTarget:nil]; + [closeButton_ setAction:nil]; + } +} + +- (void)startAlert { + // Do not start a new alert while already alerting or while in a decay cycle. + if (alertState_ == tabs::kAlertNone) { + alertState_ = tabs::kAlertRising; + [self resetLastGlowUpdateTime]; + [self adjustGlowValue]; + } +} + +- (void)cancelAlert { + if (alertState_ != tabs::kAlertNone) { + alertState_ = tabs::kAlertFalling; + alertHoldEndTime_ = + [NSDate timeIntervalSinceReferenceDate] + kGlowUpdateInterval; + [self resetLastGlowUpdateTime]; + [self adjustGlowValue]; + } +} + +- (BOOL)accessibilityIsIgnored { + return NO; +} + +- (NSArray*)accessibilityActionNames { + NSArray* parentActions = [super accessibilityActionNames]; + + return [parentActions arrayByAddingObject:NSAccessibilityPressAction]; +} + +- (NSArray*)accessibilityAttributeNames { + NSMutableArray* attributes = + [[super accessibilityAttributeNames] mutableCopy]; + [attributes addObject:NSAccessibilityTitleAttribute]; + [attributes addObject:NSAccessibilityEnabledAttribute]; + + return attributes; +} + +- (BOOL)accessibilityIsAttributeSettable:(NSString*)attribute { + if ([attribute isEqual:NSAccessibilityTitleAttribute]) + return NO; + + if ([attribute isEqual:NSAccessibilityEnabledAttribute]) + return NO; + + return [super accessibilityIsAttributeSettable:attribute]; +} + +- (id)accessibilityAttributeValue:(NSString*)attribute { + if ([attribute isEqual:NSAccessibilityRoleAttribute]) + return NSAccessibilityButtonRole; + + if ([attribute isEqual:NSAccessibilityTitleAttribute]) + return [controller_ title]; + + if ([attribute isEqual:NSAccessibilityEnabledAttribute]) + return [NSNumber numberWithBool:YES]; + + if ([attribute isEqual:NSAccessibilityChildrenAttribute]) { + // The subviews (icon and text) are clutter; filter out everything but + // useful controls. + NSArray* children = [super accessibilityAttributeValue:attribute]; + NSMutableArray* okChildren = [NSMutableArray array]; + for (id child in children) { + if ([child isKindOfClass:[NSButtonCell class]]) + [okChildren addObject:child]; + } + + return okChildren; + } + + return [super accessibilityAttributeValue:attribute]; +} + +- (ViewID)viewID { + return VIEW_ID_TAB; +} + +@end // @implementation TabView + +@implementation TabView (TabControllerInterface) + +- (void)setController:(TabController*)controller { + controller_ = controller; +} + +@end // @implementation TabView (TabControllerInterface) + +@implementation TabView(Private) + +- (void)resetLastGlowUpdateTime { + lastGlowUpdate_ = [NSDate timeIntervalSinceReferenceDate]; +} + +- (NSTimeInterval)timeElapsedSinceLastGlowUpdate { + return [NSDate timeIntervalSinceReferenceDate] - lastGlowUpdate_; +} + +- (void)adjustGlowValue { + // A time interval long enough to represent no update. + const NSTimeInterval kNoUpdate = 1000000; + + // Time until next update for either glow. + NSTimeInterval nextUpdate = kNoUpdate; + + NSTimeInterval elapsed = [self timeElapsedSinceLastGlowUpdate]; + NSTimeInterval currentTime = [NSDate timeIntervalSinceReferenceDate]; + + // TODO(viettrungluu): <http://crbug.com/30617> -- split off the stuff below + // into a pure function and add a unit test. + + CGFloat hoverAlpha = [self hoverAlpha]; + if (isMouseInside_) { + // Increase hover glow until it's 1. + if (hoverAlpha < 1) { + hoverAlpha = MIN(hoverAlpha + elapsed / kHoverShowDuration, 1); + [self setHoverAlpha:hoverAlpha]; + nextUpdate = MIN(kGlowUpdateInterval, nextUpdate); + } // Else already 1 (no update needed). + } else { + if (currentTime >= hoverHoldEndTime_) { + // No longer holding, so decrease hover glow until it's 0. + if (hoverAlpha > 0) { + hoverAlpha = MAX(hoverAlpha - elapsed / kHoverHideDuration, 0); + [self setHoverAlpha:hoverAlpha]; + nextUpdate = MIN(kGlowUpdateInterval, nextUpdate); + } // Else already 0 (no update needed). + } else { + // Schedule update for end of hold time. + nextUpdate = MIN(hoverHoldEndTime_ - currentTime, nextUpdate); + } + } + + CGFloat alertAlpha = [self alertAlpha]; + if (alertState_ == tabs::kAlertRising) { + // Increase alert glow until it's 1 ... + alertAlpha = MIN(alertAlpha + elapsed / kAlertShowDuration, 1); + [self setAlertAlpha:alertAlpha]; + + // ... and having reached 1, switch to holding. + if (alertAlpha >= 1) { + alertState_ = tabs::kAlertHolding; + alertHoldEndTime_ = currentTime + kAlertHoldDuration; + nextUpdate = MIN(kAlertHoldDuration, nextUpdate); + } else { + nextUpdate = MIN(kGlowUpdateInterval, nextUpdate); + } + } else if (alertState_ != tabs::kAlertNone) { + if (alertAlpha > 0) { + if (currentTime >= alertHoldEndTime_) { + // Stop holding, then decrease alert glow (until it's 0). + if (alertState_ == tabs::kAlertHolding) { + alertState_ = tabs::kAlertFalling; + nextUpdate = MIN(kGlowUpdateInterval, nextUpdate); + } else { + DCHECK_EQ(tabs::kAlertFalling, alertState_); + alertAlpha = MAX(alertAlpha - elapsed / kAlertHideDuration, 0); + [self setAlertAlpha:alertAlpha]; + nextUpdate = MIN(kGlowUpdateInterval, nextUpdate); + } + } else { + // Schedule update for end of hold time. + nextUpdate = MIN(alertHoldEndTime_ - currentTime, nextUpdate); + } + } else { + // Done the alert decay cycle. + alertState_ = tabs::kAlertNone; + } + } + + if (nextUpdate < kNoUpdate) + [self performSelector:_cmd withObject:nil afterDelay:nextUpdate]; + + [self resetLastGlowUpdateTime]; + [self setNeedsDisplay:YES]; +} + +// Returns the workspace id of |window|. If |useCache|, then lookup +// and remember the value in |workspaceIDCache_| until the end of the +// current drag. +- (int)getWorkspaceID:(NSWindow*)window useCache:(BOOL)useCache { + CGWindowID windowID = [window windowNumber]; + if (useCache) { + std::map<CGWindowID, int>::iterator iter = + workspaceIDCache_.find(windowID); + if (iter != workspaceIDCache_.end()) + return iter->second; + } + + int workspace = -1; + // It's possible to query in bulk, but probably not necessary. + base::mac::ScopedCFTypeRef<CFArrayRef> windowIDs(CFArrayCreate( + NULL, reinterpret_cast<const void **>(&windowID), 1, NULL)); + base::mac::ScopedCFTypeRef<CFArrayRef> descriptions( + CGWindowListCreateDescriptionFromArray(windowIDs)); + DCHECK(CFArrayGetCount(descriptions.get()) <= 1); + if (CFArrayGetCount(descriptions.get()) > 0) { + CFDictionaryRef dict = static_cast<CFDictionaryRef>( + CFArrayGetValueAtIndex(descriptions.get(), 0)); + DCHECK(CFGetTypeID(dict) == CFDictionaryGetTypeID()); + + // Sanity check the ID. + CFNumberRef otherIDRef = (CFNumberRef)mac_util::GetValueFromDictionary( + dict, kCGWindowNumber, CFNumberGetTypeID()); + CGWindowID otherID; + if (otherIDRef && + CFNumberGetValue(otherIDRef, kCGWindowIDCFNumberType, &otherID) && + otherID == windowID) { + // And then get the workspace. + CFNumberRef workspaceRef = (CFNumberRef)mac_util::GetValueFromDictionary( + dict, kCGWindowWorkspace, CFNumberGetTypeID()); + if (!workspaceRef || + !CFNumberGetValue(workspaceRef, kCFNumberIntType, &workspace)) { + workspace = -1; + } + } else { + NOTREACHED(); + } + } + if (useCache) { + workspaceIDCache_[windowID] = workspace; + } + return workspace; +} + +// Returns the bezier path used to draw the tab given the bounds to draw it in. +- (NSBezierPath*)bezierPathForRect:(NSRect)rect { + // Outset by 0.5 in order to draw on pixels rather than on borders (which + // would cause blurry pixels). Subtract 1px of height to compensate, otherwise + // clipping will occur. + rect = NSInsetRect(rect, -0.5, -0.5); + rect.size.height -= 1.0; + + NSPoint bottomLeft = NSMakePoint(NSMinX(rect), NSMinY(rect) + 2); + NSPoint bottomRight = NSMakePoint(NSMaxX(rect), NSMinY(rect) + 2); + NSPoint topRight = + NSMakePoint(NSMaxX(rect) - kInsetMultiplier * NSHeight(rect), + NSMaxY(rect)); + NSPoint topLeft = + NSMakePoint(NSMinX(rect) + kInsetMultiplier * NSHeight(rect), + NSMaxY(rect)); + + CGFloat baseControlPointOutset = NSHeight(rect) * kControlPoint1Multiplier; + CGFloat bottomControlPointInset = NSHeight(rect) * kControlPoint2Multiplier; + + // Outset many of these values by 1 to cause the fill to bleed outside the + // clip area. + NSBezierPath* path = [NSBezierPath bezierPath]; + [path moveToPoint:NSMakePoint(bottomLeft.x - 1, bottomLeft.y - 2)]; + [path lineToPoint:NSMakePoint(bottomLeft.x - 1, bottomLeft.y)]; + [path lineToPoint:bottomLeft]; + [path curveToPoint:topLeft + controlPoint1:NSMakePoint(bottomLeft.x + baseControlPointOutset, + bottomLeft.y) + controlPoint2:NSMakePoint(topLeft.x - bottomControlPointInset, + topLeft.y)]; + [path lineToPoint:topRight]; + [path curveToPoint:bottomRight + controlPoint1:NSMakePoint(topRight.x + bottomControlPointInset, + topRight.y) + controlPoint2:NSMakePoint(bottomRight.x - baseControlPointOutset, + bottomRight.y)]; + [path lineToPoint:NSMakePoint(bottomRight.x + 1, bottomRight.y)]; + [path lineToPoint:NSMakePoint(bottomRight.x + 1, bottomRight.y - 2)]; + return path; +} + +@end // @implementation TabView(Private) diff --git a/chrome/browser/ui/cocoa/tab_view_picker_table.h b/chrome/browser/ui/cocoa/tab_view_picker_table.h new file mode 100644 index 0000000..75d943f --- /dev/null +++ b/chrome/browser/ui/cocoa/tab_view_picker_table.h @@ -0,0 +1,29 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#import "base/cocoa_protocols_mac.h" +#include "base/scoped_nsobject.h" + +// TabViewPickerTable is an NSOutlineView that can be used to switch between the +// NSTabViewItems of an NSTabView. To use this, just create a +// TabViewPickerTable in Interface Builder and connect the |tabView_| outlet +// to an NSTabView. Now the table is automatically populated with the tab labels +// of the tab view, clicking the table updates the tab view, and switching +// tab view items updates the selection of the table. +@interface TabViewPickerTable : NSOutlineView <NSTabViewDelegate, + NSOutlineViewDelegate, + NSOutlineViewDataSource> { + @public + IBOutlet NSTabView* tabView_; // Visible for testing. + + @private + id oldTabViewDelegate_; + + // Shown above all the tab names. May be |nil|. + scoped_nsobject<NSString> heading_; +} +@property (nonatomic, copy) NSString* heading; +@end diff --git a/chrome/browser/ui/cocoa/tab_view_picker_table.mm b/chrome/browser/ui/cocoa/tab_view_picker_table.mm new file mode 100644 index 0000000..95dec3b --- /dev/null +++ b/chrome/browser/ui/cocoa/tab_view_picker_table.mm @@ -0,0 +1,193 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "tab_view_picker_table.h" + +#include "base/logging.h" + +@interface TabViewPickerTable (Private) +// If a heading is shown, the indices between the tab items and the table rows +// are shifted by one. These functions convert between tab indices and table +// indices. +- (NSInteger)tabIndexFromTableIndex:(NSInteger)tableIndex; +- (NSInteger)tableIndexFromTabIndex:(NSInteger)tabIndex; + +// Returns if |item| is the item shown as heading. If |heading_| is nil, this +// always returns |NO|. +- (BOOL)isHeadingItem:(id)item; + +// Reloads the outline view and sets the selection to the row corresponding to +// the currently selected tab. +- (void)reloadDataWhileKeepingCurrentTabSelected; +@end + +@implementation TabViewPickerTable + +- (id)initWithFrame:(NSRect)frame { + if ((self = [super initWithFrame:frame])) { + [self setDelegate:self]; + [self setDataSource:self]; + } + return self; +} + +- (id)initWithCoder:(NSCoder*)coder { + if ((self = [super initWithCoder:coder])) { + [self setDelegate:self]; + [self setDataSource:self]; + } + return self; +} + +- (void)awakeFromNib { + DCHECK(tabView_); + DCHECK_EQ([self delegate], self); + DCHECK_EQ([self dataSource], self); + DCHECK(![self allowsEmptySelection]); + DCHECK(![self allowsMultipleSelection]); + + // Suppress the "Selection changed" message that's sent while the table is + // being built for the first time (this causes a selection change to index 0 + // and back to the prior index). + id oldTabViewDelegate = [tabView_ delegate]; + [tabView_ setDelegate:nil]; + + [self reloadDataWhileKeepingCurrentTabSelected]; + + oldTabViewDelegate_ = oldTabViewDelegate; + [tabView_ setDelegate:self]; +} + +- (NSString*)heading { + return heading_.get(); +} + +- (void)setHeading:(NSString*)str { + heading_.reset([str copy]); + [self reloadDataWhileKeepingCurrentTabSelected]; +} + +- (void)reloadDataWhileKeepingCurrentTabSelected { + NSInteger index = + [tabView_ indexOfTabViewItem:[tabView_ selectedTabViewItem]]; + [self reloadData]; + if (heading_) + [self expandItem:[self outlineView:self child:0 ofItem:nil]]; + NSIndexSet* indexSet = + [NSIndexSet indexSetWithIndex:[self tableIndexFromTabIndex:index]]; + [self selectRowIndexes:indexSet byExtendingSelection:NO]; +} + +// NSTabViewDelegate methods. +- (void) tabView:(NSTabView*)tabView + didSelectTabViewItem:(NSTabViewItem*)tabViewItem { + DCHECK_EQ(tabView_, tabView); + NSInteger index = + [tabView_ indexOfTabViewItem:[tabView_ selectedTabViewItem]]; + NSIndexSet* indexSet = + [NSIndexSet indexSetWithIndex:[self tableIndexFromTabIndex:index]]; + [self selectRowIndexes:indexSet byExtendingSelection:NO]; + if ([oldTabViewDelegate_ + respondsToSelector:@selector(tabView:didSelectTabViewItem:)]) { + [oldTabViewDelegate_ tabView:tabView didSelectTabViewItem:tabViewItem]; + } +} + +- (BOOL) tabView:(NSTabView*)tabView + shouldSelectTabViewItem:(NSTabViewItem*)tabViewItem { + if ([oldTabViewDelegate_ + respondsToSelector:@selector(tabView:shouldSelectTabViewItem:)]) { + return [oldTabViewDelegate_ tabView:tabView + shouldSelectTabViewItem:tabViewItem]; + } + return YES; +} + +- (void) tabView:(NSTabView*)tabView + willSelectTabViewItem:(NSTabViewItem*)tabViewItem { + if ([oldTabViewDelegate_ + respondsToSelector:@selector(tabView:willSelectTabViewItem:)]) { + [oldTabViewDelegate_ tabView:tabView willSelectTabViewItem:tabViewItem]; + } +} + +- (NSInteger)tabIndexFromTableIndex:(NSInteger)tableIndex { + if (!heading_) + return tableIndex; + DCHECK(tableIndex > 0); + return tableIndex - 1; +} + +- (NSInteger)tableIndexFromTabIndex:(NSInteger)tabIndex { + DCHECK_GE(tabIndex, 0); + DCHECK_LT(tabIndex, [tabView_ numberOfTabViewItems]); + if (!heading_) + return tabIndex; + return tabIndex + 1; +} + +- (BOOL)isHeadingItem:(id)item { + return item && item == heading_.get(); +} + +// NSOutlineViewDataSource methods. +- (NSInteger) outlineView:(NSOutlineView*)outlineView + numberOfChildrenOfItem:(id)item { + if (!item) + return heading_ ? 1 : [tabView_ numberOfTabViewItems]; + return (item == heading_.get()) ? [tabView_ numberOfTabViewItems] : 0; +} + +- (BOOL)outlineView:(NSOutlineView*)outlineView isItemExpandable:(id)item { + return [self isHeadingItem:item]; +} + +- (id)outlineView:(NSOutlineView*)outlineView + child:(NSInteger)index + ofItem:(id)item { + if (!item) { + return heading_.get() ? + heading_.get() : static_cast<id>([tabView_ tabViewItemAtIndex:index]); + } + return (item == heading_.get()) ? [tabView_ tabViewItemAtIndex:index] : nil; +} + +- (id) outlineView:(NSOutlineView*)outlineView + objectValueForTableColumn:(NSTableColumn*)tableColumn + byItem:(id)item { + if ([item isKindOfClass:[NSTabViewItem class]]) + return [static_cast<NSTabViewItem*>(item) label]; + if ([self isHeadingItem:item]) + return [item uppercaseString]; + return nil; +} + +// NSOutlineViewDelegate methods. +- (void)outlineViewSelectionDidChange:(NSNotification*)notification { + int row = [self selectedRow]; + [tabView_ selectTabViewItemAtIndex:[self tabIndexFromTableIndex:row]]; +} + +- (BOOL)outlineView:(NSOutlineView *)sender isGroupItem:(id)item { + return [self isHeadingItem:item]; +} + +- (BOOL)outlineView:(NSOutlineView*)outlineView shouldExpandItem:(id)item { + return [self isHeadingItem:item]; +} + +- (BOOL)outlineView:(NSOutlineView*)outlineView shouldCollapseItem:(id)item { + return NO; +} + +- (BOOL)outlineView:(NSOutlineView *)outlineView shouldSelectItem:(id)item { + return ![self isHeadingItem:item]; +} + +// -outlineView:shouldShowOutlineCellForItem: is 10.6-only. +- (NSRect)frameOfOutlineCellAtRow:(NSInteger)row { + return NSZeroRect; +} + +@end diff --git a/chrome/browser/ui/cocoa/tab_view_picker_table_unittest.mm b/chrome/browser/ui/cocoa/tab_view_picker_table_unittest.mm new file mode 100644 index 0000000..4b22b3f --- /dev/null +++ b/chrome/browser/ui/cocoa/tab_view_picker_table_unittest.mm @@ -0,0 +1,138 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#import "chrome/browser/ui/cocoa/tab_view_picker_table.h" + +#import "base/cocoa_protocols_mac.h" +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "testing/gtest/include/gtest/gtest.h" +#import "testing/gtest_mac.h" +#include "testing/platform_test.h" + +@interface TabViewPickerTableTestPing : NSObject <NSTabViewDelegate> { + @public + BOOL didSelectItemCalled_; +} +@end + +@implementation TabViewPickerTableTestPing +- (void) tabView:(NSTabView*)tabView + didSelectTabViewItem:(NSTabViewItem*)tabViewItem { + didSelectItemCalled_ = YES; +} +@end + +namespace { + +class TabViewPickerTableTest : public CocoaTest { + public: + TabViewPickerTableTest() { + // Initialize picker table. + NSRect frame = NSMakeRect(0, 0, 30, 50); + scoped_nsobject<TabViewPickerTable> view( + [[TabViewPickerTable alloc] initWithFrame:frame]); + view_ = view.get(); + [view_ setAllowsEmptySelection:NO]; + [view_ setAllowsMultipleSelection:NO]; + [view_ addTableColumn: + [[[NSTableColumn alloc] initWithIdentifier:nil] autorelease]]; + [[test_window() contentView] addSubview:view_]; + + // Initialize source tab view, with delegate. + frame = NSMakeRect(30, 0, 50, 50); + scoped_nsobject<NSTabView> tabView( + [[NSTabView alloc] initWithFrame:frame]); + tabView_ = tabView.get(); + + scoped_nsobject<NSTabViewItem> item1( + [[NSTabViewItem alloc] initWithIdentifier:nil]); + [item1 setLabel:@"label 1"]; + [tabView_ addTabViewItem:item1]; + + scoped_nsobject<NSTabViewItem> item2( + [[NSTabViewItem alloc] initWithIdentifier:nil]); + [item2 setLabel:@"label 2"]; + [tabView_ addTabViewItem:item2]; + + [tabView_ selectTabViewItemAtIndex:1]; + [[test_window() contentView] addSubview:tabView_]; + + ping_.reset([TabViewPickerTableTestPing new]); + [tabView_ setDelegate:ping_.get()]; + + // Simulate nib loading. + view_->tabView_ = tabView_; + [view_ awakeFromNib]; + } + + TabViewPickerTable* view_; + NSTabView* tabView_; + scoped_nsobject<TabViewPickerTableTestPing> ping_; +}; + +TEST_VIEW(TabViewPickerTableTest, view_) + +TEST_F(TabViewPickerTableTest, TestInitialSelectionCorrect) { + EXPECT_EQ(1, [view_ selectedRow]); +} + +TEST_F(TabViewPickerTableTest, TestSelectionUpdates) { + [tabView_ selectTabViewItemAtIndex:0]; + EXPECT_EQ(0, [view_ selectedRow]); + + [tabView_ selectTabViewItemAtIndex:1]; + EXPECT_EQ(1, [view_ selectedRow]); +} + +TEST_F(TabViewPickerTableTest, TestDelegateStillWorks) { + EXPECT_FALSE(ping_.get()->didSelectItemCalled_); + [tabView_ selectTabViewItemAtIndex:0]; + EXPECT_TRUE(ping_.get()->didSelectItemCalled_); +} + +TEST_F(TabViewPickerTableTest, RowsCorrect) { + EXPECT_EQ(2, [view_ numberOfRows]); + EXPECT_EQ(2, + [[view_ dataSource] outlineView:view_ numberOfChildrenOfItem:nil]); + + id item; + item = [[view_ dataSource] outlineView:view_ child:0 ofItem:nil]; + EXPECT_NSEQ(@"label 1", + [[view_ dataSource] outlineView:view_ + objectValueForTableColumn:nil // ignored + byItem:item]); + item = [[view_ dataSource] outlineView:view_ child:1 ofItem:nil]; + EXPECT_NSEQ(@"label 2", + [[view_ dataSource] outlineView:view_ + objectValueForTableColumn:nil // ignored + byItem:item]); +} + +TEST_F(TabViewPickerTableTest, TestListUpdatesTabView) { + [view_ selectRowIndexes:[NSIndexSet indexSetWithIndex:0] + byExtendingSelection:NO]; + EXPECT_EQ(0, [view_ selectedRow]); // sanity + EXPECT_EQ(0, [tabView_ indexOfTabViewItem:[tabView_ selectedTabViewItem]]); +} + +TEST_F(TabViewPickerTableTest, TestWithHeadingNotEmpty) { + [view_ setHeading:@"disregard this"]; + + EXPECT_EQ(2, [view_ selectedRow]); + + [tabView_ selectTabViewItemAtIndex:0]; + EXPECT_EQ(1, [view_ selectedRow]); + [tabView_ selectTabViewItemAtIndex:1]; + EXPECT_EQ(2, [view_ selectedRow]); + + [view_ selectRowIndexes:[NSIndexSet indexSetWithIndex:1] + byExtendingSelection:NO]; + EXPECT_EQ(1, [view_ selectedRow]); // sanity + EXPECT_EQ(0, [tabView_ indexOfTabViewItem:[tabView_ selectedTabViewItem]]); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/tab_view_unittest.mm b/chrome/browser/ui/cocoa/tab_view_unittest.mm new file mode 100644 index 0000000..961c3a6 --- /dev/null +++ b/chrome/browser/ui/cocoa/tab_view_unittest.mm @@ -0,0 +1,60 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/tab_view.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +namespace { + +class TabViewTest : public CocoaTest { + public: + TabViewTest() { + NSRect frame = NSMakeRect(0, 0, 50, 30); + scoped_nsobject<TabView> view([[TabView alloc] initWithFrame:frame]); + view_ = view.get(); + [[test_window() contentView] addSubview:view_]; + } + + TabView* view_; +}; + +TEST_VIEW(TabViewTest, view_) + +// Test drawing, mostly to ensure nothing leaks or crashes. +TEST_F(TabViewTest, Display) { + for (int i = 0; i < 5; i++) { + for (int j = 0; j < 5; j++) { + [view_ setHoverAlpha:i*0.2]; + [view_ setAlertAlpha:j*0.2]; + [view_ display]; + } + } +} + +// Test it doesn't crash when asked for its menu with no TabController set. +TEST_F(TabViewTest, Menu) { + EXPECT_FALSE([view_ menu]); +} + +TEST_F(TabViewTest, Glow) { + // TODO(viettrungluu): Figure out how to test this, which is timing-sensitive + // and which moreover uses |-performSelector:withObject:afterDelay:|. + + // Call |-startAlert|/|-cancelAlert| and make sure it doesn't crash. + for (int i = 0; i < 5; i++) { + [view_ startAlert]; + [view_ cancelAlert]; + } + [view_ startAlert]; + [view_ startAlert]; + [view_ cancelAlert]; + [view_ cancelAlert]; +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/tab_window_controller.h b/chrome/browser/ui/cocoa/tab_window_controller.h new file mode 100644 index 0000000..7b5f5c8 --- /dev/null +++ b/chrome/browser/ui/cocoa/tab_window_controller.h @@ -0,0 +1,177 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_TAB_WINDOW_CONTROLLER_H_ +#define CHROME_BROWSER_UI_COCOA_TAB_WINDOW_CONTROLLER_H_ +#pragma once + +// A class acting as the Objective-C window controller for a window that has +// tabs which can be dragged around. Tabs can be re-arranged within the same +// window or dragged into other TabWindowController windows. This class doesn't +// know anything about the actual tab implementation or model, as that is fairly +// application-specific. It only provides an API to be overridden by subclasses +// to fill in the details. +// +// This assumes that there will be a view in the nib, connected to +// |tabContentArea_|, that indicates the content that it switched when switching +// between tabs. It needs to be a regular NSView, not something like an NSBox +// because the TabStripController makes certain assumptions about how it can +// swap out subviews. +// +// The tab strip can exist in different orientations and window locations, +// depending on the return value of -usesVerticalTabs. If NO (the default), +// the tab strip is placed outside the window's content area, overlapping the +// title area and window controls and will be stretched to fill the width +// of the window. If YES, the tab strip is vertical and lives within the +// window's content area. It will be stretched to fill the window's height. + +#import <Cocoa/Cocoa.h> + +#import "base/cocoa_protocols_mac.h" +#include "base/scoped_nsobject.h" + +@class FastResizeView; +@class FocusTracker; +@class TabStripView; +@class TabView; + +@interface TabWindowController : NSWindowController<NSWindowDelegate> { + @private + IBOutlet FastResizeView* tabContentArea_; + // TODO(pinkerton): Figure out a better way to initialize one or the other + // w/out needing both to be in the nib. + IBOutlet TabStripView* topTabStripView_; + IBOutlet TabStripView* sideTabStripView_; + NSWindow* overlayWindow_; // Used during dragging for window opacity tricks + NSView* cachedContentView_; // Used during dragging for identifying which + // view is the proper content area in the overlay + // (weak) + scoped_nsobject<FocusTracker> focusBeforeOverlay_; + scoped_nsobject<NSMutableSet> lockedTabs_; + BOOL closeDeferred_; // If YES, call performClose: in removeOverlay:. + // Difference between height of window content area and height of the + // |tabContentArea_|. Calculated when the window is loaded from the nib and + // cached in order to restore the delta when switching tab modes. + CGFloat contentAreaHeightDelta_; +} +@property(readonly, nonatomic) TabStripView* tabStripView; +@property(readonly, nonatomic) FastResizeView* tabContentArea; + +// Used during tab dragging to turn on/off the overlay window when a tab +// is torn off. If -deferPerformClose (below) is used, -removeOverlay will +// cause the controller to be autoreleased before returning. +- (void)showOverlay; +- (void)removeOverlay; +- (NSWindow*)overlayWindow; + +// Returns YES if it is ok to constrain the window's frame to fit the screen. +- (BOOL)shouldConstrainFrameRect; + +// A collection of methods, stubbed out in this base class, that provide +// the implementation of tab dragging based on whatever model is most +// appropriate. + +// Layout the tabs based on the current ordering of the model. +- (void)layoutTabs; + +// Creates a new window by pulling the given tab out and placing it in +// the new window. Returns the controller for the new window. The size of the +// new window will be the same size as this window. +- (TabWindowController*)detachTabToNewWindow:(TabView*)tabView; + +// Make room in the tab strip for |tab| at the given x coordinate. Will hide the +// new tab button while there's a placeholder. Subclasses need to call the +// superclass implementation. +- (void)insertPlaceholderForTab:(TabView*)tab + frame:(NSRect)frame + yStretchiness:(CGFloat)yStretchiness; + +// Removes the placeholder installed by |-insertPlaceholderForTab:atLocation:| +// and restores the new tab button. Subclasses need to call the superclass +// implementation. +- (void)removePlaceholder; + +// The follow return YES if tab dragging/tab tearing (off the tab strip)/window +// movement is currently allowed. Any number of things can choose to disable it, +// such as pending animations. The default implementations always return YES. +// Subclasses should override as appropriate. +- (BOOL)tabDraggingAllowed; +- (BOOL)tabTearingAllowed; +- (BOOL)windowMovementAllowed; + +// Show or hide the new tab button. The button is hidden immediately, but +// waits until the next call to |-layoutTabs| to show it again. +- (void)showNewTabButton:(BOOL)show; + +// Returns whether or not |tab| can still be fully seen in the tab strip or if +// its current position would cause it be obscured by things such as the edge +// of the window or the window decorations. Returns YES only if the entire tab +// is visible. The default implementation always returns YES. +- (BOOL)isTabFullyVisible:(TabView*)tab; + +// Called to check if the receiver can receive dragged tabs from +// source. Return YES if so. The default implementation returns NO. +- (BOOL)canReceiveFrom:(TabWindowController*)source; + +// Move a given tab view to the location of the current placeholder. If there is +// no placeholder, it will go at the end. |controller| is the window controller +// of a tab being dropped from a different window. It will be nil if the drag is +// within the window, otherwise the tab is removed from that window before being +// placed into this one. The implementation will call |-removePlaceholder| since +// the drag is now complete. This also calls |-layoutTabs| internally so +// clients do not need to call it again. +- (void)moveTabView:(NSView*)view + fromController:(TabWindowController*)controller; + +// Number of tabs in the tab strip. Useful, for example, to know if we're +// dragging the only tab in the window. This includes pinned tabs (both live +// and not). +- (NSInteger)numberOfTabs; + +// YES if there are tabs in the tab strip which have content, allowing for +// the notion of tabs in the tab strip that are placeholders but currently have +// no content. +- (BOOL)hasLiveTabs; + +// Return the view of the selected tab. +- (NSView *)selectedTabView; + +// The title of the selected tab. +- (NSString*)selectedTabTitle; + +// Called to check whether or not this controller's window has a tab strip (YES +// if it does, NO otherwise). The default implementation returns YES. +- (BOOL)hasTabStrip; + +// Returns YES if the tab strip lives in the window content area alongside the +// tab contents. Returns NO if the tab strip is outside the window content +// area, along the top of the window. +- (BOOL)useVerticalTabs; + +// Get/set whether a particular tab is draggable between windows. +- (BOOL)isTabDraggable:(NSView*)tabView; +- (void)setTab:(NSView*)tabView isDraggable:(BOOL)draggable; + +// Tell the window that it needs to call performClose: as soon as the current +// drag is complete. This prevents a window (and its overlay) from going away +// during a drag. +- (void)deferPerformClose; + +@end + +@interface TabWindowController(ProtectedMethods) +// Tells the tab strip to forget about this tab in preparation for it being +// put into a different tab strip, such as during a drop on another window. +- (void)detachTabView:(NSView*)view; + +// Toggles from one display mode of the tab strip to another. Will automatically +// call -layoutSubviews to reposition other content. +- (void)toggleTabStripDisplayMode; + +// Called when the size of the window content area has changed. Override to +// position specific views. Base class implementation does nothing. +- (void)layoutSubviews; +@end + +#endif // CHROME_BROWSER_UI_COCOA_TAB_WINDOW_CONTROLLER_H_ diff --git a/chrome/browser/ui/cocoa/tab_window_controller.mm b/chrome/browser/ui/cocoa/tab_window_controller.mm new file mode 100644 index 0000000..70504be --- /dev/null +++ b/chrome/browser/ui/cocoa/tab_window_controller.mm @@ -0,0 +1,351 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/tab_window_controller.h" + +#include "app/theme_provider.h" +#include "base/logging.h" +#import "chrome/browser/ui/cocoa/focus_tracker.h" +#import "chrome/browser/ui/cocoa/tab_strip_view.h" +#import "chrome/browser/ui/cocoa/themed_window.h" + +@interface TabWindowController(PRIVATE) +- (void)setUseOverlay:(BOOL)useOverlay; +@end + +@interface TabWindowOverlayWindow : NSWindow +@end + +@implementation TabWindowOverlayWindow + +- (ThemeProvider*)themeProvider { + if ([self parentWindow]) + return [[[self parentWindow] windowController] themeProvider]; + return NULL; +} + +- (ThemedWindowStyle)themedWindowStyle { + if ([self parentWindow]) + return [[[self parentWindow] windowController] themedWindowStyle]; + return NO; +} + +- (NSPoint)themePatternPhase { + if ([self parentWindow]) + return [[[self parentWindow] windowController] themePatternPhase]; + return NSZeroPoint; +} + +@end + +@implementation TabWindowController +@synthesize tabContentArea = tabContentArea_; + +- (id)initWithWindow:(NSWindow*)window { + if ((self = [super initWithWindow:window]) != nil) { + lockedTabs_.reset([[NSMutableSet alloc] initWithCapacity:10]); + } + return self; +} + +// Add the side tab strip to the left side of the window's content area, +// making it fill the full height of the content area. +- (void)addSideTabStripToWindow { + NSView* contentView = [[self window] contentView]; + NSRect contentFrame = [contentView frame]; + NSRect sideStripFrame = + NSMakeRect(0, 0, + NSWidth([sideTabStripView_ frame]), + NSHeight(contentFrame)); + [sideTabStripView_ setFrame:sideStripFrame]; + [contentView addSubview:sideTabStripView_]; +} + +// Add the top tab strop to the window, above the content box and add it to the +// view hierarchy as a sibling of the content view so it can overlap with the +// window frame. +- (void)addTopTabStripToWindow { + NSRect contentFrame = [tabContentArea_ frame]; + NSRect tabFrame = + NSMakeRect(0, NSMaxY(contentFrame), + NSWidth(contentFrame), + NSHeight([topTabStripView_ frame])); + [topTabStripView_ setFrame:tabFrame]; + NSView* contentParent = [[[self window] contentView] superview]; + [contentParent addSubview:topTabStripView_]; +} + +- (void)windowDidLoad { + // Cache the difference in height between the window content area and the + // tab content area. + NSRect tabFrame = [tabContentArea_ frame]; + NSRect contentFrame = [[[self window] contentView] frame]; + contentAreaHeightDelta_ = NSHeight(contentFrame) - NSHeight(tabFrame); + + if ([self hasTabStrip]) { + if ([self useVerticalTabs]) { + // No top tabstrip so remove the tabContentArea offset. + tabFrame.size.height = contentFrame.size.height; + [tabContentArea_ setFrame:tabFrame]; + [self addSideTabStripToWindow]; + } else { + [self addTopTabStripToWindow]; + } + } else { + // No top tabstrip so remove the tabContentArea offset. + tabFrame.size.height = contentFrame.size.height; + [tabContentArea_ setFrame:tabFrame]; + } +} + +// Toggles from one display mode of the tab strip to another. Will automatically +// call -layoutSubviews to reposition other content. +- (void)toggleTabStripDisplayMode { + // Adjust the size of the tab contents to either use more or less space, + // depending on the direction of the toggle. This needs to be done prior to + // adding back in the top tab strip as its position is based off the MaxY + // of the tab content area. + BOOL useVertical = [self useVerticalTabs]; + NSRect tabContentsFrame = [tabContentArea_ frame]; + tabContentsFrame.size.height += useVertical ? + contentAreaHeightDelta_ : -contentAreaHeightDelta_; + [tabContentArea_ setFrame:tabContentsFrame]; + + if (useVertical) { + // Remove the top tab strip and add the sidebar in. + [topTabStripView_ removeFromSuperview]; + [self addSideTabStripToWindow]; + } else { + // Remove the side tab strip and add the top tab strip as a sibling of the + // window's content area. + [sideTabStripView_ removeFromSuperview]; + NSRect tabContentsFrame = [tabContentArea_ frame]; + tabContentsFrame.size.height -= contentAreaHeightDelta_; + [tabContentArea_ setFrame:tabContentsFrame]; + [self addTopTabStripToWindow]; + } + + [self layoutSubviews]; +} + +// Return the appropriate tab strip based on whether or not side tabs are +// enabled. +- (TabStripView*)tabStripView { + if ([self useVerticalTabs]) + return sideTabStripView_; + return topTabStripView_; +} + +- (void)removeOverlay { + [self setUseOverlay:NO]; + if (closeDeferred_) { + // See comment in BrowserWindowCocoa::Close() about orderOut:. + [[self window] orderOut:self]; + [[self window] performClose:self]; // Autoreleases the controller. + } +} + +- (void)showOverlay { + [self setUseOverlay:YES]; +} + +// if |useOverlay| is true, we're moving views into the overlay's content +// area. If false, we're moving out of the overlay back into the window's +// content. +- (void)moveViewsBetweenWindowAndOverlay:(BOOL)useOverlay { + if (useOverlay) { + [[[overlayWindow_ contentView] superview] addSubview:[self tabStripView]]; + // Add the original window's content view as a subview of the overlay + // window's content view. We cannot simply use setContentView: here because + // the overlay window has a different content size (due to it being + // borderless). + [[overlayWindow_ contentView] addSubview:cachedContentView_]; + } else { + [[self window] setContentView:cachedContentView_]; + // The TabStripView always needs to be in front of the window's content + // view and therefore it should always be added after the content view is + // set. + [[[[self window] contentView] superview] addSubview:[self tabStripView]]; + [[[[self window] contentView] superview] updateTrackingAreas]; + } +} + +// If |useOverlay| is YES, creates a new overlay window and puts the tab strip +// and the content area inside of it. This allows it to have a different opacity +// from the title bar. If NO, returns everything to the previous state and +// destroys the overlay window until it's needed again. The tab strip and window +// contents are returned to the original window. +- (void)setUseOverlay:(BOOL)useOverlay { + [NSObject cancelPreviousPerformRequestsWithTarget:self + selector:@selector(removeOverlay) + object:nil]; + NSWindow* window = [self window]; + if (useOverlay && !overlayWindow_) { + DCHECK(!cachedContentView_); + overlayWindow_ = [[TabWindowOverlayWindow alloc] + initWithContentRect:[window frame] + styleMask:NSBorderlessWindowMask + backing:NSBackingStoreBuffered + defer:YES]; + [overlayWindow_ setTitle:@"overlay"]; + [overlayWindow_ setBackgroundColor:[NSColor clearColor]]; + [overlayWindow_ setOpaque:NO]; + [overlayWindow_ setDelegate:self]; + cachedContentView_ = [window contentView]; + [window addChildWindow:overlayWindow_ ordered:NSWindowAbove]; + // Sets explictly nil to the responder and then restores it. + // Leaving the first responder non-null here + // causes [RenderWidgethostViewCocoa resignFirstResponder] and + // following RenderWidgetHost::Blur(), which results unexpected + // focus lost. + focusBeforeOverlay_.reset([[FocusTracker alloc] initWithWindow:window]); + [window makeFirstResponder:nil]; + [self moveViewsBetweenWindowAndOverlay:useOverlay]; + [overlayWindow_ orderFront:nil]; + } else if (!useOverlay && overlayWindow_) { + DCHECK(cachedContentView_); + [window setContentView:cachedContentView_]; + [self moveViewsBetweenWindowAndOverlay:useOverlay]; + [focusBeforeOverlay_ restoreFocusInWindow:window]; + focusBeforeOverlay_.reset(nil); + [window display]; + [window removeChildWindow:overlayWindow_]; + [overlayWindow_ orderOut:nil]; + [overlayWindow_ release]; + overlayWindow_ = nil; + cachedContentView_ = nil; + } else { + NOTREACHED(); + } +} + +- (NSWindow*)overlayWindow { + return overlayWindow_; +} + +- (BOOL)shouldConstrainFrameRect { + // If we currently have an overlay window, do not attempt to change the + // window's size, as our overlay window doesn't know how to resize properly. + return overlayWindow_ == nil; +} + +- (BOOL)canReceiveFrom:(TabWindowController*)source { + // subclass must implement + NOTIMPLEMENTED(); + return NO; +} + +- (void)moveTabView:(NSView*)view + fromController:(TabWindowController*)dragController { + NOTIMPLEMENTED(); +} + +- (NSView*)selectedTabView { + NOTIMPLEMENTED(); + return nil; +} + +- (void)layoutTabs { + // subclass must implement + NOTIMPLEMENTED(); +} + +- (TabWindowController*)detachTabToNewWindow:(TabView*)tabView { + // subclass must implement + NOTIMPLEMENTED(); + return NULL; +} + +- (void)insertPlaceholderForTab:(TabView*)tab + frame:(NSRect)frame + yStretchiness:(CGFloat)yStretchiness { + [self showNewTabButton:NO]; +} + +- (void)removePlaceholder { + [self showNewTabButton:YES]; +} + +- (BOOL)tabDraggingAllowed { + return YES; +} + +- (BOOL)tabTearingAllowed { + return YES; +} + +- (BOOL)windowMovementAllowed { + return YES; +} + +- (BOOL)isTabFullyVisible:(TabView*)tab { + // Subclasses should implement this, but it's not necessary. + return YES; +} + +- (void)showNewTabButton:(BOOL)show { + // subclass must implement + NOTIMPLEMENTED(); +} + +- (void)detachTabView:(NSView*)view { + // subclass must implement + NOTIMPLEMENTED(); +} + +- (NSInteger)numberOfTabs { + // subclass must implement + NOTIMPLEMENTED(); + return 0; +} + +- (BOOL)hasLiveTabs { + // subclass must implement + NOTIMPLEMENTED(); + return NO; +} + +- (NSString*)selectedTabTitle { + // subclass must implement + NOTIMPLEMENTED(); + return @""; +} + +- (BOOL)hasTabStrip { + // Subclasses should implement this. + NOTIMPLEMENTED(); + return YES; +} + +- (BOOL)useVerticalTabs { + // Subclasses should implement this. + NOTIMPLEMENTED(); + return NO; +} + +- (BOOL)isTabDraggable:(NSView*)tabView { + return ![lockedTabs_ containsObject:tabView]; +} + +- (void)setTab:(NSView*)tabView isDraggable:(BOOL)draggable { + if (draggable) + [lockedTabs_ removeObject:tabView]; + else + [lockedTabs_ addObject:tabView]; +} + +// Tell the window that it needs to call performClose: as soon as the current +// drag is complete. This prevents a window (and its overlay) from going away +// during a drag. +- (void)deferPerformClose { + closeDeferred_ = YES; +} + +// Called when the size of the window content area has changed. Override to +// position specific views. Base class implementation does nothing. +- (void)layoutSubviews { + NOTIMPLEMENTED(); +} + +@end diff --git a/chrome/browser/ui/cocoa/table_model_array_controller.h b/chrome/browser/ui/cocoa/table_model_array_controller.h new file mode 100644 index 0000000..11af327 --- /dev/null +++ b/chrome/browser/ui/cocoa/table_model_array_controller.h @@ -0,0 +1,54 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_TABLE_MODEL_ARRAY_CONTROLLER_H_ +#define CHROME_BROWSER_UI_COCOA_TABLE_MODEL_ARRAY_CONTROLLER_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "app/table_model_observer.h" +#include "base/cocoa_protocols_mac.h" +#include "base/scoped_nsobject.h" +#include "base/scoped_ptr.h" + +class RemoveRowsObserverBridge; +class RemoveRowsTableModel; +@class TableModelArrayController; + +// This class functions as an adapter from a RemoveRowsTableModel to a Cocoa +// NSArrayController, to be used with bindings. +// It maps the CanRemoveRows method to its canRemove property, and exposes +// RemoveRows and RemoveAll as actions (remove: and removeAll:). +// If the table model has groups, these are inserted into the list of arranged +// objects as group rows. +// The designated initializer is the same as for NSArrayController, +// initWithContent:, but usually this class is instantiated from a nib file. +// Clicking on a group row selects all rows belonging to that group, like it +// does in a Windows table_view. +// In order to show group rows, this class must be the delegate of the +// NSTableView. +@interface TableModelArrayController : NSArrayController<NSTableViewDelegate> { + @private + RemoveRowsTableModel* model_; // weak + scoped_ptr<RemoveRowsObserverBridge> tableObserver_; + scoped_nsobject<NSDictionary> columns_; + scoped_nsobject<NSString> groupTitle_; +} + +// Bind this controller to the given model. +// |columns| is a dictionary mapping table column bindings to NSNumbers +// containing the column identifier in the TableModel. +// |groupTitleColumn| is the column in the table that should display the group +// title for a group row, usually the first column. If the model doesn't have +// groups, it can be nil. +- (void)bindToTableModel:(RemoveRowsTableModel*)model + withColumns:(NSDictionary*)columns + groupTitleColumn:(NSString*)groupTitleColumn; + +- (IBAction)removeAll:(id)sender; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_TABLE_MODEL_ARRAY_CONTROLLER_H_ diff --git a/chrome/browser/ui/cocoa/table_model_array_controller.mm b/chrome/browser/ui/cocoa/table_model_array_controller.mm new file mode 100644 index 0000000..e732127 --- /dev/null +++ b/chrome/browser/ui/cocoa/table_model_array_controller.mm @@ -0,0 +1,246 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/table_model_array_controller.h" + +#include "app/table_model.h" +#include "base/logging.h" +#include "base/sys_string_conversions.h" +#include "chrome/browser/remove_rows_table_model.h" + +@interface TableModelArrayController (PrivateMethods) + +- (NSUInteger)offsetForGroupID:(int)groupID; +- (NSUInteger)offsetForGroupID:(int)groupID startingOffset:(NSUInteger)offset; +- (NSIndexSet*)controllerRowsForModelRowsInRange:(NSRange)range; +- (void)setModelRows:(RemoveRowsTableModel::Rows*)modelRows + fromControllerRows:(NSIndexSet*)rows; +- (void)modelDidChange; +- (void)modelDidAddItemsInRange:(NSRange)range; +- (void)modelDidRemoveItemsInRange:(NSRange)range; +- (NSDictionary*)columnValuesForRow:(NSInteger)row; + +@end + +// Observer for a RemoveRowsTableModel. +class RemoveRowsObserverBridge : public TableModelObserver { + public: + RemoveRowsObserverBridge(TableModelArrayController* controller) + : controller_(controller) {} + virtual ~RemoveRowsObserverBridge() {} + + // TableModelObserver methods + virtual void OnModelChanged(); + virtual void OnItemsChanged(int start, int length); + virtual void OnItemsAdded(int start, int length); + virtual void OnItemsRemoved(int start, int length); + + private: + TableModelArrayController* controller_; // weak +}; + +void RemoveRowsObserverBridge::OnModelChanged() { + [controller_ modelDidChange]; +} + +void RemoveRowsObserverBridge::OnItemsChanged(int start, int length) { + OnItemsRemoved(start, length); + OnItemsAdded(start, length); +} + +void RemoveRowsObserverBridge::OnItemsAdded(int start, int length) { + [controller_ modelDidAddItemsInRange:NSMakeRange(start, length)]; +} + +void RemoveRowsObserverBridge::OnItemsRemoved(int start, int length) { + [controller_ modelDidRemoveItemsInRange:NSMakeRange(start, length)]; +} + +@implementation TableModelArrayController + +static NSString* const kIsGroupRow = @"_is_group_row"; +static NSString* const kGroupID = @"_group_id"; + +- (void)bindToTableModel:(RemoveRowsTableModel*)model + withColumns:(NSDictionary*)columns + groupTitleColumn:(NSString*)groupTitleColumn { + model_ = model; + tableObserver_.reset(new RemoveRowsObserverBridge(self)); + columns_.reset([columns copy]); + groupTitle_.reset([groupTitleColumn copy]); + model_->SetObserver(tableObserver_.get()); + [self modelDidChange]; +} + +- (void)modelDidChange { + NSIndexSet* indexes = [NSIndexSet indexSetWithIndexesInRange: + NSMakeRange(0, [[self arrangedObjects] count])]; + [self removeObjectsAtArrangedObjectIndexes:indexes]; + if (model_->HasGroups()) { + const TableModel::Groups& groups = model_->GetGroups(); + DCHECK(groupTitle_.get()); + for (TableModel::Groups::const_iterator it = groups.begin(); + it != groups.end(); ++it) { + NSDictionary* group = [NSDictionary dictionaryWithObjectsAndKeys: + base::SysWideToNSString(it->title), groupTitle_.get(), + [NSNumber numberWithBool:YES], kIsGroupRow, + nil]; + [self addObject:group]; + } + } + [self modelDidAddItemsInRange:NSMakeRange(0, model_->RowCount())]; +} + +- (NSUInteger)offsetForGroupID:(int)groupID startingOffset:(NSUInteger)offset { + const TableModel::Groups& groups = model_->GetGroups(); + DCHECK_GT(offset, 0u); + for (NSUInteger i = offset - 1; i < groups.size(); ++i) { + if (groups[i].id == groupID) + return i + 1; + } + NOTREACHED(); + return NSNotFound; +} + +- (NSUInteger)offsetForGroupID:(int)groupID { + return [self offsetForGroupID:groupID startingOffset:1]; +} + +- (int)groupIDForControllerRow:(NSUInteger)row { + NSDictionary* values = [[self arrangedObjects] objectAtIndex:row]; + return [[values objectForKey:kGroupID] intValue]; +} + +- (void)setModelRows:(RemoveRowsTableModel::Rows*)modelRows + fromControllerRows:(NSIndexSet*)rows { + if ([rows count] == 0) + return; + + if (!model_->HasGroups()) { + for (NSUInteger i = [rows firstIndex]; + i != NSNotFound; + i = [rows indexGreaterThanIndex:i]) { + modelRows->insert(i); + } + return; + } + + NSUInteger offset = 1; + for (NSUInteger i = [rows firstIndex]; + i != NSNotFound; + i = [rows indexGreaterThanIndex:i]) { + int group = [self groupIDForControllerRow:i]; + offset = [self offsetForGroupID:group startingOffset:offset]; + modelRows->insert(i - offset); + } +} + +- (NSIndexSet*)controllerRowsForModelRowsInRange:(NSRange)range { + if (!model_->HasGroups()) + return [NSIndexSet indexSetWithIndexesInRange:range]; + NSMutableIndexSet* indexes = [NSMutableIndexSet indexSet]; + NSUInteger offset = 1; + for (NSUInteger i = range.location; i < NSMaxRange(range); ++i) { + int group = model_->GetGroupID(i); + offset = [self offsetForGroupID:group startingOffset:offset]; + [indexes addIndex:i + offset]; + } + return indexes; +} + +- (void)modelDidAddItemsInRange:(NSRange)range { + NSMutableArray* rows = [NSMutableArray arrayWithCapacity:range.length]; + for (NSUInteger i=range.location; i<NSMaxRange(range); ++i) + [rows addObject:[self columnValuesForRow:i]]; + [self insertObjects:rows + atArrangedObjectIndexes:[self controllerRowsForModelRowsInRange:range]]; +} + +- (void)modelDidRemoveItemsInRange:(NSRange)range { + NSMutableIndexSet* indexes = + [NSMutableIndexSet indexSetWithIndexesInRange:range]; + if (model_->HasGroups()) { + // When this method is called, the model has already removed items, so + // accessing items in the model from |range.location| on may not be possible + // anymore. Therefore we use the item right before that, if it exists. + NSUInteger offset = 0; + if (range.location > 0) { + int last_group = model_->GetGroupID(range.location - 1); + offset = [self offsetForGroupID:last_group]; + } + [indexes shiftIndexesStartingAtIndex:0 by:offset]; + for (NSUInteger row = range.location + offset; + row < NSMaxRange(range) + offset; + ++row) { + if ([self tableView:nil isGroupRow:row]) { + // Skip over group rows. + [indexes shiftIndexesStartingAtIndex:row by:1]; + offset++; + } + } + } + [self removeObjectsAtArrangedObjectIndexes:indexes]; +} + +- (NSDictionary*)columnValuesForRow:(NSInteger)row { + NSMutableDictionary* dict = [NSMutableDictionary dictionary]; + if (model_->HasGroups()) { + [dict setObject:[NSNumber numberWithInt:model_->GetGroupID(row)] + forKey:kGroupID]; + } + for (NSString* identifier in columns_.get()) { + int column_id = [[columns_ objectForKey:identifier] intValue]; + std::wstring text = model_->GetText(row, column_id); + [dict setObject:base::SysWideToNSString(text) forKey:identifier]; + } + return dict; +} + +// Overridden from NSArrayController ----------------------------------------- + +- (BOOL)canRemove { + if (!model_) + return NO; + RemoveRowsTableModel::Rows rows; + [self setModelRows:&rows fromControllerRows:[self selectionIndexes]]; + return model_->CanRemoveRows(rows); +} + +- (IBAction)remove:(id)sender { + RemoveRowsTableModel::Rows rows; + [self setModelRows:&rows fromControllerRows:[self selectionIndexes]]; + model_->RemoveRows(rows); +} + +// Table View Delegate -------------------------------------------------------- + +- (BOOL)tableView:(NSTableView*)tv isGroupRow:(NSInteger)row { + NSDictionary* values = [[self arrangedObjects] objectAtIndex:row]; + return [[values objectForKey:kIsGroupRow] boolValue]; +} + +- (NSIndexSet*)tableView:(NSTableView*)tableView + selectionIndexesForProposedSelection:(NSIndexSet*)proposedIndexes { + NSMutableIndexSet* indexes = [proposedIndexes mutableCopy]; + for (NSUInteger i = [proposedIndexes firstIndex]; + i != NSNotFound; + i = [proposedIndexes indexGreaterThanIndex:i]) { + if ([self tableView:tableView isGroupRow:i]) { + [indexes removeIndex:i]; + NSUInteger row = i + 1; + while (row < [[self arrangedObjects] count] && + ![self tableView:tableView isGroupRow:row]) + [indexes addIndex:row++]; + } + } + return indexes; +} + +// Actions -------------------------------------------------------------------- + +- (IBAction)removeAll:(id)sender { + model_->RemoveAll(); +} + +@end diff --git a/chrome/browser/ui/cocoa/table_model_array_controller_unittest.mm b/chrome/browser/ui/cocoa/table_model_array_controller_unittest.mm new file mode 100644 index 0000000..c60c4cf --- /dev/null +++ b/chrome/browser/ui/cocoa/table_model_array_controller_unittest.mm @@ -0,0 +1,172 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/table_model_array_controller.h" + +#include "base/auto_reset.h" +#include "base/command_line.h" +#include "base/utf_string_conversions.h" +#include "chrome/browser/mock_plugin_exceptions_table_model.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "chrome/common/chrome_switches.h" +#include "chrome/test/testing_profile.h" +#include "grit/generated_resources.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/gtest_mac.h" +#include "webkit/glue/plugins/plugin_list.h" +#include "webkit/glue/plugins/webplugininfo.h" + +namespace { + +class TableModelArrayControllerTest : public CocoaTest { + public: + TableModelArrayControllerTest() + : command_line_(CommandLine::ForCurrentProcess(), + *CommandLine::ForCurrentProcess()) {} + + virtual void SetUp() { + CocoaTest::SetUp(); + + CommandLine::ForCurrentProcess()->AppendSwitch( + switches::kEnableResourceContentSettings); + + TestingProfile* profile = browser_helper_.profile(); + HostContentSettingsMap* map = profile->GetHostContentSettingsMap(); + + HostContentSettingsMap::Pattern example_com("[*.]example.com"); + HostContentSettingsMap::Pattern moose_org("[*.]moose.org"); + map->SetContentSetting(example_com, + CONTENT_SETTINGS_TYPE_PLUGINS, + "a-foo", + CONTENT_SETTING_ALLOW); + map->SetContentSetting(moose_org, + CONTENT_SETTINGS_TYPE_PLUGINS, + "b-bar", + CONTENT_SETTING_BLOCK); + map->SetContentSetting(example_com, + CONTENT_SETTINGS_TYPE_PLUGINS, + "b-bar", + CONTENT_SETTING_ALLOW); + + model_.reset(new MockPluginExceptionsTableModel(map, NULL)); + + NPAPI::PluginList::PluginMap plugins; + WebPluginInfo foo_plugin; + foo_plugin.path = FilePath(FILE_PATH_LITERAL("a-foo")); + foo_plugin.name = ASCIIToUTF16("FooPlugin"); + foo_plugin.enabled = true; + PluginGroup* foo_group = PluginGroup::FromWebPluginInfo(foo_plugin); + plugins[foo_group->identifier()] = linked_ptr<PluginGroup>(foo_group); + WebPluginInfo bar_plugin; + bar_plugin.path = FilePath(FILE_PATH_LITERAL("b-bar")); + bar_plugin.name = ASCIIToUTF16("BarPlugin"); + bar_plugin.enabled = true; + PluginGroup* bar_group = PluginGroup::FromWebPluginInfo(bar_plugin); + plugins[bar_group->identifier()] = linked_ptr<PluginGroup>(bar_group); + WebPluginInfo blurp_plugin; + blurp_plugin.path = FilePath(FILE_PATH_LITERAL("c-blurp")); + blurp_plugin.name = ASCIIToUTF16("BlurpPlugin"); + blurp_plugin.enabled = true; + PluginGroup* blurp_group = PluginGroup::FromWebPluginInfo(blurp_plugin); + plugins[blurp_group->identifier()] = linked_ptr<PluginGroup>(blurp_group); + + model_->set_plugins(plugins); + model_->LoadSettings(); + + id content = [NSMutableArray array]; + controller_.reset( + [[TableModelArrayController alloc] initWithContent:content]); + NSDictionary* columns = [NSDictionary dictionaryWithObjectsAndKeys: + [NSNumber numberWithInt:IDS_EXCEPTIONS_HOSTNAME_HEADER], @"title", + [NSNumber numberWithInt:IDS_EXCEPTIONS_ACTION_HEADER], @"action", + nil]; + [controller_.get() bindToTableModel:model_.get() + withColumns:columns + groupTitleColumn:@"title"]; + } + + protected: + BrowserTestHelper browser_helper_; + scoped_ptr<MockPluginExceptionsTableModel> model_; + scoped_nsobject<TableModelArrayController> controller_; + + private: + AutoReset<CommandLine> command_line_; +}; + +TEST_F(TableModelArrayControllerTest, CheckTitles) { + NSArray* titles = [[controller_.get() arrangedObjects] valueForKey:@"title"]; + EXPECT_NSEQ(@"(\n" + @" FooPlugin,\n" + @" \"[*.]example.com\",\n" + @" BarPlugin,\n" + @" \"[*.]example.com\",\n" + @" \"[*.]moose.org\"\n" + @")", + [titles description]); +} + +TEST_F(TableModelArrayControllerTest, RemoveRows) { + NSArrayController* controller = controller_.get(); + [controller setSelectionIndex:1]; + [controller remove:nil]; + NSArray* titles = [[controller arrangedObjects] valueForKey:@"title"]; + EXPECT_NSEQ(@"(\n" + @" BarPlugin,\n" + @" \"[*.]example.com\",\n" + @" \"[*.]moose.org\"\n" + @")", + [titles description]); + + [controller setSelectionIndex:2]; + [controller remove:nil]; + titles = [[controller arrangedObjects] valueForKey:@"title"]; + EXPECT_NSEQ(@"(\n" + @" BarPlugin,\n" + @" \"[*.]example.com\"\n" + @")", + [titles description]); +} + +TEST_F(TableModelArrayControllerTest, RemoveAll) { + [controller_.get() removeAll:nil]; + EXPECT_EQ(0u, [[controller_.get() arrangedObjects] count]); +} + +TEST_F(TableModelArrayControllerTest, AddException) { + TestingProfile* profile = browser_helper_.profile(); + HostContentSettingsMap* map = profile->GetHostContentSettingsMap(); + HostContentSettingsMap::Pattern example_com("[*.]example.com"); + map->SetContentSetting(example_com, + CONTENT_SETTINGS_TYPE_PLUGINS, + "c-blurp", + CONTENT_SETTING_BLOCK); + + NSArrayController* controller = controller_.get(); + NSArray* titles = [[controller arrangedObjects] valueForKey:@"title"]; + EXPECT_NSEQ(@"(\n" + @" FooPlugin,\n" + @" \"[*.]example.com\",\n" + @" BarPlugin,\n" + @" \"[*.]example.com\",\n" + @" \"[*.]moose.org\",\n" + @" BlurpPlugin,\n" + @" \"[*.]example.com\"\n" + @")", + [titles description]); + NSMutableIndexSet* indexes = [NSMutableIndexSet indexSetWithIndex:1]; + [indexes addIndex:6]; + [controller setSelectionIndexes:indexes]; + [controller remove:nil]; + titles = [[controller arrangedObjects] valueForKey:@"title"]; + EXPECT_NSEQ(@"(\n" + @" BarPlugin,\n" + @" \"[*.]example.com\",\n" + @" \"[*.]moose.org\"\n" + @")", + [titles description]); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/table_row_nsimage_cache.h b/chrome/browser/ui/cocoa/table_row_nsimage_cache.h new file mode 100644 index 0000000..c42107f --- /dev/null +++ b/chrome/browser/ui/cocoa/table_row_nsimage_cache.h @@ -0,0 +1,55 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_TABLE_ROW_NSIMAGE_CACHE_H_ +#define CHROME_BROWSER_UI_COCOA_TABLE_ROW_NSIMAGE_CACHE_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" + +class SkBitmap; + +// There are several dialogs that display tabular data with one SkBitmap +// per row. This class converts these SkBitmaps to NSImages on demand, and +// caches the results. +class TableRowNSImageCache { + public: + // Interface this cache expects for its table model. + class Table { + public: + // Returns the number of rows in the table. + virtual int RowCount() const = 0; + + // Returns the icon of the |row|th row. + virtual SkBitmap GetIcon(int row) const = 0; + + protected: + virtual ~Table() {} + }; + + // |model| must outlive the cache. + explicit TableRowNSImageCache(Table* model); + + // Lazily converts the image at the given row and caches it in |icon_images_|. + NSImage* GetImageForRow(int row); + + // Call these functions every time the table changes, to update the cache. + void OnModelChanged(); + void OnItemsChanged(int start, int length); + void OnItemsAdded(int start, int length); + void OnItemsRemoved(int start, int length); + + private: + // The table model we query for row count and icons. + Table* model_; // weak + + // Stores strong NSImage refs for icons. If an entry is NULL, it will be + // created in GetImageForRow(). + scoped_nsobject<NSPointerArray> icon_images_; +}; + +#endif // CHROME_BROWSER_UI_COCOA_TABLE_ROW_NSIMAGE_CACHE_H_ + diff --git a/chrome/browser/ui/cocoa/table_row_nsimage_cache.mm b/chrome/browser/ui/cocoa/table_row_nsimage_cache.mm new file mode 100644 index 0000000..ae60dd6 --- /dev/null +++ b/chrome/browser/ui/cocoa/table_row_nsimage_cache.mm @@ -0,0 +1,79 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/table_row_nsimage_cache.h" + +#include "base/logging.h" +#include "skia/ext/skia_utils_mac.h" +#include "third_party/skia/include/core/SkBitmap.h" + +TableRowNSImageCache::TableRowNSImageCache(Table* model) + : model_(model), + icon_images_([[NSPointerArray alloc] initWithOptions: + NSPointerFunctionsStrongMemory | + NSPointerFunctionsObjectPersonality]) { + int count = model_->RowCount(); + [icon_images_ setCount:count]; +} + +void TableRowNSImageCache::OnModelChanged() { + int count = model_->RowCount(); + [icon_images_ setCount:0]; + [icon_images_ setCount:count]; +} + +void TableRowNSImageCache::OnItemsChanged(int start, int length) { + DCHECK_LE(start + length, static_cast<int>([icon_images_ count])); + for (int i = start; i < (start + length); ++i) { + [icon_images_ replacePointerAtIndex:i withPointer:NULL]; + } + DCHECK_EQ(model_->RowCount(), + static_cast<int>([icon_images_ count])); +} + +void TableRowNSImageCache::OnItemsAdded(int start, int length) { + DCHECK_LE(start, static_cast<int>([icon_images_ count])); + + // -[NSPointerArray insertPointer:atIndex:] throws if index == count. + // Instead expand the array with NULLs. + if (start == static_cast<int>([icon_images_ count])) { + [icon_images_ setCount:start + length]; + } else { + for (int i = 0; i < length; ++i) { + [icon_images_ insertPointer:NULL atIndex:start]; // Values slide up. + } + } + DCHECK_EQ(model_->RowCount(), + static_cast<int>([icon_images_ count])); +} + +void TableRowNSImageCache::OnItemsRemoved(int start, int length) { + DCHECK_LE(start + length, static_cast<int>([icon_images_ count])); + for (int i = 0; i < length; ++i) { + [icon_images_ removePointerAtIndex:start]; // Values slide down. + } + DCHECK_EQ(model_->RowCount(), + static_cast<int>([icon_images_ count])); +} + +NSImage* TableRowNSImageCache::GetImageForRow(int row) { + DCHECK_EQ(model_->RowCount(), + static_cast<int>([icon_images_ count])); + DCHECK_GE(row, 0); + DCHECK_LT(row, static_cast<int>([icon_images_ count])); + NSImage* image = static_cast<NSImage*>([icon_images_ pointerAtIndex:row]); + if (!image) { + const SkBitmap bitmap_icon = + model_->GetIcon(row); + // This means GetIcon() will get called until it returns a non-empty bitmap. + // Empty bitmaps are intentionally not cached. + if (!bitmap_icon.isNull()) { + image = gfx::SkBitmapToNSImage(bitmap_icon); + DCHECK(image); + [icon_images_ replacePointerAtIndex:row withPointer:image]; + } + } + return image; +} + diff --git a/chrome/browser/ui/cocoa/table_row_nsimage_cache_unittest.mm b/chrome/browser/ui/cocoa/table_row_nsimage_cache_unittest.mm new file mode 100644 index 0000000..ecca71e --- /dev/null +++ b/chrome/browser/ui/cocoa/table_row_nsimage_cache_unittest.mm @@ -0,0 +1,200 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/table_row_nsimage_cache.h" + +#include "testing/gtest/include/gtest/gtest.h" +#include "third_party/skia/include/core/SkBitmap.h" + +namespace { + +class TestTable : public TableRowNSImageCache::Table { + public: + + std::vector<SkBitmap>* rows() { + return &rows_; + } + + // TableRowNSImageCache::Table overrides. + virtual int RowCount() const { + return rows_.size(); + } + virtual SkBitmap GetIcon(int index) const { + return rows_[index]; + } + + private: + std::vector<SkBitmap> rows_; +}; + +SkBitmap MakeImage(int width, int height) { + SkBitmap image; + image.setConfig(SkBitmap::kARGB_8888_Config, width, height); + EXPECT_TRUE(image.allocPixels()); + image.eraseRGB(255, 0, 0); + return image; +} + +// Define this as a macro so that the original variable names can be used in +// test output. +#define COMPARE_SK_NS_IMG_SIZES(skia, cocoa) \ + EXPECT_EQ(skia.width(), [cocoa size].width); \ + EXPECT_EQ(skia.height(), [cocoa size].height); + +TEST(TableRowNSImageCacheTest, ModelChanged) { + TestTable table; + std::vector<SkBitmap>* rows = table.rows(); + rows->push_back(MakeImage(10, 10)); + rows->push_back(MakeImage(20, 20)); + rows->push_back(MakeImage(30, 30)); + TableRowNSImageCache cache(&table); + + NSImage* image0 = cache.GetImageForRow(0); + NSImage* image1 = cache.GetImageForRow(1); + NSImage* image2 = cache.GetImageForRow(2); + + COMPARE_SK_NS_IMG_SIZES(rows->at(0), image0); + COMPARE_SK_NS_IMG_SIZES(rows->at(1), image1); + COMPARE_SK_NS_IMG_SIZES(rows->at(2), image2); + + rows->clear(); + + rows->push_back(MakeImage(15, 15)); + rows->push_back(MakeImage(25, 25)); + rows->push_back(MakeImage(35, 35)); + rows->push_back(MakeImage(45, 45)); + + // Invalidate the entire model. + cache.OnModelChanged(); + + EXPECT_NE(image0, cache.GetImageForRow(0)); + image0 = cache.GetImageForRow(0); + + EXPECT_NE(image1, cache.GetImageForRow(1)); + image1 = cache.GetImageForRow(1); + + EXPECT_NE(image2, cache.GetImageForRow(2)); + image2 = cache.GetImageForRow(2); + + NSImage* image3 = cache.GetImageForRow(3); + + COMPARE_SK_NS_IMG_SIZES(rows->at(0), image0); + COMPARE_SK_NS_IMG_SIZES(rows->at(1), image1); + COMPARE_SK_NS_IMG_SIZES(rows->at(2), image2); + COMPARE_SK_NS_IMG_SIZES(rows->at(3), image3); +} + + +TEST(TableRowNSImageCacheTest, ItemsChanged) { + TestTable table; + std::vector<SkBitmap>* rows = table.rows(); + rows->push_back(MakeImage(10, 10)); + rows->push_back(MakeImage(20, 20)); + rows->push_back(MakeImage(30, 30)); + TableRowNSImageCache cache(&table); + + NSImage* image0 = cache.GetImageForRow(0); + NSImage* image1 = cache.GetImageForRow(1); + NSImage* image2 = cache.GetImageForRow(2); + + COMPARE_SK_NS_IMG_SIZES(rows->at(0), image0); + COMPARE_SK_NS_IMG_SIZES(rows->at(1), image1); + COMPARE_SK_NS_IMG_SIZES(rows->at(2), image2); + + // Update the middle image. + (*rows)[1] = MakeImage(25, 25); + cache.OnItemsChanged(/* start=*/1, /* count=*/1); + + // Make sure the other items remained the same. + EXPECT_EQ(image0, cache.GetImageForRow(0)); + EXPECT_EQ(image2, cache.GetImageForRow(2)); + + image1 = cache.GetImageForRow(1); + COMPARE_SK_NS_IMG_SIZES(rows->at(1), image1); + + // Update more than one image. + (*rows)[0] = MakeImage(15, 15); + (*rows)[1] = MakeImage(27, 27); + EXPECT_EQ(3U, rows->size()); + cache.OnItemsChanged(0, 2); + + image0 = cache.GetImageForRow(0); + image1 = cache.GetImageForRow(1); + + COMPARE_SK_NS_IMG_SIZES(rows->at(0), image0); + COMPARE_SK_NS_IMG_SIZES(rows->at(1), image1); +} + + +TEST(TableRowNSImageCacheTest, ItemsAdded) { + TestTable table; + std::vector<SkBitmap>* rows = table.rows(); + rows->push_back(MakeImage(10, 10)); + rows->push_back(MakeImage(20, 20)); + TableRowNSImageCache cache(&table); + + NSImage* image0 = cache.GetImageForRow(0); + NSImage* image1 = cache.GetImageForRow(1); + + COMPARE_SK_NS_IMG_SIZES(rows->at(0), image0); + COMPARE_SK_NS_IMG_SIZES(rows->at(1), image1); + + // Add an item to the end. + rows->push_back(MakeImage(30, 30)); + cache.OnItemsAdded(2, 1); + + // Make sure image 1 stayed the same. + EXPECT_EQ(image1, cache.GetImageForRow(1)); + COMPARE_SK_NS_IMG_SIZES(rows->at(1), image1); + + // Check that image 2 got added correctly. + NSImage* image2 = cache.GetImageForRow(2); + COMPARE_SK_NS_IMG_SIZES(rows->at(2), image2); + + // Add two items to the begging. + rows->insert(rows->begin(), MakeImage(7, 7)); + rows->insert(rows->begin(), MakeImage(3, 3)); + cache.OnItemsAdded(0, 2); + + NSImage* image00 = cache.GetImageForRow(0); + NSImage* image01 = cache.GetImageForRow(1); + + COMPARE_SK_NS_IMG_SIZES(rows->at(0), image00); + COMPARE_SK_NS_IMG_SIZES(rows->at(1), image01); +} + + +TEST(TableRowNSImageCacheTest, ItemsRemoved) { + TestTable table; + std::vector<SkBitmap>* rows = table.rows(); + rows->push_back(MakeImage(10, 10)); + rows->push_back(MakeImage(20, 20)); + rows->push_back(MakeImage(30, 30)); + rows->push_back(MakeImage(40, 40)); + rows->push_back(MakeImage(50, 50)); + TableRowNSImageCache cache(&table); + + NSImage* image0 = cache.GetImageForRow(0); + NSImage* image1 = cache.GetImageForRow(1); + NSImage* image2 = cache.GetImageForRow(2); + NSImage* image3 = cache.GetImageForRow(3); + NSImage* image4 = cache.GetImageForRow(4); + + COMPARE_SK_NS_IMG_SIZES(rows->at(0), image0); + COMPARE_SK_NS_IMG_SIZES(rows->at(1), image1); + COMPARE_SK_NS_IMG_SIZES(rows->at(2), image2); + COMPARE_SK_NS_IMG_SIZES(rows->at(3), image3); + COMPARE_SK_NS_IMG_SIZES(rows->at(4), image4); + + rows->erase(rows->begin() + 1, rows->begin() + 4); // [20x20, 50x50) + cache.OnItemsRemoved(1, 3); + + image0 = cache.GetImageForRow(0); + image1 = cache.GetImageForRow(1); + + COMPARE_SK_NS_IMG_SIZES(rows->at(0), image0); + COMPARE_SK_NS_IMG_SIZES(rows->at(1), image1); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/tabpose_window.h b/chrome/browser/ui/cocoa/tabpose_window.h new file mode 100644 index 0000000..b798bcc --- /dev/null +++ b/chrome/browser/ui/cocoa/tabpose_window.h @@ -0,0 +1,94 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_TABPOSE_WINDOW_H_ +#define CHROME_BROWSER_UI_COCOA_TABPOSE_WINDOW_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "base/mac/scoped_cftyperef.h" + +#include "base/scoped_nsobject.h" +#include "base/scoped_ptr.h" +#include "base/scoped_vector.h" + +namespace tabpose { + +class Tile; +class TileSet; + +} + +namespace tabpose { + +enum WindowState { + kFadingIn, + kFadedIn, + kFadingOut, +}; + +} // namespace tabpose + +class TabStripModel; +class TabStripModelObserverBridge; + +// A TabposeWindow shows an overview of open tabs and lets the user select a new +// active tab. The window blocks clicks on the tab strip and the download +// shelf. Every open browser window has its own overlay, and they are +// independent of each other. +@interface TabposeWindow : NSWindow { + @private + tabpose::WindowState state_; + + // The root layer added to the content view. Covers the whole window. + CALayer* rootLayer_; // weak + + // The layer showing the background layer. Covers the whole visible area. + CALayer* bgLayer_; // weak + + // The layer drawn behind the currently selected tile. + CALayer* selectionHighlight_; // weak + + // Colors used by the layers. + base::mac::ScopedCFTypeRef<CGColorRef> gray_; + base::mac::ScopedCFTypeRef<CGColorRef> darkBlue_; + + TabStripModel* tabStripModel_; // weak + + // Stores all preview layers. The order in here matches the order in + // the tabstrip model. + scoped_nsobject<NSMutableArray> allThumbnailLayers_; + + scoped_nsobject<NSMutableArray> allFaviconLayers_; + scoped_nsobject<NSMutableArray> allTitleLayers_; + + // Manages the state of all layers. + scoped_ptr<tabpose::TileSet> tileSet_; + + // The rectangle of the window that contains all layers. This is the + // rectangle occupied by |bgLayer_|. + NSRect containingRect_; + + // Informs us of added/removed/updated tabs. + scoped_ptr<TabStripModelObserverBridge> tabStripModelObserverBridge_; +} + +// Shows a TabposeWindow on top of |parent|, with |rect| being the active area. +// If |slomo| is YES, then the appearance animation is shown in slow motion. +// The window blocks all keyboard and mouse events and releases itself when +// closed. ++ (id)openTabposeFor:(NSWindow*)parent + rect:(NSRect)rect + slomo:(BOOL)slomo + tabStripModel:(TabStripModel*)tabStripModel; +@end + +@interface TabposeWindow (TestingAPI) +- (void)selectTileAtIndexWithoutAnimation:(int)newIndex; +- (NSUInteger)thumbnailLayerCount; +- (int)selectedIndex; +@end + +#endif // CHROME_BROWSER_UI_COCOA_TABPOSE_WINDOW_H_ diff --git a/chrome/browser/ui/cocoa/tabpose_window.mm b/chrome/browser/ui/cocoa/tabpose_window.mm new file mode 100644 index 0000000..47825f5 --- /dev/null +++ b/chrome/browser/ui/cocoa/tabpose_window.mm @@ -0,0 +1,1437 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/tabpose_window.h" + +#import <QuartzCore/QuartzCore.h> + +#include "app/resource_bundle.h" +#include "base/mac_util.h" +#include "base/mac/scoped_cftyperef.h" +#include "base/scoped_callback_factory.h" +#include "base/sys_string_conversions.h" +#include "chrome/app/chrome_command_ids.h" +#include "chrome/browser/browser_process.h" +#import "chrome/browser/debugger/devtools_window.h" +#include "chrome/browser/prefs/pref_service.h" +#include "chrome/browser/renderer_host/backing_store_mac.h" +#include "chrome/browser/renderer_host/render_view_host.h" +#include "chrome/browser/renderer_host/render_widget_host_view_mac.h" +#include "chrome/browser/tab_contents/tab_contents.h" +#include "chrome/browser/tab_contents_wrapper.h" +#include "chrome/browser/tab_contents/thumbnail_generator.h" +#include "chrome/browser/tab_contents_wrapper.h" +#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_constants.h" +#import "chrome/browser/ui/cocoa/browser_window_controller.h" +#import "chrome/browser/ui/cocoa/tab_strip_controller.h" +#import "chrome/browser/ui/cocoa/tab_strip_model_observer_bridge.h" +#include "chrome/common/pref_names.h" +#include "grit/app_resources.h" +#include "skia/ext/skia_utils_mac.h" +#include "third_party/skia/include/utils/mac/SkCGUtils.h" + +const int kTopGradientHeight = 15; + +NSString* const kAnimationIdKey = @"AnimationId"; +NSString* const kAnimationIdFadeIn = @"FadeIn"; +NSString* const kAnimationIdFadeOut = @"FadeOut"; + +const CGFloat kDefaultAnimationDuration = 0.25; // In seconds. +const CGFloat kSlomoFactor = 4; +const CGFloat kObserverChangeAnimationDuration = 0.75; // In seconds. + +// CAGradientLayer is 10.6-only -- roll our own. +@interface DarkGradientLayer : CALayer +- (void)drawInContext:(CGContextRef)context; +@end + +@implementation DarkGradientLayer +- (void)drawInContext:(CGContextRef)context { + base::mac::ScopedCFTypeRef<CGColorSpaceRef> grayColorSpace( + CGColorSpaceCreateWithName(kCGColorSpaceGenericGray)); + CGFloat grays[] = { 0.277, 1.0, 0.39, 1.0 }; + CGFloat locations[] = { 0, 1 }; + base::mac::ScopedCFTypeRef<CGGradientRef> gradient( + CGGradientCreateWithColorComponents( + grayColorSpace.get(), grays, locations, arraysize(locations))); + CGPoint topLeft = CGPointMake(0.0, kTopGradientHeight); + CGContextDrawLinearGradient(context, gradient.get(), topLeft, CGPointZero, 0); +} +@end + +namespace tabpose { +class ThumbnailLoader; +} + +// A CALayer that draws a thumbnail for a TabContents object. The layer tries +// to draw the TabContents's backing store directly if possible, and requests +// a thumbnail bitmap from the TabContents's renderer process if not. +@interface ThumbnailLayer : CALayer { + // The TabContents the thumbnail is for. + TabContents* contents_; // weak + + // The size the thumbnail is drawn at when zoomed in. + NSSize fullSize_; + + // Used to load a thumbnail, if required. + scoped_refptr<tabpose::ThumbnailLoader> loader_; + + // If the backing store couldn't be used and a thumbnail was returned from a + // renderer process, it's stored in |thumbnail_|. + base::mac::ScopedCFTypeRef<CGImageRef> thumbnail_; + + // True if the layer already sent a thumbnail request to a renderer. + BOOL didSendLoad_; +} +- (id)initWithTabContents:(TabContents*)contents fullSize:(NSSize)fullSize; +- (void)drawInContext:(CGContextRef)context; +- (void)setThumbnail:(const SkBitmap&)bitmap; +@end + +namespace tabpose { + +// ThumbnailLoader talks to the renderer process to load a thumbnail of a given +// RenderWidgetHost, and sends the thumbnail back to a ThumbnailLayer once it +// comes back from the renderer. +class ThumbnailLoader : public base::RefCountedThreadSafe<ThumbnailLoader> { + public: + ThumbnailLoader(gfx::Size size, RenderWidgetHost* rwh, ThumbnailLayer* layer) + : size_(size), rwh_(rwh), layer_(layer), factory_(this) {} + + // Starts the fetch. + void LoadThumbnail(); + + private: + friend class base::RefCountedThreadSafe<ThumbnailLoader>; + ~ThumbnailLoader() { + ResetPaintingObserver(); + } + + void DidReceiveBitmap(const SkBitmap& bitmap) { + DCHECK(BrowserThread::CurrentlyOn(BrowserThread::UI)); + ResetPaintingObserver(); + [layer_ setThumbnail:bitmap]; + } + + void ResetPaintingObserver() { + if (rwh_->painting_observer() != NULL) { + DCHECK(rwh_->painting_observer() == + g_browser_process->GetThumbnailGenerator()); + rwh_->set_painting_observer(NULL); + } + } + + gfx::Size size_; + RenderWidgetHost* rwh_; // weak + ThumbnailLayer* layer_; // weak, owns us + base::ScopedCallbackFactory<ThumbnailLoader> factory_; + + DISALLOW_COPY_AND_ASSIGN(ThumbnailLoader); +}; + +void ThumbnailLoader::LoadThumbnail() { + DCHECK(BrowserThread::CurrentlyOn(BrowserThread::UI)); + ThumbnailGenerator* generator = g_browser_process->GetThumbnailGenerator(); + if (!generator) // In unit tests. + return; + + // As mentioned in ThumbnailLayer's -drawInContext:, it's sufficient to have + // thumbnails at the zoomed-out pixel size for all but the thumbnail the user + // clicks on in the end. But we don't don't which thumbnail that will be, so + // keep it simple and request full thumbnails for everything. + // TODO(thakis): Request smaller thumbnails for users with many tabs. + gfx::Size page_size(size_); // Logical size the renderer renders at. + gfx::Size pixel_size(size_); // Physical pixel size the image is rendered at. + + DCHECK(rwh_->painting_observer() == NULL || + rwh_->painting_observer() == generator); + rwh_->set_painting_observer(generator); + + // Will send an IPC to the renderer on the IO thread. + generator->AskForSnapshot( + rwh_, + /*prefer_backing_store=*/false, + factory_.NewCallback(&ThumbnailLoader::DidReceiveBitmap), + page_size, + pixel_size); +} + +} // namespace tabpose + +@implementation ThumbnailLayer + +- (id)initWithTabContents:(TabContents*)contents fullSize:(NSSize)fullSize { + CHECK(contents); + if ((self = [super init])) { + contents_ = contents; + fullSize_ = fullSize; + } + return self; +} + +- (void)setTabContents:(TabContents*)contents { + contents_ = contents; +} + +- (void)setThumbnail:(const SkBitmap&)bitmap { + // SkCreateCGImageRef() holds on to |bitmaps|'s memory, so this doesn't + // create a copy. + thumbnail_.reset(SkCreateCGImageRef(bitmap)); + loader_ = NULL; + [self setNeedsDisplay]; +} + +- (int)topOffset { + int topOffset = 0; + + // Medium term, we want to show thumbs of the actual info bar views, which + // means I need to create InfoBarControllers here. At that point, we can get + // the height from that controller. Until then, hardcode. :-/ + const int kInfoBarHeight = 31; + topOffset += contents_->infobar_delegate_count() * kInfoBarHeight; + + bool always_show_bookmark_bar = + contents_->profile()->GetPrefs()->GetBoolean(prefs::kShowBookmarkBar); + bool has_detached_bookmark_bar = + contents_->ShouldShowBookmarkBar() && !always_show_bookmark_bar; + if (has_detached_bookmark_bar) + topOffset += bookmarks::kNTPBookmarkBarHeight; + + return topOffset; +} + +- (int)bottomOffset { + int bottomOffset = 0; + TabContents* devToolsContents = + DevToolsWindow::GetDevToolsContents(contents_); + if (devToolsContents && devToolsContents->render_view_host() && + devToolsContents->render_view_host()->view()) { + // The devtool's size might not be up-to-date, but since its height doesn't + // change on window resize, and since most users don't use devtools, this is + // good enough. + bottomOffset += + devToolsContents->render_view_host()->view()->GetViewBounds().height(); + bottomOffset += 1; // :-( Divider line between web contents and devtools. + } + return bottomOffset; +} + +- (void)drawBackingStore:(BackingStoreMac*)backing_store + inRect:(CGRect)destRect + context:(CGContextRef)context { + // TODO(thakis): Add a sublayer for each accelerated surface in the rwhv. + // Until then, accelerated layers (CoreAnimation NPAPI plugins, compositor) + // won't show up in tabpose. + if (backing_store->cg_layer()) { + CGContextDrawLayerInRect(context, destRect, backing_store->cg_layer()); + } else { + base::mac::ScopedCFTypeRef<CGImageRef> image( + CGBitmapContextCreateImage(backing_store->cg_bitmap())); + CGContextDrawImage(context, destRect, image); + } +} + +- (void)drawInContext:(CGContextRef)context { + RenderWidgetHost* rwh = contents_->render_view_host(); + // NULL if renderer crashed. + RenderWidgetHostView* rwhv = rwh ? rwh->view() : NULL; + if (!rwhv) { + // TODO(thakis): Maybe draw a sad tab layer? + [super drawInContext:context]; + return; + } + + // The size of the TabContent's RenderWidgetHost might not fit to the + // current browser window at all, for example if the window was resized while + // this TabContents object was not an active tab. + // Compute the required size ourselves. Leave room for eventual infobars and + // a detached bookmarks bar on the top, and for the devtools on the bottom. + // Download shelf is not included in the |fullSize| rect, so no need to + // correct for it here. + // TODO(thakis): This is not resolution-independent. + int topOffset = [self topOffset]; + int bottomOffset = [self bottomOffset]; + gfx::Size desiredThumbSize(fullSize_.width, + fullSize_.height - topOffset - bottomOffset); + + // We need to ask the renderer for a thumbnail if + // a) there's no backing store or + // b) the backing store's size doesn't match our required size and + // c) we didn't already send a thumbnail request to the renderer. + BackingStoreMac* backing_store = + (BackingStoreMac*)rwh->GetBackingStore(/*force_create=*/false); + bool draw_backing_store = + backing_store && backing_store->size() == desiredThumbSize; + + // Next weirdness: The destination rect. If the layer is |fullSize_| big, the + // destination rect is (0, bottomOffset), (fullSize_.width, topOffset). But we + // might be amidst an animation, so interpolate that rect. + CGRect destRect = [self bounds]; + CGFloat scale = destRect.size.width / fullSize_.width; + destRect.origin.y += bottomOffset * scale; + destRect.size.height -= (bottomOffset + topOffset) * scale; + + // TODO(thakis): Draw infobars, detached bookmark bar as well. + + // If we haven't already, sent a thumbnail request to the renderer. + if (!draw_backing_store && !didSendLoad_) { + // Either the tab was never visible, or its backing store got evicted, or + // the size of the backing store is wrong. + + // We only need a thumbnail the size of the zoomed-out layer for all + // layers except the one the user clicks on. But since we can't know which + // layer that is, request full-resolution layers for all tabs. This is + // simple and seems to work in practice. + loader_ = new tabpose::ThumbnailLoader(desiredThumbSize, rwh, self); + loader_->LoadThumbnail(); + didSendLoad_ = YES; + + // Fill with bg color. + [super drawInContext:context]; + } + + if (draw_backing_store) { + // Backing store 'cache' hit! + [self drawBackingStore:backing_store inRect:destRect context:context]; + } else if (thumbnail_) { + // No cache hit, but the renderer returned a thumbnail to us. + CGContextDrawImage(context, destRect, thumbnail_.get()); + } +} + +@end + +namespace { + +class ScopedCAActionDisabler { + public: + ScopedCAActionDisabler() { + [CATransaction begin]; + [CATransaction setValue:[NSNumber numberWithBool:YES] + forKey:kCATransactionDisableActions]; + } + + ~ScopedCAActionDisabler() { + [CATransaction commit]; + } +}; + +class ScopedCAActionSetDuration { + public: + explicit ScopedCAActionSetDuration(CGFloat duration) { + [CATransaction begin]; + [CATransaction setValue:[NSNumber numberWithFloat:duration] + forKey:kCATransactionAnimationDuration]; + } + + ~ScopedCAActionSetDuration() { + [CATransaction commit]; + } +}; + +} // namespace + +// Given the number |n| of tiles with a desired aspect ratio of |a| and a +// desired distance |dx|, |dy| between tiles, returns how many tiles fit +// vertically into a rectangle with the dimensions |w_c|, |h_c|. This returns +// an exact solution, which is usually a fractional number. +static float FitNRectsWithAspectIntoBoundingSizeWithConstantPadding( + int n, double a, int w_c, int h_c, int dx, int dy) { + // We want to have the small rects have the same aspect ratio a as a full + // tab. Let w, h be the size of a small rect, and w_c, h_c the size of the + // container. dx, dy are the distances between small rects in x, y direction. + + // Geometry yields: + // w_c = nx * (w + dx) - dx <=> w = (w_c + d_x) / nx - d_x + // h_c = ny * (h + dy) - dy <=> h = (h_c + d_y) / ny - d_t + // Plugging this into + // a := tab_width / tab_height = w / h + // yields + // a = ((w_c - (nx - 1)*d_x)*ny) / (nx*(h_c - (ny - 1)*d_y)) + // Plugging in nx = n/ny and pen and paper (or wolfram alpha: + // http://www.wolframalpha.com/input/?i=(-sqrt((d+n-a+f+n)^2-4+(a+f%2Ba+h)+(-d+n-n+w))%2Ba+f+n-d+n)/(2+a+(f%2Bh)) , (solution for nx) + // http://www.wolframalpha.com/input/?i=+(-sqrt((a+f+n-d+n)^2-4+(d%2Bw)+(-a+f+n-a+h+n))-a+f+n%2Bd+n)/(2+(d%2Bw)) , (solution for ny) + // ) gives us nx and ny (but the wrong root -- s/-sqrt(FOO)/sqrt(FOO)/. + + // This function returns ny. + return (sqrt(pow(n * (a * dy - dx), 2) + + 4 * n * a * (dx + w_c) * (dy + h_c)) - + n * (a * dy - dx)) + / + (2 * (dx + w_c)); +} + +namespace tabpose { + +// A tile is what is shown for a single tab in tabpose mode. It consists of a +// title, favicon, thumbnail image, and pre- and postanimation rects. +class Tile { + public: + Tile() {} + + // Returns the rectangle this thumbnail is at at the beginning of the zoom-in + // animation. |tile| is the rectangle that's covering the whole tab area when + // the animation starts. + NSRect GetStartRectRelativeTo(const Tile& tile) const; + NSRect thumb_rect() const { return thumb_rect_; } + + NSRect favicon_rect() const { return favicon_rect_; } + SkBitmap favicon() const; + + // This changes |title_rect| and |favicon_rect| such that the favicon is on + // the font's baseline and that the minimum distance between thumb rect and + // favicon and title rects doesn't change. + // The view code + // 1. queries desired font size by calling |title_font_size()| + // 2. loads that font + // 3. calls |set_font_metrics()| which updates the title rect + // 4. receives the title rect and puts the title on it with the font from 2. + void set_font_metrics(CGFloat ascender, CGFloat descender); + CGFloat title_font_size() const { return title_font_size_; } + + NSRect title_rect() const { return title_rect_; } + + // Returns an unelided title. The view logic is responsible for eliding. + const string16& title() const { return contents_->GetTitle(); } + + TabContents* tab_contents() const { return contents_; } + void set_tab_contents(TabContents* new_contents) { contents_ = new_contents; } + + private: + friend class TileSet; + + // The thumb rect includes infobars, detached thumbnail bar, web contents, + // and devtools. + NSRect thumb_rect_; + NSRect start_thumb_rect_; + + NSRect favicon_rect_; + + CGFloat title_font_size_; + NSRect title_rect_; + + TabContents* contents_; // weak + + DISALLOW_COPY_AND_ASSIGN(Tile); +}; + +NSRect Tile::GetStartRectRelativeTo(const Tile& tile) const { + NSRect rect = start_thumb_rect_; + rect.origin.x -= tile.start_thumb_rect_.origin.x; + rect.origin.y -= tile.start_thumb_rect_.origin.y; + return rect; +} + +SkBitmap Tile::favicon() const { + if (contents_->is_app()) { + SkBitmap* icon = contents_->GetExtensionAppIcon(); + if (icon) + return *icon; + } + return contents_->GetFavIcon(); +} + +// Changes |title_rect| and |favicon_rect| such that the favicon is on the +// font's baseline and that the minimum distance between thumb rect and +// favicon and title rects doesn't change. +void Tile::set_font_metrics(CGFloat ascender, CGFloat descender) { + title_rect_.origin.y -= ascender + descender - NSHeight(title_rect_); + title_rect_.size.height = ascender + descender; + + if (NSHeight(favicon_rect_) < ascender) { + // Move favicon down. + favicon_rect_.origin.y = title_rect_.origin.y + descender; + } else { + // Move title down. + title_rect_.origin.y = favicon_rect_.origin.y - descender; + } +} + +// A tileset is responsible for owning and laying out all |Tile|s shown in a +// tabpose window. +class TileSet { + public: + TileSet() {} + + // Fills in |tiles_|. + void Build(TabStripModel* source_model); + + // Computes coordinates for |tiles_|. + void Layout(NSRect containing_rect); + + int selected_index() const { return selected_index_; } + void set_selected_index(int index); + + const Tile& selected_tile() const { return *tiles_[selected_index()]; } + Tile& tile_at(int index) { return *tiles_[index]; } + const Tile& tile_at(int index) const { return *tiles_[index]; } + + // These return which index needs to be selected when the user presses + // up, down, left, or right respectively. + int up_index() const; + int down_index() const; + int left_index() const; + int right_index() const; + + // These return which index needs to be selected on tab / shift-tab. + int next_index() const; + int previous_index() const; + + // Inserts a new Tile object containing |contents| at |index|. Does no + // relayout. + void InsertTileAt(int index, TabContents* contents); + + // Removes the Tile object at |index|. Does no relayout. + void RemoveTileAt(int index); + + // Moves the Tile object at |from_index| to |to_index|. Since this doesn't + // change the number of tiles, relayout can be done just by swapping the + // tile rectangles in the index interval [from_index, to_index], so this does + // layout. + void MoveTileFromTo(int from_index, int to_index); + + private: + int count_x() const { + return ceilf(tiles_.size() / static_cast<float>(count_y_)); + } + int count_y() const { + return count_y_; + } + int last_row_count_x() const { + return tiles_.size() - count_x() * (count_y() - 1); + } + int tiles_in_row(int row) const { + return row != count_y() - 1 ? count_x() : last_row_count_x(); + } + void index_to_tile_xy(int index, int* tile_x, int* tile_y) const { + *tile_x = index % count_x(); + *tile_y = index / count_x(); + } + int tile_xy_to_index(int tile_x, int tile_y) const { + return tile_y * count_x() + tile_x; + } + + ScopedVector<Tile> tiles_; + int selected_index_; + int count_y_; + + DISALLOW_COPY_AND_ASSIGN(TileSet); +}; + +void TileSet::Build(TabStripModel* source_model) { + selected_index_ = source_model->selected_index(); + tiles_.resize(source_model->count()); + for (size_t i = 0; i < tiles_.size(); ++i) { + tiles_[i] = new Tile; + tiles_[i]->contents_ = source_model->GetTabContentsAt(i)->tab_contents(); + } +} + +void TileSet::Layout(NSRect containing_rect) { + int tile_count = tiles_.size(); + if (tile_count == 0) // Happens e.g. during test shutdown. + return; + + // Room around the tiles insde of |containing_rect|. + const int kSmallPaddingTop = 30; + const int kSmallPaddingLeft = 30; + const int kSmallPaddingRight = 30; + const int kSmallPaddingBottom = 30; + + // Favicon / title area. + const int kThumbTitlePaddingY = 6; + const int kFaviconSize = 16; + const int kTitleHeight = 14; // Font size. + const int kTitleExtraHeight = kThumbTitlePaddingY + kTitleHeight; + const int kFaviconExtraHeight = kThumbTitlePaddingY + kFaviconSize; + const int kFaviconTitleDistanceX = 6; + const int kFooterExtraHeight = + std::max(kFaviconExtraHeight, kTitleExtraHeight); + + // Room between the tiles. + const int kSmallPaddingX = 15; + const int kSmallPaddingY = kFooterExtraHeight; + + // Aspect ratio of the containing rect. + CGFloat aspect = NSWidth(containing_rect) / NSHeight(containing_rect); + + // Room left in container after the outer padding is removed. + double container_width = + NSWidth(containing_rect) - kSmallPaddingLeft - kSmallPaddingRight; + double container_height = + NSHeight(containing_rect) - kSmallPaddingTop - kSmallPaddingBottom; + + // The tricky part is figuring out the size of a tab thumbnail, or since the + // size of the containing rect is known, the number of tiles in x and y + // direction. + // Given are the size of the containing rect, and the number of thumbnails + // that need to fit into that rect. The aspect ratio of the thumbnails needs + // to be the same as that of |containing_rect|, else they will look distorted. + // The thumbnails need to be distributed such that + // |count_x * count_y >= tile_count|, and such that wasted space is minimized. + // See the comments in + // |FitNRectsWithAspectIntoBoundingSizeWithConstantPadding()| for a more + // detailed discussion. + // TODO(thakis): It might be good enough to choose |count_x| and |count_y| + // such that count_x / count_y is roughly equal to |aspect|? + double fny = FitNRectsWithAspectIntoBoundingSizeWithConstantPadding( + tile_count, aspect, + container_width, container_height - kFooterExtraHeight, + kSmallPaddingX, kSmallPaddingY + kFooterExtraHeight); + count_y_ = roundf(fny); + + // Now that |count_x()| and |count_y_| are known, it's straightforward to + // compute thumbnail width/height. See comment in + // |FitNRectsWithAspectIntoBoundingSizeWithConstantPadding| for the derivation + // of these two formulas. + int small_width = + floor((container_width + kSmallPaddingX) / static_cast<float>(count_x()) - + kSmallPaddingX); + int small_height = + floor((container_height + kSmallPaddingY) / static_cast<float>(count_y_) - + (kSmallPaddingY + kFooterExtraHeight)); + + // |small_width / small_height| has only roughly an aspect ratio of |aspect|. + // Shrink the thumbnail rect to make the aspect ratio fit exactly, and add + // the extra space won by shrinking to the outer padding. + int smallExtraPaddingLeft = 0; + int smallExtraPaddingTop = 0; + if (aspect > small_width/static_cast<float>(small_height)) { + small_height = small_width / aspect; + CGFloat all_tiles_height = + (small_height + kSmallPaddingY + kFooterExtraHeight) * count_y() - + (kSmallPaddingY + kFooterExtraHeight); + smallExtraPaddingTop = (container_height - all_tiles_height)/2; + } else { + small_width = small_height * aspect; + CGFloat all_tiles_width = + (small_width + kSmallPaddingX) * count_x() - kSmallPaddingX; + smallExtraPaddingLeft = (container_width - all_tiles_width)/2; + } + + // Compute inter-tile padding in the zoomed-out view. + CGFloat scale_small_to_big = + NSWidth(containing_rect) / static_cast<float>(small_width); + CGFloat big_padding_x = kSmallPaddingX * scale_small_to_big; + CGFloat big_padding_y = + (kSmallPaddingY + kFooterExtraHeight) * scale_small_to_big; + + // Now all dimensions are known. Lay out all tiles on a regular grid: + // X X X X + // X X X X + // X X + for (int row = 0, i = 0; i < tile_count; ++row) { + for (int col = 0; col < count_x() && i < tile_count; ++col, ++i) { + // Compute the smalled, zoomed-out thumbnail rect. + tiles_[i]->thumb_rect_.size = NSMakeSize(small_width, small_height); + + int small_x = col * (small_width + kSmallPaddingX) + + kSmallPaddingLeft + smallExtraPaddingLeft; + int small_y = row * (small_height + kSmallPaddingY + kFooterExtraHeight) + + kSmallPaddingTop + smallExtraPaddingTop; + + tiles_[i]->thumb_rect_.origin = NSMakePoint( + small_x, NSHeight(containing_rect) - small_y - small_height); + + tiles_[i]->favicon_rect_.size = NSMakeSize(kFaviconSize, kFaviconSize); + tiles_[i]->favicon_rect_.origin = NSMakePoint( + small_x, + NSHeight(containing_rect) - + (small_y + small_height + kFaviconExtraHeight)); + + // Align lower left corner of title rect with lower left corner of favicon + // for now. The final position is computed later by + // |Tile::set_font_metrics()|. + tiles_[i]->title_font_size_ = kTitleHeight; + tiles_[i]->title_rect_.origin = NSMakePoint( + NSMaxX(tiles_[i]->favicon_rect()) + kFaviconTitleDistanceX, + NSMinY(tiles_[i]->favicon_rect())); + tiles_[i]->title_rect_.size = NSMakeSize( + small_width - + NSWidth(tiles_[i]->favicon_rect()) - kFaviconTitleDistanceX, + kTitleHeight); + + // Compute the big, pre-zoom thumbnail rect. + tiles_[i]->start_thumb_rect_.size = containing_rect.size; + + int big_x = col * (NSWidth(containing_rect) + big_padding_x); + int big_y = row * (NSHeight(containing_rect) + big_padding_y); + tiles_[i]->start_thumb_rect_.origin = NSMakePoint(big_x, -big_y); + } + } + + // Go through last row and center it: + // X X X X + // X X X X + // X X + int last_row_empty_tiles_x = count_x() - last_row_count_x(); + CGFloat small_last_row_shift_x = + last_row_empty_tiles_x * (small_width + kSmallPaddingX) / 2; + CGFloat big_last_row_shift_x = + last_row_empty_tiles_x * (NSWidth(containing_rect) + big_padding_x) / 2; + for (int i = tile_count - last_row_count_x(); i < tile_count; ++i) { + tiles_[i]->thumb_rect_.origin.x += small_last_row_shift_x; + tiles_[i]->start_thumb_rect_.origin.x += big_last_row_shift_x; + tiles_[i]->favicon_rect_.origin.x += small_last_row_shift_x; + tiles_[i]->title_rect_.origin.x += small_last_row_shift_x; + } +} + +void TileSet::set_selected_index(int index) { + CHECK_GE(index, 0); + CHECK_LT(index, static_cast<int>(tiles_.size())); + selected_index_ = index; +} + +// Given a |value| in [0, from_scale), map it into [0, to_scale) such that: +// * [0, from_scale) ends up in the middle of [0, to_scale) if the latter is +// a bigger range +// * The middle of [0, from_scale) is mapped to [0, to_scale), and the parts +// of the former that don't fit are mapped to 0 and to_scale - respectively +// if the former is a bigger range. +static int rescale(int value, int from_scale, int to_scale) { + int left = (to_scale - from_scale) / 2; + int result = value + left; + if (result < 0) + return 0; + if (result >= to_scale) + return to_scale - 1; + return result; +} + +int TileSet::up_index() const { + int tile_x, tile_y; + index_to_tile_xy(selected_index(), &tile_x, &tile_y); + tile_y -= 1; + if (tile_y == count_y() - 2) { + // Transition from last row to second-to-last row. + tile_x = rescale(tile_x, last_row_count_x(), count_x()); + } else if (tile_y < 0) { + // Transition from first row to last row. + tile_x = rescale(tile_x, count_x(), last_row_count_x()); + tile_y = count_y() - 1; + } + return tile_xy_to_index(tile_x, tile_y); +} + +int TileSet::down_index() const { + int tile_x, tile_y; + index_to_tile_xy(selected_index(), &tile_x, &tile_y); + tile_y += 1; + if (tile_y == count_y() - 1) { + // Transition from second-to-last row to last row. + tile_x = rescale(tile_x, count_x(), last_row_count_x()); + } else if (tile_y >= count_y()) { + // Transition from last row to first row. + tile_x = rescale(tile_x, last_row_count_x(), count_x()); + tile_y = 0; + } + return tile_xy_to_index(tile_x, tile_y); +} + +int TileSet::left_index() const { + int tile_x, tile_y; + index_to_tile_xy(selected_index(), &tile_x, &tile_y); + tile_x -= 1; + if (tile_x < 0) + tile_x = tiles_in_row(tile_y) - 1; + return tile_xy_to_index(tile_x, tile_y); +} + +int TileSet::right_index() const { + int tile_x, tile_y; + index_to_tile_xy(selected_index(), &tile_x, &tile_y); + tile_x += 1; + if (tile_x >= tiles_in_row(tile_y)) + tile_x = 0; + return tile_xy_to_index(tile_x, tile_y); +} + +int TileSet::next_index() const { + int new_index = selected_index() + 1; + if (new_index >= static_cast<int>(tiles_.size())) + new_index = 0; + return new_index; +} + +int TileSet::previous_index() const { + int new_index = selected_index() - 1; + if (new_index < 0) + new_index = tiles_.size() - 1; + return new_index; +} + +void TileSet::InsertTileAt(int index, TabContents* contents) { + tiles_.insert(tiles_.begin() + index, new Tile); + tiles_[index]->contents_ = contents; +} + +void TileSet::RemoveTileAt(int index) { + tiles_.erase(tiles_.begin() + index); +} + +// Moves the Tile object at |from_index| to |to_index|. Also updates rectangles +// so that the tiles stay in a left-to-right, top-to-bottom layout when walked +// in sequential order. +void TileSet::MoveTileFromTo(int from_index, int to_index) { + NSRect thumb = tiles_[from_index]->thumb_rect_; + NSRect start_thumb = tiles_[from_index]->start_thumb_rect_; + NSRect favicon = tiles_[from_index]->favicon_rect_; + NSRect title = tiles_[from_index]->title_rect_; + + scoped_ptr<Tile> tile(tiles_[from_index]); + tiles_.weak_erase(tiles_.begin() + from_index); + tiles_.insert(tiles_.begin() + to_index, tile.release()); + + int step = from_index < to_index ? -1 : 1; + for (int i = to_index; (i - from_index) * step < 0; i += step) { + tiles_[i]->thumb_rect_ = tiles_[i + step]->thumb_rect_; + tiles_[i]->start_thumb_rect_ = tiles_[i + step]->start_thumb_rect_; + tiles_[i]->favicon_rect_ = tiles_[i + step]->favicon_rect_; + tiles_[i]->title_rect_ = tiles_[i + step]->title_rect_; + } + tiles_[from_index]->thumb_rect_ = thumb; + tiles_[from_index]->start_thumb_rect_ = start_thumb; + tiles_[from_index]->favicon_rect_ = favicon; + tiles_[from_index]->title_rect_ = title; +} + +} // namespace tabpose + +void AnimateCALayerFrameFromTo( + CALayer* layer, const NSRect& from, const NSRect& to, + NSTimeInterval duration, id boundsAnimationDelegate) { + // http://developer.apple.com/mac/library/qa/qa2008/qa1620.html + CABasicAnimation* animation; + + animation = [CABasicAnimation animationWithKeyPath:@"bounds"]; + animation.fromValue = [NSValue valueWithRect:from]; + animation.toValue = [NSValue valueWithRect:to]; + animation.duration = duration; + animation.timingFunction = + [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseOut]; + animation.delegate = boundsAnimationDelegate; + + // Update the layer's bounds so the layer doesn't snap back when the animation + // completes. + layer.bounds = NSRectToCGRect(to); + + // Add the animation, overriding the implicit animation. + [layer addAnimation:animation forKey:@"bounds"]; + + // Prepare the animation from the current position to the new position. + NSPoint opoint = from.origin; + NSPoint point = to.origin; + + // Adapt to anchorPoint. + opoint.x += NSWidth(from) * layer.anchorPoint.x; + opoint.y += NSHeight(from) * layer.anchorPoint.y; + point.x += NSWidth(to) * layer.anchorPoint.x; + point.y += NSHeight(to) * layer.anchorPoint.y; + + animation = [CABasicAnimation animationWithKeyPath:@"position"]; + animation.fromValue = [NSValue valueWithPoint:opoint]; + animation.toValue = [NSValue valueWithPoint:point]; + animation.duration = duration; + animation.timingFunction = + [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseOut]; + + // Update the layer's position so that the layer doesn't snap back when the + // animation completes. + layer.position = NSPointToCGPoint(point); + + // Add the animation, overriding the implicit animation. + [layer addAnimation:animation forKey:@"position"]; +} + +@interface TabposeWindow (Private) +- (id)initForWindow:(NSWindow*)parent + rect:(NSRect)rect + slomo:(BOOL)slomo + tabStripModel:(TabStripModel*)tabStripModel; +- (void)setUpLayersInSlomo:(BOOL)slomo; +- (void)fadeAway:(BOOL)slomo; +- (void)selectTileAtIndex:(int)newIndex; +@end + +@implementation TabposeWindow + ++ (id)openTabposeFor:(NSWindow*)parent + rect:(NSRect)rect + slomo:(BOOL)slomo + tabStripModel:(TabStripModel*)tabStripModel { + // Releases itself when closed. + return [[TabposeWindow alloc] + initForWindow:parent rect:rect slomo:slomo tabStripModel:tabStripModel]; +} + +- (id)initForWindow:(NSWindow*)parent + rect:(NSRect)rect + slomo:(BOOL)slomo + tabStripModel:(TabStripModel*)tabStripModel { + NSRect frame = [parent frame]; + if ((self = [super initWithContentRect:frame + styleMask:NSBorderlessWindowMask + backing:NSBackingStoreBuffered + defer:NO])) { + containingRect_ = rect; + tabStripModel_ = tabStripModel; + state_ = tabpose::kFadingIn; + tileSet_.reset(new tabpose::TileSet); + tabStripModelObserverBridge_.reset( + new TabStripModelObserverBridge(tabStripModel_, self)); + [self setReleasedWhenClosed:YES]; + [self setOpaque:NO]; + [self setBackgroundColor:[NSColor clearColor]]; + [self setUpLayersInSlomo:slomo]; + [self setAcceptsMouseMovedEvents:YES]; + [parent addChildWindow:self ordered:NSWindowAbove]; + [self makeKeyAndOrderFront:self]; + } + return self; +} + +- (CALayer*)selectedLayer { + return [allThumbnailLayers_ objectAtIndex:tileSet_->selected_index()]; +} + +- (void)selectTileAtIndex:(int)newIndex { + const tabpose::Tile& tile = tileSet_->tile_at(newIndex); + selectionHighlight_.frame = + NSRectToCGRect(NSInsetRect(tile.thumb_rect(), -5, -5)); + tileSet_->set_selected_index(newIndex); +} + +- (void)selectTileAtIndexWithoutAnimation:(int)newIndex { + ScopedCAActionDisabler disabler; + [self selectTileAtIndex:newIndex]; +} + +- (void)addLayersForTile:(tabpose::Tile&)tile + showZoom:(BOOL)showZoom + slomo:(BOOL)slomo + animationDelegate:(id)animationDelegate { + scoped_nsobject<CALayer> layer([[ThumbnailLayer alloc] + initWithTabContents:tile.tab_contents() + fullSize:tile.GetStartRectRelativeTo( + tileSet_->selected_tile()).size]); + [layer setNeedsDisplay]; + + // Background color as placeholder for now. + layer.get().backgroundColor = CGColorGetConstantColor(kCGColorWhite); + if (showZoom) { + AnimateCALayerFrameFromTo( + layer, + tile.GetStartRectRelativeTo(tileSet_->selected_tile()), + tile.thumb_rect(), + kDefaultAnimationDuration * (slomo ? kSlomoFactor : 1), + animationDelegate); + } else { + layer.get().frame = NSRectToCGRect(tile.thumb_rect()); + } + + layer.get().shadowRadius = 10; + layer.get().shadowOffset = CGSizeMake(0, -10); + if (state_ == tabpose::kFadedIn) + layer.get().shadowOpacity = 0.5; + + [bgLayer_ addSublayer:layer]; + [allThumbnailLayers_ addObject:layer]; + + // Favicon and title. + NSFont* font = [NSFont systemFontOfSize:tile.title_font_size()]; + tile.set_font_metrics([font ascender], -[font descender]); + + NSImage* nsFavicon = gfx::SkBitmapToNSImage(tile.favicon()); + // Either we don't have a valid favicon or there was some issue converting + // it from an SkBitmap. Either way, just show the default. + if (!nsFavicon) { + NSImage* defaultFavIcon = + ResourceBundle::GetSharedInstance().GetNativeImageNamed( + IDR_DEFAULT_FAVICON); + nsFavicon = defaultFavIcon; + } + base::mac::ScopedCFTypeRef<CGImageRef> favicon( + mac_util::CopyNSImageToCGImage(nsFavicon)); + + CALayer* faviconLayer = [CALayer layer]; + faviconLayer.frame = NSRectToCGRect(tile.favicon_rect()); + faviconLayer.contents = (id)favicon.get(); + faviconLayer.zPosition = 1; // On top of the thumb shadow. + if (state_ == tabpose::kFadingIn) + faviconLayer.hidden = YES; + [bgLayer_ addSublayer:faviconLayer]; + [allFaviconLayers_ addObject:faviconLayer]; + + CATextLayer* titleLayer = [CATextLayer layer]; + titleLayer.frame = NSRectToCGRect(tile.title_rect()); + titleLayer.string = base::SysUTF16ToNSString(tile.title()); + titleLayer.fontSize = [font pointSize]; + titleLayer.truncationMode = kCATruncationEnd; + titleLayer.font = font; + titleLayer.zPosition = 1; // On top of the thumb shadow. + if (state_ == tabpose::kFadingIn) + titleLayer.hidden = YES; + [bgLayer_ addSublayer:titleLayer]; + [allTitleLayers_ addObject:titleLayer]; +} + +- (void)setUpLayersInSlomo:(BOOL)slomo { + // Root layer -- covers whole window. + rootLayer_ = [CALayer layer]; + + // In a block so that the layers don't fade in. + { + ScopedCAActionDisabler disabler; + // Background layer -- the visible part of the window. + gray_.reset(CGColorCreateGenericGray(0.39, 1.0)); + bgLayer_ = [CALayer layer]; + bgLayer_.backgroundColor = gray_; + bgLayer_.frame = NSRectToCGRect(containingRect_); + bgLayer_.masksToBounds = YES; + [rootLayer_ addSublayer:bgLayer_]; + + // Selection highlight layer. + darkBlue_.reset(CGColorCreateGenericRGB(0.25, 0.34, 0.86, 1.0)); + selectionHighlight_ = [CALayer layer]; + selectionHighlight_.backgroundColor = darkBlue_; + selectionHighlight_.cornerRadius = 5.0; + selectionHighlight_.zPosition = -1; // Behind other layers. + selectionHighlight_.hidden = YES; + [bgLayer_ addSublayer:selectionHighlight_]; + + // Top gradient. + CALayer* gradientLayer = [DarkGradientLayer layer]; + gradientLayer.frame = CGRectMake( + 0, + NSHeight(containingRect_) - kTopGradientHeight, + NSWidth(containingRect_), + kTopGradientHeight); + [gradientLayer setNeedsDisplay]; // Draw once. + [bgLayer_ addSublayer:gradientLayer]; + } + + // Layers for the tab thumbnails. + tileSet_->Build(tabStripModel_); + tileSet_->Layout(containingRect_); + allThumbnailLayers_.reset( + [[NSMutableArray alloc] initWithCapacity:tabStripModel_->count()]); + allFaviconLayers_.reset( + [[NSMutableArray alloc] initWithCapacity:tabStripModel_->count()]); + allTitleLayers_.reset( + [[NSMutableArray alloc] initWithCapacity:tabStripModel_->count()]); + + for (int i = 0; i < tabStripModel_->count(); ++i) { + // Add a delegate to one of the animations to get a notification once the + // animations are done. + [self addLayersForTile:tileSet_->tile_at(i) + showZoom:YES + slomo:slomo + animationDelegate:i == tileSet_->selected_index() ? self : nil]; + if (i == tileSet_->selected_index()) { + CALayer* layer = [allThumbnailLayers_ objectAtIndex:i]; + CAAnimation* animation = [layer animationForKey:@"bounds"]; + DCHECK(animation); + [animation setValue:kAnimationIdFadeIn forKey:kAnimationIdKey]; + } + } + [self selectTileAtIndexWithoutAnimation:tileSet_->selected_index()]; + + // Needs to happen after all layers have been added to |rootLayer_|, else + // there's a one frame flash of grey at the beginning of the animation + // (|bgLayer_| showing through with none of its children visible yet). + [[self contentView] setLayer:rootLayer_]; + [[self contentView] setWantsLayer:YES]; +} + +- (BOOL)canBecomeKeyWindow { + return YES; +} + +// Handle key events that should be executed repeatedly while the key is down. +- (void)keyDown:(NSEvent*)event { + if (state_ == tabpose::kFadingOut) + return; + NSString* characters = [event characters]; + if ([characters length] < 1) + return; + + unichar character = [characters characterAtIndex:0]; + int newIndex = -1; + switch (character) { + case NSUpArrowFunctionKey: + newIndex = tileSet_->up_index(); + break; + case NSDownArrowFunctionKey: + newIndex = tileSet_->down_index(); + break; + case NSLeftArrowFunctionKey: + newIndex = tileSet_->left_index(); + break; + case NSRightArrowFunctionKey: + newIndex = tileSet_->right_index(); + break; + case NSTabCharacter: + newIndex = tileSet_->next_index(); + break; + case NSBackTabCharacter: + newIndex = tileSet_->previous_index(); + break; + } + if (newIndex != -1) + [self selectTileAtIndexWithoutAnimation:newIndex]; +} + +// Handle keyboard events that should be executed once when the key is released. +- (void)keyUp:(NSEvent*)event { + if (state_ == tabpose::kFadingOut) + return; + NSString* characters = [event characters]; + if ([characters length] < 1) + return; + + unichar character = [characters characterAtIndex:0]; + switch (character) { + case NSEnterCharacter: + case NSNewlineCharacter: + case NSCarriageReturnCharacter: + case ' ': + [self fadeAway:([event modifierFlags] & NSShiftKeyMask) != 0]; + break; + case '\e': // Escape + tileSet_->set_selected_index(tabStripModel_->selected_index()); + [self fadeAway:([event modifierFlags] & NSShiftKeyMask) != 0]; + break; + } +} + +// Handle keyboard events that contain cmd or ctrl. +- (BOOL)performKeyEquivalent:(NSEvent*)event { + if (state_ == tabpose::kFadingOut) + return NO; + NSString* characters = [event characters]; + if ([characters length] < 1) + return NO; + unichar character = [characters characterAtIndex:0]; + if ([event modifierFlags] & NSCommandKeyMask) { + if (character >= '1' && character <= '9') { + int index = + character == '9' ? tabStripModel_->count() - 1 : character - '1'; + if (index < tabStripModel_->count()) { + tileSet_->set_selected_index(index); + [self fadeAway:([event modifierFlags] & NSShiftKeyMask) != 0]; + return YES; + } + } + } + return NO; +} + +-(void)selectTileFromMouseEvent:(NSEvent*)event { + int newIndex = -1; + CGPoint p = NSPointToCGPoint([event locationInWindow]); + for (NSUInteger i = 0; i < [allThumbnailLayers_ count]; ++i) { + CALayer* layer = [allThumbnailLayers_ objectAtIndex:i]; + CGPoint lp = [layer convertPoint:p fromLayer:rootLayer_]; + if ([static_cast<CALayer*>([layer presentationLayer]) containsPoint:lp]) + newIndex = i; + } + if (newIndex >= 0) + [self selectTileAtIndexWithoutAnimation:newIndex]; +} + +- (void)mouseMoved:(NSEvent*)event { + [self selectTileFromMouseEvent:event]; +} + +- (void)mouseDown:(NSEvent*)event { + // Just in case the user clicked without ever moving the mouse. + [self selectTileFromMouseEvent:event]; + + [self fadeAway:([event modifierFlags] & NSShiftKeyMask) != 0]; +} + +- (void)swipeWithEvent:(NSEvent*)event { + if (abs([event deltaY]) > 0.5) // Swipe up or down. + [self fadeAway:([event modifierFlags] & NSShiftKeyMask) != 0]; +} + +- (void)close { + // Prevent parent window from disappearing. + [[self parentWindow] removeChildWindow:self]; + + // We're dealloc'd in an autorelease pool – by then the observer registry + // might be dead, so explicitly reset the observer now. + tabStripModelObserverBridge_.reset(); + + [super close]; +} + +- (void)commandDispatch:(id)sender { + if ([sender tag] == IDC_TABPOSE) + [self fadeAway:NO]; +} + +- (BOOL)validateUserInterfaceItem:(id<NSValidatedUserInterfaceItem>)item { + // Disable all browser-related menu items except the tab overview toggle. + SEL action = [item action]; + NSInteger tag = [item tag]; + return action == @selector(commandDispatch:) && tag == IDC_TABPOSE; +} + +- (void)fadeAway:(BOOL)slomo { + if (state_ == tabpose::kFadingOut) + return; + + state_ = tabpose::kFadingOut; + [self setAcceptsMouseMovedEvents:NO]; + + // Select chosen tab. + if (tileSet_->selected_index() < tabStripModel_->count()) { + tabStripModel_->SelectTabContentsAt(tileSet_->selected_index(), + /*user_gesture=*/true); + } else { + DCHECK_EQ(tileSet_->selected_index(), 0); + } + + { + ScopedCAActionDisabler disableCAActions; + + // Move the selected layer on top of all other layers. + [self selectedLayer].zPosition = 1; + + selectionHighlight_.hidden = YES; + for (CALayer* layer in allFaviconLayers_.get()) + layer.hidden = YES; + for (CALayer* layer in allTitleLayers_.get()) + layer.hidden = YES; + + // Running animations with shadows is slow, so turn shadows off before + // running the exit animation. + for (CALayer* layer in allThumbnailLayers_.get()) + layer.shadowOpacity = 0.0; + } + + // Animate layers out, all in one transaction. + CGFloat duration = + 1.3 * kDefaultAnimationDuration * (slomo ? kSlomoFactor : 1); + ScopedCAActionSetDuration durationSetter(duration); + for (NSUInteger i = 0; i < [allThumbnailLayers_ count]; ++i) { + CALayer* layer = [allThumbnailLayers_ objectAtIndex:i]; + // |start_thumb_rect_| was relative to the initial index, now this needs to + // be relative to |selectedIndex_| (whose start rect was relative to + // the initial index, too). + CGRect newFrame = NSRectToCGRect( + tileSet_->tile_at(i).GetStartRectRelativeTo(tileSet_->selected_tile())); + + // Add a delegate to one of the implicit animations to get a notification + // once the animations are done. + if (static_cast<int>(i) == tileSet_->selected_index()) { + CAAnimation* animation = [CAAnimation animation]; + animation.delegate = self; + [animation setValue:kAnimationIdFadeOut forKey:kAnimationIdKey]; + [layer addAnimation:animation forKey:@"frame"]; + } + + layer.frame = newFrame; + + if (static_cast<int>(i) == tileSet_->selected_index()) { + // Redraw layer at big resolution, so that zoom-in isn't blocky. + [layer setNeedsDisplay]; + } + } +} + +- (void)animationDidStop:(CAAnimation*)animation finished:(BOOL)finished { + NSString* animationId = [animation valueForKey:kAnimationIdKey]; + if ([animationId isEqualToString:kAnimationIdFadeIn]) { + if (finished && state_ == tabpose::kFadingIn) { + // If the user clicks while the fade in animation is still running, + // |state_| is already kFadingOut. In that case, don't do anything. + state_ = tabpose::kFadedIn; + + selectionHighlight_.hidden = NO; + for (CALayer* layer in allFaviconLayers_.get()) + layer.hidden = NO; + for (CALayer* layer in allTitleLayers_.get()) + layer.hidden = NO; + + // Running animations with shadows is slow, so turn shadows on only after + // the animation is done. + ScopedCAActionDisabler disableCAActions; + for (CALayer* layer in allThumbnailLayers_.get()) + layer.shadowOpacity = 0.5; + } + } else if ([animationId isEqualToString:kAnimationIdFadeOut]) { + DCHECK_EQ(tabpose::kFadingOut, state_); + [self close]; + } +} + +- (NSUInteger)thumbnailLayerCount { + return [allThumbnailLayers_ count]; +} + +- (int)selectedIndex { + return tileSet_->selected_index(); +} + +#pragma mark TabStripModelBridge + +- (void)refreshLayerFramesAtIndex:(int)i { + const tabpose::Tile& tile = tileSet_->tile_at(i); + + CALayer* faviconLayer = [allFaviconLayers_ objectAtIndex:i]; + faviconLayer.frame = NSRectToCGRect(tile.favicon_rect()); + CALayer* titleLayer = [allTitleLayers_ objectAtIndex:i]; + titleLayer.frame = NSRectToCGRect(tile.title_rect()); + CALayer* thumbLayer = [allThumbnailLayers_ objectAtIndex:i]; + thumbLayer.frame = NSRectToCGRect(tile.thumb_rect()); +} + +- (void)insertTabWithContents:(TabContentsWrapper*)contents + atIndex:(NSInteger)index + inForeground:(bool)inForeground { + // This happens if you cmd-click a link and then immediately open tabpose + // on a slowish machine. + ScopedCAActionSetDuration durationSetter(kObserverChangeAnimationDuration); + + // Insert new layer and relayout. + tileSet_->InsertTileAt(index, contents->tab_contents()); + tileSet_->Layout(containingRect_); + [self addLayersForTile:tileSet_->tile_at(index) + showZoom:NO + slomo:NO + animationDelegate:nil]; + + // Update old layers. + DCHECK_EQ(tabStripModel_->count(), + static_cast<int>([allThumbnailLayers_ count])); + DCHECK_EQ(tabStripModel_->count(), + static_cast<int>([allTitleLayers_ count])); + DCHECK_EQ(tabStripModel_->count(), + static_cast<int>([allFaviconLayers_ count])); + + for (int i = 0; i < tabStripModel_->count(); ++i) { + if (i == index) // The new layer. + continue; + [self refreshLayerFramesAtIndex:i]; + } + + // Update selection. + int selectedIndex = tileSet_->selected_index(); + if (selectedIndex >= index) + selectedIndex++; + [self selectTileAtIndex:selectedIndex]; +} + +- (void)tabClosingWithContents:(TabContentsWrapper*)contents + atIndex:(NSInteger)index { + // We will also get a -tabDetachedWithContents:atIndex: notification for + // closing tabs, so do nothing here. +} + +- (void)tabDetachedWithContents:(TabContentsWrapper*)contents + atIndex:(NSInteger)index { + ScopedCAActionSetDuration durationSetter(kObserverChangeAnimationDuration); + + // Remove layer and relayout. + tileSet_->RemoveTileAt(index); + tileSet_->Layout(containingRect_); + + [[allThumbnailLayers_ objectAtIndex:index] removeFromSuperlayer]; + [allThumbnailLayers_ removeObjectAtIndex:index]; + [[allTitleLayers_ objectAtIndex:index] removeFromSuperlayer]; + [allTitleLayers_ removeObjectAtIndex:index]; + [[allFaviconLayers_ objectAtIndex:index] removeFromSuperlayer]; + [allFaviconLayers_ removeObjectAtIndex:index]; + + // Update old layers. + DCHECK_EQ(tabStripModel_->count(), + static_cast<int>([allThumbnailLayers_ count])); + DCHECK_EQ(tabStripModel_->count(), + static_cast<int>([allTitleLayers_ count])); + DCHECK_EQ(tabStripModel_->count(), + static_cast<int>([allFaviconLayers_ count])); + + if (tabStripModel_->count() == 0) + [self close]; + + for (int i = 0; i < tabStripModel_->count(); ++i) + [self refreshLayerFramesAtIndex:i]; + + // Update selection. + int selectedIndex = tileSet_->selected_index(); + if (selectedIndex >= index) + selectedIndex--; + if (selectedIndex >= 0) + [self selectTileAtIndex:selectedIndex]; +} + +- (void)tabMovedWithContents:(TabContentsWrapper*)contents + fromIndex:(NSInteger)from + toIndex:(NSInteger)to { + ScopedCAActionSetDuration durationSetter(kObserverChangeAnimationDuration); + + // Move tile from |from| to |to|. + tileSet_->MoveTileFromTo(from, to); + + // Move corresponding layers from |from| to |to|. + scoped_nsobject<CALayer> thumbLayer( + [[allThumbnailLayers_ objectAtIndex:from] retain]); + [allThumbnailLayers_ removeObjectAtIndex:from]; + [allThumbnailLayers_ insertObject:thumbLayer.get() atIndex:to]; + scoped_nsobject<CALayer> faviconLayer( + [[allFaviconLayers_ objectAtIndex:from] retain]); + [allFaviconLayers_ removeObjectAtIndex:from]; + [allFaviconLayers_ insertObject:faviconLayer.get() atIndex:to]; + scoped_nsobject<CALayer> titleLayer( + [[allTitleLayers_ objectAtIndex:from] retain]); + [allTitleLayers_ removeObjectAtIndex:from]; + [allTitleLayers_ insertObject:titleLayer.get() atIndex:to]; + + // Update frames of the layers. + for (int i = std::min(from, to); i <= std::max(from, to); ++i) + [self refreshLayerFramesAtIndex:i]; + + // Update selection. + int selectedIndex = tileSet_->selected_index(); + if (from == selectedIndex) + selectedIndex = to; + else if (from < selectedIndex && selectedIndex <= to) + selectedIndex--; + else if (to <= selectedIndex && selectedIndex < from) + selectedIndex++; + [self selectTileAtIndex:selectedIndex]; +} + +- (void)tabChangedWithContents:(TabContentsWrapper*)contents + atIndex:(NSInteger)index + changeType:(TabStripModelObserver::TabChangeType)change { + // Tell the window to update text, title, and thumb layers at |index| to get + // their data from |contents|. |contents| can be different from the old + // contents at that index! + // While a tab is loading, this is unfortunately called quite often for + // both the "loading" and the "all" change types, so we don't really want to + // send thumb requests to the corresponding renderer when this is called. + // For now, just make sure that we don't hold on to an invalid TabContents + // object. + tabpose::Tile& tile = tileSet_->tile_at(index); + if (contents->tab_contents() == tile.tab_contents()) { + // TODO(thakis): Install a timer to send a thumb request/update title/update + // favicon after 20ms or so, and reset the timer every time this is called + // to make sure we get an updated thumb, without requesting them all over. + return; + } + + tile.set_tab_contents(contents->tab_contents()); + ThumbnailLayer* thumbLayer = [allThumbnailLayers_ objectAtIndex:index]; + [thumbLayer setTabContents:contents->tab_contents()]; +} + +- (void)tabStripModelDeleted { + [self close]; +} + +@end diff --git a/chrome/browser/ui/cocoa/tabpose_window_unittest.mm b/chrome/browser/ui/cocoa/tabpose_window_unittest.mm new file mode 100644 index 0000000..46f0bca --- /dev/null +++ b/chrome/browser/ui/cocoa/tabpose_window_unittest.mm @@ -0,0 +1,119 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/tabpose_window.h" + +#import "chrome/browser/browser_window.h" +#include "chrome/browser/renderer_host/site_instance.h" +#include "chrome/browser/tab_contents/tab_contents.h" +#include "chrome/browser/tab_contents_wrapper.h" +#include "chrome/browser/tabs/tab_strip_model.h" +#import "chrome/browser/ui/cocoa/browser_test_helper.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "testing/gtest/include/gtest/gtest.h" + +class TabposeWindowTest : public CocoaTest { + public: + TabposeWindowTest() { + site_instance_ = + SiteInstance::CreateSiteInstance(browser_helper_.profile()); + } + + void AppendTabToStrip() { + TabContentsWrapper* tab_contents = Browser::TabContentsFactory( + browser_helper_.profile(), site_instance_, MSG_ROUTING_NONE, + NULL, NULL); + browser_helper_.browser()->tabstrip_model()->AppendTabContents( + tab_contents, /*foreground=*/true); + } + + BrowserTestHelper browser_helper_; + scoped_refptr<SiteInstance> site_instance_; +}; + +// Check that this doesn't leak. +TEST_F(TabposeWindowTest, TestShow) { + BrowserWindow* browser_window = browser_helper_.CreateBrowserWindow(); + NSWindow* parent = browser_window->GetNativeHandle(); + + [parent orderFront:nil]; + EXPECT_TRUE([parent isVisible]); + + // Add a few tabs to the tab strip model. + for (int i = 0; i < 3; ++i) + AppendTabToStrip(); + + base::mac::ScopedNSAutoreleasePool pool; + TabposeWindow* window = + [TabposeWindow openTabposeFor:parent + rect:NSMakeRect(10, 20, 250, 160) + slomo:NO + tabStripModel:browser_helper_.browser()->tabstrip_model()]; + + // Should release the window. + [window mouseDown:nil]; + + browser_helper_.CloseBrowserWindow(); +} + +TEST_F(TabposeWindowTest, TestModelObserver) { + BrowserWindow* browser_window = browser_helper_.CreateBrowserWindow(); + NSWindow* parent = browser_window->GetNativeHandle(); + [parent orderFront:nil]; + + // Add a few tabs to the tab strip model. + for (int i = 0; i < 3; ++i) + AppendTabToStrip(); + + base::mac::ScopedNSAutoreleasePool pool; + TabposeWindow* window = + [TabposeWindow openTabposeFor:parent + rect:NSMakeRect(10, 20, 250, 160) + slomo:NO + tabStripModel:browser_helper_.browser()->tabstrip_model()]; + + // Exercise all the model change events. + TabStripModel* model = browser_helper_.browser()->tabstrip_model(); + DCHECK_EQ([window thumbnailLayerCount], 3u); + DCHECK_EQ([window selectedIndex], 2); + + model->MoveTabContentsAt(0, 2, /*select_after_move=*/false); + DCHECK_EQ([window thumbnailLayerCount], 3u); + DCHECK_EQ([window selectedIndex], 1); + + model->MoveTabContentsAt(2, 0, /*select_after_move=*/false); + DCHECK_EQ([window thumbnailLayerCount], 3u); + DCHECK_EQ([window selectedIndex], 2); + + [window selectTileAtIndexWithoutAnimation:0]; + DCHECK_EQ([window selectedIndex], 0); + + model->MoveTabContentsAt(0, 2, /*select_after_move=*/false); + DCHECK_EQ([window selectedIndex], 2); + + model->MoveTabContentsAt(2, 0, /*select_after_move=*/false); + DCHECK_EQ([window selectedIndex], 0); + + delete model->DetachTabContentsAt(0); + DCHECK_EQ([window thumbnailLayerCount], 2u); + DCHECK_EQ([window selectedIndex], 0); + + AppendTabToStrip(); + DCHECK_EQ([window thumbnailLayerCount], 3u); + DCHECK_EQ([window selectedIndex], 0); + + model->CloseTabContentsAt(0, TabStripModel::CLOSE_NONE); + DCHECK_EQ([window thumbnailLayerCount], 2u); + DCHECK_EQ([window selectedIndex], 0); + + [window selectTileAtIndexWithoutAnimation:1]; + model->CloseTabContentsAt(0, TabStripModel::CLOSE_NONE); + DCHECK_EQ([window thumbnailLayerCount], 1u); + DCHECK_EQ([window selectedIndex], 0); + + // Should release the window. + [window mouseDown:nil]; + + browser_helper_.CloseBrowserWindow(); +} diff --git a/chrome/browser/ui/cocoa/task_helpers.h b/chrome/browser/ui/cocoa/task_helpers.h new file mode 100644 index 0000000..e29c068 --- /dev/null +++ b/chrome/browser/ui/cocoa/task_helpers.h @@ -0,0 +1,29 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_TASK_HELPERS_H_ +#define CHROME_BROWSER_UI_COCOA_TASK_HELPERS_H_ +#pragma once + +class Task; + +namespace tracked_objects { +class Location; +} // namespace tracked_objects + +namespace cocoa_utils { + +// This can be used in place of BrowserThread::PostTask(BrowserThread::UI, ...). +// The purpose of this function is to be able to execute Task work alongside +// native work when a MessageLoop is blocked by a nested run loop. This function +// will run the Task in both NSEventTrackingRunLoopMode and NSDefaultRunLoopMode +// for the purpose of executing work while a menu is open. See +// http://crbug.com/48679 for the full rationale. +bool PostTaskInEventTrackingRunLoopMode( + const tracked_objects::Location& from_here, + Task* task); + +} // namespace cocoa_utils + +#endif // CHROME_BROWSER_UI_COCOA_TASK_HELPERS_H_ diff --git a/chrome/browser/ui/cocoa/task_helpers.mm b/chrome/browser/ui/cocoa/task_helpers.mm new file mode 100644 index 0000000..2f8df18 --- /dev/null +++ b/chrome/browser/ui/cocoa/task_helpers.mm @@ -0,0 +1,57 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/task_helpers.h" + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_ptr.h" +#include "base/task.h" + +// This is a wrapper for running Task objects from within a native run loop. +// This can run specific tasks in that nested loop. This owns the task and will +// delete it and itself when done. +@interface NativeTaskRunner : NSObject { + @private + scoped_ptr<Task> task_; +} +- (id)initWithTask:(Task*)task; +- (void)runTask; +@end + +@implementation NativeTaskRunner +- (id)initWithTask:(Task*)task { + if ((self = [super init])) { + task_.reset(task); + } + return self; +} + +- (void)runTask { + task_->Run(); + [self autorelease]; +} +@end + +namespace cocoa_utils { + +bool PostTaskInEventTrackingRunLoopMode( + const tracked_objects::Location& from_here, + Task* task) { + // This deletes itself and the task after the task runs. + NativeTaskRunner* runner = [[NativeTaskRunner alloc] initWithTask:task]; + + // Schedule the selector in multiple modes in case this was called while a + // menu was not running. + NSArray* modes = [NSArray arrayWithObjects:NSEventTrackingRunLoopMode, + NSDefaultRunLoopMode, + nil]; + [runner performSelectorOnMainThread:@selector(runTask) + withObject:nil + waitUntilDone:NO + modes:modes]; + return true; +} + +} // namespace cocoa_utils diff --git a/chrome/browser/ui/cocoa/task_manager_mac.h b/chrome/browser/ui/cocoa/task_manager_mac.h new file mode 100644 index 0000000..9e6e70b --- /dev/null +++ b/chrome/browser/ui/cocoa/task_manager_mac.h @@ -0,0 +1,118 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_TASK_MANAGER_MAC_H_ +#define CHROME_BROWSER_UI_COCOA_TASK_MANAGER_MAC_H_ +#pragma once + +#import <Cocoa/Cocoa.h> +#include <vector> + +#include "base/cocoa_protocols_mac.h" +#include "base/scoped_nsobject.h" +#include "chrome/browser/task_manager/task_manager.h" +#include "chrome/browser/ui/cocoa/table_row_nsimage_cache.h" + +@class WindowSizeAutosaver; +class SkBitmap; +class TaskManagerMac; + +// This class is responsible for loading the task manager window and for +// managing it. +@interface TaskManagerWindowController : + NSWindowController<NSTableViewDataSource, + NSTableViewDelegate> { + @private + IBOutlet NSTableView* tableView_; + IBOutlet NSButton* endProcessButton_; + TaskManagerMac* taskManagerObserver_; // weak + TaskManager* taskManager_; // weak + TaskManagerModel* model_; // weak + + scoped_nsobject<WindowSizeAutosaver> size_saver_; + + // These contain a permutation of [0..|model_->ResourceCount() - 1|]. Used to + // implement sorting. + std::vector<int> viewToModelMap_; + std::vector<int> modelToViewMap_; + + // Descriptor of the current sort column. + scoped_nsobject<NSSortDescriptor> currentSortDescriptor_; +} + +// Creates and shows the task manager's window. +- (id)initWithTaskManagerObserver:(TaskManagerMac*)taskManagerObserver; + +// Refreshes all data in the task manager table. +- (void)reloadData; + +// Callback for "Stats for nerds" link. +- (IBAction)statsLinkClicked:(id)sender; + +// Callback for "End process" button. +- (IBAction)killSelectedProcesses:(id)sender; + +// Callback for double clicks on the table. +- (void)selectDoubleClickedTab:(id)sender; +@end + +@interface TaskManagerWindowController (TestingAPI) +- (NSTableView*)tableView; +@end + +// This class listens to task changed events sent by chrome. +class TaskManagerMac : public TaskManagerModelObserver, + public TableRowNSImageCache::Table { + public: + TaskManagerMac(TaskManager* task_manager); + virtual ~TaskManagerMac(); + + // TaskManagerModelObserver + virtual void OnModelChanged(); + virtual void OnItemsChanged(int start, int length); + virtual void OnItemsAdded(int start, int length); + virtual void OnItemsRemoved(int start, int length); + + // Called by the cocoa window controller when its window closes and the + // controller destroyed itself. Informs the model to stop updating. + void WindowWasClosed(); + + // TableRowNSImageCache::Table + virtual int RowCount() const; + virtual SkBitmap GetIcon(int r) const; + + // Creates the task manager if it doesn't exist; otherwise, it activates the + // existing task manager window. + static void Show(); + + // Returns the TaskManager observed by |this|. + TaskManager* task_manager() { return task_manager_; } + + // Lazily converts the image at the given row and caches it in |icon_cache_|. + NSImage* GetImageForRow(int row); + + // Returns the cocoa object. Used for testing. + TaskManagerWindowController* cocoa_controller() { return window_controller_; } + private: + // The task manager. + TaskManager* const task_manager_; // weak + + // Our model. + TaskManagerModel* const model_; // weak + + // Controller of our window, destroys itself when the task manager window + // is closed. + TaskManagerWindowController* window_controller_; // weak + + // Caches favicons for all rows. Needs to be initalized after |model_|. + TableRowNSImageCache icon_cache_; + + // An open task manager window. There can only be one open at a time. This + // is reset to NULL when the window is closed. + static TaskManagerMac* instance_; + + DISALLOW_COPY_AND_ASSIGN(TaskManagerMac); +}; + +#endif // CHROME_BROWSER_UI_COCOA_TASK_MANAGER_MAC_H_ diff --git a/chrome/browser/ui/cocoa/task_manager_mac.mm b/chrome/browser/ui/cocoa/task_manager_mac.mm new file mode 100644 index 0000000..c11564c --- /dev/null +++ b/chrome/browser/ui/cocoa/task_manager_mac.mm @@ -0,0 +1,582 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/task_manager_mac.h" + +#include <algorithm> +#include <vector> + +#include "app/l10n_util_mac.h" +#include "base/mac_util.h" +#include "base/sys_string_conversions.h" +#include "chrome/browser/browser_process.h" +#import "chrome/browser/ui/cocoa/window_size_autosaver.h" +#include "chrome/common/pref_names.h" +#include "grit/generated_resources.h" +#include "third_party/skia/include/core/SkBitmap.h" + +namespace { + +// Width of "a" and most other letters/digits in "small" table views. +const int kCharWidth = 6; + +// Some of the strings below have spaces at the end or are missing letters, to +// make the columns look nicer, and to take potentially longer localized strings +// into account. +const struct ColumnWidth { + int columnId; + int minWidth; + int maxWidth; // If this is -1, 1.5*minColumWidth is used as max width. +} columnWidths[] = { + // Note that arraysize includes the trailing \0. That's intended. + { IDS_TASK_MANAGER_PAGE_COLUMN, 120, 600 }, + { IDS_TASK_MANAGER_PHYSICAL_MEM_COLUMN, + arraysize("800 MiB") * kCharWidth, -1 }, + { IDS_TASK_MANAGER_SHARED_MEM_COLUMN, + arraysize("800 MiB") * kCharWidth, -1 }, + { IDS_TASK_MANAGER_PRIVATE_MEM_COLUMN, + arraysize("800 MiB") * kCharWidth, -1 }, + { IDS_TASK_MANAGER_CPU_COLUMN, + arraysize("99.9") * kCharWidth, -1 }, + { IDS_TASK_MANAGER_NET_COLUMN, + arraysize("150 kiB/s") * kCharWidth, -1 }, + { IDS_TASK_MANAGER_PROCESS_ID_COLUMN, + arraysize("73099 ") * kCharWidth, -1 }, + { IDS_TASK_MANAGER_WEBCORE_IMAGE_CACHE_COLUMN, + arraysize("2000.0K (2000.0 live)") * kCharWidth, -1 }, + { IDS_TASK_MANAGER_WEBCORE_SCRIPTS_CACHE_COLUMN, + arraysize("2000.0K (2000.0 live)") * kCharWidth, -1 }, + { IDS_TASK_MANAGER_WEBCORE_CSS_CACHE_COLUMN, + arraysize("2000.0K (2000.0 live)") * kCharWidth, -1 }, + { IDS_TASK_MANAGER_SQLITE_MEMORY_USED_COLUMN, + arraysize("800 kB") * kCharWidth, -1 }, + { IDS_TASK_MANAGER_JAVASCRIPT_MEMORY_ALLOCATED_COLUMN, + arraysize("2000.0K (2000.0 live)") * kCharWidth, -1 }, + { IDS_TASK_MANAGER_GOATS_TELEPORTED_COLUMN, + arraysize("15 ") * kCharWidth, -1 }, +}; + +class SortHelper { + public: + SortHelper(TaskManagerModel* model, NSSortDescriptor* column) + : sort_column_([[column key] intValue]), + ascending_([column ascending]), + model_(model) {} + + bool operator()(int a, int b) { + std::pair<int, int> group_range1 = model_->GetGroupRangeForResource(a); + std::pair<int, int> group_range2 = model_->GetGroupRangeForResource(b); + if (group_range1 == group_range2) { + // The two rows are in the same group, sort so that items in the same + // group always appear in the same order. |ascending_| is intentionally + // ignored. + return a < b; + } + // Sort by the first entry of each of the groups. + int cmp_result = model_->CompareValues( + group_range1.first, group_range2.first, sort_column_); + if (!ascending_) + cmp_result = -cmp_result; + return cmp_result < 0; + } + private: + int sort_column_; + bool ascending_; + TaskManagerModel* model_; // weak; +}; + +} // namespace + +@interface TaskManagerWindowController (Private) +- (NSTableColumn*)addColumnWithId:(int)columnId visible:(BOOL)isVisible; +- (void)setUpTableColumns; +- (void)setUpTableHeaderContextMenu; +- (void)toggleColumn:(id)sender; +- (void)adjustSelectionAndEndProcessButton; +- (void)deselectRows; +@end + +//////////////////////////////////////////////////////////////////////////////// +// TaskManagerWindowController implementation: + +@implementation TaskManagerWindowController + +- (id)initWithTaskManagerObserver:(TaskManagerMac*)taskManagerObserver { + NSString* nibpath = [mac_util::MainAppBundle() + pathForResource:@"TaskManager" + ofType:@"nib"]; + if ((self = [super initWithWindowNibPath:nibpath owner:self])) { + taskManagerObserver_ = taskManagerObserver; + taskManager_ = taskManagerObserver_->task_manager(); + model_ = taskManager_->model(); + + if (g_browser_process && g_browser_process->local_state()) { + size_saver_.reset([[WindowSizeAutosaver alloc] + initWithWindow:[self window] + prefService:g_browser_process->local_state() + path:prefs::kTaskManagerWindowPlacement]); + } + [self showWindow:self]; + } + return self; +} + +- (void)sortShuffleArray { + viewToModelMap_.resize(model_->ResourceCount()); + for (size_t i = 0; i < viewToModelMap_.size(); ++i) + viewToModelMap_[i] = i; + + std::sort(viewToModelMap_.begin(), viewToModelMap_.end(), + SortHelper(model_, currentSortDescriptor_.get())); + + modelToViewMap_.resize(viewToModelMap_.size()); + for (size_t i = 0; i < viewToModelMap_.size(); ++i) + modelToViewMap_[viewToModelMap_[i]] = i; +} + +- (void)reloadData { + // Store old view indices, and the model indices they map to. + NSIndexSet* viewSelection = [tableView_ selectedRowIndexes]; + std::vector<int> modelSelection; + for (NSUInteger i = [viewSelection lastIndex]; + i != NSNotFound; + i = [viewSelection indexLessThanIndex:i]) { + modelSelection.push_back(viewToModelMap_[i]); + } + + // Sort. + [self sortShuffleArray]; + + // Use the model indices to get the new view indices of the selection, and + // set selection to that. This assumes that no rows were added or removed + // (in that case, the selection is cleared before -reloadData is called). + if (modelSelection.size() > 0) + DCHECK_EQ([tableView_ numberOfRows], model_->ResourceCount()); + NSMutableIndexSet* indexSet = [NSMutableIndexSet indexSet]; + for (size_t i = 0; i < modelSelection.size(); ++i) + [indexSet addIndex:modelToViewMap_[modelSelection[i]]]; + [tableView_ selectRowIndexes:indexSet byExtendingSelection:NO]; + + [tableView_ reloadData]; + [self adjustSelectionAndEndProcessButton]; +} + +- (IBAction)statsLinkClicked:(id)sender { + TaskManager::GetInstance()->OpenAboutMemory(); +} + +- (IBAction)killSelectedProcesses:(id)sender { + NSIndexSet* selection = [tableView_ selectedRowIndexes]; + for (NSUInteger i = [selection lastIndex]; + i != NSNotFound; + i = [selection indexLessThanIndex:i]) { + taskManager_->KillProcess(viewToModelMap_[i]); + } +} + +- (void)selectDoubleClickedTab:(id)sender { + NSInteger row = [tableView_ clickedRow]; + if (row < 0) + return; // Happens e.g. if the table header is double-clicked. + taskManager_->ActivateProcess(viewToModelMap_[row]); +} + +- (NSTableView*)tableView { + return tableView_; +} + +- (void)awakeFromNib { + [self setUpTableColumns]; + [self setUpTableHeaderContextMenu]; + [self adjustSelectionAndEndProcessButton]; + + [tableView_ setDoubleAction:@selector(selectDoubleClickedTab:)]; + [tableView_ sizeToFit]; +} + +- (void)dealloc { + [tableView_ setDelegate:nil]; + [tableView_ setDataSource:nil]; + [super dealloc]; +} + +// Adds a column which has the given string id as title. |isVisible| specifies +// if the column is initially visible. +- (NSTableColumn*)addColumnWithId:(int)columnId visible:(BOOL)isVisible { + scoped_nsobject<NSTableColumn> column([[NSTableColumn alloc] + initWithIdentifier:[NSNumber numberWithInt:columnId]]); + + NSTextAlignment textAlignment = columnId == IDS_TASK_MANAGER_PAGE_COLUMN ? + NSLeftTextAlignment : NSRightTextAlignment; + + [[column.get() headerCell] + setStringValue:l10n_util::GetNSStringWithFixup(columnId)]; + [[column.get() headerCell] setAlignment:textAlignment]; + [[column.get() dataCell] setAlignment:textAlignment]; + + NSFont* font = [NSFont systemFontOfSize:[NSFont smallSystemFontSize]]; + [[column.get() dataCell] setFont:font]; + + [column.get() setHidden:!isVisible]; + [column.get() setEditable:NO]; + + // The page column should by default be sorted ascending. + BOOL ascending = columnId == IDS_TASK_MANAGER_PAGE_COLUMN; + + scoped_nsobject<NSSortDescriptor> sortDescriptor([[NSSortDescriptor alloc] + initWithKey:[NSString stringWithFormat:@"%d", columnId] + ascending:ascending]); + [column.get() setSortDescriptorPrototype:sortDescriptor.get()]; + + // Default values, only used in release builds if nobody notices the DCHECK + // during development when adding new columns. + int minWidth = 200, maxWidth = 400; + + size_t i; + for (i = 0; i < arraysize(columnWidths); ++i) { + if (columnWidths[i].columnId == columnId) { + minWidth = columnWidths[i].minWidth; + maxWidth = columnWidths[i].maxWidth; + if (maxWidth < 0) + maxWidth = 3 * minWidth / 2; // *1.5 for ints. + break; + } + } + DCHECK(i < arraysize(columnWidths)) << "Could not find " << columnId; + [column.get() setMinWidth:minWidth]; + [column.get() setMaxWidth:maxWidth]; + [column.get() setResizingMask:NSTableColumnAutoresizingMask | + NSTableColumnUserResizingMask]; + + [tableView_ addTableColumn:column.get()]; + return column.get(); // Now retained by |tableView_|. +} + +// Adds all the task manager's columns to the table. +- (void)setUpTableColumns { + for (NSTableColumn* column in [tableView_ tableColumns]) + [tableView_ removeTableColumn:column]; + NSTableColumn* nameColumn = [self addColumnWithId:IDS_TASK_MANAGER_PAGE_COLUMN + visible:YES]; + // |nameColumn| displays an icon for every row -- this is done by an + // NSButtonCell. + scoped_nsobject<NSButtonCell> nameCell( + [[NSButtonCell alloc] initTextCell:@""]); + [nameCell.get() setImagePosition:NSImageLeft]; + [nameCell.get() setButtonType:NSSwitchButton]; + [nameCell.get() setAlignment:[[nameColumn dataCell] alignment]]; + [nameCell.get() setFont:[[nameColumn dataCell] font]]; + [nameColumn setDataCell:nameCell.get()]; + + // Initially, sort on the tab name. + [tableView_ setSortDescriptors: + [NSArray arrayWithObject:[nameColumn sortDescriptorPrototype]]]; + + [self addColumnWithId:IDS_TASK_MANAGER_PHYSICAL_MEM_COLUMN visible:YES]; + [self addColumnWithId:IDS_TASK_MANAGER_SHARED_MEM_COLUMN visible:NO]; + [self addColumnWithId:IDS_TASK_MANAGER_PRIVATE_MEM_COLUMN visible:NO]; + [self addColumnWithId:IDS_TASK_MANAGER_CPU_COLUMN visible:YES]; + [self addColumnWithId:IDS_TASK_MANAGER_NET_COLUMN visible:YES]; + [self addColumnWithId:IDS_TASK_MANAGER_PROCESS_ID_COLUMN visible:NO]; + [self addColumnWithId:IDS_TASK_MANAGER_WEBCORE_IMAGE_CACHE_COLUMN + visible:NO]; + [self addColumnWithId:IDS_TASK_MANAGER_WEBCORE_SCRIPTS_CACHE_COLUMN + visible:NO]; + [self addColumnWithId:IDS_TASK_MANAGER_WEBCORE_CSS_CACHE_COLUMN visible:NO]; + [self addColumnWithId:IDS_TASK_MANAGER_SQLITE_MEMORY_USED_COLUMN visible:NO]; + [self addColumnWithId:IDS_TASK_MANAGER_JAVASCRIPT_MEMORY_ALLOCATED_COLUMN + visible:NO]; + [self addColumnWithId:IDS_TASK_MANAGER_GOATS_TELEPORTED_COLUMN visible:NO]; +} + +// Creates a context menu for the table header that allows the user to toggle +// which columns should be shown and which should be hidden (like e.g. +// Task Manager.app's table header context menu). +- (void)setUpTableHeaderContextMenu { + scoped_nsobject<NSMenu> contextMenu( + [[NSMenu alloc] initWithTitle:@"Task Manager context menu"]); + for (NSTableColumn* column in [tableView_ tableColumns]) { + NSMenuItem* item = [contextMenu.get() + addItemWithTitle:[[column headerCell] stringValue] + action:@selector(toggleColumn:) + keyEquivalent:@""]; + [item setTarget:self]; + [item setRepresentedObject:column]; + [item setState:[column isHidden] ? NSOffState : NSOnState]; + } + [[tableView_ headerView] setMenu:contextMenu.get()]; +} + +// Callback for the table header context menu. Toggles visibility of the table +// column associated with the clicked menu item. +- (void)toggleColumn:(id)item { + DCHECK([item isKindOfClass:[NSMenuItem class]]); + if (![item isKindOfClass:[NSMenuItem class]]) + return; + + NSTableColumn* column = [item representedObject]; + DCHECK(column); + NSInteger oldState = [item state]; + NSInteger newState = oldState == NSOnState ? NSOffState : NSOnState; + [column setHidden:newState == NSOffState]; + [item setState:newState]; + [tableView_ sizeToFit]; + [tableView_ setNeedsDisplay]; +} + +// This function appropriately sets the enabled states on the table's editing +// buttons. +- (void)adjustSelectionAndEndProcessButton { + bool selectionContainsBrowserProcess = false; + + // If a row is selected, make sure that all rows belonging to the same process + // are selected as well. Also, check if the selection contains the browser + // process. + NSIndexSet* selection = [tableView_ selectedRowIndexes]; + for (NSUInteger i = [selection lastIndex]; + i != NSNotFound; + i = [selection indexLessThanIndex:i]) { + int modelIndex = viewToModelMap_[i]; + if (taskManager_->IsBrowserProcess(modelIndex)) + selectionContainsBrowserProcess = true; + + std::pair<int, int> rangePair = + model_->GetGroupRangeForResource(modelIndex); + NSMutableIndexSet* indexSet = [NSMutableIndexSet indexSet]; + for (int j = 0; j < rangePair.second; ++j) + [indexSet addIndex:modelToViewMap_[rangePair.first + j]]; + [tableView_ selectRowIndexes:indexSet byExtendingSelection:YES]; + } + + bool enabled = [selection count] > 0 && !selectionContainsBrowserProcess; + [endProcessButton_ setEnabled:enabled]; +} + +- (void)deselectRows { + [tableView_ deselectAll:self]; +} + +// Table view delegate method. +- (void)tableViewSelectionIsChanging:(NSNotification*)aNotification { + [self adjustSelectionAndEndProcessButton]; +} + +- (void)windowWillClose:(NSNotification*)notification { + if (taskManagerObserver_) { + taskManagerObserver_->WindowWasClosed(); + taskManagerObserver_ = nil; + } + [self autorelease]; +} + +@end + +@implementation TaskManagerWindowController (NSTableDataSource) + +- (NSInteger)numberOfRowsInTableView:(NSTableView*)tableView { + DCHECK(tableView == tableView_ || tableView_ == nil); + return model_->ResourceCount(); +} + +- (NSString*)modelTextForRow:(int)row column:(int)columnId { + DCHECK_LT(static_cast<size_t>(row), viewToModelMap_.size()); + row = viewToModelMap_[row]; + switch (columnId) { + case IDS_TASK_MANAGER_PAGE_COLUMN: // Process + return base::SysUTF16ToNSString(model_->GetResourceTitle(row)); + + case IDS_TASK_MANAGER_PRIVATE_MEM_COLUMN: // Memory + if (!model_->IsResourceFirstInGroup(row)) + return @""; + return base::SysUTF16ToNSString(model_->GetResourcePrivateMemory(row)); + + case IDS_TASK_MANAGER_SHARED_MEM_COLUMN: // Memory + if (!model_->IsResourceFirstInGroup(row)) + return @""; + return base::SysUTF16ToNSString(model_->GetResourceSharedMemory(row)); + + case IDS_TASK_MANAGER_PHYSICAL_MEM_COLUMN: // Memory + if (!model_->IsResourceFirstInGroup(row)) + return @""; + return base::SysUTF16ToNSString(model_->GetResourcePhysicalMemory(row)); + + case IDS_TASK_MANAGER_CPU_COLUMN: // CPU + if (!model_->IsResourceFirstInGroup(row)) + return @""; + return base::SysUTF16ToNSString(model_->GetResourceCPUUsage(row)); + + case IDS_TASK_MANAGER_NET_COLUMN: // Net + return base::SysUTF16ToNSString(model_->GetResourceNetworkUsage(row)); + + case IDS_TASK_MANAGER_PROCESS_ID_COLUMN: // Process ID + if (!model_->IsResourceFirstInGroup(row)) + return @""; + return base::SysUTF16ToNSString(model_->GetResourceProcessId(row)); + + case IDS_TASK_MANAGER_WEBCORE_IMAGE_CACHE_COLUMN: // WebCore image cache + if (!model_->IsResourceFirstInGroup(row)) + return @""; + return base::SysUTF16ToNSString( + model_->GetResourceWebCoreImageCacheSize(row)); + + case IDS_TASK_MANAGER_WEBCORE_SCRIPTS_CACHE_COLUMN: // WebCore script cache + if (!model_->IsResourceFirstInGroup(row)) + return @""; + return base::SysUTF16ToNSString( + model_->GetResourceWebCoreScriptsCacheSize(row)); + + case IDS_TASK_MANAGER_WEBCORE_CSS_CACHE_COLUMN: // WebCore CSS cache + if (!model_->IsResourceFirstInGroup(row)) + return @""; + return base::SysUTF16ToNSString( + model_->GetResourceWebCoreCSSCacheSize(row)); + + case IDS_TASK_MANAGER_SQLITE_MEMORY_USED_COLUMN: + if (!model_->IsResourceFirstInGroup(row)) + return @""; + return base::SysUTF16ToNSString( + model_->GetResourceSqliteMemoryUsed(row)); + + case IDS_TASK_MANAGER_JAVASCRIPT_MEMORY_ALLOCATED_COLUMN: + if (!model_->IsResourceFirstInGroup(row)) + return @""; + return base::SysUTF16ToNSString( + model_->GetResourceV8MemoryAllocatedSize(row)); + + case IDS_TASK_MANAGER_GOATS_TELEPORTED_COLUMN: // Goats Teleported! + return base::SysUTF16ToNSString(model_->GetResourceGoatsTeleported(row)); + + default: + NOTREACHED(); + return @""; + } +} + +- (id)tableView:(NSTableView*)tableView + objectValueForTableColumn:(NSTableColumn*)tableColumn + row:(NSInteger)rowIndex { + // NSButtonCells expect an on/off state as objectValue. Their title is set + // in |tableView:dataCellForTableColumn:row:| below. + if ([[tableColumn identifier] intValue] == IDS_TASK_MANAGER_PAGE_COLUMN) { + return [NSNumber numberWithInt:NSOffState]; + } + + return [self modelTextForRow:rowIndex + column:[[tableColumn identifier] intValue]]; +} + +- (NSCell*)tableView:(NSTableView*)tableView + dataCellForTableColumn:(NSTableColumn*)tableColumn + row:(NSInteger)rowIndex { + NSCell* cell = [tableColumn dataCellForRow:rowIndex]; + + // Set the favicon and title for the task in the name column. + if ([[tableColumn identifier] intValue] == IDS_TASK_MANAGER_PAGE_COLUMN) { + DCHECK([cell isKindOfClass:[NSButtonCell class]]); + NSButtonCell* buttonCell = static_cast<NSButtonCell*>(cell); + NSString* title = [self modelTextForRow:rowIndex + column:[[tableColumn identifier] intValue]]; + [buttonCell setTitle:title]; + [buttonCell setImage: + taskManagerObserver_->GetImageForRow(viewToModelMap_[rowIndex])]; + [buttonCell setRefusesFirstResponder:YES]; // Don't push in like a button. + [buttonCell setHighlightsBy:NSNoCellMask]; + } + + return cell; +} + +- (void) tableView:(NSTableView*)tableView + sortDescriptorsDidChange:(NSArray*)oldDescriptors { + NSArray* newDescriptors = [tableView sortDescriptors]; + if ([newDescriptors count] < 1) + return; + + currentSortDescriptor_.reset([[newDescriptors objectAtIndex:0] retain]); + [self reloadData]; // Sorts. +} + +@end + +//////////////////////////////////////////////////////////////////////////////// +// TaskManagerMac implementation: + +TaskManagerMac::TaskManagerMac(TaskManager* task_manager) + : task_manager_(task_manager), + model_(task_manager->model()), + icon_cache_(this) { + window_controller_ = + [[TaskManagerWindowController alloc] initWithTaskManagerObserver:this]; + model_->AddObserver(this); +} + +// static +TaskManagerMac* TaskManagerMac::instance_ = NULL; + +TaskManagerMac::~TaskManagerMac() { + if (this == instance_) { + // Do not do this when running in unit tests: |StartUpdating()| never got + // called in that case. + task_manager_->OnWindowClosed(); + } + model_->RemoveObserver(this); +} + +//////////////////////////////////////////////////////////////////////////////// +// TaskManagerMac, TaskManagerModelObserver implementation: + +void TaskManagerMac::OnModelChanged() { + icon_cache_.OnModelChanged(); + [window_controller_ deselectRows]; + [window_controller_ reloadData]; +} + +void TaskManagerMac::OnItemsChanged(int start, int length) { + icon_cache_.OnItemsChanged(start, length); + [window_controller_ reloadData]; +} + +void TaskManagerMac::OnItemsAdded(int start, int length) { + icon_cache_.OnItemsAdded(start, length); + [window_controller_ deselectRows]; + [window_controller_ reloadData]; +} + +void TaskManagerMac::OnItemsRemoved(int start, int length) { + icon_cache_.OnItemsRemoved(start, length); + [window_controller_ deselectRows]; + [window_controller_ reloadData]; +} + +NSImage* TaskManagerMac::GetImageForRow(int row) { + return icon_cache_.GetImageForRow(row); +} + +//////////////////////////////////////////////////////////////////////////////// +// TaskManagerMac, public: + +void TaskManagerMac::WindowWasClosed() { + delete this; + instance_ = NULL; +} + +int TaskManagerMac::RowCount() const { + return model_->ResourceCount(); +} + +SkBitmap TaskManagerMac::GetIcon(int r) const { + return model_->GetResourceIcon(r); +} + +// static +void TaskManagerMac::Show() { + if (instance_) { + // If there's a Task manager window open already, just activate it. + [[instance_->window_controller_ window] + makeKeyAndOrderFront:instance_->window_controller_]; + } else { + instance_ = new TaskManagerMac(TaskManager::GetInstance()); + instance_->model_->StartUpdating(); + } +} diff --git a/chrome/browser/ui/cocoa/task_manager_mac_unittest.mm b/chrome/browser/ui/cocoa/task_manager_mac_unittest.mm new file mode 100644 index 0000000..2a7aae3 --- /dev/null +++ b/chrome/browser/ui/cocoa/task_manager_mac_unittest.mm @@ -0,0 +1,115 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#include "base/utf_string_conversions.h" +#import "chrome/browser/ui/cocoa/task_manager_mac.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "grit/generated_resources.h" +#include "testing/gtest/include/gtest/gtest.h" +#import "testing/gtest_mac.h" +#include "testing/platform_test.h" +#include "third_party/skia/include/core/SkBitmap.h" + +namespace { + +class TestResource : public TaskManager::Resource { + public: + TestResource(const string16& title, pid_t pid) : title_(title), pid_(pid) {} + virtual std::wstring GetTitle() const { return UTF16ToWide(title_); } + virtual SkBitmap GetIcon() const { return SkBitmap(); } + virtual base::ProcessHandle GetProcess() const { return pid_; } + virtual Type GetType() const { return RENDERER; } + virtual bool SupportNetworkUsage() const { return false; } + virtual void SetSupportNetworkUsage() { NOTREACHED(); } + virtual void Refresh() {} + string16 title_; + pid_t pid_; +}; + +} // namespace + +class TaskManagerWindowControllerTest : public CocoaTest { +}; + +// Test creation, to ensure nothing leaks or crashes. +TEST_F(TaskManagerWindowControllerTest, Init) { + TaskManager task_manager; + TaskManagerMac* bridge(new TaskManagerMac(&task_manager)); + TaskManagerWindowController* controller = bridge->cocoa_controller(); + + // Releases the controller, which in turn deletes |bridge|. + [controller close]; +} + +TEST_F(TaskManagerWindowControllerTest, Sort) { + TaskManager task_manager; + + TestResource resource1(UTF8ToUTF16("zzz"), 1); + TestResource resource2(UTF8ToUTF16("zzb"), 2); + TestResource resource3(UTF8ToUTF16("zza"), 2); + + task_manager.AddResource(&resource1); + task_manager.AddResource(&resource2); + task_manager.AddResource(&resource3); // Will be in the same group as 2. + + TaskManagerMac* bridge(new TaskManagerMac(&task_manager)); + TaskManagerWindowController* controller = bridge->cocoa_controller(); + NSTableView* table = [controller tableView]; + ASSERT_EQ(3, [controller numberOfRowsInTableView:table]); + + // Test that table is sorted on title. + NSTableColumn* title_column = [table tableColumnWithIdentifier: + [NSNumber numberWithInt:IDS_TASK_MANAGER_PAGE_COLUMN]]; + NSCell* cell; + cell = [controller tableView:table dataCellForTableColumn:title_column row:0]; + EXPECT_NSEQ(@"zzb", [cell title]); + cell = [controller tableView:table dataCellForTableColumn:title_column row:1]; + EXPECT_NSEQ(@"zza", [cell title]); + cell = [controller tableView:table dataCellForTableColumn:title_column row:2]; + EXPECT_NSEQ(@"zzz", [cell title]); + + // Releases the controller, which in turn deletes |bridge|. + [controller close]; + + task_manager.RemoveResource(&resource1); + task_manager.RemoveResource(&resource2); + task_manager.RemoveResource(&resource3); +} + +TEST_F(TaskManagerWindowControllerTest, SelectionAdaptsToSorting) { + TaskManager task_manager; + + TestResource resource1(UTF8ToUTF16("yyy"), 1); + TestResource resource2(UTF8ToUTF16("aaa"), 2); + + task_manager.AddResource(&resource1); + task_manager.AddResource(&resource2); + + TaskManagerMac* bridge(new TaskManagerMac(&task_manager)); + TaskManagerWindowController* controller = bridge->cocoa_controller(); + NSTableView* table = [controller tableView]; + ASSERT_EQ(2, [controller numberOfRowsInTableView:table]); + + // Select row 0 in the table (corresponds to row 1 in the model). + [table selectRowIndexes:[NSIndexSet indexSetWithIndex:0] + byExtendingSelection:NO]; + + // Change the name of resource2 so that it becomes row 1 in the table. + resource2.title_ = UTF8ToUTF16("zzz"); + bridge->OnItemsChanged(1, 1); + + // Check that the selection has moved to row 1. + NSIndexSet* selection = [table selectedRowIndexes]; + ASSERT_EQ(1u, [selection count]); + EXPECT_EQ(1u, [selection firstIndex]); + + // Releases the controller, which in turn deletes |bridge|. + [controller close]; + + task_manager.RemoveResource(&resource1); + task_manager.RemoveResource(&resource2); +} diff --git a/chrome/browser/ui/cocoa/test_event_utils.h b/chrome/browser/ui/cocoa/test_event_utils.h new file mode 100644 index 0000000..43bc78f --- /dev/null +++ b/chrome/browser/ui/cocoa/test_event_utils.h @@ -0,0 +1,48 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_TEST_EVENT_UTILS_H_ +#define CHROME_BROWSER_UI_COCOA_TEST_EVENT_UTILS_H_ +#pragma once + +#include <utility> + +#import <objc/objc-class.h> + +#include "base/basictypes.h" + +// Within a given scope, replace the selector |selector| on |target| with that +// from |source|. +class ScopedClassSwizzler { + public: + ScopedClassSwizzler(Class target, Class source, SEL selector); + ~ScopedClassSwizzler(); + + private: + Method old_selector_impl_; + Method new_selector_impl_; + + DISALLOW_COPY_AND_ASSIGN(ScopedClassSwizzler); +}; + +namespace test_event_utils { + +// Create synthetic mouse events for testing. Currently these are very +// basic, flesh out as needed. Points are all in window coordinates; +// where the window is not specified, coordinate system is undefined +// (but will be repeated when the event is queried). +NSEvent* MakeMouseEvent(NSEventType type, NSUInteger modifiers); +NSEvent* MouseEventAtPoint(NSPoint point, NSEventType type, + NSUInteger modifiers); +NSEvent* LeftMouseDownAtPoint(NSPoint point); +NSEvent* LeftMouseDownAtPointInWindow(NSPoint point, NSWindow* window); + +// Return a mouse down and an up event with the given |clickCount| at +// |view|'s midpoint. +std::pair<NSEvent*, NSEvent*> MouseClickInView(NSView* view, + NSUInteger clickCount); + +} // namespace test_event_utils + +#endif // CHROME_BROWSER_UI_COCOA_TEST_EVENT_UTILS_H_ diff --git a/chrome/browser/ui/cocoa/test_event_utils.mm b/chrome/browser/ui/cocoa/test_event_utils.mm new file mode 100644 index 0000000..9675db6 --- /dev/null +++ b/chrome/browser/ui/cocoa/test_event_utils.mm @@ -0,0 +1,86 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "chrome/browser/ui/cocoa/test_event_utils.h" + +ScopedClassSwizzler::ScopedClassSwizzler(Class target, Class source, + SEL selector) { + old_selector_impl_ = class_getInstanceMethod(target, selector); + new_selector_impl_ = class_getInstanceMethod(source, selector); + method_exchangeImplementations(old_selector_impl_, new_selector_impl_); +} + +ScopedClassSwizzler::~ScopedClassSwizzler() { + method_exchangeImplementations(old_selector_impl_, new_selector_impl_); +} + +namespace test_event_utils { + +NSEvent* MouseEventAtPoint(NSPoint point, NSEventType type, + NSUInteger modifiers) { + if (type == NSOtherMouseUp) { + // To synthesize middle clicks we need to create a CGEvent with the + // "center" button flags so that our resulting NSEvent will have the + // appropriate buttonNumber field. NSEvent provides no way to create a + // mouse event with a buttonNumber directly. + CGPoint location = { point.x, point.y }; + CGEventRef cg_event = CGEventCreateMouseEvent(NULL, kCGEventOtherMouseUp, + location, + kCGMouseButtonCenter); + NSEvent* event = [NSEvent eventWithCGEvent:cg_event]; + CFRelease(cg_event); + return event; + } + return [NSEvent mouseEventWithType:type + location:point + modifierFlags:modifiers + timestamp:0 + windowNumber:0 + context:nil + eventNumber:0 + clickCount:1 + pressure:1.0]; +} + +NSEvent* MakeMouseEvent(NSEventType type, NSUInteger modifiers) { + return MouseEventAtPoint(NSMakePoint(0, 0), type, modifiers); +} + +static NSEvent* MouseEventAtPointInWindow(NSPoint point, + NSEventType type, + NSWindow* window, + NSUInteger clickCount) { + return [NSEvent mouseEventWithType:type + location:point + modifierFlags:0 + timestamp:0 + windowNumber:[window windowNumber] + context:nil + eventNumber:0 + clickCount:clickCount + pressure:1.0]; +} + +NSEvent* LeftMouseDownAtPointInWindow(NSPoint point, NSWindow* window) { + return MouseEventAtPointInWindow(point, NSLeftMouseDown, window, 1); +} + +NSEvent* LeftMouseDownAtPoint(NSPoint point) { + return LeftMouseDownAtPointInWindow(point, nil); +} + +std::pair<NSEvent*,NSEvent*> MouseClickInView(NSView* view, + NSUInteger clickCount) { + const NSRect bounds = [view convertRect:[view bounds] toView:nil]; + const NSPoint mid_point = NSMakePoint(NSMidX(bounds), NSMidY(bounds)); + NSEvent* down = MouseEventAtPointInWindow(mid_point, NSLeftMouseDown, + [view window], clickCount); + NSEvent* up = MouseEventAtPointInWindow(mid_point, NSLeftMouseUp, + [view window], clickCount); + return std::make_pair(down, up); +} + +} // namespace test_event_utils diff --git a/chrome/browser/ui/cocoa/theme_install_bubble_view.h b/chrome/browser/ui/cocoa/theme_install_bubble_view.h new file mode 100644 index 0000000..f8208df --- /dev/null +++ b/chrome/browser/ui/cocoa/theme_install_bubble_view.h @@ -0,0 +1,57 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/common/notification_observer.h" +#include "chrome/common/notification_registrar.h" +#include "chrome/common/notification_service.h" + +@class NSWindow; +@class ThemeInstallBubbleViewCocoa; + +// ThemeInstallBubbleView is a view that provides a "Loading..." bubble in the +// center of a browser window for use when an extension or theme is loaded. +// (The Browser class only calls it to install itself into the currently active +// browser window.) If an extension is being applied, the bubble goes away +// immediately. If a theme is being applied, it disappears when the theme has +// been loaded. The purpose of this bubble is to warn the user that the browser +// may be unresponsive while the theme is being installed. +// +// Edge case: note that if one installs a theme in one window and then switches +// rapidly to another window to install a theme there as well (in the short time +// between install begin and theme caching seizing the UI thread), the loading +// bubble will only appear over the first window, as there is only ever one +// instance of the bubble. +class ThemeInstallBubbleView : public NotificationObserver { + public: + ~ThemeInstallBubbleView(); + + // NotificationObserver + virtual void Observe(NotificationType type, + const NotificationSource& source, + const NotificationDetails& details); + + // Show the loading bubble. + static void Show(NSWindow* window); + + private: + explicit ThemeInstallBubbleView(NSWindow* window); + + // The one copy of the loading bubble. + static ThemeInstallBubbleView* view_; + + // A scoped container for notification registries. + NotificationRegistrar registrar_; + + // Shut down the popup and remove our notifications. + void Close(); + + // The actual Cocoa view implementing the bubble. + ThemeInstallBubbleViewCocoa* cocoa_view_; + + // Multiple loads can be started at once. Only show one bubble, and keep + // track of number of loads happening. Close bubble when num_loads < 1. + int num_loads_extant_; + + DISALLOW_COPY_AND_ASSIGN(ThemeInstallBubbleView); +}; diff --git a/chrome/browser/ui/cocoa/theme_install_bubble_view.mm b/chrome/browser/ui/cocoa/theme_install_bubble_view.mm new file mode 100644 index 0000000..31c5e81 --- /dev/null +++ b/chrome/browser/ui/cocoa/theme_install_bubble_view.mm @@ -0,0 +1,186 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#import "chrome/browser/ui/cocoa/theme_install_bubble_view.h" + +#include "app/l10n_util_mac.h" +#include "base/scoped_nsobject.h" +#include "grit/generated_resources.h" + +namespace { + +// The alpha of the bubble. +static const float kBubbleAlpha = 0.75; + +// The roundedness of the edges of our bubble. +static const int kBubbleCornerRadius = 4; + +// Padding around text in popup box. +static const int kTextHorizPadding = 90; +static const int kTextVertPadding = 45; + +// Point size of the text in the box. +static const int kLoadingTextSize = 24; + +} + +// static +ThemeInstallBubbleView* ThemeInstallBubbleView::view_ = NULL; + +// The Cocoa view to draw a gray rounded rect with "Loading..." in it. +@interface ThemeInstallBubbleViewCocoa : NSView { + @private + scoped_nsobject<NSAttributedString> message_; + + NSRect grayRect_; + NSRect textRect_; +} + +- (id)init; + +// The size of the gray rect that will be drawn. +- (NSSize)preferredSize; +// Forces size calculations of where everything will be drawn. +- (void)layout; + +@end + +ThemeInstallBubbleView::ThemeInstallBubbleView(NSWindow* window) + : cocoa_view_([[ThemeInstallBubbleViewCocoa alloc] init]), + num_loads_extant_(1) { + DCHECK(window); + + NSView* parent_view = [window contentView]; + NSRect parent_bounds = [parent_view bounds]; + if (parent_bounds.size.height < [cocoa_view_ preferredSize].height) + Close(); + + // Close when theme has been installed. + registrar_.Add( + this, + NotificationType::BROWSER_THEME_CHANGED, + NotificationService::AllSources()); + + // Close when we are installing an extension, not a theme. + registrar_.Add( + this, + NotificationType::NO_THEME_DETECTED, + NotificationService::AllSources()); + registrar_.Add( + this, + NotificationType::EXTENSION_INSTALLED, + NotificationService::AllSources()); + registrar_.Add( + this, + NotificationType::EXTENSION_INSTALL_ERROR, + NotificationService::AllSources()); + + // Don't let the bubble overlap the confirm dialog. + registrar_.Add( + this, + NotificationType::EXTENSION_WILL_SHOW_CONFIRM_DIALOG, + NotificationService::AllSources()); + + // Add the view. + [cocoa_view_ setFrame:parent_bounds]; + [cocoa_view_ setAutoresizingMask:NSViewWidthSizable | NSViewHeightSizable]; + [parent_view addSubview:cocoa_view_ + positioned:NSWindowAbove + relativeTo:nil]; + [cocoa_view_ layout]; +} + +ThemeInstallBubbleView::~ThemeInstallBubbleView() { + // Need to delete self; the real work happens in Close(). +} + +void ThemeInstallBubbleView::Close() { + --num_loads_extant_; + if (num_loads_extant_ < 1) { + registrar_.RemoveAll(); + if (cocoa_view_ && [cocoa_view_ superview]) { + [cocoa_view_ removeFromSuperview]; + [cocoa_view_ release]; + } + view_ = NULL; + delete this; + // this is deleted; nothing more! + } +} + +void ThemeInstallBubbleView::Observe(NotificationType type, + const NotificationSource& source, + const NotificationDetails& details) { + Close(); +} + +// static +void ThemeInstallBubbleView::Show(NSWindow* window) { + if (view_) + ++view_->num_loads_extant_; + else + view_ = new ThemeInstallBubbleView(window); +} + +@implementation ThemeInstallBubbleViewCocoa + +- (id)init { + self = [super initWithFrame:NSZeroRect]; + if (self) { + NSString* loadingString = + l10n_util::GetNSStringWithFixup(IDS_THEME_LOADING_TITLE); + NSFont* loadingFont = [NSFont systemFontOfSize:kLoadingTextSize]; + NSColor* textColor = [NSColor whiteColor]; + NSDictionary* loadingAttrs = [NSDictionary dictionaryWithObjectsAndKeys: + loadingFont, NSFontAttributeName, + textColor, NSForegroundColorAttributeName, + nil]; + message_.reset([[NSAttributedString alloc] initWithString:loadingString + attributes:loadingAttrs]); + + // TODO(avi): find a white-on-black spinner + } + return self; +} + +- (NSSize)preferredSize { + NSSize size = [message_.get() size]; + size.width += kTextHorizPadding; + size.height += kTextVertPadding; + return size; +} + +// Update the layout to keep the view centered when the window is resized. +- (void)resizeWithOldSuperviewSize:(NSSize)oldBoundsSize { + [super resizeWithOldSuperviewSize:oldBoundsSize]; + [self layout]; +} + +- (void)layout { + NSRect bounds = [self bounds]; + + grayRect_.size = [self preferredSize]; + grayRect_.origin.x = (bounds.size.width - grayRect_.size.width) / 2; + grayRect_.origin.y = bounds.size.height / 2; + + textRect_.size = [message_.get() size]; + textRect_.origin.x = (bounds.size.width - [message_.get() size].width) / 2; + textRect_.origin.y = (bounds.size.height + kTextVertPadding) / 2; +} + +- (void)drawRect:(NSRect)dirtyRect { + [[NSColor clearColor] set]; + NSRectFillUsingOperation([self bounds], NSCompositeSourceOver); + + [[[NSColor blackColor] colorWithAlphaComponent:kBubbleAlpha] set]; + [[NSBezierPath bezierPathWithRoundedRect:grayRect_ + xRadius:kBubbleCornerRadius + yRadius:kBubbleCornerRadius] fill]; + + [message_.get() drawInRect:textRect_]; +} + +@end diff --git a/chrome/browser/ui/cocoa/themed_window.h b/chrome/browser/ui/cocoa/themed_window.h new file mode 100644 index 0000000..d35bfa3 --- /dev/null +++ b/chrome/browser/ui/cocoa/themed_window.h @@ -0,0 +1,30 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_THEMED_WINDOW_H_ +#define CHROME_BROWSER_UI_COCOA_THEMED_WINDOW_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +class ThemeProvider; + +// Bit flags; mix-and-match as necessary. +enum { + THEMED_NORMAL = 0, + THEMED_INCOGNITO = 1 << 0, + THEMED_POPUP = 1 << 1, + THEMED_DEVTOOLS = 1 << 2 +}; +typedef NSUInteger ThemedWindowStyle; + +// Implemented by windows that support theming. + +@interface NSWindow (ThemeProvider) +- (ThemeProvider*)themeProvider; +- (ThemedWindowStyle)themedWindowStyle; +- (NSPoint)themePatternPhase; +@end + +#endif // CHROME_BROWSER_UI_COCOA_THEMED_WINDOW_H_ diff --git a/chrome/browser/ui/cocoa/themed_window.mm b/chrome/browser/ui/cocoa/themed_window.mm new file mode 100644 index 0000000..911bf8a --- /dev/null +++ b/chrome/browser/ui/cocoa/themed_window.mm @@ -0,0 +1,23 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/themed_window.h" + +// Default implementations; used mostly for tests so that the hosting windows +// don't needs to know about the theming machinery. +@implementation NSWindow (ThemeProvider) + +- (ThemeProvider*)themeProvider { + return NULL; +} + +- (ThemedWindowStyle)themedWindowStyle { + return THEMED_NORMAL; +} + +- (NSPoint)themePatternPhase { + return NSZeroPoint; +} + +@end diff --git a/chrome/browser/ui/cocoa/throbber_view.h b/chrome/browser/ui/cocoa/throbber_view.h new file mode 100644 index 0000000..a680222 --- /dev/null +++ b/chrome/browser/ui/cocoa/throbber_view.h @@ -0,0 +1,42 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_THROBBER_VIEW_H_ +#define CHROME_BROWSER_UI_COCOA_THROBBER_VIEW_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" + +@protocol ThrobberDataDelegate; + +// A class that knows how to draw an animated state to indicate progress. +// Creating the class starts the animation, destroying it stops it. There are +// two types: +// +// - Filmstrip: Draws via a sequence of frames in an image. There is no state +// where the class is frozen on an image and not animating. The image needs to +// be made of squares such that the height divides evenly into the width. +// +// - Toast: Draws an image animating down to the bottom and then another image +// animating up from the bottom. Stops once the animation is complete. + +@interface ThrobberView : NSView { + @private + id<ThrobberDataDelegate> dataDelegate_; +} + +// Creates a filmstrip view with |frame| and image |image|. ++ (id)filmstripThrobberViewWithFrame:(NSRect)frame + image:(NSImage*)image; + +// Creates a toast view with |frame| and specified images. ++ (id)toastThrobberViewWithFrame:(NSRect)frame + beforeImage:(NSImage*)beforeImage + afterImage:(NSImage*)afterImage; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_THROBBER_VIEW_H_ diff --git a/chrome/browser/ui/cocoa/throbber_view.mm b/chrome/browser/ui/cocoa/throbber_view.mm new file mode 100644 index 0000000..c0e5dd3 --- /dev/null +++ b/chrome/browser/ui/cocoa/throbber_view.mm @@ -0,0 +1,372 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/throbber_view.h" + +#include <set> + +#include "base/logging.h" + +static const float kAnimationIntervalSeconds = 0.03; // 30ms, same as windows + +@interface ThrobberView(PrivateMethods) +- (id)initWithFrame:(NSRect)frame delegate:(id<ThrobberDataDelegate>)delegate; +- (void)maintainTimer; +- (void)animate; +@end + +@protocol ThrobberDataDelegate <NSObject> +// Is the current frame the last frame of the animation? +- (BOOL)animationIsComplete; + +// Draw the current frame into the current graphics context. +- (void)drawFrameInRect:(NSRect)rect; + +// Update the frame counter. +- (void)advanceFrame; +@end + +@interface ThrobberFilmstripDelegate : NSObject + <ThrobberDataDelegate> { + scoped_nsobject<NSImage> image_; + unsigned int numFrames_; // Number of frames in this animation. + unsigned int animationFrame_; // Current frame of the animation, + // [0..numFrames_) +} + +- (id)initWithImage:(NSImage*)image; + +@end + +@implementation ThrobberFilmstripDelegate + +- (id)initWithImage:(NSImage*)image { + if ((self = [super init])) { + // Reset the animation counter so there's no chance we are off the end. + animationFrame_ = 0; + + // Ensure that the height divides evenly into the width. Cache the + // number of frames in the animation for later. + NSSize imageSize = [image size]; + DCHECK(imageSize.height && imageSize.width); + if (!imageSize.height) + return nil; + DCHECK((int)imageSize.width % (int)imageSize.height == 0); + numFrames_ = (int)imageSize.width / (int)imageSize.height; + DCHECK(numFrames_); + image_.reset([image retain]); + } + return self; +} + +- (BOOL)animationIsComplete { + return NO; +} + +- (void)drawFrameInRect:(NSRect)rect { + float imageDimension = [image_ size].height; + float xOffset = animationFrame_ * imageDimension; + NSRect sourceImageRect = + NSMakeRect(xOffset, 0, imageDimension, imageDimension); + [image_ drawInRect:rect + fromRect:sourceImageRect + operation:NSCompositeSourceOver + fraction:1.0]; +} + +- (void)advanceFrame { + animationFrame_ = ++animationFrame_ % numFrames_; +} + +@end + +@interface ThrobberToastDelegate : NSObject + <ThrobberDataDelegate> { + scoped_nsobject<NSImage> image1_; + scoped_nsobject<NSImage> image2_; + NSSize image1Size_; + NSSize image2Size_; + int animationFrame_; // Current frame of the animation, +} + +- (id)initWithImage1:(NSImage*)image1 image2:(NSImage*)image2; + +@end + +@implementation ThrobberToastDelegate + +- (id)initWithImage1:(NSImage*)image1 image2:(NSImage*)image2 { + if ((self = [super init])) { + image1_.reset([image1 retain]); + image2_.reset([image2 retain]); + image1Size_ = [image1 size]; + image2Size_ = [image2 size]; + animationFrame_ = 0; + } + return self; +} + +- (BOOL)animationIsComplete { + if (animationFrame_ >= image1Size_.height + image2Size_.height) + return YES; + + return NO; +} + +// From [0..image1Height) we draw image1, at image1Height we draw nothing, and +// from [image1Height+1..image1Hight+image2Height] we draw the second image. +- (void)drawFrameInRect:(NSRect)rect { + NSImage* image = nil; + NSSize srcSize; + NSRect destRect; + + if (animationFrame_ < image1Size_.height) { + image = image1_.get(); + srcSize = image1Size_; + destRect = NSMakeRect(0, -animationFrame_, + image1Size_.width, image1Size_.height); + } else if (animationFrame_ == image1Size_.height) { + // nothing; intermediate blank frame + } else { + image = image2_.get(); + srcSize = image2Size_; + destRect = NSMakeRect(0, animationFrame_ - + (image1Size_.height + image2Size_.height), + image2Size_.width, image2Size_.height); + } + + if (image) { + NSRect sourceImageRect = + NSMakeRect(0, 0, srcSize.width, srcSize.height); + [image drawInRect:destRect + fromRect:sourceImageRect + operation:NSCompositeSourceOver + fraction:1.0]; + } +} + +- (void)advanceFrame { + ++animationFrame_; +} + +@end + +typedef std::set<ThrobberView*> ThrobberSet; + +// ThrobberTimer manages the animation of a set of ThrobberViews. It allows +// a single timer instance to be shared among as many ThrobberViews as needed. +@interface ThrobberTimer : NSObject { + @private + // A set of weak references to each ThrobberView that should be notified + // whenever the timer fires. + ThrobberSet throbbers_; + + // Weak reference to the timer that calls back to this object. The timer + // retains this object. + NSTimer* timer_; + + // Whether the timer is actively running. To avoid timer construction + // and destruction overhead, the timer is not invalidated when it is not + // needed, but its next-fire date is set to [NSDate distantFuture]. + // It is not possible to determine whether the timer has been suspended by + // comparing its fireDate to [NSDate distantFuture], though, so a separate + // variable is used to track this state. + BOOL timerRunning_; + + // The thread that created this object. Used to validate that ThrobberViews + // are only added and removed on the same thread that the fire action will + // be performed on. + NSThread* validThread_; +} + +// Returns a shared ThrobberTimer. Everyone is expected to use the same +// instance. ++ (ThrobberTimer*)sharedThrobberTimer; + +// Invalidates the timer, which will cause it to remove itself from the run +// loop. This causes the timer to be released, and it should then release +// this object. +- (void)invalidate; + +// Adds or removes ThrobberView objects from the throbbers_ set. +- (void)addThrobber:(ThrobberView*)throbber; +- (void)removeThrobber:(ThrobberView*)throbber; +@end + +@interface ThrobberTimer(PrivateMethods) +// Starts or stops the timer as needed as ThrobberViews are added and removed +// from the throbbers_ set. +- (void)maintainTimer; + +// Calls animate on each ThrobberView in the throbbers_ set. +- (void)fire:(NSTimer*)timer; +@end + +@implementation ThrobberTimer +- (id)init { + if ((self = [super init])) { + // Start out with a timer that fires at the appropriate interval, but + // prevent it from firing by setting its next-fire date to the distant + // future. Once a ThrobberView is added, the timer will be allowed to + // start firing. + timer_ = [NSTimer scheduledTimerWithTimeInterval:kAnimationIntervalSeconds + target:self + selector:@selector(fire:) + userInfo:nil + repeats:YES]; + [timer_ setFireDate:[NSDate distantFuture]]; + timerRunning_ = NO; + + validThread_ = [NSThread currentThread]; + } + return self; +} + ++ (ThrobberTimer*)sharedThrobberTimer { + // Leaked. That's OK, it's scoped to the lifetime of the application. + static ThrobberTimer* sharedInstance = [[ThrobberTimer alloc] init]; + return sharedInstance; +} + +- (void)invalidate { + [timer_ invalidate]; +} + +- (void)addThrobber:(ThrobberView*)throbber { + DCHECK([NSThread currentThread] == validThread_); + throbbers_.insert(throbber); + [self maintainTimer]; +} + +- (void)removeThrobber:(ThrobberView*)throbber { + DCHECK([NSThread currentThread] == validThread_); + throbbers_.erase(throbber); + [self maintainTimer]; +} + +- (void)maintainTimer { + BOOL oldRunning = timerRunning_; + BOOL newRunning = throbbers_.empty() ? NO : YES; + + if (oldRunning == newRunning) + return; + + // To start the timer, set its next-fire date to an appropriate interval from + // now. To suspend the timer, set its next-fire date to a preposterous time + // in the future. + NSDate* fireDate; + if (newRunning) + fireDate = [NSDate dateWithTimeIntervalSinceNow:kAnimationIntervalSeconds]; + else + fireDate = [NSDate distantFuture]; + + [timer_ setFireDate:fireDate]; + timerRunning_ = newRunning; +} + +- (void)fire:(NSTimer*)timer { + // The call to [throbber animate] may result in the ThrobberView calling + // removeThrobber: if it decides it's done animating. That would invalidate + // the iterator, making it impossible to correctly get to the next element + // in the set. To prevent that from happening, a second iterator is used + // and incremented before calling [throbber animate]. + ThrobberSet::const_iterator current = throbbers_.begin(); + ThrobberSet::const_iterator next = current; + while (current != throbbers_.end()) { + ++next; + ThrobberView* throbber = *current; + [throbber animate]; + current = next; + } +} +@end + +@implementation ThrobberView + ++ (id)filmstripThrobberViewWithFrame:(NSRect)frame + image:(NSImage*)image { + ThrobberFilmstripDelegate* delegate = + [[[ThrobberFilmstripDelegate alloc] initWithImage:image] autorelease]; + if (!delegate) + return nil; + + return [[[ThrobberView alloc] initWithFrame:frame + delegate:delegate] autorelease]; +} + ++ (id)toastThrobberViewWithFrame:(NSRect)frame + beforeImage:(NSImage*)beforeImage + afterImage:(NSImage*)afterImage { + ThrobberToastDelegate* delegate = + [[[ThrobberToastDelegate alloc] initWithImage1:beforeImage + image2:afterImage] autorelease]; + if (!delegate) + return nil; + + return [[[ThrobberView alloc] initWithFrame:frame + delegate:delegate] autorelease]; +} + +- (id)initWithFrame:(NSRect)frame delegate:(id<ThrobberDataDelegate>)delegate { + if ((self = [super initWithFrame:frame])) { + dataDelegate_ = [delegate retain]; + } + return self; +} + +- (void)dealloc { + [dataDelegate_ release]; + [[ThrobberTimer sharedThrobberTimer] removeThrobber:self]; + + [super dealloc]; +} + +// Manages this ThrobberView's membership in the shared throbber timer set on +// the basis of its visibility and whether its animation needs to continue +// running. +- (void)maintainTimer { + ThrobberTimer* throbberTimer = [ThrobberTimer sharedThrobberTimer]; + + if ([self window] && ![self isHidden] && ![dataDelegate_ animationIsComplete]) + [throbberTimer addThrobber:self]; + else + [throbberTimer removeThrobber:self]; +} + +// A ThrobberView added to a window may need to begin animating; a ThrobberView +// removed from a window should stop. +- (void)viewDidMoveToWindow { + [self maintainTimer]; + [super viewDidMoveToWindow]; +} + +// A hidden ThrobberView should stop animating. +- (void)viewDidHide { + [self maintainTimer]; + [super viewDidHide]; +} + +// A visible ThrobberView may need to start animating. +- (void)viewDidUnhide { + [self maintainTimer]; + [super viewDidUnhide]; +} + +// Called when the timer fires. Advance the frame, dirty the display, and remove +// the throbber if it's no longer needed. +- (void)animate { + [dataDelegate_ advanceFrame]; + [self setNeedsDisplay:YES]; + + if ([dataDelegate_ animationIsComplete]) { + [[ThrobberTimer sharedThrobberTimer] removeThrobber:self]; + } +} + +// Overridden to draw the appropriate frame in the image strip. +- (void)drawRect:(NSRect)rect { + [dataDelegate_ drawFrameInRect:[self bounds]]; +} + +@end diff --git a/chrome/browser/ui/cocoa/throbber_view_unittest.mm b/chrome/browser/ui/cocoa/throbber_view_unittest.mm new file mode 100644 index 0000000..b1a2ce4 --- /dev/null +++ b/chrome/browser/ui/cocoa/throbber_view_unittest.mm @@ -0,0 +1,32 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "app/resource_bundle.h" +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/throbber_view.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" +#include "grit/app_resources.h" + +namespace { + +class ThrobberViewTest : public CocoaTest { + public: + ThrobberViewTest() { + NSRect frame = NSMakeRect(10, 10, 16, 16); + NSImage* image = + ResourceBundle::GetSharedInstance().GetNativeImageNamed(IDR_THROBBER); + view_ = [ThrobberView filmstripThrobberViewWithFrame:frame image:image]; + [[test_window() contentView] addSubview:view_]; + } + + ThrobberView* view_; +}; + +TEST_VIEW(ThrobberViewTest, view_) + +} // namespace diff --git a/chrome/browser/ui/cocoa/toolbar_controller.h b/chrome/browser/ui/cocoa/toolbar_controller.h new file mode 100644 index 0000000..029f89d --- /dev/null +++ b/chrome/browser/ui/cocoa/toolbar_controller.h @@ -0,0 +1,189 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_TOOLBAR_CONTROLLER_H_ +#define CHROME_BROWSER_UI_COCOA_TOOLBAR_CONTROLLER_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_ptr.h" +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/command_observer_bridge.h" +#import "chrome/browser/ui/cocoa/delayedmenu_button.h" +#import "chrome/browser/ui/cocoa/url_drop_target.h" +#import "chrome/browser/ui/cocoa/view_resizer.h" +#include "chrome/browser/prefs/pref_member.h" + +@class AutocompleteTextField; +@class AutocompleteTextFieldEditor; +@class BrowserActionsContainerView; +@class BackForwardMenuController; +class Browser; +@class BrowserActionsController; +class CommandUpdater; +@class DelayedMenuButton; +class LocationBar; +class LocationBarViewMac; +@class MenuButton; +namespace ToolbarControllerInternal { +class NotificationBridge; +class WrenchAcceleratorDelegate; +} // namespace ToolbarControllerInternal +class Profile; +@class ReloadButton; +class TabContents; +class ToolbarModel; +@class WrenchMenuController; +class WrenchMenuModel; + +// A controller for the toolbar in the browser window. Manages +// updating the state for location bar and back/fwd/reload/go buttons. +// Manages the bookmark bar and its position in the window relative to +// the web content view. + +@interface ToolbarController : NSViewController<CommandObserverProtocol, + URLDropTargetController> { + @protected + // The ordering is important for unit tests. If new items are added or the + // ordering is changed, make sure to update |-toolbarViews| and the + // corresponding enum in the unit tests. + IBOutlet DelayedMenuButton* backButton_; + IBOutlet DelayedMenuButton* forwardButton_; + IBOutlet ReloadButton* reloadButton_; + IBOutlet NSButton* homeButton_; + IBOutlet MenuButton* wrenchButton_; + IBOutlet AutocompleteTextField* locationBar_; + IBOutlet BrowserActionsContainerView* browserActionsContainerView_; + IBOutlet WrenchMenuController* wrenchMenuController_; + + @private + ToolbarModel* toolbarModel_; // weak, one per window + CommandUpdater* commands_; // weak, one per window + Profile* profile_; // weak, one per window + Browser* browser_; // weak, one per window + scoped_ptr<CommandObserverBridge> commandObserver_; + scoped_ptr<LocationBarViewMac> locationBarView_; + scoped_nsobject<AutocompleteTextFieldEditor> autocompleteTextFieldEditor_; + id<ViewResizer> resizeDelegate_; // weak + scoped_nsobject<BackForwardMenuController> backMenuController_; + scoped_nsobject<BackForwardMenuController> forwardMenuController_; + scoped_nsobject<BrowserActionsController> browserActionsController_; + + // Lazily-instantiated model and delegate for the menu on the + // wrench button. Once visible, it will be non-null, but will not + // reaped when the menu is hidden once it is initially shown. + scoped_ptr<ToolbarControllerInternal::WrenchAcceleratorDelegate> + acceleratorDelegate_; + scoped_ptr<WrenchMenuModel> wrenchMenuModel_; + + // Used for monitoring the optional toolbar button prefs. + scoped_ptr<ToolbarControllerInternal::NotificationBridge> notificationBridge_; + BooleanPrefMember showHomeButton_; + BooleanPrefMember showPageOptionButtons_; + BOOL hasToolbar_; // If NO, we may have only the location bar. + BOOL hasLocationBar_; // If |hasToolbar_| is YES, this must also be YES. + BOOL locationBarAtMinSize_; // If the location bar is at the minimum size. + + // We have an extra retain in the locationBar_. + // See comments in awakeFromNib for more info. + scoped_nsobject<AutocompleteTextField> locationBarRetainer_; + + // Tracking area for mouse enter/exit/moved in the toolbar. + scoped_nsobject<NSTrackingArea> trackingArea_; + + // We retain/release the hover button since interaction with the + // button may make it go away (e.g. delete menu option over a + // bookmark button). Thus this variable is not weak. The + // hoveredButton_ is required to have an NSCell that responds to + // setMouseInside:animate:. + NSButton* hoveredButton_; +} + +// Initialize the toolbar and register for command updates. The profile is +// needed for initializing the location bar. The browser is needed for +// initializing the back/forward menus. +- (id)initWithModel:(ToolbarModel*)model + commands:(CommandUpdater*)commands + profile:(Profile*)profile + browser:(Browser*)browser + resizeDelegate:(id<ViewResizer>)resizeDelegate; + +// Get the C++ bridge object representing the location bar for this tab. +- (LocationBarViewMac*)locationBarBridge; + +// Called by the Window delegate so we can provide a custom field editor if +// needed. +// Note that this may be called for objects unrelated to the toolbar. +// returns nil if we don't want to override the custom field editor for |obj|. +- (id)customFieldEditorForObject:(id)obj; + +// Make the location bar the first responder, if possible. +- (void)focusLocationBar:(BOOL)selectAll; + +// Updates the toolbar (and transitively the location bar) with the states of +// the specified |tab|. If |shouldRestore| is true, we're switching +// (back?) to this tab and should restore any previous location bar state +// (such as user editing) as well. +- (void)updateToolbarWithContents:(TabContents*)tabForRestoring + shouldRestoreState:(BOOL)shouldRestore; + +// Sets whether or not the current page in the frontmost tab is bookmarked. +- (void)setStarredState:(BOOL)isStarred; + +// Called to update the loading state. Handles updating the go/stop +// button state. |force| is set if the update is due to changing +// tabs, as opposed to the page-load finishing. See comment in +// reload_button.h. +- (void)setIsLoading:(BOOL)isLoading force:(BOOL)force; + +// Allow turning off the toolbar (but we may keep the location bar without a +// surrounding toolbar). If |toolbar| is YES, the value of |hasLocationBar| is +// ignored. This changes the behavior of other methods, like |-view|. +- (void)setHasToolbar:(BOOL)toolbar hasLocationBar:(BOOL)locBar; + +// Point on the star icon for the bookmark bubble to be - in the +// associated window's coordinate system. +- (NSPoint)bookmarkBubblePoint; + +// Returns the desired toolbar height for the given compression factor. +- (CGFloat)desiredHeightForCompression:(CGFloat)compressByHeight; + +// Set the opacity of the divider (the line at the bottom) *if* we have a +// |ToolbarView| (0 means don't show it); no-op otherwise. +- (void)setDividerOpacity:(CGFloat)opacity; + +// Create and add the Browser Action buttons to the toolbar view. +- (void)createBrowserActionButtons; + +// Return the BrowserActionsController for this toolbar. +- (BrowserActionsController*)browserActionsController; + +@end + +// A set of private methods used by subclasses. Do not call these directly +// unless a subclass of ToolbarController. +@interface ToolbarController(ProtectedMethods) +// Designated initializer which takes a nib name in order to allow subclasses +// to load a different nib file. +- (id)initWithModel:(ToolbarModel*)model + commands:(CommandUpdater*)commands + profile:(Profile*)profile + browser:(Browser*)browser + resizeDelegate:(id<ViewResizer>)resizeDelegate + nibFileNamed:(NSString*)nibName; +@end + +// A set of private methods used by tests, in the absence of "friends" in ObjC. +@interface ToolbarController(PrivateTestMethods) +// Returns an array of views in the order of the outlets above. +- (NSArray*)toolbarViews; +- (void)showOptionalHomeButton; +- (void)installWrenchMenu; +- (WrenchMenuController*)wrenchMenuController; +// Return a hover button for the current event. +- (NSButton*)hoverButtonForEvent:(NSEvent*)theEvent; +@end + +#endif // CHROME_BROWSER_UI_COCOA_TOOLBAR_CONTROLLER_H_ diff --git a/chrome/browser/ui/cocoa/toolbar_controller.mm b/chrome/browser/ui/cocoa/toolbar_controller.mm new file mode 100644 index 0000000..aa1d521 --- /dev/null +++ b/chrome/browser/ui/cocoa/toolbar_controller.mm @@ -0,0 +1,753 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/toolbar_controller.h" + +#include <algorithm> + +#include "app/l10n_util.h" +#include "app/l10n_util_mac.h" +#include "app/menus/accelerator_cocoa.h" +#include "app/menus/menu_model.h" +#include "base/mac_util.h" +#include "base/nsimage_cache_mac.h" +#include "base/singleton.h" +#include "base/sys_string_conversions.h" +#include "chrome/app/chrome_command_ids.h" +#include "chrome/browser/autocomplete/autocomplete_edit_view.h" +#include "chrome/browser/net/url_fixer_upper.h" +#include "chrome/browser/prefs/pref_service.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/search_engines/template_url_model.h" +#include "chrome/browser/tab_contents/tab_contents.h" +#include "chrome/browser/themes/browser_theme_provider.h" +#include "chrome/browser/toolbar_model.h" +#include "chrome/browser/upgrade_detector.h" +#include "chrome/browser/wrench_menu_model.h" +#include "chrome/browser/ui/browser.h" +#include "chrome/browser/ui/browser_window.h" +#import "chrome/browser/ui/cocoa/accelerators_cocoa.h" +#import "chrome/browser/ui/cocoa/back_forward_menu_controller.h" +#import "chrome/browser/ui/cocoa/background_gradient_view.h" +#import "chrome/browser/ui/cocoa/encoding_menu_controller_delegate_mac.h" +#import "chrome/browser/ui/cocoa/extensions/browser_action_button.h" +#import "chrome/browser/ui/cocoa/extensions/browser_actions_container_view.h" +#import "chrome/browser/ui/cocoa/extensions/browser_actions_controller.h" +#import "chrome/browser/ui/cocoa/gradient_button_cell.h" +#import "chrome/browser/ui/cocoa/location_bar/autocomplete_text_field_editor.h" +#import "chrome/browser/ui/cocoa/location_bar/location_bar_view_mac.h" +#import "chrome/browser/ui/cocoa/menu_button.h" +#import "chrome/browser/ui/cocoa/menu_controller.h" +#import "chrome/browser/ui/cocoa/reload_button.h" +#import "chrome/browser/ui/cocoa/toolbar_view.h" +#import "chrome/browser/ui/cocoa/view_id_util.h" +#import "chrome/browser/ui/cocoa/wrench_menu_controller.h" +#include "chrome/common/notification_details.h" +#include "chrome/common/notification_observer.h" +#include "chrome/common/notification_service.h" +#include "chrome/common/notification_type.h" +#include "chrome/common/pref_names.h" +#include "gfx/rect.h" +#include "grit/chromium_strings.h" +#include "grit/generated_resources.h" +#include "grit/theme_resources.h" + +namespace { + +// Names of images in the bundle for buttons. +NSString* const kBackButtonImageName = @"back_Template.pdf"; +NSString* const kForwardButtonImageName = @"forward_Template.pdf"; +NSString* const kReloadButtonReloadImageName = @"reload_Template.pdf"; +NSString* const kReloadButtonStopImageName = @"stop_Template.pdf"; +NSString* const kHomeButtonImageName = @"home_Template.pdf"; +NSString* const kWrenchButtonImageName = @"tools_Template.pdf"; + +// Height of the toolbar in pixels when the bookmark bar is closed. +const CGFloat kBaseToolbarHeight = 35.0; + +// The minimum width of the location bar in pixels. +const CGFloat kMinimumLocationBarWidth = 100.0; + +// The duration of any animation that occurs within the toolbar in seconds. +const CGFloat kAnimationDuration = 0.2; + +// The amount of left padding that the wrench menu should have. +const CGFloat kWrenchMenuLeftPadding = 3.0; + +} // namespace + +@interface ToolbarController(Private) +- (void)addAccessibilityDescriptions; +- (void)initCommandStatus:(CommandUpdater*)commands; +- (void)prefChanged:(std::string*)prefName; +- (BackgroundGradientView*)backgroundGradientView; +- (void)toolbarFrameChanged; +- (void)pinLocationBarToLeftOfBrowserActionsContainerAndAnimate:(BOOL)animate; +- (void)maintainMinimumLocationBarWidth; +- (void)adjustBrowserActionsContainerForNewWindow:(NSNotification*)notification; +- (void)browserActionsContainerDragged:(NSNotification*)notification; +- (void)browserActionsContainerDragFinished:(NSNotification*)notification; +- (void)browserActionsVisibilityChanged:(NSNotification*)notification; +- (void)adjustLocationSizeBy:(CGFloat)dX animate:(BOOL)animate; +- (void)badgeWrenchMenu; +@end + +namespace ToolbarControllerInternal { + +// A C++ delegate that handles the accelerators in the wrench menu. +class WrenchAcceleratorDelegate : public menus::AcceleratorProvider { + public: + virtual bool GetAcceleratorForCommandId(int command_id, + menus::Accelerator* accelerator_generic) { + // Downcast so that when the copy constructor is invoked below, the key + // string gets copied, too. + menus::AcceleratorCocoa* out_accelerator = + static_cast<menus::AcceleratorCocoa*>(accelerator_generic); + AcceleratorsCocoa* keymap = Singleton<AcceleratorsCocoa>::get(); + const menus::AcceleratorCocoa* accelerator = + keymap->GetAcceleratorForCommand(command_id); + if (accelerator) { + *out_accelerator = *accelerator; + return true; + } + return false; + } +}; + +// A class registered for C++ notifications. This is used to detect changes in +// preferences and upgrade available notifications. Bridges the notification +// back to the ToolbarController. +class NotificationBridge : public NotificationObserver { + public: + explicit NotificationBridge(ToolbarController* controller) + : controller_(controller) { + registrar_.Add(this, NotificationType::UPGRADE_RECOMMENDED, + NotificationService::AllSources()); + } + + // Overridden from NotificationObserver: + virtual void Observe(NotificationType type, + const NotificationSource& source, + const NotificationDetails& details) { + if (type == NotificationType::PREF_CHANGED) + [controller_ prefChanged:Details<std::string>(details).ptr()]; + else if (type == NotificationType::UPGRADE_RECOMMENDED) + [controller_ badgeWrenchMenu]; + } + + private: + ToolbarController* controller_; // weak, owns us + + NotificationRegistrar registrar_; +}; + +} // namespace ToolbarControllerInternal + +@implementation ToolbarController + +- (id)initWithModel:(ToolbarModel*)model + commands:(CommandUpdater*)commands + profile:(Profile*)profile + browser:(Browser*)browser + resizeDelegate:(id<ViewResizer>)resizeDelegate + nibFileNamed:(NSString*)nibName { + DCHECK(model && commands && profile && [nibName length]); + if ((self = [super initWithNibName:nibName + bundle:mac_util::MainAppBundle()])) { + toolbarModel_ = model; + commands_ = commands; + profile_ = profile; + browser_ = browser; + resizeDelegate_ = resizeDelegate; + hasToolbar_ = YES; + hasLocationBar_ = YES; + + // Register for notifications about state changes for the toolbar buttons + commandObserver_.reset(new CommandObserverBridge(self, commands)); + commandObserver_->ObserveCommand(IDC_BACK); + commandObserver_->ObserveCommand(IDC_FORWARD); + commandObserver_->ObserveCommand(IDC_RELOAD); + commandObserver_->ObserveCommand(IDC_HOME); + commandObserver_->ObserveCommand(IDC_BOOKMARK_PAGE); + } + return self; +} + +- (id)initWithModel:(ToolbarModel*)model + commands:(CommandUpdater*)commands + profile:(Profile*)profile + browser:(Browser*)browser + resizeDelegate:(id<ViewResizer>)resizeDelegate { + if ((self = [self initWithModel:model + commands:commands + profile:profile + browser:browser + resizeDelegate:resizeDelegate + nibFileNamed:@"Toolbar"])) { + } + return self; +} + + +- (void)dealloc { + // Unset ViewIDs of toolbar elements. + // ViewIDs of |toolbarView|, |reloadButton_|, |locationBar_| and + // |browserActionsContainerView_| are handled by themselves. + view_id_util::UnsetID(backButton_); + view_id_util::UnsetID(forwardButton_); + view_id_util::UnsetID(homeButton_); + view_id_util::UnsetID(wrenchButton_); + + // Make sure any code in the base class which assumes [self view] is + // the "parent" view continues to work. + hasToolbar_ = YES; + hasLocationBar_ = YES; + + [[NSNotificationCenter defaultCenter] removeObserver:self]; + + if (trackingArea_.get()) + [[self view] removeTrackingArea:trackingArea_.get()]; + [super dealloc]; +} + +// Called after the view is done loading and the outlets have been hooked up. +// Now we can hook up bridges that rely on UI objects such as the location +// bar and button state. +- (void)awakeFromNib { + // A bug in AppKit (<rdar://7298597>, <http://openradar.me/7298597>) causes + // images loaded directly from nibs in a framework to not get their "template" + // flags set properly. Thus, despite the images being set on the buttons in + // the xib, we must set them in code. + [backButton_ setImage:nsimage_cache::ImageNamed(kBackButtonImageName)]; + [forwardButton_ setImage:nsimage_cache::ImageNamed(kForwardButtonImageName)]; + [reloadButton_ + setImage:nsimage_cache::ImageNamed(kReloadButtonReloadImageName)]; + [homeButton_ setImage:nsimage_cache::ImageNamed(kHomeButtonImageName)]; + [wrenchButton_ setImage:nsimage_cache::ImageNamed(kWrenchButtonImageName)]; + + if (Singleton<UpgradeDetector>::get()->notify_upgrade()) + [self badgeWrenchMenu]; + + [backButton_ setShowsBorderOnlyWhileMouseInside:YES]; + [forwardButton_ setShowsBorderOnlyWhileMouseInside:YES]; + [reloadButton_ setShowsBorderOnlyWhileMouseInside:YES]; + [homeButton_ setShowsBorderOnlyWhileMouseInside:YES]; + [wrenchButton_ setShowsBorderOnlyWhileMouseInside:YES]; + + [self initCommandStatus:commands_]; + locationBarView_.reset(new LocationBarViewMac(locationBar_, + commands_, toolbarModel_, + profile_, browser_)); + [locationBar_ setFont:[NSFont systemFontOfSize:[NSFont systemFontSize]]]; + // Register pref observers for the optional home and page/options buttons + // and then add them to the toolbar based on those prefs. + notificationBridge_.reset( + new ToolbarControllerInternal::NotificationBridge(self)); + PrefService* prefs = profile_->GetPrefs(); + showHomeButton_.Init(prefs::kShowHomeButton, prefs, + notificationBridge_.get()); + showPageOptionButtons_.Init(prefs::kShowPageOptionsButtons, prefs, + notificationBridge_.get()); + [self showOptionalHomeButton]; + [self installWrenchMenu]; + + // Create the controllers for the back/forward menus. + backMenuController_.reset([[BackForwardMenuController alloc] + initWithBrowser:browser_ + modelType:BACK_FORWARD_MENU_TYPE_BACK + button:backButton_]); + forwardMenuController_.reset([[BackForwardMenuController alloc] + initWithBrowser:browser_ + modelType:BACK_FORWARD_MENU_TYPE_FORWARD + button:forwardButton_]); + + // For a popup window, the toolbar is really just a location bar + // (see override for [ToolbarController view], below). When going + // fullscreen, we remove the toolbar controller's view from the view + // hierarchy. Calling [locationBar_ removeFromSuperview] when going + // fullscreen causes it to get released, making us unhappy + // (http://crbug.com/18551). We avoid the problem by incrementing + // the retain count of the location bar; use of the scoped object + // helps us remember to release it. + locationBarRetainer_.reset([locationBar_ retain]); + trackingArea_.reset( + [[NSTrackingArea alloc] initWithRect:NSZeroRect // Ignored + options:NSTrackingMouseMoved | + NSTrackingInVisibleRect | + NSTrackingMouseEnteredAndExited | + NSTrackingActiveAlways + owner:self + userInfo:nil]); + NSView* toolbarView = [self view]; + [toolbarView addTrackingArea:trackingArea_.get()]; + + // If the user has any Browser Actions installed, the container view for them + // may have to be resized depending on the width of the toolbar frame. + [toolbarView setPostsFrameChangedNotifications:YES]; + [[NSNotificationCenter defaultCenter] + addObserver:self + selector:@selector(toolbarFrameChanged) + name:NSViewFrameDidChangeNotification + object:toolbarView]; + + // Set ViewIDs for toolbar elements which don't have their dedicated class. + // ViewIDs of |toolbarView|, |reloadButton_|, |locationBar_| and + // |browserActionsContainerView_| are handled by themselves. + view_id_util::SetID(backButton_, VIEW_ID_BACK_BUTTON); + view_id_util::SetID(forwardButton_, VIEW_ID_FORWARD_BUTTON); + view_id_util::SetID(homeButton_, VIEW_ID_HOME_BUTTON); + view_id_util::SetID(wrenchButton_, VIEW_ID_APP_MENU); + + [self addAccessibilityDescriptions]; +} + +- (void)addAccessibilityDescriptions { + // Set accessibility descriptions. http://openradar.appspot.com/7496255 + NSString* description = l10n_util::GetNSStringWithFixup(IDS_ACCNAME_BACK); + [[backButton_ cell] + accessibilitySetOverrideValue:description + forAttribute:NSAccessibilityDescriptionAttribute]; + description = l10n_util::GetNSStringWithFixup(IDS_ACCNAME_FORWARD); + [[forwardButton_ cell] + accessibilitySetOverrideValue:description + forAttribute:NSAccessibilityDescriptionAttribute]; + description = l10n_util::GetNSStringWithFixup(IDS_ACCNAME_RELOAD); + [[reloadButton_ cell] + accessibilitySetOverrideValue:description + forAttribute:NSAccessibilityDescriptionAttribute]; + description = l10n_util::GetNSStringWithFixup(IDS_ACCNAME_HOME); + [[homeButton_ cell] + accessibilitySetOverrideValue:description + forAttribute:NSAccessibilityDescriptionAttribute]; + description = l10n_util::GetNSStringWithFixup(IDS_ACCNAME_LOCATION); + [[locationBar_ cell] + accessibilitySetOverrideValue:description + forAttribute:NSAccessibilityDescriptionAttribute]; + description = l10n_util::GetNSStringWithFixup(IDS_ACCNAME_APP); + [[wrenchButton_ cell] + accessibilitySetOverrideValue:description + forAttribute:NSAccessibilityDescriptionAttribute]; +} + +- (void)mouseExited:(NSEvent*)theEvent { + [[hoveredButton_ cell] setMouseInside:NO animate:YES]; + [hoveredButton_ release]; + hoveredButton_ = nil; +} + +- (NSButton*)hoverButtonForEvent:(NSEvent*)theEvent { + NSButton* targetView = (NSButton*)[[self view] + hitTest:[theEvent locationInWindow]]; + + // Only interpret the view as a hoverButton_ if it's both button and has a + // button cell that cares. GradientButtonCell derived cells care. + if (([targetView isKindOfClass:[NSButton class]]) && + ([[targetView cell] + respondsToSelector:@selector(setMouseInside:animate:)])) + return targetView; + return nil; +} + +- (void)mouseMoved:(NSEvent*)theEvent { + NSButton* targetView = [self hoverButtonForEvent:theEvent]; + if (hoveredButton_ != targetView) { + [[hoveredButton_ cell] setMouseInside:NO animate:YES]; + [[targetView cell] setMouseInside:YES animate:YES]; + [hoveredButton_ release]; + hoveredButton_ = [targetView retain]; + } +} + +- (void)mouseEntered:(NSEvent*)event { + [self mouseMoved:event]; +} + +- (LocationBarViewMac*)locationBarBridge { + return locationBarView_.get(); +} + +- (void)focusLocationBar:(BOOL)selectAll { + if (locationBarView_.get()) + locationBarView_->FocusLocation(selectAll ? true : false); +} + +// Called when the state for a command changes to |enabled|. Update the +// corresponding UI element. +- (void)enabledStateChangedForCommand:(NSInteger)command enabled:(BOOL)enabled { + NSButton* button = nil; + switch (command) { + case IDC_BACK: + button = backButton_; + break; + case IDC_FORWARD: + button = forwardButton_; + break; + case IDC_HOME: + button = homeButton_; + break; + } + [button setEnabled:enabled]; +} + +// Init the enabled state of the buttons on the toolbar to match the state in +// the controller. +- (void)initCommandStatus:(CommandUpdater*)commands { + [backButton_ setEnabled:commands->IsCommandEnabled(IDC_BACK) ? YES : NO]; + [forwardButton_ + setEnabled:commands->IsCommandEnabled(IDC_FORWARD) ? YES : NO]; + [reloadButton_ setEnabled:YES]; + [homeButton_ setEnabled:commands->IsCommandEnabled(IDC_HOME) ? YES : NO]; +} + +- (void)updateToolbarWithContents:(TabContents*)tab + shouldRestoreState:(BOOL)shouldRestore { + locationBarView_->Update(tab, shouldRestore ? true : false); + + [locationBar_ updateCursorAndToolTipRects]; + + if (browserActionsController_.get()) { + [browserActionsController_ update]; + } +} + +- (void)setStarredState:(BOOL)isStarred { + locationBarView_->SetStarred(isStarred ? true : false); +} + +- (void)setIsLoading:(BOOL)isLoading force:(BOOL)force { + [reloadButton_ setIsLoading:isLoading force:force]; +} + +- (void)setHasToolbar:(BOOL)toolbar hasLocationBar:(BOOL)locBar { + [self view]; // Force nib loading. + + hasToolbar_ = toolbar; + + // If there's a toolbar, there must be a location bar. + DCHECK((toolbar && locBar) || !toolbar); + hasLocationBar_ = toolbar ? YES : locBar; + + // Decide whether to hide/show based on whether there's a location bar. + [[self view] setHidden:!hasLocationBar_]; + + // Make location bar not editable when in a pop-up. + locationBarView_->SetEditable(toolbar); +} + +- (NSView*)view { + if (hasToolbar_) + return [super view]; + return locationBar_; +} + +// (Private) Returns the backdrop to the toolbar. +- (BackgroundGradientView*)backgroundGradientView { + // We really do mean |[super view]|; see our override of |-view|. + DCHECK([[super view] isKindOfClass:[BackgroundGradientView class]]); + return (BackgroundGradientView*)[super view]; +} + +- (id)customFieldEditorForObject:(id)obj { + if (obj == locationBar_) { + // Lazilly construct Field editor, Cocoa UI code always runs on the + // same thread, so there shoudn't be a race condition here. + if (autocompleteTextFieldEditor_.get() == nil) { + autocompleteTextFieldEditor_.reset( + [[AutocompleteTextFieldEditor alloc] init]); + } + + // This needs to be called every time, otherwise notifications + // aren't sent correctly. + DCHECK(autocompleteTextFieldEditor_.get()); + [autocompleteTextFieldEditor_.get() setFieldEditor:YES]; + return autocompleteTextFieldEditor_.get(); + } + return nil; +} + +// Returns an array of views in the order of the outlets above. +- (NSArray*)toolbarViews { + return [NSArray arrayWithObjects:backButton_, forwardButton_, reloadButton_, + homeButton_, wrenchButton_, locationBar_, + browserActionsContainerView_, nil]; +} + +// Moves |rect| to the right by |delta|, keeping the right side fixed by +// shrinking the width to compensate. Passing a negative value for |deltaX| +// moves to the left and increases the width. +- (NSRect)adjustRect:(NSRect)rect byAmount:(CGFloat)deltaX { + NSRect frame = NSOffsetRect(rect, deltaX, 0); + frame.size.width -= deltaX; + return frame; +} + +// Show or hide the home button based on the pref. +- (void)showOptionalHomeButton { + // Ignore this message if only showing the URL bar. + if (!hasToolbar_) + return; + BOOL hide = showHomeButton_.GetValue() ? NO : YES; + if (hide == [homeButton_ isHidden]) + return; // Nothing to do, view state matches pref state. + + // Always shift the text field by the width of the home button minus one pixel + // since the frame edges of each button are right on top of each other. When + // hiding the button, reverse the direction of the movement (to the left). + CGFloat moveX = [homeButton_ frame].size.width - 1.0; + if (hide) + moveX *= -1; // Reverse the direction of the move. + + [locationBar_ setFrame:[self adjustRect:[locationBar_ frame] + byAmount:moveX]]; + [homeButton_ setHidden:hide]; +} + +// Install the menu wrench buttons. Calling this repeatedly is inexpensive so it +// can be done every time the buttons are shown. +- (void)installWrenchMenu { + if (wrenchMenuModel_.get()) + return; + acceleratorDelegate_.reset( + new ToolbarControllerInternal::WrenchAcceleratorDelegate()); + + wrenchMenuModel_.reset(new WrenchMenuModel( + acceleratorDelegate_.get(), browser_)); + [wrenchMenuController_ setModel:wrenchMenuModel_.get()]; + [wrenchMenuController_ setUseWithPopUpButtonCell:YES]; + [wrenchButton_ setAttachedMenu:[wrenchMenuController_ menu]]; +} + +- (WrenchMenuController*)wrenchMenuController { + return wrenchMenuController_; +} + +- (void)badgeWrenchMenu { + // In the Windows version, the ball doesn't actually pulsate, and is always + // drawn with the inactive image. Why? (We follow suit, though not on the + // weird positioning they do that overlaps the button border.) + NSImage* badge = nsimage_cache::ImageNamed(@"upgrade_dot.pdf"); + NSImage* wrenchImage = nsimage_cache::ImageNamed(kWrenchButtonImageName); + NSSize wrenchImageSize = [wrenchImage size]; + + scoped_nsobject<NSImage> overlayImage( + [[NSImage alloc] initWithSize:wrenchImageSize]); + + [overlayImage lockFocus]; + [badge drawAtPoint:NSZeroPoint + fromRect:NSZeroRect + operation:NSCompositeSourceOver + fraction:1.0]; + [overlayImage unlockFocus]; + + [[wrenchButton_ cell] setOverlayImage:overlayImage]; +} + +- (void)prefChanged:(std::string*)prefName { + if (!prefName) return; + if (*prefName == prefs::kShowHomeButton) { + [self showOptionalHomeButton]; + } +} + +- (void)createBrowserActionButtons { + if (!browserActionsController_.get()) { + browserActionsController_.reset([[BrowserActionsController alloc] + initWithBrowser:browser_ + containerView:browserActionsContainerView_]); + [[NSNotificationCenter defaultCenter] + addObserver:self + selector:@selector(browserActionsContainerDragged:) + name:kBrowserActionGrippyDraggingNotification + object:browserActionsController_]; + [[NSNotificationCenter defaultCenter] + addObserver:self + selector:@selector(browserActionsContainerDragFinished:) + name:kBrowserActionGrippyDragFinishedNotification + object:browserActionsController_]; + [[NSNotificationCenter defaultCenter] + addObserver:self + selector:@selector(browserActionsVisibilityChanged:) + name:kBrowserActionVisibilityChangedNotification + object:browserActionsController_]; + [[NSNotificationCenter defaultCenter] + addObserver:self + selector:@selector(adjustBrowserActionsContainerForNewWindow:) + name:NSWindowDidBecomeKeyNotification + object:[[self view] window]]; + } + CGFloat containerWidth = [browserActionsContainerView_ isHidden] ? 0.0 : + NSWidth([browserActionsContainerView_ frame]); + if (containerWidth > 0.0) + [self adjustLocationSizeBy:(containerWidth * -1) animate:NO]; +} + +- (void)adjustBrowserActionsContainerForNewWindow: + (NSNotification*)notification { + [self toolbarFrameChanged]; + [[NSNotificationCenter defaultCenter] + removeObserver:self + name:NSWindowDidBecomeKeyNotification + object:[[self view] window]]; +} + +- (void)browserActionsContainerDragged:(NSNotification*)notification { + CGFloat locationBarWidth = NSWidth([locationBar_ frame]); + locationBarAtMinSize_ = locationBarWidth <= kMinimumLocationBarWidth; + [browserActionsContainerView_ setCanDragLeft:!locationBarAtMinSize_]; + [browserActionsContainerView_ setGrippyPinned:locationBarAtMinSize_]; + [self adjustLocationSizeBy: + [browserActionsContainerView_ resizeDeltaX] animate:NO]; +} + +- (void)browserActionsContainerDragFinished:(NSNotification*)notification { + [browserActionsController_ resizeContainerAndAnimate:YES]; + [self pinLocationBarToLeftOfBrowserActionsContainerAndAnimate:YES]; +} + +- (void)browserActionsVisibilityChanged:(NSNotification*)notification { + [self pinLocationBarToLeftOfBrowserActionsContainerAndAnimate:NO]; +} + +- (void)pinLocationBarToLeftOfBrowserActionsContainerAndAnimate:(BOOL)animate { + CGFloat locationBarXPos = NSMaxX([locationBar_ frame]); + CGFloat leftDistance; + + if ([browserActionsContainerView_ isHidden]) { + CGFloat edgeXPos = [wrenchButton_ frame].origin.x; + leftDistance = edgeXPos - locationBarXPos - kWrenchMenuLeftPadding; + } else { + NSRect containerFrame = animate ? + [browserActionsContainerView_ animationEndFrame] : + [browserActionsContainerView_ frame]; + + leftDistance = containerFrame.origin.x - locationBarXPos; + } + if (leftDistance != 0.0) + [self adjustLocationSizeBy:leftDistance animate:animate]; +} + +- (void)maintainMinimumLocationBarWidth { + CGFloat locationBarWidth = NSWidth([locationBar_ frame]); + locationBarAtMinSize_ = locationBarWidth <= kMinimumLocationBarWidth; + if (locationBarAtMinSize_) { + CGFloat dX = kMinimumLocationBarWidth - locationBarWidth; + [self adjustLocationSizeBy:dX animate:NO]; + } +} + +- (void)toolbarFrameChanged { + // Do nothing if the frame changes but no Browser Action Controller is + // present. + if (!browserActionsController_.get()) + return; + + [self maintainMinimumLocationBarWidth]; + + if (locationBarAtMinSize_) { + // Once the grippy is pinned, leave it until it is explicity un-pinned. + [browserActionsContainerView_ setGrippyPinned:YES]; + NSRect containerFrame = [browserActionsContainerView_ frame]; + // Determine how much the container needs to move in case it's overlapping + // with the location bar. + CGFloat dX = NSMaxX([locationBar_ frame]) - containerFrame.origin.x; + containerFrame = NSOffsetRect(containerFrame, dX, 0); + containerFrame.size.width -= dX; + [browserActionsContainerView_ setFrame:containerFrame]; + } else if (!locationBarAtMinSize_ && + [browserActionsContainerView_ grippyPinned]) { + // Expand out the container until it hits the saved size, then unpin the + // grippy. + // Add 0.1 pixel so that it doesn't hit the minimum width codepath above. + CGFloat dX = NSWidth([locationBar_ frame]) - + (kMinimumLocationBarWidth + 0.1); + NSRect containerFrame = [browserActionsContainerView_ frame]; + containerFrame = NSOffsetRect(containerFrame, -dX, 0); + containerFrame.size.width += dX; + CGFloat savedContainerWidth = [browserActionsController_ savedWidth]; + if (NSWidth(containerFrame) >= savedContainerWidth) { + containerFrame = NSOffsetRect(containerFrame, + NSWidth(containerFrame) - savedContainerWidth, 0); + containerFrame.size.width = savedContainerWidth; + [browserActionsContainerView_ setGrippyPinned:NO]; + } + [browserActionsContainerView_ setFrame:containerFrame]; + [self pinLocationBarToLeftOfBrowserActionsContainerAndAnimate:NO]; + } +} + +- (void)adjustLocationSizeBy:(CGFloat)dX animate:(BOOL)animate { + // Ensure that the location bar is in its proper place. + NSRect locationFrame = [locationBar_ frame]; + locationFrame.size.width += dX; + + if (!animate) { + [locationBar_ setFrame:locationFrame]; + return; + } + + [NSAnimationContext beginGrouping]; + [[NSAnimationContext currentContext] setDuration:kAnimationDuration]; + [[locationBar_ animator] setFrame:locationFrame]; + [NSAnimationContext endGrouping]; +} + +- (NSPoint)bookmarkBubblePoint { + return locationBarView_->GetBookmarkBubblePoint(); +} + +- (CGFloat)desiredHeightForCompression:(CGFloat)compressByHeight { + // With no toolbar, just ignore the compression. + return hasToolbar_ ? kBaseToolbarHeight - compressByHeight : + NSHeight([locationBar_ frame]); +} + +- (void)setDividerOpacity:(CGFloat)opacity { + BackgroundGradientView* view = [self backgroundGradientView]; + [view setShowsDivider:(opacity > 0 ? YES : NO)]; + + // We may not have a toolbar view (e.g., popup windows only have a location + // bar). + if ([view isKindOfClass:[ToolbarView class]]) { + ToolbarView* toolbarView = (ToolbarView*)view; + [toolbarView setDividerOpacity:opacity]; + } +} + +- (BrowserActionsController*)browserActionsController { + return browserActionsController_.get(); +} + +// (URLDropTargetController protocol) +- (void)dropURLs:(NSArray*)urls inView:(NSView*)view at:(NSPoint)point { + // TODO(viettrungluu): This code is more or less copied from the code in + // |TabStripController|. I'll refactor this soon to make it common and expand + // its capabilities (e.g., allow text DnD). + if ([urls count] < 1) { + NOTREACHED(); + return; + } + + // TODO(viettrungluu): dropping multiple URLs? + if ([urls count] > 1) + NOTIMPLEMENTED(); + + // Get the first URL and fix it up. + GURL url(URLFixerUpper::FixupURL( + base::SysNSStringToUTF8([urls objectAtIndex:0]), std::string())); + + browser_->GetSelectedTabContents()->OpenURL(url, GURL(), CURRENT_TAB, + PageTransition::TYPED); +} + +// (URLDropTargetController protocol) +- (void)indicateDropURLsInView:(NSView*)view at:(NSPoint)point { + // Do nothing. +} + +// (URLDropTargetController protocol) +- (void)hideDropURLsIndicatorInView:(NSView*)view { + // Do nothing. +} + +@end diff --git a/chrome/browser/ui/cocoa/toolbar_controller_unittest.mm b/chrome/browser/ui/cocoa/toolbar_controller_unittest.mm new file mode 100644 index 0000000..b57fdf8 --- /dev/null +++ b/chrome/browser/ui/cocoa/toolbar_controller_unittest.mm @@ -0,0 +1,237 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#import "base/scoped_nsobject.h" +#include "chrome/app/chrome_command_ids.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/gradient_button_cell.h" +#import "chrome/browser/ui/cocoa/toolbar_controller.h" +#import "chrome/browser/ui/cocoa/view_resizer_pong.h" +#include "chrome/browser/prefs/pref_service.h" +#include "chrome/common/pref_names.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +// An NSView that fakes out hitTest:. +@interface HitView : NSView { + id hitTestReturn_; +} +@end + +@implementation HitView + +- (void)setHitTestReturn:(id)rtn { + hitTestReturn_ = rtn; +} + +- (NSView *)hitTest:(NSPoint)aPoint { + return hitTestReturn_; +} + +@end + + +namespace { + +class ToolbarControllerTest : public CocoaTest { + public: + + // Indexes that match the ordering returned by the private ToolbarController + // |-toolbarViews| method. + enum { + kBackIndex, kForwardIndex, kReloadIndex, kHomeIndex, + kWrenchIndex, kLocationIndex, kBrowserActionContainerViewIndex + }; + + ToolbarControllerTest() { + Browser* browser = helper_.browser(); + CommandUpdater* updater = browser->command_updater(); + // The default state for the commands is true, set a couple to false to + // ensure they get picked up correct on initialization + updater->UpdateCommandEnabled(IDC_BACK, false); + updater->UpdateCommandEnabled(IDC_FORWARD, false); + resizeDelegate_.reset([[ViewResizerPong alloc] init]); + bar_.reset( + [[ToolbarController alloc] initWithModel:browser->toolbar_model() + commands:browser->command_updater() + profile:helper_.profile() + browser:browser + resizeDelegate:resizeDelegate_.get()]); + EXPECT_TRUE([bar_ view]); + NSView* parent = [test_window() contentView]; + [parent addSubview:[bar_ view]]; + } + + // Make sure the enabled state of the view is the same as the corresponding + // command in the updater. The views are in the declaration order of outlets. + void CompareState(CommandUpdater* updater, NSArray* views) { + EXPECT_EQ(updater->IsCommandEnabled(IDC_BACK), + [[views objectAtIndex:kBackIndex] isEnabled] ? true : false); + EXPECT_EQ(updater->IsCommandEnabled(IDC_FORWARD), + [[views objectAtIndex:kForwardIndex] isEnabled] ? true : false); + EXPECT_EQ(updater->IsCommandEnabled(IDC_RELOAD), + [[views objectAtIndex:kReloadIndex] isEnabled] ? true : false); + EXPECT_EQ(updater->IsCommandEnabled(IDC_HOME), + [[views objectAtIndex:kHomeIndex] isEnabled] ? true : false); + } + + BrowserTestHelper helper_; + scoped_nsobject<ViewResizerPong> resizeDelegate_; + scoped_nsobject<ToolbarController> bar_; +}; + +TEST_VIEW(ToolbarControllerTest, [bar_ view]) + +// Test the initial state that everything is sync'd up +TEST_F(ToolbarControllerTest, InitialState) { + CommandUpdater* updater = helper_.browser()->command_updater(); + CompareState(updater, [bar_ toolbarViews]); +} + +// Make sure a "titlebar only" toolbar with location bar works. +TEST_F(ToolbarControllerTest, TitlebarOnly) { + NSView* view = [bar_ view]; + + [bar_ setHasToolbar:NO hasLocationBar:YES]; + EXPECT_NE(view, [bar_ view]); + + // Simulate a popup going fullscreen and back by performing the reparenting + // that happens during fullscreen transitions + NSView* superview = [view superview]; + [view removeFromSuperview]; + [superview addSubview:view]; + + [bar_ setHasToolbar:YES hasLocationBar:YES]; + EXPECT_EQ(view, [bar_ view]); + + // Leave it off to make sure that's fine + [bar_ setHasToolbar:NO hasLocationBar:YES]; +} + +// Make sure it works in the completely undecorated case. +TEST_F(ToolbarControllerTest, NoLocationBar) { + NSView* view = [bar_ view]; + + [bar_ setHasToolbar:NO hasLocationBar:NO]; + EXPECT_NE(view, [bar_ view]); + EXPECT_TRUE([[bar_ view] isHidden]); + + // Simulate a popup going fullscreen and back by performing the reparenting + // that happens during fullscreen transitions + NSView* superview = [view superview]; + [view removeFromSuperview]; + [superview addSubview:view]; +} + +// Make some changes to the enabled state of a few of the buttons and ensure +// that we're still in sync. +TEST_F(ToolbarControllerTest, UpdateEnabledState) { + CommandUpdater* updater = helper_.browser()->command_updater(); + EXPECT_FALSE(updater->IsCommandEnabled(IDC_BACK)); + EXPECT_FALSE(updater->IsCommandEnabled(IDC_FORWARD)); + updater->UpdateCommandEnabled(IDC_BACK, true); + updater->UpdateCommandEnabled(IDC_FORWARD, true); + CompareState(updater, [bar_ toolbarViews]); +} + +// Focus the location bar and make sure that it's the first responder. +TEST_F(ToolbarControllerTest, FocusLocation) { + NSWindow* window = test_window(); + [window makeFirstResponder:[window contentView]]; + EXPECT_EQ([window firstResponder], [window contentView]); + [bar_ focusLocationBar:YES]; + EXPECT_NE([window firstResponder], [window contentView]); + NSView* locationBar = [[bar_ toolbarViews] objectAtIndex:kLocationIndex]; + EXPECT_EQ([window firstResponder], [(id)locationBar currentEditor]); +} + +TEST_F(ToolbarControllerTest, LoadingState) { + // In its initial state, the reload button has a tag of + // IDC_RELOAD. When loading, it should be IDC_STOP. + NSButton* reload = [[bar_ toolbarViews] objectAtIndex:kReloadIndex]; + EXPECT_EQ([reload tag], IDC_RELOAD); + [bar_ setIsLoading:YES force:YES]; + EXPECT_EQ([reload tag], IDC_STOP); + [bar_ setIsLoading:NO force:YES]; + EXPECT_EQ([reload tag], IDC_RELOAD); +} + +// Check that toggling the state of the home button changes the visible +// state of the home button and moves the other items accordingly. +TEST_F(ToolbarControllerTest, ToggleHome) { + PrefService* prefs = helper_.profile()->GetPrefs(); + bool showHome = prefs->GetBoolean(prefs::kShowHomeButton); + NSView* homeButton = [[bar_ toolbarViews] objectAtIndex:kHomeIndex]; + EXPECT_EQ(showHome, ![homeButton isHidden]); + + NSView* locationBar = [[bar_ toolbarViews] objectAtIndex:kLocationIndex]; + NSRect originalLocationBarFrame = [locationBar frame]; + + // Toggle the pref and make sure the button changed state and the other + // views moved. + prefs->SetBoolean(prefs::kShowHomeButton, !showHome); + EXPECT_EQ(showHome, [homeButton isHidden]); + EXPECT_NE(NSMinX(originalLocationBarFrame), NSMinX([locationBar frame])); + EXPECT_NE(NSWidth(originalLocationBarFrame), NSWidth([locationBar frame])); +} + +// Ensure that we don't toggle the buttons when we have a strip marked as not +// having the full toolbar. Also ensure that the location bar doesn't change +// size. +TEST_F(ToolbarControllerTest, DontToggleWhenNoToolbar) { + [bar_ setHasToolbar:NO hasLocationBar:YES]; + NSView* homeButton = [[bar_ toolbarViews] objectAtIndex:kHomeIndex]; + NSView* locationBar = [[bar_ toolbarViews] objectAtIndex:kLocationIndex]; + NSRect locationBarFrame = [locationBar frame]; + EXPECT_EQ([homeButton isHidden], YES); + [bar_ showOptionalHomeButton]; + EXPECT_EQ([homeButton isHidden], YES); + NSRect newLocationBarFrame = [locationBar frame]; + EXPECT_TRUE(NSEqualRects(locationBarFrame, newLocationBarFrame)); + newLocationBarFrame = [locationBar frame]; + EXPECT_TRUE(NSEqualRects(locationBarFrame, newLocationBarFrame)); +} + +TEST_F(ToolbarControllerTest, BookmarkBubblePoint) { + const NSPoint starPoint = [bar_ bookmarkBubblePoint]; + const NSRect barFrame = + [[bar_ view] convertRect:[[bar_ view] bounds] toView:nil]; + + // Make sure the star is completely inside the location bar. + EXPECT_TRUE(NSPointInRect(starPoint, barFrame)); +} + +TEST_F(ToolbarControllerTest, HoverButtonForEvent) { + scoped_nsobject<HitView> view([[HitView alloc] + initWithFrame:NSMakeRect(0,0,100,100)]); + [bar_ setView:view]; + NSEvent* event = [NSEvent mouseEventWithType:NSMouseMoved + location:NSMakePoint(10,10) + modifierFlags:0 + timestamp:0 + windowNumber:0 + context:nil + eventNumber:0 + clickCount:0 + pressure:0.0]; + + // NOT a match. + [view setHitTestReturn:bar_.get()]; + EXPECT_FALSE([bar_ hoverButtonForEvent:event]); + + // Not yet... + scoped_nsobject<NSButton> button([[NSButton alloc] init]); + [view setHitTestReturn:button]; + EXPECT_FALSE([bar_ hoverButtonForEvent:event]); + + // Now! + scoped_nsobject<GradientButtonCell> cell([[GradientButtonCell alloc] init]); + [button setCell:cell.get()]; + EXPECT_TRUE([bar_ hoverButtonForEvent:nil]); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/toolbar_view.h b/chrome/browser/ui/cocoa/toolbar_view.h new file mode 100644 index 0000000..54f3135 --- /dev/null +++ b/chrome/browser/ui/cocoa/toolbar_view.h @@ -0,0 +1,26 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_TOOLBAR_VIEW_H_ +#define CHROME_BROWSER_UI_COCOA_TOOLBAR_VIEW_H_ +#pragma once + +#import <Cocoa/Cocoa.h> +#import "chrome/browser/ui/cocoa/background_gradient_view.h" + +// A view that handles any special rendering of the toolbar bar. At +// this time it only draws a gradient. Future changes (e.g. themes) +// may require new functionality here. + +@interface ToolbarView : BackgroundGradientView { + @private + // The opacity of the divider line (at the bottom of the toolbar); used when + // the detached bookmark bar is morphing to the normal bar and vice versa. + CGFloat dividerOpacity_; +} + +@property(assign, nonatomic) CGFloat dividerOpacity; +@end + +#endif // CHROME_BROWSER_UI_COCOA_TOOLBAR_VIEW_H_ diff --git a/chrome/browser/ui/cocoa/toolbar_view.mm b/chrome/browser/ui/cocoa/toolbar_view.mm new file mode 100644 index 0000000..fb4bbdd --- /dev/null +++ b/chrome/browser/ui/cocoa/toolbar_view.mm @@ -0,0 +1,47 @@ + // Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/toolbar_view.h" + +#import "chrome/browser/ui/cocoa/themed_window.h" +#import "chrome/browser/ui/cocoa/view_id_util.h" + +@implementation ToolbarView + +@synthesize dividerOpacity = dividerOpacity_; + +// Prevent mouse down events from moving the parent window around. +- (BOOL)mouseDownCanMoveWindow { + return NO; +} + +- (void)drawRect:(NSRect)rect { + // The toolbar's background pattern is phased relative to the + // tab strip view's background pattern. + NSPoint phase = [[self window] themePatternPhase]; + [[NSGraphicsContext currentContext] setPatternPhase:phase]; + [self drawBackground]; +} + +// Override of |-[BackgroundGradientView strokeColor]|; make it respect opacity. +- (NSColor*)strokeColor { + return [[super strokeColor] colorWithAlphaComponent:[self dividerOpacity]]; +} + +- (BOOL)accessibilityIsIgnored { + return NO; +} + +- (id)accessibilityAttributeValue:(NSString*)attribute { + if ([attribute isEqual:NSAccessibilityRoleAttribute]) + return NSAccessibilityToolbarRole; + + return [super accessibilityAttributeValue:attribute]; +} + +- (ViewID)viewID { + return VIEW_ID_TOOLBAR; +} + +@end diff --git a/chrome/browser/ui/cocoa/toolbar_view_unittest.mm b/chrome/browser/ui/cocoa/toolbar_view_unittest.mm new file mode 100644 index 0000000..242b618 --- /dev/null +++ b/chrome/browser/ui/cocoa/toolbar_view_unittest.mm @@ -0,0 +1,23 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/toolbar_view.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +namespace { + +class ToolbarViewTest : public CocoaTest { +}; + +// This class only needs to do one thing: prevent mouse down events from moving +// the parent window around. +TEST_F(ToolbarViewTest, CanDragWindow) { + scoped_nsobject<ToolbarView> view([[ToolbarView alloc] init]); + EXPECT_FALSE([view mouseDownCanMoveWindow]); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/translate/after_translate_infobar_controller.h b/chrome/browser/ui/cocoa/translate/after_translate_infobar_controller.h new file mode 100644 index 0000000..80a5091 --- /dev/null +++ b/chrome/browser/ui/cocoa/translate/after_translate_infobar_controller.h @@ -0,0 +1,11 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/translate/translate_infobar_base.h" + +@interface AfterTranslateInfobarController : TranslateInfoBarControllerBase { + bool swappedLanugageButtons_; +} + +@end diff --git a/chrome/browser/ui/cocoa/translate/after_translate_infobar_controller.mm b/chrome/browser/ui/cocoa/translate/after_translate_infobar_controller.mm new file mode 100644 index 0000000..54ab77f --- /dev/null +++ b/chrome/browser/ui/cocoa/translate/after_translate_infobar_controller.mm @@ -0,0 +1,60 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/translate/after_translate_infobar_controller.h" +#include "base/sys_string_conversions.h" + +using TranslateInfoBarUtilities::MoveControl; +using TranslateInfoBarUtilities::VerifyControlOrderAndSpacing; + +@implementation AfterTranslateInfobarController + +- (void)loadLabelText { + std::vector<string16> strings; + TranslateInfoBarDelegate::GetAfterTranslateStrings( + &strings, &swappedLanugageButtons_); + DCHECK(strings.size() == 3U); + NSString* string1 = base::SysUTF16ToNSString(strings[0]); + NSString* string2 = base::SysUTF16ToNSString(strings[1]); + NSString* string3 = base::SysUTF16ToNSString(strings[2]); + + [label1_ setStringValue:string1]; + [label2_ setStringValue:string2]; + [label3_ setStringValue:string3]; +} + +- (void)layout { + [self removeOkCancelButtons]; + [optionsPopUp_ setHidden:NO]; + NSView* firstPopup = fromLanguagePopUp_; + NSView* lastPopup = toLanguagePopUp_; + if (swappedLanugageButtons_) { + firstPopup = toLanguagePopUp_; + lastPopup = fromLanguagePopUp_; + } + NSView* lastControl = lastPopup; + + MoveControl(label1_, firstPopup, spaceBetweenControls_ / 2, true); + MoveControl(firstPopup, label2_, spaceBetweenControls_ / 2, true); + MoveControl(label2_, lastPopup, spaceBetweenControls_ / 2, true); + MoveControl(lastPopup, label3_, 0, true); + lastControl = label3_; + + MoveControl(lastControl, showOriginalButton_, spaceBetweenControls_ * 2, + true); +} + +- (NSArray*)visibleControls { + return [NSArray arrayWithObjects:label1_.get(), fromLanguagePopUp_.get(), + label2_.get(), toLanguagePopUp_.get(), label3_.get(), + showOriginalButton_.get(), nil]; +} + +- (bool)verifyLayout { + if ([optionsPopUp_ isHidden]) + return false; + return [super verifyLayout]; +} + +@end diff --git a/chrome/browser/ui/cocoa/translate/before_translate_infobar_controller.h b/chrome/browser/ui/cocoa/translate/before_translate_infobar_controller.h new file mode 100644 index 0000000..a96f9af --- /dev/null +++ b/chrome/browser/ui/cocoa/translate/before_translate_infobar_controller.h @@ -0,0 +1,23 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/translate/translate_infobar_base.h" + +@interface BeforeTranslateInfobarController : TranslateInfoBarControllerBase { + scoped_nsobject<NSButton> alwaysTranslateButton_; + scoped_nsobject<NSButton> neverTranslateButton_; +} + +// Creates and initializes the alwaysTranslate and neverTranslate buttons. +- (void)initializeExtraControls; + +@end + +@interface BeforeTranslateInfobarController (TestingAPI) + +- (NSButton*)alwaysTranslateButton; +- (NSButton*)neverTranslateButton; + +@end + diff --git a/chrome/browser/ui/cocoa/translate/before_translate_infobar_controller.mm b/chrome/browser/ui/cocoa/translate/before_translate_infobar_controller.mm new file mode 100644 index 0000000..0beedad --- /dev/null +++ b/chrome/browser/ui/cocoa/translate/before_translate_infobar_controller.mm @@ -0,0 +1,123 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/translate/before_translate_infobar_controller.h" + +#include "app/l10n_util.h" +#include "base/sys_string_conversions.h" +#include "grit/generated_resources.h" + +using TranslateInfoBarUtilities::MoveControl; +using TranslateInfoBarUtilities::VerifyControlOrderAndSpacing; + +namespace { + +NSButton* CreateNSButtonWithResourceIDAndParameter( + int resourceId, const string16& param) { + string16 title = l10n_util::GetStringFUTF16(resourceId, param); + NSButton* button = [[NSButton alloc] init]; + [button setTitle:base::SysUTF16ToNSString(title)]; + [button setBezelStyle:NSTexturedRoundedBezelStyle]; + return button; +} + +} // namespace + +@implementation BeforeTranslateInfobarController + +- (id) initWithDelegate:(InfoBarDelegate *)delegate { + if ((self = [super initWithDelegate:delegate])) { + [self initializeExtraControls]; + } + return self; +} + +- (void)initializeExtraControls { + TranslateInfoBarDelegate* delegate = [self delegate]; + const string16& language = delegate->GetLanguageDisplayableNameAt( + delegate->original_language_index()); + neverTranslateButton_.reset( + CreateNSButtonWithResourceIDAndParameter( + IDS_TRANSLATE_INFOBAR_NEVER_TRANSLATE, language)); + [neverTranslateButton_ setTarget:self]; + [neverTranslateButton_ setAction:@selector(neverTranslate:)]; + + alwaysTranslateButton_.reset( + CreateNSButtonWithResourceIDAndParameter( + IDS_TRANSLATE_INFOBAR_ALWAYS_TRANSLATE, language)); + [alwaysTranslateButton_ setTarget:self]; + [alwaysTranslateButton_ setAction:@selector(alwaysTranslate:)]; +} + +- (void)layout { + MoveControl(label1_, fromLanguagePopUp_, spaceBetweenControls_ / 2, true); + MoveControl(fromLanguagePopUp_, label2_, spaceBetweenControls_, true); + MoveControl(label2_, okButton_, spaceBetweenControls_, true); + MoveControl(okButton_, cancelButton_, spaceBetweenControls_, true); + NSView* lastControl = cancelButton_; + if (neverTranslateButton_.get()) { + MoveControl(lastControl, neverTranslateButton_.get(), + spaceBetweenControls_, true); + lastControl = neverTranslateButton_.get(); + } + if (alwaysTranslateButton_.get()) { + MoveControl(lastControl, alwaysTranslateButton_.get(), + spaceBetweenControls_, true); + } +} + +- (void)loadLabelText { + size_t offset = 0; + string16 text = + l10n_util::GetStringFUTF16(IDS_TRANSLATE_INFOBAR_BEFORE_MESSAGE, + string16(), &offset); + NSString* string1 = base::SysUTF16ToNSString(text.substr(0, offset)); + NSString* string2 = base::SysUTF16ToNSString(text.substr(offset)); + [label1_ setStringValue:string1]; + [label2_ setStringValue:string2]; + [label3_ setStringValue:@""]; +} + +- (NSArray*)visibleControls { + NSMutableArray* visibleControls = [NSMutableArray arrayWithObjects: + label1_.get(), fromLanguagePopUp_.get(), label2_.get(), + okButton_, cancelButton_, nil]; + + if ([self delegate]->ShouldShowNeverTranslateButton()) + [visibleControls addObject:neverTranslateButton_.get()]; + + if ([self delegate]->ShouldShowAlwaysTranslateButton()) + [visibleControls addObject:alwaysTranslateButton_.get()]; + + return visibleControls; +} + +// This is called when the "Never Translate [language]" button is pressed. +- (void)neverTranslate:(id)sender { + [self delegate]->NeverTranslatePageLanguage(); +} + +// This is called when the "Always Translate [language]" button is pressed. +- (void)alwaysTranslate:(id)sender { + [self delegate]->AlwaysTranslatePageLanguage(); +} + +- (bool)verifyLayout { + if ([optionsPopUp_ isHidden]) + return false; + return [super verifyLayout]; +} + +@end + +@implementation BeforeTranslateInfobarController (TestingAPI) + +- (NSButton*)alwaysTranslateButton { + return alwaysTranslateButton_.get(); +} +- (NSButton*)neverTranslateButton { + return neverTranslateButton_.get(); +} + +@end diff --git a/chrome/browser/ui/cocoa/translate/translate_infobar_base.h b/chrome/browser/ui/cocoa/translate/translate_infobar_base.h new file mode 100644 index 0000000..306dad1 --- /dev/null +++ b/chrome/browser/ui/cocoa/translate/translate_infobar_base.h @@ -0,0 +1,163 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_TRANSLATE_INFOBAR_BASE_H_ +#define CHROME_BROWSER_UI_COCOA_TRANSLATE_INFOBAR_BASE_H_ +#pragma once + +#import <Cocoa/Cocoa.h> +#import "chrome/browser/ui/cocoa/infobar_controller.h" + +#import "base/cocoa_protocols_mac.h" +#import "base/scoped_nsobject.h" +#include "base/scoped_ptr.h" +#include "chrome/browser/translate/languages_menu_model.h" +#include "chrome/browser/translate/options_menu_model.h" +#include "chrome/browser/translate/translate_infobar_delegate.h" +#include "chrome/common/translate_errors.h" + +class TranslateInfoBarMenuModel; + +#pragma mark TranslateInfoBarUtilities helper functions. +namespace TranslateInfoBarUtilities { + +// Move the |toMove| view |spacing| pixels before/after the |anchor| view. +// |after| signifies the side of |anchor| on which to place |toMove|. +void MoveControl(NSView* anchor, NSView* toMove, int spacing, bool after); + +// Vertically center |toMove| in its container. +void VerticallyCenterView(NSView *toMove); +// Check that the control |before| is ordered visually before the |after| +// control. +// Also, check that there is space between them. +bool VerifyControlOrderAndSpacing(id before, id after); + +// Creates a label control in the style we need for the translate infobar's +// labels within |bounds|. +NSTextField* CreateLabel(NSRect bounds); + +// Adds an item with the specified properties to |menu|. +void AddMenuItem(NSMenu *menu, id target, SEL selector, NSString* title, + int tag, bool enabled, bool checked); + +} // namespace + +// The base class for the three translate infobars. This class does all of the +// heavy UI lifting, while deferring to the subclass to tell it what views +// should be shown and where. Subclasses need to implement: +// - (void)layout; +// - (void)loadLabelText; +// - (void)visibleControls; +// - (bool)verifyLayout; // For testing. +@interface TranslateInfoBarControllerBase : InfoBarController<NSMenuDelegate> { + @protected + scoped_nsobject<NSTextField> label1_; + scoped_nsobject<NSTextField> label2_; + scoped_nsobject<NSTextField> label3_; + scoped_nsobject<NSPopUpButton> fromLanguagePopUp_; + scoped_nsobject<NSPopUpButton> toLanguagePopUp_; + scoped_nsobject<NSPopUpButton> optionsPopUp_; + scoped_nsobject<NSButton> showOriginalButton_; + // This is the button used in the translate message infobar. It can either be + // a "Try Again" button, or a "Show Original" button in the case that the + // page was translated from an unknown language. + scoped_nsobject<NSButton> translateMessageButton_; + + // In the current locale, are the "from" and "to" language popup menu + // flipped from what they'd appear in English. + bool swappedLanguagePlaceholders_; + + // Space between controls in pixels - read from the NIB. + CGFloat spaceBetweenControls_; + + scoped_ptr<LanguagesMenuModel> originalLanguageMenuModel_; + scoped_ptr<LanguagesMenuModel> targetLanguageMenuModel_; + scoped_ptr<OptionsMenuModel> optionsMenuModel_; +} + +// Returns the delegate as a TranslateInfoBarDelegate. +- (TranslateInfoBarDelegate*)delegate; + +// Called when the "Show Original" button is pressed. +- (IBAction)showOriginal:(id)sender; + +@end + +@interface TranslateInfoBarControllerBase (ProtectedAPI) + +// Resizes or hides the options button based on how much space is available +// so that it doesn't overlap other buttons. +// lastView is the rightmost view, the first one that the options button +// would overlap with. +- (void)adjustOptionsButtonSizeAndVisibilityForView:(NSView*)lastView; + +// Move all the currently visible views into the correct place for the +// current mode. +// Must be implemented by the subclass. +- (void)layout; + +// Loads the text for the 3 labels. There is only one message, but since +// it has controls separating parts of it, it is separated into 3 separate +// labels. +// Must be implemented by the subclass. +- (void)loadLabelText; + +// Returns the controls that are visible in the subclasses infobar. The +// default implementation returns an empty array. The controls should +// be returned in the order they are displayed, otherwise the layout test +// will fail. +// Must be implemented by the subclass. +- (NSArray*)visibleControls; + +// Shows the array of controls provided by the subclass. +- (void)showVisibleControls:(NSArray*)visibleControls; + +// Hides the OK and Cancel buttons. +- (void)removeOkCancelButtons; + +// Called when the source or target language selection changes in a menu. +// |newLanguageIdx| is the index of the newly selected item in the appropriate +// menu. +- (void)sourceLanguageModified:(NSInteger)newLanguageIdx; +- (void)targetLanguageModified:(NSInteger)newLanguageIdx; + +// Called when an item in one of the toolbar's language or options +// menus is selected. +- (void)languageMenuChanged:(id)item; +- (void)optionsMenuChanged:(id)item; + +// Teardown and rebuild the options menu. When the infobar is small, the +// options menu is shrunk to just a drop down arrow, so the title needs +// to be empty. +- (void)rebuildOptionsMenu:(BOOL)hideTitle; + +// Whether or not this infobar should show the options popup. +- (BOOL)shouldShowOptionsPopUp; + +@end // TranslateInfoBarControllerBase (ProtectedAPI) + +#pragma mark TestingAPI + +@interface TranslateInfoBarControllerBase (TestingAPI) + +// All the controls used in any of the translate states. +// This is used for verifying layout and for setting the +// correct styles on each button. +- (NSArray*)allControls; + +// Verifies that the layout of the infobar is correct. +// Must be implmented by the subclass. +- (bool)verifyLayout; + +// Returns the underlying options menu. +- (NSMenu*)optionsMenu; + +// Returns |translateMessageButton_|, see declaration of member +// variable for a full description. +- (NSButton*)translateMessageButton; + +@end // TranslateInfoBarControllerBase (TestingAPI) + + +#endif // CHROME_BROWSER_UI_COCOA_TRANSLATE_INFOBAR_BASE_H_ diff --git a/chrome/browser/ui/cocoa/translate/translate_infobar_base.mm b/chrome/browser/ui/cocoa/translate/translate_infobar_base.mm new file mode 100644 index 0000000..4a08895 --- /dev/null +++ b/chrome/browser/ui/cocoa/translate/translate_infobar_base.mm @@ -0,0 +1,642 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> +#import "chrome/browser/ui/cocoa/translate/translate_infobar_base.h" + +#include "app/l10n_util.h" +#include "base/logging.h" +#include "base/mac_util.h" +#include "base/metrics/histogram.h" +#include "base/sys_string_conversions.h" +#include "chrome/app/chrome_command_ids.h" +#include "chrome/browser/translate/translate_infobar_delegate.h" +#import "chrome/browser/ui/cocoa/hover_close_button.h" +#include "chrome/browser/ui/cocoa/infobar.h" +#import "chrome/browser/ui/cocoa/infobar_controller.h" +#import "chrome/browser/ui/cocoa/infobar_gradient_view.h" +#include "chrome/browser/ui/cocoa/translate/after_translate_infobar_controller.h" +#import "chrome/browser/ui/cocoa/translate/before_translate_infobar_controller.h" +#include "chrome/browser/ui/cocoa/translate/translate_message_infobar_controller.h" +#include "grit/generated_resources.h" +#include "third_party/GTM/AppKit/GTMUILocalizerAndLayoutTweaker.h" + +using TranslateInfoBarUtilities::MoveControl; +using TranslateInfoBarUtilities::VerticallyCenterView; +using TranslateInfoBarUtilities::VerifyControlOrderAndSpacing; +using TranslateInfoBarUtilities::CreateLabel; +using TranslateInfoBarUtilities::AddMenuItem; + +#pragma mark TranslateInfoBarUtilities helper functions. + +namespace TranslateInfoBarUtilities { + +// Move the |toMove| view |spacing| pixels before/after the |anchor| view. +// |after| signifies the side of |anchor| on which to place |toMove|. +void MoveControl(NSView* anchor, NSView* toMove, int spacing, bool after) { + NSRect anchorFrame = [anchor frame]; + NSRect toMoveFrame = [toMove frame]; + + // At the time of this writing, OS X doesn't natively support BiDi UIs, but + // it doesn't hurt to be forward looking. + bool toRight = after; + + if (toRight) { + toMoveFrame.origin.x = NSMaxX(anchorFrame) + spacing; + } else { + // Place toMove to theleft of anchor. + toMoveFrame.origin.x = NSMinX(anchorFrame) - + spacing - NSWidth(toMoveFrame); + } + [toMove setFrame:toMoveFrame]; +} + +// Check that the control |before| is ordered visually before the |after| +// control. +// Also, check that there is space between them. +bool VerifyControlOrderAndSpacing(id before, id after) { + NSRect beforeFrame = [before frame]; + NSRect afterFrame = [after frame]; + return NSMinX(afterFrame) >= NSMaxX(beforeFrame); +} + +// Vertically center |toMove| in its container. +void VerticallyCenterView(NSView *toMove) { + NSRect superViewFrame = [[toMove superview] frame]; + NSRect viewFrame = [toMove frame]; + viewFrame.origin.y = + floor((NSHeight(superViewFrame) - NSHeight(viewFrame))/2.0); + [toMove setFrame:viewFrame]; +} + +// Creates a label control in the style we need for the translate infobar's +// labels within |bounds|. +NSTextField* CreateLabel(NSRect bounds) { + NSTextField* ret = [[NSTextField alloc] initWithFrame:bounds]; + [ret setEditable:NO]; + [ret setDrawsBackground:NO]; + [ret setBordered:NO]; + return ret; +} + +// Adds an item with the specified properties to |menu|. +void AddMenuItem(NSMenu *menu, id target, SEL selector, NSString* title, + int tag, bool enabled, bool checked) { + if (tag == -1) { + [menu addItem:[NSMenuItem separatorItem]]; + } else { + NSMenuItem* item = [[[NSMenuItem alloc] + initWithTitle:title + action:selector + keyEquivalent:@""] autorelease]; + [item setTag:tag]; + [menu addItem:item]; + [item setTarget:target]; + if (checked) + [item setState:NSOnState]; + if (!enabled) + [item setEnabled:NO]; + } +} + +} // namespace TranslateInfoBarUtilities + +// TranslateInfoBarDelegate views specific method: +InfoBar* TranslateInfoBarDelegate::CreateInfoBar() { + TranslateInfoBarControllerBase* infobar_controller = NULL; + switch (type_) { + case BEFORE_TRANSLATE: + infobar_controller = + [[BeforeTranslateInfobarController alloc] initWithDelegate:this]; + break; + case AFTER_TRANSLATE: + infobar_controller = + [[AfterTranslateInfobarController alloc] initWithDelegate:this]; + break; + case TRANSLATING: + case TRANSLATION_ERROR: + infobar_controller = + [[TranslateMessageInfobarController alloc] initWithDelegate:this]; + break; + default: + NOTREACHED(); + } + return new InfoBar(infobar_controller); +} + +@implementation TranslateInfoBarControllerBase (FrameChangeObserver) + +// Triggered when the frame changes. This will figure out what size and +// visibility the options popup should be. +- (void)didChangeFrame:(NSNotification*)notification { + [self adjustOptionsButtonSizeAndVisibilityForView: + [[self visibleControls] lastObject]]; +} + +@end + + +@interface TranslateInfoBarControllerBase (Private) + +// Removes all controls so that layout can add in only the controls +// required. +- (void)clearAllControls; + +// Create all the various controls we need for the toolbar. +- (void)constructViews; + +// Reloads text for all labels for the current state. +- (void)loadLabelText:(TranslateErrors::Type)error; + +// Set the infobar background gradient. +- (void)setInfoBarGradientColor; + +// Main function to update the toolbar graphic state and data model after +// the state has changed. +// Controls are moved around as needed and visibility changed to match the +// current state. +- (void)updateState; + +// Called when the source or target language selection changes in a menu. +// |newLanguageIdx| is the index of the newly selected item in the appropriate +// menu. +- (void)sourceLanguageModified:(NSInteger)newLanguageIdx; +- (void)targetLanguageModified:(NSInteger)newLanguageIdx; + +// Completely rebuild "from" and "to" language menus from the data model. +- (void)populateLanguageMenus; + +@end + +#pragma mark TranslateInfoBarController class + +@implementation TranslateInfoBarControllerBase + +- (id)initWithDelegate:(InfoBarDelegate*)delegate { + if ((self = [super initWithDelegate:delegate])) { + originalLanguageMenuModel_.reset( + new LanguagesMenuModel([self delegate], + LanguagesMenuModel::ORIGINAL)); + + targetLanguageMenuModel_.reset( + new LanguagesMenuModel([self delegate], + LanguagesMenuModel::TARGET)); + } + return self; +} + +- (TranslateInfoBarDelegate*)delegate { + return reinterpret_cast<TranslateInfoBarDelegate*>(delegate_); +} + +- (void)constructViews { + // Using a zero or very large frame causes GTMUILocalizerAndLayoutTweaker + // to not resize the view properly so we take the bounds of the first label + // which is contained in the nib. + NSRect bogusFrame = [label_ frame]; + label1_.reset(CreateLabel(bogusFrame)); + label2_.reset(CreateLabel(bogusFrame)); + label3_.reset(CreateLabel(bogusFrame)); + + optionsPopUp_.reset([[NSPopUpButton alloc] initWithFrame:bogusFrame + pullsDown:YES]); + fromLanguagePopUp_.reset([[NSPopUpButton alloc] initWithFrame:bogusFrame + pullsDown:NO]); + toLanguagePopUp_.reset([[NSPopUpButton alloc] initWithFrame:bogusFrame + pullsDown:NO]); + showOriginalButton_.reset([[NSButton alloc] init]); + translateMessageButton_.reset([[NSButton alloc] init]); +} + +- (void)sourceLanguageModified:(NSInteger)newLanguageIdx { + DCHECK_GT(newLanguageIdx, -1); + if (newLanguageIdx == [self delegate]->original_language_index()) + return; + [self delegate]->SetOriginalLanguage(newLanguageIdx); + int commandId = IDC_TRANSLATE_ORIGINAL_LANGUAGE_BASE + newLanguageIdx; + int newMenuIdx = [fromLanguagePopUp_ indexOfItemWithTag:commandId]; + [fromLanguagePopUp_ selectItemAtIndex:newMenuIdx]; +} + +- (void)targetLanguageModified:(NSInteger)newLanguageIdx { + DCHECK_GT(newLanguageIdx, -1); + if (newLanguageIdx == [self delegate]->target_language_index()) + return; + [self delegate]->SetTargetLanguage(newLanguageIdx); + int commandId = IDC_TRANSLATE_TARGET_LANGUAGE_BASE + newLanguageIdx; + int newMenuIdx = [toLanguagePopUp_ indexOfItemWithTag:commandId]; + [toLanguagePopUp_ selectItemAtIndex:newMenuIdx]; +} + +- (void)loadLabelText { + // Do nothing by default, should be implemented by subclasses. +} + +- (void)updateState { + [self loadLabelText]; + [self clearAllControls]; + [self showVisibleControls:[self visibleControls]]; + [optionsPopUp_ setHidden:![self shouldShowOptionsPopUp]]; + [self layout]; + [self adjustOptionsButtonSizeAndVisibilityForView: + [[self visibleControls] lastObject]]; +} + +- (void)setInfoBarGradientColor { + NSColor* startingColor = [NSColor colorWithCalibratedWhite:0.93 alpha:1.0]; + NSColor* endingColor = [NSColor colorWithCalibratedWhite:0.85 alpha:1.0]; + NSGradient* translateInfoBarGradient = + [[[NSGradient alloc] initWithStartingColor:startingColor + endingColor:endingColor] autorelease]; + + [infoBarView_ setGradient:translateInfoBarGradient]; + [infoBarView_ + setStrokeColor:[NSColor colorWithCalibratedWhite:0.75 alpha:1.0]]; +} + +- (void)removeOkCancelButtons { + // Removing okButton_ & cancelButton_ from the view may cause them + // to be released and since we can still access them from other areas + // in the code later, we need them to be nil when this happens. + [okButton_ removeFromSuperview]; + okButton_ = nil; + [cancelButton_ removeFromSuperview]; + cancelButton_ = nil; +} + +- (void)clearAllControls { + // Step 1: remove all controls from the infobar so we have a clean slate. + NSArray *allControls = [self allControls]; + + for (NSControl* control in allControls) { + if ([control superview]) + [control removeFromSuperview]; + } +} + +- (void)showVisibleControls:(NSArray*)visibleControls { + NSRect optionsFrame = [optionsPopUp_ frame]; + for (NSControl* control in visibleControls) { + [GTMUILocalizerAndLayoutTweaker sizeToFitView:control]; + [control setAutoresizingMask:NSViewMaxXMargin | NSViewMinYMargin | + NSViewMaxYMargin]; + + // Need to check if a view is already attached since |label1_| is always + // parented and we don't want to add it again. + if (![control superview]) + [infoBarView_ addSubview:control]; + + if ([control isKindOfClass:[NSButton class]]) + VerticallyCenterView(control); + + // Make "from" and "to" language popup menus the same size as the options + // menu. + // We don't autosize since some languages names are really long causing + // the toolbar to overflow. + if ([control isKindOfClass:[NSPopUpButton class]]) + [control setFrame:optionsFrame]; + } +} + +- (void)layout { + +} + +- (NSArray*)visibleControls { + return [NSArray array]; +} + +- (void)rebuildOptionsMenu:(BOOL)hideTitle { + if (![self shouldShowOptionsPopUp]) + return; + + // The options model doesn't know how to handle state transitions, so rebuild + // it each time through here. + optionsMenuModel_.reset( + new OptionsMenuModel([self delegate])); + + [optionsPopUp_ removeAllItems]; + // Set title. + NSString* optionsLabel = hideTitle ? @"" : + l10n_util::GetNSString(IDS_TRANSLATE_INFOBAR_OPTIONS); + [optionsPopUp_ addItemWithTitle:optionsLabel]; + + // Populate options menu. + NSMenu* optionsMenu = [optionsPopUp_ menu]; + [optionsMenu setAutoenablesItems:NO]; + for (int i = 0; i < optionsMenuModel_->GetItemCount(); ++i) { + NSString* title = base::SysUTF16ToNSString( + optionsMenuModel_->GetLabelAt(i)); + int cmd = optionsMenuModel_->GetCommandIdAt(i); + bool checked = optionsMenuModel_->IsItemCheckedAt(i); + bool enabled = optionsMenuModel_->IsEnabledAt(i); + AddMenuItem(optionsMenu, + self, + @selector(optionsMenuChanged:), + title, + cmd, + enabled, + checked); + } +} + +- (BOOL)shouldShowOptionsPopUp { + return YES; +} + +- (void)populateLanguageMenus { + NSMenu* originalLanguageMenu = [fromLanguagePopUp_ menu]; + [originalLanguageMenu setAutoenablesItems:NO]; + int selectedMenuIndex = 0; + int selectedLangIndex = [self delegate]->original_language_index(); + for (int i = 0; i < originalLanguageMenuModel_->GetItemCount(); ++i) { + NSString* title = base::SysUTF16ToNSString( + originalLanguageMenuModel_->GetLabelAt(i)); + int cmd = originalLanguageMenuModel_->GetCommandIdAt(i); + bool checked = (cmd == selectedLangIndex); + if (checked) + selectedMenuIndex = i; + bool enabled = originalLanguageMenuModel_->IsEnabledAt(i); + cmd += IDC_TRANSLATE_ORIGINAL_LANGUAGE_BASE; + AddMenuItem(originalLanguageMenu, + self, + @selector(languageMenuChanged:), + title, + cmd, + enabled, + checked); + } + [fromLanguagePopUp_ selectItemAtIndex:selectedMenuIndex]; + + NSMenu* targetLanguageMenu = [toLanguagePopUp_ menu]; + [targetLanguageMenu setAutoenablesItems:NO]; + selectedLangIndex = [self delegate]->target_language_index(); + for (int i = 0; i < targetLanguageMenuModel_->GetItemCount(); ++i) { + NSString* title = base::SysUTF16ToNSString( + targetLanguageMenuModel_->GetLabelAt(i)); + int cmd = targetLanguageMenuModel_->GetCommandIdAt(i); + bool checked = (cmd == selectedLangIndex); + if (checked) + selectedMenuIndex = i; + bool enabled = targetLanguageMenuModel_->IsEnabledAt(i); + cmd += IDC_TRANSLATE_TARGET_LANGUAGE_BASE; + AddMenuItem(targetLanguageMenu, + self, + @selector(languageMenuChanged:), + title, + cmd, + enabled, + checked); + } + [toLanguagePopUp_ selectItemAtIndex:selectedMenuIndex]; +} + +- (void)addAdditionalControls { + using l10n_util::GetNSString; + using l10n_util::GetNSStringWithFixup; + + // Get layout information from the NIB. + NSRect okButtonFrame = [okButton_ frame]; + NSRect cancelButtonFrame = [cancelButton_ frame]; + spaceBetweenControls_ = NSMinX(cancelButtonFrame) - NSMaxX(okButtonFrame); + + // Set infobar background color. + [self setInfoBarGradientColor]; + + // Instantiate additional controls. + [self constructViews]; + + // Set ourselves as the delegate for the options menu so we can populate it + // dynamically. + [[optionsPopUp_ menu] setDelegate:self]; + + // Replace label_ with label1_ so we get a consistent look between all the + // labels we display in the translate view. + [[label_ superview] replaceSubview:label_ with:label1_.get()]; + label_.reset(); // Now released. + + // Populate contextual menus. + [self rebuildOptionsMenu:NO]; + [self populateLanguageMenus]; + + // Set OK & Cancel text. + [okButton_ setTitle:GetNSStringWithFixup(IDS_TRANSLATE_INFOBAR_ACCEPT)]; + [cancelButton_ setTitle:GetNSStringWithFixup(IDS_TRANSLATE_INFOBAR_DENY)]; + + // Set up "Show original" and "Try again" buttons. + [showOriginalButton_ setFrame:okButtonFrame]; + + // Set each of the buttons and popups to the NSTexturedRoundedBezelStyle + // (metal-looking) style. + NSArray* allControls = [self allControls]; + for (NSControl* control in allControls) { + if (![control isKindOfClass:[NSButton class]]) + continue; + NSButton* button = (NSButton*)control; + [button setBezelStyle:NSTexturedRoundedBezelStyle]; + if ([button isKindOfClass:[NSPopUpButton class]]) { + [[button cell] setArrowPosition:NSPopUpArrowAtBottom]; + } + } + // The options button is handled differently than the rest as it floats + // to the right. + [optionsPopUp_ setBezelStyle:NSTexturedRoundedBezelStyle]; + [[optionsPopUp_ cell] setArrowPosition:NSPopUpArrowAtBottom]; + + [showOriginalButton_ setTarget:self]; + [showOriginalButton_ setAction:@selector(showOriginal:)]; + [translateMessageButton_ setTarget:self]; + [translateMessageButton_ setAction:@selector(messageButtonPressed:)]; + + [showOriginalButton_ + setTitle:GetNSStringWithFixup(IDS_TRANSLATE_INFOBAR_REVERT)]; + + // Add and configure controls that are visible in all modes. + [optionsPopUp_ setAutoresizingMask:NSViewMinXMargin | NSViewMinYMargin | + NSViewMaxYMargin]; + // Add "options" popup z-ordered below all other controls so when we + // resize the toolbar it doesn't hide them. + [infoBarView_ addSubview:optionsPopUp_ + positioned:NSWindowBelow + relativeTo:nil]; + [GTMUILocalizerAndLayoutTweaker sizeToFitView:optionsPopUp_]; + MoveControl(closeButton_, optionsPopUp_, spaceBetweenControls_, false); + VerticallyCenterView(optionsPopUp_); + + [infoBarView_ setPostsFrameChangedNotifications:YES]; + [[NSNotificationCenter defaultCenter] + addObserver:self + selector:@selector(didChangeFrame:) + name:NSViewFrameDidChangeNotification + object:infoBarView_]; + // Show and place GUI elements. + [self updateState]; +} + +- (void)adjustOptionsButtonSizeAndVisibilityForView:(NSView*)lastView { + [optionsPopUp_ setHidden:NO]; + [self rebuildOptionsMenu:NO]; + [[optionsPopUp_ cell] setArrowPosition:NSPopUpArrowAtBottom]; + [optionsPopUp_ sizeToFit]; + + MoveControl(closeButton_, optionsPopUp_, spaceBetweenControls_, false); + if (!VerifyControlOrderAndSpacing(lastView, optionsPopUp_)) { + [self rebuildOptionsMenu:YES]; + NSRect oldFrame = [optionsPopUp_ frame]; + oldFrame.size.width = NSHeight(oldFrame); + [optionsPopUp_ setFrame:oldFrame]; + [[optionsPopUp_ cell] setArrowPosition:NSPopUpArrowAtCenter]; + MoveControl(closeButton_, optionsPopUp_, spaceBetweenControls_, false); + if (!VerifyControlOrderAndSpacing(lastView, optionsPopUp_)) { + [optionsPopUp_ setHidden:YES]; + } + } +} + +// Called when "Translate" button is clicked. +- (IBAction)ok:(id)sender { + TranslateInfoBarDelegate* delegate = [self delegate]; + TranslateInfoBarDelegate::Type state = delegate->type(); + DCHECK(state == TranslateInfoBarDelegate::BEFORE_TRANSLATE || + state == TranslateInfoBarDelegate::TRANSLATION_ERROR); + delegate->Translate(); + UMA_HISTOGRAM_COUNTS("Translate.Translate", 1); +} + +// Called when someone clicks on the "Nope" button. +- (IBAction)cancel:(id)sender { + DCHECK( + [self delegate]->type() == TranslateInfoBarDelegate::BEFORE_TRANSLATE); + [self delegate]->TranslationDeclined(); + UMA_HISTOGRAM_COUNTS("Translate.DeclineTranslate", 1); + [super dismiss:nil]; +} + +- (void)messageButtonPressed:(id)sender { + [self delegate]->MessageInfoBarButtonPressed(); +} + +- (IBAction)showOriginal:(id)sender { + [self delegate]->RevertTranslation(); +} + +// Called when any of the language drop down menus are changed. +- (void)languageMenuChanged:(id)item { + if ([item respondsToSelector:@selector(tag)]) { + int cmd = [item tag]; + if (cmd >= IDC_TRANSLATE_TARGET_LANGUAGE_BASE) { + cmd -= IDC_TRANSLATE_TARGET_LANGUAGE_BASE; + [self targetLanguageModified:cmd]; + return; + } else if (cmd >= IDC_TRANSLATE_ORIGINAL_LANGUAGE_BASE) { + cmd -= IDC_TRANSLATE_ORIGINAL_LANGUAGE_BASE; + [self sourceLanguageModified:cmd]; + return; + } + } + NOTREACHED() << "Language menu was changed with a bad language ID"; +} + +// Called when the options menu is changed. +- (void)optionsMenuChanged:(id)item { + if ([item respondsToSelector:@selector(tag)]) { + int cmd = [item tag]; + // Danger Will Robinson! : This call can release the infobar (e.g. invoking + // "About Translate" can open a new tab). + // Do not access member variables after this line! + optionsMenuModel_->ExecuteCommand(cmd); + } else { + NOTREACHED(); + } +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [super dealloc]; +} + +#pragma mark NSMenuDelegate + +// Invoked by virtue of us being set as the delegate for the options menu. +- (void)menuNeedsUpdate:(NSMenu *)menu { + [self adjustOptionsButtonSizeAndVisibilityForView: + [[self visibleControls] lastObject]]; +} + +@end + +@implementation TranslateInfoBarControllerBase (TestingAPI) + +- (NSArray*)allControls { + return [NSArray arrayWithObjects:label1_.get(),fromLanguagePopUp_.get(), + label2_.get(), toLanguagePopUp_.get(), label3_.get(), okButton_, + cancelButton_, showOriginalButton_.get(), translateMessageButton_.get(), + nil]; +} + +- (NSMenu*)optionsMenu { + return [optionsPopUp_ menu]; +} + +- (NSButton*)translateMessageButton { + return translateMessageButton_.get(); +} + +- (bool)verifyLayout { + // All the controls available to translate infobars, except the options popup. + // The options popup is shown/hidden instead of actually removed. This gets + // checked in the subclasses. + NSArray* allControls = [self allControls]; + NSArray* visibleControls = [self visibleControls]; + + // Step 1: Make sure control visibility is what we expect. + for (NSUInteger i = 0; i < [allControls count]; ++i) { + id control = [allControls objectAtIndex:i]; + bool hasSuperView = [control superview]; + bool expectedVisibility = [visibleControls containsObject:control]; + + if (expectedVisibility != hasSuperView) { + NSString *title = @""; + if ([control isKindOfClass:[NSPopUpButton class]]) { + title = [[[control menu] itemAtIndex:0] title]; + } + + LOG(ERROR) << + "State: " << [self description] << + " Control @" << i << (hasSuperView ? " has" : " doesn't have") << + " a superview" << [[control description] UTF8String] << + " Title=" << [title UTF8String]; + return false; + } + } + + // Step 2: Check that controls are ordered correctly with no overlap. + id previousControl = nil; + for (NSUInteger i = 0; i < [visibleControls count]; ++i) { + id control = [visibleControls objectAtIndex:i]; + // The options pop up doesn't lay out like the rest of the controls as + // it floats to the right. It has some known issues shown in + // http://crbug.com/47941. + if (control == optionsPopUp_.get()) + continue; + if (previousControl && + !VerifyControlOrderAndSpacing(previousControl, control)) { + NSString *title = @""; + if ([control isKindOfClass:[NSPopUpButton class]]) { + title = [[[control menu] itemAtIndex:0] title]; + } + LOG(ERROR) << + "State: " << [self description] << + " Control @" << i << " not ordered correctly: " << + [[control description] UTF8String] <<[title UTF8String]; + return false; + } + previousControl = control; + } + + return true; +} + +@end // TranslateInfoBarControllerBase (TestingAPI) + diff --git a/chrome/browser/ui/cocoa/translate/translate_infobar_unittest.mm b/chrome/browser/ui/cocoa/translate/translate_infobar_unittest.mm new file mode 100644 index 0000000..9ee57a4 --- /dev/null +++ b/chrome/browser/ui/cocoa/translate/translate_infobar_unittest.mm @@ -0,0 +1,254 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#import "base/scoped_nsobject.h" +#import "base/string_util.h" +#include "base/utf_string_conversions.h" +#import "chrome/app/chrome_command_ids.h" // For translate menu command ids. +#import "chrome/browser/renderer_host/site_instance.h" +#import "chrome/browser/tab_contents/tab_contents.h" +#import "chrome/browser/translate/translate_infobar_delegate.h" +#import "chrome/browser/ui/cocoa/browser_test_helper.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/infobar.h" +#import "chrome/browser/ui/cocoa/translate/translate_infobar_base.h" +#import "chrome/browser/ui/cocoa/translate/before_translate_infobar_controller.h" +#import "testing/gmock/include/gmock/gmock.h" +#import "testing/gtest/include/gtest/gtest.h" +#import "testing/platform_test.h" + +namespace { + +// All states the translate toolbar can assume. +TranslateInfoBarDelegate::Type kTranslateToolbarStates[] = { + TranslateInfoBarDelegate::BEFORE_TRANSLATE, + TranslateInfoBarDelegate::AFTER_TRANSLATE, + TranslateInfoBarDelegate::TRANSLATING, + TranslateInfoBarDelegate::TRANSLATION_ERROR +}; + +class MockTranslateInfoBarDelegate : public TranslateInfoBarDelegate { + public: + MockTranslateInfoBarDelegate(TranslateInfoBarDelegate::Type type, + TranslateErrors::Type error, + TabContents* contents) + : TranslateInfoBarDelegate(type, error, contents, "en", "es"){ + // Start out in the "Before Translate" state. + type_ = type; + + } + + virtual string16 GetDisplayNameForLocale(const std::string& language_code) { + return ASCIIToUTF16("Foo"); + } + + virtual bool IsLanguageBlacklisted() { + return false; + } + + virtual bool IsSiteBlacklisted() { + return false; + } + + virtual bool ShouldAlwaysTranslate() { + return false; + } + + MOCK_METHOD0(Translate, void()); + MOCK_METHOD0(RevertTranslation, void()); + MOCK_METHOD0(TranslationDeclined, void()); + MOCK_METHOD0(ToggleLanguageBlacklist, void()); + MOCK_METHOD0(ToggleSiteBlacklist, void()); + MOCK_METHOD0(ToggleAlwaysTranslate, void()); +}; + +class TranslationInfoBarTest : public CocoaTest { + public: + BrowserTestHelper browser_helper_; + scoped_ptr<TabContents> tab_contents; + scoped_ptr<MockTranslateInfoBarDelegate> infobar_delegate; + scoped_nsobject<TranslateInfoBarControllerBase> infobar_controller; + + public: + // Each test gets a single Mock translate delegate for the lifetime of + // the test. + virtual void SetUp() { + CocoaTest::SetUp(); + tab_contents.reset( + new TabContents(browser_helper_.profile(), + NULL, + MSG_ROUTING_NONE, + NULL, + NULL)); + CreateInfoBar(); + } + + void CreateInfoBar() { + CreateInfoBar(TranslateInfoBarDelegate::BEFORE_TRANSLATE); + } + + void CreateInfoBar(TranslateInfoBarDelegate::Type type) { + TranslateErrors::Type error = TranslateErrors::NONE; + if (type == TranslateInfoBarDelegate::TRANSLATION_ERROR) + error = TranslateErrors::NETWORK; + infobar_delegate.reset( + new MockTranslateInfoBarDelegate(type, error, tab_contents.get())); + [[infobar_controller view] removeFromSuperview]; + scoped_ptr<InfoBar> infobar(infobar_delegate->CreateInfoBar()); + infobar_controller.reset( + reinterpret_cast<TranslateInfoBarControllerBase*>( + infobar->controller())); + // We need to set the window to be wide so that the options button + // doesn't overlap the other buttons. + [test_window() setContentSize:NSMakeSize(2000, 500)]; + [[infobar_controller view] setFrame:NSMakeRect(0, 0, 2000, 500)]; + [[test_window() contentView] addSubview:[infobar_controller view]]; + } +}; + +// Check that we can instantiate a Translate Infobar correctly. +TEST_F(TranslationInfoBarTest, Instantiate) { + CreateInfoBar(); + ASSERT_TRUE(infobar_controller.get()); +} + +// Check that clicking the Translate button calls Translate(). +TEST_F(TranslationInfoBarTest, TranslateCalledOnButtonPress) { + CreateInfoBar(); + + EXPECT_CALL(*infobar_delegate, Translate()).Times(1); + [infobar_controller ok:nil]; +} + +// Check that clicking the "Retry" button calls Translate() when we're +// in the error mode - http://crbug.com/41315 . +TEST_F(TranslationInfoBarTest, TranslateCalledInErrorMode) { + CreateInfoBar(TranslateInfoBarDelegate::TRANSLATION_ERROR); + + EXPECT_CALL(*infobar_delegate, Translate()).Times(1); + + [infobar_controller ok:nil]; +} + +// Check that clicking the "Show Original button calls RevertTranslation(). +TEST_F(TranslationInfoBarTest, RevertCalledOnButtonPress) { + CreateInfoBar(); + + EXPECT_CALL(*infobar_delegate, RevertTranslation()).Times(1); + [infobar_controller showOriginal:nil]; +} + +// Check that items in the options menu are hooked up correctly. +TEST_F(TranslationInfoBarTest, OptionsMenuItemsHookedUp) { + EXPECT_CALL(*infobar_delegate, Translate()) + .Times(0); + + [infobar_controller rebuildOptionsMenu:NO]; + NSMenu* optionsMenu = [infobar_controller optionsMenu]; + NSArray* optionsMenuItems = [optionsMenu itemArray]; + + EXPECT_EQ(7U, [optionsMenuItems count]); + + // First item is the options menu button's title, so there's no need to test + // that the target on that is setup correctly. + for (NSUInteger i = 1; i < [optionsMenuItems count]; ++i) { + NSMenuItem* item = [optionsMenuItems objectAtIndex:i]; + if (![item isSeparatorItem]) + EXPECT_EQ([item target], infobar_controller.get()); + } + NSMenuItem* alwaysTranslateLanguateItem = [optionsMenuItems objectAtIndex:1]; + NSMenuItem* neverTranslateLanguateItem = [optionsMenuItems objectAtIndex:2]; + NSMenuItem* neverTranslateSiteItem = [optionsMenuItems objectAtIndex:3]; + // Separator at 4. + NSMenuItem* reportBadLanguageItem = [optionsMenuItems objectAtIndex:5]; + NSMenuItem* aboutTranslateItem = [optionsMenuItems objectAtIndex:6]; + + { + EXPECT_CALL(*infobar_delegate, ToggleAlwaysTranslate()) + .Times(1); + [infobar_controller optionsMenuChanged:alwaysTranslateLanguateItem]; + } + + { + EXPECT_CALL(*infobar_delegate, ToggleLanguageBlacklist()) + .Times(1); + [infobar_controller optionsMenuChanged:neverTranslateLanguateItem]; + } + + { + EXPECT_CALL(*infobar_delegate, ToggleSiteBlacklist()) + .Times(1); + [infobar_controller optionsMenuChanged:neverTranslateSiteItem]; + } + + { + // Can't mock these effectively, so just check that the tag is set + // correctly. + EXPECT_EQ(IDC_TRANSLATE_REPORT_BAD_LANGUAGE_DETECTION, + [reportBadLanguageItem tag]); + EXPECT_EQ(IDC_TRANSLATE_OPTIONS_ABOUT, [aboutTranslateItem tag]); + } +} + +// Check that selecting a new item from the "Source Language" popup in "before +// translate" mode doesn't trigger a translation or change state. +// http://crbug.com/36666 +TEST_F(TranslationInfoBarTest, Bug36666) { + EXPECT_CALL(*infobar_delegate, Translate()) + .Times(0); + + CreateInfoBar(); + int arbitrary_index = 2; + [infobar_controller sourceLanguageModified:arbitrary_index]; + EXPECT_CALL(*infobar_delegate, Translate()) + .Times(0); +} + +// Check that the infobar lays itself out correctly when instantiated in +// each of the states. +// http://crbug.com/36895 +TEST_F(TranslationInfoBarTest, Bug36895) { + EXPECT_CALL(*infobar_delegate, Translate()) + .Times(0); + + for (size_t i = 0; i < arraysize(kTranslateToolbarStates); ++i) { + CreateInfoBar(kTranslateToolbarStates[i]); + EXPECT_TRUE( + [infobar_controller verifyLayout]) << "Layout wrong, for state #" << i; + } +} + +// Verify that the infobar shows the "Always translate this language" button +// after doing 3 translations. +TEST_F(TranslationInfoBarTest, TriggerShowAlwaysTranslateButton) { + TranslatePrefs translate_prefs(browser_helper_.profile()->GetPrefs()); + translate_prefs.ResetTranslationAcceptedCount("en"); + for (int i = 0; i < 4; ++i) { + translate_prefs.IncrementTranslationAcceptedCount("en"); + } + CreateInfoBar(TranslateInfoBarDelegate::BEFORE_TRANSLATE); + BeforeTranslateInfobarController* controller = + (BeforeTranslateInfobarController*)infobar_controller.get(); + EXPECT_TRUE([[controller alwaysTranslateButton] superview] != nil); + EXPECT_TRUE([[controller neverTranslateButton] superview] == nil); +} + +// Verify that the infobar shows the "Never translate this language" button +// after denying 3 translations. +TEST_F(TranslationInfoBarTest, TriggerShowNeverTranslateButton) { + TranslatePrefs translate_prefs(browser_helper_.profile()->GetPrefs()); + translate_prefs.ResetTranslationDeniedCount("en"); + for (int i = 0; i < 4; ++i) { + translate_prefs.IncrementTranslationDeniedCount("en"); + } + CreateInfoBar(TranslateInfoBarDelegate::BEFORE_TRANSLATE); + BeforeTranslateInfobarController* controller = + (BeforeTranslateInfobarController*)infobar_controller.get(); + EXPECT_TRUE([[controller alwaysTranslateButton] superview] == nil); + EXPECT_TRUE([[controller neverTranslateButton] superview] != nil); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/translate/translate_message_infobar_controller.h b/chrome/browser/ui/cocoa/translate/translate_message_infobar_controller.h new file mode 100644 index 0000000..8a85403 --- /dev/null +++ b/chrome/browser/ui/cocoa/translate/translate_message_infobar_controller.h @@ -0,0 +1,10 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/translate/translate_infobar_base.h" + +@interface TranslateMessageInfobarController : TranslateInfoBarControllerBase { +} + +@end diff --git a/chrome/browser/ui/cocoa/translate/translate_message_infobar_controller.mm b/chrome/browser/ui/cocoa/translate/translate_message_infobar_controller.mm new file mode 100644 index 0000000..00ffe2e --- /dev/null +++ b/chrome/browser/ui/cocoa/translate/translate_message_infobar_controller.mm @@ -0,0 +1,53 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/translate/translate_message_infobar_controller.h" + +#include "base/sys_string_conversions.h" + +using TranslateInfoBarUtilities::MoveControl; + +@implementation TranslateMessageInfobarController + +- (void)layout { + [self removeOkCancelButtons]; + MoveControl(label1_, translateMessageButton_, spaceBetweenControls_ * 2, true); + TranslateInfoBarDelegate* delegate = [self delegate]; + if ([self delegate]->ShouldShowMessageInfoBarButton()) { + string16 buttonText = delegate->GetMessageInfoBarButtonText(); + [translateMessageButton_ setTitle:base::SysUTF16ToNSString(buttonText)]; + [translateMessageButton_ sizeToFit]; + } +} + +- (void)adjustOptionsButtonSizeAndVisibilityForView:(NSView*)lastView { + // Do nothing, but stop the options button from showing up. +} + +- (NSArray*)visibleControls { + NSMutableArray* visibleControls = + [NSMutableArray arrayWithObjects:label1_.get(), nil]; + if ([self delegate]->ShouldShowMessageInfoBarButton()) + [visibleControls addObject:translateMessageButton_]; + return visibleControls; +} + +- (void)loadLabelText { + TranslateInfoBarDelegate* delegate = [self delegate]; + string16 messageText = delegate->GetMessageInfoBarText(); + NSString* string1 = base::SysUTF16ToNSString(messageText); + [label1_ setStringValue:string1]; +} + +- (bool)verifyLayout { + if (![optionsPopUp_ isHidden]) + return false; + return [super verifyLayout]; +} + +- (BOOL)shouldShowOptionsPopUp { + return NO; +} + +@end diff --git a/chrome/browser/ui/cocoa/ui_localizer.h b/chrome/browser/ui/cocoa/ui_localizer.h new file mode 100644 index 0000000..6e426c4 --- /dev/null +++ b/chrome/browser/ui/cocoa/ui_localizer.h @@ -0,0 +1,35 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_UI_LOCALIZER_H_ +#define CHROME_BROWSER_UI_COCOA_UI_LOCALIZER_H_ +#pragma once + +#import "third_party/GTM/AppKit/GTMUILocalizer.h" + +@class NSString; + +// A base class for generated localizers. +// +// To use this, include your xib file in the list generate_localizer scans (see +// chrome.gyp). Then add an instance of ChromeUILocalizer to the xib. +// Connect the owner_ outlet of the instance to the "File's Owner" of the xib. +// It expects the owner_ outlet to be an instance or subclass of +// NSWindowController or NSViewController. It will then localize any items in +// the NSWindowController's window and subviews, or the NSViewController's view +// and subviews, when awakeFromNib is called on the instance. You can +// optionally hook up otherObjectToLocalize_ and yetAnotherObjectToLocalize_ and +// those will also be localized. Strings in the xib that you want localized must +// start with ^IDS. The value must be a valid resource constant. +// Things that will be localized are: +// - Titles and altTitles (for menus, buttons, windows, menuitems, -tabViewItem) +// - -stringValue (for labels) +// - tooltips +// - accessibility help +// - accessibility descriptions +// - menus +@interface ChromeUILocalizer : GTMUILocalizer +@end + +#endif // CHROME_BROWSER_UI_COCOA_UI_LOCALIZER_H_ diff --git a/chrome/browser/ui/cocoa/ui_localizer.mm b/chrome/browser/ui/cocoa/ui_localizer.mm new file mode 100644 index 0000000..fc2e97e --- /dev/null +++ b/chrome/browser/ui/cocoa/ui_localizer.mm @@ -0,0 +1,98 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/ui_localizer.h" + +#import <Foundation/Foundation.h> + +#include <stdlib.h> +#include "app/l10n_util.h" +#include "app/l10n_util_mac.h" +#include "base/sys_string_conversions.h" +#include "base/logging.h" +#include "grit/app_strings.h" +#include "grit/chromium_strings.h" +#include "grit/generated_resources.h" + +struct UILocalizerResourceMap { + const char* const name; + unsigned int label_id; + unsigned int label_arg_id; +}; + + +namespace { + +// Utility function for bsearch on a ResourceMap table +int ResourceMapCompare(const void* utf8Void, + const void* resourceMapVoid) { + const char* utf8_key = reinterpret_cast<const char*>(utf8Void); + const UILocalizerResourceMap* res_map = + reinterpret_cast<const UILocalizerResourceMap*> (resourceMapVoid); + return strcmp(utf8_key, res_map->name); +} + +} // namespace + +@interface GTMUILocalizer (PrivateAdditions) +- (void)localizedObjects; +@end + +@implementation GTMUILocalizer (PrivateAdditions) +- (void)localizedObjects { + // The ivars are private, so this method lets us trigger the localization + // from -[ChromeUILocalizer awakeFromNib]. + [self localizeObject:owner_ recursively:YES]; + [self localizeObject:otherObjectToLocalize_ recursively:YES]; + [self localizeObject:yetAnotherObjectToLocalize_ recursively:YES]; +} + @end + +@implementation ChromeUILocalizer + +- (void)awakeFromNib { + // The GTM base is bundle based, since don't need the bundle, use this + // override to bypass the bundle lookup and directly do the localization + // calls. + [self localizedObjects]; +} + +- (NSString *)localizedStringForString:(NSString *)string { + + // Include the table here so it is a local static. This header provides + // kUIResources and kUIResourcesSize. +#include "ui_localizer_table.h" + + // Look up the string for the resource id to fetch. + const char* utf8_key = [string UTF8String]; + if (utf8_key) { + const void* valVoid = bsearch(utf8_key, + kUIResources, + kUIResourcesSize, + sizeof(UILocalizerResourceMap), + ResourceMapCompare); + const UILocalizerResourceMap* val = + reinterpret_cast<const UILocalizerResourceMap*>(valVoid); + if (val) { + // Do we need to build the string, or just fetch it? + if (val->label_arg_id != 0) { + const string16 label_arg(l10n_util::GetStringUTF16(val->label_arg_id)); + return l10n_util::GetNSStringFWithFixup(val->label_id, + label_arg); + } + + return l10n_util::GetNSStringWithFixup(val->label_id); + } + + // Sanity check, there shouldn't be any strings with this id that aren't + // in our map. + DLOG_IF(WARNING, [string hasPrefix:@"^ID"]) << "Key '" << utf8_key + << "' wasn't in the resource map?"; + } + + // If we didn't find anything, this string doesn't need localizing. + return nil; +} + +@end diff --git a/chrome/browser/ui/cocoa/url_drop_target.h b/chrome/browser/ui/cocoa/url_drop_target.h new file mode 100644 index 0000000..abfefb3 --- /dev/null +++ b/chrome/browser/ui/cocoa/url_drop_target.h @@ -0,0 +1,71 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_URL_DROP_TARGET_H_ +#define CHROME_BROWSER_UI_COCOA_URL_DROP_TARGET_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +@protocol URLDropTarget; +@protocol URLDropTargetController; + +// Object which coordinates the dropping of URLs on a given view, sending data +// and updates to a controller. +@interface URLDropTargetHandler : NSObject { + @private + NSView<URLDropTarget>* view_; // weak +} + +// Returns an array of drag types that can be handled. ++ (NSArray*)handledDragTypes; + +// Initialize the given view, which must implement the |URLDropTarget| (below), +// to accept drops of URLs. +- (id)initWithView:(NSView<URLDropTarget>*)view; + +// The owner view should implement the following methods by calling the +// |URLDropTargetHandler|'s version, and leave the others to the default +// implementation provided by |NSView|/|NSWindow|. +- (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)sender; +- (NSDragOperation)draggingUpdated:(id<NSDraggingInfo>)sender; +- (void)draggingExited:(id<NSDraggingInfo>)sender; +- (BOOL)performDragOperation:(id<NSDraggingInfo>)sender; + +@end // @interface URLDropTargetHandler + +// Protocol which views that are URL drop targets and use |URLDropTargetHandler| +// must implement. +@protocol URLDropTarget + +// Returns the controller which handles the drop. +- (id<URLDropTargetController>)urlDropController; + +// The following, which come from |NSDraggingDestination|, must be implemented +// by calling the |URLDropTargetHandler|'s implementations. +- (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)sender; +- (NSDragOperation)draggingUpdated:(id<NSDraggingInfo>)sender; +- (void)draggingExited:(id<NSDraggingInfo>)sender; +- (BOOL)performDragOperation:(id<NSDraggingInfo>)sender; + +@end // @protocol URLDropTarget + +// Protocol for the controller which handles the actual drop data/drop updates. +@protocol URLDropTargetController + +// The given URLs (an |NSArray| of |NSString|s) were dropped in the given view +// at the given point (in that view's coordinates). +- (void)dropURLs:(NSArray*)urls inView:(NSView*)view at:(NSPoint)point; + +// Dragging is in progress over the owner view (at the given point, in view +// coordinates) and any indicator of location -- e.g., an arrow -- should be +// updated/shown. +- (void)indicateDropURLsInView:(NSView*)view at:(NSPoint)point; + +// Dragging is over, and any indicator should be hidden. +- (void)hideDropURLsIndicatorInView:(NSView*)view; + +@end // @protocol URLDropTargetController + +#endif // CHROME_BROWSER_UI_COCOA_URL_DROP_TARGET_H_ diff --git a/chrome/browser/ui/cocoa/url_drop_target.mm b/chrome/browser/ui/cocoa/url_drop_target.mm new file mode 100644 index 0000000..d854775 --- /dev/null +++ b/chrome/browser/ui/cocoa/url_drop_target.mm @@ -0,0 +1,98 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/url_drop_target.h" + +#include "base/basictypes.h" +#import "third_party/mozilla/NSPasteboard+Utils.h" + +@interface URLDropTargetHandler(Private) + +// Gets the appropriate drag operation given the |NSDraggingInfo|. +- (NSDragOperation)getDragOperation:(id<NSDraggingInfo>)sender; + +// Tell the window controller to hide the drop indicator. +- (void)hideIndicator; + +@end // @interface URLDropTargetHandler(Private) + +@implementation URLDropTargetHandler + ++ (NSArray*)handledDragTypes { + return [NSArray arrayWithObjects:kWebURLsWithTitlesPboardType, + NSURLPboardType, + NSStringPboardType, + NSFilenamesPboardType, + nil]; +} + +- (id)initWithView:(NSView<URLDropTarget>*)view { + if ((self = [super init])) { + view_ = view; + [view_ registerForDraggedTypes:[URLDropTargetHandler handledDragTypes]]; + } + return self; +} + +// The following four methods implement parts of the |NSDraggingDestination| +// protocol, which the owner should "forward" to its |URLDropTargetHandler| +// (us). + +- (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)sender { + return [self getDragOperation:sender]; +} + +- (NSDragOperation)draggingUpdated:(id<NSDraggingInfo>)sender { + NSDragOperation dragOp = [self getDragOperation:sender]; + if (dragOp == NSDragOperationCopy) { + // Just tell the window controller to update the indicator. + NSPoint hoverPoint = [view_ convertPoint:[sender draggingLocation] + fromView:nil]; + [[view_ urlDropController] indicateDropURLsInView:view_ at:hoverPoint]; + } + return dragOp; +} + +- (void)draggingExited:(id<NSDraggingInfo>)sender { + [self hideIndicator]; +} + +- (BOOL)performDragOperation:(id<NSDraggingInfo>)sender { + [self hideIndicator]; + + NSPasteboard* pboard = [sender draggingPasteboard]; + if ([pboard containsURLData]) { + NSArray* urls = nil; + NSArray* titles; // discarded + [pboard getURLs:&urls andTitles:&titles convertingFilenames:YES]; + + if ([urls count]) { + // Tell the window controller about the dropped URL(s). + NSPoint dropPoint = + [view_ convertPoint:[sender draggingLocation] fromView:nil]; + [[view_ urlDropController] dropURLs:urls inView:view_ at:dropPoint]; + return YES; + } + } + + return NO; +} + +@end // @implementation URLDropTargetHandler + +@implementation URLDropTargetHandler(Private) + +- (NSDragOperation)getDragOperation:(id<NSDraggingInfo>)sender { + if (![[sender draggingPasteboard] containsURLData]) + return NSDragOperationNone; + + // Only allow the copy operation. + return [sender draggingSourceOperationMask] & NSDragOperationCopy; +} + +- (void)hideIndicator { + [[view_ urlDropController] hideDropURLsIndicatorInView:view_]; +} + +@end // @implementation URLDropTargetHandler(Private) diff --git a/chrome/browser/ui/cocoa/vertical_gradient_view.h b/chrome/browser/ui/cocoa/vertical_gradient_view.h new file mode 100644 index 0000000..98a3a2b --- /dev/null +++ b/chrome/browser/ui/cocoa/vertical_gradient_view.h @@ -0,0 +1,36 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_VERTICAL_GRADIENT_VIEW_H_ +#define CHROME_BROWSER_UI_COCOA_VERTICAL_GRADIENT_VIEW_H_ +#pragma once + +#include "base/scoped_nsobject.h" + +#import <Cocoa/Cocoa.h> + +// Draws a vertical background gradient with a bottom stroke. The gradient and +// stroke colors can be defined by calling |setGradient| and |setStrokeColor|, +// respectively. Alternatively, you may override the |gradient| and +// |strokeColor| accessors in order to provide colors dynamically. If the +// gradient or color is |nil|, the respective element will not be drawn. +@interface VerticalGradientView : NSView { + @private + // The gradient to draw. + scoped_nsobject<NSGradient> gradient_; + // Color for bottom stroke. + scoped_nsobject<NSColor> strokeColor_; +} + +// Gets and sets the gradient to paint as background. +- (NSGradient*)gradient; +- (void)setGradient:(NSGradient*)gradient; + +// Gets and sets the color of the stroke drawn at the bottom of the view. +- (NSColor*)strokeColor; +- (void)setStrokeColor:(NSColor*)gradient; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_VERTICAL_GRADIENT_VIEW_H_ diff --git a/chrome/browser/ui/cocoa/vertical_gradient_view.mm b/chrome/browser/ui/cocoa/vertical_gradient_view.mm new file mode 100644 index 0000000..30b9e2f --- /dev/null +++ b/chrome/browser/ui/cocoa/vertical_gradient_view.mm @@ -0,0 +1,39 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/cocoa/vertical_gradient_view.h" + +@implementation VerticalGradientView + +- (NSGradient*)gradient { + return gradient_; +} + +- (void)setGradient:(NSGradient*)gradient { + gradient_.reset([gradient retain]); +} + +- (NSColor*)strokeColor { + return strokeColor_; +} + +- (void)setStrokeColor:(NSColor*)strokeColor { + strokeColor_.reset([strokeColor retain]); +} + +- (void)drawRect:(NSRect)rect { + // Draw gradient. + [[self gradient] drawInRect:[self bounds] angle:270]; + + // Draw bottom stroke. + NSColor* strokeColor = [self strokeColor]; + if (strokeColor) { + [[self strokeColor] set]; + NSRect borderRect, contentRect; + NSDivideRect([self bounds], &borderRect, &contentRect, 1, NSMinYEdge); + NSRectFillUsingOperation(borderRect, NSCompositeSourceOver); + } +} + +@end diff --git a/chrome/browser/ui/cocoa/vertical_gradient_view_unittest.mm b/chrome/browser/ui/cocoa/vertical_gradient_view_unittest.mm new file mode 100644 index 0000000..e574a69 --- /dev/null +++ b/chrome/browser/ui/cocoa/vertical_gradient_view_unittest.mm @@ -0,0 +1,27 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/scoped_nsobject.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/vertical_gradient_view.h" + +namespace { + +class VerticalGradientViewTest : public CocoaTest { + public: + VerticalGradientViewTest() { + NSRect frame = NSMakeRect(0, 0, 50, 27); + scoped_nsobject<VerticalGradientView> view( + [[VerticalGradientView alloc] initWithFrame:frame]); + view_ = view.get(); + [[test_window() contentView] addSubview:view_]; + } + + VerticalGradientView* view_; +}; + +TEST_VIEW(VerticalGradientViewTest, view_); + +} // namespace + diff --git a/chrome/browser/ui/cocoa/view_id_util.h b/chrome/browser/ui/cocoa/view_id_util.h new file mode 100644 index 0000000..e4ca62c --- /dev/null +++ b/chrome/browser/ui/cocoa/view_id_util.h @@ -0,0 +1,52 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_VIEW_ID_UTIL_H_ +#define CHROME_BROWSER_UI_COCOA_VIEW_ID_UTIL_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "gfx/native_widget_types.h" +#include "chrome/browser/view_ids.h" + +// ViewIDs are a system that indexes important views in the browser window by a +// ViewID identifier (integer). This is a useful compatibility for finding a +// view object in cross-platform tests. See BrowserFocusTest.* for an example +// of how ViewIDs are used. + +// For views with fixed ViewIDs, we add a -viewID method to them to return their +// ViewIDs directly. But for views with changeable ViewIDs, as NSView itself +// doesn't provide a facility to store its ViewID, to avoid modifying each +// individual classes for adding ViewID support, we use an internal map to store +// ViewIDs of each view and provide some utility functions for NSView to +// set/unset the ViewID and lookup a view with a specified ViewID. + +namespace view_id_util { + +// Associates the given ViewID with the view. It shall be called upon the view's +// initialization. +void SetID(NSView* view, ViewID viewID); + +// Removes the association between the view and its ViewID. It shall be called +// just before the view's destruction. +void UnsetID(NSView* view); + +// Returns the view with a specific ViewID in a window, or nil if no view in the +// window has that ViewID. +NSView* GetView(NSWindow* window, ViewID viewID); + +} // namespace view_id_util + + +@interface NSView (ViewID) + +// Returns the ViewID associated to the receiver. The default implementation +// looks up the view's ViewID in the internal view to ViewID map. A subclass may +// override this method to return its fixed ViewID. +- (ViewID)viewID; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_VIEW_ID_UTIL_H_ diff --git a/chrome/browser/ui/cocoa/view_id_util.mm b/chrome/browser/ui/cocoa/view_id_util.mm new file mode 100644 index 0000000..df8f079 --- /dev/null +++ b/chrome/browser/ui/cocoa/view_id_util.mm @@ -0,0 +1,87 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/view_id_util.h" + +#import <Cocoa/Cocoa.h> + +#include <map> +#include <utility> + +#include "base/logging.h" +#include "base/singleton.h" +#import "chrome/browser/ui/cocoa/browser_window_controller.h" +#import "chrome/browser/ui/cocoa/tab_strip_controller.h" + +namespace { + +// TODO(suzhe): After migrating to Mac OS X 10.6, we may use Objective-C's new +// "Associative References" feature to attach the ViewID to the view directly +// rather than using a separated map. +typedef std::map<NSView*, ViewID> ViewIDMap; + +// Returns the view's nearest descendant (including itself) with a specific +// ViewID, or nil if no subview has that ViewID. +NSView* FindViewWithID(NSView* view, ViewID viewID) { + if ([view viewID] == viewID) + return view; + + for (NSView* subview in [view subviews]) { + NSView* result = FindViewWithID(subview, viewID); + if (result != nil) + return result; + } + return nil; +} + +} // anonymous namespace + +namespace view_id_util { + +void SetID(NSView* view, ViewID viewID) { + DCHECK(view); + DCHECK(viewID != VIEW_ID_NONE); + // We handle VIEW_ID_TAB_0 to VIEW_ID_TAB_LAST in GetView() function directly. + DCHECK(!((viewID >= VIEW_ID_TAB_0) && (viewID <= VIEW_ID_TAB_LAST))); + (*Singleton<ViewIDMap>::get())[view] = viewID; +} + +void UnsetID(NSView* view) { + DCHECK(view); + Singleton<ViewIDMap>::get()->erase(view); +} + +NSView* GetView(NSWindow* window, ViewID viewID) { + DCHECK(viewID != VIEW_ID_NONE); + DCHECK(window); + + // As tabs can be created, destroyed or rearranged dynamically, we handle them + // here specially. + if (viewID >= VIEW_ID_TAB_0 && viewID <= VIEW_ID_TAB_LAST) { + BrowserWindowController* windowController = [window windowController]; + DCHECK([windowController isKindOfClass:[BrowserWindowController class]]); + TabStripController* tabStripController = + [windowController tabStripController]; + DCHECK(tabStripController); + NSUInteger count = [tabStripController viewsCount]; + DCHECK(count); + NSUInteger index = + (viewID == VIEW_ID_TAB_LAST ? count - 1 : viewID - VIEW_ID_TAB_0); + return index < count ? [tabStripController viewAtIndex:index] : nil; + } + + return FindViewWithID([[window contentView] superview], viewID); +} + +} // namespace view_id_util + +@implementation NSView (ViewID) + +- (ViewID)viewID { + ViewIDMap* map = Singleton<ViewIDMap>::get(); + ViewIDMap::const_iterator iter = map->find(self); + return iter != map->end() ? iter->second : VIEW_ID_NONE; +} + +@end diff --git a/chrome/browser/ui/cocoa/view_id_util_browsertest.mm b/chrome/browser/ui/cocoa/view_id_util_browsertest.mm new file mode 100644 index 0000000..ff4c35d --- /dev/null +++ b/chrome/browser/ui/cocoa/view_id_util_browsertest.mm @@ -0,0 +1,118 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/basictypes.h" +#include "base/command_line.h" +#include "base/utf_string_conversions.h" +#include "chrome/browser/bookmarks/bookmark_model.h" +#include "chrome/browser/download/download_shelf.h" +#include "chrome/browser/prefs/pref_service.h" +#include "chrome/browser/profile.h" +#include "chrome/browser/sidebar/sidebar_manager.h" +#include "chrome/browser/ui/browser.h" +#include "chrome/browser/ui/browser_window.h" +#include "chrome/browser/ui/cocoa/view_id_util.h" +#include "chrome/common/chrome_switches.h" +#include "chrome/common/pref_names.h" +#include "chrome/common/url_constants.h" +#include "chrome/test/in_process_browser_test.h" +#include "chrome/test/ui_test_utils.h" + +// Basic sanity check of ViewID use on the mac. +class ViewIDTest : public InProcessBrowserTest { + public: + ViewIDTest() : root_window_(nil) { + CommandLine::ForCurrentProcess()->AppendSwitch( + switches::kEnableExperimentalExtensionApis); + } + + void CheckViewID(ViewID view_id, bool should_have) { + if (!root_window_) + root_window_ = browser()->window()->GetNativeHandle(); + + ASSERT_TRUE(root_window_); + NSView* view = view_id_util::GetView(root_window_, view_id); + EXPECT_EQ(should_have, !!view) << " Failed id=" << view_id; + } + + void DoTest() { + // Make sure FindBar is created to test + // VIEW_ID_FIND_IN_PAGE_TEXT_FIELD and VIEW_ID_FIND_IN_PAGE. + browser()->ShowFindBar(); + + // Make sure sidebar is created to test VIEW_ID_SIDE_BAR_CONTAINER. + const char sidebar_content_id[] = "test_content_id"; + SidebarManager::GetInstance()->ShowSidebar( + browser()->GetSelectedTabContents(), sidebar_content_id); + SidebarManager::GetInstance()->ExpandSidebar( + browser()->GetSelectedTabContents(), sidebar_content_id); + + // Make sure docked devtools is created to test VIEW_ID_DEV_TOOLS_DOCKED + browser()->profile()->GetPrefs()->SetBoolean(prefs::kDevToolsOpenDocked, + true); + browser()->ToggleDevToolsWindow(DEVTOOLS_TOGGLE_ACTION_INSPECT); + + // Make sure download shelf is created to test VIEW_ID_DOWNLOAD_SHELF + browser()->window()->GetDownloadShelf()->Show(); + + // Create a bookmark to test VIEW_ID_BOOKMARK_BAR_ELEMENT + BookmarkModel* bookmark_model = browser()->profile()->GetBookmarkModel(); + if (bookmark_model) { + if (!bookmark_model->IsLoaded()) + ui_test_utils::WaitForBookmarkModelToLoad(bookmark_model); + + bookmark_model->SetURLStarred(GURL(chrome::kAboutBlankURL), + UTF8ToUTF16("about"), true); + } + + for (int i = VIEW_ID_TOOLBAR; i < VIEW_ID_PREDEFINED_COUNT; ++i) { + // Mac implementation does not support following ids yet. + if (i == VIEW_ID_STAR_BUTTON || + i == VIEW_ID_AUTOCOMPLETE || + i == VIEW_ID_CONTENTS_SPLIT || + i == VIEW_ID_SIDE_BAR_SPLIT || + i == VIEW_ID_FEEDBACK_BUTTON) { + continue; + } + + CheckViewID(static_cast<ViewID>(i), true); + } + + CheckViewID(VIEW_ID_TAB, true); + CheckViewID(VIEW_ID_TAB_STRIP, true); + CheckViewID(VIEW_ID_PREDEFINED_COUNT, false); + } + + private: + NSWindow* root_window_; +}; + +IN_PROC_BROWSER_TEST_F(ViewIDTest, Basic) { + ASSERT_NO_FATAL_FAILURE(DoTest()); +} + +IN_PROC_BROWSER_TEST_F(ViewIDTest, Fullscreen) { + browser()->window()->SetFullscreen(true); + ASSERT_NO_FATAL_FAILURE(DoTest()); +} + +IN_PROC_BROWSER_TEST_F(ViewIDTest, Tab) { + CheckViewID(VIEW_ID_TAB_0, true); + CheckViewID(VIEW_ID_TAB_LAST, true); + + // Open 9 new tabs. + for (int i = 1; i <= 9; ++i) { + CheckViewID(static_cast<ViewID>(VIEW_ID_TAB_0 + i), false); + browser()->OpenURL(GURL(chrome::kAboutBlankURL), GURL(), + NEW_BACKGROUND_TAB, PageTransition::TYPED); + CheckViewID(static_cast<ViewID>(VIEW_ID_TAB_0 + i), true); + // VIEW_ID_TAB_LAST should always be available. + CheckViewID(VIEW_ID_TAB_LAST, true); + } + + // Open the 11th tab. + browser()->OpenURL(GURL(chrome::kAboutBlankURL), GURL(), + NEW_BACKGROUND_TAB, PageTransition::TYPED); + CheckViewID(VIEW_ID_TAB_LAST, true); +} diff --git a/chrome/browser/ui/cocoa/view_resizer.h b/chrome/browser/ui/cocoa/view_resizer.h new file mode 100644 index 0000000..f27373f --- /dev/null +++ b/chrome/browser/ui/cocoa/view_resizer.h @@ -0,0 +1,28 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_VIEW_RESIZER_H_ +#define CHROME_BROWSER_UI_COCOA_VIEW_RESIZER_H_ +#pragma once + +#include "chrome/browser/tabs/tab_strip_model.h" + +#import <Cocoa/Cocoa.h> + +// Defines a protocol that allows controllers to delegate resizing their views +// to their parents. When a controller needs to change a view's height, rather +// than resizing it directly, it sends a message to its parent asking the parent +// to perform the resize. This allows the parent to do any re-layout that may +// become necessary due to the resize. +@protocol ViewResizer <NSObject> +- (void)resizeView:(NSView*)view newHeight:(CGFloat)height; + +@optional +// Optional method called when an animation is beginning or ending. Resize +// delegates can implement this method if they need to modify their behavior +// while an animation is running. +- (void)setAnimationInProgress:(BOOL)inProgress; +@end + +#endif // CHROME_BROWSER_UI_COCOA_VIEW_RESIZER_H_ diff --git a/chrome/browser/ui/cocoa/view_resizer_pong.h b/chrome/browser/ui/cocoa/view_resizer_pong.h new file mode 100644 index 0000000..628c0d5 --- /dev/null +++ b/chrome/browser/ui/cocoa/view_resizer_pong.h @@ -0,0 +1,22 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_VIEW_RESIZER_PONG_H_ +#define CHROME_BROWSER_UI_COCOA_VIEW_RESIZER_PONG_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#import "chrome/browser/ui/cocoa/view_resizer.h" + +@interface ViewResizerPong : NSObject<ViewResizer> { + @private + CGFloat height_; +} +@property (nonatomic) CGFloat height; + +- (void)resizeView:(NSView*)view newHeight:(CGFloat)height; +@end + +#endif // CHROME_BROWSER_UI_COCOA_VIEW_RESIZER_PONG_H_ diff --git a/chrome/browser/ui/cocoa/view_resizer_pong.mm b/chrome/browser/ui/cocoa/view_resizer_pong.mm new file mode 100644 index 0000000..f063dbf --- /dev/null +++ b/chrome/browser/ui/cocoa/view_resizer_pong.mm @@ -0,0 +1,20 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#import "chrome/browser/ui/cocoa/view_resizer_pong.h" + +@implementation ViewResizerPong + +@synthesize height = height_; + +- (void)resizeView:(NSView*)view newHeight:(CGFloat)height { + [self setHeight:height]; + + // Set the view's height and width, in case it uses that as important state. + [view setFrame:NSMakeRect(100, 50, + NSWidth([[view superview] frame]) - 50, height)]; +} +@end diff --git a/chrome/browser/ui/cocoa/web_contents_drag_source.h b/chrome/browser/ui/cocoa/web_contents_drag_source.h new file mode 100644 index 0000000..aa9dd73 --- /dev/null +++ b/chrome/browser/ui/cocoa/web_contents_drag_source.h @@ -0,0 +1,62 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_WEB_CONTENTS_DRAG_SOURCE_H_ +#define CHROME_BROWSER_UI_COCOA_WEB_CONTENTS_DRAG_SOURCE_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#include "base/scoped_nsobject.h" +#include "chrome/browser/bookmarks/bookmark_node_data.h" + +@class TabContentsViewCocoa; + +// A class that handles tracking and event processing for a drag and drop +// originating from the content area. Subclasses should implement +// fillClipboard and dragImage. +@interface WebContentsDragSource : NSObject { + @private + // Our tab. Weak reference (owns or co-owns us). + TabContentsViewCocoa* contentsView_; + + // Our pasteboard. + scoped_nsobject<NSPasteboard> pasteboard_; + + // A mask of the allowed drag operations. + NSDragOperation dragOperationMask_; +} + +// Initialize a DragDataSource object for a drag (originating on the given +// contentsView and with the given dropData and pboard). Fill the pasteboard +// with data types appropriate for dropData. +- (id)initWithContentsView:(TabContentsViewCocoa*)contentsView + pasteboard:(NSPasteboard*)pboard + dragOperationMask:(NSDragOperation)dragOperationMask; + +// Creates the drag image. Implemented by the subclass. +- (NSImage*)dragImage; + +// Put the data being dragged onto the pasteboard. Implemented by the +// subclass. +- (void)fillPasteboard; + +// Returns a mask of the allowed drag operations. +- (NSDragOperation)draggingSourceOperationMaskForLocal:(BOOL)isLocal; + +// Start the drag (on the originally provided contentsView); can do this right +// after -initWithContentsView:.... +- (void)startDrag; + +// End the drag and clear the pasteboard; hook up to +// -draggedImage:endedAt:operation:. +- (void)endDragAt:(NSPoint)screenPoint + operation:(NSDragOperation)operation; + +// Drag moved; hook up to -draggedImage:movedTo:. +- (void)moveDragTo:(NSPoint)screenPoint; + +@end + +#endif // CHROME_BROWSER_UI_COCOA_WEB_CONTENTS_DRAG_SOURCE_H_ diff --git a/chrome/browser/ui/cocoa/web_contents_drag_source.mm b/chrome/browser/ui/cocoa/web_contents_drag_source.mm new file mode 100644 index 0000000..072909a --- /dev/null +++ b/chrome/browser/ui/cocoa/web_contents_drag_source.mm @@ -0,0 +1,130 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/web_contents_drag_source.h" + +#include "base/nsimage_cache_mac.h" +#include "base/string_util.h" +#include "base/sys_string_conversions.h" +#include "chrome/browser/renderer_host/render_view_host.h" +#include "chrome/browser/tab_contents/tab_contents.h" +#include "chrome/browser/tab_contents/tab_contents_view_mac.h" + +namespace { + +// Make a drag image from the drop data. +// TODO(feldstein): Make this work +NSImage* MakeDragImage() { + // TODO(feldstein): Just a stub for now. Make it do something (see, e.g., + // WebKit/WebKit/mac/Misc/WebNSViewExtras.m: |-_web_DragImageForElement:...|). + + // Default to returning a generic image. + return nsimage_cache::ImageNamed(@"nav.pdf"); +} + +// Flips screen and point coordinates over the y axis to work with webkit +// coordinate systems. +void FlipPointCoordinates(NSPoint& screenPoint, + NSPoint& localPoint, + NSView* view) { + NSRect viewFrame = [view frame]; + localPoint.y = NSHeight(viewFrame) - localPoint.y; + // Flip |screenPoint|. + NSRect screenFrame = [[[view window] screen] frame]; + screenPoint.y = NSHeight(screenFrame) - screenPoint.y; +} + +} // namespace + + +@implementation WebContentsDragSource + +- (id)initWithContentsView:(TabContentsViewCocoa*)contentsView + pasteboard:(NSPasteboard*)pboard + dragOperationMask:(NSDragOperation)dragOperationMask { + if ((self = [super init])) { + contentsView_ = contentsView; + DCHECK(contentsView_); + + pasteboard_.reset([pboard retain]); + DCHECK(pasteboard_.get()); + + dragOperationMask_ = dragOperationMask; + } + + return self; +} + +- (NSImage*)dragImage { + return MakeDragImage(); +} + +- (void)fillPasteboard { + NOTIMPLEMENTED() << "Subclasses should implement fillPasteboard"; +} + +- (NSDragOperation)draggingSourceOperationMaskForLocal:(BOOL)isLocal { + return dragOperationMask_; +} + +- (void)startDrag { + [self fillPasteboard]; + NSEvent* currentEvent = [NSApp currentEvent]; + + // Synthesize an event for dragging, since we can't be sure that + // [NSApp currentEvent] will return a valid dragging event. + NSWindow* window = [contentsView_ window]; + NSPoint position = [window mouseLocationOutsideOfEventStream]; + NSTimeInterval eventTime = [currentEvent timestamp]; + NSEvent* dragEvent = [NSEvent mouseEventWithType:NSLeftMouseDragged + location:position + modifierFlags:NSLeftMouseDraggedMask + timestamp:eventTime + windowNumber:[window windowNumber] + context:nil + eventNumber:0 + clickCount:1 + pressure:1.0]; + [window dragImage:[self dragImage] + at:position + offset:NSZeroSize + event:dragEvent + pasteboard:pasteboard_ + source:self + slideBack:YES]; +} + +- (void)draggedImage:(NSImage *)anImage endedAt:(NSPoint)aPoint + operation:(NSDragOperation)operation { +} + +- (void)endDragAt:(NSPoint)screenPoint + operation:(NSDragOperation)operation { + RenderViewHost* rvh = [contentsView_ tabContents]->render_view_host(); + if (rvh) { + rvh->DragSourceSystemDragEnded(); + + NSPoint localPoint = [contentsView_ convertPoint:screenPoint fromView: nil]; + FlipPointCoordinates(screenPoint, localPoint, contentsView_); + rvh->DragSourceEndedAt(localPoint.x, localPoint.y, + screenPoint.x, screenPoint.y, + static_cast<WebKit::WebDragOperation>(operation)); + } + + // Make sure the pasteboard owner isn't us. + [pasteboard_ declareTypes:[NSArray array] owner:nil]; +} + +- (void)moveDragTo:(NSPoint)screenPoint { + RenderViewHost* rvh = [contentsView_ tabContents]->render_view_host(); + if (rvh) { + NSPoint localPoint = [contentsView_ convertPoint:screenPoint fromView:nil]; + FlipPointCoordinates(screenPoint, localPoint, contentsView_); + rvh->DragSourceMovedTo(localPoint.x, localPoint.y, + screenPoint.x, screenPoint.y); + } +} + +@end // @implementation WebContentsDragSource + diff --git a/chrome/browser/ui/cocoa/web_drag_source.h b/chrome/browser/ui/cocoa/web_drag_source.h new file mode 100644 index 0000000..9cfcba5 --- /dev/null +++ b/chrome/browser/ui/cocoa/web_drag_source.h @@ -0,0 +1,80 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/file_path.h" +#include "base/scoped_nsobject.h" +#include "base/scoped_ptr.h" +#include "googleurl/src/gurl.h" + +@class TabContentsViewCocoa; +struct WebDropData; + +// A class that handles tracking and event processing for a drag and drop +// originating from the content area. +@interface WebDragSource : NSObject { + @private + // Our tab. Weak reference (owns or co-owns us). + TabContentsViewCocoa* contentsView_; + + // Our drop data. Should only be initialized once. + scoped_ptr<WebDropData> dropData_; + + // The image to show as drag image. Can be nil. + scoped_nsobject<NSImage> dragImage_; + + // The offset to draw |dragImage_| at. + NSPoint imageOffset_; + + // Our pasteboard. + scoped_nsobject<NSPasteboard> pasteboard_; + + // A mask of the allowed drag operations. + NSDragOperation dragOperationMask_; + + // The file name to be saved to for a drag-out download. + FilePath downloadFileName_; + + // The URL to download from for a drag-out download. + GURL downloadURL_; +} + +// Initialize a WebDragSource object for a drag (originating on the given +// contentsView and with the given dropData and pboard). Fill the pasteboard +// with data types appropriate for dropData. +- (id)initWithContentsView:(TabContentsViewCocoa*)contentsView + dropData:(const WebDropData*)dropData + image:(NSImage*)image + offset:(NSPoint)offset + pasteboard:(NSPasteboard*)pboard + dragOperationMask:(NSDragOperation)dragOperationMask; + +// Returns a mask of the allowed drag operations. +- (NSDragOperation)draggingSourceOperationMaskForLocal:(BOOL)isLocal; + +// Call when asked to do a lazy write to the pasteboard; hook up to +// -pasteboard:provideDataForType: (on the contentsView). +- (void)lazyWriteToPasteboard:(NSPasteboard*)pboard + forType:(NSString*)type; + +// Start the drag (on the originally provided contentsView); can do this right +// after -initWithContentsView:.... +- (void)startDrag; + +// End the drag and clear the pasteboard; hook up to +// -draggedImage:endedAt:operation:. +- (void)endDragAt:(NSPoint)screenPoint + operation:(NSDragOperation)operation; + +// Drag moved; hook up to -draggedImage:movedTo:. +- (void)moveDragTo:(NSPoint)screenPoint; + +// Call to drag a promised file to the given path (should be called before +// -endDragAt:...); hook up to -namesOfPromisedFilesDroppedAtDestination:. +// Returns the file name (not including path) of the file deposited (or which +// will be deposited). +- (NSString*)dragPromisedFileTo:(NSString*)path; + +@end diff --git a/chrome/browser/ui/cocoa/web_drag_source.mm b/chrome/browser/ui/cocoa/web_drag_source.mm new file mode 100644 index 0000000..cad5014 --- /dev/null +++ b/chrome/browser/ui/cocoa/web_drag_source.mm @@ -0,0 +1,412 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/web_drag_source.h" + +#include "base/file_path.h" +#include "base/nsimage_cache_mac.h" +#include "base/string_util.h" +#include "base/sys_string_conversions.h" +#include "base/task.h" +#include "base/thread.h" +#include "base/utf_string_conversions.h" +#include "chrome/browser/browser_process.h" +#include "chrome/browser/download/download_manager.h" +#include "chrome/browser/download/download_util.h" +#include "chrome/browser/download/drag_download_file.h" +#include "chrome/browser/download/drag_download_util.h" +#include "chrome/browser/renderer_host/render_view_host.h" +#include "chrome/browser/tab_contents/tab_contents.h" +#include "chrome/browser/tab_contents/tab_contents_view_mac.h" +#include "net/base/file_stream.h" +#include "net/base/net_util.h" +#import "third_party/mozilla/NSPasteboard+Utils.h" +#include "webkit/glue/webdropdata.h" + +using base::SysNSStringToUTF8; +using base::SysUTF8ToNSString; +using base::SysUTF16ToNSString; +using net::FileStream; + + +namespace { + +// An unofficial standard pasteboard title type to be provided alongside the +// |NSURLPboardType|. +NSString* const kNSURLTitlePboardType = @"public.url-name"; + +// Returns a filename appropriate for the drop data +// TODO(viettrungluu): Refactor to make it common across platforms, +// and move it somewhere sensible. +FilePath GetFileNameFromDragData(const WebDropData& drop_data) { + // Images without ALT text will only have a file extension so we need to + // synthesize one from the provided extension and URL. + FilePath file_name([SysUTF16ToNSString(drop_data.file_description_filename) + fileSystemRepresentation]); + file_name = file_name.BaseName().RemoveExtension(); + + if (file_name.empty()) { + // Retrieve the name from the URL. + file_name = net::GetSuggestedFilename(drop_data.url, "", "", FilePath()); + } + + file_name = file_name.ReplaceExtension([SysUTF16ToNSString( + drop_data.file_extension) fileSystemRepresentation]); + + return file_name; +} + +// This class's sole task is to write out data for a promised file; the caller +// is responsible for opening the file. +class PromiseWriterTask : public Task { + public: + // Assumes ownership of file_stream. + PromiseWriterTask(const WebDropData& drop_data, + FileStream* file_stream); + virtual ~PromiseWriterTask(); + virtual void Run(); + + private: + WebDropData drop_data_; + + // This class takes ownership of file_stream_ and will close and delete it. + scoped_ptr<FileStream> file_stream_; +}; + +// Takes the drop data and an open file stream (which it takes ownership of and +// will close and delete). +PromiseWriterTask::PromiseWriterTask(const WebDropData& drop_data, + FileStream* file_stream) : + drop_data_(drop_data) { + file_stream_.reset(file_stream); + DCHECK(file_stream_.get()); +} + +PromiseWriterTask::~PromiseWriterTask() { + DCHECK(file_stream_.get()); + if (file_stream_.get()) + file_stream_->Close(); +} + +void PromiseWriterTask::Run() { + CHECK(file_stream_.get()); + file_stream_->Write(drop_data_.file_contents.data(), + drop_data_.file_contents.length(), + NULL); + + // Let our destructor take care of business. +} + +} // namespace + + +@interface WebDragSource(Private) + +- (void)fillPasteboard; +- (NSImage*)dragImage; + +@end // @interface WebDragSource(Private) + + +@implementation WebDragSource + +- (id)initWithContentsView:(TabContentsViewCocoa*)contentsView + dropData:(const WebDropData*)dropData + image:(NSImage*)image + offset:(NSPoint)offset + pasteboard:(NSPasteboard*)pboard + dragOperationMask:(NSDragOperation)dragOperationMask { + if ((self = [super init])) { + contentsView_ = contentsView; + DCHECK(contentsView_); + + dropData_.reset(new WebDropData(*dropData)); + DCHECK(dropData_.get()); + + dragImage_.reset([image retain]); + imageOffset_ = offset; + + pasteboard_.reset([pboard retain]); + DCHECK(pasteboard_.get()); + + dragOperationMask_ = dragOperationMask; + + [self fillPasteboard]; + } + + return self; +} + +- (NSDragOperation)draggingSourceOperationMaskForLocal:(BOOL)isLocal { + return dragOperationMask_; +} + +- (void)lazyWriteToPasteboard:(NSPasteboard*)pboard forType:(NSString*)type { + // NSHTMLPboardType requires the character set to be declared. Otherwise, it + // assumes US-ASCII. Awesome. + static const string16 kHtmlHeader = + ASCIIToUTF16("<meta http-equiv=\"Content-Type\" " + "content=\"text/html;charset=UTF-8\">"); + + // Be extra paranoid; avoid crashing. + if (!dropData_.get()) { + NOTREACHED() << "No drag-and-drop data available for lazy write."; + return; + } + + // HTML. + if ([type isEqualToString:NSHTMLPboardType]) { + DCHECK(!dropData_->text_html.empty()); + // See comment on |kHtmlHeader| above. + [pboard setString:SysUTF16ToNSString(kHtmlHeader + dropData_->text_html) + forType:NSHTMLPboardType]; + + // URL. + } else if ([type isEqualToString:NSURLPboardType]) { + DCHECK(dropData_->url.is_valid()); + NSURL* url = [NSURL URLWithString:SysUTF8ToNSString(dropData_->url.spec())]; + [url writeToPasteboard:pboard]; + + // URL title. + } else if ([type isEqualToString:kNSURLTitlePboardType]) { + [pboard setString:SysUTF16ToNSString(dropData_->url_title) + forType:kNSURLTitlePboardType]; + + // File contents. + } else if ([type isEqualToString:NSFileContentsPboardType] || + [type isEqualToString:NSCreateFileContentsPboardType( + SysUTF16ToNSString(dropData_->file_extension))]) { + // TODO(viettrungluu: find something which is known to accept + // NSFileContentsPboardType to check that this actually works! + scoped_nsobject<NSFileWrapper> file_wrapper( + [[NSFileWrapper alloc] initRegularFileWithContents:[NSData + dataWithBytes:dropData_->file_contents.data() + length:dropData_->file_contents.length()]]); + [file_wrapper setPreferredFilename:SysUTF8ToNSString( + GetFileNameFromDragData(*dropData_).value())]; + [pboard writeFileWrapper:file_wrapper]; + + // TIFF. + } else if ([type isEqualToString:NSTIFFPboardType]) { + // TODO(viettrungluu): This is a bit odd since we rely on Cocoa to render + // our image into a TIFF. This is also suboptimal since this is all done + // synchronously. I'm not sure there's much we can easily do about it. + scoped_nsobject<NSImage> image( + [[NSImage alloc] initWithData:[NSData + dataWithBytes:dropData_->file_contents.data() + length:dropData_->file_contents.length()]]); + [pboard setData:[image TIFFRepresentation] forType:NSTIFFPboardType]; + + // Plain text. + } else if ([type isEqualToString:NSStringPboardType]) { + DCHECK(!dropData_->plain_text.empty()); + [pboard setString:SysUTF16ToNSString(dropData_->plain_text) + forType:NSStringPboardType]; + + // Oops! + } else { + NOTREACHED() << "Asked for a drag pasteboard type we didn't offer."; + } +} + +- (NSPoint)convertScreenPoint:(NSPoint)screenPoint { + DCHECK([contentsView_ window]); + NSPoint basePoint = [[contentsView_ window] convertScreenToBase:screenPoint]; + return [contentsView_ convertPoint:basePoint fromView:nil]; +} + +- (void)startDrag { + NSEvent* currentEvent = [NSApp currentEvent]; + + // Synthesize an event for dragging, since we can't be sure that + // [NSApp currentEvent] will return a valid dragging event. + NSWindow* window = [contentsView_ window]; + NSPoint position = [window mouseLocationOutsideOfEventStream]; + NSTimeInterval eventTime = [currentEvent timestamp]; + NSEvent* dragEvent = [NSEvent mouseEventWithType:NSLeftMouseDragged + location:position + modifierFlags:NSLeftMouseDraggedMask + timestamp:eventTime + windowNumber:[window windowNumber] + context:nil + eventNumber:0 + clickCount:1 + pressure:1.0]; + + if (dragImage_) { + position.x -= imageOffset_.x; + // Deal with Cocoa's flipped coordinate system. + position.y -= [dragImage_.get() size].height - imageOffset_.y; + } + // Per kwebster, offset arg is ignored, see -_web_DragImageForElement: in + // third_party/WebKit/WebKit/mac/Misc/WebNSViewExtras.m. + [window dragImage:[self dragImage] + at:position + offset:NSZeroSize + event:dragEvent + pasteboard:pasteboard_ + source:contentsView_ + slideBack:YES]; +} + +- (void)endDragAt:(NSPoint)screenPoint + operation:(NSDragOperation)operation { + RenderViewHost* rvh = [contentsView_ tabContents]->render_view_host(); + if (rvh) { + rvh->DragSourceSystemDragEnded(); + + // Convert |screenPoint| to view coordinates and flip it. + NSPoint localPoint = [self convertScreenPoint:screenPoint]; + NSRect viewFrame = [contentsView_ frame]; + localPoint.y = viewFrame.size.height - localPoint.y; + // Flip |screenPoint|. + NSRect screenFrame = [[[contentsView_ window] screen] frame]; + screenPoint.y = screenFrame.size.height - screenPoint.y; + + rvh->DragSourceEndedAt(localPoint.x, localPoint.y, + screenPoint.x, screenPoint.y, + static_cast<WebKit::WebDragOperation>(operation)); + } + + // Make sure the pasteboard owner isn't us. + [pasteboard_ declareTypes:[NSArray array] owner:nil]; +} + +- (void)moveDragTo:(NSPoint)screenPoint { + RenderViewHost* rvh = [contentsView_ tabContents]->render_view_host(); + if (rvh) { + // Convert |screenPoint| to view coordinates and flip it. + NSPoint localPoint = [self convertScreenPoint:screenPoint]; + NSRect viewFrame = [contentsView_ frame]; + localPoint.y = viewFrame.size.height - localPoint.y; + // Flip |screenPoint|. + NSRect screenFrame = [[[contentsView_ window] screen] frame]; + screenPoint.y = screenFrame.size.height - screenPoint.y; + + rvh->DragSourceMovedTo(localPoint.x, localPoint.y, + screenPoint.x, screenPoint.y); + } +} + +- (NSString*)dragPromisedFileTo:(NSString*)path { + // Be extra paranoid; avoid crashing. + if (!dropData_.get()) { + NOTREACHED() << "No drag-and-drop data available for promised file."; + return nil; + } + + FilePath fileName = downloadFileName_.empty() ? + GetFileNameFromDragData(*dropData_) : downloadFileName_; + FilePath filePath(SysNSStringToUTF8(path)); + filePath = filePath.Append(fileName); + FileStream* fileStream = + drag_download_util::CreateFileStreamForDrop(&filePath); + if (!fileStream) + return nil; + + if (downloadURL_.is_valid()) { + TabContents* tabContents = [contentsView_ tabContents]; + scoped_refptr<DragDownloadFile> dragFileDownloader(new DragDownloadFile( + filePath, + linked_ptr<net::FileStream>(fileStream), + downloadURL_, + tabContents->GetURL(), + tabContents->encoding(), + tabContents)); + + // The finalizer will take care of closing and deletion. + dragFileDownloader->Start( + new drag_download_util::PromiseFileFinalizer(dragFileDownloader)); + } else { + // The writer will take care of closing and deletion. + g_browser_process->file_thread()->message_loop()->PostTask(FROM_HERE, + new PromiseWriterTask(*dropData_, fileStream)); + } + + // Once we've created the file, we should return the file name. + return SysUTF8ToNSString(filePath.BaseName().value()); +} + +@end // @implementation WebDragSource + + +@implementation WebDragSource (Private) + +- (void)fillPasteboard { + DCHECK(pasteboard_.get()); + + [pasteboard_ declareTypes:[NSArray array] owner:contentsView_]; + + // HTML. + if (!dropData_->text_html.empty()) + [pasteboard_ addTypes:[NSArray arrayWithObject:NSHTMLPboardType] + owner:contentsView_]; + + // URL (and title). + if (dropData_->url.is_valid()) + [pasteboard_ addTypes:[NSArray arrayWithObjects:NSURLPboardType, + kNSURLTitlePboardType, nil] + owner:contentsView_]; + + // File. + if (!dropData_->file_contents.empty() || + !dropData_->download_metadata.empty()) { + NSString* fileExtension = 0; + + if (dropData_->download_metadata.empty()) { + // |dropData_->file_extension| comes with the '.', which we must strip. + fileExtension = (dropData_->file_extension.length() > 0) ? + SysUTF16ToNSString(dropData_->file_extension.substr(1)) : @""; + } else { + string16 mimeType; + FilePath fileName; + if (drag_download_util::ParseDownloadMetadata( + dropData_->download_metadata, + &mimeType, + &fileName, + &downloadURL_)) { + std::string contentDisposition = + "attachment; filename=" + fileName.value(); + download_util::GenerateFileName(downloadURL_, + contentDisposition, + std::string(), + UTF16ToUTF8(mimeType), + &downloadFileName_); + fileExtension = SysUTF8ToNSString(downloadFileName_.Extension()); + } + } + + if (fileExtension) { + // File contents (with and without specific type), file (HFS) promise, + // TIFF. + // TODO(viettrungluu): others? + [pasteboard_ addTypes:[NSArray arrayWithObjects: + NSFileContentsPboardType, + NSCreateFileContentsPboardType(fileExtension), + NSFilesPromisePboardType, + NSTIFFPboardType, + nil] + owner:contentsView_]; + + // For the file promise, we need to specify the extension. + [pasteboard_ setPropertyList:[NSArray arrayWithObject:fileExtension] + forType:NSFilesPromisePboardType]; + } + } + + // Plain text. + if (!dropData_->plain_text.empty()) + [pasteboard_ addTypes:[NSArray arrayWithObject:NSStringPboardType] + owner:contentsView_]; +} + +- (NSImage*)dragImage { + if (dragImage_) + return dragImage_; + + // Default to returning a generic image. + return nsimage_cache::ImageNamed(@"nav.pdf"); +} + +@end // @implementation WebDragSource (Private) diff --git a/chrome/browser/ui/cocoa/web_drop_target.h b/chrome/browser/ui/cocoa/web_drop_target.h new file mode 100644 index 0000000..7f18ccf --- /dev/null +++ b/chrome/browser/ui/cocoa/web_drop_target.h @@ -0,0 +1,80 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#include "base/string16.h" + +class GURL; +class RenderViewHost; +class TabContents; +struct WebDropData; + +// A typedef for a RenderViewHost used for comparison purposes only. +typedef RenderViewHost* RenderViewHostIdentifier; + +// A class that handles tracking and event processing for a drag and drop +// over the content area. Assumes something else initiates the drag, this is +// only for processing during a drag. + +@interface WebDropTarget : NSObject { + @private + // Our associated TabContents. Weak reference. + TabContents* tabContents_; + + // Updated asynchronously during a drag to tell us whether or not we should + // allow the drop. + NSDragOperation current_operation_; + + // Keep track of the render view host we're dragging over. If it changes + // during a drag, we need to re-send the DragEnter message. + RenderViewHostIdentifier currentRVH_; +} + +// |contents| is the TabContents representing this tab, used to communicate +// drag&drop messages to WebCore and handle navigation on a successful drop +// (if necessary). +- (id)initWithTabContents:(TabContents*)contents; + +// Sets the current operation negotiated by the source and destination, +// which determines whether or not we should allow the drop. Takes effect the +// next time |-draggingUpdated:| is called. +- (void)setCurrentOperation: (NSDragOperation)operation; + +// Messages to send during the tracking of a drag, ususally upon receiving +// calls from the view system. Communicates the drag messages to WebCore. +- (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)info + view:(NSView*)view; +- (void)draggingExited:(id<NSDraggingInfo>)info; +- (NSDragOperation)draggingUpdated:(id<NSDraggingInfo>)info + view:(NSView*)view; +- (BOOL)performDragOperation:(id<NSDraggingInfo>)info + view:(NSView*)view; + +@end + +// Public use only for unit tests. +@interface WebDropTarget(Testing) +// Populate the |url| and |title| with URL data in |pboard|. There may be more +// than one, but we only handle dropping the first. |url| must not be |NULL|; +// |title| is an optional parameter. Returns |YES| if URL data was obtained from +// the pasteboard, |NO| otherwise. If |convertFilenames| is |YES|, the function +// will also attempt to convert filenames in |pboard| to file URLs. +- (BOOL)populateURL:(GURL*)url + andTitle:(string16*)title + fromPasteboard:(NSPasteboard*)pboard + convertingFilenames:(BOOL)convertFilenames; +// Given |data|, which should not be nil, fill it in using the contents of the +// given pasteboard. +- (void)populateWebDropData:(WebDropData*)data + fromPasteboard:(NSPasteboard*)pboard; +// Given a point in window coordinates and a view in that window, return a +// flipped point in the coordinate system of |view|. +- (NSPoint)flipWindowPointToView:(const NSPoint&)windowPoint + view:(NSView*)view; +// Given a point in window coordinates and a view in that window, return a +// flipped point in screen coordinates. +- (NSPoint)flipWindowPointToScreen:(const NSPoint&)windowPoint + view:(NSView*)view; +@end diff --git a/chrome/browser/ui/cocoa/web_drop_target.mm b/chrome/browser/ui/cocoa/web_drop_target.mm new file mode 100644 index 0000000..08174b5 --- /dev/null +++ b/chrome/browser/ui/cocoa/web_drop_target.mm @@ -0,0 +1,283 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/web_drop_target.h" + +#include "base/sys_string_conversions.h" +#include "chrome/browser/bookmarks/bookmark_node_data.h" +#include "chrome/browser/bookmarks/bookmark_pasteboard_helper_mac.h" +#include "chrome/browser/renderer_host/render_view_host.h" +#include "chrome/browser/tab_contents/tab_contents.h" +#import "third_party/mozilla/NSPasteboard+Utils.h" +#include "webkit/glue/webdropdata.h" +#include "webkit/glue/window_open_disposition.h" + +using WebKit::WebDragOperationsMask; + +@implementation WebDropTarget + +// |contents| is the TabContents representing this tab, used to communicate +// drag&drop messages to WebCore and handle navigation on a successful drop +// (if necessary). +- (id)initWithTabContents:(TabContents*)contents { + if ((self = [super init])) { + tabContents_ = contents; + } + return self; +} + +// Call to set whether or not we should allow the drop. Takes effect the +// next time |-draggingUpdated:| is called. +- (void)setCurrentOperation: (NSDragOperation)operation { + current_operation_ = operation; +} + +// Given a point in window coordinates and a view in that window, return a +// flipped point in the coordinate system of |view|. +- (NSPoint)flipWindowPointToView:(const NSPoint&)windowPoint + view:(NSView*)view { + DCHECK(view); + NSPoint viewPoint = [view convertPoint:windowPoint fromView:nil]; + NSRect viewFrame = [view frame]; + viewPoint.y = viewFrame.size.height - viewPoint.y; + return viewPoint; +} + +// Given a point in window coordinates and a view in that window, return a +// flipped point in screen coordinates. +- (NSPoint)flipWindowPointToScreen:(const NSPoint&)windowPoint + view:(NSView*)view { + DCHECK(view); + NSPoint screenPoint = [[view window] convertBaseToScreen:windowPoint]; + NSScreen* screen = [[view window] screen]; + NSRect screenFrame = [screen frame]; + screenPoint.y = screenFrame.size.height - screenPoint.y; + return screenPoint; +} + +// Return YES if the drop site only allows drops that would navigate. If this +// is the case, we don't want to pass messages to the renderer because there's +// really no point (i.e., there's nothing that cares about the mouse position or +// entering and exiting). One example is an interstitial page (e.g., safe +// browsing warning). +- (BOOL)onlyAllowsNavigation { + return tabContents_->showing_interstitial_page(); +} + +// Messages to send during the tracking of a drag, ususally upon recieving +// calls from the view system. Communicates the drag messages to WebCore. + +- (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)info + view:(NSView*)view { + // Save off the RVH so we can tell if it changes during a drag. If it does, + // we need to send a new enter message in draggingUpdated:. + currentRVH_ = tabContents_->render_view_host(); + + if ([self onlyAllowsNavigation]) { + if ([[info draggingPasteboard] containsURLData]) + return NSDragOperationCopy; + return NSDragOperationNone; + } + + // If the tab is showing the boomark manager, send BookmarkDrag events + RenderViewHostDelegate::BookmarkDrag* dragDelegate = + tabContents_->GetBookmarkDragDelegate(); + BookmarkNodeData dragData; + if(dragDelegate && dragData.ReadFromDragClipboard()) + dragDelegate->OnDragEnter(dragData); + + // Fill out a WebDropData from pasteboard. + WebDropData data; + [self populateWebDropData:&data fromPasteboard:[info draggingPasteboard]]; + + // Create the appropriate mouse locations for WebCore. The draggingLocation + // is in window coordinates. Both need to be flipped. + NSPoint windowPoint = [info draggingLocation]; + NSPoint viewPoint = [self flipWindowPointToView:windowPoint view:view]; + NSPoint screenPoint = [self flipWindowPointToScreen:windowPoint view:view]; + NSDragOperation mask = [info draggingSourceOperationMask]; + tabContents_->render_view_host()->DragTargetDragEnter(data, + gfx::Point(viewPoint.x, viewPoint.y), + gfx::Point(screenPoint.x, screenPoint.y), + static_cast<WebDragOperationsMask>(mask)); + + // We won't know the true operation (whether the drag is allowed) until we + // hear back from the renderer. For now, be optimistic: + current_operation_ = NSDragOperationCopy; + return current_operation_; +} + +- (void)draggingExited:(id<NSDraggingInfo>)info { + DCHECK(currentRVH_); + if (currentRVH_ != tabContents_->render_view_host()) + return; + + // Nothing to do in the interstitial case. + + tabContents_->render_view_host()->DragTargetDragLeave(); +} + +- (NSDragOperation)draggingUpdated:(id<NSDraggingInfo>)info + view:(NSView*)view { + DCHECK(currentRVH_); + if (currentRVH_ != tabContents_->render_view_host()) + [self draggingEntered:info view:view]; + + if ([self onlyAllowsNavigation]) { + if ([[info draggingPasteboard] containsURLData]) + return NSDragOperationCopy; + return NSDragOperationNone; + } + + // Create the appropriate mouse locations for WebCore. The draggingLocation + // is in window coordinates. + NSPoint windowPoint = [info draggingLocation]; + NSPoint viewPoint = [self flipWindowPointToView:windowPoint view:view]; + NSPoint screenPoint = [self flipWindowPointToScreen:windowPoint view:view]; + NSDragOperation mask = [info draggingSourceOperationMask]; + tabContents_->render_view_host()->DragTargetDragOver( + gfx::Point(viewPoint.x, viewPoint.y), + gfx::Point(screenPoint.x, screenPoint.y), + static_cast<WebDragOperationsMask>(mask)); + + // If the tab is showing the boomark manager, send BookmarkDrag events + RenderViewHostDelegate::BookmarkDrag* dragDelegate = + tabContents_->GetBookmarkDragDelegate(); + BookmarkNodeData dragData; + if(dragDelegate && dragData.ReadFromDragClipboard()) + dragDelegate->OnDragOver(dragData); + return current_operation_; +} + +- (BOOL)performDragOperation:(id<NSDraggingInfo>)info + view:(NSView*)view { + if (currentRVH_ != tabContents_->render_view_host()) + [self draggingEntered:info view:view]; + + // Check if we only allow navigation and navigate to a url on the pasteboard. + if ([self onlyAllowsNavigation]) { + NSPasteboard* pboard = [info draggingPasteboard]; + if ([pboard containsURLData]) { + GURL url; + [self populateURL:&url + andTitle:NULL + fromPasteboard:pboard + convertingFilenames:YES]; + tabContents_->OpenURL(url, GURL(), CURRENT_TAB, + PageTransition::AUTO_BOOKMARK); + return YES; + } + return NO; + } + + // If the tab is showing the boomark manager, send BookmarkDrag events + RenderViewHostDelegate::BookmarkDrag* dragDelegate = + tabContents_->GetBookmarkDragDelegate(); + BookmarkNodeData dragData; + if(dragDelegate && dragData.ReadFromDragClipboard()) + dragDelegate->OnDrop(dragData); + + currentRVH_ = NULL; + + // Create the appropriate mouse locations for WebCore. The draggingLocation + // is in window coordinates. Both need to be flipped. + NSPoint windowPoint = [info draggingLocation]; + NSPoint viewPoint = [self flipWindowPointToView:windowPoint view:view]; + NSPoint screenPoint = [self flipWindowPointToScreen:windowPoint view:view]; + tabContents_->render_view_host()->DragTargetDrop( + gfx::Point(viewPoint.x, viewPoint.y), + gfx::Point(screenPoint.x, screenPoint.y)); + + return YES; +} + +// Populate the |url| and |title| with URL data in |pboard|. There may be more +// than one, but we only handle dropping the first. |url| must not be |NULL|; +// |title| is an optional parameter. Returns |YES| if URL data was obtained from +// the pasteboard, |NO| otherwise. If |convertFilenames| is |YES|, the function +// will also attempt to convert filenames in |pboard| to file URLs. +- (BOOL)populateURL:(GURL*)url + andTitle:(string16*)title + fromPasteboard:(NSPasteboard*)pboard + convertingFilenames:(BOOL)convertFilenames { + DCHECK(url); + DCHECK(title); + + // Bail out early if there's no URL data. + if (![pboard containsURLData]) + return NO; + + // |-getURLs:andTitles:convertingFilenames:| will already validate URIs so we + // don't need to again. The arrays returned are both of NSString's. + NSArray* urls = nil; + NSArray* titles = nil; + [pboard getURLs:&urls andTitles:&titles convertingFilenames:convertFilenames]; + DCHECK_EQ([urls count], [titles count]); + // It's possible that no URLs were actually provided! + if (![urls count]) + return NO; + NSString* urlString = [urls objectAtIndex:0]; + if ([urlString length]) { + // Check again just to make sure to not assign NULL into a std::string, + // which throws an exception. + const char* utf8Url = [urlString UTF8String]; + if (utf8Url) { + *url = GURL(utf8Url); + // Extra paranoia check. + if (title && [titles count]) + *title = base::SysNSStringToUTF16([titles objectAtIndex:0]); + } + } + return YES; +} + +// Given |data|, which should not be nil, fill it in using the contents of the +// given pasteboard. +- (void)populateWebDropData:(WebDropData*)data + fromPasteboard:(NSPasteboard*)pboard { + DCHECK(data); + DCHECK(pboard); + NSArray* types = [pboard types]; + + // Get URL if possible. To avoid exposing file system paths to web content, + // filenames in the drag are not converted to file URLs. + [self populateURL:&data->url + andTitle:&data->url_title + fromPasteboard:pboard + convertingFilenames:NO]; + + // Get plain text. + if ([types containsObject:NSStringPboardType]) { + data->plain_text = + base::SysNSStringToUTF16([pboard stringForType:NSStringPboardType]); + } + + // Get HTML. If there's no HTML, try RTF. + if ([types containsObject:NSHTMLPboardType]) { + data->text_html = + base::SysNSStringToUTF16([pboard stringForType:NSHTMLPboardType]); + } else if ([types containsObject:NSRTFPboardType]) { + NSString* html = [pboard htmlFromRtf]; + data->text_html = base::SysNSStringToUTF16(html); + } + + // Get files. + if ([types containsObject:NSFilenamesPboardType]) { + NSArray* files = [pboard propertyListForType:NSFilenamesPboardType]; + if ([files isKindOfClass:[NSArray class]] && [files count]) { + for (NSUInteger i = 0; i < [files count]; i++) { + NSString* filename = [files objectAtIndex:i]; + BOOL isDir = NO; + BOOL exists = [[NSFileManager defaultManager] fileExistsAtPath:filename + isDirectory:&isDir]; + if (exists && !isDir) + data->filenames.push_back(base::SysNSStringToUTF16(filename)); + } + } + } + + // TODO(pinkerton): Get file contents. http://crbug.com/34661 +} + +@end diff --git a/chrome/browser/ui/cocoa/web_drop_target_unittest.mm b/chrome/browser/ui/cocoa/web_drop_target_unittest.mm new file mode 100644 index 0000000..0261e89 --- /dev/null +++ b/chrome/browser/ui/cocoa/web_drop_target_unittest.mm @@ -0,0 +1,166 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/mac/scoped_nsautorelease_pool.h" +#include "base/sys_string_conversions.h" +#include "base/utf_string_conversions.h" +#include "chrome/browser/renderer_host/test/test_render_view_host.h" +#include "chrome/browser/tab_contents/test_tab_contents.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/web_drop_target.h" +#include "testing/gtest/include/gtest/gtest.h" +#import "third_party/mozilla/NSPasteboard+Utils.h" +#include "webkit/glue/webdropdata.h" + +class WebDropTargetTest : public RenderViewHostTestHarness { + public: + virtual void SetUp() { + RenderViewHostTestHarness::SetUp(); + CocoaTest::BootstrapCocoa(); + drop_target_.reset([[WebDropTarget alloc] initWithTabContents:contents()]); + } + + void PutURLOnPasteboard(NSString* urlString, NSPasteboard* pboard) { + [pboard declareTypes:[NSArray arrayWithObject:NSURLPboardType] + owner:nil]; + NSURL* url = [NSURL URLWithString:urlString]; + EXPECT_TRUE(url); + [url writeToPasteboard:pboard]; + } + + void PutCoreURLAndTitleOnPasteboard(NSString* urlString, NSString* title, + NSPasteboard* pboard) { + [pboard + declareTypes:[NSArray arrayWithObjects:kCorePasteboardFlavorType_url, + kCorePasteboardFlavorType_urln, + nil] + owner:nil]; + [pboard setString:urlString + forType:kCorePasteboardFlavorType_url]; + [pboard setString:title + forType:kCorePasteboardFlavorType_urln]; + } + + base::mac::ScopedNSAutoreleasePool pool_; + scoped_nsobject<WebDropTarget> drop_target_; +}; + +// Make sure nothing leaks. +TEST_F(WebDropTargetTest, Init) { + EXPECT_TRUE(drop_target_); +} + +// Test flipping of coordinates given a point in window coordinates. +TEST_F(WebDropTargetTest, Flip) { + NSPoint windowPoint = NSZeroPoint; + scoped_nsobject<NSWindow> window([[CocoaTestHelperWindow alloc] init]); + NSPoint viewPoint = + [drop_target_ flipWindowPointToView:windowPoint + view:[window contentView]]; + NSPoint screenPoint = + [drop_target_ flipWindowPointToScreen:windowPoint + view:[window contentView]]; + EXPECT_EQ(0, viewPoint.x); + EXPECT_EQ(600, viewPoint.y); + EXPECT_EQ(0, screenPoint.x); + // We can't put a value on the screen size since everyone will have a + // different one. + EXPECT_NE(0, screenPoint.y); +} + +TEST_F(WebDropTargetTest, URL) { + NSPasteboard* pboard = nil; + NSString* url = nil; + NSString* title = nil; + GURL result_url; + string16 result_title; + + // Put a URL on the pasteboard and check it. + pboard = [NSPasteboard pasteboardWithUniqueName]; + url = @"http://www.google.com/"; + PutURLOnPasteboard(url, pboard); + EXPECT_TRUE([drop_target_ populateURL:&result_url + andTitle:&result_title + fromPasteboard:pboard + convertingFilenames:NO]); + EXPECT_EQ(base::SysNSStringToUTF8(url), result_url.spec()); + [pboard releaseGlobally]; + + // Put a 'url ' and 'urln' on the pasteboard and check it. + pboard = [NSPasteboard pasteboardWithUniqueName]; + url = @"http://www.google.com/"; + title = @"Title of Awesomeness!", + PutCoreURLAndTitleOnPasteboard(url, title, pboard); + EXPECT_TRUE([drop_target_ populateURL:&result_url + andTitle:&result_title + fromPasteboard:pboard + convertingFilenames:NO]); + EXPECT_EQ(base::SysNSStringToUTF8(url), result_url.spec()); + EXPECT_EQ(base::SysNSStringToUTF16(title), result_title); + [pboard releaseGlobally]; + + // Also check that it passes file:// via 'url '/'urln' properly. + pboard = [NSPasteboard pasteboardWithUniqueName]; + url = @"file:///tmp/dont_delete_me.txt"; + title = @"very important"; + PutCoreURLAndTitleOnPasteboard(url, title, pboard); + EXPECT_TRUE([drop_target_ populateURL:&result_url + andTitle:&result_title + fromPasteboard:pboard + convertingFilenames:NO]); + EXPECT_EQ(base::SysNSStringToUTF8(url), result_url.spec()); + EXPECT_EQ(base::SysNSStringToUTF16(title), result_title); + [pboard releaseGlobally]; + + // And javascript:. + pboard = [NSPasteboard pasteboardWithUniqueName]; + url = @"javascript:open('http://www.youtube.com/')"; + title = @"kill some time"; + PutCoreURLAndTitleOnPasteboard(url, title, pboard); + EXPECT_TRUE([drop_target_ populateURL:&result_url + andTitle:&result_title + fromPasteboard:pboard + convertingFilenames:NO]); + EXPECT_EQ(base::SysNSStringToUTF8(url), result_url.spec()); + EXPECT_EQ(base::SysNSStringToUTF16(title), result_title); + [pboard releaseGlobally]; + + pboard = [NSPasteboard pasteboardWithUniqueName]; + url = @"/bin/sh"; + [pboard declareTypes:[NSArray arrayWithObject:NSFilenamesPboardType] + owner:nil]; + [pboard setPropertyList:[NSArray arrayWithObject:url] + forType:NSFilenamesPboardType]; + EXPECT_FALSE([drop_target_ populateURL:&result_url + andTitle:&result_title + fromPasteboard:pboard + convertingFilenames:NO]); + EXPECT_TRUE([drop_target_ populateURL:&result_url + andTitle:&result_title + fromPasteboard:pboard + convertingFilenames:YES]); + EXPECT_EQ("file://localhost/bin/sh", result_url.spec()); + EXPECT_EQ("sh", UTF16ToUTF8(result_title)); + [pboard releaseGlobally]; +} + +TEST_F(WebDropTargetTest, Data) { + WebDropData data; + NSPasteboard* pboard = [NSPasteboard pasteboardWithUniqueName]; + + PutURLOnPasteboard(@"http://www.google.com", pboard); + [pboard addTypes:[NSArray arrayWithObjects:NSHTMLPboardType, + NSStringPboardType, nil] + owner:nil]; + NSString* htmlString = @"<html><body><b>hi there</b></body></html>"; + NSString* textString = @"hi there"; + [pboard setString:htmlString forType:NSHTMLPboardType]; + [pboard setString:textString forType:NSStringPboardType]; + [drop_target_ populateWebDropData:&data fromPasteboard:pboard]; + EXPECT_EQ(data.url.spec(), "http://www.google.com/"); + EXPECT_EQ(base::SysNSStringToUTF16(textString), data.plain_text); + EXPECT_EQ(base::SysNSStringToUTF16(htmlString), data.text_html); + + [pboard releaseGlobally]; +} diff --git a/chrome/browser/ui/cocoa/window_size_autosaver.h b/chrome/browser/ui/cocoa/window_size_autosaver.h new file mode 100644 index 0000000..5dbcff4 --- /dev/null +++ b/chrome/browser/ui/cocoa/window_size_autosaver.h @@ -0,0 +1,35 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_WINDOW_SIZE_AUTOSAVER_H_ +#define CHROME_BROWSER_UI_COCOA_WINDOW_SIZE_AUTOSAVER_H_ +#pragma once + +class PrefService; +@class NSWindow; + +// WindowSizeAutosaver is a helper class that makes it easy to let windows +// autoremember their position or position and size in a PrefService object. +// To use this, add a |scoped_nsobject<WindowSizeAutosaver>| to your window +// controller and initialize it in the window controller's init method, passing +// a window and an autosave name. The autosaver will register for "window moved" +// and "window resized" notifications and write the current window state to the +// prefs service every time they fire. The window's size is automatically +// restored when the autosaver's |initWithWindow:...| method is called. +// +// Note: Your xib file should have "Visible at launch" UNCHECKED, so that the +// initial repositioning is not visible. +@interface WindowSizeAutosaver : NSObject { + NSWindow* window_; // weak + PrefService* prefService_; // weak + const char* path_; +} + +- (id)initWithWindow:(NSWindow*)window + prefService:(PrefService*)prefs + path:(const char*)path; +@end + +#endif // CHROME_BROWSER_UI_COCOA_WINDOW_SIZE_AUTOSAVER_H_ + diff --git a/chrome/browser/ui/cocoa/window_size_autosaver.mm b/chrome/browser/ui/cocoa/window_size_autosaver.mm new file mode 100644 index 0000000..5ca9878 --- /dev/null +++ b/chrome/browser/ui/cocoa/window_size_autosaver.mm @@ -0,0 +1,108 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#import "chrome/browser/ui/cocoa/window_size_autosaver.h" + +#include "chrome/browser/prefs/pref_service.h" + +// If the window width stored in the prefs is smaller than this, the size is +// not restored but instead cleared from the profile -- to protect users from +// accidentally making their windows very small and then not finding them again. +const int kMinWindowWidth = 101; + +// Minimum restored window height, see |kMinWindowWidth|. +const int kMinWindowHeight = 17; + +@interface WindowSizeAutosaver (Private) +- (void)save:(NSNotification*)notification; +- (void)restore; +@end + +@implementation WindowSizeAutosaver + +- (id)initWithWindow:(NSWindow*)window + prefService:(PrefService*)prefs + path:(const char*)path { + if ((self = [super init])) { + window_ = window; + prefService_ = prefs; + path_ = path; + + [self restore]; + [[NSNotificationCenter defaultCenter] + addObserver:self + selector:@selector(save:) + name:NSWindowDidMoveNotification + object:window_]; + [[NSNotificationCenter defaultCenter] + addObserver:self + selector:@selector(save:) + name:NSWindowDidResizeNotification + object:window_]; + } + return self; +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [super dealloc]; +} + +- (void)save:(NSNotification*)notification { + DictionaryValue* windowPrefs = prefService_->GetMutableDictionary(path_); + NSRect frame = [window_ frame]; + if ([window_ styleMask] & NSResizableWindowMask) { + // Save the origin of the window. + windowPrefs->SetInteger("left", NSMinX(frame)); + windowPrefs->SetInteger("right", NSMaxX(frame)); + // windows's and linux's profiles have top < bottom due to having their + // screen origin in the upper left, while cocoa's is in the lower left. To + // keep the top < bottom invariant, store top in bottom and vice versa. + windowPrefs->SetInteger("top", NSMinY(frame)); + windowPrefs->SetInteger("bottom", NSMaxY(frame)); + } else { + // Save the origin of the window. + windowPrefs->SetInteger("x", frame.origin.x); + windowPrefs->SetInteger("y", frame.origin.y); + } +} + +- (void)restore { + // Get the positioning information. + DictionaryValue* windowPrefs = prefService_->GetMutableDictionary(path_); + if ([window_ styleMask] & NSResizableWindowMask) { + int x1, x2, y1, y2; + if (!windowPrefs->GetInteger("left", &x1) || + !windowPrefs->GetInteger("right", &x2) || + !windowPrefs->GetInteger("top", &y1) || + !windowPrefs->GetInteger("bottom", &y2)) { + return; + } + if (x2 - x1 < kMinWindowWidth || y2 - y1 < kMinWindowHeight) { + // Windows should never be very small. + windowPrefs->Remove("left", NULL); + windowPrefs->Remove("right", NULL); + windowPrefs->Remove("top", NULL); + windowPrefs->Remove("bottom", NULL); + } else { + [window_ setFrame:NSMakeRect(x1, y1, x2 - x1, y2 - y1) display:YES]; + + // Make sure the window is on-screen. + [window_ cascadeTopLeftFromPoint:NSZeroPoint]; + } + } else { + int x, y; + if (!windowPrefs->GetInteger("x", &x) || + !windowPrefs->GetInteger("y", &y)) + return; // Nothing stored. + // Turn the origin (lower-left) into an upper-left window point. + NSPoint upperLeft = NSMakePoint(x, y + NSHeight([window_ frame])); + [window_ cascadeTopLeftFromPoint:upperLeft]; + } +} + +@end + diff --git a/chrome/browser/ui/cocoa/window_size_autosaver_unittest.mm b/chrome/browser/ui/cocoa/window_size_autosaver_unittest.mm new file mode 100644 index 0000000..c3b33ed --- /dev/null +++ b/chrome/browser/ui/cocoa/window_size_autosaver_unittest.mm @@ -0,0 +1,201 @@ +// Copyright (c) 2009 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import <Cocoa/Cocoa.h> + +#import "chrome/browser/ui/cocoa/window_size_autosaver.h" + +#include "base/scoped_nsobject.h" +#include "chrome/browser/prefs/pref_service.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +namespace { + +class WindowSizeAutosaverTest : public CocoaTest { + virtual void SetUp() { + CocoaTest::SetUp(); + path_ = "WindowSizeAutosaverTest"; + window_ = + [[NSWindow alloc] initWithContentRect:NSMakeRect(100, 101, 150, 151) + styleMask:NSTitledWindowMask| + NSResizableWindowMask + backing:NSBackingStoreBuffered + defer:NO]; + browser_helper_.profile()->GetPrefs()->RegisterDictionaryPref(path_); + } + + virtual void TearDown() { + [window_ close]; + CocoaTest::TearDown(); + } + + public: + BrowserTestHelper browser_helper_; + NSWindow* window_; + const char* path_; +}; + +TEST_F(WindowSizeAutosaverTest, RestoresAndSavesPos) { + PrefService* pref = browser_helper_.profile()->GetPrefs(); + ASSERT_TRUE(pref != NULL); + + // Check to make sure there is no existing pref for window placement. + ASSERT_TRUE(pref->GetDictionary(path_) == NULL); + + // Replace the window with one that doesn't have resize controls. + [window_ close]; + window_ = + [[NSWindow alloc] initWithContentRect:NSMakeRect(100, 101, 150, 151) + styleMask:NSTitledWindowMask + backing:NSBackingStoreBuffered + defer:NO]; + + // Ask the window to save its position, then check that a preference + // exists. We're technically passing in a pointer to the user prefs + // and not the local state prefs, but a PrefService* is a + // PrefService*, and this is a unittest. + + { + NSRect frame = [window_ frame]; + // Empty state, shouldn't restore: + scoped_nsobject<WindowSizeAutosaver> sizeSaver([[WindowSizeAutosaver alloc] + initWithWindow:window_ + prefService:pref + path:path_]); + EXPECT_EQ(NSMinX(frame), NSMinX([window_ frame])); + EXPECT_EQ(NSMinY(frame), NSMinY([window_ frame])); + EXPECT_EQ(NSWidth(frame), NSWidth([window_ frame])); + EXPECT_EQ(NSHeight(frame), NSHeight([window_ frame])); + + // Move and resize window, should store position but not size. + [window_ setFrame:NSMakeRect(300, 310, 250, 252) display:NO]; + } + + // Another window movement -- shouldn't be recorded. + [window_ setFrame:NSMakeRect(400, 420, 160, 162) display:NO]; + + { + // Should restore last stored position, but not size. + scoped_nsobject<WindowSizeAutosaver> sizeSaver([[WindowSizeAutosaver alloc] + initWithWindow:window_ + prefService:pref + path:path_]); + EXPECT_EQ(300, NSMinX([window_ frame])); + EXPECT_EQ(310, NSMinY([window_ frame])); + EXPECT_EQ(160, NSWidth([window_ frame])); + EXPECT_EQ(162, NSHeight([window_ frame])); + } + + // ...and it should be in the profile, too. + EXPECT_TRUE(pref->GetDictionary(path_) != NULL); + int x, y; + DictionaryValue* windowPref = pref->GetMutableDictionary(path_); + EXPECT_FALSE(windowPref->GetInteger("left", &x)); + EXPECT_FALSE(windowPref->GetInteger("right", &x)); + EXPECT_FALSE(windowPref->GetInteger("top", &x)); + EXPECT_FALSE(windowPref->GetInteger("bottom", &x)); + ASSERT_TRUE(windowPref->GetInteger("x", &x)); + ASSERT_TRUE(windowPref->GetInteger("y", &y)); + EXPECT_EQ(300, x); + EXPECT_EQ(310, y); +} + +TEST_F(WindowSizeAutosaverTest, RestoresAndSavesRect) { + PrefService* pref = browser_helper_.profile()->GetPrefs(); + ASSERT_TRUE(pref != NULL); + + // Check to make sure there is no existing pref for window placement. + ASSERT_TRUE(pref->GetDictionary(path_) == NULL); + + // Ask the window to save its position, then check that a preference + // exists. We're technically passing in a pointer to the user prefs + // and not the local state prefs, but a PrefService* is a + // PrefService*, and this is a unittest. + + { + NSRect frame = [window_ frame]; + // Empty state, shouldn't restore: + scoped_nsobject<WindowSizeAutosaver> sizeSaver([[WindowSizeAutosaver alloc] + initWithWindow:window_ + prefService:pref + path:path_]); + EXPECT_EQ(NSMinX(frame), NSMinX([window_ frame])); + EXPECT_EQ(NSMinY(frame), NSMinY([window_ frame])); + EXPECT_EQ(NSWidth(frame), NSWidth([window_ frame])); + EXPECT_EQ(NSHeight(frame), NSHeight([window_ frame])); + + // Move and resize window, should store + [window_ setFrame:NSMakeRect(300, 310, 250, 252) display:NO]; + } + + // Another window movement -- shouldn't be recorded. + [window_ setFrame:NSMakeRect(400, 420, 160, 162) display:NO]; + + { + // Should restore last stored size + scoped_nsobject<WindowSizeAutosaver> sizeSaver([[WindowSizeAutosaver alloc] + initWithWindow:window_ + prefService:pref + path:path_]); + EXPECT_EQ(300, NSMinX([window_ frame])); + EXPECT_EQ(310, NSMinY([window_ frame])); + EXPECT_EQ(250, NSWidth([window_ frame])); + EXPECT_EQ(252, NSHeight([window_ frame])); + } + + // ...and it should be in the profile, too. + EXPECT_TRUE(pref->GetDictionary(path_) != NULL); + int x1, y1, x2, y2; + DictionaryValue* windowPref = pref->GetMutableDictionary(path_); + EXPECT_FALSE(windowPref->GetInteger("x", &x1)); + EXPECT_FALSE(windowPref->GetInteger("y", &x1)); + ASSERT_TRUE(windowPref->GetInteger("left", &x1)); + ASSERT_TRUE(windowPref->GetInteger("right", &x2)); + ASSERT_TRUE(windowPref->GetInteger("top", &y1)); + ASSERT_TRUE(windowPref->GetInteger("bottom", &y2)); + EXPECT_EQ(300, x1); + EXPECT_EQ(310, y1); + EXPECT_EQ(300 + 250, x2); + EXPECT_EQ(310 + 252, y2); +} + +// http://crbug.com/39625 +TEST_F(WindowSizeAutosaverTest, DoesNotRestoreButClearsEmptyRect) { + PrefService* pref = browser_helper_.profile()->GetPrefs(); + ASSERT_TRUE(pref != NULL); + + DictionaryValue* windowPref = pref->GetMutableDictionary(path_); + windowPref->SetInteger("left", 50); + windowPref->SetInteger("right", 50); + windowPref->SetInteger("top", 60); + windowPref->SetInteger("bottom", 60); + + { + // Window rect shouldn't change... + NSRect frame = [window_ frame]; + scoped_nsobject<WindowSizeAutosaver> sizeSaver([[WindowSizeAutosaver alloc] + initWithWindow:window_ + prefService:pref + path:path_]); + EXPECT_EQ(NSMinX(frame), NSMinX([window_ frame])); + EXPECT_EQ(NSMinY(frame), NSMinY([window_ frame])); + EXPECT_EQ(NSWidth(frame), NSWidth([window_ frame])); + EXPECT_EQ(NSHeight(frame), NSHeight([window_ frame])); + } + + // ...and it should be gone from the profile, too. + EXPECT_TRUE(pref->GetDictionary(path_) != NULL); + int x1, y1, x2, y2; + EXPECT_FALSE(windowPref->GetInteger("x", &x1)); + EXPECT_FALSE(windowPref->GetInteger("y", &x1)); + ASSERT_FALSE(windowPref->GetInteger("left", &x1)); + ASSERT_FALSE(windowPref->GetInteger("right", &x2)); + ASSERT_FALSE(windowPref->GetInteger("top", &y1)); + ASSERT_FALSE(windowPref->GetInteger("bottom", &y2)); +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/wrench_menu_button_cell.h b/chrome/browser/ui/cocoa/wrench_menu_button_cell.h new file mode 100644 index 0000000..c2d3432 --- /dev/null +++ b/chrome/browser/ui/cocoa/wrench_menu_button_cell.h @@ -0,0 +1,19 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_WRENCH_MENU_BUTTON_CELL_H_ +#define CHROME_BROWSER_UI_COCOA_WRENCH_MENU_BUTTON_CELL_H_ + +#import <Cocoa/Cocoa.h> + +// The WrenchMenuButtonCell overrides drawing the background gradient to use +// the same colors as NSSmallSquareBezelStyle but as a smooth gradient, rather +// than two blocks of colors. This also uses the blue menu highlight color for +// the pressed state. +@interface WrenchMenuButtonCell : NSButtonCell { +} + +@end + +#endif // CHROME_BROWSER_UI_COCOA_WRENCH_MENU_BUTTON_CELL_H_ diff --git a/chrome/browser/ui/cocoa/wrench_menu_button_cell.mm b/chrome/browser/ui/cocoa/wrench_menu_button_cell.mm new file mode 100644 index 0000000..c2a15b7 --- /dev/null +++ b/chrome/browser/ui/cocoa/wrench_menu_button_cell.mm @@ -0,0 +1,48 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/wrench_menu_button_cell.h" + +#include "base/scoped_nsobject.h" + +@implementation WrenchMenuButtonCell + +- (void)drawBezelWithFrame:(NSRect)frame inView:(NSView*)controlView { + [NSGraphicsContext saveGraphicsState]; + + // Inset the rect to match the appearance of the layout of interface builder. + // The bounding rect of buttons is actually larger than the display rect shown + // there. + frame = NSInsetRect(frame, 0.0, 1.0); + + // Stroking the rect gives a weak stroke. Filling and insetting gives a + // strong, un-anti-aliased border. + [[NSColor colorWithDeviceWhite:0.663 alpha:1.0] set]; + NSRectFill(frame); + frame = NSInsetRect(frame, 1.0, 1.0); + + // The default state should be a subtle gray gradient. + if (![self isHighlighted]) { + NSColor* end = [NSColor colorWithDeviceWhite:0.922 alpha:1.0]; + scoped_nsobject<NSGradient> gradient( + [[NSGradient alloc] initWithStartingColor:[NSColor whiteColor] + endingColor:end]); + [gradient drawInRect:frame angle:90.0]; + } else { + // |+selectedMenuItemColor| appears to be a gradient, so just filling the + // rect with that color produces the desired effect. + [[NSColor selectedMenuItemColor] set]; + NSRectFill(frame); + } + + [NSGraphicsContext restoreGraphicsState]; +} + +- (NSBackgroundStyle)interiorBackgroundStyle { + if ([self isHighlighted]) + return NSBackgroundStyleDark; + return [super interiorBackgroundStyle]; +} + +@end diff --git a/chrome/browser/ui/cocoa/wrench_menu_button_cell_unittest.mm b/chrome/browser/ui/cocoa/wrench_menu_button_cell_unittest.mm new file mode 100644 index 0000000..7ab1588 --- /dev/null +++ b/chrome/browser/ui/cocoa/wrench_menu_button_cell_unittest.mm @@ -0,0 +1,51 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/scoped_nsobject.h" +#include "chrome/app/chrome_command_ids.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/wrench_menu_button_cell.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +@interface TestWrenchMenuButton : NSButton +@end +@implementation TestWrenchMenuButton ++ (Class)cellClass { + return [WrenchMenuButtonCell class]; +} +@end + +namespace { + +class WrenchMenuButtonCellTest : public CocoaTest { + public: + void SetUp() { + CocoaTest::SetUp(); + + NSRect frame = NSMakeRect(10, 10, 50, 19); + button_.reset([[TestWrenchMenuButton alloc] initWithFrame:frame]); + [button_ setBezelStyle:NSSmallSquareBezelStyle]; + [[button_ cell] setControlSize:NSSmallControlSize]; + [button_ setTitle:@"Allays"]; + [button_ setButtonType:NSMomentaryPushInButton]; + } + + scoped_nsobject<NSButton> button_; +}; + +TEST_F(WrenchMenuButtonCellTest, Draw) { + ASSERT_TRUE(button_.get()); + [[test_window() contentView] addSubview:button_.get()]; + [button_ setNeedsDisplay:YES]; +} + +TEST_F(WrenchMenuButtonCellTest, DrawHighlight) { + ASSERT_TRUE(button_.get()); + [[test_window() contentView] addSubview:button_.get()]; + [button_ highlight:YES]; + [button_ setNeedsDisplay:YES]; +} + +} // namespace diff --git a/chrome/browser/ui/cocoa/wrench_menu_controller.h b/chrome/browser/ui/cocoa/wrench_menu_controller.h new file mode 100644 index 0000000..5f1d9ca --- /dev/null +++ b/chrome/browser/ui/cocoa/wrench_menu_controller.h @@ -0,0 +1,72 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_COCOA_WRENCH_MENU_CONTROLLER_H_ +#define CHROME_BROWSER_UI_COCOA_WRENCH_MENU_CONTROLLER_H_ +#pragma once + +#import <Cocoa/Cocoa.h> + +#import "base/cocoa_protocols_mac.h" +#include "base/scoped_ptr.h" +#import "chrome/browser/ui/cocoa/menu_controller.h" + +@class MenuTrackedRootView; +@class ToolbarController; +class WrenchMenuModel; + +namespace WrenchMenuControllerInternal { +class ZoomLevelObserver; +} // namespace WrenchMenuControllerInternal + +// The Wrench menu has a creative layout, with buttons in menu items. There is +// a cross-platform model for this special menu, but on the Mac it's easier to +// get spacing and alignment precisely right using a NIB. To do that, we +// subclass the generic MenuController implementation and special-case the two +// items that require specific layout and load them from the NIB. +// +// This object is instantiated in Toolbar.xib and is configured by the +// ToolbarController. +@interface WrenchMenuController : MenuController<NSMenuDelegate> { + IBOutlet MenuTrackedRootView* editItem_; + IBOutlet NSButton* editCut_; + IBOutlet NSButton* editCopy_; + IBOutlet NSButton* editPaste_; + + IBOutlet MenuTrackedRootView* zoomItem_; + IBOutlet NSButton* zoomPlus_; + IBOutlet NSButton* zoomDisplay_; + IBOutlet NSButton* zoomMinus_; + IBOutlet NSButton* zoomFullScreen_; + + scoped_ptr<WrenchMenuControllerInternal::ZoomLevelObserver> observer_; +} + +// Designated initializer; called within the NIB. +- (id)init; + +// Used to dispatch commands from the Wrench menu. The custom items within the +// menu cannot be hooked up directly to First Responder because the window in +// which the controls reside is not the BrowserWindowController, but a +// NSCarbonMenuWindow; this screws up the typical |-commandDispatch:| system. +- (IBAction)dispatchWrenchMenuCommand:(id)sender; + +// Returns the weak reference to the WrenchMenuModel. +- (WrenchMenuModel*)wrenchMenuModel; + +@end + +//////////////////////////////////////////////////////////////////////////////// + +@interface WrenchMenuController (UnitTesting) +// |-dispatchWrenchMenuCommand:| calls this after it has determined the tag of +// the sender. The default implementation executes the command on the outermost +// run loop using |-performSelector...withDelay:|. This is not desirable in +// unit tests because it's hard to test around run loops in a deterministic +// manner. To avoid those headaches, tests should provide an alternative +// implementation. +- (void)dispatchCommandInternal:(NSInteger)tag; +@end + +#endif // CHROME_BROWSER_UI_COCOA_WRENCH_MENU_CONTROLLER_H_ diff --git a/chrome/browser/ui/cocoa/wrench_menu_controller.mm b/chrome/browser/ui/cocoa/wrench_menu_controller.mm new file mode 100644 index 0000000..d4a9872 --- /dev/null +++ b/chrome/browser/ui/cocoa/wrench_menu_controller.mm @@ -0,0 +1,213 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "chrome/browser/ui/cocoa/wrench_menu_controller.h" + +#include "app/l10n_util.h" +#include "app/menus/menu_model.h" +#include "base/sys_string_conversions.h" +#include "chrome/app/chrome_command_ids.h" +#import "chrome/browser/ui/cocoa/menu_tracked_root_view.h" +#import "chrome/browser/ui/cocoa/toolbar_controller.h" +#include "chrome/browser/ui/browser.h" +#include "chrome/browser/ui/browser_window.h" +#include "chrome/browser/wrench_menu_model.h" +#include "chrome/common/notification_observer.h" +#include "chrome/common/notification_service.h" +#include "chrome/common/notification_source.h" +#include "chrome/common/notification_type.h" +#include "grit/chromium_strings.h" +#include "grit/generated_resources.h" + +@interface WrenchMenuController (Private) +- (void)adjustPositioning; +- (void)performCommandDispatch:(NSNumber*)tag; +- (NSButton*)zoomDisplay; +@end + +namespace WrenchMenuControllerInternal { + +class ZoomLevelObserver : public NotificationObserver { + public: + explicit ZoomLevelObserver(WrenchMenuController* controller) + : controller_(controller) { + registrar_.Add(this, NotificationType::ZOOM_LEVEL_CHANGED, + NotificationService::AllSources()); + } + + void Observe(NotificationType type, + const NotificationSource& source, + const NotificationDetails& details) { + DCHECK_EQ(type.value, NotificationType::ZOOM_LEVEL_CHANGED); + WrenchMenuModel* wrenchMenuModel = [controller_ wrenchMenuModel]; + wrenchMenuModel->UpdateZoomControls(); + const string16 level = + wrenchMenuModel->GetLabelForCommandId(IDC_ZOOM_PERCENT_DISPLAY); + [[controller_ zoomDisplay] setTitle:SysUTF16ToNSString(level)]; + } + + private: + NotificationRegistrar registrar_; + WrenchMenuController* controller_; // Weak; owns this. +}; + +} // namespace WrenchMenuControllerInternal + +@implementation WrenchMenuController + +- (id)init { + if ((self = [super init])) { + observer_.reset(new WrenchMenuControllerInternal::ZoomLevelObserver(self)); + } + return self; +} + +- (void)addItemToMenu:(NSMenu*)menu + atIndex:(NSInteger)index + fromModel:(menus::MenuModel*)model + modelIndex:(int)modelIndex { + // Non-button item types should be built as normal items. + menus::MenuModel::ItemType type = model->GetTypeAt(modelIndex); + if (type != menus::MenuModel::TYPE_BUTTON_ITEM) { + [super addItemToMenu:menu + atIndex:index + fromModel:model + modelIndex:modelIndex]; + return; + } + + // Handle the special-cased menu items. + int command_id = model->GetCommandIdAt(modelIndex); + scoped_nsobject<NSMenuItem> customItem( + [[NSMenuItem alloc] initWithTitle:@"" + action:nil + keyEquivalent:@""]); + switch (command_id) { + case IDC_EDIT_MENU: + DCHECK(editItem_); + [customItem setView:editItem_]; + [editItem_ setMenuItem:customItem]; + break; + case IDC_ZOOM_MENU: + DCHECK(zoomItem_); + [customItem setView:zoomItem_]; + [zoomItem_ setMenuItem:customItem]; + break; + default: + NOTREACHED(); + break; + } + [self adjustPositioning]; + [menu insertItem:customItem.get() atIndex:index]; +} + +- (NSMenu*)menu { + NSMenu* menu = [super menu]; + if (![menu delegate]) { + [menu setDelegate:self]; + } + return menu; +} + +- (void)menuWillOpen:(NSMenu*)menu { + NSString* title = base::SysUTF16ToNSString( + [self wrenchMenuModel]->GetLabelForCommandId(IDC_ZOOM_PERCENT_DISPLAY)); + [[zoomItem_ viewWithTag:IDC_ZOOM_PERCENT_DISPLAY] setTitle:title]; + + NSImage* icon = [self wrenchMenuModel]->browser()->window()->IsFullscreen() ? + [NSImage imageNamed:NSImageNameExitFullScreenTemplate] : + [NSImage imageNamed:NSImageNameEnterFullScreenTemplate]; + [zoomFullScreen_ setImage:icon]; +} + +// Used to dispatch commands from the Wrench menu. The custom items within the +// menu cannot be hooked up directly to First Responder because the window in +// which the controls reside is not the BrowserWindowController, but a +// NSCarbonMenuWindow; this screws up the typical |-commandDispatch:| system. +- (IBAction)dispatchWrenchMenuCommand:(id)sender { + NSInteger tag = [sender tag]; + if (sender == zoomPlus_ || sender == zoomMinus_) { + // Do a direct dispatch rather than scheduling on the outermost run loop, + // which would not get hit until after the menu had closed. + [self performCommandDispatch:[NSNumber numberWithInt:tag]]; + + // The zoom buttons should not close the menu if opened sticky. + if ([sender respondsToSelector:@selector(isTracking)] && + [sender performSelector:@selector(isTracking)]) { + [menu_ cancelTracking]; + } + } else { + // The custom views within the Wrench menu are abnormal and keep the menu + // open after a target-action. Close the menu manually. + [menu_ cancelTracking]; + [self dispatchCommandInternal:tag]; + } +} + +- (void)dispatchCommandInternal:(NSInteger)tag { + // Executing certain commands from the nested run loop of the menu can lead + // to wonky behavior (e.g. http://crbug.com/49716). To avoid this, schedule + // the dispatch on the outermost run loop. + [self performSelector:@selector(performCommandDispatch:) + withObject:[NSNumber numberWithInt:tag] + afterDelay:0.0]; +} + +// Used to perform the actual dispatch on the outermost runloop. +- (void)performCommandDispatch:(NSNumber*)tag { + [self wrenchMenuModel]->ExecuteCommand([tag intValue]); +} + +- (WrenchMenuModel*)wrenchMenuModel { + return static_cast<WrenchMenuModel*>(model_); +} + +// Fit the localized strings into the Cut/Copy/Paste control, then resize the +// whole menu item accordingly. +- (void)adjustPositioning { + const CGFloat kButtonPadding = 12; + CGFloat delta = 0; + + // Go through the three buttons from right-to-left, adjusting the size to fit + // the localized strings while keeping them all aligned on their horizontal + // edges. + const size_t kAdjustViewCount = 3; + NSButton* views[kAdjustViewCount] = { editPaste_, editCopy_, editCut_ }; + for (size_t i = 0; i < kAdjustViewCount; ++i) { + NSButton* button = views[i]; + CGFloat originalWidth = NSWidth([button frame]); + + // Do not let |-sizeToFit| change the height of the button. + NSSize size = [button frame].size; + [button sizeToFit]; + size.width = [button frame].size.width + kButtonPadding; + [button setFrameSize:size]; + + CGFloat newWidth = size.width; + delta += newWidth - originalWidth; + + NSRect frame = [button frame]; + frame.origin.x -= delta; + [button setFrame:frame]; + } + + // Resize the menu item by the total amound the buttons changed so that the + // spacing between the buttons and the title remains the same. + NSRect itemFrame = [editItem_ frame]; + itemFrame.size.width += delta; + [editItem_ setFrame:itemFrame]; + + // Also resize the superview of the buttons, which is an NSView used to slide + // when the item title is too big and GTM resizes it. + NSRect parentFrame = [[editCut_ superview] frame]; + parentFrame.size.width += delta; + parentFrame.origin.x -= delta; + [[editCut_ superview] setFrame:parentFrame]; +} + +- (NSButton*)zoomDisplay { + return zoomDisplay_; +} + +@end // @implementation WrenchMenuController diff --git a/chrome/browser/ui/cocoa/wrench_menu_controller_unittest.mm b/chrome/browser/ui/cocoa/wrench_menu_controller_unittest.mm new file mode 100644 index 0000000..243b2af --- /dev/null +++ b/chrome/browser/ui/cocoa/wrench_menu_controller_unittest.mm @@ -0,0 +1,84 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/scoped_nsobject.h" +#include "chrome/app/chrome_command_ids.h" +#include "chrome/browser/wrench_menu_model.h" +#include "chrome/browser/ui/cocoa/browser_test_helper.h" +#import "chrome/browser/ui/cocoa/cocoa_test_helper.h" +#import "chrome/browser/ui/cocoa/toolbar_controller.h" +#import "chrome/browser/ui/cocoa/wrench_menu_controller.h" +#import "chrome/browser/ui/cocoa/view_resizer_pong.h" +#include "testing/gmock/include/gmock/gmock.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/platform_test.h" + +// Override to avoid dealing with run loops in the testing environment. +@implementation WrenchMenuController (UnitTesting) +- (void)dispatchCommandInternal:(NSInteger)tag { + [self wrenchMenuModel]->ExecuteCommand(tag); +} +@end + + +namespace { + +class MockWrenchMenuModel : public WrenchMenuModel { + public: + MockWrenchMenuModel() : WrenchMenuModel() {} + ~MockWrenchMenuModel() { + // This dirty, ugly hack gets around a bug in the test. In + // ~WrenchMenuModel(), there's a call to TabstripModel::RemoveObserver(this) + // which mysteriously leads to this crash: http://crbug.com/49206 . It + // seems that the vector of observers is getting hosed somewhere between + // |-[ToolbarController dealloc]| and ~MockWrenchMenuModel(). This line + // short-circuits the parent destructor to avoid this crash. + tabstrip_model_ = NULL; + } + MOCK_METHOD1(ExecuteCommand, void(int command_id)); +}; + +class WrenchMenuControllerTest : public CocoaTest { + public: + void SetUp() { + Browser* browser = helper_.browser(); + resize_delegate_.reset([[ViewResizerPong alloc] init]); + toolbar_controller_.reset( + [[ToolbarController alloc] initWithModel:browser->toolbar_model() + commands:browser->command_updater() + profile:helper_.profile() + browser:browser + resizeDelegate:resize_delegate_.get()]); + EXPECT_TRUE([toolbar_controller_ view]); + NSView* parent = [test_window() contentView]; + [parent addSubview:[toolbar_controller_ view]]; + } + + WrenchMenuController* controller() { + return [toolbar_controller_ wrenchMenuController]; + } + + BrowserTestHelper helper_; + scoped_nsobject<ViewResizerPong> resize_delegate_; + MockWrenchMenuModel fake_model_; + scoped_nsobject<ToolbarController> toolbar_controller_; +}; + +TEST_F(WrenchMenuControllerTest, Initialized) { + EXPECT_TRUE([controller() menu]); + EXPECT_GE([[controller() menu] numberOfItems], 5); +} + +TEST_F(WrenchMenuControllerTest, DispatchSimple) { + scoped_nsobject<NSButton> button([[NSButton alloc] init]); + [button setTag:IDC_ZOOM_PLUS]; + + // Set fake model to test dispatching. + EXPECT_CALL(fake_model_, ExecuteCommand(IDC_ZOOM_PLUS)); + [controller() setModel:&fake_model_]; + + [controller() dispatchWrenchMenuCommand:button.get()]; +} + +} // namespace |