diff --git a/AppKit/CPOutlineView.j b/AppKit/CPOutlineView.j index 5cffb0d25..f27952646 100644 --- a/AppKit/CPOutlineView.j +++ b/AppKit/CPOutlineView.j @@ -79,11 +79,11 @@ CPOutlineViewDropOnItemIndex = -1; @ingroup appkit @class CPOutlineView - CPOutlineView is a subclass of CPTableView that inherates the row and column format to display hierarchial data. + CPOutlineView is a subclass of CPTableView that inherates the row and column format to display hierarchial data. The outlineview adds the ability to expand and collapse items. This is useful for browsing a tree like structure such as directories or a filesystem. Like the tableview, an outlineview uses a data source to supply its data. For this reason you must implement a couple data source methods (documented in setDataSource:) - + */ @implementation CPOutlineView : CPTableView { @@ -151,7 +151,7 @@ CPOutlineViewDropOnItemIndex = -1; } /*! In addition to standard delegation, the outline view also supports data source delegation. This method sets the data source object. - Just like the TableView you have CPTableColumns but instead of rows you deal with items. + Just like the TableView you have CPTableColumns but instead of rows you deal with items. You must implement these data source methods: @@ -165,7 +165,7 @@ CPOutlineViewDropOnItemIndex = -1; Returns the number of child items of a given item. If item is nil you should return the number of top level (root) items. - (id)outlineView:(CPOutlineView)outlineView objectValueForTableColumn:(CPTableColumn)tableColumn byItem:(id)item; - Returns the object value of the item in a given column. + Returns the object value of the item in a given column. The following methods are optional: @@ -195,7 +195,7 @@ CPOutlineViewDropOnItemIndex = -1; Returns YES if the drop operation is allowed otherwise NO. This method is invoked by the outlineview after a drag should begin, but before it is started. If you dont want the drag to being return NO. If you want the drag to begin you should return YES and place the drag data on the pboard. - + */ - (void)setDataSource:(id)aDataSource { @@ -280,11 +280,11 @@ CPOutlineViewDropOnItemIndex = -1; } /*! - Used to find if an item is already expanded. + Used to find if an item is already expanded. @param anItem - the item you are interest in. - @return BOOL - Yes if the item is already expanded, otherwise NO. + @return BOOL - Yes if the item is already expanded, otherwise NO. */ - (BOOL)isItemExpanded:(id)anItem { @@ -331,6 +331,22 @@ CPOutlineViewDropOnItemIndex = -1; if (!itemInfo.isExpanded) { [self _noteItemWillExpand:anItem]; + + // Shift selection indexes below so that the same items remain selected. + var newRowCount = [_outlineViewDataSource outlineView:self numberOfChildrenOfItem:anItem]; + if (newRowCount) + { + var selection = [self selectedRowIndexes], + expandIndex = [self rowForItem:anItem] + 1; + + if ([selection intersectsIndexesInRange:CPMakeRange(expandIndex, _itemsForRows.length)]) + { + [self _noteSelectionIsChanging]; + [selection shiftIndexesStartingAtIndex:expandIndex by:newRowCount]; + [self _setSelectedRowIndexes:selection]; + } + } + itemInfo.isExpanded = YES; [self _noteItemDidExpand:anItem]; [self reloadItem:anItem reloadChildren:YES]; @@ -365,7 +381,47 @@ CPOutlineViewDropOnItemIndex = -1; return; [self _noteItemWillCollapse:anItem]; + // Update selections: + // * Deselect items inside the collapsed item. + // * Shift row selections below the collapsed item so that the same logical items remain selected. + var collapseTopIndex = [self rowForItem:anItem], + topLevel = [self levelForRow:collapseTopIndex], + collapseEndIndex = collapseTopIndex; + + while (collapseEndIndex + 1 < _itemsForRows.length && [self levelForRow:collapseEndIndex + 1] > topLevel) + collapseEndIndex++; + + var collapseRange = CPMakeRange(collapseTopIndex + 1, collapseEndIndex - collapseTopIndex); + if (collapseRange.length) + { + var selection = [self selectedRowIndexes], + didChange = NO; + + if ([selection intersectsIndexesInRange:collapseRange]) + { + [selection removeIndexesInRange:collapseRange]; + [self _noteSelectionIsChanging]; + didChange = YES; + // Will call _noteSelectionDidChange + [self _setSelectedRowIndexes:selection]; + } + + // Shift any selected rows below upwards. + if ([selection intersectsIndexesInRange:CPMakeRange(collapseEndIndex + 1, _itemsForRows.length)]) + { + // Notify if that wasn't already done above. + if (!didChange) + [self _noteSelectionIsChanging]; + didChange = YES; + + [selection shiftIndexesStartingAtIndex:collapseEndIndex + 1 by:-collapseRange.length]; + } + + if (didChange) + [self _setSelectedRowIndexes:selection]; + } itemInfo.isExpanded = NO; + [self _noteItemDidCollapse:anItem]; [self reloadItem:anItem reloadChildren:YES]; @@ -500,7 +556,7 @@ CPOutlineViewDropOnItemIndex = -1; } /*! - Returns the width of an indentation level. + Returns the width of an indentation level. @return float - the width of the indentation per level. */ @@ -580,14 +636,14 @@ CPOutlineViewDropOnItemIndex = -1; /*! Returns the frame of the disclosure button for the outline column. If the item is not expandable a CGZeroRect is returned. - Subclasses can return a CGZeroRect to prevent the disclosure control from being displayed. + Subclasses can return a CGZeroRect to prevent the disclosure control from being displayed. @param aRow - The row of the reciever @return CGRect - The rect of the disclosure button at aRow. */ - (CGRect)frameOfOutlineDisclosureControlAtRow:(CPInteger)aRow { - if (![self isExpandable:[self itemAtRow:aRow]]) + if (![self isExpandable:[self itemAtRow:aRow]]) return _CGRectMakeZero(); var dataViewFrame = [self _frameOfOutlineDataViewAtRow:aRow], @@ -670,7 +726,7 @@ CPOutlineViewDropOnItemIndex = -1; Implement this to indicate whether a given item should be rendered using the group item style. Return YES if the item is a group item, otherwise NO. - @param aDelegate - the delegate object you wish to set for the reciever. + @param aDelegate - the delegate object you wish to set for the reciever. */ - (void)setDelegate:(id)aDelegate { @@ -944,7 +1000,7 @@ CPOutlineViewDropOnItemIndex = -1; Retargets the drop item for the outlineview. To specify a drop on theItem, you specify item as theItem and index as CPOutlineViewDropOnItemIndex. To specify a drop between child 1 and 2 of theItem, you specify item as theItem and index as 2. - To specify a drop on an item that canŐt be expanded theItem, you specify item as someOutlineItem and index as CPOutlineViewDropOnItemIndex. + To specify a drop on an item that can't be expanded theItem, you specify item as someOutlineItem and index as CPOutlineViewDropOnItemIndex. @param theItem - The item you want to retarget the drop on. @param theIndex - The index of the child item you want to retarget the drop between. Pass CPOutlineViewDropOnItemIndex if you want to drop on theItem. diff --git a/Tests/AppKit/CPOutlineViewTest.j b/Tests/AppKit/CPOutlineViewTest.j new file mode 100644 index 000000000..0359f4fb5 --- /dev/null +++ b/Tests/AppKit/CPOutlineViewTest.j @@ -0,0 +1,169 @@ +@import + +@implementation CPOutlineViewTest : OJTestCase +{ + CPOutlineView outlineView; + CPTableColumn tableColumn; + TestDataSource dataSource; +} + +- (void)setUp +{ + outlineView = [[CPOutlineView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; + + tableColumn = [[CPTableColumn alloc] initWithIdentifier:@"Foo"]; + [outlineView addTableColumn:tableColumn]; + [outlineView setOutlineTableColumn:tableColumn]; + + [outlineView setAllowsMultipleSelection:YES]; + + dataSource = [TestDataSource new]; + [dataSource setEntries:[".1", ".1.1", ".1.2", ".1.2.1", ".1.2.2", ".2", ".3", ".3.1"]]; + + [outlineView setDataSource:dataSource]; + [outlineView expandItem:nil expandChildren:YES]; +} + +/*! + Test that entries load and hide correctly. +*/ +- (void)testCollapse +{ + // By default all rows should be visible. + var entries = [dataSource entries]; + for (var i = 0; i < entries.length; i++) + { + var item = [outlineView itemAtRow:i]; + [self assert:entries[i] equals:item message:"item " + i + " visible, in correct order"]; + } + + // Now collapse the .1 group. + [outlineView collapseItem:".1"]; + var expected = [".1", ".2", ".3", ".3.1"]; + for (var i = 0; i < expected.length; i++) + { + var item = [outlineView itemAtRow:i]; + [self assert:expected[i] equals:item message:"item " + i + " visible after collapse, in correct order"]; + } +} + +/*! + Test that when an ancestor item containing a selected item is collapsed, the item is deselected. +*/ +- (void)testCollapseDeselect +{ + var preSelection = [CPIndexSet indexSet]; + [preSelection addIndex:[outlineView rowForItem:".1.2.2"]]; + [preSelection addIndex:[outlineView rowForItem:".1.1"]]; + + [outlineView selectRowIndexes:preSelection byExtendingSelection:NO]; + + [outlineView collapseItem:".1.2"]; + + var afterSelection = [outlineView selectedRowIndexes]; + + [self assert:1 equals:[afterSelection count] message:"1 selection should remain"]; + [self assert:".1.1" equals:[outlineView itemAtRow:[outlineView selectedRow]] message:".1.1 selection should remain"]; +} + +/*! + Test that the selection stays on the same item below a collapse. +*/ +- (void)testCollapseWithSelectionBelow +{ + // [".1", ".1.1", ".1.2", ".1.2.1", ".1.2.2", ".2", ".3", ".3.1"] + var preSelection = [CPIndexSet indexSet]; + [preSelection addIndex:[outlineView rowForItem:".1.1"]]; + [preSelection addIndex:[outlineView rowForItem:".3.1"]]; + + [outlineView selectRowIndexes:preSelection byExtendingSelection:NO]; + + [outlineView collapseItem:".1.2"]; + + var afterSelection = [outlineView selectedRowIndexes]; + + [self assert:2 equals:[afterSelection count] message:"selections should remain"]; + [self assert:".1.1" equals:[outlineView itemAtRow:[afterSelection firstIndex]] message:".1.1 selection should remain"]; + [self assert:".3.1" equals:[outlineView itemAtRow:[afterSelection lastIndex]] message:".3.1 selection should remain"]; + + // Collapse where one selection disappears and one shifts. + preSelection = [CPIndexSet indexSet]; + [preSelection addIndex:[outlineView rowForItem:".1.1"]]; + [preSelection addIndex:[outlineView rowForItem:".3"]]; + + [outlineView selectRowIndexes:preSelection byExtendingSelection:NO]; + + [outlineView collapseItem:".1"]; + + afterSelection = [outlineView selectedRowIndexes]; + + [self assert:1 equals:[afterSelection count] message:"1 selection should disappear"]; + + [self assert:".3" equals:[outlineView itemAtRow:[afterSelection firstIndex]] message:".3 selection should remain"]; +} + +/*! + Test that the selection stays on the same item below an expand. +*/ +- (void)testExpandWithSelectionBelow +{ + // [".1", ".1.1", ".1.2", ".1.2.1", ".1.2.2", ".2", ".3", ".3.1"] + [outlineView collapseItem:".1.2"]; + + var preSelection = [CPIndexSet indexSet]; + [preSelection addIndex:[outlineView rowForItem:".1.1"]]; + [preSelection addIndex:[outlineView rowForItem:".2"]]; + + [outlineView selectRowIndexes:preSelection byExtendingSelection:NO]; + + [outlineView expandItem:".1.2"]; + afterSelection = [outlineView selectedRowIndexes]; + + [self assert:2 equals:[afterSelection count] message:"selections should remain"]; + + [self assert:".1.1" equals:[outlineView itemAtRow:[afterSelection firstIndex]] message:".1.1 selection should remain"]; + [self assert:".2" equals:[outlineView itemAtRow:[afterSelection lastIndex]] message:".2 selection should remain"]; +} + +@end + +@implementation TestDataSource : CPObject +{ + CPArray entries @accessors; +} + +- (CPArray)childrenOfPrefix:(CPString)theItem +{ + if (!theItem) + theItem = ""; + + var matcher = new RegExp("^" + theItem + "\\.\\d$"), + children = []; + for (var i = 0; i < entries.length; i++) + if (matcher.exec(entries[i])) + children.push(entries[i]); + + return children; +} + +- (id)outlineView:(CPOutlineView)theOutlineView child:(int)theIndex ofItem:(id)theItem +{ + return [self childrenOfPrefix:theItem][theIndex]; +} + +- (BOOL)outlineView:(CPOutlineView)theOutlineView isItemExpandable:(id)theItem +{ + return !![[self childrenOfPrefix:theItem] count]; +} + +- (int)outlineView:(CPOutlineView)theOutlineView numberOfChildrenOfItem:(id)theItem +{ + return [[self childrenOfPrefix:theItem] count]; +} + +- (id)outlineView:(CPOutlineView)anOutlineView objectValueForTableColumn:(CPTableColumn)theColumn byItem:(id)theItem +{ + return theItem; +} + +@end