blob: 7800dc40046426f0b390060c0e5d416f02eec748 [file] [log] [blame]
// Copyright (c) 2011 The Chromium Authors. 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 "base/mac/mac_util.h"
#import "base/mac/sdk_forward_declarations.h"
#include "chrome/browser/themes/theme_service.h"
#include "chrome/browser/ui/cocoa/browser_dialogs_views_mac.h"
#import "chrome/browser/ui/cocoa/browser_window_controller.h"
#include "chrome/browser/ui/cocoa/l10n_util.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/location_bar_decoration.h"
#include "chrome/browser/ui/cocoa/omnibox/omnibox_view_mac.h"
#import "chrome/browser/ui/cocoa/toolbar/toolbar_controller.h"
#import "chrome/browser/ui/cocoa/url_drop_target.h"
#import "chrome/browser/ui/cocoa/view_id_util.h"
#import "ui/base/cocoa/nsview_additions.h"
#include "ui/gfx/scoped_ns_graphics_context_save_gstate_mac.h"
namespace {
const CGFloat kAnimationDuration = 0.2;
}
@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]);
resizeAnimation_.reset([[NSViewAnimation alloc] init]);
[resizeAnimation_ setDuration:kAnimationDuration];
[resizeAnimation_ setAnimationBlockingMode:NSAnimationNonblocking];
[self setAlignment:cocoa_l10n_util::ShouldDoExperimentalRTLLayout()
? NSRightTextAlignment
: NSLeftTextAlignment];
// Disable Force Touch in the Omnibox. Note that this API is documented as
// being available in 10.11 or higher, but if the API is available in an older
// version we still want to use it. That prevents us from guarding the call
// with @available, so instead we use respondsToSelector and silence the
// availability warning. Also, because NSPressureConfiguration is not in the
// original 10.10 SDK, use NSClassFromString() to instantiate it (otherwise
// there's a linker error).
if (base::mac::IsAtLeastOS10_10() &&
[self respondsToSelector:@selector(setPressureConfiguration:)]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wunguarded-availability"
NSPressureConfiguration* pressureConfiguration =
[[[NSClassFromString(@"NSPressureConfiguration") alloc]
initWithPressureBehavior:NSPressureBehaviorPrimaryClick]
autorelease];
[self setPressureConfiguration:pressureConfiguration];
#pragma clang diagnostic pop
}
}
- (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 {
// TODO(groby): Figure out if OnMouseDown needs to be postponed/skipped
// for button decorations.
if (observer_)
observer_->OnMouseDown([theEvent buttonNumber]);
// 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];
}
- (void)mouseUp:(NSEvent*)theEvent {
const NSRect bounds([self bounds]);
AutocompleteTextFieldCell* cell = [self cell];
[cell mouseUp:theEvent inRect:bounds ofView:self];
}
- (void)rightMouseDown:(NSEvent*)event {
if (observer_)
observer_->OnMouseDown([event buttonNumber]);
[super rightMouseDown:event];
}
- (void)otherMouseDown:(NSEvent *)event {
if (observer_)
observer_->OnMouseDown([event buttonNumber]);
[super otherMouseDown:event];
}
// Overridden so that cursor and tooltip rects can be updated.
- (void)setFrame:(NSRect)frameRect {
[super setFrame:frameRect];
if (observer_) {
observer_->OnFrameChanged();
}
[self updateMouseTracking];
}
- (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)animateToFrame:(NSRect)frame {
[self stopAnimation];
NSDictionary* animationDictionary = @{
NSViewAnimationTargetKey : self,
NSViewAnimationStartFrameKey : [NSValue valueWithRect:[self frame]],
NSViewAnimationEndFrameKey : [NSValue valueWithRect:frame]
};
[resizeAnimation_ setViewAnimations:@[ animationDictionary ]];
[resizeAnimation_ startAnimation];
}
- (void)stopAnimation {
if ([resizeAnimation_ isAnimating]) {
// [NSViewAnimation stopAnimation] results in advancing the animation to
// the end. Since this is almost certainly not the behavior we want, reset
// the frame to the current frame.
NSRect frame = [self frame];
[resizeAnimation_ stopAnimation];
[self setFrame:frame];
}
}
- (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];
}
- (NSPoint)bubblePointForDecoration:(LocationBarDecoration*)decoration {
// Use MD-style anchoring, even if only pilot dialogs are enabled. MD dialogs
// have no arrow and align corners. Cocoa dialogs will always have an arrow.
// This causes the arrows on Cocoa dialogs to align to the omnibox corner.
if (!chrome::ShowPilotDialogsWithViewsToolkit())
return [self arrowAnchorPointForDecoration:decoration];
// Under MD, dialogs have no arrow and anchor to corner of the location bar
// frame, not a specific point within it. See http://crbug.com/566115.
// Inset the omnibox frame by 2 real pixels. This is done because the border
// stroke of the omnibox is inside its frame, but bubbles have no border
// stroke. The bubble border is part of the shadow drawn by the window server;
// outside the bubble frame. In the Y direction, some of that same "gap" must
// be kept, otherwise the "border" stroke from the window server shadow would
// be drawn inside the omnibox. But since these insets round to integers when
// positioning the window, retina needs to use a zero vertical offset to avoid
// insetting an entire DIP. This looks OK, since the bubble border and omnibox
// border are still drawn flush. TODO(tapted): Convince the borders to overlap
// on retina somehow.
const CGFloat kStrokeInsetX = 2 * [self cr_lineWidth];
const CGFloat kStrokeInsetY = [self cr_lineWidth] == 1.0 ? 1 : 0;
const NSRect frame = NSInsetRect([self bounds], kStrokeInsetX, kStrokeInsetY);
BOOL isLeftDecoration = [[self cell] isLeftDecoration:decoration];
NSPoint point = NSMakePoint(isLeftDecoration ? NSMinX(frame) : NSMaxX(frame),
NSMaxY(frame));
return [self convertPoint:point toView:nil];
}
- (NSPoint)arrowAnchorPointForDecoration:(LocationBarDecoration*)decoration {
const NSRect frame =
[[self cell] frameForDecoration:decoration inFrame:[self bounds]];
NSPoint point = decoration->GetBubblePointInFrame(frame);
return [self convertPoint:point toView: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)updateMouseTracking {
// 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] updateMouseTrackingAndToolTipsInRect:[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 OmniboxViewMac. -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)windowDidResize:(NSNotification*)notification {
DCHECK_EQ([self window], [notification object]);
if (observer_)
observer_->OnFrameChanged();
}
- (void)viewWillMoveToWindow:(NSWindow*)newWindow {
if ([self window]) {
NSNotificationCenter* nc = [NSNotificationCenter defaultCenter];
[nc removeObserver:self
name:NSWindowDidResignKeyNotification
object:[self window]];
[nc removeObserver:self
name:NSWindowDidResizeNotification
object:[self window]];
[nc removeObserver:self
name:NSWindowDidChangeScreenNotification
object:[self window]];
}
}
- (void)windowDidChangeScreen {
// Inform the AutocompleteTextFieldCell's of the coordinate system line
// width needed to draw a single-pixel line. This value changes as we move
// between Retina and non-Retina displays.
[[self cell] setSinglePixelLineWidth:[self cr_lineWidth]];
[self setNeedsDisplay];
}
- (void)updateColorsToMatchTheme {
if (![[self window] inIncognitoMode]) {
return;
}
// Invert the textfield's colors when Material Design and Incognito and not
// a custom theme.
bool inDarkMode = [[self window] inIncognitoModeWithSystemTheme];
const CGFloat kDarkModeGray = 97 / 255.;
[self setBackgroundColor:
inDarkMode ? [NSColor colorWithGenericGamma22White:kDarkModeGray
alpha:1]
: [NSColor whiteColor]];
[self setTextColor:OmniboxViewMac::BaseTextColor(inDarkMode)];
}
- (void)viewDidMoveToWindow {
if (![self window]) {
return;
}
// Allow the ToolbarController to take action upon the
// AutocompleteTextField being added to the window.
BrowserWindowController* browserWindowController =
[BrowserWindowController browserWindowControllerForView:self];
[[browserWindowController toolbarController] locationBarWasAddedToWindow];
[self updateColorsToMatchTheme];
NSNotificationCenter* nc = [NSNotificationCenter defaultCenter];
[nc addObserver:self
selector:@selector(windowDidResignKey:)
name:NSWindowDidResignKeyNotification
object:[self window]];
[nc addObserver:self
selector:@selector(windowDidResize:)
name:NSWindowDidResizeNotification
object:[self window]];
[nc addObserver:self
selector:@selector(windowDidChangeScreen)
name:NSWindowDidChangeScreenNotification
object:[self window]];
// Make sure the cell has the current line width.
[[self cell] setSinglePixelLineWidth:[self cr_lineWidth]];
// 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 isTabbedWindow])
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;
}
// If the event is a left-mouse click, and it lands on a decoration, then the
// event should not cause the text field to become first responder.
NSEvent* event = [NSApp currentEvent];
if ([event type] == NSLeftMouseDown) {
LocationBarDecoration* decoration =
[[self cell] decorationForEvent:event inRect:[self bounds] ofView:self];
if (decoration && decoration->AcceptsMousePress() != AcceptsPress::NEVER)
return NO;
}
return [super acceptsFirstResponder];
}
// (Overridden from NSResponder)
- (BOOL)becomeFirstResponder {
BOOL doAccept = [super becomeFirstResponder];
if (doAccept) {
[[BrowserWindowController browserWindowControllerForView:self]
lockToolbarVisibilityForOwner:self
withAnimation:YES];
// 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.
[[self cell] handleFocusEvent:[NSApp currentEvent] ofView:self];
}
return doAccept;
}
// (Overridden from NSResponder)
- (BOOL)resignFirstResponder {
BOOL doResign = [super resignFirstResponder];
if (doResign) {
[[BrowserWindowController browserWindowControllerForView:self]
releaseToolbarVisibilityForOwner:self
withAnimation:YES];
}
return doResign;
}
// (URLDropTarget protocol)
- (id<URLDropTargetController>)urlDropController {
BrowserWindowController* windowController =
[BrowserWindowController browserWindowControllerForView:self];
return [windowController toolbarController];
}
// (URLDropTarget protocol)
- (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)sender {
bool canDropAtLocation =
[[self cell] canDropAtLocationInWindow:[sender draggingLocation]
ofView:self];
return canDropAtLocation ? [dropHandler_ draggingEntered:sender]
: NSDragOperationNone;
}
// (URLDropTarget protocol)
- (NSDragOperation)draggingUpdated:(id<NSDraggingInfo>)sender {
bool canDropAtLocation =
[[self cell] canDropAtLocationInWindow:[sender draggingLocation]
ofView:self];
return canDropAtLocation ? [dropHandler_ draggingUpdated:sender]
: NSDragOperationNone;
}
// (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_OMNIBOX;
}
// ThemedWindowDrawing implementation.
- (void)windowDidChangeTheme {
[self updateColorsToMatchTheme];
}
- (void)windowDidChangeActive {
}
@end