From c519784ecc2ea669d083ae79cad1e53a2b1e5bc8 Mon Sep 17 00:00:00 2001 From: daboe01 Date: Sat, 7 Mar 2026 16:17:36 +0100 Subject: [PATCH 1/6] New: support for right-aligned buttons in CPButtonBar (#745) --- AppKit/CPButtonBar.j | 88 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 72 insertions(+), 16 deletions(-) diff --git a/AppKit/CPButtonBar.j b/AppKit/CPButtonBar.j index 28b17538d..44aba3384 100644 --- a/AppKit/CPButtonBar.j +++ b/AppKit/CPButtonBar.j @@ -36,6 +36,7 @@ BOOL _hasResizeControl; BOOL _resizeControlIsLeftAligned; CPArray _buttons; + CPArray _rightButtons; } + (id)plusButton @@ -104,6 +105,7 @@ if (self) { _buttons = []; + _rightButtons = []; [self setNeedsLayout]; } @@ -157,6 +159,30 @@ return [CPArray arrayWithArray:_buttons]; } +- (void)setRightButtons:(CPArray)buttons +{ + for (var i = [_rightButtons count] - 1; i >= 0; i--) + { + [_rightButtons[i] removeFromSuperview]; + [_rightButtons[i] removeObserver:self forKeyPath:@"hidden"]; + } + + _rightButtons = [CPArray arrayWithArray:buttons]; + + for (var i = [_rightButtons count] - 1; i >= 0; i--) + { + [_rightButtons[i] addObserver:self forKeyPath:@"hidden" options:CPKeyValueObservingOptionNew | CPKeyValueObservingOptionOld context:nil]; + [_rightButtons[i] setBordered:YES]; + } + + [self setNeedsLayout]; +} + +- (CPArray)rightButtons +{ + return [CPArray arrayWithArray:_rightButtons]; +} + - (void)setHasResizeControl:(BOOL)shouldHaveResizeControl { if (_hasResizeControl === shouldHaveResizeControl) @@ -243,46 +269,73 @@ } } - var currentButtonOffset = _resizeControlIsLeftAligned ? CGRectGetMaxX([self bounds]) + 1 : -1, - bounds = [self bounds], + var rightButtonsNotHidden = [CPArray arrayWithArray:_rightButtons], + rightCount = [rightButtonsNotHidden count]; + + while (rightCount--) + { + var button = rightButtonsNotHidden[rightCount]; + + if ([button isHidden]) + { + [button removeFromSuperview]; + [rightButtonsNotHidden removeObject:button]; + } + } + + var bounds = [self bounds], height = CGRectGetHeight(bounds) - 1, frameWidth = CGRectGetWidth(bounds), resizeRect = _hasResizeControl ? [self rectForEphemeralSubviewNamed:"resize-control-view"] : CGRectMakeZero(), resizeWidth = CGRectGetWidth(resizeRect), availableWidth = frameWidth - resizeWidth - 1; - for (var i = 0, count = [buttonsNotHidden count]; i < count; i++) + var currentLeftOffset = _resizeControlIsLeftAligned ? resizeWidth - 1 : -1, + currentRightOffset = _resizeControlIsLeftAligned ? CGRectGetMaxX(bounds) + 1 : CGRectGetMaxX(bounds) - resizeWidth + 1; + + var setupButton = function(button, isRightAligned) { - var button = buttonsNotHidden[i], - width = CGRectGetWidth([button frame]); + var width = CGRectGetWidth([button frame]); if (availableWidth > width) availableWidth -= width; else - break; + return NO; - if (_resizeControlIsLeftAligned) + if (isRightAligned) { - [button setFrame:CGRectMake(currentButtonOffset - width, 1, width, height)]; - currentButtonOffset -= width - 1; + [button setFrame:CGRectMake(currentRightOffset - width, 1, width, height)]; + currentRightOffset -= width - 1; } else - { - [button setFrame:CGRectMake(currentButtonOffset, 1, width, height)]; - currentButtonOffset += width - 1; + {[button setFrame:CGRectMake(currentLeftOffset, 1, width, height)]; + currentLeftOffset += width - 1; } [button setValue:normalColor forThemeAttribute:@"bezel-color" inStates:[CPThemeStateNormal, CPThemeStateBordered]]; - [button setValue:highlightedColor forThemeAttribute:@"bezel-color" inStates:[CPThemeStateHighlighted, CPThemeStateBordered, ]]; + [button setValue:highlightedColor forThemeAttribute:@"bezel-color" inStates:[CPThemeStateHighlighted, CPThemeStateBordered]]; [button setValue:disabledColor forThemeAttribute:@"bezel-color" inStates:[CPThemeStateDisabled, CPThemeStateBordered]]; [button setValue:textColor forThemeAttribute:@"text-color" inState:CPThemeStateBordered]; - // FIXME shouldn't need this - [button setValue:normalColor forThemeAttribute:@"bezel-color" inStates:[CPThemeStateNormal, CPThemeStateBordered, CPPopUpButtonStatePullsDown]]; + // FIXME shouldn't need this[button setValue:normalColor forThemeAttribute:@"bezel-color" inStates:[CPThemeStateNormal, CPThemeStateBordered, CPPopUpButtonStatePullsDown]]; [button setValue:highlightedColor forThemeAttribute:@"bezel-color" inStates:[CPThemeStateHighlighted, CPThemeStateBordered, CPPopUpButtonStatePullsDown]]; [button setValue:disabledColor forThemeAttribute:@"bezel-color" inStates:[CPThemeStateDisabled, CPThemeStateBordered, CPPopUpButtonStatePullsDown]]; [self addSubview:button]; + + return YES; + }; + + for (var i = 0, count = [buttonsNotHidden count]; i < count; i++) + { + if (!setupButton(buttonsNotHidden[i], _resizeControlIsLeftAligned)) + break; + } + + for (var i = 0, count = [rightButtonsNotHidden count]; i < count; i++) + { + if (!setupButton(rightButtonsNotHidden[i], YES)) + break; } if (_hasResizeControl) @@ -314,7 +367,8 @@ var CPButtonBarHasResizeControlKey = @"CPButtonBarHasResizeControlKey", CPButtonBarResizeControlIsLeftAlignedKey = @"CPButtonBarResizeControlIsLeftAlignedKey", - CPButtonBarButtonsKey = @"CPButtonBarButtonsKey"; + CPButtonBarButtonsKey = @"CPButtonBarButtonsKey", + CPButtonBarRightButtonsKey = @"CPButtonBarRightButtonsKey"; @implementation CPButtonBar (CPCoding) @@ -325,6 +379,7 @@ var CPButtonBarHasResizeControlKey = @"CPButtonBarHasResizeControlKey", [aCoder encodeBool:_hasResizeControl forKey:CPButtonBarHasResizeControlKey]; [aCoder encodeBool:_resizeControlIsLeftAligned forKey:CPButtonBarResizeControlIsLeftAlignedKey]; [aCoder encodeObject:_buttons forKey:CPButtonBarButtonsKey]; + [aCoder encodeObject:_rightButtons forKey:CPButtonBarRightButtonsKey]; } - (id)initWithCoder:(CPCoder)aCoder @@ -332,6 +387,7 @@ var CPButtonBarHasResizeControlKey = @"CPButtonBarHasResizeControlKey", if (self = [super initWithCoder:aCoder]) { _buttons = [aCoder decodeObjectForKey:CPButtonBarButtonsKey] || []; + _rightButtons = [aCoder decodeObjectForKey:CPButtonBarRightButtonsKey] || []; _hasResizeControl = [aCoder decodeBoolForKey:CPButtonBarHasResizeControlKey]; _resizeControlIsLeftAligned = [aCoder decodeBoolForKey:CPButtonBarResizeControlIsLeftAlignedKey]; } From 043029cf41b2fd4505dd16b8ba5c015f9a12b880 Mon Sep 17 00:00:00 2001 From: daboe01 Date: Fri, 3 Apr 2026 13:30:13 +0200 Subject: [PATCH 2/6] new: _CPKVOCollectionOperatorObserver --- Foundation/CPKeyValueObserving.j | 176 ++++++++++++++++++++++++++++++- 1 file changed, 174 insertions(+), 2 deletions(-) diff --git a/Foundation/CPKeyValueObserving.j b/Foundation/CPKeyValueObserving.j index f789fcbf9..3d3aa4315 100644 --- a/Foundation/CPKeyValueObserving.j +++ b/Foundation/CPKeyValueObserving.j @@ -763,9 +763,12 @@ var kvoNewAndOld = CPKeyValueObservingOptionNew | CPKeyValueObservingOpti if (!anObserver) return; - var forwarder = nil; + var forwarder = nil, + collectionOperatorMatch = aPath.match(/^(.*)\.(@\w+)(?:\.(.*))?$/); - if (aPath.indexOf('.') !== CPNotFound && aPath.charAt(0) !== '@') + if (collectionOperatorMatch) + forwarder = [[_CPKVOCollectionOperatorObserver alloc] initWithTarget:_targetObject observer:anObserver keyPath:aPath options:options context:aContext]; + else if (aPath.indexOf('.') !== CPNotFound && aPath.charAt(0) !== '@') forwarder = [[_CPKVOForwardingObserver alloc] initWithKeyPath:aPath object:_targetObject observer:anObserver options:options context:aContext]; else [self _replaceModifiersForKey:aPath]; @@ -1265,6 +1268,175 @@ var kvoNewAndOld = CPKeyValueObservingOptionNew | CPKeyValueObservingOpti @end + +@implementation _CPKVOCollectionOperatorObserver : CPObject +{ + id _target; + id _originalObserver; + CPString _fullKeyPath; + unsigned _options; + id _context; + + CPString _collectionKeyPath; + CPString _operator; + CPString _valueKeyPath; + + CPArray _observedItems; +} + +- (id)initWithTarget:(id)aTarget observer:(id)anObserver keyPath:(CPString)aKeyPath options:(unsigned)options context:(id)aContext +{ + self = [super init]; + + if (self) + { + _target = aTarget; + _originalObserver = anObserver; + _fullKeyPath = aKeyPath; + _options = options; + _context = aContext; + _observedItems = [CPArray array]; + + // Parse key path like "collectionKeyPath.@sum.valueKeyPath" + // Captures: 1=collectionKeyPath, 2=@sum, 3=valueKeyPath + var match = aKeyPath.match(/^(.*)\.(@\w+)(?:\.(.*))?$/); + if (match) + { + _collectionKeyPath = match[1]; + _operator = match[2]; + _valueKeyPath = match[3]; + } + + // Observe the collection itself on the target to catch array resets/mutations + [_target addObserver:self forKeyPath:_collectionKeyPath options:CPKeyValueObservingOptionNew | CPKeyValueObservingOptionOld context:nil]; + + [self _setupItemObservers]; + } + + return self; +} + +- (void)_setupItemObservers +{ + var collection = [_target valueForKeyPath:_collectionKeyPath]; + + if (collection && [collection respondsToSelector:@selector(objectEnumerator)]) + { + var enumerator = [collection objectEnumerator], + item; + + while ((item = [enumerator nextObject]) !== nil) + { + if (_valueKeyPath && [item respondsToSelector:@selector(addObserver:forKeyPath:options:context:)]) + [item addObserver:self forKeyPath:_valueKeyPath options:_options context:nil]; + + [_observedItems addObject:item]; + } + } +} + +- (void)_tearDownItemObservers +{ + if (_valueKeyPath) + { + var count = [_observedItems count]; + while (count--) + { + var item = [_observedItems objectAtIndex:count]; + if ([item respondsToSelector:@selector(removeObserver:forKeyPath:context:)]) + [item removeObserver:self forKeyPath:_valueKeyPath context:nil]; + } + } + + [_observedItems removeAllObjects]; +} + +- (void)observeValueForKeyPath:(CPString)keyPath ofObject:(id)object change:(CPDictionary)change context:(id)context +{ + // Branch 1: The collection on the target was modified + if (object === _target && [keyPath isEqualToString:_collectionKeyPath]) + { + var kind = [change objectForKey:CPKeyValueChangeKindKey]; + + if (kind === CPKeyValueChangeSetting) + { + [self _tearDownItemObservers]; + [self _setupItemObservers]; + } + else if (kind === CPKeyValueChangeInsertion || kind === CPKeyValueChangeReplacement) + { + if (kind === CPKeyValueChangeReplacement) + { + var oldItems = [change objectForKey:CPKeyValueChangeOldKey]; + if (oldItems) + { + for (var i = 0, len = oldItems.length; i < len; i++) + { + var item = oldItems[i]; + if (_valueKeyPath && [item respondsToSelector:@selector(removeObserver:forKeyPath:context:)]) + [item removeObserver:self forKeyPath:_valueKeyPath context:nil]; + [_observedItems removeObject:item]; + } + } + } + + var newItems = [change objectForKey:CPKeyValueChangeNewKey]; + if (newItems) + { + for (var i = 0, len = newItems.length; i < len; i++) + { + var item = newItems[i]; + if (_valueKeyPath && [item respondsToSelector:@selector(addObserver:forKeyPath:options:context:)]) + [item addObserver:self forKeyPath:_valueKeyPath options:_options context:nil]; + [_observedItems addObject:item]; + } + } + } + else if (kind === CPKeyValueChangeRemoval) + { + var oldItems = [change objectForKey:CPKeyValueChangeOldKey]; + if (oldItems) + { + for (var i = 0, len = oldItems.length; i < len; i++) + { + var item = oldItems[i]; + if (_valueKeyPath && [item respondsToSelector:@selector(removeObserver:forKeyPath:context:)]) + [item removeObserver:self forKeyPath:_valueKeyPath context:nil]; + [_observedItems removeObject:item]; + } + } + } + + [self _notifyOriginalObserver]; + } + // Branch 2: One of the children items has updated + else if (_valueKeyPath && [keyPath isEqualToString:_valueKeyPath] && [_observedItems containsObject:object]) + { + [self _notifyOriginalObserver]; + } +} + +- (void)_notifyOriginalObserver +{ + // Ask standard KVC to evaluate the entire aggregate string (e.g., @sum.value) + var newValue = [_target valueForKeyPath:_fullKeyPath], + change = @{ + CPKeyValueChangeKindKey: CPKeyValueChangeSetting, + CPKeyValueChangeNewKey: (newValue !== nil ? newValue : [CPNull null]) + }; + + [_originalObserver observeValueForKeyPath:_fullKeyPath ofObject:_target change:change context:_context]; +} + +- (void)finalize +{ + [_target removeObserver:self forKeyPath:_collectionKeyPath]; + [self _tearDownItemObservers]; +} + +@end + + @implementation _CPKVOForwardingObserver : CPObject { id _object; From 4b81001330e83c51d62ad16dec023008decba8f4 Mon Sep 17 00:00:00 2001 From: daboe01 Date: Fri, 3 Apr 2026 13:33:06 +0200 Subject: [PATCH 3/6] new: testCollectionOperatorKeyPath --- Tests/Foundation/CPKVOTest.j | 63 ++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/Tests/Foundation/CPKVOTest.j b/Tests/Foundation/CPKVOTest.j index 631e2ac80..b3215ec4e 100644 --- a/Tests/Foundation/CPKVOTest.j +++ b/Tests/Foundation/CPKVOTest.j @@ -3,6 +3,8 @@ @class CarTester @class ToManyTester +@class AccountTester +@class TransactionTester @implementation CPKVOTest : OJTestCase { @@ -436,6 +438,35 @@ [self assertTrue:_sawObservation message:@"Never recieved an observation"]; } +- (void)testCollectionOperatorKeyPath +{ + var account = [[AccountTester alloc] init], + t1 = [[TransactionTester alloc] initWithAmount:10.0], + t2 = [[TransactionTester alloc] initWithAmount:20.0]; + + [account setTransactions:[t1, t2]]; + + // 1. Test updating a child property triggers an aggregate update + [account addObserver:self forKeyPath:@"transactions.@sum.amount" options:CPKeyValueObservingOptionNew | CPKeyValueObservingOptionOld context:@"testCollectionOperatorKeyPath_ItemChange"]; + + _sawObservation = NO; + [t1 setAmount:15.0]; // KVC sum goes from 30.0 -> 35.0 + + [self assertTrue:_sawObservation message:@"Never received an observation when child item's property changed"]; + [account removeObserver:self forKeyPath:@"transactions.@sum.amount"]; + + // 2. Test replacing the whole array triggers an aggregate update + [account addObserver:self forKeyPath:@"transactions.@sum.amount" options:CPKeyValueObservingOptionNew | CPKeyValueObservingOptionOld context:@"testCollectionOperatorKeyPath_ArrayReplace"]; + + var t3 = [[TransactionTester alloc] initWithAmount:5.0]; + + _sawObservation = NO; + [account setTransactions:[t1, t2, t3]]; // KVC sum goes from 35.0 -> 40.0 + + [self assertTrue:_sawObservation message:@"Never received an observation when collection was replaced"]; + [account removeObserver:self forKeyPath:@"transactions.@sum.amount"]; +} + - (void)testPerformance { bob = [PersonTester new]; @@ -777,6 +808,16 @@ testNestedNotificationsBobCount += 1; break; + case "testCollectionOperatorKeyPath_ItemChange": + [self assert:newValue equals:35.0]; + [self assert:oldValue equals:30.0]; + break; + + case "testCollectionOperatorKeyPath_ArrayReplace": + [self assert:newValue equals:40.0]; + [self assert:oldValue equals:35.0]; + break; + default: [self assertFalse:YES message:@"unhandled observation, must be an error"]; return; @@ -955,3 +996,25 @@ } @end + +@implementation TransactionTester : CPObject +{ + float amount @accessors; +} + +- (id)initWithAmount:(float)anAmount +{ + self = [super init]; + if (self) { + amount = anAmount; + } + return self; +} + +@end + +@implementation AccountTester : CPObject +{ + CPArray transactions @accessors; +} +@end From 7fbea7913ad294172b83ba0156f74321aa640815 Mon Sep 17 00:00:00 2001 From: daboe01 Date: Fri, 3 Apr 2026 13:39:42 +0200 Subject: [PATCH 4/6] new: manual test --- Tests/Manual/CPButtonBarTest/AppController.j | 104 +++++++++ Tests/Manual/CPButtonBarTest/Info.plist | 12 ++ Tests/Manual/CPButtonBarTest/Jakefile | 81 +++++++ .../CPButtonBarTest/Resources/spinner.gif | Bin 0 -> 1434 bytes Tests/Manual/CPButtonBarTest/index-debug.html | 204 ++++++++++++++++++ Tests/Manual/CPButtonBarTest/index.html | 166 ++++++++++++++ Tests/Manual/CPButtonBarTest/main.j | 18 ++ 7 files changed, 585 insertions(+) create mode 100644 Tests/Manual/CPButtonBarTest/AppController.j create mode 100644 Tests/Manual/CPButtonBarTest/Info.plist create mode 100644 Tests/Manual/CPButtonBarTest/Jakefile create mode 100644 Tests/Manual/CPButtonBarTest/Resources/spinner.gif create mode 100644 Tests/Manual/CPButtonBarTest/index-debug.html create mode 100644 Tests/Manual/CPButtonBarTest/index.html create mode 100644 Tests/Manual/CPButtonBarTest/main.j diff --git a/Tests/Manual/CPButtonBarTest/AppController.j b/Tests/Manual/CPButtonBarTest/AppController.j new file mode 100644 index 000000000..20e1af7ae --- /dev/null +++ b/Tests/Manual/CPButtonBarTest/AppController.j @@ -0,0 +1,104 @@ +/* + * AppController.j + * CPButtonBar Right Buttons Manual Test + */ + +@import +@import +@import +@import + +@implementation AppController : CPObject +{ + CPButtonBar buttonBar; +} + +- (void)applicationDidFinishLaunching:(CPNotification)aNotification +{ + var theWindow = [[CPWindow alloc] initWithContentRect:CGRectMakeZero() styleMask:CPBorderlessBridgeWindowMask], + contentView = [theWindow contentView]; + + var bounds = [contentView bounds]; + + // 1. Create the ButtonBar + buttonBar = [[CPButtonBar alloc] initWithFrame:CGRectMake(0, CGRectGetHeight(bounds) - 28, CGRectGetWidth(bounds), 28)]; + [buttonBar setAutoresizingMask:CPViewWidthSizable | CPViewMinYMargin]; + + // Add resize control to ensure bounds calculations are correct + [buttonBar setHasResizeControl:YES]; + + // 2. Setup standard Left Buttons + var plusButton = [CPButtonBar plusButton], + minusButton = [CPButtonBar minusButton]; + + [buttonBar setButtons:[plusButton, minusButton]]; + + // 3. Setup the new Right Buttons (PR #745) + var actionButton = [CPButtonBar actionPopupButton], + customRightBtn = [[CPButton alloc] initWithFrame:CGRectMake(0, 0, 70, 24)]; + + [customRightBtn setTitle:@"Settings"]; + [buttonBar setRightButtons:[customRightBtn, actionButton]]; + + [contentView addSubview:buttonBar]; + + // --- Testing Controls --- + + // Button to test resizing alignment layout + var toggleAlignmentBtn = [CPButton buttonWithTitle:@"Toggle Resize Control Alignment"]; + [toggleAlignmentBtn setFrameOrigin:CGPointMake(20, 20)]; + [toggleAlignmentBtn sizeToFit]; + [toggleAlignmentBtn setTarget:self]; + [toggleAlignmentBtn setAction:@selector(toggleResizeAlignment:)]; + [contentView addSubview:toggleAlignmentBtn]; + + // Button to test the KVO "hidden" observer on rightButtons + var toggleVisibilityBtn = [CPButton buttonWithTitle:@"Toggle Right Button Visibility"]; + [toggleVisibilityBtn setFrameOrigin:CGPointMake(20, 60)]; + [toggleVisibilityBtn sizeToFit]; + [toggleVisibilityBtn setTarget:self]; + [toggleVisibilityBtn setAction:@selector(toggleRightVisibility:)]; + [contentView addSubview:toggleVisibilityBtn]; + + // Button to test CPCoding archiving/unarchiving of rightButtons + var testArchivingBtn = [CPButton buttonWithTitle:@"Test Archiving (CPCoding)"]; + [testArchivingBtn setFrameOrigin:CGPointMake(20, 100)]; + [testArchivingBtn sizeToFit]; + [testArchivingBtn setTarget:self]; + [testArchivingBtn setAction:@selector(testArchiving:)]; + [contentView addSubview:testArchivingBtn]; + + [theWindow orderFront:self]; +} + +- (void)toggleResizeAlignment:(id)sender +{ + // Flipping the resize control to the left shouldn't break the right-aligned buttons + [buttonBar setResizeControlIsLeftAligned:![buttonBar resizeControlIsLeftAligned]]; +} + +- (void)toggleRightVisibility:(id)sender +{ + var rightBtns = [buttonBar rightButtons]; + if ([rightBtns count] > 0) + { + var btn = rightBtns[0]; + [btn setHidden:![btn isHidden]]; + } +} + +- (void)testArchiving:(id)sender +{ + // Test the new CPButtonBarRightButtonsKey in CPCoding + var data = [CPKeyedArchiver archivedDataWithRootObject:buttonBar]; + var unarchivedBar = [CPKeyedUnarchiver unarchiveObjectWithData:data]; + + var bounds = [[buttonBar superview] bounds]; + + // Place the cloned bar directly above the original one + [unarchivedBar setFrame:CGRectMake(0, CGRectGetHeight(bounds) - 56, CGRectGetWidth(bounds), 28)]; + + [[buttonBar superview] addSubview:unarchivedBar]; +} + +@end diff --git a/Tests/Manual/CPButtonBarTest/Info.plist b/Tests/Manual/CPButtonBarTest/Info.plist new file mode 100644 index 000000000..e9a691d12 --- /dev/null +++ b/Tests/Manual/CPButtonBarTest/Info.plist @@ -0,0 +1,12 @@ + + + + + CPApplicationDelegateClass + AppController + CPBundleName + CPButtonBarTest + CPPrincipalClass + CPApplication + + diff --git a/Tests/Manual/CPButtonBarTest/Jakefile b/Tests/Manual/CPButtonBarTest/Jakefile new file mode 100644 index 000000000..542de14fd --- /dev/null +++ b/Tests/Manual/CPButtonBarTest/Jakefile @@ -0,0 +1,81 @@ +/* + * Jakefile + * CPBrowserTest + * + * Created by Ross Boucher on March 23, 2010. + * Copyright 2010, 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 ("CPBrowserTest", function(task) +{ + task.setBuildIntermediatesPath(FILE.join("Build", "CPBrowserTest.build", configuration)); + task.setBuildPath(FILE.join("Build", configuration)); + + task.setProductName("CPBrowserTest"); + task.setIdentifier("com.yourcompany.CPBrowserTest"); + task.setVersion("1.0"); + task.setAuthor("Your Company"); + task.setEmail("feedback @nospam@ yourcompany.com"); + task.setSummary("CPBrowserTest"); + 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"); +}); + +function printResults(configuration) +{ + print("----------------------------"); + print(configuration+" app built at path: "+FILE.join("Build", configuration, "CPBrowserTest")); + print("----------------------------"); +} + +task ("default", ["CPBrowserTest"], 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", "CPBrowserTest", "index.html")]); +}); + +task ("run-release", ["release"], function() +{ + OS.system(["open", FILE.join("Build", "Release", "CPBrowserTest", "index.html")]); +}); + +task ("deploy", ["release"], function() +{ + FILE.mkdirs(FILE.join("Build", "Deployment", "CPBrowserTest")); + OS.system(["press", "-f", FILE.join("Build", "Release", "CPBrowserTest"), FILE.join("Build", "Deployment", "CPBrowserTest")]); + printResults("Deployment") +}); diff --git a/Tests/Manual/CPButtonBarTest/Resources/spinner.gif b/Tests/Manual/CPButtonBarTest/Resources/spinner.gif new file mode 100644 index 0000000000000000000000000000000000000000..a5e705f6cbdf914e5e714c35a8dfef807f19e3c2 GIT binary patch literal 1434 zcmZvbdrVVj7{t$-UNOu86#VHu^|s&3rWfwHBbPG_8{SrlB{Tv1uvvj4t6zP!)#d!Ogc zP^62J^$0+~?-ZcXXpBaq%jFsw8L`=H2?+@R02Yg-*Xw;gACBV^i3CMahr>Z667S!? zk3LDe>VLEsU;sWo$Py~o!gWuaIAnaG3Sv``&tvc+8DJO!| zt4fcbQ8tW}a&xZfgtQ9BnFYLvGG|PvCNlg&uh@Q^w9Pz}+I^hCj`vu9JQADPqdkyk zR40xycD9geUgJu1e;$L`Fpa)?Wl-2&6hO@U*KrPqK(I1H=1h?2fce}6GhhP1oBZC# z1e@gt-qFa6lZrl%s1>bdxg*W)Ebt__O(4sj6(*2X@ciUClwHH#U(NRM7XAjS z+scDZ32J*PCoslsgS~#ZlCCGo`r>S6F)Ghw5wVlqHioFaw?ucD(XoLJ0j;28oPoPV z9A}dR$0SKn>uExfkl#j09o;v$BZ909R=*p{ATNq8BJW;YduX2QMOU7$a$_L5e!G^g zpbkx5G4&FZ%7m14rTN}5YB(;x7$5XOc_hf3E|Hw$TVg)P#qf~}nOn03uqsp>UG>vi zWThIoO)-1Q#@u#O;fMC#WAf@Xj70-0g9YZ;dA$HH1fW1q=9-c>>_yA0S$7M*5?}vi z)8MU`Q%P!7sEBBnd$DrKgFQ2?O%6LpdaC%F8Pn-)EC2t=j~ zoaLamla$FX!GQqWi!%s>sj-#5SJX0IF!TP=r21Z}s)k#btN0?=I7uD9C)Gb$fZ#sm zaVq7g`LwZ%PfNpN^_N-IGLHj@AV`s#4&va7_{Hw63G6BkSGXHdogTZ%QOiRb bDpZ@qYcJKM>x%b%*HX74c8MnqfHi*u-bC?g literal 0 HcmV?d00001 diff --git a/Tests/Manual/CPButtonBarTest/index-debug.html b/Tests/Manual/CPButtonBarTest/index-debug.html new file mode 100644 index 000000000..a36b1d3b9 --- /dev/null +++ b/Tests/Manual/CPButtonBarTest/index-debug.html @@ -0,0 +1,204 @@ + + + + + + + + + + + + + + + + + + __project.name__ + + + + + + + + + + + + + + +
+
+
+ +
+
+ +
+ + diff --git a/Tests/Manual/CPButtonBarTest/index.html b/Tests/Manual/CPButtonBarTest/index.html new file mode 100644 index 000000000..ac42c98a7 --- /dev/null +++ b/Tests/Manual/CPButtonBarTest/index.html @@ -0,0 +1,166 @@ + + + + + + + + + + + + + + + + + + __project.name__ + + + + + + + + + + + + +
+
+
+ +
+
+ +
+ + diff --git a/Tests/Manual/CPButtonBarTest/main.j b/Tests/Manual/CPButtonBarTest/main.j new file mode 100644 index 000000000..b953537a0 --- /dev/null +++ b/Tests/Manual/CPButtonBarTest/main.j @@ -0,0 +1,18 @@ +/* + * AppController.j + * CPButtonBarTest + * + * Created by Ross Boucher on March 23, 2010. + * Copyright 2010, Your Company All rights reserved. + */ + +@import +@import + +@import "AppController.j" + + +function main(args, namedArgs) +{ + CPApplicationMain(args, namedArgs); +} From 0e5f3b1697b0b99be46e04591381aa1e1303ee6d Mon Sep 17 00:00:00 2001 From: daboe01 Date: Fri, 3 Apr 2026 13:49:33 +0200 Subject: [PATCH 5/6] fixed caching --- Foundation/CPKeyValueObserving.j | 122 ++++++++++++++++++------------- 1 file changed, 70 insertions(+), 52 deletions(-) diff --git a/Foundation/CPKeyValueObserving.j b/Foundation/CPKeyValueObserving.j index 3d3aa4315..855342d6a 100644 --- a/Foundation/CPKeyValueObserving.j +++ b/Foundation/CPKeyValueObserving.j @@ -1282,6 +1282,7 @@ var kvoNewAndOld = CPKeyValueObservingOptionNew | CPKeyValueObservingOpti CPString _valueKeyPath; CPArray _observedItems; + id _cachedValue; } - (id)initWithTarget:(id)aTarget observer:(id)anObserver keyPath:(CPString)aKeyPath options:(unsigned)options context:(id)aContext @@ -1297,8 +1298,6 @@ var kvoNewAndOld = CPKeyValueObservingOptionNew | CPKeyValueObservingOpti _context = aContext; _observedItems = [CPArray array]; - // Parse key path like "collectionKeyPath.@sum.valueKeyPath" - // Captures: 1=collectionKeyPath, 2=@sum, 3=valueKeyPath var match = aKeyPath.match(/^(.*)\.(@\w+)(?:\.(.*))?$/); if (match) { @@ -1307,8 +1306,9 @@ var kvoNewAndOld = CPKeyValueObservingOptionNew | CPKeyValueObservingOpti _valueKeyPath = match[3]; } - // Observe the collection itself on the target to catch array resets/mutations - [_target addObserver:self forKeyPath:_collectionKeyPath options:CPKeyValueObservingOptionNew | CPKeyValueObservingOptionOld context:nil]; + _cachedValue = [_target valueForKeyPath:_fullKeyPath]; + + [_target addObserver:self forKeyPath:_collectionKeyPath options:_options context:nil]; [self _setupItemObservers]; } @@ -1353,19 +1353,50 @@ var kvoNewAndOld = CPKeyValueObservingOptionNew | CPKeyValueObservingOpti - (void)observeValueForKeyPath:(CPString)keyPath ofObject:(id)object change:(CPDictionary)change context:(id)context { - // Branch 1: The collection on the target was modified + var isPrior = [[change objectForKey:CPKeyValueChangeNotificationIsPriorKey] boolValue]; + if (object === _target && [keyPath isEqualToString:_collectionKeyPath]) { - var kind = [change objectForKey:CPKeyValueChangeKindKey]; + // Only tear down/rebuild observers when the actual change happens, not during the "Prior" notification + if (!isPrior) + { + var kind = [change objectForKey:CPKeyValueChangeKindKey]; - if (kind === CPKeyValueChangeSetting) - { - [self _tearDownItemObservers]; - [self _setupItemObservers]; - } - else if (kind === CPKeyValueChangeInsertion || kind === CPKeyValueChangeReplacement) - { - if (kind === CPKeyValueChangeReplacement) + if (kind === CPKeyValueChangeSetting) + { + [self _tearDownItemObservers]; + [self _setupItemObservers]; + } + else if (kind === CPKeyValueChangeInsertion || kind === CPKeyValueChangeReplacement) + { + if (kind === CPKeyValueChangeReplacement) + { + var oldItems = [change objectForKey:CPKeyValueChangeOldKey]; + if (oldItems) + { + for (var i = 0, len = oldItems.length; i < len; i++) + { + var item = oldItems[i]; + if (_valueKeyPath && [item respondsToSelector:@selector(removeObserver:forKeyPath:context:)]) + [item removeObserver:self forKeyPath:_valueKeyPath context:nil]; + [_observedItems removeObject:item]; + } + } + } + + var newItems = [change objectForKey:CPKeyValueChangeNewKey]; + if (newItems) + { + for (var i = 0, len = newItems.length; i < len; i++) + { + var item = newItems[i]; + if (_valueKeyPath && [item respondsToSelector:@selector(addObserver:forKeyPath:options:context:)]) + [item addObserver:self forKeyPath:_valueKeyPath options:_options context:nil]; + [_observedItems addObject:item]; + } + } + } + else if (kind === CPKeyValueChangeRemoval) { var oldItems = [change objectForKey:CPKeyValueChangeOldKey]; if (oldItems) @@ -1379,53 +1410,41 @@ var kvoNewAndOld = CPKeyValueObservingOptionNew | CPKeyValueObservingOpti } } } - - var newItems = [change objectForKey:CPKeyValueChangeNewKey]; - if (newItems) - { - for (var i = 0, len = newItems.length; i < len; i++) - { - var item = newItems[i]; - if (_valueKeyPath && [item respondsToSelector:@selector(addObserver:forKeyPath:options:context:)]) - [item addObserver:self forKeyPath:_valueKeyPath options:_options context:nil]; - [_observedItems addObject:item]; - } - } - } - else if (kind === CPKeyValueChangeRemoval) - { - var oldItems = [change objectForKey:CPKeyValueChangeOldKey]; - if (oldItems) - { - for (var i = 0, len = oldItems.length; i < len; i++) - { - var item = oldItems[i]; - if (_valueKeyPath && [item respondsToSelector:@selector(removeObserver:forKeyPath:context:)]) - [item removeObserver:self forKeyPath:_valueKeyPath context:nil]; - [_observedItems removeObject:item]; - } - } } - [self _notifyOriginalObserver]; + [self _notifyOriginalObserverIsPrior:isPrior]; } - // Branch 2: One of the children items has updated else if (_valueKeyPath && [keyPath isEqualToString:_valueKeyPath] && [_observedItems containsObject:object]) { - [self _notifyOriginalObserver]; + [self _notifyOriginalObserverIsPrior:isPrior]; } } -- (void)_notifyOriginalObserver +- (void)_notifyOriginalObserverIsPrior:(BOOL)isPrior { - // Ask standard KVC to evaluate the entire aggregate string (e.g., @sum.value) - var newValue = [_target valueForKeyPath:_fullKeyPath], - change = @{ - CPKeyValueChangeKindKey: CPKeyValueChangeSetting, - CPKeyValueChangeNewKey: (newValue !== nil ? newValue : [CPNull null]) - }; + var change = [CPMutableDictionary dictionaryWithObject:CPKeyValueChangeSetting forKey:CPKeyValueChangeKindKey]; - [_originalObserver observeValueForKeyPath:_fullKeyPath ofObject:_target change:change context:_context]; + if (isPrior) + [change setObject:YES forKey:CPKeyValueChangeNotificationIsPriorKey]; + + if (_options & CPKeyValueObservingOptionOld) + [change setObject:(_cachedValue !== nil ? _cachedValue : [CPNull null]) forKey:CPKeyValueChangeOldKey]; + + if (!isPrior) + { + var newValue = [_target valueForKeyPath:_fullKeyPath]; + if (_options & CPKeyValueObservingOptionNew) + [change setObject:(newValue !== nil ? newValue : [CPNull null]) forKey:CPKeyValueChangeNewKey]; + + [_originalObserver observeValueForKeyPath:_fullKeyPath ofObject:_target change:change context:_context]; + + // Update the cache for the next notification + _cachedValue = newValue; + } + else + { + [_originalObserver observeValueForKeyPath:_fullKeyPath ofObject:_target change:change context:_context]; + } } - (void)finalize @@ -1436,7 +1455,6 @@ var kvoNewAndOld = CPKeyValueObservingOptionNew | CPKeyValueObservingOpti @end - @implementation _CPKVOForwardingObserver : CPObject { id _object; From 0bd83b6635dab461afd62eb2c69d03e754c2ffaf Mon Sep 17 00:00:00 2001 From: daboe01 Date: Fri, 3 Apr 2026 15:20:59 +0200 Subject: [PATCH 6/6] new: clear CPPredicateEditor rows when objectValue is set to nil --- AppKit/CPRuleEditor/CPPredicateEditor.j | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/AppKit/CPRuleEditor/CPPredicateEditor.j b/AppKit/CPRuleEditor/CPPredicateEditor.j index 16cf1f685..5076b7ddc 100644 --- a/AppKit/CPRuleEditor/CPPredicateEditor.j +++ b/AppKit/CPRuleEditor/CPPredicateEditor.j @@ -269,6 +269,8 @@ _currentAnimation = nil; _sendAction = NO; + var rows = []; + if (predicate != nil) { if ((_nestingMode == CPRuleEditorNestingModeSimple || _nestingMode == CPRuleEditorNestingModeCompound) @@ -276,10 +278,13 @@ predicate = [[CPCompoundPredicate alloc] initWithType:[self _compoundPredicateTypeForRootRows] subpredicates:[CPArray arrayWithObject:predicate]]; var row = [self _rowObjectFromPredicate:predicate]; + if (row != nil) - [_boundArrayOwner setValue:[CPArray arrayWithObject:row] forKey:_boundArrayKeyPath]; + [rows addObject:row]; } + [_boundArrayOwner setValue:rows forKey:_boundArrayKeyPath]; + [self setAnimation:animation]; }