From 622d738b44a7824a7cc50dd504c5c49e8f156e80 Mon Sep 17 00:00:00 2001 From: daboe01 Date: Sat, 7 Mar 2026 19:25:32 +0100 Subject: [PATCH 01/10] new: CPTreeController --- AppKit/CPTreeController.j | 674 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 674 insertions(+) create mode 100644 AppKit/CPTreeController.j diff --git a/AppKit/CPTreeController.j b/AppKit/CPTreeController.j new file mode 100644 index 000000000..f1bee03fc --- /dev/null +++ b/AppKit/CPTreeController.j @@ -0,0 +1,674 @@ +/* + * CPTreeController.j + * AppKit + * + * Adapted for Cappuccino + * + * 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" + +/*! + @class CPTreeController + + CPTreeController is a bindings-compatible class that manages a tree of objects. + It provides selection and sort management for hierarchical data. + */ +@implementation CPTreeController : CPObjectController +{ + BOOL _avoidsEmptySelection; + BOOL _preservesSelection; + BOOL _selectsInsertedObjects; + BOOL _alwaysUsesMultipleValuesMarker; + + CPString _childrenKeyPath; + CPString _countKeyPath; + CPString _leafKeyPath; + + CPArray _sortDescriptors; + id _arrangedObjects; // The proxy root node containing the tree + + CPArray _selectionIndexPaths; // Array of CPIndexPath objects + + 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 +{ + self = [super init]; + + if (self) + { + _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:[[self newObject]]]; +} + +// --- Properties --- + +- (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]; +} + +// --- Key Paths --- + +- (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; +} + +// --- Node Key Path Overrides --- + +- (CPString)childrenKeyPathForNode:(CPTreeNode)node +{ + return [self childrenKeyPath]; +} + +- (CPString)countKeyPathForNode:(CPTreeNode)node +{ + return [self countKeyPath]; +} + +- (CPString)leafKeyPathForNode:(CPTreeNode)node +{ + return [self leafKeyPath]; +} + +// --- Content and Arranged Objects --- + +- (void)setContent:(id)value +{ + if (_disableSetContent) + return; + + if (value == nil) + value = []; + + if (![value isKindOfClass:[CPArray class]]) + value = [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[self content]; +} + +- (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]; + + // Rebuild the proxy tree. In a full implementation, this observes children using _childrenKeyPath + // and applies _sortDescriptors recursively.[self __rebuildArrangedObjectsTree]; + + if ([self preservesSelection]) + [self __setSelectedObjects:oldSelectedObjects]; + else + [self __setSelectionIndexPaths:oldSelectionIndexPaths avoidEmpty:_avoidsEmptySelection]; +} + +- (void)__rebuildArrangedObjectsTree +{ + // A simplified rebuilding logic: we set the content as the represented object's children + // of the root proxy node. + var rootNode = [[CPTreeNode alloc] initWithRepresentedObject:nil]; + var contentArray = [self contentArray]; + + // Sort top level if needed + var sortedContent = contentArray; + if ([_sortDescriptors count] > 0) + sortedContent = [contentArray sortedArrayUsingDescriptors:_sortDescriptors]; + + var count = [sortedContent count]; + var children = [CPMutableArray arrayWithCapacity:count]; + + for (var i = 0; i < count; i++) + { + var node = [[CPTreeNode alloc] initWithRepresentedObject:sortedContent[i]]; + // Note: Recursive population and sorting of children based on _childrenKeyPath + // would occur here in a complete tree parser.[children addObject:node]; + } + + [[rootNode mutableChildNodes] addObjectsFromArray:children]; + _arrangedObjects = rootNode; +} + +// --- Selection Management --- + +- (CPIndexPath)selectionIndexPath +{ + return [_selectionIndexPaths count] > 0 ? [_selectionIndexPaths objectAtIndex:0] : nil; +} + +- (BOOL)setSelectionIndexPath:(CPIndexPath)indexPath +{ + var paths = indexPath ? [indexPath] : []; + 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 = []; + + if (![newPaths count] && avoidEmpty) + { + if ([[[self arrangedObjects] childNodes] count] > 0) + newPaths = [[CPIndexPath indexPathWithIndex:0]]; + } + + if ([_selectionIndexPaths isEqualToArray:newPaths]) + return NO; + + _selectionIndexPaths = [newPaths copy]; + + var binderClass = [[self class] _binderClassForBinding:@"selectionIndexPaths"]; + [[binderClass getBinding:@"selectionIndexPaths" forObject:self] reverseSetValueFor:@"selectionIndexPaths"]; + + return YES; +} + +- (BOOL)addSelectionIndexPaths:(CPArray)indexPaths +{ + var newPaths = [_selectionIndexPaths mutableCopy]; + [newPaths addObjectsFromArray:indexPaths]; + // Remove duplicates and maintain sorted order + // ... + return[self setSelectionIndexPaths:newPaths]; +} + +- (BOOL)removeSelectionIndexPaths:(CPArray)indexPaths +{ + var newPaths = [_selectionIndexPaths mutableCopy]; + [newPaths removeObjectsInArray:indexPaths]; + return [self setSelectionIndexPaths:newPaths]; +} + +- (CPArray)selectedNodes +{ + var nodes = [], + count = [_selectionIndexPaths count]; + + for (var i = 0; i < count; i++) + { + var node = [[self arrangedObjects] descendantNodeAtIndexPath:_selectionIndexPaths[i]]; + if (node)[nodes addObject:node]; + } + return nodes; +} + +- (CPArray)selectedObjects +{ + var objects = [], + nodes = [self selectedNodes], + count = [nodes count]; + + for (var i = 0; i < count; i++) + [objects addObject:[nodes[i] representedObject]]; + + return objects; +} + +- (BOOL)__setSelectedObjects:(CPArray)objects +{ + // Search the tree for index paths matching the passed objects and update selection + // (Omitted recursive search for brevity) + return YES; +} + +// --- Adding, Inserting, Removing --- + +- (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]; + + var selectionPath = [self selectionIndexPath] || [CPIndexPath indexPathWithIndex:[[[self arrangedObjects] childNodes] count]]; + + // Increment the last index by 1 to add *after* the current selection + var length = [selectionPath length], + lastIndex = [selectionPath indexAtPosition:length - 1]; + + var 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]; + + var 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]; + var 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:[anObject] atArrangedObjectIndexPaths:[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[i], + path = indexPaths[i], + length = [path length]; + + if (length === 1) + { + // Insert at root level + [_contentObject insertObject:object atIndex:[path indexAtPosition:0]]; + } + else + { + // Insert into a parent node's children + var parentPath = [path indexPathByRemovingLastIndex], + parentNode = [[self arrangedObjects] descendantNodeAtIndexPath:parentPath], + parentObj = [parentNode representedObject], + childIndex = [path indexAtPosition:length - 1]; + + var children = [parentObj valueForKeyPath:_childrenKeyPath]; + if (!children) + { + children = [CPMutableArray array]; + [parentObj setValue:children forKeyPath:_childrenKeyPath]; + } + [children insertObject:object atIndex:childIndex]; + } + } + + [[CPBinder 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:[indexPath]]; +} + +- (void)removeObjectsAtArrangedObjectIndexPaths:(CPArray)indexPaths +{ + [self willChangeValueForKey:@"content"]; + _disableSetContent = YES; + + // Remove in reverse order to prevent shifting indices from invalidating remaining paths + var sortedPaths = [indexPaths sortedArrayUsingSelector:@selector(compare:)], + count = [sortedPaths count]; + + for (var i = count - 1; i >= 0; i--) + { + var path = sortedPaths[i], + length = [path length]; + + if (length === 1) + { + [_contentObject removeObjectAtIndex:[path indexAtPosition:0]]; + } + else + { + var parentPath = [path indexPathByRemovingLastIndex], + parentNode = [[self arrangedObjects] descendantNodeAtIndexPath:parentPath], + parentObj = [parentNode representedObject], + childIndex = [path indexAtPosition:length - 1]; + + var children = [parentObj valueForKeyPath:_childrenKeyPath]; + if (children) + [children removeObjectAtIndex:childIndex]; + } + } + + [[CPBinder getBinding:@"contentArray" forObject:self] _contentArrayDidChange]; + _disableSetContent = NO; + + [self _rearrangeObjects]; + [self didChangeValueForKey:@"content"]; +} + +- (void)moveNode:(CPTreeNode)node toIndexPath:(CPIndexPath)indexPath +{ + [self moveNodes:[node] toIndexPath:indexPath]; +} + +- (void)moveNodes:(CPArray)nodes toIndexPath:(CPIndexPath)startingIndexPath +{ + // A proper implementation handles repositioning inside the tree while maintaining object state[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:[]]; + } + + 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 + From 12960f8160d64b38b37ebea55eaa8f5a008ac1c9 Mon Sep 17 00:00:00 2001 From: daboe01 Date: Sat, 7 Mar 2026 19:27:01 +0100 Subject: [PATCH 02/10] new: testcase --- Tests/AppKit/CPTreeControllerTest.j | 250 ++++++++++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 Tests/AppKit/CPTreeControllerTest.j diff --git a/Tests/AppKit/CPTreeControllerTest.j b/Tests/AppKit/CPTreeControllerTest.j new file mode 100644 index 000000000..4d2f3fd3f --- /dev/null +++ b/Tests/AppKit/CPTreeControllerTest.j @@ -0,0 +1,250 @@ +/* + * CPTreeControllerTest.j + * + * Test suite for CPTreeController, adapted for Cappuccino + */ + +@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 +{ + // Root Level + var engineering = [OrgNode nodeWithName:@"Engineering"], + marketing =[OrgNode nodeWithName:@"Marketing"]; + + // Children of Engineering + var webTeam = [OrgNode nodeWithName:@"Web Team"], + backendTeam = [OrgNode nodeWithName:@"Backend Team"]; + + [engineering setChildren:[CPMutableArray arrayWithObjects:webTeam, backendTeam]]; + + // Children of Web Team + var dev1 =[OrgNode nodeWithName:@"Francisco"], + dev2 = [OrgNode nodeWithName:@"Ross"]; + + [webTeam setChildren:[CPMutableArray arrayWithObjects:dev1, dev2]]; + + return [CPMutableArray arrayWithObjects:engineering, marketing]; +} + +- (void)setUp +{ + // Init global CPApp used internally in AppKit + [[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:[] 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]; + + // Select Engineering -> Web Team (Index Path: [0, 0]) + 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[0] representedObject] name]]; +} + +- (void)testAddChild +{ + var controller = [self treeController]; + [controller setObjectClass:[OrgNode class]]; + + // Select Engineering -> Backend Team (Index Path:[0, 1]) + var parentPath = [[CPIndexPath indexPathWithIndex:0] indexPathByAddingIndex:1]; + [controller setSelectionIndexPath:parentPath]; + + // Insert a child into Backend Team + var newDev = [OrgNode nodeWithName:@"Tom"]; + var insertPath =[parentPath indexPathByAddingIndex:0]; + + [controller insertObject:newDev atArrangedObjectIndexPath:insertPath]; + + // Validate it was added to the content + 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]; + + // Insert at root level, index 1 (between Engineering and Marketing) + 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]]; + + // Insert nested (Engineering -> HR) + 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]; + + // Remove Engineering -> Web Team -> Francisco (Index Path: [0, 0, 0]) + 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]; + + // Remove both Engineering (0) and Marketing (1) + var paths = [[CPIndexPath indexPathWithIndex:0], + [CPIndexPath indexPathWithIndex:1] + ]; + + [controller removeObjectsAtArrangedObjectIndexPaths:paths]; + + [self assert:0 equals:[[controller contentArray] count] message:@"All root nodes should be removed"]; +} + +- (void)testAvoidsEmptySelection +{ + var controller = [self treeController];[controller setAvoidsEmptySelection:YES]; + + // Set empty selection manually + [controller setSelectionIndexPaths:[]]; + + [self assertTrue:([[controller selectionIndexPaths] count] == 1) message:@"Selection should fallback to the first item when avoidsEmptySelection is YES"]; + [self assert:[CPIndexPath indexPathWithIndex:0] equals:[controller selectionIndexPath]]; + + [controller setAvoidsEmptySelection:NO];[controller setSelectionIndexPaths:[]]; + + [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]; + // Use a custom key path + [controller setChildrenKeyPath:@"subItems"]; + + var data =[OrgNode nodeWithName:@"Root"]; + [data setValue:[CPMutableArray arrayWithObject:[OrgNode nodeWithName:@"Sub"]] forKey:@"subItems"]; + + [controller setContent:[CPMutableArray arrayWithObject:data]]; + + // Insert at [0, 0] + 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]]; + + // Verify proxy tree rebuilt + [self assert:2 equals:[[[controller arrangedObjects] childNodes] count]]; +} + +- (void)testSelectedObjects +{ + var controller =[self treeController]; + + // Select Marketing [1] + var path =[CPIndexPath indexPathWithIndex:1]; + [controller setSelectionIndexPath:path]; + + var selectedObjects =[controller selectedObjects]; + [self assert:1 equals:[selectedObjects count]];[self assert:@"Marketing" equals:[selectedObjects[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); // For testing custom key paths +} + ++ (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 \ No newline at end of file From 10d18e35ede6d0ccbbf49e5a00f07ace3f09f4b1 Mon Sep 17 00:00:00 2001 From: daboe01 Date: Sat, 7 Mar 2026 19:28:04 +0100 Subject: [PATCH 03/10] formatting --- AppKit/CPTreeController.j | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AppKit/CPTreeController.j b/AppKit/CPTreeController.j index f1bee03fc..3f5246494 100644 --- a/AppKit/CPTreeController.j +++ b/AppKit/CPTreeController.j @@ -2,7 +2,7 @@ * CPTreeController.j * AppKit * - * Adapted for Cappuccino + * 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 From 4970a2742d90e1bdc61ea2a5d88444a04f71d539 Mon Sep 17 00:00:00 2001 From: daboe01 Date: Sat, 7 Mar 2026 19:47:31 +0100 Subject: [PATCH 04/10] fixed: recursive tree building --- AppKit/CPTreeController.j | 97 +++++++++++++++++++++++---------------- 1 file changed, 57 insertions(+), 40 deletions(-) diff --git a/AppKit/CPTreeController.j b/AppKit/CPTreeController.j index 3f5246494..3274d2a08 100644 --- a/AppKit/CPTreeController.j +++ b/AppKit/CPTreeController.j @@ -264,7 +264,8 @@ [self _rearrangeObjects]; - if ([self preservesSelection])[self __setSelectedObjects:oldSelectedObjects]; + if ([self preservesSelection]) + [self __setSelectedObjects:oldSelectedObjects]; else[self __setSelectionIndexPaths:oldSelectionIndexPaths avoidEmpty:_avoidsEmptySelection]; } @@ -275,7 +276,7 @@ - (id)contentArray { - return[self content]; + return [self content]; } - (id)arrangedObjects @@ -300,8 +301,7 @@ else oldSelectionIndexPaths = [self selectionIndexPaths]; - // Rebuild the proxy tree. In a full implementation, this observes children using _childrenKeyPath - // and applies _sortDescriptors recursively.[self __rebuildArrangedObjectsTree]; + // Rebuild the proxy tree from the content using _childrenKeyPath and _sortDescriptors[self __rebuildArrangedObjectsTree]; if ([self preservesSelection]) [self __setSelectedObjects:oldSelectedObjects]; @@ -311,28 +311,50 @@ - (void)__rebuildArrangedObjectsTree { - // A simplified rebuilding logic: we set the content as the represented object's children - // of the root proxy node. var rootNode = [[CPTreeNode alloc] initWithRepresentedObject:nil]; var contentArray = [self contentArray]; - // Sort top level if needed - var sortedContent = contentArray; - if ([_sortDescriptors count] > 0) - sortedContent = [contentArray sortedArrayUsingDescriptors:_sortDescriptors]; + if (contentArray && [contentArray count] > 0) + { + var children = [self _buildTreeNodesForObjects:contentArray]; + [[rootNode mutableChildNodes] addObjectsFromArray:children]; + } - var count = [sortedContent count]; - var children = [CPMutableArray arrayWithCapacity:count]; + _arrangedObjects = rootNode; +} + +// Recursively builds the node tree mapping model objects to CPTreeNodes +- (CPArray)_buildTreeNodesForObjects:(CPArray)objects +{ + var count = [objects count]; + if (count === 0) + return[]; + + var sortedObjects = objects; + if ([_sortDescriptors count] > 0) + sortedObjects = [objects sortedArrayUsingDescriptors:_sortDescriptors]; + + var nodes = [CPMutableArray arrayWithCapacity:count]; for (var i = 0; i < count; i++) { - var node = [[CPTreeNode alloc] initWithRepresentedObject:sortedContent[i]]; - // Note: Recursive population and sorting of children based on _childrenKeyPath - // would occur here in a complete tree parser.[children addObject:node]; + var obj = sortedObjects[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]; } - [[rootNode mutableChildNodes] addObjectsFromArray:children]; - _arrangedObjects = rootNode; + return nodes; } // --- Selection Management --- @@ -389,16 +411,15 @@ { var newPaths = [_selectionIndexPaths mutableCopy]; [newPaths addObjectsFromArray:indexPaths]; - // Remove duplicates and maintain sorted order - // ... - return[self setSelectionIndexPaths:newPaths]; + // Remove duplicates and maintain sorted order (omitted for brevity) + return [self setSelectionIndexPaths:newPaths]; } - (BOOL)removeSelectionIndexPaths:(CPArray)indexPaths { var newPaths = [_selectionIndexPaths mutableCopy]; [newPaths removeObjectsInArray:indexPaths]; - return [self setSelectionIndexPaths:newPaths]; + return[self setSelectionIndexPaths:newPaths]; } - (CPArray)selectedNodes @@ -409,7 +430,8 @@ for (var i = 0; i < count; i++) { var node = [[self arrangedObjects] descendantNodeAtIndexPath:_selectionIndexPaths[i]]; - if (node)[nodes addObject:node]; + if (node) + [nodes addObject:node]; } return nodes; } @@ -437,7 +459,7 @@ - (BOOL)canInsert { - return [self isEditable]; + return[self isEditable]; } - (BOOL)canInsertChild @@ -473,11 +495,12 @@ if (![self canAddChild]) return; - var newObject = [self automaticallyPreparesContent] ? [self newObject] : [self _defaultNewObject], + var newObject = [self automaticallyPreparesContent] ?[self newObject] : [self _defaultNewObject], parentNode = [[self arrangedObjects] descendantNodeAtIndexPath:[self selectionIndexPath]], childCount = [[parentNode childNodes] count]; var insertPath = [[self selectionIndexPath] indexPathByAddingIndex:childCount]; + [self insertObject:newObject atArrangedObjectIndexPath:insertPath]; } @@ -486,7 +509,7 @@ if (![self canInsert]) return; - var newObject = [self automaticallyPreparesContent] ? [self newObject] :[self _defaultNewObject]; + var newObject = [self automaticallyPreparesContent] ?[self newObject] : [self _defaultNewObject]; var indexPath = [self selectionIndexPath] ||[CPIndexPath indexPathWithIndex:0]; [self insertObject:newObject atArrangedObjectIndexPath:indexPath]; @@ -497,19 +520,18 @@ if (![self canInsertChild]) return; - var newObject = [self automaticallyPreparesContent] ? [self newObject] :[self _defaultNewObject], + 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:[anObject] atArrangedObjectIndexPaths:[indexPath]]; +{[self insertObjects:[anObject] atArrangedObjectIndexPaths:[indexPath]]; } - (void)insertObjects:(CPArray)objects atArrangedObjectIndexPaths:(CPArray)indexPaths -{ - [self willChangeValueForKey:@"content"]; +{[self willChangeValueForKey:@"content"]; _disableSetContent = YES; var count = [objects count]; @@ -521,8 +543,7 @@ if (length === 1) { - // Insert at root level - [_contentObject insertObject:object atIndex:[path indexAtPosition:0]]; + // Insert at root level[_contentObject insertObject:object atIndex:[path indexAtPosition:0]]; } else { @@ -547,15 +568,13 @@ [self _rearrangeObjects]; - if ([self selectsInsertedObjects]) - [self setSelectionIndexPaths:indexPaths]; + if ([self selectsInsertedObjects])[self setSelectionIndexPaths:indexPaths]; [self didChangeValueForKey:@"content"]; } - (void)remove:(id)sender -{ - [self removeObjectsAtArrangedObjectIndexPaths:_selectionIndexPaths]; +{[self removeObjectsAtArrangedObjectIndexPaths:_selectionIndexPaths]; } - (void)removeObjectAtArrangedObjectIndexPath:(CPIndexPath)indexPath @@ -608,7 +627,7 @@ - (void)moveNodes:(CPArray)nodes toIndexPath:(CPIndexPath)startingIndexPath { - // A proper implementation handles repositioning inside the tree while maintaining object state[CPException raise:CPUnsupportedMethodException reason:@"moveNodes:toIndexPath: is not yet implemented in CPTreeController."]; + [CPException raise:CPUnsupportedMethodException reason:@"moveNodes:toIndexPath: is not yet implemented in CPTreeController."]; } @end @@ -644,8 +663,7 @@ CPTreeControllerLeafKeyPath = @"CPTreeControllerLeafKeyPath if (![self content] && [self automaticallyPreparesContent]) [self prepareContent]; - else if (![self content]) - [self _setContentArray:[]]; + else if (![self content])[self _setContentArray:[]]; } return self; @@ -671,4 +689,3 @@ CPTreeControllerLeafKeyPath = @"CPTreeControllerLeafKeyPath } @end - From 98c01b88e29e0dbae1297cc4aef3c0ea2014e053 Mon Sep 17 00:00:00 2001 From: daboe01 Date: Sat, 7 Mar 2026 20:00:09 +0100 Subject: [PATCH 05/10] fixed various binding issues --- AppKit/CPTreeController.j | 377 +++++++++++----------------- Tests/AppKit/CPTreeControllerTest.j | 159 ++++++------ 2 files changed, 224 insertions(+), 312 deletions(-) diff --git a/AppKit/CPTreeController.j b/AppKit/CPTreeController.j index 3274d2a08..aefaa0660 100644 --- a/AppKit/CPTreeController.j +++ b/AppKit/CPTreeController.j @@ -25,12 +25,6 @@ @import "CPKeyValueBinding.j" @import "CPTreeNode.j" -/*! - @class CPTreeController - - CPTreeController is a bindings-compatible class that manages a tree of objects. - It provides selection and sort management for hierarchical data. - */ @implementation CPTreeController : CPObjectController { BOOL _avoidsEmptySelection; @@ -43,10 +37,9 @@ CPString _leafKeyPath; CPArray _sortDescriptors; - id _arrangedObjects; // The proxy root node containing the tree - - CPArray _selectionIndexPaths; // Array of CPIndexPath objects + id _arrangedObjects; + CPArray _selectionIndexPaths; BOOL _disableSetContent; } @@ -91,7 +84,7 @@ + (CPSet)keyPathsForValuesAffectingCanInsert { - return[CPSet setWithObjects:@"selectionIndexPaths"]; + return [CPSet setWithObjects:@"selectionIndexPaths"]; } + (CPSet)keyPathsForValuesAffectingCanInsertChild @@ -101,20 +94,16 @@ - (id)init { - self = [super init]; - - if (self) + if (self = [super init]) { _preservesSelection = YES; _selectsInsertedObjects = YES; _avoidsEmptySelection = YES; _alwaysUsesMultipleValuesMarker = NO; - _childrenKeyPath = @"children"; [self _init]; } - return self; } @@ -126,57 +115,22 @@ } - (void)prepareContent -{ - [self _setContentArray:[[self newObject]]]; +{[self _setContentArray:[CPArray arrayWithObject:[self newObject]]]; } -// --- Properties --- +- (BOOL)preservesSelection { return _preservesSelection; } +- (void)setPreservesSelection:(BOOL)value { _preservesSelection = value; } -- (BOOL)preservesSelection -{ - return _preservesSelection; -} +- (BOOL)selectsInsertedObjects { return _selectsInsertedObjects; } +- (void)setSelectsInsertedObjects:(BOOL)value { _selectsInsertedObjects = value; } -- (void)setPreservesSelection:(BOOL)value -{ - _preservesSelection = value; -} +- (BOOL)avoidsEmptySelection { return _avoidsEmptySelection; } +- (void)setAvoidsEmptySelection:(BOOL)value { _avoidsEmptySelection = 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; -} +- (BOOL)alwaysUsesMultipleValuesMarker { return _alwaysUsesMultipleValuesMarker; } +- (void)setAlwaysUsesMultipleValuesMarker:(BOOL)aFlag { _alwaysUsesMultipleValuesMarker = aFlag; } +- (CPArray)sortDescriptors { return _sortDescriptors; } - (void)setSortDescriptors:(CPArray)value { if (_sortDescriptors === value) @@ -186,71 +140,31 @@ [self _rearrangeObjects]; } -// --- Key Paths --- - -- (CPString)childrenKeyPath -{ - return _childrenKeyPath; -} - +- (CPString)childrenKeyPath { return _childrenKeyPath; } - (void)setChildrenKeyPath:(CPString)aKeyPath { - if (_childrenKeyPath === aKeyPath) - return; - - _childrenKeyPath = aKeyPath; - [self rearrangeObjects]; + if (_childrenKeyPath === aKeyPath) return; + _childrenKeyPath = aKeyPath;[self rearrangeObjects]; } -- (CPString)countKeyPath -{ - return _countKeyPath; -} +- (CPString)countKeyPath { return _countKeyPath; } +- (void)setCountKeyPath:(CPString)aKeyPath { _countKeyPath = aKeyPath; } -- (void)setCountKeyPath:(CPString)aKeyPath -{ - _countKeyPath = aKeyPath; -} +- (CPString)leafKeyPath { return _leafKeyPath; } +- (void)setLeafKeyPath:(CPString)aKeyPath { _leafKeyPath = aKeyPath; } -- (CPString)leafKeyPath -{ - return _leafKeyPath; -} - -- (void)setLeafKeyPath:(CPString)aKeyPath -{ - _leafKeyPath = aKeyPath; -} - -// --- Node Key Path Overrides --- - -- (CPString)childrenKeyPathForNode:(CPTreeNode)node -{ - return [self childrenKeyPath]; -} - -- (CPString)countKeyPathForNode:(CPTreeNode)node -{ - return [self countKeyPath]; -} - -- (CPString)leafKeyPathForNode:(CPTreeNode)node -{ - return [self leafKeyPath]; -} - -// --- Content and Arranged Objects --- +- (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 == nil) - value = []; + if (_disableSetContent) return; + if (!value) + value = [CPArray array]; if (![value isKindOfClass:[CPArray class]]) - value = [value]; + value = [CPArray arrayWithObject:value]; var oldSelectedObjects = nil, oldSelectionIndexPaths = nil; @@ -264,25 +178,13 @@ [self _rearrangeObjects]; - if ([self preservesSelection]) - [self __setSelectedObjects:oldSelectedObjects]; + if ([self preservesSelection])[self __setSelectedObjects:oldSelectedObjects]; else[self __setSelectionIndexPaths:oldSelectionIndexPaths avoidEmpty:_avoidsEmptySelection]; } -- (void)_setContentArray:(id)anArray -{ - [self setContent:anArray]; -} - -- (id)contentArray -{ - return [self content]; -} - -- (id)arrangedObjects -{ - return _arrangedObjects; -} +- (void)_setContentArray:(id)anArray {[self setContent:anArray]; } +- (id)contentArray { return [self content]; } +- (id)arrangedObjects { return _arrangedObjects; } - (void)rearrangeObjects { @@ -301,18 +203,16 @@ else oldSelectionIndexPaths = [self selectionIndexPaths]; - // Rebuild the proxy tree from the content using _childrenKeyPath and _sortDescriptors[self __rebuildArrangedObjectsTree]; + [self __rebuildArrangedObjectsTree]; - if ([self preservesSelection]) - [self __setSelectedObjects:oldSelectedObjects]; - else - [self __setSelectionIndexPaths:oldSelectionIndexPaths avoidEmpty:_avoidsEmptySelection]; + if ([self preservesSelection])[self __setSelectedObjects:oldSelectedObjects]; + else[self __setSelectionIndexPaths:oldSelectionIndexPaths avoidEmpty:_avoidsEmptySelection]; } - (void)__rebuildArrangedObjectsTree { - var rootNode = [[CPTreeNode alloc] initWithRepresentedObject:nil]; - var contentArray = [self contentArray]; + var rootNode = [[CPTreeNode alloc] initWithRepresentedObject:nil], + contentArray = [self contentArray]; if (contentArray && [contentArray count] > 0) { @@ -323,22 +223,20 @@ _arrangedObjects = rootNode; } -// Recursively builds the node tree mapping model objects to CPTreeNodes - (CPArray)_buildTreeNodesForObjects:(CPArray)objects { var count = [objects count]; - if (count === 0) - return[]; + if (count === 0) return [CPArray array]; var sortedObjects = objects; - if ([_sortDescriptors count] > 0) + if (_sortDescriptors && [_sortDescriptors count] > 0) sortedObjects = [objects sortedArrayUsingDescriptors:_sortDescriptors]; var nodes = [CPMutableArray arrayWithCapacity:count]; for (var i = 0; i < count; i++) { - var obj = sortedObjects[i], + var obj = [sortedObjects objectAtIndex:i], node = [[CPTreeNode alloc] initWithRepresentedObject:obj]; if (_childrenKeyPath) @@ -349,16 +247,12 @@ var childNodes = [self _buildTreeNodesForObjects:childObjects]; [[node mutableChildNodes] addObjectsFromArray:childNodes]; } - } - - [nodes addObject:node]; + }[nodes addObject:node]; } return nodes; } -// --- Selection Management --- - - (CPIndexPath)selectionIndexPath { return [_selectionIndexPaths count] > 0 ? [_selectionIndexPaths objectAtIndex:0] : nil; @@ -366,20 +260,18 @@ - (BOOL)setSelectionIndexPath:(CPIndexPath)indexPath { - var paths = indexPath ? [indexPath] : []; - return [self setSelectionIndexPaths:paths]; + var paths = indexPath ? [CPArray arrayWithObject:indexPath] : [CPArray array]; + return[self setSelectionIndexPaths:paths]; } -- (CPArray)selectionIndexPaths -{ - return _selectionIndexPaths; -} +- (CPArray)selectionIndexPaths { return _selectionIndexPaths; } - (BOOL)setSelectionIndexPaths:(CPArray)indexPaths { [self _selectionWillChange]; var result = [self __setSelectionIndexPaths:indexPaths avoidEmpty:NO]; [self _selectionDidChange]; + return result; } @@ -388,12 +280,12 @@ var newPaths = indexPaths; if (!newPaths) - newPaths = []; + newPaths = [CPArray array]; if (![newPaths count] && avoidEmpty) { if ([[[self arrangedObjects] childNodes] count] > 0) - newPaths = [[CPIndexPath indexPathWithIndex:0]]; + newPaths = [CPArray arrayWithObject:[CPIndexPath indexPathWithIndex:0]]; } if ([_selectionIndexPaths isEqualToArray:newPaths]) @@ -402,7 +294,11 @@ _selectionIndexPaths = [newPaths copy]; var binderClass = [[self class] _binderClassForBinding:@"selectionIndexPaths"]; - [[binderClass getBinding:@"selectionIndexPaths" forObject:self] reverseSetValueFor:@"selectionIndexPaths"]; + if (binderClass) + { + var binding = [binderClass getBinding:@"selectionIndexPaths" forObject:self]; + if (binding)[binding reverseSetValueFor:@"selectionIndexPaths"]; + } return YES; } @@ -410,8 +306,9 @@ - (BOOL)addSelectionIndexPaths:(CPArray)indexPaths { var newPaths = [_selectionIndexPaths mutableCopy]; + [newPaths addObjectsFromArray:indexPaths]; - // Remove duplicates and maintain sorted order (omitted for brevity) + return [self setSelectionIndexPaths:newPaths]; } @@ -424,12 +321,12 @@ - (CPArray)selectedNodes { - var nodes = [], + var nodes = [CPMutableArray array], count = [_selectionIndexPaths count]; for (var i = 0; i < count; i++) { - var node = [[self arrangedObjects] descendantNodeAtIndexPath:_selectionIndexPaths[i]]; + var node = [[self arrangedObjects] descendantNodeAtIndexPath:[_selectionIndexPaths objectAtIndex:i]]; if (node) [nodes addObject:node]; } @@ -438,55 +335,70 @@ - (CPArray)selectedObjects { - var objects = [], + var objects = [CPMutableArray array], nodes = [self selectedNodes], count = [nodes count]; for (var i = 0; i < count; i++) - [objects addObject:[nodes[i] representedObject]]; + [objects addObject:[[nodes objectAtIndex:i] representedObject]]; return objects; } - (BOOL)__setSelectedObjects:(CPArray)objects { - // Search the tree for index paths matching the passed objects and update selection - // (Omitted recursive search for brevity) - return YES; + 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]; } -// --- Adding, Inserting, Removing --- - -- (BOOL)canInsert +- (CPIndexPath)_indexPathForObject:(id)anObject inNode:(CPTreeNode)node { - return[self isEditable]; + 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)canInsertChild -{ - return [self isEditable] && [_selectionIndexPaths count] > 0; -} - -- (BOOL)canAddChild -{ - return [self canInsertChild]; -} +- (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; + if (![self canInsert]) return; - var newObject = [self automaticallyPreparesContent] ? [self newObject] : [self _defaultNewObject]; + var newObject = [self automaticallyPreparesContent] ? [self newObject] :[self _defaultNewObject], + selectionPath = [self selectionIndexPath]; - var selectionPath = [self selectionIndexPath] || [CPIndexPath indexPathWithIndex:[[[self arrangedObjects] childNodes] count]]; + if (!selectionPath) + selectionPath = [CPIndexPath indexPathWithIndex:[[[self arrangedObjects] childNodes] count]]; - // Increment the last index by 1 to add *after* the current selection var length = [selectionPath length], - lastIndex = [selectionPath indexAtPosition:length - 1]; + lastIndex = [selectionPath indexAtPosition:length - 1], + insertPath = [selectionPath indexPathByRemovingLastIndex]; - var insertPath = [selectionPath indexPathByRemovingLastIndex]; insertPath = [insertPath indexPathByAddingIndex:lastIndex + 1]; + [self insertObject:newObject atArrangedObjectIndexPath:insertPath]; } @@ -497,28 +409,25 @@ var newObject = [self automaticallyPreparesContent] ?[self newObject] : [self _defaultNewObject], parentNode = [[self arrangedObjects] descendantNodeAtIndexPath:[self selectionIndexPath]], - childCount = [[parentNode childNodes] count]; - - var insertPath = [[self selectionIndexPath] indexPathByAddingIndex:childCount]; + childCount = [[parentNode childNodes] count], + insertPath = [[self selectionIndexPath] indexPathByAddingIndex:childCount]; [self insertObject:newObject atArrangedObjectIndexPath:insertPath]; } - (void)insert:(id)sender { - if (![self canInsert]) - return; + if (![self canInsert]) return; - var newObject = [self automaticallyPreparesContent] ?[self newObject] : [self _defaultNewObject]; - var indexPath = [self selectionIndexPath] ||[CPIndexPath indexPathWithIndex:0]; + 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; + if (![self canInsertChild]) return; var newObject = [self automaticallyPreparesContent] ? [self newObject] : [self _defaultNewObject], insertPath = [[self selectionIndexPath] indexPathByAddingIndex:0]; @@ -527,7 +436,8 @@ } - (void)insertObject:(id)anObject atArrangedObjectIndexPath:(CPIndexPath)indexPath -{[self insertObjects:[anObject] atArrangedObjectIndexPaths:[indexPath]]; +{ + [self insertObjects:[CPArray arrayWithObject:anObject] atArrangedObjectIndexPaths:[CPArray arrayWithObject:indexPath]]; } - (void)insertObjects:(CPArray)objects atArrangedObjectIndexPaths:(CPArray)indexPaths @@ -537,35 +447,42 @@ var count = [objects count]; for (var i = 0; i < count; i++) { - var object = objects[i], - path = indexPaths[i], + var object = [objects objectAtIndex:i], + path = [indexPaths objectAtIndex:i], length = [path length]; if (length === 1) - { - // Insert at root level[_contentObject insertObject:object atIndex:[path indexAtPosition:0]]; + {[_contentObject insertObject:object atIndex:[path indexAtPosition:0]]; } else { - // Insert into a parent node's children var parentPath = [path indexPathByRemovingLastIndex], - parentNode = [[self arrangedObjects] descendantNodeAtIndexPath:parentPath], - parentObj = [parentNode representedObject], - childIndex = [path indexAtPosition:length - 1]; + parentNode = [[self arrangedObjects] descendantNodeAtIndexPath:parentPath]; - var children = [parentObj valueForKeyPath:_childrenKeyPath]; - if (!children) + if (parentNode) { - children = [CPMutableArray array]; - [parentObj setValue:children forKeyPath:_childrenKeyPath]; + 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]; } - [children insertObject:object atIndex:childIndex]; } } - [[CPBinder getBinding:@"contentArray" forObject:self] _contentArrayDidChange]; - _disableSetContent = NO; + var binding = [[self class] _binderClassForBinding:@"contentArray"]; + if (binding) + [[binding getBinding:@"contentArray" forObject:self] _contentArrayDidChange]; + _disableSetContent = NO; [self _rearrangeObjects]; if ([self selectsInsertedObjects])[self setSelectionIndexPaths:indexPaths]; @@ -574,12 +491,12 @@ } - (void)remove:(id)sender -{[self removeObjectsAtArrangedObjectIndexPaths:_selectionIndexPaths]; +{ + [self removeObjectsAtArrangedObjectIndexPaths:_selectionIndexPaths]; } - (void)removeObjectAtArrangedObjectIndexPath:(CPIndexPath)indexPath -{ - [self removeObjectsAtArrangedObjectIndexPaths:[indexPath]]; +{[self removeObjectsAtArrangedObjectIndexPaths:[CPArray arrayWithObject:indexPath]]; } - (void)removeObjectsAtArrangedObjectIndexPaths:(CPArray)indexPaths @@ -587,47 +504,50 @@ [self willChangeValueForKey:@"content"]; _disableSetContent = YES; - // Remove in reverse order to prevent shifting indices from invalidating remaining paths var sortedPaths = [indexPaths sortedArrayUsingSelector:@selector(compare:)], count = [sortedPaths count]; for (var i = count - 1; i >= 0; i--) { - var path = sortedPaths[i], + var path = [sortedPaths objectAtIndex:i], length = [path length]; if (length === 1) - { - [_contentObject removeObjectAtIndex:[path indexAtPosition:0]]; + {[_contentObject removeObjectAtIndex:[path indexAtPosition:0]]; } else { var parentPath = [path indexPathByRemovingLastIndex], - parentNode = [[self arrangedObjects] descendantNodeAtIndexPath:parentPath], - parentObj = [parentNode representedObject], - childIndex = [path indexAtPosition:length - 1]; + parentNode = [[self arrangedObjects] descendantNodeAtIndexPath:parentPath]; - var children = [parentObj valueForKeyPath:_childrenKeyPath]; - if (children) - [children removeObjectAtIndex:childIndex]; + if (parentNode) + { + var parentObj = [parentNode representedObject], + childIndex = [path indexAtPosition:length - 1], + mutableChildren = [parentObj mutableArrayValueForKeyPath:_childrenKeyPath]; + + if (mutableChildren && childIndex <[mutableChildren count]) + [mutableChildren removeObjectAtIndex:childIndex]; + } } } - [[CPBinder getBinding:@"contentArray" forObject:self] _contentArrayDidChange]; - _disableSetContent = NO; + 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:[node] toIndexPath: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."]; +{[CPException raise:CPUnsupportedMethodException reason:@"moveNodes:toIndexPath: is not yet implemented in CPTreeController."]; } @end @@ -663,7 +583,8 @@ CPTreeControllerLeafKeyPath = @"CPTreeControllerLeafKeyPath if (![self content] && [self automaticallyPreparesContent]) [self prepareContent]; - else if (![self content])[self _setContentArray:[]]; + else if (![self content]) + [self _setContentArray:[CPArray array]]; } return self; diff --git a/Tests/AppKit/CPTreeControllerTest.j b/Tests/AppKit/CPTreeControllerTest.j index 4d2f3fd3f..0538de760 100644 --- a/Tests/AppKit/CPTreeControllerTest.j +++ b/Tests/AppKit/CPTreeControllerTest.j @@ -1,7 +1,7 @@ /* * CPTreeControllerTest.j * - * Test suite for CPTreeController, adapted for Cappuccino + * Test suite for CPTreeController */ @import @@ -23,20 +23,17 @@ - (CPArray)makeTestTree { - // Root Level var engineering = [OrgNode nodeWithName:@"Engineering"], - marketing =[OrgNode nodeWithName:@"Marketing"]; + marketing = [OrgNode nodeWithName:@"Marketing"]; - // Children of Engineering var webTeam = [OrgNode nodeWithName:@"Web Team"], - backendTeam = [OrgNode nodeWithName:@"Backend Team"]; - + backendTeam = [OrgNode nodeWithName:@"Backend Team"]; + [engineering setChildren:[CPMutableArray arrayWithObjects:webTeam, backendTeam]]; - // Children of Web Team - var dev1 =[OrgNode nodeWithName:@"Francisco"], - dev2 = [OrgNode nodeWithName:@"Ross"]; - + var dev1 = [OrgNode nodeWithName:@"Francisco"], + dev2 = [OrgNode nodeWithName:@"Ross"]; + [webTeam setChildren:[CPMutableArray arrayWithObjects:dev1, dev2]]; return [CPMutableArray arrayWithObjects:engineering, marketing]; @@ -44,23 +41,25 @@ - (void)setUp { - // Init global CPApp used internally in AppKit [[CPApplication alloc] init]; _contentArray = [self makeTestTree]; _treeController = [[CPTreeController alloc] init]; - [_treeController setChildrenKeyPath:@"children"];[_treeController setContent:[_contentArray copy]]; + [_treeController setChildrenKeyPath:@"children"]; + [_treeController setContent:[_contentArray copy]]; } - (void)testInitWithContent -{[self assert:[_contentArray count] equals:[[_treeController contentArray] count]]; +{ + [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:[] equals:[emptyController contentArray]];[self assert:0 equals:[[[emptyController arrangedObjects] childNodes] count]]; + [self assert:[CPArray array] equals:[emptyController contentArray]]; + [self assert:0 equals:[[[emptyController arrangedObjects] childNodes] count]]; } - (void)testSetContent @@ -75,54 +74,50 @@ - (void)testSelectionPaths { var controller = [self treeController]; - - // Select Engineering -> Web Team (Index Path: [0, 0]) - var path = [[CPIndexPath indexPathWithIndex:0] indexPathByAddingIndex:0];[controller setSelectionIndexPath:path]; - - var selectedPath = [controller selectionIndexPath];[self assert:path equals:selectedPath]; - + + 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[0] representedObject] name]]; + [self assert:@"Web Team" equals:[[[selectedNodes objectAtIndex:0] representedObject] name]]; } - (void)testAddChild { var controller = [self treeController]; [controller setObjectClass:[OrgNode class]]; - - // Select Engineering -> Backend Team (Index Path:[0, 1]) + var parentPath = [[CPIndexPath indexPathWithIndex:0] indexPathByAddingIndex:1]; [controller setSelectionIndexPath:parentPath]; - - // Insert a child into Backend Team + var newDev = [OrgNode nodeWithName:@"Tom"]; - var insertPath =[parentPath indexPathByAddingIndex:0]; - + var insertPath = [parentPath indexPathByAddingIndex:0]; [controller insertObject:newDev atArrangedObjectIndexPath:insertPath]; - - // Validate it was added to the content + 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]]; + 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]; - - // Insert at root level, index 1 (between Engineering and Marketing) - var path =[CPIndexPath indexPathWithIndex:1]; - var hrDept = [OrgNode nodeWithName:@"Human Resources"];[controller insertObject:hrDept atArrangedObjectIndexPath:path]; - + + 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]]; - - // Insert nested (Engineering -> HR) + var nestedPath = [[CPIndexPath indexPathWithIndex:0] indexPathByAddingIndex:0]; - var subDept =[OrgNode nodeWithName:@"Sub Dept"]; - + 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"]; @@ -131,61 +126,56 @@ - (void)testRemoveObjectAtArrangedObjectIndexPath { var controller = [self treeController]; - - // Remove Engineering -> Web Team -> Francisco (Index Path: [0, 0, 0]) + 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"]; + 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]; - - // Remove both Engineering (0) and Marketing (1) - var paths = [[CPIndexPath indexPathWithIndex:0], - [CPIndexPath indexPathWithIndex:1] - ]; - + + 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)testAvoidsEmptySelection { - var controller = [self treeController];[controller setAvoidsEmptySelection:YES]; - - // Set empty selection manually - [controller setSelectionIndexPaths:[]]; - + var controller = [self treeController]; + [controller setAvoidsEmptySelection:YES]; + [controller setSelectionIndexPaths:[CPArray array]]; + [self assertTrue:([[controller selectionIndexPaths] count] == 1) message:@"Selection should fallback to the first item when avoidsEmptySelection is YES"]; [self assert:[CPIndexPath indexPathWithIndex:0] equals:[controller selectionIndexPath]]; - - [controller setAvoidsEmptySelection:NO];[controller setSelectionIndexPaths:[]]; - + [controller setAvoidsEmptySelection:NO]; + [controller setSelectionIndexPaths:[CPArray array]]; [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]; - // Use a custom key path [controller setChildrenKeyPath:@"subItems"]; - - var data =[OrgNode nodeWithName:@"Root"]; + + var data = [OrgNode nodeWithName:@"Root"]; [data setValue:[CPMutableArray arrayWithObject:[OrgNode nodeWithName:@"Sub"]] forKey:@"subItems"]; - + [controller setContent:[CPMutableArray arrayWithObject:data]]; - - // Insert at [0, 0] + var path = [[CPIndexPath indexPathWithIndex:0] indexPathByAddingIndex:0]; - var newItem = [OrgNode nodeWithName:@"New Sub"];[controller insertObject:newItem atArrangedObjectIndexPath:path]; - + 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"]; @@ -193,36 +183,36 @@ - (void)testContentBinding { - var controller = [[CPTreeController alloc] init];[controller bind:@"contentArray" toObject:self withKeyPath:@"contentArray" options:nil]; + var controller = [[CPTreeController alloc] init]; + + [controller bind:@"contentArray" toObject:self withKeyPath:@"contentArray" options:nil]; [self assert:[self contentArray] equals:[controller contentArray]]; - - // Verify proxy tree rebuilt [self assert:2 equals:[[[controller arrangedObjects] childNodes] count]]; } - (void)testSelectedObjects { - var controller =[self treeController]; - - // Select Marketing [1] - var path =[CPIndexPath indexPathWithIndex:1]; + 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[0] name]]; + + 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); // For testing custom key paths + CPMutableArray _subItems @accessors(property=subItems); } + (id)nodeWithName:(CPString)aName @@ -236,7 +226,7 @@ { _name = aName; _children = [CPMutableArray array]; - _subItems =[CPMutableArray array]; + _subItems = [CPMutableArray array]; } return self; @@ -244,7 +234,8 @@ - (CPString)description { - return [CPString stringWithFormat:@"", [self name]]; + return[CPString stringWithFormat:@"", [self name]]; } -@end \ No newline at end of file +@end + From cd4c3cffe087029ce8b29bf5c78a9772ba62a173 Mon Sep 17 00:00:00 2001 From: daboe01 Date: Sat, 7 Mar 2026 20:05:50 +0100 Subject: [PATCH 06/10] fixed: test cases --- Tests/AppKit/CPTreeControllerTest.j | 41 +++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/Tests/AppKit/CPTreeControllerTest.j b/Tests/AppKit/CPTreeControllerTest.j index 0538de760..bf94102c2 100644 --- a/Tests/AppKit/CPTreeControllerTest.j +++ b/Tests/AppKit/CPTreeControllerTest.j @@ -100,7 +100,6 @@ 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]]; } @@ -111,6 +110,7 @@ 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]]; @@ -142,23 +142,49 @@ { var controller = [self treeController]; - var paths = [CPArray arrayWithObjects:[CPIndexPath indexPathWithIndex:0],[CPIndexPath indexPathWithIndex:1]]; + 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)testAvoidsEmptySelection +- (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]; - [controller setSelectionIndexPaths:[CPArray array]]; + + 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]; - [controller setSelectionIndexPaths:[CPArray array]]; + + // 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"]; } @@ -168,8 +194,8 @@ [controller setChildrenKeyPath:@"subItems"]; var data = [OrgNode nodeWithName:@"Root"]; - [data setValue:[CPMutableArray arrayWithObject:[OrgNode nodeWithName:@"Sub"]] forKey:@"subItems"]; + [data setValue:[CPMutableArray arrayWithObject:[OrgNode nodeWithName:@"Sub"]] forKey:@"subItems"]; [controller setContent:[CPMutableArray arrayWithObject:data]]; var path = [[CPIndexPath indexPathWithIndex:0] indexPathByAddingIndex:0]; @@ -186,7 +212,6 @@ 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]]; } @@ -199,6 +224,7 @@ [controller setSelectionIndexPath:path]; var selectedObjects = [controller selectedObjects]; + [self assert:1 equals:[selectedObjects count]]; [self assert:@"Marketing" equals:[[selectedObjects objectAtIndex:0] name]]; } @@ -238,4 +264,3 @@ } @end - From b1f2fb44a82be68930c8f373f6d05686bedd2ae1 Mon Sep 17 00:00:00 2001 From: daboe01 Date: Sat, 7 Mar 2026 21:43:41 +0100 Subject: [PATCH 07/10] new: CPOutlineView bindings support --- AppKit/CPOutlineView+CPBindings.j | 229 ++++++++++++++++++++++++++++ AppKit/CPOutlineView.j | 2 + Tests/AppKit/CPTreeControllerTest.j | 2 +- 3 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 AppKit/CPOutlineView+CPBindings.j diff --git a/AppKit/CPOutlineView+CPBindings.j b/AppKit/CPOutlineView+CPBindings.j new file mode 100644 index 000000000..658ff777e --- /dev/null +++ b/AppKit/CPOutlineView+CPBindings.j @@ -0,0 +1,229 @@ +/* + * CPOutlineView+CPBindings.j + * AppKit + * + * Adds Cocoa Bindings support to CPOutlineView. + */ + +@import +@import +@import +@import +@import "CPKeyValueBinding.j" +@import "CPTreeNode.j" + +@class CPOutlineView; + +@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) + ++ (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 +{ + [super bind]; + [_source setDataSource:self]; +} + +- (void)unbind +{ + if ([_source dataSource] === self) + [_source setDataSource:nil]; + + [super unbind]; +} + +- (void)updateSource +{ + var value = [self valueForBinding:CPObservedKeyPathKey]; + + if (!value || ![value isKindOfClass:[CPTreeNode class]]) + _rootNode = [[CPTreeNode alloc] initWithRepresentedObject:nil]; + else + _rootNode = value; + + [_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 +{ + // Normally column values are resolved via the table column's own bindings, + // but we return the represented object here as a standard fallback for cell-based tables. + 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 + +- (void)bind +{ + [super bind]; + + // Observe selection changes originating from the user clicking the outline view + [[CPNotificationCenter defaultCenter] + addObserver:self + selector:@selector(outlineViewSelectionDidChange:) + name:CPOutlineViewSelectionDidChangeNotification + object:_source]; +} + +- (void)unbind +{ + [[CPNotificationCenter defaultCenter] + removeObserver:self + name:CPOutlineViewSelectionDidChangeNotification + object:_source]; + + [super unbind]; +} + +- (void)updateSource +{ + var indexPaths = [self valueForBinding:CPObservedKeyPathKey] || [], + indexes = [CPMutableIndexSet indexSet], + contentBinder = [CPBinder getBinding:@"content" forObject:_source]; + + var rootNode = [contentBinder respondsToSelector:@selector(rootNode)] ? [contentBinder rootNode] : nil; + + if (rootNode) + { + for (var i = 0, count = [indexPaths count]; i < count; i++) + { + var item = [rootNode descendantNodeAtIndexPath:[indexPaths objectAtIndex:i]]; + if (item) + { + // Auto-expand all parents so the selection becomes visible + var parentsToExpand = [CPMutableArray array], + parent = [item parentNode]; + + while (parent && parent !== rootNode) + { + [parentsToExpand insertObject:parent atIndex:0]; // Top-down + 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]; + } + } + } + + // Suppress KVO while we programmatically adjust the CPOutlineView selection[self suppressSpecificNotificationFromObject:_source keyPath:@"selectionIndexPaths"]; + [_source selectRowIndexes:indexes byExtendingSelection:NO]; + [self unsuppressSpecificNotificationFromObject:_source keyPath:@"selectionIndexPaths"]; +} + +- (void)outlineViewSelectionDidChange:(CPNotification)note +{ + // We only want to push the change back if we aren't currently syncing down from the model + if ([self isSpecificNotificationSuppressedFromObject:_source keyPath:@"selectionIndexPaths"]) + return; + + var paths = [_source selectionIndexPaths]; + + // Reverse-set the value to push it up to the CPTreeController's selectionIndexPaths[self reverseSetValueFor:CPObservedKeyPathKey value:paths]; +} + +@end diff --git a/AppKit/CPOutlineView.j b/AppKit/CPOutlineView.j index 2f0cd73c0..ee76438b2 100644 --- a/AppKit/CPOutlineView.j +++ b/AppKit/CPOutlineView.j @@ -2344,3 +2344,5 @@ var colorForDisclosureTriangle = function(isSelected, isHighlighted) ? [CPColor colorWithCalibratedWhite:0.4 alpha: 1.0] : [CPColor colorWithCalibratedWhite:0.5 alpha: 1.0]); }; + +@import "CPOutlineView+CPBindings.j" diff --git a/Tests/AppKit/CPTreeControllerTest.j b/Tests/AppKit/CPTreeControllerTest.j index bf94102c2..9487e4e46 100644 --- a/Tests/AppKit/CPTreeControllerTest.j +++ b/Tests/AppKit/CPTreeControllerTest.j @@ -260,7 +260,7 @@ - (CPString)description { - return[CPString stringWithFormat:@"", [self name]]; + return [CPString stringWithFormat:@"", [self name]]; } @end From 8ac2b944001bdd5acd4c2af8013c887fb59eefbf Mon Sep 17 00:00:00 2001 From: daboe01 Date: Sat, 7 Mar 2026 21:48:10 +0100 Subject: [PATCH 08/10] refac --- AppKit/CPOutlineView+CPBindings.j | 229 ------------------------------ AppKit/CPOutlineView.j | 214 +++++++++++++++++++++++++++- 2 files changed, 213 insertions(+), 230 deletions(-) delete mode 100644 AppKit/CPOutlineView+CPBindings.j diff --git a/AppKit/CPOutlineView+CPBindings.j b/AppKit/CPOutlineView+CPBindings.j deleted file mode 100644 index 658ff777e..000000000 --- a/AppKit/CPOutlineView+CPBindings.j +++ /dev/null @@ -1,229 +0,0 @@ -/* - * CPOutlineView+CPBindings.j - * AppKit - * - * Adds Cocoa Bindings support to CPOutlineView. - */ - -@import -@import -@import -@import -@import "CPKeyValueBinding.j" -@import "CPTreeNode.j" - -@class CPOutlineView; - -@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) - -+ (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 -{ - [super bind]; - [_source setDataSource:self]; -} - -- (void)unbind -{ - if ([_source dataSource] === self) - [_source setDataSource:nil]; - - [super unbind]; -} - -- (void)updateSource -{ - var value = [self valueForBinding:CPObservedKeyPathKey]; - - if (!value || ![value isKindOfClass:[CPTreeNode class]]) - _rootNode = [[CPTreeNode alloc] initWithRepresentedObject:nil]; - else - _rootNode = value; - - [_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 -{ - // Normally column values are resolved via the table column's own bindings, - // but we return the represented object here as a standard fallback for cell-based tables. - 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 - -- (void)bind -{ - [super bind]; - - // Observe selection changes originating from the user clicking the outline view - [[CPNotificationCenter defaultCenter] - addObserver:self - selector:@selector(outlineViewSelectionDidChange:) - name:CPOutlineViewSelectionDidChangeNotification - object:_source]; -} - -- (void)unbind -{ - [[CPNotificationCenter defaultCenter] - removeObserver:self - name:CPOutlineViewSelectionDidChangeNotification - object:_source]; - - [super unbind]; -} - -- (void)updateSource -{ - var indexPaths = [self valueForBinding:CPObservedKeyPathKey] || [], - indexes = [CPMutableIndexSet indexSet], - contentBinder = [CPBinder getBinding:@"content" forObject:_source]; - - var rootNode = [contentBinder respondsToSelector:@selector(rootNode)] ? [contentBinder rootNode] : nil; - - if (rootNode) - { - for (var i = 0, count = [indexPaths count]; i < count; i++) - { - var item = [rootNode descendantNodeAtIndexPath:[indexPaths objectAtIndex:i]]; - if (item) - { - // Auto-expand all parents so the selection becomes visible - var parentsToExpand = [CPMutableArray array], - parent = [item parentNode]; - - while (parent && parent !== rootNode) - { - [parentsToExpand insertObject:parent atIndex:0]; // Top-down - 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]; - } - } - } - - // Suppress KVO while we programmatically adjust the CPOutlineView selection[self suppressSpecificNotificationFromObject:_source keyPath:@"selectionIndexPaths"]; - [_source selectRowIndexes:indexes byExtendingSelection:NO]; - [self unsuppressSpecificNotificationFromObject:_source keyPath:@"selectionIndexPaths"]; -} - -- (void)outlineViewSelectionDidChange:(CPNotification)note -{ - // We only want to push the change back if we aren't currently syncing down from the model - if ([self isSpecificNotificationSuppressedFromObject:_source keyPath:@"selectionIndexPaths"]) - return; - - var paths = [_source selectionIndexPaths]; - - // Reverse-set the value to push it up to the CPTreeController's selectionIndexPaths[self reverseSetValueFor:CPObservedKeyPathKey value:paths]; -} - -@end diff --git a/AppKit/CPOutlineView.j b/AppKit/CPOutlineView.j index ee76438b2..fba31f06e 100644 --- a/AppKit/CPOutlineView.j +++ b/AppKit/CPOutlineView.j @@ -2345,4 +2345,216 @@ var colorForDisclosureTriangle = function(isSelected, isHighlighted) : [CPColor colorWithCalibratedWhite:0.5 alpha: 1.0]); }; -@import "CPOutlineView+CPBindings.j" +@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) + ++ (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 +{ + [super bind]; + [_source setDataSource:self]; +} + +- (void)unbind +{ + if ([_source dataSource] === self) + [_source setDataSource:nil]; + + [super unbind]; +} + +- (void)updateSource +{ + var value = [self valueForBinding:CPObservedKeyPathKey]; + + if (!value || ![value isKindOfClass:[CPTreeNode class]]) + _rootNode = [[CPTreeNode alloc] initWithRepresentedObject:nil]; + else + _rootNode = value; + + [_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 +{ + // Normally column values are resolved via the table column's own bindings, + // but we return the represented object here as a standard fallback for cell-based tables. + 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 + +- (void)bind +{ + [super bind]; + + // Observe selection changes originating from the user clicking the outline view + [[CPNotificationCenter defaultCenter] + addObserver:self + selector:@selector(outlineViewSelectionDidChange:) + name:CPOutlineViewSelectionDidChangeNotification + object:_source]; +} + +- (void)unbind +{ + [[CPNotificationCenter defaultCenter] + removeObserver:self + name:CPOutlineViewSelectionDidChangeNotification + object:_source]; + + [super unbind]; +} + +- (void)updateSource +{ + var indexPaths = [self valueForBinding:CPObservedKeyPathKey] || [], + indexes = [CPMutableIndexSet indexSet], + contentBinder = [CPBinder getBinding:@"content" forObject:_source]; + + var rootNode = [contentBinder respondsToSelector:@selector(rootNode)] ? [contentBinder rootNode] : nil; + + if (rootNode) + { + for (var i = 0, count = [indexPaths count]; i < count; i++) + { + var item = [rootNode descendantNodeAtIndexPath:[indexPaths objectAtIndex:i]]; + if (item) + { + // Auto-expand all parents so the selection becomes visible + var parentsToExpand = [CPMutableArray array], + parent = [item parentNode]; + + while (parent && parent !== rootNode) + { + [parentsToExpand insertObject:parent atIndex:0]; // Top-down + 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]; + } + } + } + + // Suppress KVO while we programmatically adjust the CPOutlineView selection[self suppressSpecificNotificationFromObject:_source keyPath:@"selectionIndexPaths"]; + [_source selectRowIndexes:indexes byExtendingSelection:NO]; + [self unsuppressSpecificNotificationFromObject:_source keyPath:@"selectionIndexPaths"]; +} + +- (void)outlineViewSelectionDidChange:(CPNotification)note +{ + // We only want to push the change back if we aren't currently syncing down from the model + if ([self isSpecificNotificationSuppressedFromObject:_source keyPath:@"selectionIndexPaths"]) + return; + + var paths = [_source selectionIndexPaths]; + + // Reverse-set the value to push it up to the CPTreeController's selectionIndexPaths[self reverseSetValueFor:CPObservedKeyPathKey value:paths]; +} + +@end From c70079ae71c3bf74e1df5c7e2e8bba02c0ee6e5f Mon Sep 17 00:00:00 2001 From: daboe01 Date: Sun, 8 Mar 2026 18:03:52 +0100 Subject: [PATCH 09/10] improved: bindings to CPOutlineView --- AppKit/AppKit.j | 1 + AppKit/CPOutlineView.j | 153 ++++++++++--- AppKit/CPTreeController.j | 31 ++- AppKit/CPTreeNode.j | 122 +++++++---- .../CPTreeControllerTest/AppController.j | 153 +++++++++++++ Tests/Manual/CPTreeControllerTest/Info.plist | 12 ++ Tests/Manual/CPTreeControllerTest/Jakefile | 93 ++++++++ .../CPTreeControllerTest/index-debug.html | 204 ++++++++++++++++++ Tests/Manual/CPTreeControllerTest/index.html | 166 ++++++++++++++ Tests/Manual/CPTreeControllerTest/main.j | 18 ++ 10 files changed, 873 insertions(+), 80 deletions(-) create mode 100644 Tests/Manual/CPTreeControllerTest/AppController.j create mode 100644 Tests/Manual/CPTreeControllerTest/Info.plist create mode 100644 Tests/Manual/CPTreeControllerTest/Jakefile create mode 100644 Tests/Manual/CPTreeControllerTest/index-debug.html create mode 100644 Tests/Manual/CPTreeControllerTest/index.html create mode 100644 Tests/Manual/CPTreeControllerTest/main.j 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 fba31f06e..593da156f 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 && itemInfo[[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: @@ -2386,6 +2411,10 @@ var colorForDisclosureTriangle = function(isSelected, isHighlighted) @implementation CPOutlineView (CPBinder) +- (id)content { return nil; } +- (void)setContent:(id)aContent { } +- (void)setSelectionIndexPaths:(CPArray)paths { } + + (Class)_binderClassForBinding:(CPString)aBinding { if (aBinding === @"content") @@ -2413,8 +2442,12 @@ var colorForDisclosureTriangle = function(isSelected, isHighlighted) - (void)bind { - [super 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 @@ -2425,16 +2458,30 @@ var colorForDisclosureTriangle = function(isSelected, isHighlighted) [super unbind]; } -- (void)updateSource +- (void)setValueFor:(CPString)aBinding { - var value = [self valueForBinding:CPObservedKeyPathKey]; - + 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; - [_source reloadData]; + // 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 @@ -2464,8 +2511,6 @@ var colorForDisclosureTriangle = function(isSelected, isHighlighted) - (id)outlineView:(CPOutlineView)outlineView objectValueForTableColumn:(CPTableColumn)tableColumn byItem:(id)item { - // Normally column values are resolved via the table column's own bindings, - // but we return the represented object here as a standard fallback for cell-based tables. if ([item respondsToSelector:@selector(representedObject)]) return [item representedObject]; @@ -2474,7 +2519,6 @@ var colorForDisclosureTriangle = function(isSelected, isHighlighted) @end - // --- Selection Index Paths Binder --- /*! @@ -2483,12 +2527,14 @@ var colorForDisclosureTriangle = function(isSelected, isHighlighted) It also intercepts changes from the CPTreeController and auto-expands the tree to highlight them. */ @implementation _CPOutlineViewSelectionIndexPathsBinder : CPBinder +{ + BOOL _isSyncingFromModel; +} - (void)bind { [super bind]; - // Observe selection changes originating from the user clicking the outline view [[CPNotificationCenter defaultCenter] addObserver:self selector:@selector(outlineViewSelectionDidChange:) @@ -2506,28 +2552,33 @@ var colorForDisclosureTriangle = function(isSelected, isHighlighted) [super unbind]; } -- (void)updateSource +- (void)setValueFor:(CPString)aBinding { - var indexPaths = [self valueForBinding:CPObservedKeyPathKey] || [], - indexes = [CPMutableIndexSet indexSet], - contentBinder = [CPBinder getBinding:@"content" forObject:_source]; - - var rootNode = [contentBinder respondsToSelector:@selector(rootNode)] ? [contentBinder rootNode] : nil; - + // 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) { - // Auto-expand all parents so the selection becomes visible var parentsToExpand = [CPMutableArray array], parent = [item parentNode]; while (parent && parent !== rootNode) { - [parentsToExpand insertObject:parent atIndex:0]; // Top-down + [parentsToExpand insertObject:parent atIndex:0]; parent = [parent parentNode]; } @@ -2535,26 +2586,64 @@ var colorForDisclosureTriangle = function(isSelected, isHighlighted) [_source expandItem:parentsToExpand[j]]; var row = [_source rowForItem:item]; + if (row !== CPNotFound && row >= 0) [indexes addIndex:row]; } } } - // Suppress KVO while we programmatically adjust the CPOutlineView selection[self suppressSpecificNotificationFromObject:_source keyPath:@"selectionIndexPaths"]; + // Adjust the CPOutlineView selection [_source selectRowIndexes:indexes byExtendingSelection:NO]; - [self unsuppressSpecificNotificationFromObject:_source keyPath:@"selectionIndexPaths"]; + + // 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 ([self isSpecificNotificationSuppressedFromObject:_source keyPath:@"selectionIndexPaths"]) + if (_isSyncingFromModel) return; - - var paths = [_source selectionIndexPaths]; - - // Reverse-set the value to push it up to the CPTreeController's selectionIndexPaths[self reverseSetValueFor:CPObservedKeyPathKey value:paths]; + + // 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 index aefaa0660..8bee3ef95 100644 --- a/AppKit/CPTreeController.j +++ b/AppKit/CPTreeController.j @@ -183,7 +183,7 @@ } - (void)_setContentArray:(id)anArray {[self setContent:anArray]; } -- (id)contentArray { return [self content]; } +- (id)contentArray { return _contentObject; } - (id)arrangedObjects { return _arrangedObjects; } - (void)rearrangeObjects @@ -226,9 +226,12 @@ - (CPArray)_buildTreeNodesForObjects:(CPArray)objects { var count = [objects count]; - if (count === 0) return [CPArray array]; + + if (count === 0) + return []; var sortedObjects = objects; + if (_sortDescriptors && [_sortDescriptors count] > 0) sortedObjects = [objects sortedArrayUsingDescriptors:_sortDescriptors]; @@ -242,12 +245,15 @@ if (_childrenKeyPath) { var childObjects = [obj valueForKeyPath:_childrenKeyPath]; + if (childObjects && [childObjects count] > 0) { var childNodes = [self _buildTreeNodesForObjects:childObjects]; [[node mutableChildNodes] addObjectsFromArray:childNodes]; } - }[nodes addObject:node]; + } + + [nodes addObject:node]; } return nodes; @@ -291,15 +297,23 @@ 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"]; + + if (binding) + [binding reverseSetValueFor:@"selectionIndexPaths"]; } + [self didChangeValueForKey:@"selectionIndexPaths"]; + return YES; } @@ -441,7 +455,8 @@ } - (void)insertObjects:(CPArray)objects atArrangedObjectIndexPaths:(CPArray)indexPaths -{[self willChangeValueForKey:@"content"]; +{ + [self willChangeValueForKey:@"content"]; _disableSetContent = YES; var count = [objects count]; @@ -496,7 +511,8 @@ } - (void)removeObjectAtArrangedObjectIndexPath:(CPIndexPath)indexPath -{[self removeObjectsAtArrangedObjectIndexPaths:[CPArray arrayWithObject:indexPath]]; +{ + [self removeObjectsAtArrangedObjectIndexPaths:[CPArray arrayWithObject:indexPath]]; } - (void)removeObjectsAtArrangedObjectIndexPaths:(CPArray)indexPaths @@ -513,7 +529,8 @@ length = [path length]; if (length === 1) - {[_contentObject removeObjectAtIndex:[path indexAtPosition:0]]; + { + [_contentObject removeObjectAtIndex:[path indexAtPosition:0]]; } else { diff --git a/AppKit/CPTreeNode.j b/AppKit/CPTreeNode.j index 0f5f88a33..a095ca13c 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,34 @@ - (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; + var index = [_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 +87,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 +118,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 +154,36 @@ 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]]; - - return node; + var index = [indexPath indexAtPosition:0], + count = [_childNodes count]; + + if (index >= count) + return nil; + + var child = [_childNodes objectAtIndex:index]; + + if ([indexPath length] == 1) + return child; + + return [child descendantNodeAtIndexPath:[indexPath indexPathByRemovingFirstIndex]]; } @end +// Coding implementation remains correct var CPTreeNodeRepresentedObjectKey = @"CPTreeNodeRepresentedObjectKey", CPTreeNodeParentNodeKey = @"CPTreeNodeParentNodeKey", CPTreeNodeChildNodesKey = @"CPTreeNodeChildNodesKey"; @@ -163,6 +199,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/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); +} From 031e5e7ca3fcbdbf4c48c23dc60d1db1d91c3689 Mon Sep 17 00:00:00 2001 From: daboe01 Date: Sun, 8 Mar 2026 19:52:58 +0100 Subject: [PATCH 10/10] fixed: selection issues --- AppKit/CPOutlineView.j | 29 +++++++++++++++-------------- AppKit/CPTreeController.j | 1 - AppKit/CPTreeNode.j | 32 ++++++++++++++++++-------------- Tests/AppKit/CPTreeNodeTest.j | 2 +- 4 files changed, 34 insertions(+), 30 deletions(-) diff --git a/AppKit/CPOutlineView.j b/AppKit/CPOutlineView.j index 593da156f..3e8cb34d8 100644 --- a/AppKit/CPOutlineView.j +++ b/AppKit/CPOutlineView.j @@ -1110,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 (parent && itemInfo[[parent UID]] === _rootItemInfo) + if (parent && _itemInfosForItems[[parent UID]] === _rootItemInfo) parent = nil; return parent; @@ -2531,25 +2531,26 @@ var colorForDisclosureTriangle = function(isSelected, isHighlighted) BOOL _isSyncingFromModel; } -- (void)bind +- (id)initWithBinding:(CPString)aBinding name:(CPString)aName to:(id)aDestination keyPath:(CPString)aKeyPath options:(CPDictionary)options from:(id)aSource { - [super bind]; - - [[CPNotificationCenter defaultCenter] + self = [super initWithBinding:aBinding name:aName to:aDestination keyPath:aKeyPath options:options from:aSource]; + + [[CPNotificationCenter defaultCenter] addObserver:self selector:@selector(outlineViewSelectionDidChange:) name:CPOutlineViewSelectionDidChangeNotification - object:_source]; + object:aSource]; } -- (void)unbind ++ (void)unbind:(CPString)aBinding forObject:(id)anObject { - [[CPNotificationCenter defaultCenter] - removeObserver:self - name:CPOutlineViewSelectionDidChangeNotification - object:_source]; - - [super unbind]; + if (aBinding === "selectionIndexPaths") + [[CPNotificationCenter defaultCenter] + removeObserver:self + name:CPOutlineViewSelectionDidChangeNotification + object:anObject]; + + [super unbind:aBinding forObject:anObject]; } - (void)setValueFor:(CPString)aBinding @@ -2606,7 +2607,7 @@ var colorForDisclosureTriangle = function(isSelected, isHighlighted) if (_isSyncingFromModel) return; - // In CPBinder, reverseSetValueFor: takes the name of the property on _source + // 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"]; } diff --git a/AppKit/CPTreeController.j b/AppKit/CPTreeController.j index 8bee3ef95..2d0e46802 100644 --- a/AppKit/CPTreeController.j +++ b/AppKit/CPTreeController.j @@ -300,7 +300,6 @@ [self willChangeValueForKey:@"selectionIndexPaths"]; _selectionIndexPaths = [newPaths copy]; - var binderClass = [[self class] _binderClassForBinding:@"selectionIndexPaths"]; diff --git a/AppKit/CPTreeNode.j b/AppKit/CPTreeNode.j index a095ca13c..f25c83649 100644 --- a/AppKit/CPTreeNode.j +++ b/AppKit/CPTreeNode.j @@ -54,7 +54,8 @@ // If we have a parent, calculate path based on parent's path + our index if (_parentNode) { - var index = [_childNodes indexOfObjectIdenticalTo:self]; + // 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. @@ -68,7 +69,7 @@ // 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; + return nil; } - (BOOL)isLeaf @@ -167,18 +168,21 @@ if (!indexPath || [indexPath length] == 0) return self; - var index = [indexPath indexAtPosition:0], - count = [_childNodes count]; - - if (index >= count) - return nil; - - var child = [_childNodes objectAtIndex:index]; - - if ([indexPath length] == 1) - return child; - - return [child descendantNodeAtIndexPath:[indexPath indexPathByRemovingFirstIndex]]; + 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 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