diff --git a/AppKit/AppKit.j b/AppKit/AppKit.j index a893e0204..edf1dd794 100644 --- a/AppKit/AppKit.j +++ b/AppKit/AppKit.j @@ -116,3 +116,4 @@ @import "CPWindowController.j" @import "CPWorkspace.j" @import "CPFontPanel.j" +@import "CPTreeController.j" diff --git a/AppKit/CPOutlineView.j b/AppKit/CPOutlineView.j index 2f0cd73c0..3e8cb34d8 100644 --- a/AppKit/CPOutlineView.j +++ b/AppKit/CPOutlineView.j @@ -773,6 +773,11 @@ var CPOutlineViewCoalesceSelectionNotificationStateOff = 0, [self reloadItem:anItem reloadChildren:NO]; } +- (int)_numberOfRows +{ + return _itemsForRows ? _itemsForRows.length : 0; +} + /*! Reloads the data for a given item and optionally the children. @@ -784,6 +789,8 @@ var CPOutlineViewCoalesceSelectionNotificationStateOff = 0, _pendingItemToClean = []; _itemAddedDuringLastLoading = []; + var previousRowCount = _itemsForRows.length; + if (!!shouldReloadChildren || !anItem) [self _loadItemInfoForItem:anItem intermediate:NO]; else @@ -791,6 +798,11 @@ var CPOutlineViewCoalesceSelectionNotificationStateOff = 0, [self _cleanPendingItem]; + // Safely update the table size and force a synchronous layout recalculation + // BEFORE the views are reloaded, avoiding the clipping issue. + if (_itemsForRows.length !== previousRowCount) + [self noteNumberOfRowsChanged]; + [super _reloadDataViews]; } @@ -837,9 +849,20 @@ var CPOutlineViewCoalesceSelectionNotificationStateOff = 0, for (var i = [previousItems count] - 1; i >= 0; i--) { - var item = previousItems[i]; + var item = previousItems[i], + found = NO; - if (![children containsObject:item]) + // Use strict identity (===) instead of containsObject: (which triggers isEqual:) + for (var j = 0, count = children.length; j < count; j++) + { + if (children[j] === item) + { + found = YES; + break; + } + } + + if (!found) [self _addPendingItem:item]; } } @@ -853,7 +876,8 @@ var CPOutlineViewCoalesceSelectionNotificationStateOff = 0, var children = itemInfo.children; - for (var i = [children count]; i >= 0; i--) + // Fixed out-of-bounds index (was previously [children count]) + for (var i = children.length - 1; i >= 0; i--) { var child = children[i]; [self _addPendingItem:child]; @@ -864,7 +888,7 @@ var CPOutlineViewCoalesceSelectionNotificationStateOff = 0, - (void)_cleanPendingItem { - for (var i = [_pendingItemToClean count]; i >= 0; i--) + for (var i = [_pendingItemToClean count] - 1; i >= 0; i--) { var item = _pendingItemToClean[i]; @@ -908,7 +932,8 @@ var CPOutlineViewCoalesceSelectionNotificationStateOff = 0, var weight = itemInfo.weight, descendants = anItem ? [anItem] : []; - [_itemAddedDuringLastLoading addObject:anItem]; + if (anItem) + [_itemAddedDuringLastLoading addObject:anItem]; if (itemInfo.isExpanded && [self _sendDataSourceShouldDeferDisplayingChildrenOfItem:anItem]) { @@ -1085,7 +1110,7 @@ var CPOutlineViewCoalesceSelectionNotificationStateOff = 0, var parent = itemInfo.parent; // Check if the parent is the root item because we never return the actual root item - if (itemInfo[[parent UID]] === _rootItemInfo) + if (parent && _itemInfosForItems[[parent UID]] === _rootItemInfo) parent = nil; return parent; @@ -2179,7 +2204,7 @@ var CPOutlineViewCoalesceSelectionNotificationStateOff = 0, if ((_outlineView._implementedOutlineViewDelegateMethods & CPOutlineViewDelegate_outlineView_menuForTableColumn_item_)) { var item = [_outlineView itemAtRow:aRow]; - return [_outlineView._outlineViewDelegate outlineView:_outlineView menuForTableColumn:aTableColumn item:item] + return [_outlineView._outlineViewDelegate outlineView:_outlineView menuForTableColumn:aTableColumn item:item]; } // We reimplement CPView menuForEvent: because we can't call it directly. CPTableView implements menuForEvent: @@ -2344,3 +2369,282 @@ var colorForDisclosureTriangle = function(isSelected, isHighlighted) ? [CPColor colorWithCalibratedWhite:0.4 alpha: 1.0] : [CPColor colorWithCalibratedWhite:0.5 alpha: 1.0]); }; + +@implementation CPOutlineView (CPBindings) + ++ (void)initialize +{ + if (self !== [CPOutlineView class]) + return; + + [self exposeBinding:@"content"]; + [self exposeBinding:@"selectionIndexPaths"]; + [self exposeBinding:@"sortDescriptors"]; +} + +/*! + Returns the currently selected index paths. + This allows the outline view to be KVC-compliant for `selectionIndexPaths`. +*/ +- (CPArray)selectionIndexPaths +{ + var indexes = [self selectedRowIndexes], + paths = [CPMutableArray array], + index = [indexes firstIndex]; + + while (index !== CPNotFound) + { + var item = [self itemAtRow:index]; + + // Check if the item is a CPTreeNode proxy (which it will be when bound to CPTreeController) + if ([item respondsToSelector:@selector(indexPath)]) + [paths addObject:[item indexPath]]; + + index = [indexes indexGreaterThanIndex:index]; + } + + return paths; +} + +@end + + +@implementation CPOutlineView (CPBinder) + +- (id)content { return nil; } +- (void)setContent:(id)aContent { } +- (void)setSelectionIndexPaths:(CPArray)paths { } + ++ (Class)_binderClassForBinding:(CPString)aBinding +{ + if (aBinding === @"content") + return [_CPOutlineViewContentBinder class]; + + if (aBinding === @"selectionIndexPaths") + return [_CPOutlineViewSelectionIndexPathsBinder class]; + + return [super _binderClassForBinding:aBinding]; +} + +@end + + +// --- Content Binder --- + +/*! + _CPOutlineViewContentBinder acts as the CPOutlineViewDataSource when the outline view + is bound to a CPTreeController's arrangedObjects. +*/ +@implementation _CPOutlineViewContentBinder : CPBinder +{ + CPTreeNode _rootNode; +} + +- (void)bind +{ + // 1. Set the data source FIRST so that it is ready when + //[super bind] triggers the initial synchronous setValueFor: + [_source setDataSource:self]; + + // 2. Establish KVO (which immediately triggers setValueFor:) + [super bind]; +} + +- (void)unbind +{ + if ([_source dataSource] === self) + [_source setDataSource:nil]; + + [super unbind]; +} + +- (void)setValueFor:(CPString)aBinding +{ + var destination = [_info objectForKey:CPObservedObjectKey], + keyPath =[_info objectForKey:CPObservedKeyPathKey], + value = [destination valueForKeyPath:keyPath]; + + if (!value || ![value isKindOfClass:[CPTreeNode class]]) + _rootNode = [[CPTreeNode alloc] initWithRepresentedObject:nil]; + else + _rootNode = value; + + // Because CPBinder triggers setValueFor: synchronously during its initialization + // (before -bind is ever called), we must lazily assign the data source here. + if ([_source dataSource] !== self) + { + // Assigning the data source automatically triggers [_source reloadData] + // inside CPOutlineView, so we don't need to call it manually here. + [_source setDataSource:self]; + } + else + { + // If it was already set, we just manually trigger the reload. + [_source reloadData]; + } +} + +- (CPTreeNode)rootNode +{ + return _rootNode; +} + +// -- CPOutlineViewDataSource implementation -- + +- (id)outlineView:(CPOutlineView)outlineView child:(CPInteger)index ofItem:(id)item +{ + var node = item || _rootNode; + return [[node childNodes] objectAtIndex:index]; +} + +- (BOOL)outlineView:(CPOutlineView)outlineView isItemExpandable:(id)item +{ + var node = item || _rootNode; + return ![node isLeaf]; +} + +- (int)outlineView:(CPOutlineView)outlineView numberOfChildrenOfItem:(id)item +{ + var node = item || _rootNode; + return [[node childNodes] count]; +} + +- (id)outlineView:(CPOutlineView)outlineView objectValueForTableColumn:(CPTableColumn)tableColumn byItem:(id)item +{ + if ([item respondsToSelector:@selector(representedObject)]) + return [item representedObject]; + + return item; +} + +@end + +// --- Selection Index Paths Binder --- + +/*! + _CPOutlineViewSelectionIndexPathsBinder listens for selection changes on the CPOutlineView + and translates the selected rows into CPIndexPaths to push to the CPTreeController. + It also intercepts changes from the CPTreeController and auto-expands the tree to highlight them. +*/ +@implementation _CPOutlineViewSelectionIndexPathsBinder : CPBinder +{ + BOOL _isSyncingFromModel; +} + +- (id)initWithBinding:(CPString)aBinding name:(CPString)aName to:(id)aDestination keyPath:(CPString)aKeyPath options:(CPDictionary)options from:(id)aSource +{ + self = [super initWithBinding:aBinding name:aName to:aDestination keyPath:aKeyPath options:options from:aSource]; + + [[CPNotificationCenter defaultCenter] + addObserver:self + selector:@selector(outlineViewSelectionDidChange:) + name:CPOutlineViewSelectionDidChangeNotification + object:aSource]; +} + ++ (void)unbind:(CPString)aBinding forObject:(id)anObject +{ + if (aBinding === "selectionIndexPaths") + [[CPNotificationCenter defaultCenter] + removeObserver:self + name:CPOutlineViewSelectionDidChangeNotification + object:anObject]; + + [super unbind:aBinding forObject:anObject]; +} + +- (void)setValueFor:(CPString)aBinding +{ + // 1. SUPPRESS KVO AT THE VERY TOP to avoid circular updates when expanding parents + _isSyncingFromModel = YES; + + var destination = [_info objectForKey:CPObservedObjectKey], + keyPath = [_info objectForKey:CPObservedKeyPathKey], + indexPaths = [destination valueForKeyPath:keyPath] || [], + indexes = [CPMutableIndexSet indexSet]; + + // 2. Fetch the root node directly from the CPTreeController (destination) + var rootNode = [destination respondsToSelector:@selector(arrangedObjects)] ? [destination arrangedObjects] : nil; + + if (rootNode) + { + for (var i = 0, count = [indexPaths count]; i < count; i++) + { + var item = [rootNode descendantNodeAtIndexPath:[indexPaths objectAtIndex:i]]; + + if (item) + { + var parentsToExpand = [CPMutableArray array], + parent = [item parentNode]; + + while (parent && parent !== rootNode) + { + [parentsToExpand insertObject:parent atIndex:0]; + parent = [parent parentNode]; + } + + for (var j = 0; j < [parentsToExpand count]; j++) + [_source expandItem:parentsToExpand[j]]; + + var row = [_source rowForItem:item]; + + if (row !== CPNotFound && row >= 0) + [indexes addIndex:row]; + } + } + } + + // Adjust the CPOutlineView selection + [_source selectRowIndexes:indexes byExtendingSelection:NO]; + + // 3. Re-enable KVO after adjustments are done + _isSyncingFromModel = NO; +} + +- (void)outlineViewSelectionDidChange:(CPNotification)note +{ + // We only want to push the change back if we aren't currently syncing down from the model + if (_isSyncingFromModel) + return; + + // In CPBinder, reverseSetValueFor: takes the name of the property on _source + // it should fetch the updated value from. Since CPOutlineView has the selectionIndexPaths method: + [self reverseSetValueFor:@"selectionIndexPaths"]; +} + +@end + +@implementation _CPOutlineViewContentBinder (DynamicColumns) + +- (id)outlineView:(CPOutlineView)outlineView objectValueForTableColumn:(CPTableColumn)tableColumn byItem:(id)item +{ + var rep = [item respondsToSelector:@selector(representedObject)] ? [item representedObject] : item; + + // Dynamically fetch the value using the column's identifier (e.g., "name") + if (rep && [tableColumn identifier]) + return [rep valueForKey:[tableColumn identifier]]; + + return rep; +} + +// Add this to support inline bidirectional editing in the outline view +- (void)outlineView:(CPOutlineView)outlineView setObjectValue:(id)value forTableColumn:(CPTableColumn)tableColumn byItem:(id)item +{ + var rep = [item respondsToSelector:@selector(representedObject)] ?[item representedObject] : item; + + // Push the inline edit back to the model using the column's identifier + if (rep && [tableColumn identifier]) + [rep setValue:value forKey:[tableColumn identifier]]; +} + +- (id)content +{ + // CPTableView internals probe the binder for its flat content to draw rows. + // For an outline view, the flat content is exactly the internally mapped items for rows. + if (_source && _source._itemsForRows) + return _source._itemsForRows; + + return []; +} + +@end diff --git a/AppKit/CPTreeController.j b/AppKit/CPTreeController.j new file mode 100644 index 000000000..2d0e46802 --- /dev/null +++ b/AppKit/CPTreeController.j @@ -0,0 +1,628 @@ +/* + * CPTreeController.j + * AppKit + * + * Daniel Boehringer Mar/2026 + * + * 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 "CPObjectController.j" +@import "CPKeyValueBinding.j" +@import "CPTreeNode.j" + +@implementation CPTreeController : CPObjectController +{ + BOOL _avoidsEmptySelection; + BOOL _preservesSelection; + BOOL _selectsInsertedObjects; + BOOL _alwaysUsesMultipleValuesMarker; + + CPString _childrenKeyPath; + CPString _countKeyPath; + CPString _leafKeyPath; + + CPArray _sortDescriptors; + id _arrangedObjects; + + CPArray _selectionIndexPaths; + BOOL _disableSetContent; +} + ++ (void)initialize +{ + if (self !== [CPTreeController class]) + return; + + [self exposeBinding:@"contentArray"]; + [self exposeBinding:@"sortDescriptors"]; +} + ++ (CPSet)keyPathsForValuesAffectingContentArray +{ + return[CPSet setWithObjects:@"content"]; +} + ++ (CPSet)keyPathsForValuesAffectingArrangedObjects +{ + return [CPSet setWithObjects:@"content", @"sortDescriptors", @"childrenKeyPath"]; +} + ++ (CPSet)keyPathsForValuesAffectingSelectionIndexPath +{ + return[CPSet setWithObjects:@"selectionIndexPaths"]; +} + ++ (CPSet)keyPathsForValuesAffectingSelectedObjects +{ + return [CPSet setWithObjects:@"selectionIndexPaths"]; +} + ++ (CPSet)keyPathsForValuesAffectingSelectedNodes +{ + return [CPSet setWithObjects:@"selectionIndexPaths"]; +} + ++ (CPSet)keyPathsForValuesAffectingCanAddChild +{ + return[CPSet setWithObjects:@"selectionIndexPaths"]; +} + ++ (CPSet)keyPathsForValuesAffectingCanInsert +{ + return [CPSet setWithObjects:@"selectionIndexPaths"]; +} + ++ (CPSet)keyPathsForValuesAffectingCanInsertChild +{ + return [CPSet setWithObjects:@"selectionIndexPaths"]; +} + +- (id)init +{ + if (self = [super init]) + { + _preservesSelection = YES; + _selectsInsertedObjects = YES; + _avoidsEmptySelection = YES; + _alwaysUsesMultipleValuesMarker = NO; + _childrenKeyPath = @"children"; + + [self _init]; + } + return self; +} + +- (void)_init +{ + _sortDescriptors = [CPArray array]; + _selectionIndexPaths = [CPArray array]; + _arrangedObjects = [[CPTreeNode alloc] initWithRepresentedObject:nil]; +} + +- (void)prepareContent +{[self _setContentArray:[CPArray arrayWithObject:[self newObject]]]; +} + +- (BOOL)preservesSelection { return _preservesSelection; } +- (void)setPreservesSelection:(BOOL)value { _preservesSelection = value; } + +- (BOOL)selectsInsertedObjects { return _selectsInsertedObjects; } +- (void)setSelectsInsertedObjects:(BOOL)value { _selectsInsertedObjects = value; } + +- (BOOL)avoidsEmptySelection { return _avoidsEmptySelection; } +- (void)setAvoidsEmptySelection:(BOOL)value { _avoidsEmptySelection = value; } + +- (BOOL)alwaysUsesMultipleValuesMarker { return _alwaysUsesMultipleValuesMarker; } +- (void)setAlwaysUsesMultipleValuesMarker:(BOOL)aFlag { _alwaysUsesMultipleValuesMarker = aFlag; } + +- (CPArray)sortDescriptors { return _sortDescriptors; } +- (void)setSortDescriptors:(CPArray)value +{ + if (_sortDescriptors === value) + return; + + _sortDescriptors = [value copy]; + [self _rearrangeObjects]; +} + +- (CPString)childrenKeyPath { return _childrenKeyPath; } +- (void)setChildrenKeyPath:(CPString)aKeyPath +{ + if (_childrenKeyPath === aKeyPath) return; + _childrenKeyPath = aKeyPath;[self rearrangeObjects]; +} + +- (CPString)countKeyPath { return _countKeyPath; } +- (void)setCountKeyPath:(CPString)aKeyPath { _countKeyPath = aKeyPath; } + +- (CPString)leafKeyPath { return _leafKeyPath; } +- (void)setLeafKeyPath:(CPString)aKeyPath { _leafKeyPath = aKeyPath; } + +- (CPString)childrenKeyPathForNode:(CPTreeNode)node { return [self childrenKeyPath]; } +- (CPString)countKeyPathForNode:(CPTreeNode)node { return [self countKeyPath]; } +- (CPString)leafKeyPathForNode:(CPTreeNode)node { return [self leafKeyPath]; } + +- (void)setContent:(id)value +{ + if (_disableSetContent) return; + + if (!value) + value = [CPArray array]; + if (![value isKindOfClass:[CPArray class]]) + value = [CPArray arrayWithObject:value]; + + var oldSelectedObjects = nil, + oldSelectionIndexPaths = nil; + + if ([self preservesSelection]) + oldSelectedObjects = [self selectedObjects]; + else + oldSelectionIndexPaths = [self selectionIndexPaths]; + + _contentObject = value; + + [self _rearrangeObjects]; + + if ([self preservesSelection])[self __setSelectedObjects:oldSelectedObjects]; + else[self __setSelectionIndexPaths:oldSelectionIndexPaths avoidEmpty:_avoidsEmptySelection]; +} + +- (void)_setContentArray:(id)anArray {[self setContent:anArray]; } +- (id)contentArray { return _contentObject; } +- (id)arrangedObjects { return _arrangedObjects; } + +- (void)rearrangeObjects +{ + [self willChangeValueForKey:@"arrangedObjects"]; + [self _rearrangeObjects]; + [self didChangeValueForKey:@"arrangedObjects"]; +} + +- (void)_rearrangeObjects +{ + var oldSelectedObjects = nil, + oldSelectionIndexPaths = nil; + + if ([self preservesSelection]) + oldSelectedObjects = [self selectedObjects]; + else + oldSelectionIndexPaths = [self selectionIndexPaths]; + + [self __rebuildArrangedObjectsTree]; + + if ([self preservesSelection])[self __setSelectedObjects:oldSelectedObjects]; + else[self __setSelectionIndexPaths:oldSelectionIndexPaths avoidEmpty:_avoidsEmptySelection]; +} + +- (void)__rebuildArrangedObjectsTree +{ + var rootNode = [[CPTreeNode alloc] initWithRepresentedObject:nil], + contentArray = [self contentArray]; + + if (contentArray && [contentArray count] > 0) + { + var children = [self _buildTreeNodesForObjects:contentArray]; + [[rootNode mutableChildNodes] addObjectsFromArray:children]; + } + + _arrangedObjects = rootNode; +} + +- (CPArray)_buildTreeNodesForObjects:(CPArray)objects +{ + var count = [objects count]; + + if (count === 0) + return []; + + var sortedObjects = objects; + + if (_sortDescriptors && [_sortDescriptors count] > 0) + sortedObjects = [objects sortedArrayUsingDescriptors:_sortDescriptors]; + + var nodes = [CPMutableArray arrayWithCapacity:count]; + + for (var i = 0; i < count; i++) + { + var obj = [sortedObjects objectAtIndex:i], + node = [[CPTreeNode alloc] initWithRepresentedObject:obj]; + + if (_childrenKeyPath) + { + var childObjects = [obj valueForKeyPath:_childrenKeyPath]; + + if (childObjects && [childObjects count] > 0) + { + var childNodes = [self _buildTreeNodesForObjects:childObjects]; + [[node mutableChildNodes] addObjectsFromArray:childNodes]; + } + } + + [nodes addObject:node]; + } + + return nodes; +} + +- (CPIndexPath)selectionIndexPath +{ + return [_selectionIndexPaths count] > 0 ? [_selectionIndexPaths objectAtIndex:0] : nil; +} + +- (BOOL)setSelectionIndexPath:(CPIndexPath)indexPath +{ + var paths = indexPath ? [CPArray arrayWithObject:indexPath] : [CPArray array]; + return[self setSelectionIndexPaths:paths]; +} + +- (CPArray)selectionIndexPaths { return _selectionIndexPaths; } + +- (BOOL)setSelectionIndexPaths:(CPArray)indexPaths +{ + [self _selectionWillChange]; + var result = [self __setSelectionIndexPaths:indexPaths avoidEmpty:NO]; + [self _selectionDidChange]; + + return result; +} + +- (BOOL)__setSelectionIndexPaths:(CPArray)indexPaths avoidEmpty:(BOOL)avoidEmpty +{ + var newPaths = indexPaths; + + if (!newPaths) + newPaths = [CPArray array]; + + if (![newPaths count] && avoidEmpty) + { + if ([[[self arrangedObjects] childNodes] count] > 0) + newPaths = [CPArray arrayWithObject:[CPIndexPath indexPathWithIndex:0]]; + } + + if ([_selectionIndexPaths isEqualToArray:newPaths]) + return NO; + + [self willChangeValueForKey:@"selectionIndexPaths"]; + + _selectionIndexPaths = [newPaths copy]; + + var binderClass = [[self class] _binderClassForBinding:@"selectionIndexPaths"]; + + if (binderClass) + { + var binding = [binderClass getBinding:@"selectionIndexPaths" forObject:self]; + + if (binding) + [binding reverseSetValueFor:@"selectionIndexPaths"]; + } + + [self didChangeValueForKey:@"selectionIndexPaths"]; + + return YES; +} + +- (BOOL)addSelectionIndexPaths:(CPArray)indexPaths +{ + var newPaths = [_selectionIndexPaths mutableCopy]; + + [newPaths addObjectsFromArray:indexPaths]; + + return [self setSelectionIndexPaths:newPaths]; +} + +- (BOOL)removeSelectionIndexPaths:(CPArray)indexPaths +{ + var newPaths = [_selectionIndexPaths mutableCopy]; + [newPaths removeObjectsInArray:indexPaths]; + return[self setSelectionIndexPaths:newPaths]; +} + +- (CPArray)selectedNodes +{ + var nodes = [CPMutableArray array], + count = [_selectionIndexPaths count]; + + for (var i = 0; i < count; i++) + { + var node = [[self arrangedObjects] descendantNodeAtIndexPath:[_selectionIndexPaths objectAtIndex:i]]; + if (node) + [nodes addObject:node]; + } + return nodes; +} + +- (CPArray)selectedObjects +{ + var objects = [CPMutableArray array], + nodes = [self selectedNodes], + count = [nodes count]; + + for (var i = 0; i < count; i++) + [objects addObject:[[nodes objectAtIndex:i] representedObject]]; + + return objects; +} + +- (BOOL)__setSelectedObjects:(CPArray)objects +{ + if (!objects || [objects count] === 0) + return[self __setSelectionIndexPaths:[CPArray array] avoidEmpty:_avoidsEmptySelection]; + + var newPaths = [CPMutableArray array]; + for (var i = 0, count = [objects count]; i < count; i++) + { + var path = [self _indexPathForObject:[objects objectAtIndex:i] inNode:[self arrangedObjects]]; + if (path) + [newPaths addObject:path]; + } + + return[self __setSelectionIndexPaths:newPaths avoidEmpty:_avoidsEmptySelection]; +} + +- (CPIndexPath)_indexPathForObject:(id)anObject inNode:(CPTreeNode)node +{ + if ([node representedObject] === anObject && [node parentNode] != nil) + return [node indexPath]; + + var children = [node childNodes]; + if (children) + { + for (var i = 0, count = [children count]; i < count; i++) + { + var found = [self _indexPathForObject:anObject inNode:[children objectAtIndex:i]]; + if (found) + return found; + } + } + return nil; +} + +- (BOOL)canInsert { return[self isEditable]; } +- (BOOL)canInsertChild { return [self isEditable] &&[_selectionIndexPaths count] > 0; } +- (BOOL)canAddChild { return [self canInsertChild]; } + +- (void)add:(id)sender +{ + if (![self canInsert]) return; + + var newObject = [self automaticallyPreparesContent] ? [self newObject] :[self _defaultNewObject], + selectionPath = [self selectionIndexPath]; + + if (!selectionPath) + selectionPath = [CPIndexPath indexPathWithIndex:[[[self arrangedObjects] childNodes] count]]; + + var length = [selectionPath length], + lastIndex = [selectionPath indexAtPosition:length - 1], + insertPath = [selectionPath indexPathByRemovingLastIndex]; + + insertPath = [insertPath indexPathByAddingIndex:lastIndex + 1]; + + [self insertObject:newObject atArrangedObjectIndexPath:insertPath]; +} + +- (void)addChild:(id)sender +{ + if (![self canAddChild]) + return; + + var newObject = [self automaticallyPreparesContent] ?[self newObject] : [self _defaultNewObject], + parentNode = [[self arrangedObjects] descendantNodeAtIndexPath:[self selectionIndexPath]], + childCount = [[parentNode childNodes] count], + insertPath = [[self selectionIndexPath] indexPathByAddingIndex:childCount]; + + [self insertObject:newObject atArrangedObjectIndexPath:insertPath]; +} + +- (void)insert:(id)sender +{ + if (![self canInsert]) return; + + var newObject = [self automaticallyPreparesContent] ? [self newObject] :[self _defaultNewObject], + indexPath = [self selectionIndexPath] || [CPIndexPath indexPathWithIndex:0]; + + [self insertObject:newObject atArrangedObjectIndexPath:indexPath]; +} + +- (void)insertChild:(id)sender +{ + if (![self canInsertChild]) return; + + var newObject = [self automaticallyPreparesContent] ? [self newObject] : [self _defaultNewObject], + insertPath = [[self selectionIndexPath] indexPathByAddingIndex:0]; + + [self insertObject:newObject atArrangedObjectIndexPath:insertPath]; +} + +- (void)insertObject:(id)anObject atArrangedObjectIndexPath:(CPIndexPath)indexPath +{ + [self insertObjects:[CPArray arrayWithObject:anObject] atArrangedObjectIndexPaths:[CPArray arrayWithObject:indexPath]]; +} + +- (void)insertObjects:(CPArray)objects atArrangedObjectIndexPaths:(CPArray)indexPaths +{ + [self willChangeValueForKey:@"content"]; + _disableSetContent = YES; + + var count = [objects count]; + for (var i = 0; i < count; i++) + { + var object = [objects objectAtIndex:i], + path = [indexPaths objectAtIndex:i], + length = [path length]; + + if (length === 1) + {[_contentObject insertObject:object atIndex:[path indexAtPosition:0]]; + } + else + { + var parentPath = [path indexPathByRemovingLastIndex], + parentNode = [[self arrangedObjects] descendantNodeAtIndexPath:parentPath]; + + if (parentNode) + { + var parentObj = [parentNode representedObject], + childIndex = [path indexAtPosition:length - 1]; + + var children = [parentObj valueForKeyPath:_childrenKeyPath]; + if (!children) + { + children = [CPMutableArray array]; + [parentObj setValue:children forKeyPath:_childrenKeyPath]; + } + + var mutableChildren = [parentObj mutableArrayValueForKeyPath:_childrenKeyPath]; + + [mutableChildren insertObject:object atIndex:childIndex]; + } + } + } + + var binding = [[self class] _binderClassForBinding:@"contentArray"]; + if (binding) + [[binding getBinding:@"contentArray" forObject:self] _contentArrayDidChange]; + + _disableSetContent = NO; + [self _rearrangeObjects]; + + if ([self selectsInsertedObjects])[self setSelectionIndexPaths:indexPaths]; + + [self didChangeValueForKey:@"content"]; +} + +- (void)remove:(id)sender +{ + [self removeObjectsAtArrangedObjectIndexPaths:_selectionIndexPaths]; +} + +- (void)removeObjectAtArrangedObjectIndexPath:(CPIndexPath)indexPath +{ + [self removeObjectsAtArrangedObjectIndexPaths:[CPArray arrayWithObject:indexPath]]; +} + +- (void)removeObjectsAtArrangedObjectIndexPaths:(CPArray)indexPaths +{ + [self willChangeValueForKey:@"content"]; + _disableSetContent = YES; + + var sortedPaths = [indexPaths sortedArrayUsingSelector:@selector(compare:)], + count = [sortedPaths count]; + + for (var i = count - 1; i >= 0; i--) + { + var path = [sortedPaths objectAtIndex:i], + length = [path length]; + + if (length === 1) + { + [_contentObject removeObjectAtIndex:[path indexAtPosition:0]]; + } + else + { + var parentPath = [path indexPathByRemovingLastIndex], + parentNode = [[self arrangedObjects] descendantNodeAtIndexPath:parentPath]; + + if (parentNode) + { + var parentObj = [parentNode representedObject], + childIndex = [path indexAtPosition:length - 1], + mutableChildren = [parentObj mutableArrayValueForKeyPath:_childrenKeyPath]; + + if (mutableChildren && childIndex <[mutableChildren count]) + [mutableChildren removeObjectAtIndex:childIndex]; + } + } + } + + var binding = [[self class] _binderClassForBinding:@"contentArray"]; + if (binding) + [[binding getBinding:@"contentArray" forObject:self] _contentArrayDidChange]; + + _disableSetContent = NO; + [self _rearrangeObjects]; + [self didChangeValueForKey:@"content"]; +} + +- (void)moveNode:(CPTreeNode)node toIndexPath:(CPIndexPath)indexPath +{ + [self moveNodes:[CPArray arrayWithObject:node] toIndexPath:indexPath]; +} + +- (void)moveNodes:(CPArray)nodes toIndexPath:(CPIndexPath)startingIndexPath +{[CPException raise:CPUnsupportedMethodException reason:@"moveNodes:toIndexPath: is not yet implemented in CPTreeController."]; +} + +@end + +var CPTreeControllerAvoidsEmptySelection = @"CPTreeControllerAvoidsEmptySelection", +CPTreeControllerPreservesSelection = @"CPTreeControllerPreservesSelection", +CPTreeControllerSelectsInsertedObjects = @"CPTreeControllerSelectsInsertedObjects", +CPTreeControllerAlwaysUsesMultipleValuesMarker = @"CPTreeControllerAlwaysUsesMultipleValuesMarker", +CPTreeControllerChildrenKeyPath = @"CPTreeControllerChildrenKeyPath", +CPTreeControllerCountKeyPath = @"CPTreeControllerCountKeyPath", +CPTreeControllerLeafKeyPath = @"CPTreeControllerLeafKeyPath"; + +@implementation CPTreeController (CPCoding) + +- (id)initWithCoder:(CPCoder)aCoder +{ + self = [super initWithCoder:aCoder]; + + if (self) + { + _avoidsEmptySelection = [aCoder decodeBoolForKey:CPTreeControllerAvoidsEmptySelection]; + _preservesSelection = [aCoder decodeBoolForKey:CPTreeControllerPreservesSelection]; + _selectsInsertedObjects = [aCoder decodeBoolForKey:CPTreeControllerSelectsInsertedObjects]; + _alwaysUsesMultipleValuesMarker = [aCoder decodeBoolForKey:CPTreeControllerAlwaysUsesMultipleValuesMarker]; + + _childrenKeyPath = [aCoder decodeObjectForKey:CPTreeControllerChildrenKeyPath] || @"children"; + _countKeyPath = [aCoder decodeObjectForKey:CPTreeControllerCountKeyPath]; + _leafKeyPath = [aCoder decodeObjectForKey:CPTreeControllerLeafKeyPath]; + + _sortDescriptors = [CPArray array]; + _selectionIndexPaths = [CPArray array]; + _arrangedObjects = [[CPTreeNode alloc] initWithRepresentedObject:nil]; + + if (![self content] && [self automaticallyPreparesContent]) + [self prepareContent]; + else if (![self content]) + [self _setContentArray:[CPArray array]]; + } + + return self; +} + +- (void)encodeWithCoder:(CPCoder)aCoder +{ + [super encodeWithCoder:aCoder]; + + [aCoder encodeBool:_avoidsEmptySelection forKey:CPTreeControllerAvoidsEmptySelection]; + [aCoder encodeBool:_preservesSelection forKey:CPTreeControllerPreservesSelection]; + [aCoder encodeBool:_selectsInsertedObjects forKey:CPTreeControllerSelectsInsertedObjects]; + [aCoder encodeBool:_alwaysUsesMultipleValuesMarker forKey:CPTreeControllerAlwaysUsesMultipleValuesMarker]; + [aCoder encodeObject:_childrenKeyPath forKey:CPTreeControllerChildrenKeyPath]; + [aCoder encodeObject:_countKeyPath forKey:CPTreeControllerCountKeyPath]; + [aCoder encodeObject:_leafKeyPath forKey:CPTreeControllerLeafKeyPath]; +} + +- (void)awakeFromCib +{ + [self _selectionWillChange]; + [self _selectionDidChange]; +} + +@end diff --git a/AppKit/CPTreeNode.j b/AppKit/CPTreeNode.j index 0f5f88a33..f25c83649 100644 --- a/AppKit/CPTreeNode.j +++ b/AppKit/CPTreeNode.j @@ -22,13 +22,12 @@ @import @import - +@import @implementation CPTreeNode : CPObject { - id _representedObject @accessors(readonly, property=representedObject); - - CPTreeNode _parentNode @accessors(readonly, property=parentNode); + id _representedObject @accessors(property=representedObject); + CPTreeNode _parentNode @accessors(property=parentNode); CPMutableArray _childNodes; } @@ -52,36 +51,35 @@ - (CPIndexPath)indexPath { - if (_parentNode != nil) + // If we have a parent, calculate path based on parent's path + our index + if (_parentNode) { - var path; - var index; - - index = [[_parentNode childNodes] indexOfObject:self]; - path = [_parentNode indexPath]; - - if (path != nil) - { - return [path indexPathByAddingIndex:index]; - } - else - { - return [CPIndexPath indexPathWithIndex:index]; - } - } - else - { - return nil; + // Search the parent's child nodes, not our own! + var index = [[_parentNode childNodes] indexOfObjectIdenticalTo:self]; + + // If the parent is the root (and technically has no path itself in some implementations), + // we might get nil. Handle that gracefully. + var parentPath = [_parentNode indexPath]; + + if (parentPath) + return [parentPath indexPathByAddingIndex:index]; + + return [CPIndexPath indexPathWithIndex:index]; } + + // If we are the root, we don't have an index path in the context of a tree controller usually, + // or we are [] (empty path). Returning nil is acceptable for the absolute root. + return nil; } - (BOOL)isLeaf { - return [_childNodes count] <= 0; + return [_childNodes count] == 0; } - (CPArray)childNodes { + // Return a copy to prevent external modification without KVC return [_childNodes copy]; } @@ -90,18 +88,29 @@ return [self mutableArrayValueForKey:@"childNodes"]; } -- (void)insertObject:(id)aTreeNode inChildNodesAtIndex:(CPInteger)anIndex -{ - [[aTreeNode._parentNode mutableChildNodes] removeObjectIdenticalTo:aTreeNode]; +// MARK: - KVC Compliance Methods - aTreeNode._parentNode = self; +- (void)insertObject:(CPTreeNode)aTreeNode inChildNodesAtIndex:(CPInteger)anIndex +{ + // Optional: Auto-detach from old parent if strictly moving nodes + if ([aTreeNode isKindOfClass:[CPTreeNode class]] && aTreeNode._parentNode) + { + [[aTreeNode._parentNode mutableChildNodes] removeObjectIdenticalTo:aTreeNode]; + } + + // Direct ivar access is allowed here since we are inside the class implementation + if ([aTreeNode isKindOfClass:[CPTreeNode class]]) + aTreeNode._parentNode = self; [_childNodes insertObject:aTreeNode atIndex:anIndex]; } - (void)removeObjectFromChildNodesAtIndex:(CPInteger)anIndex { - [_childNodes objectAtIndex:anIndex]._parentNode = nil; + var node = [_childNodes objectAtIndex:anIndex]; + + if ([node isKindOfClass:[CPTreeNode class]]) + node._parentNode = nil; [_childNodes removeObjectAtIndex:anIndex]; } @@ -110,17 +119,34 @@ { var oldTreeNode = [_childNodes objectAtIndex:anIndex]; - oldTreeNode._parentNode = nil; - aTreeNode._parentNode = self; + if ([oldTreeNode isKindOfClass:[CPTreeNode class]]) + oldTreeNode._parentNode = nil; + + if ([aTreeNode isKindOfClass:[CPTreeNode class]]) + aTreeNode._parentNode = self; [_childNodes replaceObjectAtIndex:anIndex withObject:aTreeNode]; } +// MARK: - Convenience Accessors + - (id)objectInChildNodesAtIndex:(CPInteger)anIndex { - return _childNodes[anIndex]; + return [_childNodes objectAtIndex:anIndex]; } +- (CPInteger)count +{ + return [_childNodes count]; +} + +- (id)objectAtIndex:(CPInteger)anIndex +{ + return [_childNodes objectAtIndex:anIndex]; +} + +// MARK: - Utility + - (void)sortWithSortDescriptors:(CPArray)sortDescriptors recursively:(BOOL)shouldSortRecursively { [_childNodes sortUsingDescriptors:sortDescriptors]; @@ -129,25 +155,39 @@ return; var count = [_childNodes count]; - while (count--) - [_childNodes[count] sortWithSortDescriptors:sortDescriptors recursively:YES]; + { + var child = [_childNodes objectAtIndex:count]; + if ([child respondsToSelector:@selector(sortWithSortDescriptors:recursively:)]) + [child sortWithSortDescriptors:sortDescriptors recursively:YES]; + } } - (CPTreeNode)descendantNodeAtIndexPath:(CPIndexPath)indexPath { - var index = 0, - count = [indexPath length], - node = self; + if (!indexPath || [indexPath length] == 0) + return self; - for (; index < count; ++index) - node = [node objectInChildNodesAtIndex:[indexPath indexAtPosition:index]]; + var node = self, + length = [indexPath length]; + + for (var i = 0; i < length; i++) + { + var index = [indexPath indexAtPosition:i], + count = [node count]; + + if (index >= count || index < 0) + return nil; + + node = [node objectAtIndex:index]; + } return node; } @end +// Coding implementation remains correct var CPTreeNodeRepresentedObjectKey = @"CPTreeNodeRepresentedObjectKey", CPTreeNodeParentNodeKey = @"CPTreeNodeParentNodeKey", CPTreeNodeChildNodesKey = @"CPTreeNodeChildNodesKey"; @@ -163,6 +203,10 @@ var CPTreeNodeRepresentedObjectKey = @"CPTreeNodeRepresentedObjectKey", _representedObject = [aCoder decodeObjectForKey:CPTreeNodeRepresentedObjectKey]; _parentNode = [aCoder decodeObjectForKey:CPTreeNodeParentNodeKey]; _childNodes = [aCoder decodeObjectForKey:CPTreeNodeChildNodesKey]; + + // Safety check to ensure decoding gave us a CPArray + if (!_childNodes) + _childNodes = [[CPMutableArray alloc] init]; } return self; diff --git a/Tests/AppKit/CPTreeControllerTest.j b/Tests/AppKit/CPTreeControllerTest.j new file mode 100644 index 000000000..9487e4e46 --- /dev/null +++ b/Tests/AppKit/CPTreeControllerTest.j @@ -0,0 +1,266 @@ +/* + * CPTreeControllerTest.j + * + * Test suite for CPTreeController + */ + +@import +@import +@import +@import +@import + +@class OrgNode + +@implementation CPTreeControllerTest : OJTestCase +{ + CPTreeController _treeController @accessors(property=treeController); + CPArray _contentArray @accessors(property=contentArray); + + CPArray observations; + int aCount @accessors; +} + +- (CPArray)makeTestTree +{ + var engineering = [OrgNode nodeWithName:@"Engineering"], + marketing = [OrgNode nodeWithName:@"Marketing"]; + + var webTeam = [OrgNode nodeWithName:@"Web Team"], + backendTeam = [OrgNode nodeWithName:@"Backend Team"]; + + [engineering setChildren:[CPMutableArray arrayWithObjects:webTeam, backendTeam]]; + + var dev1 = [OrgNode nodeWithName:@"Francisco"], + dev2 = [OrgNode nodeWithName:@"Ross"]; + + [webTeam setChildren:[CPMutableArray arrayWithObjects:dev1, dev2]]; + + return [CPMutableArray arrayWithObjects:engineering, marketing]; +} + +- (void)setUp +{ + [[CPApplication alloc] init]; + + _contentArray = [self makeTestTree]; + _treeController = [[CPTreeController alloc] init]; + [_treeController setChildrenKeyPath:@"children"]; + [_treeController setContent:[_contentArray copy]]; +} + +- (void)testInitWithContent +{ + [self assert:[_contentArray count] equals:[[_treeController contentArray] count]]; + [self assert:[CPTreeNode class] equals:[[[self treeController] arrangedObjects] class] message:@"arranged objects should be a proxy CPTreeNode root"]; +} + +- (void)testInitWithoutContent +{ + var emptyController = [[CPTreeController alloc] init]; + [self assert:[CPArray array] equals:[emptyController contentArray]]; + [self assert:0 equals:[[[emptyController arrangedObjects] childNodes] count]]; +} + +- (void)testSetContent +{ + var newTree = [CPMutableArray arrayWithObject:[OrgNode nodeWithName:@"Solo Department"]]; + [[self treeController] setContent:newTree]; + + [self assert:newTree equals:[[self treeController] contentArray]]; + [self assert:1 equals:[[[[self treeController] arrangedObjects] childNodes] count]]; +} + +- (void)testSelectionPaths +{ + var controller = [self treeController]; + + var path = [[CPIndexPath indexPathWithIndex:0] indexPathByAddingIndex:0]; + [controller setSelectionIndexPath:path]; + + var selectedPath = [controller selectionIndexPath]; + [self assert:path equals:selectedPath]; + + var selectedNodes = [controller selectedNodes]; + [self assert:1 equals:[selectedNodes count]]; + [self assert:@"Web Team" equals:[[[selectedNodes objectAtIndex:0] representedObject] name]]; +} + +- (void)testAddChild +{ + var controller = [self treeController]; + [controller setObjectClass:[OrgNode class]]; + + var parentPath = [[CPIndexPath indexPathWithIndex:0] indexPathByAddingIndex:1]; + [controller setSelectionIndexPath:parentPath]; + + var newDev = [OrgNode nodeWithName:@"Tom"]; + var insertPath = [parentPath indexPathByAddingIndex:0]; + [controller insertObject:newDev atArrangedObjectIndexPath:insertPath]; + + var engineering = [[controller contentArray] objectAtIndex:0], + backendTeam = [[engineering children] objectAtIndex:1]; + [self assert:1 equals:[[backendTeam children] count] message:@"Child should be added to the model object's children array"]; + [self assert:@"Tom" equals:[[[backendTeam children] objectAtIndex:0] name]]; +} + +- (void)testInsertObjectAtArrangedObjectIndexPath +{ + var controller = [self treeController]; + + var path = [CPIndexPath indexPathWithIndex:1]; + var hrDept = [OrgNode nodeWithName:@"Human Resources"]; + + [controller insertObject:hrDept atArrangedObjectIndexPath:path]; + [self assert:3 equals:[[controller contentArray] count]]; + [self assert:hrDept equals:[[controller contentArray] objectAtIndex:1]]; + + var nestedPath = [[CPIndexPath indexPathWithIndex:0] indexPathByAddingIndex:0]; + var subDept = [OrgNode nodeWithName:@"Sub Dept"]; + + [controller insertObject:subDept atArrangedObjectIndexPath:nestedPath]; + var engChildren = [[[controller contentArray] objectAtIndex:0] children]; + [self assert:subDept equals:[engChildren objectAtIndex:0] message:@"Object should be inserted at the correct nested index path"]; +} + +- (void)testRemoveObjectAtArrangedObjectIndexPath +{ + var controller = [self treeController]; + + var path = [[[CPIndexPath indexPathWithIndex:0] indexPathByAddingIndex:0] indexPathByAddingIndex:0]; + + [controller removeObjectAtArrangedObjectIndexPath:path]; + + var engineering = [[controller contentArray] objectAtIndex:0], + webTeam = [[engineering children] objectAtIndex:0]; + + [self assert:1 equals:[[webTeam children] count] message:@"Francisco should be removed, leaving only Ross"]; + [self assert:@"Ross" equals:[[[webTeam children] objectAtIndex:0] name]]; +} + +- (void)testRemoveObjectsAtArrangedObjectIndexPaths +{ + var controller = [self treeController]; + + var paths = [CPArray arrayWithObjects:[CPIndexPath indexPathWithIndex:0], [CPIndexPath indexPathWithIndex:1]]; + + [controller removeObjectsAtArrangedObjectIndexPaths:paths]; + + [self assert:0 equals:[[controller contentArray] count] message:@"All root nodes should be removed"]; +} + +- (void)testSelectingEmptyIndexPathsExplicitlyWithAvoidsEmptySelection +{ + var controller = [self treeController]; + + [controller setAvoidsEmptySelection:YES]; + + [controller setSelectionIndexPath:[CPIndexPath indexPathWithIndex:0]]; + [controller setSelectionIndexPaths:[CPArray array]]; + + [self assertTrue:([[controller selectionIndexPaths] count] == 0) message:@"Selection should be empty when unselecting explicitly, even with avoidsEmptySelection"]; +} + +- (void)testAvoidsEmptySelectionWhenRemoving +{ + var controller = [self treeController]; + [controller setAvoidsEmptySelection:YES]; + + var path = [CPIndexPath indexPathWithIndex:0]; + [controller setSelectionIndexPath:path]; + + // Remove "Engineering" + [controller removeObjectAtArrangedObjectIndexPath:path]; + + [self assertTrue:([[controller selectionIndexPaths] count] == 1) message:@"Selection should fallback to the first item when avoidsEmptySelection is YES"]; + // "Marketing" is now at index 0 + [self assert:[CPIndexPath indexPathWithIndex:0] equals:[controller selectionIndexPath]]; + + // Test behavior when AvoidsEmptySelection is NO[controller insertObject:[OrgNode nodeWithName:@"New Dept"] atArrangedObjectIndexPath:[CPIndexPath indexPathWithIndex:1]]; + [controller setAvoidsEmptySelection:NO]; + + // Reselect "Marketing" at index 0 + [controller setSelectionIndexPath:[CPIndexPath indexPathWithIndex:0]]; + + // Remove "Marketing" + [controller removeObjectAtArrangedObjectIndexPath:[CPIndexPath indexPathWithIndex:0]]; + + [self assertTrue:([[controller selectionIndexPaths] count] == 0) message:@"Selection should be allowed to be empty when avoidsEmptySelection is NO"]; +} + +- (void)testChildrenKeyPathOverride +{ + var controller = [[CPTreeController alloc] init]; + [controller setChildrenKeyPath:@"subItems"]; + + var data = [OrgNode nodeWithName:@"Root"]; + + [data setValue:[CPMutableArray arrayWithObject:[OrgNode nodeWithName:@"Sub"]] forKey:@"subItems"]; + [controller setContent:[CPMutableArray arrayWithObject:data]]; + + var path = [[CPIndexPath indexPathWithIndex:0] indexPathByAddingIndex:0]; + var newItem = [OrgNode nodeWithName:@"New Sub"]; + [controller insertObject:newItem atArrangedObjectIndexPath:path]; + + var subs = [data valueForKey:@"subItems"]; + [self assert:2 equals:[subs count]]; + [self assert:newItem equals:[subs objectAtIndex:0] message:@"Object should be inserted using the custom childrenKeyPath"]; +} + +- (void)testContentBinding +{ + var controller = [[CPTreeController alloc] init]; + + [controller bind:@"contentArray" toObject:self withKeyPath:@"contentArray" options:nil]; + [self assert:[self contentArray] equals:[controller contentArray]]; + [self assert:2 equals:[[[controller arrangedObjects] childNodes] count]]; +} + +- (void)testSelectedObjects +{ + var controller = [self treeController]; + + var path = [CPIndexPath indexPathWithIndex:1]; + [controller setSelectionIndexPath:path]; + + var selectedObjects = [controller selectedObjects]; + + [self assert:1 equals:[selectedObjects count]]; + [self assert:@"Marketing" equals:[[selectedObjects objectAtIndex:0] name]]; +} + +@end + +/* + * Dummy Model Class for Testing + */ +@implementation OrgNode : CPObject +{ + CPString _name @accessors(property=name); + CPMutableArray _children @accessors(property=children); + CPMutableArray _subItems @accessors(property=subItems); +} + ++ (id)nodeWithName:(CPString)aName +{ + return [[self alloc] initWithName:aName]; +} + +- (id)initWithName:(CPString)aName +{ + if (self = [super init]) + { + _name = aName; + _children = [CPMutableArray array]; + _subItems = [CPMutableArray array]; + } + + return self; +} + +- (CPString)description +{ + return [CPString stringWithFormat:@"", [self name]]; +} + +@end diff --git a/Tests/AppKit/CPTreeNodeTest.j b/Tests/AppKit/CPTreeNodeTest.j index 4e40b5f98..a9c8c9c19 100644 --- a/Tests/AppKit/CPTreeNodeTest.j +++ b/Tests/AppKit/CPTreeNodeTest.j @@ -25,7 +25,7 @@ indexPath = [CPIndexPath indexPathWithIndex:1]; - [self assert:undefined equals:[treeNode descendantNodeAtIndexPath:indexPath]]; + [self assert:nil equals:[treeNode descendantNodeAtIndexPath:indexPath]]; } @end diff --git a/Tests/Manual/CPTreeControllerTest/AppController.j b/Tests/Manual/CPTreeControllerTest/AppController.j new file mode 100644 index 000000000..15e24c0e4 --- /dev/null +++ b/Tests/Manual/CPTreeControllerTest/AppController.j @@ -0,0 +1,153 @@ +/* + * AppController.j + * TreeControllerBindingsTest + * + * Created for testing CPOutlineView and CPTreeController bindings. + */ + +@import +@import + +@implementation AppController : CPObject +{ + CPTreeController treeController; + CPTextField logField; +} + +- (void)applicationDidFinishLaunching:(CPNotification)aNotification +{ + var theWindow = [[CPWindow alloc] initWithContentRect:CGRectMakeZero() styleMask:CPBorderlessBridgeWindowMask], + contentView = [theWindow contentView]; + + // 1. Create the Data Model + var root1 = [[Node alloc] initWithName:@"Root 1" children:[]], + child1 = [[Node alloc] initWithName:@"Child 1.1" children:[]], + child2 = [[Node alloc] initWithName:@"Child 1.2" children:[]], + root2 = [[Node alloc] initWithName:@"Root 2" children:[]], + child3 = [[Node alloc] initWithName:@"Child 2.1" children:[]]; + + [root1 setChildren:[child1, child2]]; + [root2 setChildren:[child3]]; + var contentArray = [root1, root2]; + + // 2. Setup the Tree Controller + treeController = [[CPTreeController alloc] init]; + [treeController setChildrenKeyPath:@"children"]; + [treeController setContent:contentArray]; + + // 3. Setup the Outline View + var scrollView = [[CPScrollView alloc] initWithFrame:CGRectMake(20, 20, 250, 300)]; + [scrollView setAutohidesScrollers:YES]; + + var outlineView = [[CPOutlineView alloc] initWithFrame:CGRectMake(0, 0, 250, 300)]; + var column = [[CPTableColumn alloc] initWithIdentifier:@"name"]; + [[column headerView] setStringValue:@"Node Name"]; + [column setWidth:240]; + [column setEditable:YES]; // Editable to test bidirectional bindings in the tree + + [outlineView addTableColumn:column]; + [outlineView setOutlineTableColumn:column]; + [outlineView setAllowsMultipleSelection:YES]; + [scrollView setDocumentView:outlineView]; + [contentView addSubview:scrollView]; + + // 4. Establish Bindings for the Outline View + [outlineView bind:@"content" toObject:treeController withKeyPath:@"arrangedObjects" options:nil]; + [outlineView bind:@"selectionIndexPaths" toObject:treeController withKeyPath:@"selectionIndexPaths" options:nil]; + + var scrollView2 = [[CPScrollView alloc] initWithFrame:CGRectMake(300, 20, 250, 300)]; + [scrollView2 setAutohidesScrollers:YES]; + var outlineView2 = [[CPOutlineView alloc] initWithFrame:CGRectMake(0, 0, 250, 300)]; + var column2 = [[CPTableColumn alloc] initWithIdentifier:@"name"]; + [[column2 headerView] setStringValue:@"Node Name"]; + [column2 setWidth:240]; + [column2 setEditable:YES]; // Editable to test bidirectional bindings in the tree + + [outlineView2 addTableColumn:column2]; + [outlineView2 setOutlineTableColumn:column2]; + [outlineView2 setAllowsMultipleSelection:YES]; + [scrollView2 setDocumentView:outlineView2]; + [contentView addSubview:scrollView2]; + + // 4. Establish Bindings for the Outline View + [outlineView2 bind:@"content" toObject:treeController withKeyPath:@"arrangedObjects" options:nil]; + [outlineView2 bind:@"selectionIndexPaths" toObject:treeController withKeyPath:@"selectionIndexPaths" options:nil]; + + + + [theWindow orderFront:self]; +} + +- (void)selectSpecificNode:(id)sender +{ + // Programmatically select index path [0, 1] which is "Child 1.2" + // This tests the `_CPOutlineViewSelectionIndexPathsBinder` auto-expand logic. + var path =[CPIndexPath indexPathWithIndexes:[0, 1]]; + [treeController setSelectionIndexPath:path]; +} + +- (void)observeValueForKeyPath:(CPString)keyPath ofObject:(id)object change:(CPDictionary)change context:(id)context +{ + if (keyPath === @"selectionIndexPaths") + { + var selectedObjects = [treeController selectedObjects]; + if ([selectedObjects count] > 0) + { + var names = [CPMutableArray array]; + for (var i = 0; i <[selectedObjects count]; i++) + [names addObject:[selectedObjects[i] name]];[logField setStringValue:[names componentsJoinedByString:@", "]]; + } + else + { + [logField setStringValue:@"Nothing selected"]; + } + } +} + +@end + + +// --- Custom Data Model --- + +@implementation Node : CPObject +{ + CPString name; + CPArray children; +} + +- (id)initWithName:(CPString)aName children:(CPArray)someChildren +{ + self = [super init]; + if (self) + { + name = aName; + children = someChildren; + } + return self; +} + +// Explicit accessors to ensure Key-Value Observing (KVO) works flawlessly. +- (void)setName:(CPString)aName +{ + [self willChangeValueForKey:@"name"]; + name = aName;[self didChangeValueForKey:@"name"]; +} + +- (CPString)name +{ + return name; +} + +- (void)setChildren:(CPArray)someChildren +{ + [self willChangeValueForKey:@"children"]; + children = someChildren; + [self didChangeValueForKey:@"children"]; +} + +- (CPArray)children +{ + return children; +} + +@end diff --git a/Tests/Manual/CPTreeControllerTest/Info.plist b/Tests/Manual/CPTreeControllerTest/Info.plist new file mode 100644 index 000000000..3af283991 --- /dev/null +++ b/Tests/Manual/CPTreeControllerTest/Info.plist @@ -0,0 +1,12 @@ + + + + + CPApplicationDelegateClass + AppController + CPBundleName + treecontroller + CPPrincipalClass + CPApplication + + diff --git a/Tests/Manual/CPTreeControllerTest/Jakefile b/Tests/Manual/CPTreeControllerTest/Jakefile new file mode 100644 index 000000000..8f62ca5cd --- /dev/null +++ b/Tests/Manual/CPTreeControllerTest/Jakefile @@ -0,0 +1,93 @@ +/* + * Jakefile + * tooltips + * + * Created by You on April 26, 2011. + * Copyright 2011, Your Company All rights reserved. + */ + +var ENV = require("system").env, + FILE = require("file"), + JAKE = require("jake"), + task = JAKE.task, + FileList = JAKE.FileList, + app = require("cappuccino/jake").app, + configuration = ENV["CONFIG"] || ENV["CONFIGURATION"] || ENV["c"] || "Debug", + OS = require("os"); + +app ("tooltips", function(task) +{ + task.setBuildIntermediatesPath(FILE.join("Build", "tooltips.build", configuration)); + task.setBuildPath(FILE.join("Build", configuration)); + + task.setProductName("tooltips"); + task.setIdentifier("com.yourcompany.tooltips"); + task.setVersion("1.0"); + task.setAuthor("Your Company"); + task.setEmail("feedback @nospam@ yourcompany.com"); + task.setSummary("tooltips"); + task.setSources((new FileList("**/*.j")).exclude(FILE.join("Build", "**"))); + task.setResources(new FileList("Resources/**")); + task.setIndexFilePath("index.html"); + task.setInfoPlistPath("Info.plist"); + + if (configuration === "Debug") + task.setCompilerFlags("-DDEBUG -g"); + else + task.setCompilerFlags("-O"); +}); + +task ("default", ["tooltips"], function() +{ + printResults(configuration); +}); + +task ("build", ["default"]); + +task ("debug", function() +{ + ENV["CONFIGURATION"] = "Debug"; + JAKE.subjake(["."], "build", ENV); +}); + +task ("release", function() +{ + ENV["CONFIGURATION"] = "Release"; + JAKE.subjake(["."], "build", ENV); +}); + +task ("run", ["debug"], function() +{ + OS.system(["open", FILE.join("Build", "Debug", "tooltips", "index.html")]); +}); + +task ("run-release", ["release"], function() +{ + OS.system(["open", FILE.join("Build", "Release", "tooltips", "index.html")]); +}); + +task ("deploy", ["release"], function() +{ + FILE.mkdirs(FILE.join("Build", "Deployment", "tooltips")); + OS.system(["press", "-f", FILE.join("Build", "Release", "tooltips"), FILE.join("Build", "Deployment", "tooltips")]); + printResults("Deployment") +}); + +task ("desktop", ["release"], function() +{ + FILE.mkdirs(FILE.join("Build", "Desktop", "tooltips")); + require("cappuccino/nativehost").buildNativeHost(FILE.join("Build", "Release", "tooltips"), FILE.join("Build", "Desktop", "tooltips", "tooltips.app")); + printResults("Desktop") +}); + +task ("run-desktop", ["desktop"], function() +{ + OS.system([FILE.join("Build", "Desktop", "tooltips", "tooltips.app", "Contents", "MacOS", "NativeHost"), "-i"]); +}); + +function printResults(configuration) +{ + print("----------------------------"); + print(configuration+" app built at path: "+FILE.join("Build", configuration, "tooltips")); + print("----------------------------"); +} diff --git a/Tests/Manual/CPTreeControllerTest/index-debug.html b/Tests/Manual/CPTreeControllerTest/index-debug.html new file mode 100644 index 000000000..a36b1d3b9 --- /dev/null +++ b/Tests/Manual/CPTreeControllerTest/index-debug.html @@ -0,0 +1,204 @@ + + + + + + + + + + + + + + + + + + __project.name__ + + + + + + + + + + + + + + +
+
+
+ +
+
+ +
+ + diff --git a/Tests/Manual/CPTreeControllerTest/index.html b/Tests/Manual/CPTreeControllerTest/index.html new file mode 100644 index 000000000..ac42c98a7 --- /dev/null +++ b/Tests/Manual/CPTreeControllerTest/index.html @@ -0,0 +1,166 @@ + + + + + + + + + + + + + + + + + + __project.name__ + + + + + + + + + + + + +
+
+
+ +
+
+ +
+ + diff --git a/Tests/Manual/CPTreeControllerTest/main.j b/Tests/Manual/CPTreeControllerTest/main.j new file mode 100644 index 000000000..9e6a15286 --- /dev/null +++ b/Tests/Manual/CPTreeControllerTest/main.j @@ -0,0 +1,18 @@ +/* + * AppController.j + * tooltips + * + * Created by You on April 26, 2011. + * Copyright 2011, Your Company All rights reserved. + */ + +@import +@import + +@import "AppController.j" + + +function main(args, namedArgs) +{ + CPApplicationMain(args, namedArgs); +}