mirror of
https://github.com/cappuccino/cappuccino.git
synced 2026-08-26 05:27:03 +00:00
If the autocomplete state had not been set yet, tokens could not be finished using the token separator, return or tab. Instead the user typed text remained in its 'non token' state.
1376 lines
45 KiB
Plaintext
Executable File
1376 lines
45 KiB
Plaintext
Executable File
/*
|
|
* 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 <Foundation/CPCharacterSet.j>
|
|
@import <Foundation/CPIndexSet.j>
|
|
@import <Foundation/CPTimer.j>
|
|
|
|
@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;
|
|
BOOL _shouldNotifyTarget;
|
|
}
|
|
|
|
+ (CPCharacterSet)defaultTokenizingCharacterSet
|
|
{
|
|
return [CPCharacterSet characterSetWithCharactersInString:@","];
|
|
}
|
|
|
|
+ (CPString)defaultThemeClass
|
|
{
|
|
return "tokenfield";
|
|
}
|
|
|
|
- (id)initWithFrame:(CPRect)frame
|
|
{
|
|
if (self = [super initWithFrame:frame])
|
|
{
|
|
_completionDelay = [CPTokenField defaultCompletionDelay];
|
|
_tokenizingCharacterSet = [[self class] defaultTokenizingCharacterSet];
|
|
[self setBezeled:YES];
|
|
|
|
[self _init];
|
|
|
|
[self setObjectValue:[]];
|
|
|
|
[self setNeedsLayout];
|
|
}
|
|
|
|
return self;
|
|
}
|
|
|
|
- (void)_init
|
|
{
|
|
_selectedRange = CPMakeRange(0, 0);
|
|
|
|
var frame = [self frame];
|
|
|
|
_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];
|
|
|
|
_cachedCompletions = [];
|
|
|
|
_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];
|
|
}
|
|
|
|
// ===============
|
|
// = CONVENIENCE =
|
|
// ===============
|
|
- (void)_retrieveCompletions
|
|
{
|
|
var indexOfSelectedItem = 0;
|
|
|
|
_cachedCompletions = [self tokenField:self completionsForSubstring:[self _inputElement].value indexOfToken:0 indexOfSelectedItem:indexOfSelectedItem];
|
|
|
|
[_autocompleteView selectRowIndexes:[CPIndexSet indexSetWithIndex:indexOfSelectedItem] byExtendingSelection:NO];
|
|
[_autocompleteView reloadData];
|
|
}
|
|
|
|
- (void)_autocompleteWithDOMEvent:(JSObject)DOMEvent
|
|
{
|
|
if (![self _inputElement].value && (!_cachedCompletions || ![self hasThemeState:CPThemeStateAutoCompleting]))
|
|
return;
|
|
|
|
[self _hideCompletions];
|
|
|
|
var token = _cachedCompletions ? _cachedCompletions[[_autocompleteView selectedRow]] : nil,
|
|
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 fall back to the normal behavior, resigning first
|
|
// responder or selecting 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.
|
|
// Explicitly remove the last object because the array contains strings and removeObject uses isEqual to compare objects
|
|
if (shouldRemoveLastObject)
|
|
[objectValue removeObjectAtIndex:_selectedRange.location];
|
|
|
|
// Give the delegate a chance to confirm, replace or add to the list of tokens being added.
|
|
var delegateApprovedObjects = [self tokenField:self shouldAddObjects:[CPArray arrayWithObject:token] atIndex:_selectedRange.location],
|
|
delegateApprovedObjectsCount = [delegateApprovedObjects count];
|
|
if (delegateApprovedObjects)
|
|
{
|
|
for (var i = 0; i < delegateApprovedObjectsCount; i++)
|
|
{
|
|
[objectValue insertObject:[delegateApprovedObjects objectAtIndex:i] atIndex:_selectedRange.location + i];
|
|
}
|
|
}
|
|
|
|
// Put the cursor after the last inserted token.
|
|
var location = _selectedRange.location;
|
|
|
|
[self setObjectValue:objectValue];
|
|
|
|
if (delegateApprovedObjectsCount)
|
|
location += delegateApprovedObjectsCount;
|
|
_selectedRange = CPMakeRange(location, 0);
|
|
|
|
[self _inputElement].value = @"";
|
|
[self setNeedsLayout];
|
|
|
|
[self _controlTextDidChange];
|
|
}
|
|
|
|
- (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 selection was to the right of the deleted token, move it to the left. If the deleted token was
|
|
// selected, deselect it.
|
|
if (indexOfToken < _selectedRange.location)
|
|
_selectedRange.location--;
|
|
else
|
|
[self _deselectToken:token];
|
|
|
|
// Preserve selection.
|
|
var selection = CPCopyRange(_selectedRange);
|
|
[objectValue removeObjectAtIndex:indexOfToken];
|
|
[self setObjectValue:objectValue];
|
|
_selectedRange = selection;
|
|
|
|
[self setNeedsLayout];
|
|
[self _controlTextDidChange];
|
|
}
|
|
|
|
- (void)_controlTextDidChange
|
|
{
|
|
var binderClass = [[self class] _binderClassForBinding:CPValueBinding],
|
|
theBinding = [binderClass getBinding:CPValueBinding forObject:self];
|
|
|
|
if (theBinding)
|
|
[theBinding reverseSetValueFor:@"objectValue"];
|
|
|
|
[self textDidChange:[CPNotification notificationWithName:CPControlTextDidChangeNotification object:self userInfo:nil]];
|
|
|
|
_shouldNotifyTarget = YES;
|
|
}
|
|
|
|
- (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];
|
|
}
|
|
|
|
- (void)_updatePlaceholderState
|
|
{
|
|
if (([[self _tokens] count] === 0) && ![self hasThemeState:CPThemeStateEditing])
|
|
[self setThemeState:CPTextFieldStatePlaceholder];
|
|
else
|
|
[self unsetThemeState:CPTextFieldStatePlaceholder];
|
|
}
|
|
|
|
// =============
|
|
// = 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];
|
|
|
|
if (_shouldNotifyTarget)
|
|
{
|
|
_shouldNotifyTarget = NO;
|
|
[self textDidEndEditing:[CPNotification notificationWithName:CPControlTextDidEndEditingNotification object:self userInfo:nil]];
|
|
|
|
if ([self sendsActionOnEndEditing])
|
|
[self sendAction:[self action] to:[self target]];
|
|
}
|
|
|
|
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 infinitely
|
|
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];
|
|
}
|
|
|
|
- (void)sendAction:(SEL)anAction to:(id)anObject
|
|
{
|
|
_shouldNotifyTarget = NO;
|
|
[super sendAction:anAction to:anObject];
|
|
}
|
|
|
|
// 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];
|
|
|
|
#if PLATFORM(DOM)
|
|
_autocompleteContainer._DOMElement.style.zIndex = 1000; // Anything else doesn't seem to work
|
|
#endif
|
|
}
|
|
|
|
- (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 necessary
|
|
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 auto-completing
|
|
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 [];
|
|
}
|
|
|
|
// // Allows 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.
|
|
- (CPArray)tokenField:(CPTokenField)tokenField shouldAddObjects:(CPArray)tokens atIndex:(int)index
|
|
{
|
|
var delegate = [self delegate];
|
|
if ([delegate respondsToSelector:@selector(tokenField:shouldAddObjects:atIndex:)])
|
|
{
|
|
var approvedObjects = [delegate tokenField:tokenField shouldAddObjects:tokens atIndex:index];
|
|
if (approvedObjects !== nil)
|
|
return approvedObjects;
|
|
}
|
|
|
|
return tokens;
|
|
}
|
|
|
|
//
|
|
// // 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
|
|
|
|
|
|
var CPTokenFieldTokenizingCharacterSetKey = "CPTokenFieldTokenizingCharacterSetKey",
|
|
CPTokenFieldCompletionDelayKey = "CPTokenFieldCompletionDelay";
|
|
|
|
@implementation CPTokenField (CPCoding)
|
|
|
|
- (id)initWithCoder:(CPCoder)aCoder
|
|
{
|
|
self = [super initWithCoder:aCoder];
|
|
|
|
if (self)
|
|
{
|
|
_tokenizingCharacterSet = [aCoder decodeObjectForKey:CPTokenFieldTokenizingCharacterSetKey] || [[self class] defaultTokenizingCharacterSet];
|
|
_completionDelay = [aCoder decodeDoubleForKey:CPTokenFieldCompletionDelayKey] || [[self class] defaultCompletionDelay];
|
|
|
|
[self _init];
|
|
|
|
[self setNeedsLayout];
|
|
[self setNeedsDisplay:YES];
|
|
}
|
|
|
|
return self;
|
|
}
|
|
|
|
- (void)encodeWithCoder:(CPCoder)aCoder
|
|
{
|
|
[super encodeWithCoder:aCoder];
|
|
|
|
[aCoder encodeInt:_tokenizingCharacterSet forKey:CPTokenFieldTokenizingCharacterSetKey];
|
|
[aCoder encodeDouble:_completionDelay forKey:CPTokenFieldCompletionDelayKey];
|
|
}
|
|
|
|
@end
|