diff --git a/AppKit/CPSplitViewController.j b/AppKit/CPSplitViewController.j new file mode 100644 index 000000000..fe23f53df --- /dev/null +++ b/AppKit/CPSplitViewController.j @@ -0,0 +1,388 @@ +/* + * CPSplitViewController.j + * + * Created by Daniel Boehringer on September 2, 2025. + * Copyright (c) 2025 Daniel Boehringer. All rights reserved. + * + * 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 "CPViewController.j" +@import "CPSplitView.j" + +var CPSplitViewControllerAutomaticDimension = -1.0; + +/** + * A container view controller that manages two or more child view + * controllers in a split view interface. + * + * This class provides a controller-level abstraction for CPSplitView, + * managing the addition, removal, and arrangement of view controllers + * through CPSplitViewItem instances. + * + * This implementation was synthesized with the assistance of an LLM, + * directed by the author. + */ +@implementation CPSplitViewController : CPViewController +{ + /** @private The underlying split view that arranges the child views. */ + CPSplitView _splitView; + + /** @private An array of CPSplitViewItem objects managed by this controller. */ + CPMutableArray _splitViewItems; + + /** @private The minimum thickness for sidebars to be displayed inline. */ + CPNumber _minimumThicknessForInlineSidebars; +} + +// MARK: - Initialization + +- (id)init +{ + if (self = [super init]) + { + _splitViewItems = [CPMutableArray array]; + _minimumThicknessForInlineSidebars = 20.0; + } + return self; +} + +// MARK: - View Lifecycle + +- (void)loadView +{ + if (!_splitView) + { + _splitView = [[CPSplitView alloc] initWithFrame:CGRectMake(0,0,400,400)]; + [_splitView setDelegate:self]; + } + [self setView:_splitView]; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + for (var i = 0; i < [_splitViewItems count]; i++) + { + var viewController = [[_splitViewItems objectAtIndex:i] viewController]; + [[self splitView] addArrangedSubview:[viewController view]]; + } +} + +// MARK: - Accessors + +/** + * Returns the CPSplitView instance managed by the controller. + * + * @returns {CPSplitView} The split view. + */ +- (CPSplitView)splitView +{ + return _splitView; +} + +/** + * Sets a custom split view for the controller. + * + * @param {CPSplitView} splitView The custom split view to use. + */ +- (void)setSplitView:(CPSplitView)splitView +{ + if (_splitView !== splitView) + { + _splitView = splitView; + [_splitView setDelegate:self]; + if ([self isViewLoaded]) + [self setView:_splitView]; + } +} + +/** + * Returns the array of split view items. + * + * @returns {CPArray} The array of CPSplitViewItem objects. + */ +- (CPArray)splitViewItems +{ + return _splitViewItems; +} + +/** + * Sets the array of split view items, replacing any existing items. + * + * @param {CPArray} splitViewItems An array of CPSplitViewItem objects. + */ +- (void)setSplitViewItems:(CPArray)splitViewItems +{ + // Remove all existing items + while ([_splitViewItems count] > 0) + { + [self removeSplitViewItem:[_splitViewItems lastObject]]; + } + + // Add new items + for (var i = 0; i < [splitViewItems count]; i++) + { + [self addSplitViewItem:[splitViewItems objectAtIndex:i]]; + } +} + +// MARK: - Managing Split View Items + +/** + * Adds a split view item to the end of the split view. + * + * @param {CPSplitViewItem} splitViewItem The split view item to add. + */ +- (void)addSplitViewItem:(CPSplitViewItem)splitViewItem +{ + [self insertSplitViewItem:splitViewItem atIndex:[_splitViewItems count]]; +} + +/** + * Inserts a split view item at a specific index. + * + * @param {CPSplitViewItem} splitViewItem The split view item to insert. + * @param {CPInteger} index The zero-based index at which to insert the item. + */ +- (void)insertSplitViewItem:(CPSplitViewItem)splitViewItem atIndex:(CPInteger)index +{ + [splitViewItem _setSplitViewController:self]; + [_splitViewItems insertObject:splitViewItem atIndex:index]; + [self addChildViewController:[splitViewItem viewController]]; + + if ([self isViewLoaded]) + [[self splitView] insertArrangedSubview:[[splitViewItem viewController] view] atIndex:index]; +} + +/** + * Removes the specified split view item. + * + * @param {CPSplitViewItem} splitViewItem The split view item to remove. + */ +- (void)removeSplitViewItem:(CPSplitViewItem)splitViewItem +{ + var viewController = [splitViewItem viewController]; + if ([self isViewLoaded]) + [[viewController view] removeFromSuperview]; + + [viewController removeFromParentViewController]; + [splitViewItem _setSplitViewController:nil]; + [_splitViewItems removeObject:splitViewItem]; +} + +/** + * Retrieves the split view item associated with a given view controller. + * + * @param {CPViewController} viewController The view controller to find. + * @returns {CPSplitViewItem | null} The corresponding split view item, or nil if not found. + */ +- (CPSplitViewItem)splitViewItemForViewController:(CPViewController)viewController +{ + for (var i = 0; i < [_splitViewItems count]; i++) + { + var item = [_splitViewItems objectAtIndex:i]; + if ([item viewController] === viewController) + return item; + } + return nil; +} + +// MARK: - Managing Sidebars and Inspectors + +/** + * Toggles the collapsed state of the first split view item, typically a sidebar. + * + * @param {id} sender The object that initiated the action. + */ +- (void)toggleSidebar:(id)sender +{ + if ([_splitViewItems count] > 0) + { + var sidebarItem = [_splitViewItems objectAtIndex:0]; + [sidebarItem setCollapsed:![sidebarItem isCollapsed]]; + } +} + +/** + * Toggles the collapsed state of the last split view item, typically an inspector. + * + * @param {id} sender The object that initiated the action. + */ +- (void)toggleInspector:(id)sender +{ + if ([_splitViewItems count] > 1) + { + var inspectorItem = [_splitViewItems lastObject]; + [inspectorItem setCollapsed:![inspectorItem isCollapsed]]; + } +} + +/** + * Returns the minimum thickness for sidebars to be displayed inline. + * + * @returns {CPNumber} The minimum thickness. + */ +- (CPNumber)minimumThicknessForInlineSidebars +{ + return _minimumThicknessForInlineSidebars; +} + +// MARK: - CPSplitViewDelegate Methods + +// Note: A more complete implementation would forward these delegate methods +// to a separate delegate property on the CPSplitViewController itself. +// For now, these are stubbed to demonstrate where they would be handled. + +- (BOOL)splitView:(CPSplitView)splitView canCollapseSubview:(CPView)subview +{ + // Default behavior: allow all subviews to be collapsed. + return YES; +} + +- (CGFloat)splitView:(CPSplitView)splitView constrainMinCoordinate:(CGFloat)proposedMinimumPosition ofSubviewAt:(CPInteger)dividerIndex +{ + return proposedMinimumPosition; +} + +- (CGFloat)splitView:(CPSplitView)splitView constrainMaxCoordinate:(CGFloat)proposedMaximumPosition ofSubviewAt:(CPInteger)dividerIndex +{ + return proposedMaximumPosition; +} + +- (CGRect)splitView:(CPSplitView)splitView effectiveRect:(CGRect)proposedEffectiveRect forDrawnRect:(CGRect)drawnRect ofDividerAtIndex:(CPInteger)dividerIndex +{ + // Default behavior: return the proposed rectangle. + // This can be overridden to provide a larger or custom hit area for the divider. + return proposedEffectiveRect; +} + +- (void)splitViewDidResizeSubviews:(CPNotification)notification +{ + // Can be used to respond to user-initiated resizing. +} + +@end + +/** + * An object that manages a view controller within a CPSplitViewController. + * + * A CPSplitViewItem acts as a wrapper around a CPViewController, + * maintaining properties like its collapsed state within the parent + * split view controller. + */ +@implementation CPSplitViewItem : CPObject +{ + /** @private The view controller managed by this item. */ + CPViewController _viewController; + + /** @private A boolean indicating whether the item is collapsed. */ + BOOL _isCollapsed; + + /** @private A weak reference to the owning split view controller. */ + CPSplitViewController _splitViewController; +} + +// MARK: - Class Methods + +/** + * Creates and returns a new split view item with the specified view controller. + * + * @param {CPViewController} viewController The view controller for the new item. + * @returns {instancetype} A new CPSplitViewItem instance. + */ ++ (instancetype)splitViewItemWithViewController:(CPViewController)viewController +{ + return [[self alloc] initWithViewController:viewController]; +} + +// MARK: - Initialization + +/** + * Initializes a new split view item with the specified view controller. + * + * @param {CPViewController} viewController The view controller for the new item. + * @returns {id} The initialized CPSplitViewItem instance. + */ +- (id)initWithViewController:(CPViewController)viewController +{ + if (self = [super init]) + { + _viewController = viewController; + _isCollapsed = NO; + } + return self; +} + +// MARK: - Accessors + +/** + * Returns the view controller associated with the item. + * + * @returns {CPViewController} The associated view controller. + */ +- (CPViewController)viewController +{ + return _viewController; +} + +/** + * Returns a boolean value indicating whether the item is collapsed. + * + * @returns {BOOL} YES if the item is collapsed, otherwise NO. + */ +- (BOOL)isCollapsed +{ + return _isCollapsed; +} + +/** + * Sets the collapsed state of the item. + * When collapsed, the view controller's view is hidden. + * + * @param {BOOL} shouldCollapse YES to collapse the item, NO to expand it. + */ +- (void)setCollapsed:(BOOL)shouldCollapse +{ + if (_isCollapsed === shouldCollapse) + return; + + _isCollapsed = shouldCollapse; + [[_viewController view] setHidden:shouldCollapse]; +} + +/** + * Returns the split view controller that owns this item. + * + * @returns {CPSplitViewController | null} The parent split view controller. + */ +- (CPSplitViewController)splitViewController +{ + return _splitViewController; +} + +/** + * @private + * Sets the owning split view controller. This method is for internal use by + * CPSplitViewController. + * + * @param {CPSplitViewController} splitViewController The parent controller. + */ +- (void)_setSplitViewController:(CPSplitViewController)splitViewController +{ + _splitViewController = splitViewController; +} + +@end diff --git a/Tests/AppKit/CPSplitViewControllerTests.j b/Tests/AppKit/CPSplitViewControllerTests.j new file mode 100644 index 000000000..464dfae36 --- /dev/null +++ b/Tests/AppKit/CPSplitViewControllerTests.j @@ -0,0 +1,186 @@ +@import +@import + +// MARK: - Test Implementation + +@implementation CPSplitViewControllerTest : OJTestCase +{ + // Instance variables can be declared here if needed for tests. +} + +// MARK: - Initialization Tests + +- (void)testInit +{ + var splitViewController = [[CPSplitViewController alloc] init]; + + [self assertNotNil:splitViewController message:@"CPSplitViewController should initialize."]; + [self assertNotNil:[splitViewController splitViewItems] message:@"splitViewItems should be an empty array on init."]; + [self assert:[[splitViewController splitViewItems] count] equals:0 message:@"splitViewItems should have 0 items on init."]; +} + +- (void)testViewLoading +{ + var splitViewController = [[CPSplitViewController alloc] init]; + + // Accessing the view property triggers loadView + var view = [splitViewController view]; + + [self assertNotNil:view message:@"The controller's view should be created."]; + [self assertTrue:[view isKindOfClass:[CPSplitView class]] message:@"The controller's view should be a CPSplitView."]; + [self assert:[splitViewController splitView] equals:view message:@"The splitView accessor should return the controller's view."]; +} + +- (void)testSplitViewItemInit +{ + var childVC = [[CPViewController alloc] init]; + var item = [CPSplitViewItem splitViewItemWithViewController:childVC]; + + [self assertNotNil:item message:@"CPSplitViewItem should be created."]; + [self assert:[item viewController] equals:childVC message:@"The item should hold the correct view controller."]; + [self assertFalse:[item isCollapsed] message:@"A new item should not be collapsed."]; +} + +// MARK: - Item Management Tests + +- (void)testAddSplitViewItem +{ + var splitViewController = [[CPSplitViewController alloc] init]; + [splitViewController view]; // Ensure view is loaded + + var childVC1 = [[CPViewController alloc] init]; + var item1 = [CPSplitViewItem splitViewItemWithViewController:childVC1]; + + [splitViewController addSplitViewItem:item1]; + + [self assert:[[splitViewController splitViewItems] count] equals:1 message:@"Should have 1 split view item."]; + [self assert:[splitViewController splitViewItemForViewController:childVC1] equals:item1 message:@"Should be able to retrieve item by its view controller."]; + [self assert:[item1 splitViewController] equals:splitViewController message:@"Item should have a reference to its parent controller."]; + [self assert:[[[splitViewController splitView] arrangedSubviews] count] equals:1 message:@"The splitView should have one arranged subview."]; + [self assert:[[[childVC1 view] superview] equals:[splitViewController splitView]] message:@"Child's view should be a subview of the splitView."]; +} + +- (void)testInsertSplitViewItem +{ + var splitViewController = [[CPSplitViewController alloc] init]; + [splitViewController view]; + + var childVC1 = [[CPViewController alloc] init]; + var childVC2 = [[CPViewController alloc] init]; + var item1 = [CPSplitViewItem splitViewItemWithViewController:childVC1]; + var item2 = [CPSplitViewItem splitViewItemWithViewController:childVC2]; + + [splitViewController addSplitViewItem:item1]; + [splitViewController insertSplitViewItem:item2 atIndex:0]; + + [self assert:[[splitViewController splitViewItems] count] equals:2 message:@"Should have 2 split view items."]; + [self assert:[[splitViewController splitViewItems] objectAtIndex:0] equals:item2 message:@"Item2 should be at index 0."]; + + var arrangedSubviews = [[splitViewController splitView] arrangedSubviews]; + [self assert:[arrangedSubviews objectAtIndex:0] equals:[childVC2 view] message:@"The view of item2 should be the first arranged subview."]; +} + +- (void)testRemoveSplitViewItem +{ + var splitViewController = [[CPSplitViewController alloc] init]; + [splitViewController view]; + + var childVC1 = [[CPViewController alloc] init]; + var item1 = [CPSplitViewItem splitViewItemWithViewController:childVC1]; + + [splitViewController addSplitViewItem:item1]; + [self assert:[[splitViewController splitViewItems] count] equals:1 message:@"Pre-condition: Should have 1 item."]; + + [splitViewController removeSplitViewItem:item1]; + + [self assert:[[splitViewController splitViewItems] count] equals:0 message:@"Should have 0 split view items after removal."]; + [self assertNil:[item1 splitViewController] message:@"Item's reference to parent should be nil after removal."]; + [self assert:[[[splitViewController splitView] arrangedSubviews] count] equals:0 message:@"The splitView should have no arranged subviews after removal."]; + [self assertNil:[[childVC1 view] superview] message:@"Child's view should be removed from the splitView."]; +} + +- (void)testAddingItemsBeforeViewLoads +{ + var splitViewController = [[CPSplitViewController alloc] init]; + + var childVC1 = [[CPViewController alloc] init]; + var childVC2 = [[CPViewController alloc] init]; + var item1 = [CPSplitViewItem splitViewItemWithViewController:childVC1]; + var item2 = [CPSplitViewItem splitViewItemWithViewController:childVC2]; + + [splitViewController addSplitViewItem:item1]; + [splitViewController addSplitViewItem:item2]; + + [self assert:[[[splitViewController childViewControllers] objectAtIndex:0] equals:childVC1] message:@"Child view controllers should be added immediately."]; + + // View is not loaded yet, so it should have no arranged subviews + [self assertNil:[splitViewController _splitView] message:@"SplitView should not exist yet."]; + + // Trigger viewDidLoad + [splitViewController view]; + + // Now check if the views were added + var arrangedSubviews = [[splitViewController splitView] arrangedSubviews]; + [self assert:[arrangedSubviews count] equals:2 message:@"SplitView should have 2 subviews after loading."]; + [self assert:[arrangedSubviews objectAtIndex:0] equals:[childVC1 view]]; + [self assert:[arrangedSubviews objectAtIndex:1] equals:[childVC2 view]]; +} + + +// MARK: - Toggling and State Tests + +- (void)testToggleSidebar +{ + var splitViewController = [[CPSplitViewController alloc] init]; + var childVC1 = [[CPViewController alloc] init]; + var item1 = [CPSplitViewItem splitViewItemWithViewController:childVC1]; + [splitViewController addSplitViewItem:item1]; + + [splitViewController toggleSidebar:self]; + [self assertTrue:[item1 isCollapsed] message:@"Sidebar item should be collapsed."]; + [self assertTrue:[[childVC1 view] isHidden] message:@"Sidebar view should be hidden."]; + + [splitViewController toggleSidebar:self]; + [self assertFalse:[item1 isCollapsed] message:@"Sidebar item should be expanded again."]; + [self assertFalse:[[childVC1 view] isHidden] message:@"Sidebar view should be visible again."]; +} + +- (void)testToggleInspector +{ + var splitViewController = [[CPSplitViewController alloc] init]; + var childVC1 = [[CPViewController alloc] init]; + var childVC2 = [[CPViewController alloc] init]; + var item1 = [CPSplitViewItem splitViewItemWithViewController:childVC1]; + var item2 = [CPSplitViewItem splitViewItemWithViewController:childVC2]; + [splitViewController addSplitViewItem:item1]; + [splitViewController addSplitViewItem:item2]; + + [splitViewController toggleInspector:self]; + [self assertTrue:[item2 isCollapsed] message:@"Inspector item should be collapsed."]; + [self assertTrue:[[childVC2 view] isHidden] message:@"Inspector view should be hidden."]; + + [splitViewController toggleInspector:self]; + [self assertFalse:[item2 isCollapsed] message:@"Inspector item should be expanded again."]; + [self assertFalse:[[childVC2 view] isHidden] message:@"Inspector view should be visible again."]; +} + +- (void)testSetCollapsed +{ + var childVC = [[CPViewController alloc] init]; + var item = [CPSplitViewItem splitViewItemWithViewController:childVC]; + var childView = [childVC view]; + + [item setCollapsed:YES]; + [self assertTrue:[item isCollapsed] message:@"Item's collapsed property should be YES."]; + [self assertTrue:[childView isHidden] message:@"The item's view should be hidden."]; + + [item setCollapsed:NO]; + [self assertFalse:[item isCollapsed] message:@"Item's collapsed property should be NO."]; + [self assertFalse:[childView isHidden] message:@"The item's view should be visible."]; + + // Test idempotency + [item setCollapsed:NO]; + [self assertFalse:[item isCollapsed] message:@"Setting the same value should have no effect."]; +} + +@end \ No newline at end of file