/* * CPTokenField.j * AppKit * * Created by Klaas Pieter Annema. * Copyright 2008, 280 North, Inc. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 2.1 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ @import @import @import @import "CPButton.j" @import "CPScrollView.j" @import "CPTextField.j" @import "CPTableView.j" @import "CPWindow.j" @import "_CPMenuWindow.j" #if PLATFORM(DOM) var CPTokenFieldDOMInputElement = nil, CPTokenFieldDOMPasswordInputElement = nil, CPTokenFieldDOMStandardInputElement = nil, CPTokenFieldInputOwner = nil, CPTokenFieldTextDidChangeValue = nil, CPTokenFieldInputResigning = NO, CPTokenFieldInputDidBlur = NO, CPTokenFieldInputIsActive = NO, CPTokenFieldCachedSelectStartFunction = nil, CPTokenFieldCachedDragFunction = nil, CPTokenFieldFocusInput = NO, CPTokenFieldBlurFunction = nil, CPTokenFieldKeyUpFunction = nil, CPTokenFieldKeyPressFunction = nil, CPTokenFieldKeyDownFunction = nil; #endif var CPThemeStateAutoCompleting = @"CPThemeStateAutoCompleting", CPTokenFieldTableColumnIdentifier = @"CPTokenFieldTableColumnIdentifier", CPScrollDestinationNone = 0, CPScrollDestinationLeft = 1, CPScrollDestinationRight = 2; @implementation CPTokenField : CPTextField { CPScrollView _tokenScrollView; int _shouldScrollTo; CPRange _selectedRange; CPView _autocompleteContainer; CPScrollView _autocompleteScrollView; CPTableView _autocompleteView; CPTimeInterval _completionDelay; CPTimer _showCompletionsTimer; CPArray _cachedCompletions; CPCharacterSet _tokenizingCharacterSet @accessors(property=tokenizingCharacterSet); CPEvent _mouseDownEvent; BOOL _preventResign; } + (CPCharacterSet)defaultTokenizingCharacterSet { return [CPCharacterSet characterSetWithCharactersInString:@","]; } + (CPString)defaultThemeClass { return "tokenfield"; } - (id)initWithFrame:(CPRect)frame { if (self = [super initWithFrame:frame]) { _selectedRange = CPMakeRange(0, 0); _tokenScrollView = [[CPScrollView alloc] initWithFrame:CGRectMakeZero()]; [_tokenScrollView setHasHorizontalScroller:NO]; [_tokenScrollView setHasVerticalScroller:NO]; [_tokenScrollView setAutoresizingMask:CPViewWidthSizable | CPViewHeightSizable]; var contentView = [[CPView alloc] initWithFrame:CGRectMakeZero()]; [contentView setAutoresizingMask:CPViewWidthSizable]; [_tokenScrollView setDocumentView:contentView]; [self addSubview:_tokenScrollView]; _tokenIndex = 0; _cachedCompletions = []; _completionDelay = [CPTokenField defaultCompletionDelay]; _tokenizingCharacterSet = [[self class] defaultTokenizingCharacterSet]; _autocompleteContainer = [[CPView alloc] initWithFrame:CPRectMake(0.0, 0.0, frame.size.width, 92.0)]; [_autocompleteContainer setBackgroundColor:[_CPMenuWindow backgroundColorForBackgroundStyle:_CPMenuWindowPopUpBackgroundStyle]]; _autocompleteScrollView = [[CPScrollView alloc] initWithFrame:CPRectMake(1.0, 1.0, frame.size.width - 2.0, 90.0)]; [_autocompleteScrollView setAutohidesScrollers:YES]; [_autocompleteScrollView setHasHorizontalScroller:NO]; [_autocompleteContainer addSubview:_autocompleteScrollView]; _autocompleteView = [[CPTableView alloc] initWithFrame:CPRectMakeZero()]; var tableColumn = [[CPTableColumn alloc] initWithIdentifier:CPTokenFieldTableColumnIdentifier]; [tableColumn setResizingMask:CPTableColumnAutoresizingMask]; [_autocompleteView addTableColumn:tableColumn]; [_autocompleteView setDataSource:self]; [_autocompleteView setDelegate:self]; [_autocompleteView setAllowsMultipleSelection:NO]; [_autocompleteView setHeaderView:nil]; [_autocompleteView setCornerView:nil]; [_autocompleteView setRowHeight:30.0]; [_autocompleteView setGridStyleMask:CPTableViewSolidHorizontalGridLineMask]; [_autocompleteView setBackgroundColor:[CPColor clearColor]]; [_autocompleteView setGridColor:[CPColor colorWithRed:242.0 / 255.0 green:243.0 / 255.0 blue:245.0 / 255.0 alpha:1.0]]; [_autocompleteScrollView setDocumentView:_autocompleteView]; [self setBezeled:YES]; [self setObjectValue:[]]; [self setNeedsLayout]; } return self; } // =============== // = CONVENIENCE = // =============== - (void)_retrieveCompletions { var indexOfSelectedItem = 0; _cachedCompletions = [self tokenField:self completionsForSubstring:[self _inputElement].value indexOfToken:_tokenIndex indexOfSelectedItem:indexOfSelectedItem]; [_autocompleteView selectRowIndexes:[CPIndexSet indexSetWithIndex:indexOfSelectedItem] byExtendingSelection:NO]; [_autocompleteView reloadData]; } - (void)_autocompleteWithDOMEvent:(JSObject)DOMEvent { if (!_cachedCompletions || ![self hasThemeState:CPThemeStateAutoCompleting]) return; [self _hideCompletions]; var token = _cachedCompletions[[_autocompleteView selectedRow]], shouldRemoveLastObject = token !== @"" && [self _inputElement].value !== @""; if (!token) token = [self _inputElement].value; // Make sure the user typed an actual token to prevent the previous token from being emptied // If the input area is empty, we want to fallback to the normal behaviour, resigning first responder or select the next or previous key view if (!token || token === @"") { if (DOMEvent && DOMEvent.keyCode === CPTabKeyCode) { if (!DOMEvent.shiftKey) [[self window] selectNextKeyView:self]; else [[self window] selectPreviousKeyView:self]; } else [[self window] makeFirstResponder:nil]; return; } var objectValue = [self objectValue]; // Remove the uncompleted token and add the token string. // Explicitely remove the last object because the array contains strings and removeObject uses isEqual to compare objects if (shouldRemoveLastObject) [objectValue removeObjectAtIndex:_selectedRange.location]; [objectValue insertObject:token atIndex:_selectedRange.location]; var location = _selectedRange.location; [self setObjectValue:objectValue]; _selectedRange = CPMakeRange(location + 1, 0); [self _inputElement].value = @""; [self setNeedsLayout]; var theBinding = [CPKeyValueBinding getBinding:CPValueBinding forObject:self]; if (theBinding) [theBinding reverseSetValueFor:@"objectValue"]; } - (void)_autocomplete { [self _autocompleteWithDOMEvent:nil]; } - (void)_selectToken:(_CPTokenFieldToken)token byExtendingSelection:(BOOL)extend { var indexOfToken = [[self _tokens] indexOfObject:token]; if (indexOfToken == CPNotFound) { if (!extend) _selectedRange = CPMakeRange([[self _tokens] count], 0); } else if (extend) _selectedRange = CPUnionRange(_selectedRange, CPMakeRange(indexOfToken, 1)); else _selectedRange = CPMakeRange(indexOfToken, 1); [self setNeedsLayout]; } - (void)_deselectToken:(_CPTokenFieldToken)token { var indexOfToken = [[self _tokens] indexOfObject:token]; if (CPLocationInRange(indexOfToken, _selectedRange)) _selectedRange = CPMakeRange(MAX(indexOfToken, _selectedRange.location), MIN(_selectedRange.length, indexOfToken - _selectedRange.location)); [self setNeedsLayout]; } - (void)_deleteToken:(_CPTokenFieldToken)token { var indexOfToken = [[self _tokens] indexOfObject:token], objectValue = [self objectValue]; // If the token was selected, deselect it for selection preservation. [self _deselectToken:token]; // Preserve selection. var selection = CPCopyRange(_selectedRange); [objectValue removeObjectAtIndex:indexOfToken]; [self setObjectValue:objectValue]; _selectedRange = selection; [self setNeedsLayout]; [self _controlTextDidChange]; } - (void)_controlTextDidChange { var theBinding = [CPKeyValueBinding getBinding:CPValueBinding forObject:self]; if (theBinding) [theBinding reverseSetValueFor:@"objectValue"]; [self textDidChange:[CPNotification notificationWithName:CPControlTextDidChangeNotification object:self userInfo:nil]]; } - (void)_removeSelectedTokens:(id)sender { var tokens = [self objectValue]; for (var i = _selectedRange.length - 1; i >= 0; i--) [tokens removeObjectAtIndex:_selectedRange.location + i]; var collapsedSelection = _selectedRange.location; [self setObjectValue:tokens]; // setObjectValue moves the cursor to the end of the selection. We want it to stay // where the selected tokens were. _selectedRange = CPMakeRange(collapsedSelection, 0); [self _controlTextDidChange]; } // ============= // = RESPONDER = // ============= - (BOOL)becomeFirstResponder { if (CPTokenFieldInputOwner && [CPTokenFieldInputOwner window] !== [self window]) [[CPTokenFieldInputOwner window] makeFirstResponder:nil]; [self setThemeState:CPThemeStateEditing]; [self _updatePlaceholderState]; [self setNeedsLayout]; #if PLATFORM(DOM) var string = [self stringValue], element = [self _inputElement]; element.value = nil; element.style.color = [[self currentValueForThemeAttribute:@"text-color"] cssString]; element.style.font = [[self currentValueForThemeAttribute:@"font"] cssString]; element.style.zIndex = 1000; switch ([self alignment]) { case CPCenterTextAlignment: element.style.textAlign = "center"; break; case CPRightTextAlignment: element.style.textAlign = "right"; break; default: element.style.textAlign = "left"; } var contentRect = [self contentRectForBounds:[self bounds]]; element.style.top = CGRectGetMinY(contentRect) + "px"; element.style.left = (CGRectGetMinX(contentRect) - 1) + "px"; // why -1? element.style.width = CGRectGetWidth(contentRect) + "px"; element.style.height = CGRectGetHeight(contentRect) + "px"; [_tokenScrollView documentView]._DOMElement.appendChild(element); window.setTimeout(function() { element.focus(); CPTokenFieldInputOwner = self; }, 0.0); //post CPControlTextDidBeginEditingNotification [self textDidBeginEditing:[CPNotification notificationWithName:CPControlTextDidBeginEditingNotification object:self userInfo:nil]]; [[[self window] platformWindow] _propagateCurrentDOMEvent:YES]; CPTokenFieldInputIsActive = YES; if (document.attachEvent) { CPTokenFieldCachedSelectStartFunction = document.body.onselectstart; CPTokenFieldCachedDragFunction = document.body.ondrag; document.body.ondrag = function () {}; document.body.onselectstart = function () {}; } #endif return YES; } - (BOOL)resignFirstResponder { if (_preventResign) return NO; [self unsetThemeState:CPThemeStateEditing]; [self _autocomplete]; #if PLATFORM(DOM) var element = [self _inputElement]; CPTokenFieldInputResigning = YES; element.blur(); if (!CPTokenFieldInputDidBlur) CPTokenFieldBlurFunction(); CPTokenFieldInputDidBlur = NO; CPTokenFieldInputResigning = NO; if (element.parentNode == [_tokenScrollView documentView]._DOMElement) element.parentNode.removeChild(element); CPTokenFieldInputIsActive = NO; if (document.attachEvent) { CPTokenFieldCachedSelectStartFunction = nil; CPTokenFieldCachedDragFunction = nil; document.body.ondrag = CPTokenFieldCachedDragFunction document.body.onselectstart = CPTokenFieldCachedSelectStartFunction } #endif [self _updatePlaceholderState]; [self setNeedsLayout]; [self textDidEndEditing:[CPNotification notificationWithName:CPControlTextDidBeginEditingNotification object:self userInfo:nil]]; return YES; } - (void)mouseDown:(CPEvent)anEvent { _preventResign = YES; _mouseDownEvent = anEvent; [self _selectToken:nil byExtendingSelection:NO]; [super mouseDown:anEvent]; } - (void)mouseUp:(CPEvent)anEvent { _preventResign = NO; _mouseDownEvent = nil; } - (void)mouseDownOnToken:(_CPTokenFieldToken)aToken withEvent:(CPEvent)anEvent { _preventResign = YES; _mouseDownEvent = anEvent; } - (void)mouseUpOnToken:(_CPTokenFieldToken)aToken withEvent:(CPEvent)anEvent { if (_mouseDownEvent && CGPointEqualToPoint([_mouseDownEvent locationInWindow], [anEvent locationInWindow])) { [self _selectToken:aToken byExtendingSelection:[anEvent modifierFlags] & CPShiftKeyMask]; [[self window] makeFirstResponder:self]; // Snap to the token if it's only half visible due to mouse wheel scrolling. _shouldScrollTo = aToken; } _preventResign = NO; } // =========== // = CONTROL = // =========== - (CPArray)_tokens { // We return super here because objectValue uses this method // If we called self we would loop infinitly return [super objectValue]; } - (CPString)stringValue { return [[self objectValue] componentsJoinedByString:@","]; } - (id)objectValue { var objectValue = []; for (var i = 0, count = [[self _tokens] count]; i < count; i++) { var token = [[self _tokens] objectAtIndex:i]; if ([token isKindOfClass:[CPString class]]) continue; [objectValue addObject:[token representedObject]]; } #if PLATFORM(DOM) if ([self _inputElement].value != @"") [objectValue insertObject:[self _inputElement].value atIndex:_selectedRange.location]; #endif return objectValue; } - (void)setObjectValue:(id)aValue { if (aValue !== nil && ![aValue isKindOfClass:[CPArray class]]) { [super setObjectValue:nil]; return; } var superValue = [super objectValue]; if (aValue === superValue || [aValue isEqualToArray:superValue]) return; var contentView = [_tokenScrollView documentView]; // Preserve as many existing tokens as possible to reduce redraw flickering. var oldTokens = [self _tokens], newTokens = []; if (aValue !== nil) { for (var i = 0, count = [aValue count]; i < count; i++) { // Do we have this token among the old ones? var tokenObject = aValue[i], tokenValue = [self tokenField:self displayStringForRepresentedObject:tokenObject], newToken = nil; for (var j = 0, oldCount = [oldTokens count]; j < oldCount; j++) { var oldToken = oldTokens[j]; if ([oldToken representedObject] == tokenObject) { // Yep. Reuse it. [oldTokens removeObjectAtIndex:j]; newToken = oldToken; break; } } if (newToken === nil) { newToken = [[_CPTokenFieldToken alloc] init]; [newToken setTokenField:self]; [newToken setRepresentedObject:tokenObject]; [newToken setStringValue:tokenValue]; [contentView addSubview:newToken]; } newTokens.push(newToken); } } // Remove any now unused tokens. for (var j = 0, oldCount = [oldTokens count]; j < oldCount; j++) [oldTokens[j] removeFromSuperview]; /* [CPTextField setObjectValue] will try to set the _inputElement.value to the new objectValue, if the _inputElement exists. This is wrong for us since our objectValue is an array of tokens, so we can't use [super setObjectValue:objectValue]; Instead do what CPControl setObjectValue would. */ _value = newTokens; // Reset the selection. [self _selectToken:nil byExtendingSelection:NO]; [self _updatePlaceholderState]; _shouldScrollTo = CPScrollDestinationRight; [self setNeedsLayout]; [self setNeedsDisplay:YES]; } // Incredible hack to disable supers implementation // so it cannot change our object value and break the tokenfield - (void)_setStringValue:(id)aValue { } // ======== // = VIEW = // ======== - (void)viewDidMoveToWindow { [[[self window] contentView] addSubview:_autocompleteContainer]; _autocompleteContainer._DOMElement.style.zIndex = 1000; // Anything else doesn't seem to work } - (void)removeFromSuperview { [_autocompleteContainer removeFromSuperview]; } // ============= // = TEXTFIELD = // ============= #if PLATFORM(DOM) - (DOMElement)_inputElement { if (!CPTokenFieldDOMInputElement) { CPTokenFieldDOMInputElement = document.createElement("input"); CPTokenFieldDOMInputElement.style.position = "absolute"; CPTokenFieldDOMInputElement.style.border = "0px"; CPTokenFieldDOMInputElement.style.padding = "0px"; CPTokenFieldDOMInputElement.style.margin = "0px"; CPTokenFieldDOMInputElement.style.whiteSpace = "pre"; CPTokenFieldDOMInputElement.style.background = "transparent"; CPTokenFieldDOMInputElement.style.outline = "none"; CPTokenFieldBlurFunction = function(anEvent) { if (CPTokenFieldInputOwner && [CPTokenFieldInputOwner._tokenScrollView documentView]._DOMElement != CPTokenFieldDOMInputElement.parentNode) return; if (CPTokenFieldInputOwner && CPTokenFieldInputOwner._preventResign) return false; if (!CPTokenFieldInputResigning && !CPTokenFieldFocusInput) { [[CPTokenFieldInputOwner window] makeFirstResponder:nil]; return; } CPTokenFieldHandleBlur(anEvent, CPTokenFieldDOMInputElement); CPTokenFieldInputDidBlur = YES; return true; } CPTokenFieldKeyDownFunction = function(aDOMEvent) { aDOMEvent = aDOMEvent || window.event CPTokenFieldTextDidChangeValue = [CPTokenFieldInputOwner stringValue]; // Update the selectedIndex if necesary var index = [[CPTokenFieldInputOwner autocompleteView] selectedRow]; if (aDOMEvent.keyCode === CPUpArrowKeyCode) index -= 1; else if (aDOMEvent.keyCode === CPDownArrowKeyCode) index += 1; if (index > [[CPTokenFieldInputOwner autocompleteView] numberOfRows] - 1) index = [[CPTokenFieldInputOwner autocompleteView] numberOfRows] - 1; if (index < 0) index = 0; [[CPTokenFieldInputOwner autocompleteView] selectRowIndexes:[CPIndexSet indexSetWithIndex:index] byExtendingSelection:NO]; var autocompleteView = [CPTokenFieldInputOwner autocompleteView], clipView = [[autocompleteView enclosingScrollView] contentView], rowRect = [autocompleteView rectOfRow:index], owner = CPTokenFieldInputOwner; if (rowRect && !CPRectContainsRect([clipView bounds], rowRect)) [clipView scrollToPoint:[autocompleteView rectOfRow:index].origin]; if (aDOMEvent.keyCode === CPReturnKeyCode || aDOMEvent.keyCode === CPTabKeyCode) { if (aDOMEvent.preventDefault) aDOMEvent.preventDefault(); if (aDOMEvent.stopPropagation) aDOMEvent.stopPropagation(); aDOMEvent.cancelBubble = true; // Only resign first responder if we weren't autocompleting if (![CPTokenFieldInputOwner hasThemeState:CPThemeStateAutoCompleting]) { if (aDOMEvent && aDOMEvent.keyCode === CPReturnKeyCode) { [owner sendAction:[owner action] to:[owner target]]; [[owner window] makeFirstResponder:nil]; } else if (aDOMEvent && aDOMEvent.keyCode === CPTabKeyCode) { if (!aDOMEvent.shiftKey) [[owner window] selectNextKeyView:owner]; else [[owner window] selectPreviousKeyView:owner]; } } [owner _autocompleteWithDOMEvent:aDOMEvent]; [owner setNeedsLayout]; } else if (aDOMEvent.keyCode === CPEscapeKeyCode) { [CPTokenFieldInputOwner _hideCompletions]; } else if (aDOMEvent.keyCode === CPUpArrowKeyCode || aDOMEvent.keyCode === CPDownArrowKeyCode) { if (aDOMEvent.preventDefault) aDOMEvent.preventDefault(); if (aDOMEvent.stopPropagation) aDOMEvent.stopPropagation(); aDOMEvent.cancelBubble = true; } else if (aDOMEvent.keyCode == CPLeftArrowKeyCode && owner._selectedRange.location > 0 && CPTokenFieldDOMInputElement.value == "") { // Move the cursor back one token if the input is empty and the left arrow key is pressed. if (!aDOMEvent.shiftKey) { if (owner._selectedRange.length) // Simply collapse the range. owner._selectedRange.length = 0; else owner._selectedRange.location--; } else { owner._selectedRange.location--; // When shift is depressed, select the next token backwards. owner._selectedRange.length++; } owner._shouldScrollTo = CPScrollDestinationLeft; [owner setNeedsLayout]; } else if (aDOMEvent.keyCode == CPRightArrowKeyCode && owner._selectedRange.location < [[owner _tokens] count] && CPTokenFieldDOMInputElement.value == "") { if (!aDOMEvent.shiftKey) { if (owner._selectedRange.length) { // Place the cursor at the end of the selection and collapse. owner._selectedRange.location = CPMaxRange(owner._selectedRange); owner._selectedRange.length = 0; } else { // Move the cursor forward one token if the input is empty and the right arrow key is pressed. owner._selectedRange.location = MIN([[owner _tokens] count], owner._selectedRange.location + owner._selectedRange.length + 1); } } else { // Leave the selection location in place but include the next token to the right. owner._selectedRange.length++; } owner._shouldScrollTo = CPScrollDestinationRight; [owner setNeedsLayout]; } else if (aDOMEvent.keyCode === CPDeleteKeyCode) { // Highlight the previous token if backspace was pressed in an empty input element or re-show the completions view if (CPTokenFieldDOMInputElement.value == @"") { [self _hideCompletions]; if (CPEmptyRange(CPTokenFieldInputOwner._selectedRange)) { if (CPTokenFieldInputOwner._selectedRange.location > 0) { var tokens = [CPTokenFieldInputOwner _tokens], tokenView = [tokens objectAtIndex:(CPTokenFieldInputOwner._selectedRange.location - 1)]; [CPTokenFieldInputOwner _selectToken:tokenView byExtendingSelection:NO]; } } else [CPTokenFieldInputOwner _removeSelectedTokens:nil]; } else [CPTokenFieldInputOwner _delayedShowCompletions]; } else if (aDOMEvent.keyCode === CPDeleteForwardKeyCode && CPTokenFieldDOMInputElement.value == @"") { // Delete forward if nothing is selected, else delete all selected. [self _hideCompletions]; if (CPEmptyRange(CPTokenFieldInputOwner._selectedRange)) { var tokens = [CPTokenFieldInputOwner _tokens]; if (CPTokenFieldInputOwner._selectedRange.location < [tokens count]) [CPTokenFieldInputOwner _deleteToken:tokens[CPTokenFieldInputOwner._selectedRange.location]]; } else [CPTokenFieldInputOwner _removeSelectedTokens:nil]; } return true; } CPTokenFieldKeyPressFunction = function(aDOMEvent) { aDOMEvent = aDOMEvent || window.event; var character = String.fromCharCode(aDOMEvent.keyCode || aDOMEvent.which), owner = CPTokenFieldInputOwner; if ([[owner tokenizingCharacterSet] characterIsMember:character]) { if (aDOMEvent.preventDefault) aDOMEvent.preventDefault(); if (aDOMEvent.stopPropagation) aDOMEvent.stopPropagation(); aDOMEvent.cancelBubble = true; [owner _autocompleteWithDOMEvent:aDOMEvent]; [owner setNeedsLayout]; return true; } [CPTokenFieldInputOwner _delayedShowCompletions]; // If there was a selection, collapse it now since we're typing in a new token. owner._selectedRange.length = 0; // Force immediate layout in case word wrapping is now necessary. [owner setNeedsLayout]; [[CPRunLoop currentRunLoop] limitDateForMode:CPDefaultRunLoopMode]; } CPTokenFieldKeyUpFunction = function() { if ([CPTokenFieldInputOwner stringValue] !== CPTokenFieldTextDidChangeValue) { CPTokenFieldTextDidChangeValue = [CPTokenFieldInputOwner stringValue]; [CPTokenFieldInputOwner textDidChange:[CPNotification notificationWithName:CPControlTextDidChangeNotification object:CPTokenFieldInputOwner userInfo:nil]]; } [self setNeedsLayout]; [[CPRunLoop currentRunLoop] limitDateForMode:CPDefaultRunLoopMode]; } CPTokenFieldHandleBlur = function(anEvent) { CPTokenFieldInputOwner = nil; [[CPRunLoop currentRunLoop] limitDateForMode:CPDefaultRunLoopMode]; } if (document.attachEvent) { CPTokenFieldDOMInputElement.attachEvent("on" + CPDOMEventKeyUp, CPTokenFieldKeyUpFunction); CPTokenFieldDOMInputElement.attachEvent("on" + CPDOMEventKeyDown, CPTokenFieldKeyDownFunction); CPTokenFieldDOMInputElement.attachEvent("on" + CPDOMEventKeyPress, CPTokenFieldKeyPressFunction); } else { CPTokenFieldDOMInputElement.addEventListener(CPDOMEventKeyUp, CPTokenFieldKeyUpFunction, NO); CPTokenFieldDOMInputElement.addEventListener(CPDOMEventKeyDown, CPTokenFieldKeyDownFunction, NO); CPTokenFieldDOMInputElement.addEventListener(CPDOMEventKeyPress, CPTokenFieldKeyPressFunction, NO); } //FIXME make this not onblur CPTokenFieldDOMInputElement.onblur = CPTokenFieldBlurFunction; CPTokenFieldDOMStandardInputElement = CPTokenFieldDOMInputElement; } if (CPFeatureIsCompatible(CPInputTypeCanBeChangedFeature)) { if ([CPTokenFieldInputOwner isSecure]) CPTokenFieldDOMInputElement.type = "password"; else CPTokenFieldDOMInputElement.type = "text"; return CPTokenFieldDOMInputElement; } return CPTokenFieldDOMInputElement; } #endif // - (void)setTokenStyle: (NSTokenStyle) style; // - (NSTokenStyle)tokenStyle; // // ==================== // = COMPLETION DELAY = // ==================== - (void)setCompletionDelay:(CPTimeInterval)delay { _completionDelay = delay; } - (NSTimeInterval)completionDelay { return _completionDelay; } + (NSTimeInterval)defaultCompletionDelay { return 0.5; } // =========================== // = SHOW / HIDE COMPLETIONS = // =========================== - (void)_showCompletions:(CPTimer)timer { [self _retrieveCompletions] [self setThemeState:CPThemeStateAutoCompleting]; [self setNeedsLayout]; } - (void)_delayedShowCompletions { _showCompletionsTimer = [CPTimer scheduledTimerWithTimeInterval:[self completionDelay] target:self selector:@selector(_showCompletions:) userInfo:nil repeats:NO]; } - (void)_cancelShowCompletions { if ([_showCompletionsTimer isValid]) [_showCompletionsTimer invalidate]; } - (void)_hideCompletions { [self _cancelShowCompletions]; [self unsetThemeState:CPThemeStateAutoCompleting]; [self setNeedsLayout]; } // ========== // = LAYOUT = // ========== - (void)layoutSubviews { [super layoutSubviews]; [_tokenScrollView setFrame:[self rectForEphemeralSubviewNamed:"content-view"]]; var textFieldContentView = [self layoutEphemeralSubviewNamed:@"content-view" positioned:CPWindowAbove relativeToEphemeralSubviewNamed:@"bezel-view"]; if (textFieldContentView) [textFieldContentView setHidden:[self stringValue] !== @""]; var frame = [self frame], contentView = [_tokenScrollView documentView], tokens = [self _tokens]; // Correctly size the tableview // FIXME Horizontal scrolling will not work because we are not actually looking at the content to set the width for the table column [[_autocompleteView tableColumnWithIdentifier:CPTokenFieldTableColumnIdentifier] setWidth:[[_autocompleteScrollView contentView] frame].size.width]; if ([self hasThemeState:CPThemeStateAutoCompleting] && [_cachedCompletions count]) { // Manually sizeToFit because CPTableView's sizeToFit doesn't work properly [_autocompleteContainer setHidden:NO]; var frameOrigin = [self convertPoint:[self bounds].origin toView:[_autocompleteContainer superview]]; [_autocompleteContainer setFrameOrigin:CPPointMake(frameOrigin.x, frameOrigin.y + frame.size.height)]; [_autocompleteContainer setFrameSize:CPSizeMake(CPRectGetWidth([self bounds]), 92.0)]; [_autocompleteScrollView setFrameSize:CPSizeMake([_autocompleteContainer frame].size.width - 2.0, 90.0)]; } else [_autocompleteContainer setHidden:YES]; // Hack to make sure we are handling an array if (![tokens isKindOfClass:[CPArray class]]) return; // Move each token into the right position. var contentRect = CGRectMakeCopy([contentView bounds]), contentOrigin = contentRect.origin, contentSize = contentRect.size, offset = CPPointMake(contentOrigin.x, contentOrigin.y), spaceBetweenTokens = CPSizeMake(2.0, 2.0), isEditing = [[self window] firstResponder] == self, tokenToken = [_CPTokenFieldToken new]; // Get the height of a typical token, or a token token if you will. [tokenToken sizeToFit]; var tokenHeight = CGRectGetHeight([tokenToken bounds]); var fitAndFrame = function(width, height) { var r = CGRectMake(0, 0, width, height); if (offset.x + width >= contentSize.width && offset.x > contentOrigin.x) { offset.x = contentOrigin.x; offset.y += height + spaceBetweenTokens.height; } r.origin.x = offset.x; r.origin.y = offset.y; // Make sure the frame fits. if (CGRectGetHeight([contentView bounds]) < offset.y + height) [contentView setFrame:CGRectMake(0, 0, CGRectGetWidth([_tokenScrollView bounds]), offset.y + height)]; offset.x += width + spaceBetweenTokens.width; return r; } var placeEditor = function(useRemainingWidth) { var element = [self _inputElement], textWidth = 1; if (_selectedRange.length === 0) { // XXX The "X" here is used to estimate the space needed to fit the next character // without clipping. Since different fonts might have different sizes of "X" this // solution is not ideal, but it works. textWidth = [(element.value || @"") + "X" sizeWithFont:[self font]].width; if (useRemainingWidth) textWidth = MAX(contentSize.width - offset.x - 1, textWidth); } var inputFrame = fitAndFrame(textWidth, tokenHeight); element.style.left = inputFrame.origin.x + "px"; element.style.top = inputFrame.origin.y + "px"; element.style.width = inputFrame.size.width + "px"; element.style.height = inputFrame.size.height + "px"; // When editing, always scroll to the cursor. if (_selectedRange.length == 0) [[_tokenScrollView documentView] scrollRectToVisible:inputFrame]; } for (var i = 0, count = [tokens count]; i < count; i++) { if (isEditing && i == CPMaxRange(_selectedRange)) placeEditor(false); var tokenView = [tokens objectAtIndex:i]; // Make sure we are only changing completed tokens if ([tokenView isKindOfClass:[CPString class]]) continue; [tokenView setHighlighted:CPLocationInRange(i, _selectedRange)]; [tokenView sizeToFit]; var size = [contentView bounds].size, tokenViewSize = [tokenView bounds].size, tokenFrame = fitAndFrame(tokenViewSize.width, tokenViewSize.height); [tokenView setFrame:tokenFrame]; } if (isEditing && CPMaxRange(_selectedRange) >= [tokens count]) placeEditor(true); // Hide the editor if there are selected tokens, but still keep it active // so we can continue using our standard keyboard handling events. if (isEditing && _selectedRange.length) { [self _inputElement].style.left = "-10000px"; [self _inputElement].focus(); } // Trim off any excess height downwards. if (CGRectGetHeight([contentView bounds]) > offset.y + tokenHeight) [contentView setFrame:CGRectMake(0, 0, CGRectGetWidth([_tokenScrollView bounds]), offset.y + tokenHeight)]; if (_shouldScrollTo !== CPScrollDestinationNone) { // Only carry out the scroll if the cursor isn't visible. if (!(isEditing && _selectedRange.length == 0)) { var scrollToToken = _shouldScrollTo; if (scrollToToken === CPScrollDestinationLeft) scrollToToken = tokens[_selectedRange.location] else if (scrollToToken === CPScrollDestinationRight) scrollToToken = tokens[MAX(0, CPMaxRange(_selectedRange) - 1)]; [self _scrollTokenViewToVisible:scrollToToken]; } _shouldScrollTo = CPScrollDestinationNone; } } - (BOOL)_scrollTokenViewToVisible:(_CPTokenFieldToken)aToken { if (!aToken) return; return [[_tokenScrollView documentView] scrollRectToVisible:[aToken frame]]; } // ====================== // = TABLEVIEW DATSOURCE / DELEGATE = // ====================== - (int)numberOfRowsInTableView:(CPTableView)tableView { return [_cachedCompletions count]; } - (void)tableView:(CPTableView)tableView objectValueForTableColumn:(CPTableColumn)tableColumn row:(int)row { return [self tokenField:self displayStringForRepresentedObject:[_cachedCompletions objectAtIndex:row]]; } - (void)tableViewSelectionDidChange:(CPNotification)notification { // make sure a mouse click in the tableview doesn't steal first responder state window.setTimeout(function() { [[self window] makeFirstResponder:self]; }, 2.0); } // ============= // = ACCESSORS = // ============= - (CPTableView)autocompleteView { return _autocompleteView; } @end @implementation CPTokenField (CPTokenFieldDelegate) // // Each element in the array should be an NSString or an array of NSStrings. // // substring is the partial string that is being completed. tokenIndex is the index of the token being completed. // // selectedIndex allows you to return by reference an index specifying which of the completions should be selected initially. // // The default behavior is not to have any completions. - (CPArray)tokenField:(CPTokenField)tokenField completionsForSubstring:(CPString)substring indexOfToken:(int)tokenIndex indexOfSelectedItem:(int)selectedIndex { if ([[self delegate] respondsToSelector:@selector(tokenField:completionsForSubstring:indexOfToken:indexOfSelectedItem:)]) { return [[self delegate] tokenField:tokenField completionsForSubstring:substring indexOfToken:_tokenIndex indexOfSelectedItem:selectedIndex]; } return []; } // // Alows the delegate to provide a string to be displayed as a proxy for the given represented object. // // If you return nil or do not implement this method, then representedObject is displayed as the string. - (CPString)tokenField:(CPTokenField)tokenField displayStringForRepresentedObject:(id)representedObject { if ([[self delegate] respondsToSelector:@selector(tokenField:displayStringForRepresentedObject:)]) { var stringForRepresentedObject = [[self delegate] tokenField:tokenField displayStringForRepresentedObject:representedObject]; if (stringForRepresentedObject !== nil) { return stringForRepresentedObject; } } return representedObject; } // // // return an array of represented objects you want to add. // // If you want to reject the add, return an empty array. // // returning nil will cause an error. // - (NSArray *)tokenField:(NSTokenField *)tokenField shouldAddObjects:(NSArray *)tokens atIndex:(NSUInteger)index; // // // If you return nil or don't implement these delegate methods, we will assume // // editing string = display string = represented object // - (NSString *)tokenField:(NSTokenField *)tokenField editingStringForRepresentedObject:(id)representedObject; // - (id)tokenField:(NSTokenField *)tokenField representedObjectForEditingString: (NSString *)editingString; // // // We put the string on the pasteboard before calling this delegate method. // // By default, we write the NSStringPboardType as well as an array of NSStrings. // - (BOOL)tokenField:(NSTokenField *)tokenField writeRepresentedObjects:(NSArray *)objects toPasteboard:(NSPasteboard *)pboard; // // // Return an array of represented objects to add to the token field. // - (NSArray *)tokenField:(NSTokenField *)tokenField readFromPasteboard:(NSPasteboard *)pboard; // // // By default the tokens have no menu. // - (NSMenu *)tokenField:(NSTokenField *)tokenField menuForRepresentedObject:(id)representedObject; // - (BOOL)tokenField:(NSTokenField *)tokenField hasMenuForRepresentedObject:(id)representedObject; // // // This method allows you to change the style for individual tokens as well as have mixed text and tokens. // - (NSTokenStyle)tokenField:(NSTokenField *)tokenField styleForRepresentedObject:(id)representedObject; @end @implementation _CPTokenFieldToken : CPTextField { _CPTokenFieldTokenCloseButton _deleteButton; CPTokenField _tokenField; id _representedObject; } + (CPString)defaultThemeClass { return "tokenfield-token"; } - (id)initWithFrame:(CPRect)frame { if (self = [super initWithFrame:frame]) { _deleteButton = [[_CPTokenFieldTokenCloseButton alloc] initWithFrame:CPRectMakeZero()]; [self addSubview:_deleteButton]; [self setEditable:NO]; [self setHighlighted:NO]; [self setBezeled:YES]; } return self; } - (CPTokenField)tokenField { return _tokenField; } - (void)setTokenField:(CPTokenField)tokenField { _tokenField = tokenField; } - (id)representedObject { return _representedObject; } - (void)setRepresentedObject:(id)representedObject { _representedObject = representedObject; } - (CGSize)_minimumFrameSize { var size = CGSizeMakeZero(), minSize = [self currentValueForThemeAttribute:@"min-size"], contentInset = [self currentValueForThemeAttribute:@"content-inset"]; // Tokens are fixed height, so we could as well have used max-size here. size.height = minSize.height; size.width = MAX(minSize.width, [([self stringValue] || @" ") sizeWithFont:[self font]].width + contentInset.left + contentInset.right); return size; } - (void)layoutSubviews { [super layoutSubviews]; var bezelView = [self layoutEphemeralSubviewNamed:@"bezel-view" positioned:CPWindowBelow relativeToEphemeralSubviewNamed:@"content-view"]; if (bezelView) { [_deleteButton setTarget:self]; [_deleteButton setAction:@selector(_delete:)]; var frame = [bezelView frame], buttonOffset = [_deleteButton currentValueForThemeAttribute:@"offset"], buttonSize = [_deleteButton currentValueForThemeAttribute:@"min-size"]; [_deleteButton setFrame:CPRectMake(CPRectGetMaxX(frame) - buttonOffset.x, CPRectGetMinY(frame) + buttonOffset.y, buttonSize.width, buttonSize.height)]; } } - (void)mouseDown:(CPEvent)anEvent { [_tokenField mouseDownOnToken:self withEvent:anEvent]; } - (void)mouseUp:(CPEvent)anEvent { [_tokenField mouseUpOnToken:self withEvent:anEvent]; } - (void)_delete:(id)sender { [_tokenField _deleteToken:self]; } @end /* Theming hook. */ @implementation _CPTokenFieldTokenCloseButton : CPButton { } + (id)themeAttributes { var attributes = [CPButton themeAttributes]; [attributes setObject:CGPointMake(15, 5) forKey:@"offset"]; return attributes; } + (CPString)defaultThemeClass { return "tokenfield-token-close-button"; } @end