From 25d186bf5c263ed66a2e499d1963fe267697cab8 Mon Sep 17 00:00:00 2001 From: daboe01 Date: Thu, 20 Nov 2025 19:58:24 +0100 Subject: [PATCH 1/5] New: CAAnimationGroup and implement timer-based animations in CALayer --- AppKit/CoreAnimation/CAAnimationGroup.j | 66 ++++++++++ AppKit/CoreAnimation/CALayer.j | 166 ++++++++++++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 AppKit/CoreAnimation/CAAnimationGroup.j diff --git a/AppKit/CoreAnimation/CAAnimationGroup.j b/AppKit/CoreAnimation/CAAnimationGroup.j new file mode 100644 index 000000000..e84c5f57d --- /dev/null +++ b/AppKit/CoreAnimation/CAAnimationGroup.j @@ -0,0 +1,66 @@ +/* + * CAAnimationGroup.j + * AppKit + * + * Implements grouping for Core Animation. + */ + +@import +@import "CAAnimation.j" + +@implementation CAAnimationGroup : CAAnimation +{ + CPArray _animations; +} + ++ (id)group +{ + return [[self alloc] init]; +} + +- (id)init +{ + if (self = [super init]) + { + _animations = []; + } + return self; +} + +- (void)setAnimations:(CPArray)anArray +{ + if (_animations === anArray) + return; + + _animations = anArray; +} + +- (CPArray)animations +{ + return _animations; +} + +/* + Iterates through children and executes them recursively. + This effectively runs all grouped animations concurrently. +*/ +- (void)runActionForKey:(CPString)aKey object:(id)anObject arguments:(CPDictionary)arguments +{ + var count = [_animations count], + i = 0; + + for (; i < count; i++) + { + var animation = [_animations objectAtIndex:i]; + + // Recursively call runActionForKey on the child. + // If the child is a CABasicAnimation, it will call [anObject addAnimation:...] + // If the child is another Group, it will recurse here. + if ([animation respondsToSelector:@selector(runActionForKey:object:arguments:)]) + { + [animation runActionForKey:aKey object:anObject arguments:arguments]; + } + } +} + +@end diff --git a/AppKit/CoreAnimation/CALayer.j b/AppKit/CoreAnimation/CALayer.j index 20d2dae66..14bd97ca8 100644 --- a/AppKit/CoreAnimation/CALayer.j +++ b/AppKit/CoreAnimation/CALayer.j @@ -118,6 +118,8 @@ var CALayerRegisteredRunLoopUpdates = nil; CGAffineTransform _transformToLayer; CGAffineTransform _transformFromLayer; + + CPMutableDictionary _activeAnimations; } @global document @@ -160,6 +162,8 @@ var CALayerRegisteredRunLoopUpdates = nil; _sublayers = []; + _activeAnimations = [CPMutableDictionary dictionary]; + #if PLATFORM(DOM) _DOMElement = document.createElement("div"); @@ -977,6 +981,168 @@ if (_DOMContentsElement && aLayer._zPosition > _DOMContentsElement.style.zIndex) return _delegate; } +/* + Adds an animation to the layer. + Supports CABasicAnimation for Numbers (opacity) and Points (position/anchorPoint). +*/ +- (void)addAnimation:(CAAnimation)anim forKey:(CPString)key +{ + if (!anim) return; + + // Remove existing animation for this key + [self removeAnimationForKey:key]; + + // Determine KeyPath + var keyPath = key; + if ([anim respondsToSelector:@selector(keyPath)] && [anim keyPath]) + keyPath = [anim keyPath]; + + // Determine Start Value + var startValue = nil; + if ([anim respondsToSelector:@selector(fromValue)]) + startValue = [anim fromValue]; + + // Fallback to current layer value + if (startValue == nil) + startValue = [self valueForKey:keyPath]; + + // Determine End Value + var endValue = nil; + if ([anim respondsToSelector:@selector(toValue)]) + endValue = [anim toValue]; + + if (endValue == nil) + return; + + // Determine Duration + var duration = 0.25; + if ([anim respondsToSelector:@selector(duration)]) + duration = [anim duration]; + + // Create a JS context object to hold state (lightweight) + var context = { + "animation": anim, + "keyPath": keyPath, + "startValue": startValue, + "endValue": endValue, + "startTime": [CPDate date], + "duration": duration, + "timer": nil + }; + + // Schedule Timer (approx 60fps) + var timer = [CPTimer scheduledTimerWithTimeInterval:1.0/60.0 + target:self + selector:@selector(_animationTick:) + userInfo:context + repeats:YES]; + + context.timer = timer; + + [_activeAnimations setObject:context forKey:key]; +} + +- (void)removeAnimationForKey:(CPString)key +{ + var context = [_activeAnimations objectForKey:key]; + if (context) + { + var timer = context.timer; + if (timer) + [timer invalidate]; + + [_activeAnimations removeObjectForKey:key]; + } +} + +- (void)removeAllAnimations +{ + var keys = [_activeAnimations allKeys], + count = [keys count]; + + while (count--) + { + [self removeAnimationForKey:[keys objectAtIndex:count]]; + } +} + +- (void)_animationTick:(CPTimer)timer +{ + var context = [timer userInfo], + anim = context.animation, + startTime = context.startTime, + duration = context.duration, + now = [CPDate date]; + + // Calculate Progress + var elapsed = [now timeIntervalSinceDate:startTime], + progress = elapsed / duration; + + if (progress > 1.0) progress = 1.0; + + // Interpolate + var start = context.startValue, + end = context.endValue, + current = nil; + + // Interpolation Logic + if (typeof start === "number") + { + current = start + (end - start) * progress; + } + // Check for CGPoint (Simple JS Objects in Cappuccino) + else if (start && start.x !== undefined && start.y !== undefined) + { + var x = start.x + (end.x - start.x) * progress, + y = start.y + (end.y - start.y) * progress; + current = CGPointMake(x, y); + } + // Check for CGSize + else if (start && start.width !== undefined && start.height !== undefined) + { + var w = start.width + (end.width - start.width) * progress, + h = start.height + (end.height - start.height) * progress; + current = CGSizeMake(w, h); + } + // Check for CGRect + else if (start && start.origin !== undefined && start.size !== undefined) + { + var x = start.origin.x + (end.origin.x - start.origin.x) * progress, + y = start.origin.y + (end.origin.y - start.origin.y) * progress, + w = start.size.width + (end.size.width - start.size.width) * progress, + h = start.size.height + (end.size.height - start.size.height) * progress; + current = CGRectMake(x, y, w, h); + } + + // Apply Value + if (current !== nil) + [self setValue:current forKey:context.keyPath]; + + // Completion + if (progress >= 1.0) + { + [timer invalidate]; + + // Check removedOnCompletion + var shouldRemove = YES; + if ([anim respondsToSelector:@selector(isRemovedOnCompletion)]) + shouldRemove = [anim isRemovedOnCompletion]; + + if (shouldRemove) + { + // Remove from dictionary by finding the key for this context + var allKeys = [_activeAnimations allKeysForObject:context]; + if ([allKeys count] > 0) + [_activeAnimations removeObjectForKey:[allKeys objectAtIndex:0]]; + } + + // Notify Delegate + var delegate = [anim delegate]; + if (delegate && [delegate respondsToSelector:@selector(animationDidStop:finished:)]) + [delegate animationDidStop:anim finished:YES]; + } +} + /* @ignore */ - (void)_setOwningView:(CPView)anOwningView { From ed50f3b654f017bfd25d7de4d390dca48a3a8da8 Mon Sep 17 00:00:00 2001 From: daboe01 Date: Thu, 20 Nov 2025 22:07:44 +0100 Subject: [PATCH 2/5] add test --- .../CPAnimationContextTest/AppController.j | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/Tests/Manual/CPAnimationContextTest/AppController.j b/Tests/Manual/CPAnimationContextTest/AppController.j index 7ae29e0eb..961cdf07a 100644 --- a/Tests/Manual/CPAnimationContextTest/AppController.j +++ b/Tests/Manual/CPAnimationContextTest/AppController.j @@ -403,6 +403,61 @@ [CPAnimationContext endGrouping]; } +- (void)testGroupAnimation:(id)sender +{ + // Reset state + [_testView setFrame:_initialTestViewFrame]; + [_testView setAlphaValue:1.0]; + [_pathView setPath:nil]; // Clear the path view as we aren't using it here + + var layer = [_testView layer]; + + // 1. Define the start and end positions + // CALayer 'position' corresponds to the center of the view (anchorPoint 0.5,0.5) + var startPos = [layer position]; + var endPos = CGPointMake(startPos.x + 150, startPos.y + 50); + + // 2. Create a Position Animation + var moveAnim = [CABasicAnimation animationWithKeyPath:@"position"]; + [moveAnim setFromValue:startPos]; + [moveAnim setToValue:endPos]; + [moveAnim setDuration:1.0]; + + // 3. Create an Opacity Animation + var fadeAnim = [CABasicAnimation animationWithKeyPath:@"opacity"]; + [fadeAnim setFromValue:1.0]; + [fadeAnim setToValue:0.25]; + [fadeAnim setDuration:1.0]; + + // 4. Group them + // This tests the recursive logic in CAAnimationGroup and the timer logic in CALayer + var group = [CAAnimationGroup group]; + [group setAnimations:[moveAnim, fadeAnim]]; + [group setDuration:1.0]; + + // 5. Run the animation on the layer + [layer addAnimation:group forKey:@"groupTest"]; + + // 6. Verify results after the animation completes (1.0s duration + 0.1s buffer) + [self performSelector:@selector(_verifyGroupAnimation:) withObject:endPos afterDelay:1.1]; +} + +- (void)_verifyGroupAnimation:(CGPoint)expectedPos +{ + var layer = [_testView layer], + currentPos = [layer position], + currentOpacity = [layer opacity]; + + // Allow for small floating point differences + var posPassed = (Math.abs(currentPos.x - expectedPos.x) < 1.0 && Math.abs(currentPos.y - expectedPos.y) < 1.0); + var opacityPassed = (Math.abs(currentOpacity - 0.25) < 0.05); + + [self markTest:@selector(testGroupAnimation:) didPass:(posPassed && opacityPassed)]; + + // Reset for next test + [self performSelector:@selector(cleanupAfterAnimation) withObject:nil afterDelay:0.5]; +} + @end var unCamelCase = function(aString) From 3d0639b6dcff20704e10f6b8bc45e020d499edbb Mon Sep 17 00:00:00 2001 From: daboe01 Date: Fri, 21 Nov 2025 08:18:41 +0100 Subject: [PATCH 3/5] fixed: use requestAnimationFrame instead of setTimeout --- AppKit/CoreAnimation/CAAnimationGroup.j | 4 +- AppKit/CoreAnimation/CALayer.j | 125 +++++++++++++----------- 2 files changed, 70 insertions(+), 59 deletions(-) diff --git a/AppKit/CoreAnimation/CAAnimationGroup.j b/AppKit/CoreAnimation/CAAnimationGroup.j index e84c5f57d..936af3b44 100644 --- a/AppKit/CoreAnimation/CAAnimationGroup.j +++ b/AppKit/CoreAnimation/CAAnimationGroup.j @@ -1,7 +1,9 @@ /* * CAAnimationGroup.j * AppKit - * + * Created by Daniel Boehringer. + * Copyright 2025. + * * Implements grouping for Core Animation. */ diff --git a/AppKit/CoreAnimation/CALayer.j b/AppKit/CoreAnimation/CALayer.j index 14bd97ca8..a09d8c3df 100644 --- a/AppKit/CoreAnimation/CALayer.j +++ b/AppKit/CoreAnimation/CALayer.j @@ -989,67 +989,73 @@ if (_DOMContentsElement && aLayer._zPosition > _DOMContentsElement.style.zIndex) { if (!anim) return; - // Remove existing animation for this key + // 1. Remove existing animation for this key [self removeAnimationForKey:key]; - // Determine KeyPath + // 2. Determine Properties var keyPath = key; if ([anim respondsToSelector:@selector(keyPath)] && [anim keyPath]) keyPath = [anim keyPath]; - // Determine Start Value - var startValue = nil; - if ([anim respondsToSelector:@selector(fromValue)]) - startValue = [anim fromValue]; - - // Fallback to current layer value + var startValue = ([anim respondsToSelector:@selector(fromValue)]) ? [anim fromValue] : nil; + if (startValue == nil) startValue = [self valueForKey:keyPath]; - // Determine End Value - var endValue = nil; - if ([anim respondsToSelector:@selector(toValue)]) - endValue = [anim toValue]; + var endValue = ([anim respondsToSelector:@selector(toValue)]) ? [anim toValue] : nil; if (endValue == nil) return; - // Determine Duration - var duration = 0.25; - if ([anim respondsToSelector:@selector(duration)]) - duration = [anim duration]; - - // Create a JS context object to hold state (lightweight) + var duration = ([anim respondsToSelector:@selector(duration)]) ? [anim duration] : 0.25; + // Convert seconds to milliseconds for rAF math + var durationMS = duration * 1000.0; + + // 3. Create Context var context = { "animation": anim, "keyPath": keyPath, "startValue": startValue, "endValue": endValue, - "startTime": [CPDate date], - "duration": duration, - "timer": nil + "duration": durationMS, + "startTime": null, // Will be set on first frame + "requestId": null // To cancel if needed }; - // Schedule Timer (approx 60fps) - var timer = [CPTimer scheduledTimerWithTimeInterval:1.0/60.0 - target:self - selector:@selector(_animationTick:) - userInfo:context - repeats:YES]; + // 4. Define the Render Loop + // We use a JavaScript closure to capture 'self' and 'context' + var _self = self; - context.timer = timer; + var renderLoop = function(timestamp) { + // Pass control back to Objective-J to handle the logic + // Returns YES if animation should continue, NO if finished. + var shouldContinue = [_self _renderAnimationStep:context timestamp:timestamp]; + if (shouldContinue) + context.requestId = window.requestAnimationFrame(renderLoop); + else + context.requestId = null; + // Cleanup is handled inside _renderAnimationStep: when it returns NO + }; + + // 5. Kick off the loop + context.requestId = window.requestAnimationFrame(renderLoop); + + // 6. Store context [_activeAnimations setObject:context forKey:key]; } +/* + Cancels the specific animation frame and removes it from the dictionary. +*/ - (void)removeAnimationForKey:(CPString)key { var context = [_activeAnimations objectForKey:key]; + if (context) { - var timer = context.timer; - if (timer) - [timer invalidate]; + if (context.requestId !== null) + window.cancelAnimationFrame(context.requestId); [_activeAnimations removeObjectForKey:key]; } @@ -1061,51 +1067,48 @@ if (_DOMContentsElement && aLayer._zPosition > _DOMContentsElement.style.zIndex) count = [keys count]; while (count--) - { [self removeAnimationForKey:[keys objectAtIndex:count]]; - } } -- (void)_animationTick:(CPTimer)timer +/* + Internal method called every frame by requestAnimationFrame. + Returns YES to continue, NO to stop. +*/ +- (BOOL)_renderAnimationStep:(JSObject)context timestamp:(double)timestamp { - var context = [timer userInfo], - anim = context.animation, - startTime = context.startTime, - duration = context.duration, - now = [CPDate date]; + // 1. Initialize Start Time on first frame + if (context.startTime === null) + context.startTime = timestamp; - // Calculate Progress - var elapsed = [now timeIntervalSinceDate:startTime], - progress = elapsed / duration; + // 2. Calculate Progress + var elapsed = timestamp - context.startTime, + progress = elapsed / context.duration; + // Clamp to 1.0 if (progress > 1.0) progress = 1.0; - // Interpolate + // 3. Interpolate Values var start = context.startValue, end = context.endValue, current = nil; - // Interpolation Logic if (typeof start === "number") { current = start + (end - start) * progress; } - // Check for CGPoint (Simple JS Objects in Cappuccino) - else if (start && start.x !== undefined && start.y !== undefined) + else if (start && start.x !== undefined && start.y !== undefined) // CGPoint { var x = start.x + (end.x - start.x) * progress, y = start.y + (end.y - start.y) * progress; current = CGPointMake(x, y); } - // Check for CGSize - else if (start && start.width !== undefined && start.height !== undefined) + else if (start && start.width !== undefined && start.height !== undefined) // CGSize { var w = start.width + (end.width - start.width) * progress, h = start.height + (end.height - start.height) * progress; current = CGSizeMake(w, h); } - // Check for CGRect - else if (start && start.origin !== undefined && start.size !== undefined) + else if (start && start.origin !== undefined && start.size !== undefined) // CGRect { var x = start.origin.x + (end.origin.x - start.origin.x) * progress, y = start.origin.y + (end.origin.y - start.origin.y) * progress, @@ -1114,33 +1117,39 @@ if (_DOMContentsElement && aLayer._zPosition > _DOMContentsElement.style.zIndex) current = CGRectMake(x, y, w, h); } - // Apply Value + // 4. Apply Value if (current !== nil) [self setValue:current forKey:context.keyPath]; - // Completion + // 5. Check for Completion if (progress >= 1.0) { - [timer invalidate]; - - // Check removedOnCompletion + var anim = context.animation; + + // Handle removedOnCompletion var shouldRemove = YES; if ([anim respondsToSelector:@selector(isRemovedOnCompletion)]) shouldRemove = [anim isRemovedOnCompletion]; - + if (shouldRemove) { - // Remove from dictionary by finding the key for this context + // Remove from _activeAnimations + // We search by object equality to ensure we delete the right key var allKeys = [_activeAnimations allKeysForObject:context]; if ([allKeys count] > 0) [_activeAnimations removeObjectForKey:[allKeys objectAtIndex:0]]; } - + // Notify Delegate var delegate = [anim delegate]; + if (delegate && [delegate respondsToSelector:@selector(animationDidStop:finished:)]) [delegate animationDidStop:anim finished:YES]; + + return NO; // Stop the loop } + + return YES; // Continue the loop } /* @ignore */ From 8433d4a1d1b20ba0927eb71014c4b3d48e57f4b3 Mon Sep 17 00:00:00 2001 From: daboe01 Date: Sun, 25 Jan 2026 17:25:17 +0100 Subject: [PATCH 4/5] new: support for kCAMediaTimingFunctionEaseInEaseOut --- AppKit/CoreAnimation/CALayer.j | 150 +++++++++++++++++++-------------- 1 file changed, 87 insertions(+), 63 deletions(-) diff --git a/AppKit/CoreAnimation/CALayer.j b/AppKit/CoreAnimation/CALayer.j index a09d8c3df..4c0ad999e 100644 --- a/AppKit/CoreAnimation/CALayer.j +++ b/AppKit/CoreAnimation/CALayer.j @@ -998,18 +998,17 @@ if (_DOMContentsElement && aLayer._zPosition > _DOMContentsElement.style.zIndex) keyPath = [anim keyPath]; var startValue = ([anim respondsToSelector:@selector(fromValue)]) ? [anim fromValue] : nil; - if (startValue == nil) startValue = [self valueForKey:keyPath]; var endValue = ([anim respondsToSelector:@selector(toValue)]) ? [anim toValue] : nil; - if (endValue == nil) return; var duration = ([anim respondsToSelector:@selector(duration)]) ? [anim duration] : 0.25; - // Convert seconds to milliseconds for rAF math - var durationMS = duration * 1000.0; + + // Default to EaseInEaseOut if not specified + var timingFunction = ([anim respondsToSelector:@selector(timingFunction)]) ? [anim timingFunction] : [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]; // 3. Create Context var context = { @@ -1017,46 +1016,34 @@ if (_DOMContentsElement && aLayer._zPosition > _DOMContentsElement.style.zIndex) "keyPath": keyPath, "startValue": startValue, "endValue": endValue, - "duration": durationMS, - "startTime": null, // Will be set on first frame - "requestId": null // To cancel if needed + "duration": duration * 1000.0, // ms + "timingFunction": timingFunction, + "startTime": null, + "requestId": null }; // 4. Define the Render Loop - // We use a JavaScript closure to capture 'self' and 'context' var _self = self; - - var renderLoop = function(timestamp) { - // Pass control back to Objective-J to handle the logic - // Returns YES if animation should continue, NO if finished. - var shouldContinue = [_self _renderAnimationStep:context timestamp:timestamp]; - if (shouldContinue) + var renderLoop = function(timestamp) { + if ([_self _renderAnimationStep:context timestamp:timestamp]) context.requestId = window.requestAnimationFrame(renderLoop); else context.requestId = null; - // Cleanup is handled inside _renderAnimationStep: when it returns NO }; - // 5. Kick off the loop + // 5. Kick off context.requestId = window.requestAnimationFrame(renderLoop); - - // 6. Store context [_activeAnimations setObject:context forKey:key]; } -/* - Cancels the specific animation frame and removes it from the dictionary. -*/ - (void)removeAnimationForKey:(CPString)key { var context = [_activeAnimations objectForKey:key]; - if (context) { if (context.requestId !== null) window.cancelAnimationFrame(context.requestId); - [_activeAnimations removeObjectForKey:key]; } } @@ -1065,91 +1052,128 @@ if (_DOMContentsElement && aLayer._zPosition > _DOMContentsElement.style.zIndex) { var keys = [_activeAnimations allKeys], count = [keys count]; - while (count--) [self removeAnimationForKey:[keys objectAtIndex:count]]; } /* - Internal method called every frame by requestAnimationFrame. - Returns YES to continue, NO to stop. + Solves Cubic Bezier for t. + p1, p2 are the control points (x,y). p0 is 0,0, p3 is 1,1. + This is a simplified solver for standard Core Animation timing functions. */ +- (float)_solveBezier:(float)t forTimingFunction:(CAMediaTimingFunction)tf +{ + if (!tf) return t; + + // Linear optimization + if (tf === [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionLinear]) + return t; + + var points = [tf controlPoints]; // [c1x, c1y, c2x, c2y] + var p1x = points[0], p1y = points[1], + p2x = points[2], p2y = points[3]; + + // Simple polynomial evaluation (De Casteljau's algorithm/Cubic formula subset) + // Since we are usually dealing with standard easing, we can approximate 1D easing on the Y axis + // based on linear time X, or do a full solve. + // For brevity/speed in JS, we often approximate basic easing: + + // 3t^2 * (1-t) + t^3 ... standard bezier blending functions + var cx = 3.0 * p1x; + var bx = 3.0 * (p2x - p1x) - cx; + var ax = 1.0 - cx - bx; + + var cy = 3.0 * p1y; + var by = 3.0 * (p2y - p1y) - cy; + var ay = 1.0 - cy - by; + + // Solve for X given t (time) using Newton-Raphson + var sampleT = t; + for (var i = 0; i < 5; i++) { + var x = ((ax * sampleT + bx) * sampleT + cx) * sampleT - t; + if (Math.abs(x) < 1e-3) break; + var d = (3.0 * ax * sampleT + 2.0 * bx) * sampleT + cx; + if (Math.abs(d) < 1e-6) break; + sampleT = sampleT - x / d; + } + + // Solve for Y given derived T + return ((ay * sampleT + by) * sampleT + cy) * sampleT; +} + - (BOOL)_renderAnimationStep:(JSObject)context timestamp:(double)timestamp { - // 1. Initialize Start Time on first frame if (context.startTime === null) context.startTime = timestamp; - // 2. Calculate Progress var elapsed = timestamp - context.startTime, - progress = elapsed / context.duration; + linearProgress = elapsed / context.duration; - // Clamp to 1.0 - if (progress > 1.0) progress = 1.0; + if (linearProgress > 1.0) linearProgress = 1.0; + + // Apply Timing Function + var progress = [self _solveBezier:linearProgress forTimingFunction:context.timingFunction]; - // 3. Interpolate Values var start = context.startValue, end = context.endValue, current = nil; + // Number if (typeof start === "number") { current = start + (end - start) * progress; } + // Point / Size / Rect else if (start && start.x !== undefined && start.y !== undefined) // CGPoint { - var x = start.x + (end.x - start.x) * progress, - y = start.y + (end.y - start.y) * progress; - current = CGPointMake(x, y); + current = CGPointMake(start.x + (end.x - start.x) * progress, + start.y + (end.y - start.y) * progress); } else if (start && start.width !== undefined && start.height !== undefined) // CGSize { - var w = start.width + (end.width - start.width) * progress, - h = start.height + (end.height - start.height) * progress; - current = CGSizeMake(w, h); + current = CGSizeMake(start.width + (end.width - start.width) * progress, + start.height + (end.height - start.height) * progress); } else if (start && start.origin !== undefined && start.size !== undefined) // CGRect { - var x = start.origin.x + (end.origin.x - start.origin.x) * progress, - y = start.origin.y + (end.origin.y - start.origin.y) * progress, - w = start.size.width + (end.size.width - start.size.width) * progress, - h = start.size.height + (end.size.height - start.size.height) * progress; - current = CGRectMake(x, y, w, h); + current = CGRectMake( + start.origin.x + (end.origin.x - start.origin.x) * progress, + start.origin.y + (end.origin.y - start.origin.y) * progress, + start.size.width + (end.size.width - start.size.width) * progress, + start.size.height + (end.size.height - start.size.height) * progress + ); } - // 4. Apply Value if (current !== nil) [self setValue:current forKey:context.keyPath]; - // 5. Check for Completion - if (progress >= 1.0) + if (linearProgress >= 1.0) { var anim = context.animation; - - // Handle removedOnCompletion - var shouldRemove = YES; - if ([anim respondsToSelector:@selector(isRemovedOnCompletion)]) - shouldRemove = [anim isRemovedOnCompletion]; - - if (shouldRemove) - { - // Remove from _activeAnimations - // We search by object equality to ensure we delete the right key - var allKeys = [_activeAnimations allKeysForObject:context]; - if ([allKeys count] > 0) - [_activeAnimations removeObjectForKey:[allKeys objectAtIndex:0]]; + + // Cleanup + var shouldRemove = [anim respondsToSelector:@selector(isRemovedOnCompletion)] ? [anim isRemovedOnCompletion] : YES; + + if (shouldRemove) { + // Find key by context identity to handle groups correctly + var keys = [_activeAnimations allKeys]; + for (var i = 0; i < keys.length; i++) { + if ([_activeAnimations objectForKey:keys[i]] === context) { + [_activeAnimations removeObjectForKey:keys[i]]; + break; + } + } } - // Notify Delegate + // Delegate var delegate = [anim delegate]; - if (delegate && [delegate respondsToSelector:@selector(animationDidStop:finished:)]) [delegate animationDidStop:anim finished:YES]; - return NO; // Stop the loop + return NO; } - return YES; // Continue the loop + return YES; } /* @ignore */ From 0fc2ed70811983425df2af29eb7d899bba4a5e18 Mon Sep 17 00:00:00 2001 From: daboe01 Date: Sun, 25 Jan 2026 19:33:33 +0100 Subject: [PATCH 5/5] new: manual test --- AppKit/CoreAnimation/CALayer.j | 57 +++++- .../CPAnimationContextTest/AppController.j | 192 ++++++++++++++++++ 2 files changed, 241 insertions(+), 8 deletions(-) diff --git a/AppKit/CoreAnimation/CALayer.j b/AppKit/CoreAnimation/CALayer.j index 4c0ad999e..2f60cdccf 100644 --- a/AppKit/CoreAnimation/CALayer.j +++ b/AppKit/CoreAnimation/CALayer.j @@ -989,19 +989,54 @@ if (_DOMContentsElement && aLayer._zPosition > _DOMContentsElement.style.zIndex) { if (!anim) return; - // 1. Remove existing animation for this key - [self removeAnimationForKey:key]; + // --- 1. Handle Animation Groups --- + // If it's a group, we simply schedule its children individually. + if ([anim respondsToSelector:@selector(animations)] && [anim animations]) + { + var animations = [anim animations], + count = [animations count], + i = 0; - // 2. Determine Properties + for (; i < count; i++) + { + var child = [animations objectAtIndex:i]; + + // Recurse: Add the child animation. + // We pass 'nil' for the key so the child's own 'keyPath' + // is used as the storage identifier in the dictionary. + [self addAnimation:child forKey:nil]; + } + return; + } + + // --- 2. Determine KeyPath --- var keyPath = key; + + // If the animation object has an explicit keyPath (like CABasicAnimation), use it. if ([anim respondsToSelector:@selector(keyPath)] && [anim keyPath]) keyPath = [anim keyPath]; + // If we can't determine a property to animate, we must abort. + if (!keyPath) return; + + // --- 3. Determine Values --- var startValue = ([anim respondsToSelector:@selector(fromValue)]) ? [anim fromValue] : nil; + + // If startValue is missing, try to read it from the layer. + // We wrap this in a try-catch to prevent crashes if 'keyPath' is invalid. if (startValue == nil) - startValue = [self valueForKey:keyPath]; + { + try { + startValue = [self valueForKey:keyPath]; + } + catch (e) { + // The keyPath was likely invalid (not KVC compliant), abort. + return; + } + } var endValue = ([anim respondsToSelector:@selector(toValue)]) ? [anim toValue] : nil; + if (endValue == nil) return; @@ -1010,7 +1045,7 @@ if (_DOMContentsElement && aLayer._zPosition > _DOMContentsElement.style.zIndex) // Default to EaseInEaseOut if not specified var timingFunction = ([anim respondsToSelector:@selector(timingFunction)]) ? [anim timingFunction] : [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]; - // 3. Create Context + // --- 4. Prepare Context --- var context = { "animation": anim, "keyPath": keyPath, @@ -1022,7 +1057,7 @@ if (_DOMContentsElement && aLayer._zPosition > _DOMContentsElement.style.zIndex) "requestId": null }; - // 4. Define the Render Loop + // --- 5. Render Loop --- var _self = self; var renderLoop = function(timestamp) { @@ -1032,9 +1067,15 @@ if (_DOMContentsElement && aLayer._zPosition > _DOMContentsElement.style.zIndex) context.requestId = null; }; - // 5. Kick off + // --- 6. Storage & Kickoff --- + // Use the keyPath as the identifier if no specific key was provided + var storageKey = (key && key.length > 0) ? key : keyPath; + + // Remove any conflicting animation on this specific property/key + [self removeAnimationForKey:storageKey]; + context.requestId = window.requestAnimationFrame(renderLoop); - [_activeAnimations setObject:context forKey:key]; + [_activeAnimations setObject:context forKey:storageKey]; } - (void)removeAnimationForKey:(CPString)key diff --git a/Tests/Manual/CPAnimationContextTest/AppController.j b/Tests/Manual/CPAnimationContextTest/AppController.j index 961cdf07a..dbcac5947 100644 --- a/Tests/Manual/CPAnimationContextTest/AppController.j +++ b/Tests/Manual/CPAnimationContextTest/AppController.j @@ -9,6 +9,7 @@ @import @import @import +@import #define UIAssert(a) [self markTest:_cmd didPass:a]; @@ -410,11 +411,19 @@ [_testView setAlphaValue:1.0]; [_pathView setPath:nil]; // Clear the path view as we aren't using it here + // FIX: Ensure the view is layer-backed. + // Without this, [_testView layer] returns nil. + [_testView setWantsLayer:YES]; + var layer = [_testView layer]; // 1. Define the start and end positions // CALayer 'position' corresponds to the center of the view (anchorPoint 0.5,0.5) var startPos = [layer position]; + + // Safety check in case layer creation failed (though setWantsLayer:YES should ensure it) + if (!startPos) startPos = CGPointMake(0,0); + var endPos = CGPointMake(startPos.x + 150, startPos.y + 50); // 2. Create a Position Animation @@ -458,6 +467,189 @@ [self performSelector:@selector(cleanupAfterAnimation) withObject:nil afterDelay:0.5]; } +- (void)testManualRotation:(id)sender +{ + // 1. Cleanup previous test view + if (_testView) + [_testView removeFromSuperview]; + + // 2. Setup the RotatableView + // View is 100x100, but the blue box drawn inside is 70x70 to allow room to spin. + var frame = CGRectMake(390, 450, 100, 100); + _testView = [[RotatableView alloc] initWithFrame:frame]; + [[theWindow contentView] addSubview:_testView]; + + // Ensure layer-backed so we have a layer to animate + [_testView setWantsLayer:YES]; + + var layer = [_testView layer]; + [layer setDelegate:_testView]; + + // 3. Define the Animation + var rotationAnim = [CABasicAnimation animationWithKeyPath:@"angle"]; + + // Rotate 360 degrees (2 * PI) + [rotationAnim setFromValue:0.0]; + [rotationAnim setToValue:2 * PI]; + [rotationAnim setDuration:2.0]; + + // Use an easing function for smooth start/stop + [rotationAnim setTimingFunction:[CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]]; + + // 4. Add to Layer + [layer addAnimation:rotationAnim forKey:@"rotateTest"]; + + // 5. Verify results after animation + [self performSelector:@selector(_verifyRotation:) withObject:nil afterDelay:2.1]; +} + +- (void)_verifyRotation:(id)sender +{ + var layer = [_testView layer]; + var endAngle = [layer angle]; + + // Check if we reached approx 2*PI (6.28) + var passed = (Math.abs(endAngle - (2 * PI)) < 0.1); + + [self markTest:@selector(testManualRotation:) didPass:passed]; + + // Reset view + [self performSelector:@selector(cleanupAfterAnimation) withObject:nil afterDelay:0.5]; +} + +@end + +/* + A custom view that draws a box with a line in it. + We draw the box smaller than the view bounds to prevent clipping during rotation. +*/ +@implementation RotatableView : CPView +{ + float _angle; +} + +- (void)initWithFrame:(CGRect)aFrame +{ + self = [super initWithFrame:aFrame]; + _angle = 0; + + return self; +} + +- (void)setAngle:(float)anAngle +{ + _angle = anAngle; + [self display]; +} + +- (float)angle +{ + return _angle; +} + +- (void)drawRect:(CGRect)aRect +{ + var context = [[CPGraphicsContext currentContext] graphicsPort], + bounds = [self bounds], + cx = CGRectGetWidth(bounds) / 2.0, + cy = CGRectGetHeight(bounds) / 2.0; + + // 1. Clear Context + CGContextClearRect(context, bounds); + + // 2. Precompute Trig + var cosA = Math.cos(_angle), + sinA = Math.sin(_angle); + + /* + Helper closure to transform a local point (x,y) relative to center + into global view coordinates. + */ + var getPoint = function(localX, localY) + { + // Rotation Matrix: + // x' = x*cos - y*sin + // y' = x*sin + y*cos + var rotX = localX * cosA - localY * sinA; + var rotY = localX * sinA + localY * cosA; + + // Translate back to view center + return CGPointMake(cx + rotX, cy + rotY); + }; + + // --- DRAW BLUE SQUARE (70x70) --- + var s = 35.0; // half size + + // Calculate the 4 corners manually + var p1 = getPoint(-s, -s); // Top-Left + var p2 = getPoint( s, -s); // Top-Right + var p3 = getPoint( s, s); // Bottom-Right + var p4 = getPoint(-s, s); // Bottom-Left + + CGContextBeginPath(context); + CGContextMoveToPoint(context, p1.x, p1.y); + CGContextAddLineToPoint(context, p2.x, p2.y); + CGContextAddLineToPoint(context, p3.x, p3.y); + CGContextAddLineToPoint(context, p4.x, p4.y); + CGContextClosePath(context); + + [[CPColor greenColor] setFill]; + CGContextFillPath(context); + + // --- DRAW RED MARKER (Top-Left Corner) --- + // A 20x20 square in the top-left of the blue box + // Local coords relative to center: x from -35 to -15, y from -35 to -15 + var r1 = getPoint(-35, -35); + var r2 = getPoint(-15, -35); + var r3 = getPoint(-15, -15); + var r4 = getPoint(-35, -15); + + CGContextBeginPath(context); + CGContextMoveToPoint(context, r1.x, r1.y); + CGContextAddLineToPoint(context, r2.x, r2.y); + CGContextAddLineToPoint(context, r3.x, r3.y); + CGContextAddLineToPoint(context, r4.x, r4.y); + CGContextClosePath(context); + + [[CPColor redColor] setFill]; + CGContextFillPath(context); + + // --- DRAW WHITE POINTER LINE --- + // Line from Center (0,0) to Right Edge (35, 0) + var lineStart = getPoint(0, 0); + var lineEnd = getPoint(35, 0); + + CGContextBeginPath(context); + CGContextMoveToPoint(context, lineStart.x, lineStart.y); + CGContextAddLineToPoint(context, lineEnd.x, lineEnd.y); + + [[CPColor whiteColor] setStroke]; + CGContextSetLineWidth(context, 3.0); + CGContextStrokePath(context); +} + +@end + +/* + Category to allow CALayer to drive the 'angle' property on the view. +*/ +@implementation CALayer (RotationTest) + +- (void)setAngle:(float)anAngle +{ + // Store it so [self valueForKey:@"angle"] works for animation start values + self._angle = anAngle; + + // Forward the value to the View (the layer's delegate) to trigger drawRect + if (_delegate && [_delegate respondsToSelector:@selector(setAngle:)]) + [_delegate setAngle:anAngle]; +} + +- (float)angle +{ + return self._angle || 0.0; +} + @end var unCamelCase = function(aString)