diff --git a/AppKit/CoreAnimation/CAAnimationGroup.j b/AppKit/CoreAnimation/CAAnimationGroup.j new file mode 100644 index 000000000..936af3b44 --- /dev/null +++ b/AppKit/CoreAnimation/CAAnimationGroup.j @@ -0,0 +1,68 @@ +/* + * CAAnimationGroup.j + * AppKit + * Created by Daniel Boehringer. + * Copyright 2025. + * + * 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..2f60cdccf 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,242 @@ 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; + + // --- 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; + + 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) + { + 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; + + var duration = ([anim respondsToSelector:@selector(duration)]) ? [anim duration] : 0.25; + + // Default to EaseInEaseOut if not specified + var timingFunction = ([anim respondsToSelector:@selector(timingFunction)]) ? [anim timingFunction] : [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]; + + // --- 4. Prepare Context --- + var context = { + "animation": anim, + "keyPath": keyPath, + "startValue": startValue, + "endValue": endValue, + "duration": duration * 1000.0, // ms + "timingFunction": timingFunction, + "startTime": null, + "requestId": null + }; + + // --- 5. Render Loop --- + var _self = self; + + var renderLoop = function(timestamp) { + if ([_self _renderAnimationStep:context timestamp:timestamp]) + context.requestId = window.requestAnimationFrame(renderLoop); + else + context.requestId = null; + }; + + // --- 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:storageKey]; +} + +- (void)removeAnimationForKey:(CPString)key +{ + var context = [_activeAnimations objectForKey:key]; + if (context) + { + if (context.requestId !== null) + window.cancelAnimationFrame(context.requestId); + [_activeAnimations removeObjectForKey:key]; + } +} + +- (void)removeAllAnimations +{ + var keys = [_activeAnimations allKeys], + count = [keys count]; + while (count--) + [self removeAnimationForKey:[keys objectAtIndex:count]]; +} + +/* + 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 +{ + if (context.startTime === null) + context.startTime = timestamp; + + var elapsed = timestamp - context.startTime, + linearProgress = elapsed / context.duration; + + if (linearProgress > 1.0) linearProgress = 1.0; + + // Apply Timing Function + var progress = [self _solveBezier:linearProgress forTimingFunction:context.timingFunction]; + + 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 + { + 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 + { + 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 + { + 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 + ); + } + + if (current !== nil) + [self setValue:current forKey:context.keyPath]; + + if (linearProgress >= 1.0) + { + var anim = context.animation; + + // 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; + } + } + } + + // Delegate + var delegate = [anim delegate]; + if (delegate && [delegate respondsToSelector:@selector(animationDidStop:finished:)]) + [delegate animationDidStop:anim finished:YES]; + + return NO; + } + + return YES; +} + /* @ignore */ - (void)_setOwningView:(CPView)anOwningView { diff --git a/Tests/Manual/CPAnimationContextTest/AppController.j b/Tests/Manual/CPAnimationContextTest/AppController.j index 7ae29e0eb..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]; @@ -403,6 +404,252 @@ [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 + + // 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 + 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]; +} + +- (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)