From 1f2defae70da7b162fd097fea311801aa2c463b2 Mon Sep 17 00:00:00 2001 From: Cameron Webb Date: Tue, 27 Oct 2026 16:26:49 -0500 Subject: [PATCH 1/5] Page curl: implement the state machine and the rest of its tests The machine mirrors the controller's transitions: a settle during a bake or a turn is kept and baked after, a failed bake blanks the slots and asks for a settle up to three times, a landed turn drops the settle for the page it left until the manager moves or another page bakes. Results that arrive in the wrong state change nothing, so a bake, landing or settle request from before a teardown is inert. Covers the nine groups of the test plan, 46 tests. The ordering sweep replays every sequence of five events from the enabled state and checks the invariants after each step. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01635Bx126DsG1CbDzDxxBJx --- apple-tests/RNCPageCurlStateMachineTests.m | 474 +++++++++++++++++++++ apple/RNCPageCurlStateMachine.m | 291 ++++++++++++- 2 files changed, 751 insertions(+), 14 deletions(-) diff --git a/apple-tests/RNCPageCurlStateMachineTests.m b/apple-tests/RNCPageCurlStateMachineTests.m index 6d73be609..6c326280a 100644 --- a/apple-tests/RNCPageCurlStateMachineTests.m +++ b/apple-tests/RNCPageCurlStateMachineTests.m @@ -40,6 +40,27 @@ return lines; } +// the token of the settle request in `effects`, or 0 when there is none +static NSUInteger SettleRequestToken(NSArray *effects) +{ + for (RNCPageCurlEffect *effect in effects) { + if (effect.kind == RNCPageCurlEffectRequestSettle) { + return effect.token; + } + } + return 0; +} + +static BOOL Contains(NSArray *effects, RNCPageCurlEffectKind kind) +{ + for (RNCPageCurlEffect *effect in effects) { + if (effect.kind == kind) { + return YES; + } + } + return NO; +} + @interface RNCPageCurlStateMachineTests : XCTestCase @end @@ -60,6 +81,14 @@ - (void)bakeFirstPage [_machine bakeFinished:YES]; } +// first page baked, then a turn toward the next page landed; the manager has not settled yet +- (void)landTurn +{ + [self bakeFirstPage]; + [_machine turnBegan]; + [_machine turnFinishedToward:@"next" completed:YES]; +} + #pragma mark - the bake loop - (void)testStateNamesMatchTheLog @@ -131,4 +160,449 @@ - (void)testEveryStateChangeMovesTheSerial XCTAssertEqual(_machine.stateSerial, baking, @"a kept settle changes no state"); } +#pragma mark - failed bakes + +- (void)testAFailedBakeBlanksTheSlotsAndAsksForASettle +{ + [_machine enable]; + [_machine settled:Settle(3, 1)]; + XCTAssertEqualObjects(Describe([_machine bakeFinished:NO]), (@[@"blank slots", @"state awaitingSettle (bake failed)", @"request settle"])); + XCTAssertEqual(_machine.state, RNCPageCurlStateAwaitingSettle); +} + +- (void)testThreeFailedBakesInARowGoIdleWithoutAsking +{ + [_machine enable]; + [_machine settled:Settle(3, 1)]; + [_machine bakeFinished:NO]; + [_machine settled:Settle(3, 1)]; + [_machine bakeFinished:NO]; + [_machine settled:Settle(3, 1)]; + XCTAssertEqualObjects(Describe([_machine bakeFinished:NO]), (@[@"blank slots", @"state idle (bakes kept failing)"])); + XCTAssertEqual(_machine.state, RNCPageCurlStateIdle); +} + +- (void)testASuccessfulBakeResetsTheFailureCount +{ + [_machine enable]; + [_machine settled:Settle(3, 1)]; + [_machine bakeFinished:NO]; + [_machine settled:Settle(3, 1)]; + [_machine bakeFinished:NO]; + [_machine settled:Settle(3, 1)]; + [_machine bakeFinished:YES]; + [_machine settled:Settle(4, 1)]; + [_machine bakeFinished:NO]; + [_machine settled:Settle(4, 1)]; + XCTAssertEqualObjects(Describe([_machine bakeFinished:NO]), (@[@"blank slots", @"state awaitingSettle (bake failed)", @"request settle"])); +} + +- (void)testAKeptSettleIsBakedAfterAFailedBakeWithoutCountingTheFailure +{ + [_machine enable]; + [_machine settled:Settle(3, 1)]; + [_machine settled:Settle(4, 1)]; + XCTAssertEqualObjects(Describe([_machine bakeFinished:NO]), (@[@"blank slots", @"bake page 4 chunk 1"])); + XCTAssertEqual(_machine.state, RNCPageCurlStateBaking); + // two counted failures leave the machine still asking; a counted kept one would have made three + [_machine bakeFinished:NO]; + [_machine settled:Settle(4, 1)]; + XCTAssertEqualObjects(Describe([_machine bakeFinished:NO]), (@[@"blank slots", @"state awaitingSettle (bake failed)", @"request settle"])); +} + +#pragma mark - failed settle requests + +- (void)testAFailedSettleRequestBlanksTheSlotsAndGoesIdle +{ + [_machine enable]; + [_machine settled:Settle(3, 1)]; + NSUInteger token = SettleRequestToken([_machine bakeFinished:NO]); + XCTAssertNotEqual(token, 0); + XCTAssertEqualObjects(Describe([_machine settleRequestFinished:NO token:token]), (@[@"blank slots", @"state idle (settle request failed)"])); +} + +- (void)testASuccessfulSettleRequestChangesNothing +{ + [_machine enable]; + [_machine settled:Settle(3, 1)]; + NSUInteger token = SettleRequestToken([_machine bakeFinished:NO]); + XCTAssertEqualObjects(Describe([_machine settleRequestFinished:YES token:token]), (@[])); + XCTAssertEqual(_machine.state, RNCPageCurlStateAwaitingSettle); +} + +- (void)testAFailedSettleRequestFromAnEarlierWaitChangesNothing +{ + [_machine enable]; + [_machine settled:Settle(3, 1)]; + NSUInteger token = SettleRequestToken([_machine bakeFinished:NO]); + [_machine settled:Settle(3, 1)]; + [_machine bakeFinished:YES]; + XCTAssertEqualObjects(Describe([_machine settleRequestFinished:NO token:token]), (@[])); + XCTAssertEqual(_machine.state, RNCPageCurlStateIdle); +} + +- (void)testAFailedSettleRequestFromAnEarlierWaitForTheSameStateChangesNothing +{ + [_machine enable]; + [_machine settled:Settle(3, 1)]; + NSUInteger first = SettleRequestToken([_machine bakeFinished:NO]); + [_machine settled:Settle(3, 1)]; + NSUInteger second = SettleRequestToken([_machine bakeFinished:NO]); + XCTAssertNotEqual(first, second); + XCTAssertEqualObjects(Describe([_machine settleRequestFinished:NO token:first]), (@[])); + XCTAssertEqual(_machine.state, RNCPageCurlStateAwaitingSettle); +} + +#pragma mark - turns + +- (void)testATurnBeginsOnlyWhenIdle +{ + [_machine enable]; + XCTAssertFalse(_machine.canBeginTurn); + XCTAssertEqualObjects(Describe([_machine turnBegan]), (@[])); + [_machine settled:Settle(3, 1)]; + [_machine bakeFinished:YES]; + XCTAssertTrue(_machine.canBeginTurn); + XCTAssertEqualObjects(Describe([_machine turnBegan]), (@[@"state animating (turn begins)"])); + XCTAssertFalse(_machine.canBeginTurn); +} + +- (void)testASettleDuringATurnIsKept +{ + [self bakeFirstPage]; + [_machine turnBegan]; + XCTAssertEqualObjects(Describe([_machine settled:Settle(4, 1)]), (@[])); + XCTAssertEqual(_machine.state, RNCPageCurlStateAnimating); +} + +- (void)testACancelledTurnShowsTheCurrentPageAndRests +{ + [self bakeFirstPage]; + [_machine turnBegan]; + XCTAssertEqualObjects(Describe([_machine turnFinishedToward:@"next" completed:NO]), (@[@"show current", @"state idle (turn cancelled)"])); +} + +- (void)testACancelledTurnBakesTheKeptSettle +{ + [self bakeFirstPage]; + [_machine turnBegan]; + [_machine settled:Settle(4, 1)]; + XCTAssertEqualObjects(Describe([_machine turnFinishedToward:@"next" completed:NO]), + (@[@"show current", @"state baking (settle)", @"bake page 4 chunk 1"])); +} + +- (void)testALandedTurnShowsTheLandedPageAndAsksTheManagerToLand +{ + [self bakeFirstPage]; + [_machine turnBegan]; + XCTAssertEqualObjects(Describe([_machine turnFinishedToward:@"next" completed:YES]), + (@[@"show landed next", @"state awaitingSettle (turn landed)", @"land next"])); + XCTAssertEqual(_machine.state, RNCPageCurlStateAwaitingSettle); +} + +- (void)testALandedTurnDropsTheSettleKeptDuringIt +{ + [self bakeFirstPage]; + [_machine turnBegan]; + [_machine settled:Settle(9, 1)]; + [_machine turnFinishedToward:@"next" completed:YES]; + XCTAssertEqualObjects(Describe([_machine settled:Settle(4, 1)]), (@[@"state baking (settle)", @"bake page 4 chunk 1"])); +} + +- (void)testATurnFinishedWhileNotAnimatingChangesNothing +{ + [self bakeFirstPage]; + XCTAssertEqualObjects(Describe([_machine turnFinishedToward:@"next" completed:YES]), (@[])); + XCTAssertEqual(_machine.state, RNCPageCurlStateIdle); +} + +#pragma mark - landing guard + +- (void)testASettleForThePageJustLeftIsDroppedAfterALanding +{ + [self landTurn]; + XCTAssertEqualObjects(Describe([_machine settled:Settle(3, 1)]), (@[])); + XCTAssertEqual(_machine.state, RNCPageCurlStateAwaitingSettle); + XCTAssertEqualObjects(Describe([_machine settled:Settle(4, 1)]), (@[@"state baking (settle)", @"bake page 4 chunk 1"])); +} + +- (void)testASettleForTheSamePageInAnotherChunkBakesAfterALanding +{ + [self landTurn]; + XCTAssertEqualObjects(Describe([_machine settled:Settle(3, 2)]), (@[@"state baking (settle)", @"bake page 3 chunk 2"])); +} + +- (void)testUnsettledEndsTheLandingGuard +{ + [self landTurn]; + XCTAssertEqualObjects(Describe([_machine unsettled]), (@[])); + XCTAssertEqualObjects(Describe([_machine settled:Settle(3, 1)]), (@[@"state baking (settle)", @"bake page 3 chunk 1"])); +} + +- (void)testAFailedLandingEndsTheGuardAndAsksForASettle +{ + [self landTurn]; + XCTAssertEqualObjects(Describe([_machine landingFinished:NO]), (@[@"request settle"])); + XCTAssertEqual(_machine.state, RNCPageCurlStateAwaitingSettle); + XCTAssertEqualObjects(Describe([_machine settled:Settle(3, 1)]), (@[@"state baking (settle)", @"bake page 3 chunk 1"])); +} + +- (void)testASuccessfulLandingChangesNothing +{ + [self landTurn]; + XCTAssertEqualObjects(Describe([_machine landingFinished:YES]), (@[])); + XCTAssertEqualObjects(Describe([_machine settled:Settle(3, 1)]), (@[]), @"the guard still holds"); +} + +- (void)testTheGuardEndsOnceAnotherPageBakes +{ + [self landTurn]; + [_machine settled:Settle(4, 1)]; + [_machine bakeFinished:YES]; + XCTAssertEqualObjects(Describe([_machine settled:Settle(3, 1)]), (@[@"state baking (settle)", @"bake page 3 chunk 1"])); +} + +#pragma mark - taps + +- (void)testATapWaitsForItsResult +{ + [self bakeFirstPage]; + XCTAssertEqualObjects(Describe([_machine tapped]), (@[@"state awaitingTapResult (tap)"])); + XCTAssertFalse(_machine.canBeginTurn); +} + +- (void)testATapThatTurnedNoPageOpensInput +{ + [self bakeFirstPage]; + [_machine tapped]; + XCTAssertEqualObjects(Describe([_machine touchEnded]), (@[@"state idle (tap turned no page)"])); +} + +- (void)testASettleAfterATapBakes +{ + [self bakeFirstPage]; + [_machine tapped]; + XCTAssertEqualObjects(Describe([_machine settled:Settle(4, 1)]), (@[@"state baking (settle)", @"bake page 4 chunk 1"])); +} + +- (void)testUnsettledAfterATapWaitsForTheSettle +{ + [self bakeFirstPage]; + [_machine tapped]; + XCTAssertEqualObjects(Describe([_machine unsettled]), (@[@"state awaitingSettle (unsettled)"])); +} + +- (void)testATapWhileBusyChangesNothing +{ + [_machine enable]; + XCTAssertEqualObjects(Describe([_machine tapped]), (@[])); + XCTAssertEqual(_machine.state, RNCPageCurlStateAwaitingSettle); +} + +- (void)testATouchEndWhileIdleChangesNothing +{ + [self bakeFirstPage]; + XCTAssertEqualObjects(Describe([_machine touchEnded]), (@[])); + XCTAssertEqual(_machine.state, RNCPageCurlStateIdle); +} + +#pragma mark - unsettled and rebake requests + +- (void)testUnsettledWhileIdleWaitsForTheSettle +{ + [self bakeFirstPage]; + XCTAssertEqualObjects(Describe([_machine unsettled]), (@[@"state awaitingSettle (unsettled)"])); +} + +- (void)testUnsettledDuringABakeChangesNothing +{ + [_machine enable]; + [_machine settled:Settle(3, 1)]; + XCTAssertEqualObjects(Describe([_machine unsettled]), (@[])); + XCTAssertEqual(_machine.state, RNCPageCurlStateBaking); +} + +- (void)testARebakeRequestWhileIdleWaitsAndAsks +{ + [self bakeFirstPage]; + XCTAssertEqualObjects(Describe([_machine rebakeRequested:@"resize"]), (@[@"state awaitingSettle (resize)", @"request settle"])); +} + +- (void)testARebakeRequestWhileWaitingOnATapWaitsAndAsks +{ + [self bakeFirstPage]; + [_machine tapped]; + XCTAssertEqualObjects(Describe([_machine rebakeRequested:@"resize"]), (@[@"state awaitingSettle (resize)", @"request settle"])); +} + +- (void)testARebakeRequestDuringABakeOnlyAsks +{ + [_machine enable]; + [_machine settled:Settle(3, 1)]; + XCTAssertEqualObjects(Describe([_machine rebakeRequested:@"resize"]), (@[@"request settle"])); + XCTAssertEqual(_machine.state, RNCPageCurlStateBaking); + [_machine settled:Settle(3, 1)]; + XCTAssertEqualObjects(Describe([_machine bakeFinished:YES]), (@[@"bake page 3 chunk 1"]), @"the settle that came back is kept"); +} + +- (void)testAFailedRebakeRequestWhileIdleOpensInputAgain +{ + [self bakeFirstPage]; + NSUInteger token = SettleRequestToken([_machine rebakeRequested:@"resize"]); + XCTAssertEqualObjects(Describe([_machine settleRequestFinished:NO token:token]), (@[@"blank slots", @"state idle (settle request failed)"])); +} + +#pragma mark - teardown + +- (void)testTeardownGoesIdleWithNothingKeptAndMovesTheSerial +{ + [_machine enable]; + [_machine settled:Settle(3, 1)]; + [_machine settled:Settle(4, 1)]; + NSUInteger before = _machine.stateSerial; + [_machine teardown]; + XCTAssertEqual(_machine.state, RNCPageCurlStateIdle); + XCTAssertNotEqual(_machine.stateSerial, before); + XCTAssertEqualObjects(_machine.summary, @"idle"); +} + +- (void)testTeardownClearsTheLandingGuard +{ + [self landTurn]; + [_machine teardown]; + [_machine enable]; + XCTAssertEqualObjects(Describe([_machine settled:Settle(3, 1)]), (@[@"state baking (settle)", @"bake page 3 chunk 1"])); +} + +- (void)testTeardownClearsTheFailureCount +{ + [_machine enable]; + [_machine settled:Settle(3, 1)]; + [_machine bakeFinished:NO]; + [_machine settled:Settle(3, 1)]; + [_machine bakeFinished:NO]; + [_machine teardown]; + [_machine enable]; + [_machine settled:Settle(3, 1)]; + [_machine bakeFinished:NO]; + [_machine settled:Settle(3, 1)]; + XCTAssertEqualObjects(Describe([_machine bakeFinished:NO]), (@[@"blank slots", @"state awaitingSettle (bake failed)", @"request settle"])); +} + +- (void)testResultsAfterATeardownChangeNothing +{ + [self landTurn]; + [_machine settled:Settle(4, 1)]; + NSUInteger token = SettleRequestToken([_machine rebakeRequested:@"resize"]); + [_machine teardown]; + NSUInteger serial = _machine.stateSerial; + XCTAssertEqualObjects(Describe([_machine bakeFinished:YES]), (@[])); + XCTAssertEqualObjects(Describe([_machine bakeFinished:NO]), (@[])); + XCTAssertEqualObjects(Describe([_machine landingFinished:NO]), (@[])); + XCTAssertEqualObjects(Describe([_machine settleRequestFinished:NO token:token]), (@[])); + XCTAssertEqual(_machine.state, RNCPageCurlStateIdle); + XCTAssertEqual(_machine.stateSerial, serial); +} + +#pragma mark - every ordering + +typedef NSArray *(^RNCPageCurlEvent)(RNCPageCurlStateMachine *machine, NSUInteger lastToken); + +// every event the controller can report, as a block the sweep applies to a machine +- (NSDictionary *)events +{ + return @{ + @"settled 3": ^(RNCPageCurlStateMachine *m, NSUInteger token) { return [m settled:Settle(3, 1)]; }, + @"settled 4": ^(RNCPageCurlStateMachine *m, NSUInteger token) { return [m settled:Settle(4, 1)]; }, + @"unsettled": ^(RNCPageCurlStateMachine *m, NSUInteger token) { return [m unsettled]; }, + @"touchEnded": ^(RNCPageCurlStateMachine *m, NSUInteger token) { return [m touchEnded]; }, + @"tapped": ^(RNCPageCurlStateMachine *m, NSUInteger token) { return [m tapped]; }, + @"turnBegan": ^(RNCPageCurlStateMachine *m, NSUInteger token) { return [m turnBegan]; }, + @"turn landed": ^(RNCPageCurlStateMachine *m, NSUInteger token) { return [m turnFinishedToward:@"next" completed:YES]; }, + @"turn cancelled": ^(RNCPageCurlStateMachine *m, NSUInteger token) { return [m turnFinishedToward:@"next" completed:NO]; }, + @"bake ok": ^(RNCPageCurlStateMachine *m, NSUInteger token) { return [m bakeFinished:YES]; }, + @"bake failed": ^(RNCPageCurlStateMachine *m, NSUInteger token) { return [m bakeFinished:NO]; }, + @"landing ok": ^(RNCPageCurlStateMachine *m, NSUInteger token) { return [m landingFinished:YES]; }, + @"landing failed": ^(RNCPageCurlStateMachine *m, NSUInteger token) { return [m landingFinished:NO]; }, + @"settle request failed": ^(RNCPageCurlStateMachine *m, NSUInteger token) { return [m settleRequestFinished:NO token:token]; }, + @"rebake": ^(RNCPageCurlStateMachine *m, NSUInteger token) { return [m rebakeRequested:@"resize"]; }, + }; +} + +// replays `sequence` on a fresh enabled machine and checks the invariants after every step +- (void)checkSequence:(NSArray *)sequence events:(NSDictionary *)events +{ + RNCPageCurlStateMachine *machine = [RNCPageCurlStateMachine new]; + [machine enable]; + NSUInteger bakesOutstanding = 0; + NSUInteger lastToken = 0; + NSUInteger lastSerial = machine.stateSerial; + BOOL settleExpected = YES; + NSString *trail = @"enable"; + for (NSString *name in sequence) { + trail = [trail stringByAppendingFormat:@" > %@", name]; + BOOL wasBaking = machine.state == RNCPageCurlStateBaking; + NSArray *effects = events[name](machine, lastToken); + + if ([name hasPrefix:@"bake "] && wasBaking) { + bakesOutstanding -= 1; + } + NSUInteger bakesStarted = 0; + for (RNCPageCurlEffect *effect in effects) { + if (effect.kind == RNCPageCurlEffectBake) { + bakesStarted += 1; + } + if (effect.kind == RNCPageCurlEffectRequestSettle) { + lastToken = effect.token; + settleExpected = YES; + } + if (effect.kind == RNCPageCurlEffectLand) { + settleExpected = YES; + } + } + bakesOutstanding += bakesStarted; + if ([name isEqualToString:@"unsettled"]) { + settleExpected = YES; + } + if ([name hasPrefix:@"settled"] && (bakesStarted > 0 || machine.state == RNCPageCurlStateBaking || machine.state == RNCPageCurlStateAnimating)) { + settleExpected = NO; + } + + XCTAssertLessThanOrEqual(bakesStarted, 1, @"%@: one bake per step", trail); + XCTAssertLessThanOrEqual(bakesOutstanding, 1, @"%@: at most one bake outstanding", trail); + XCTAssertEqual(machine.state == RNCPageCurlStateBaking, bakesOutstanding == 1, @"%@: baking exactly while a bake is outstanding", trail); + if (bakesStarted > 0) { + XCTAssertEqual(machine.state, RNCPageCurlStateBaking, @"%@: a bake starts only on entering baking", trail); + } + XCTAssertEqual(machine.canBeginTurn, machine.state == RNCPageCurlStateIdle, @"%@: input is blocked exactly when not idle", trail); + if (machine.state == RNCPageCurlStateAwaitingSettle) { + XCTAssertTrue(settleExpected, @"%@: waiting for a settle nothing will send", trail); + } + BOOL stateChanged = Contains(effects, RNCPageCurlEffectEnterState); + XCTAssertEqual(machine.stateSerial != lastSerial, stateChanged, @"%@: the serial moves exactly on a state change", trail); + XCTAssertGreaterThanOrEqual(machine.stateSerial, lastSerial, @"%@: the serial never goes back", trail); + lastSerial = machine.stateSerial; + } +} + +- (void)testEveryOrderingKeepsTheInvariants +{ + NSDictionary *events = [self events]; + NSArray *names = [events.allKeys sortedArrayUsingSelector:@selector(compare:)]; + const NSUInteger depth = 5; + NSUInteger count = 1; + for (NSUInteger i = 0; i < depth; i++) { + count *= names.count; + } + for (NSUInteger index = 0; index < count; index++) { + NSMutableArray *sequence = [NSMutableArray arrayWithCapacity:depth]; + NSUInteger rest = index; + for (NSUInteger i = 0; i < depth; i++) { + [sequence addObject:names[rest % names.count]]; + rest /= names.count; + } + [self checkSequence:sequence events:events]; + } +} + @end diff --git a/apple/RNCPageCurlStateMachine.m b/apple/RNCPageCurlStateMachine.m index 59da31d57..1ea7cb8df 100644 --- a/apple/RNCPageCurlStateMachine.m +++ b/apple/RNCPageCurlStateMachine.m @@ -1,82 +1,345 @@ #import "RNCPageCurlStateMachine.h" +static const NSUInteger RNCPageCurlMaxFailedBakes = 3; + NSString *RNCPageCurlStateName(RNCPageCurlState state) { + switch (state) { + case RNCPageCurlStateIdle: return @"idle"; + case RNCPageCurlStateAnimating: return @"animating"; + case RNCPageCurlStateBaking: return @"baking"; + case RNCPageCurlStateAwaitingSettle: return @"awaitingSettle"; + case RNCPageCurlStateAwaitingTapResult: return @"awaitingTapResult"; + } return @"?"; } +@interface RNCPageCurlEffect () +@property (nonatomic, readwrite) RNCPageCurlEffectKind kind; +@property (nonatomic, readwrite) RNCPageCurlState state; +@property (nonatomic, readwrite, copy, nullable) NSString *reason; +@property (nonatomic, readwrite, copy, nullable) NSDictionary *settle; +@property (nonatomic, readwrite, copy, nullable) NSString *direction; +@property (nonatomic, readwrite) NSUInteger token; +@end + @implementation RNCPageCurlEffect + ++ (instancetype)effectOfKind:(RNCPageCurlEffectKind)kind +{ + RNCPageCurlEffect *effect = [self new]; + effect.kind = kind; + return effect; +} + +- (NSString *)description +{ + switch (self.kind) { + case RNCPageCurlEffectEnterState: return [NSString stringWithFormat:@"enter %@ (%@)", RNCPageCurlStateName(self.state), self.reason]; + case RNCPageCurlEffectBake: return [NSString stringWithFormat:@"bake page %@ chunk %@", self.settle[@"page"], self.settle[@"chunkIndex"]]; + case RNCPageCurlEffectBlankSlots: return @"blank slots"; + case RNCPageCurlEffectShowLandedPage: return [NSString stringWithFormat:@"show landed %@", self.direction]; + case RNCPageCurlEffectShowCurrentPage: return @"show current page"; + case RNCPageCurlEffectLand: return [NSString stringWithFormat:@"land %@", self.direction]; + case RNCPageCurlEffectRequestSettle: return [NSString stringWithFormat:@"request settle #%lu", (unsigned long)self.token]; + case RNCPageCurlEffectNote: return [NSString stringWithFormat:@"note: %@", self.reason]; + } + return @"?"; +} + @end -@implementation RNCPageCurlStateMachine +@implementation RNCPageCurlStateMachine { + // a settle that arrived while a bake or a turn was running; baked once that ends + NSDictionary *_keptSettle; + // the settle the slots were last baked from + NSDictionary *_bakedSettle; + // after a landed turn, a settle naming the page just left is from before the landing + BOOL _awaitingLanding; + NSUInteger _failedBakes; + NSUInteger _settleRequestToken; + NSUInteger _settleRequestSerial; +} + +- (instancetype)init +{ + if (self = [super init]) { + _state = RNCPageCurlStateIdle; + } + return self; +} - (BOOL)canBeginTurn { - return NO; + return _state == RNCPageCurlStateIdle; } - (NSString *)summary { - return @""; + NSMutableArray *parts = [NSMutableArray arrayWithObject:RNCPageCurlStateName(_state)]; + if (_keptSettle != nil) { + [parts addObject:[NSString stringWithFormat:@"kept page %@ chunk %@", _keptSettle[@"page"], _keptSettle[@"chunkIndex"]]]; + } + if (_awaitingLanding) { + [parts addObject:@"awaiting landing"]; + } + if (_failedBakes > 0) { + [parts addObject:[NSString stringWithFormat:@"%lu failed bakes", (unsigned long)_failedBakes]]; + } + return [parts componentsJoinedByString:@", "]; +} + +#pragma mark - effects + +- (RNCPageCurlEffect *)enter:(RNCPageCurlState)state reason:(NSString *)reason +{ + RNCPageCurlEffect *effect = [RNCPageCurlEffect effectOfKind:RNCPageCurlEffectEnterState]; + effect.state = state; + effect.reason = reason; + return effect; +} + +- (RNCPageCurlEffect *)note:(NSString *)reason +{ + RNCPageCurlEffect *effect = [RNCPageCurlEffect effectOfKind:RNCPageCurlEffectNote]; + effect.reason = reason; + return effect; +} + +- (RNCPageCurlEffect *)directed:(RNCPageCurlEffectKind)kind toward:(NSString *)direction +{ + RNCPageCurlEffect *effect = [RNCPageCurlEffect effectOfKind:kind]; + effect.direction = direction; + return effect; +} + +// the state changes once per call at most, so a list holds at most one enter effect +- (void)setState:(RNCPageCurlState)state reason:(NSString *)reason into:(NSMutableArray *)effects +{ + if (state == _state) { + return; + } + _state = state; + _stateSerial += 1; + [effects addObject:[self enter:state reason:reason]]; +} + +- (void)bake:(NSDictionary *)settle into:(NSMutableArray *)effects +{ + _keptSettle = nil; + _awaitingLanding = NO; + _bakedSettle = settle; + [self setState:RNCPageCurlStateBaking reason:@"settle" into:effects]; + RNCPageCurlEffect *effect = [RNCPageCurlEffect effectOfKind:RNCPageCurlEffectBake]; + effect.settle = settle; + [effects addObject:effect]; +} + +// the kept settle starts the next bake under the same cover; with none the machine rests +- (void)bakeKeptOrIdle:(NSString *)reason into:(NSMutableArray *)effects +{ + if (_keptSettle != nil) { + [self bake:_keptSettle into:effects]; + return; + } + [self setState:RNCPageCurlStateIdle reason:reason into:effects]; +} + +- (void)requestSettleInto:(NSMutableArray *)effects +{ + _settleRequestToken += 1; + _settleRequestSerial = _stateSerial; + RNCPageCurlEffect *effect = [RNCPageCurlEffect effectOfKind:RNCPageCurlEffectRequestSettle]; + effect.token = _settleRequestToken; + [effects addObject:effect]; +} + +// the manager is about to move the page: a machine resting on it waits for the settle +- (void)awaitSettleIfResting:(NSString *)reason into:(NSMutableArray *)effects +{ + if (_state == RNCPageCurlStateIdle || _state == RNCPageCurlStateAwaitingTapResult) { + [self setState:RNCPageCurlStateAwaitingSettle reason:reason into:effects]; + } } +#pragma mark - lifecycle + - (NSArray *)enable { - return @[]; + NSMutableArray *effects = [NSMutableArray array]; + _keptSettle = nil; + _bakedSettle = nil; + _awaitingLanding = NO; + _failedBakes = 0; + // nothing is baked yet; the manager's first settle starts the first bake + [self setState:RNCPageCurlStateAwaitingSettle reason:@"enabled" into:effects]; + return effects; } - (void)teardown { + _stateSerial += 1; + _state = RNCPageCurlStateIdle; + _keptSettle = nil; + _bakedSettle = nil; + _awaitingLanding = NO; + _failedBakes = 0; } +#pragma mark - messages from the manager + - (NSArray *)settled:(NSDictionary *)settle { - return @[]; + NSMutableArray *effects = [NSMutableArray array]; + if (_state == RNCPageCurlStateBaking || _state == RNCPageCurlStateAnimating) { + _keptSettle = settle; + [effects addObject:[self note:[NSString stringWithFormat:@"settle kept until the %@ ends", RNCPageCurlStateName(_state)]]]; + return effects; + } + // posted during the turn, delivered after it landed: it names the page the reader just left + if (_awaitingLanding && [self settle:settle namesSamePageAs:_bakedSettle]) { + [effects addObject:[self note:@"settle for the page just left; dropped"]]; + return effects; + } + [self bake:settle into:effects]; + return effects; } - (NSArray *)unsettled { - return @[]; + NSMutableArray *effects = [NSMutableArray array]; + // the manager posts this before it moves, so every settle after it is from after the landing + _awaitingLanding = NO; + [self awaitSettleIfResting:@"unsettled" into:effects]; + return effects; } - (NSArray *)touchEnded { - return @[]; + NSMutableArray *effects = [NSMutableArray array]; + if (_state == RNCPageCurlStateAwaitingTapResult) { + [self setState:RNCPageCurlStateIdle reason:@"tap turned no page" into:effects]; + } + return effects; } +#pragma mark - the finger + - (NSArray *)tapped { - return @[]; + NSMutableArray *effects = [NSMutableArray array]; + if (_state == RNCPageCurlStateIdle) { + [self setState:RNCPageCurlStateAwaitingTapResult reason:@"tap" into:effects]; + } + return effects; } - (NSArray *)turnBegan { - return @[]; + NSMutableArray *effects = [NSMutableArray array]; + if (_state == RNCPageCurlStateIdle) { + [self setState:RNCPageCurlStateAnimating reason:@"turn begins" into:effects]; + } + return effects; } - (NSArray *)turnFinishedToward:(NSString *)direction completed:(BOOL)completed { - return @[]; + NSMutableArray *effects = [NSMutableArray array]; + if (_state != RNCPageCurlStateAnimating) { + [effects addObject:[self note:@"turn finished while not animating; ignored"]]; + return effects; + } + if (!completed) { + // the three bakes are still valid + [effects addObject:[RNCPageCurlEffect effectOfKind:RNCPageCurlEffectShowCurrentPage]]; + [self bakeKeptOrIdle:@"turn cancelled" into:effects]; + return effects; + } + // the cover shows the landed page; the manager turns to it and its settle bakes every slot again + [effects addObject:[self directed:RNCPageCurlEffectShowLandedPage toward:direction]]; + // a settle from during the turn describes the page just left; the landing produces a fresh one + _keptSettle = nil; + _awaitingLanding = YES; + [self setState:RNCPageCurlStateAwaitingSettle reason:@"turn landed" into:effects]; + [effects addObject:[self directed:RNCPageCurlEffectLand toward:direction]]; + return effects; } +#pragma mark - results of effects + - (NSArray *)bakeFinished:(BOOL)ok { - return @[]; + NSMutableArray *effects = [NSMutableArray array]; + if (_state != RNCPageCurlStateBaking) { + [effects addObject:[self note:@"bake finished while not baking; ignored"]]; + return effects; + } + if (ok) { + _failedBakes = 0; + [self bakeKeptOrIdle:@"baked" into:effects]; + return effects; + } + // the slots describe another page now + [effects addObject:[RNCPageCurlEffect effectOfKind:RNCPageCurlEffectBlankSlots]]; + if (_keptSettle != nil) { + [self bake:_keptSettle into:effects]; + return effects; + } + _failedBakes += 1; + if (_failedBakes >= RNCPageCurlMaxFailedBakes) { + // taps still turn pages; the next settle the manager sends on its own bakes again + [self setState:RNCPageCurlStateIdle reason:@"bakes kept failing" into:effects]; + return effects; + } + [self setState:RNCPageCurlStateAwaitingSettle reason:@"bake failed" into:effects]; + [self requestSettleInto:effects]; + return effects; } - (NSArray *)landingFinished:(BOOL)ok { - return @[]; + NSMutableArray *effects = [NSMutableArray array]; + // a landing that already produced its settle, or was torn down, has nothing left to repair + if (ok || !_awaitingLanding) { + return effects; + } + // the webview never moved, so the settle it sends names the page the turn started on + _awaitingLanding = NO; + [self awaitSettleIfResting:@"landing failed" into:effects]; + [self requestSettleInto:effects]; + return effects; } - (NSArray *)settleRequestFinished:(BOOL)ok token:(NSUInteger)token { - return @[]; + NSMutableArray *effects = [NSMutableArray array]; + BOOL sameWait = _state == RNCPageCurlStateAwaitingSettle && token == _settleRequestToken && _stateSerial == _settleRequestSerial; + if (ok || !sameWait) { + return effects; + } + // no settle is coming: open input with nothing to curl onto until the manager settles on its own + [effects addObject:[RNCPageCurlEffect effectOfKind:RNCPageCurlEffectBlankSlots]]; + [self setState:RNCPageCurlStateIdle reason:@"settle request failed" into:effects]; + return effects; } - (NSArray *)rebakeRequested:(NSString *)reason { - return @[]; + NSMutableArray *effects = [NSMutableArray array]; + // a busy machine needs nothing more: the settle that comes back is kept and baked when it frees + [self awaitSettleIfResting:reason into:effects]; + [self requestSettleInto:effects]; + return effects; +} + +#pragma mark - helpers + +- (BOOL)settle:(NSDictionary *)settle namesSamePageAs:(NSDictionary *)other +{ + return other != nil && + [settle[@"page"] integerValue] == [other[@"page"] integerValue] && + [settle[@"chunkIndex"] integerValue] == [other[@"chunkIndex"] integerValue]; } @end From 1d9dd9b005df1bc2d830ab9888a5ac1d7fa4d9df Mon Sep 17 00:00:00 2001 From: Cameron Webb Date: Tue, 27 Oct 2026 16:45:10 -0500 Subject: [PATCH 2/5] Page curl: move the controller onto the state machine The controller no longer keeps its own copy of the bake loop. It reports each event to RNCPageCurlStateMachine and carries out the effects it returns, so the transitions the tests cover are the ones that run. The landing watchdog and the settle request move with their effects; results from before a teardown are dropped by the teardown count as before. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01635Bx126DsG1CbDzDxxBJx --- apple/RNCWebViewPageCurl.m | 280 ++++++++++++++----------------------- 1 file changed, 104 insertions(+), 176 deletions(-) diff --git a/apple/RNCWebViewPageCurl.m b/apple/RNCWebViewPageCurl.m index f12024415..1176febdd 100644 --- a/apple/RNCWebViewPageCurl.m +++ b/apple/RNCWebViewPageCurl.m @@ -1,5 +1,6 @@ #import "RNCWebViewPageCurl.h" #import "RNCPageCurlRenderer.h" +#import "RNCPageCurlStateMachine.h" #import BOOL RNCPageCurlLoggingEnabled = NO; @@ -7,7 +8,6 @@ static NSString *const RNCPageCurlSlotCurrent = @"current"; static NSString *const RNCPageCurlSlotPrevious = @"previous"; static NSString *const RNCPageCurlSlotNext = @"next"; -static const NSUInteger RNCPageCurlMaxFailedBakes = 3; // defaults for the tuning JSON; the Bookwise side documents each knob static NSDictionary *RNCPageCurlTuningDefaults(void) @@ -43,31 +43,6 @@ typedef void (^RNCPageCurlStep)(void (^done)(BOOL ok)); -// the only state the controller keeps about the bake loop; input reaches the webview only when idle -typedef NS_ENUM(NSInteger, RNCPageCurlState) { - RNCPageCurlStateIdle, - // a finger is turning a sheet, or the release animation is playing; the DOM does not move - RNCPageCurlStateAnimating, - // the slots are being snapshotted; runs to completion, a settle that arrives meanwhile is kept for after - RNCPageCurlStateBaking, - // the manager is moving the page (a tap, a jump, or a landed curl's page turn); its settle starts the bake - RNCPageCurlStateAwaitingSettle, - // a tap reached the webview; the manager answers with a settle (page turned) or touchEnd (nothing turned) - RNCPageCurlStateAwaitingTapResult, -}; - -static NSString *RNCPageCurlStateName(RNCPageCurlState state) -{ - switch (state) { - case RNCPageCurlStateIdle: return @"idle"; - case RNCPageCurlStateAnimating: return @"animating"; - case RNCPageCurlStateBaking: return @"baking"; - case RNCPageCurlStateAwaitingSettle: return @"awaitingSettle"; - case RNCPageCurlStateAwaitingTapResult: return @"awaitingTapResult"; - } - return @"?"; -} - // the paper faded toward the opposite extreme so it reads as the reverse side in both themes static UIColor *RNCPageCurlFadedPaper(UIColor *paper) { @@ -203,21 +178,14 @@ @implementation RNCWebViewPageCurl { BOOL _enabled; BOOL _spread; BOOL _edgeEmitted; - RNCPageCurlState _state; + // the bake loop; the controller reports what happened and carries out the effects it returns + RNCPageCurlStateMachine *_machine; // UIKit hit-tests once, at touch-down; only a touch it delivered inside the webview is one the // manager saw and will answer BOOL _touchReachedWebView; // a bake step still in flight from a torn-down controller stops; setSpine tears down and re-enables, // which _enabled alone cannot tell apart NSUInteger _teardownCount; - // bumped on every state change and on teardown; the settle watchdog checks it has not moved - NSUInteger _stateSerial; - // the turn just landed and the manager was asked to move; a settle for the page it left is stale - BOOL _awaitingLanding; - // bakes that failed back to back; at the cap the controller stops asking for another - NSUInteger _failedBakes; - // the latest settle that arrived while a bake or turn was running; applied once the controller is free - NSDictionary *_pendingSettle; // the page the webview rests on; pages count from 0 inside the chunk NSInteger _page; NSInteger _lastPage; @@ -256,6 +224,7 @@ - (instancetype)initWithHostView:(UIView *)hostView webView:(WKWebView *)webView if ((self = [super init])) { _hostView = hostView; _webView = webView; + _machine = [RNCPageCurlStateMachine new]; _slots = [NSMutableDictionary dictionary]; for (NSString *slot in @[RNCPageCurlSlotPrevious, RNCPageCurlSlotCurrent, RNCPageCurlSlotNext]) { _slots[slot] = [RNCPageCurlSlot new]; @@ -452,8 +421,7 @@ - (void)setEnabled:(BOOL)enabled [self applyColors]; [self applyTuning]; [self showCover]; - // nothing is baked yet; the manager's first settle starts the first bake - [self setState:RNCPageCurlStateAwaitingSettle reason:@"enabled"]; + [self apply:[_machine enable]]; __weak __typeof(self) weakSelf = self; _touchObserver = [RNCPageCurlTouchObserver new]; @@ -467,7 +435,7 @@ - (void)setEnabled:(BOOL)enabled } UIView *hit = strongSelf->_touchObserver.touch.view; strongSelf->_touchReachedWebView = hit != nil && strongSelf->_webView != nil && [hit isDescendantOfView:strongSelf->_webView]; - RNCPageCurlLog(@"[page-curl] touches began; state=%@ reachedWebView=%d", RNCPageCurlStateName(strongSelf->_state), strongSelf->_touchReachedWebView); + RNCPageCurlLog(@"[page-curl] touches began; state=%@ reachedWebView=%d", RNCPageCurlStateName(strongSelf->_machine.state), strongSelf->_touchReachedWebView); [strongSelf makeWebPansYieldToCurl]; [strongSelf emit:@"touch" direction:nil detail:nil]; }; @@ -477,10 +445,10 @@ - (void)setEnabled:(BOOL)enabled return; } BOOL moved = strongSelf->_touchObserver.moved; - RNCPageCurlLog(@"[page-curl] touches ended; moved=%d state=%@ reachedWebView=%d", moved, RNCPageCurlStateName(strongSelf->_state), + RNCPageCurlLog(@"[page-curl] touches ended; moved=%d state=%@ reachedWebView=%d", moved, RNCPageCurlStateName(strongSelf->_machine.state), strongSelf->_touchReachedWebView); - if (!moved && strongSelf->_touchReachedWebView && strongSelf->_state == RNCPageCurlStateIdle) { - [strongSelf setState:RNCPageCurlStateAwaitingTapResult reason:@"tap"]; + if (!moved && strongSelf->_touchReachedWebView && strongSelf->_machine.state == RNCPageCurlStateIdle) { + [strongSelf apply:[strongSelf->_machine tapped]]; [strongSelf emit:@"tap" direction:nil detail:nil]; } }; @@ -555,32 +523,11 @@ - (void)setSpine:(NSString *)spine } } -// bakes no longer match the page; the manager settles again once the page has painted. A busy -// controller needs nothing: the settle the manager sends is kept and applied when the controller frees +// bakes no longer match the page; the manager settles again once the page has painted - (void)requestRebake:(NSString *)reason { - RNCPageCurlLog(@"[page-curl] rebake requested (%@) state=%@", reason, RNCPageCurlStateName(_state)); - [self awaitSettleIfResting:reason]; - NSUInteger serial = _stateSerial; - __weak __typeof(self) weakSelf = self; - [self callBridge:@"invalidateNativePageCurl" argument:nil completion:^(BOOL ok, id result) { - __strong __typeof(weakSelf) strongSelf = weakSelf; - if (ok || strongSelf == nil || strongSelf->_stateSerial != serial || strongSelf->_state != RNCPageCurlStateAwaitingSettle) { - return; - } - // no settle is coming: open input with nothing to curl onto until the manager settles on its own - [strongSelf blankAllSlots]; - [strongSelf setState:RNCPageCurlStateIdle reason:@"rebake request failed"]; - }]; -} - -// the manager is about to move the page: a controller resting on it (idle, or waiting on a tap's -// result) waits for the settle -- (void)awaitSettleIfResting:(NSString *)reason -{ - if (_state == RNCPageCurlStateIdle || _state == RNCPageCurlStateAwaitingTapResult) { - [self setState:RNCPageCurlStateAwaitingSettle reason:reason]; - } + RNCPageCurlLog(@"[page-curl] rebake requested (%@) state=%@", reason, RNCPageCurlStateName(_machine.state)); + [self apply:[_machine rebakeRequested:reason]]; } - (void)teardown @@ -588,9 +535,7 @@ - (void)teardown RNCPageCurlLog(@"[page-curl] teardown"); _enabled = NO; _teardownCount += 1; - _stateSerial += 1; - _awaitingLanding = NO; - _failedBakes = 0; + [_machine teardown]; [self stopAnimation]; if (_pan != nil) { [_pan.view removeGestureRecognizer:_pan]; @@ -606,8 +551,6 @@ - (void)teardown _inputBlocker = nil; _turnDirection = nil; _edgeEmitted = NO; - _pendingSettle = nil; - _state = RNCPageCurlStateIdle; } - (void)layoutWithBounds:(CGRect)bounds @@ -949,14 +892,11 @@ - (void)addNeighborStepsForPage:(NSInteger)page direction:(NSString *)direction // every slot from scratch; the settle says which page the webview rests on - (void)runBakeWithSettle:(NSDictionary *)settle { - _pendingSettle = nil; - _awaitingLanding = NO; _edgeEmitted = NO; _page = [settle[@"page"] integerValue]; _lastPage = [settle[@"totalPages"] integerValue]; _chunkIndex = [settle[@"chunkIndex"] integerValue]; _isLastChunk = [settle[@"isLastChunk"] boolValue]; - [self setState:RNCPageCurlStateBaking reason:@"settle"]; NSUInteger teardownCount = _teardownCount; CFTimeInterval start = CACurrentMediaTime(); RNCPageCurlLog(@"[page-curl] bake start page=%ld/%ld chunk=%ld last=%d", (long)_page, (long)_lastPage, (long)_chunkIndex, _isLastChunk); @@ -983,94 +923,74 @@ - (void)runBakeWithSettle:(NSDictionary *)settle return; } RNCPageCurlLog(@"[page-curl] bake %@ in %.1fms", ok ? @"done" : @"FAILED", (CACurrentMediaTime() - start) * 1000.0); - [strongSelf finishBake:ok]; + [strongSelf apply:[strongSelf->_machine bakeFinished:ok]]; }]; } -// a settle that arrived while the controller was busy starts the next bake under the same cover -- (void)bakePendingOrIdle:(NSString *)reason -{ - if (_pendingSettle != nil) { - RNCPageCurlLog(@"[page-curl] baking again: a settle arrived meanwhile"); - [self runBakeWithSettle:_pendingSettle]; - return; - } - [self setState:RNCPageCurlStateIdle reason:reason]; -} - -// a finished bake opens input; one that stopped early blanks its slots, which describe another page, -// then bakes a kept settle, asks the manager for a new one, or gives up at the cap -- (void)finishBake:(BOOL)ok -{ - if (ok) { - _failedBakes = 0; - [self bakePendingOrIdle:@"baked"]; - return; - } - [self blankAllSlots]; - if (_pendingSettle != nil) { - [self runBakeWithSettle:_pendingSettle]; - return; - } - _failedBakes += 1; - if (_failedBakes >= RNCPageCurlMaxFailedBakes) { - // taps still turn pages; the next settle the manager sends on its own bakes again - RNCPageCurlLog(@"[page-curl] %lu bakes failed in a row; no curl until the next settle", (unsigned long)_failedBakes); - [self setState:RNCPageCurlStateIdle reason:@"bakes kept failing"]; - return; - } - [self setState:RNCPageCurlStateAwaitingSettle reason:@"bake failed"]; - [self requestRebake:@"bake failed"]; -} - #pragma mark - messages from the content frame - (void)handleMessage:(NSDictionary *)message { NSString *type = message[@"type"]; - RNCPageCurlLog(@"[page-curl] message %@ (state=%@ pending=%d)", message, RNCPageCurlStateName(_state), _pendingSettle != nil); + RNCPageCurlLog(@"[page-curl] message %@ (%@)", message, _machine.summary); if (_renderer == nil) { return; } if ([type isEqualToString:@"settled"]) { [self emit:@"settled" direction:nil detail:[NSString stringWithFormat:@"page %@/%@ chunk %@", message[@"page"], message[@"totalPages"], message[@"chunkIndex"]]]; - if (_state == RNCPageCurlStateBaking || _state == RNCPageCurlStateAnimating) { - RNCPageCurlLog(@"[page-curl] settle kept until the %@ ends", RNCPageCurlStateName(_state)); - _pendingSettle = message; - return; - } - // posted during the turn, delivered after it landed: it names the page the reader just left - if (_awaitingLanding && [message[@"page"] integerValue] == _page && [message[@"chunkIndex"] integerValue] == _chunkIndex) { - RNCPageCurlLog(@"[page-curl] settle for the page just left; dropped"); - return; - } - [self runBakeWithSettle:message]; + [self apply:[_machine settled:message]]; return; } if ([type isEqualToString:@"unsettled"]) { - // the manager posts this before it moves, so every settle after it is from after the landing - _awaitingLanding = NO; - [self awaitSettleIfResting:@"unsettled"]; + [self apply:[_machine unsettled]]; return; } if ([type isEqualToString:@"touchEnd"]) { - if (_state == RNCPageCurlStateAwaitingTapResult) { - [self setState:RNCPageCurlStateIdle reason:@"tap turned no page"]; - } + [self apply:[_machine touchEnded]]; return; } RNCPageCurlLog(@"[page-curl] unknown message type %@", type); } -#pragma mark - state +#pragma mark - effects + +// carries out what the machine asked for, in order +- (void)apply:(NSArray *)effects +{ + for (RNCPageCurlEffect *effect in effects) { + switch (effect.kind) { + case RNCPageCurlEffectEnterState: + [self enterState:effect.state reason:effect.reason]; + break; + case RNCPageCurlEffectBake: + [self runBakeWithSettle:effect.settle]; + break; + case RNCPageCurlEffectBlankSlots: + [self blankAllSlots]; + break; + case RNCPageCurlEffectShowLandedPage: + [self rotateSlotsToward:effect.direction]; + [self showCover]; + break; + case RNCPageCurlEffectShowCurrentPage: + [self showCover]; + break; + case RNCPageCurlEffectLand: + [self landToward:effect.direction]; + break; + case RNCPageCurlEffectRequestSettle: + [self requestSettleWithToken:effect.token]; + break; + case RNCPageCurlEffectNote: + RNCPageCurlLog(@"[page-curl] %@", effect.reason); + break; + } + } +} -- (void)setState:(RNCPageCurlState)state reason:(NSString *)reason +- (void)enterState:(RNCPageCurlState)state reason:(NSString *)reason { - if (state != _state) { - RNCPageCurlLog(@"[page-curl] %@ -> %@ (%@)", RNCPageCurlStateName(_state), RNCPageCurlStateName(state), reason); - _stateSerial += 1; - } - _state = state; + RNCPageCurlLog(@"[page-curl] -> %@ (%@)", RNCPageCurlStateName(state), reason); // both overlays follow the state: idle is the webview, bare, with touches reaching it _inputBlocker.hidden = state == RNCPageCurlStateIdle; if (state == RNCPageCurlStateIdle) { @@ -1080,6 +1000,43 @@ - (void)setState:(RNCPageCurlState)state reason:(NSString *)reason } } +// the manager turns to the landed page the way a margin tap does; past the chunk edge that is its own chunk switch +- (void)landToward:(NSString *)direction +{ + NSString *fn = [direction isEqualToString:RNCPageCurlSlotNext] ? @"instantlyScrollPageDown" : @"instantlyScrollPageUp"; + RNCPageCurlLog(@"[page-curl] turn landed toward %@; %@", direction, fn); + NSUInteger teardownCount = _teardownCount; + __weak __typeof(self) weakSelf = self; + [self callBridge:fn argument:nil completion:^(BOOL ok, id result) { + __strong __typeof(weakSelf) strongSelf = weakSelf; + if (strongSelf == nil || teardownCount != strongSelf->_teardownCount) { + return; + } + [strongSelf apply:[strongSelf->_machine landingFinished:ok]]; + }]; + // only the manager's settle leaves this state; say so if it has not come + NSUInteger serial = _machine.stateSerial; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + __strong __typeof(weakSelf) strongSelf = weakSelf; + if (strongSelf != nil && strongSelf->_machine.stateSerial == serial) { + RNCPageCurlLog(@"[page-curl] no settle 3s after landing toward %@; still awaiting it", direction); + } + }); +} + +- (void)requestSettleWithToken:(NSUInteger)token +{ + NSUInteger teardownCount = _teardownCount; + __weak __typeof(self) weakSelf = self; + [self callBridge:@"invalidateNativePageCurl" argument:nil completion:^(BOOL ok, id result) { + __strong __typeof(weakSelf) strongSelf = weakSelf; + if (strongSelf == nil || teardownCount != strongSelf->_teardownCount) { + return; + } + [strongSelf apply:[strongSelf->_machine settleRequestFinished:ok token:token]]; + }]; +} + - (BOOL)panActive { return _pan.state == UIGestureRecognizerStateBegan || _pan.state == UIGestureRecognizerStateChanged; @@ -1094,15 +1051,15 @@ - (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer } // the curl pan claims every drag while the curl is on, so WebKit's pans never scroll the page // themselves; if the controller is busy the turn starts once it is idle - RNCPageCurlLog(@"[page-curl] pan should begin? state=%@", RNCPageCurlStateName(_state)); - return _state != RNCPageCurlStateAnimating; + RNCPageCurlLog(@"[page-curl] pan should begin? state=%@", RNCPageCurlStateName(_machine.state)); + return _machine.state != RNCPageCurlStateAnimating; } // the active pan's turn, from its original touch-down: the pan's own translation restarts from zero // where recognition began, and a pan that began while the controller was busy starts here once it is idle - (void)beginTurnForPan { - if (![self panActive] || _turnDirection != nil || _turnDeclined || _state != RNCPageCurlStateIdle) { + if (![self panActive] || _turnDirection != nil || _turnDeclined || !_machine.canBeginTurn) { return; } CGPoint touchDown = _touchObserver.startPoint; @@ -1122,11 +1079,11 @@ - (void)onPan:(UIPanGestureRecognizer *)pan { CGPoint location = [pan locationInView:_hostView]; RNCPageCurlLog(@"[page-curl] pan state=%ld location=%.1f,%.1f touchDown=%@ curlState=%@ turn=%@", (long)pan.state, location.x, location.y, - NSStringFromCGPoint(_touchObserver.startPoint), RNCPageCurlStateName(_state), _turnDirection); + NSStringFromCGPoint(_touchObserver.startPoint), RNCPageCurlStateName(_machine.state), _turnDirection); BOOL moving = pan.state == UIGestureRecognizerStateBegan || pan.state == UIGestureRecognizerStateChanged; if (_turnDirection == nil && moving) { - if (_state != RNCPageCurlStateIdle && pan.state == UIGestureRecognizerStateBegan) { - RNCPageCurlLog(@"[page-curl] pan began while %@; holding the drag until idle", RNCPageCurlStateName(_state)); + if (!_machine.canBeginTurn && pan.state == UIGestureRecognizerStateBegan) { + RNCPageCurlLog(@"[page-curl] pan began while %@; holding the drag until idle", RNCPageCurlStateName(_machine.state)); } [self beginTurnForPan]; return; @@ -1135,7 +1092,7 @@ - (void)onPan:(UIPanGestureRecognizer *)pan if (pan.state == UIGestureRecognizerStateEnded || pan.state == UIGestureRecognizerStateCancelled || pan.state == UIGestureRecognizerStateFailed) { _turnDeclined = NO; - RNCPageCurlLog(@"[page-curl] pan ended without a turn (state=%@)", RNCPageCurlStateName(_state)); + RNCPageCurlLog(@"[page-curl] pan ended without a turn (state=%@)", RNCPageCurlStateName(_machine.state)); } return; } @@ -1174,7 +1131,7 @@ - (void)beginTurn:(NSString *)direction from:(CGPoint)start BOOL next = [direction isEqualToString:RNCPageCurlSlotNext]; _turnDirection = direction; _turnReversed = !next && !_spread; - [self setState:RNCPageCurlStateAnimating reason:@"turn begins"]; + [self apply:[_machine turnBegan]]; [self showTurnScene:direction]; CGFloat width = _turnSheetRect.size.width; CGFloat height = _turnSheetRect.size.height; @@ -1364,41 +1321,12 @@ - (void)finishTurnCompleted:(BOOL)completed _turnDirection = nil; _edgeEmitted = NO; RNCPageCurlLog(@"[page-curl] turn %@ finished completed=%d", direction, completed); - if (!completed) { - [self showCover]; + if (completed) { + [self emit:@"turn" direction:direction detail:[NSString stringWithFormat:@"from page %ld", (long)_page]]; + } else { [self emit:@"cancel" direction:nil detail:nil]; - // the three bakes are still valid - [self bakePendingOrIdle:@"turn cancelled"]; - return; } - [self emit:@"turn" direction:direction detail:[NSString stringWithFormat:@"from page %ld", (long)_page]]; - // the cover shows the landed page; the manager turns to it the way a margin tap does, and its - // settle bakes every slot again. Past the chunk edge that is the manager's own chunk switch - [self rotateSlotsToward:direction]; - [self showCover]; - // a settle from during the turn describes the page just left; the landing produces a fresh one - _pendingSettle = nil; - _awaitingLanding = YES; - [self setState:RNCPageCurlStateAwaitingSettle reason:@"turn landed"]; - NSString *fn = [direction isEqualToString:RNCPageCurlSlotNext] ? @"instantlyScrollPageDown" : @"instantlyScrollPageUp"; - RNCPageCurlLog(@"[page-curl] turn landed toward %@; %@", direction, fn); - __weak __typeof(self) weakSelf = self; - [self callBridge:fn argument:nil completion:^(BOOL ok, id result) { - __strong __typeof(weakSelf) strongSelf = weakSelf; - if (!ok && strongSelf != nil) { - // the webview never moved, so the settle it sends names the page the turn started on - strongSelf->_awaitingLanding = NO; - [strongSelf requestRebake:@"landing failed"]; - } - }]; - // only the manager's settle leaves this state; say so if it has not come - NSUInteger serial = _stateSerial; - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - __strong __typeof(weakSelf) strongSelf = weakSelf; - if (strongSelf != nil && strongSelf->_stateSerial == serial) { - RNCPageCurlLog(@"[page-curl] no settle 3s after landing toward %@; still awaiting it", direction); - } - }); + [self apply:[_machine turnFinishedToward:direction completed:completed]]; } @end From 21222eed5d74edb5099b070848f3acdab3af4189 Mon Sep 17 00:00:00 2001 From: Cameron Webb Date: Wed, 28 Oct 2026 14:03:22 -0500 Subject: [PATCH 3/5] Page curl: let the curl pan wait on WebKit's holds instead of a 350ms timer A touch that moved less than 8pt in its first 350ms handed itself to WebKit for good, so a drag that began with a short pause never curled and the manager's own swipe rule turned the page instead. Now the curl pan requires WebKit's long holds (the selection loupe, the link lift) to fail, which they do as soon as the finger moves, and the short tap highlight holds keep yielding to the pan as before. A still finger still selects; a moving one curls, however long it paused first. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01635Bx126DsG1CbDzDxxBJx --- apple/RNCWebViewPageCurl.m | 70 ++++++++++++++++++++------------------ 1 file changed, 36 insertions(+), 34 deletions(-) diff --git a/apple/RNCWebViewPageCurl.m b/apple/RNCWebViewPageCurl.m index 1176febdd..f3d30c5c0 100644 --- a/apple/RNCWebViewPageCurl.m +++ b/apple/RNCWebViewPageCurl.m @@ -4,6 +4,8 @@ #import BOOL RNCPageCurlLoggingEnabled = NO; +// holds at least this long carry an intent of their own (select, lift a link); shorter ones are tap decoration +static const NSTimeInterval RNCPageCurlIntentHoldSeconds = 0.3; static NSString *const RNCPageCurlSlotCurrent = @"current"; static NSString *const RNCPageCurlSlotPrevious = @"previous"; @@ -60,16 +62,13 @@ static CGFloat RNCPageCurlEaseOut(CGFloat t) return 1 - inv * inv * inv; } -// Never recognizes; only reports raw touch begin/end so a tap can be told apart from a drag -// and a still hold can hand the touch back to WebKit's selection recognizers. +// Never recognizes; only reports raw touch begin/end so a tap can be told apart from a drag. @interface RNCPageCurlTouchObserver : UIGestureRecognizer @property (nonatomic, copy) void (^onTouchesBegan)(void); @property (nonatomic, copy) void (^onTouchesEnded)(void); -@property (nonatomic, copy) void (^onHeldStill)(void); @property (nonatomic, assign) CGPoint startPoint; @property (nonatomic, assign) CFTimeInterval startTime; @property (nonatomic, assign) BOOL moved; -@property (nonatomic, assign) NSUInteger touchSequence; // the first finger down; later fingers are ignored until it lifts @property (nonatomic, weak) UITouch *touch; @end @@ -86,16 +85,6 @@ - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event self.startPoint = [self.touch locationInView:self.view]; self.startTime = CACurrentMediaTime(); self.moved = NO; - self.touchSequence += 1; - NSUInteger sequence = self.touchSequence; - __weak __typeof(self) weakSelf = self; - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.35 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - __strong __typeof(weakSelf) strongSelf = weakSelf; - if (strongSelf != nil && strongSelf.touchSequence == sequence && !strongSelf.moved && - strongSelf.state == UIGestureRecognizerStatePossible && strongSelf.onHeldStill) { - strongSelf.onHeldStill(); - } - }); if (self.onTouchesBegan) { self.onTouchesBegan(); } @@ -109,8 +98,9 @@ - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event return; } CGPoint point = [touch locationInView:self.view]; - if (fabs(point.x - self.startPoint.x) > 8 || fabs(point.y - self.startPoint.y) > 8) { + if (!self.moved && (fabs(point.x - self.startPoint.x) > 8 || fabs(point.y - self.startPoint.y) > 8)) { self.moved = YES; + RNCPageCurlLog(@"[page-curl] touch moved past 8pt after %.0fms", (CACurrentMediaTime() - self.startTime) * 1000); } } @@ -180,6 +170,7 @@ @implementation RNCWebViewPageCurl { BOOL _edgeEmitted; // the bake loop; the controller reports what happened and carries out the effects it returns RNCPageCurlStateMachine *_machine; + NSHashTable *_knownHolds; // UIKit hit-tests once, at touch-down; only a touch it delivered inside the webview is one the // manager saw and will answer BOOL _touchReachedWebView; @@ -447,26 +438,22 @@ - (void)setEnabled:(BOOL)enabled BOOL moved = strongSelf->_touchObserver.moved; RNCPageCurlLog(@"[page-curl] touches ended; moved=%d state=%@ reachedWebView=%d", moved, RNCPageCurlStateName(strongSelf->_machine.state), strongSelf->_touchReachedWebView); + if (RNCPageCurlLoggingEnabled) { + NSMutableArray *states = [NSMutableArray array]; + [states addObject:[NSString stringWithFormat:@"pan=%ld", (long)strongSelf->_pan.state]]; + for (UIGestureRecognizer *hold in strongSelf->_knownHolds) { + [states addObject:[NSString stringWithFormat:@"%@=%ld", NSStringFromClass([hold class]), (long)hold.state]]; + } + RNCPageCurlLog(@"[page-curl] recognizer states at lift: %@", [states componentsJoinedByString:@" "]); + } if (!moved && strongSelf->_touchReachedWebView && strongSelf->_machine.state == RNCPageCurlStateIdle) { [strongSelf apply:[strongSelf->_machine tapped]]; [strongSelf emit:@"tap" direction:nil detail:nil]; } }; - _touchObserver.onHeldStill = ^{ - __strong __typeof(weakSelf) strongSelf = weakSelf; - if (strongSelf == nil || strongSelf->_pan == nil) { - return; - } - // a still hold is a text-selection intent, not a page drag: fail the curl pan so - // WebKit's long-press recognizers (which now wait on it) can proceed - if (strongSelf->_pan.state == UIGestureRecognizerStatePossible) { - RNCPageCurlLog(@"[page-curl] held still for 350ms; releasing the curl pan for this touch"); - strongSelf->_pan.enabled = NO; - strongSelf->_pan.enabled = YES; - } - }; [host addGestureRecognizer:_touchObserver]; + _knownHolds = [NSHashTable weakObjectsHashTable]; _pan = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(onPan:)]; _pan.maximumNumberOfTouches = 1; _pan.delegate = self; @@ -476,7 +463,10 @@ - (void)setEnabled:(BOOL)enabled } // touch scrolling off in every scroll view inside the webview (rescanned at each touch-down: WebKit -// adds more as chunks load) and its pans wait for the curl pan; not undone, the caller remounts +// adds more as chunks load) and its pans wait for the curl pan; not undone, the caller remounts. +// The curl pan itself waits for WebKit's long holds (the selection loupe at 0.5s, link lift at +// 0.65s): a hold fails as soon as the finger moves, so a drag still curls at once, and a still +// finger selects. The short holds (tap highlight at 0.12s) keep waiting on the pan like the pans do - (void)makeWebPansYieldToCurl { if (_webView == nil || _pan == nil) { @@ -484,6 +474,7 @@ - (void)makeWebPansYieldToCurl } NSMutableArray *stack = [NSMutableArray arrayWithObject:_webView]; NSUInteger recognizers = 0; + NSUInteger holds = 0; NSUInteger scrollViews = 0; while (stack.count > 0) { UIView *view = stack.lastObject; @@ -496,17 +487,27 @@ - (void)makeWebPansYieldToCurl } } for (UIGestureRecognizer *recognizer in view.gestureRecognizers) { - BOOL isPan = [recognizer isKindOfClass:[UIPanGestureRecognizer class]]; - BOOL isLongPress = [recognizer isKindOfClass:[UILongPressGestureRecognizer class]]; - if ((isPan || isLongPress) && recognizer != _pan) { + if (recognizer == _pan) { + continue; + } + UILongPressGestureRecognizer *hold = [recognizer isKindOfClass:[UILongPressGestureRecognizer class]] ? (UILongPressGestureRecognizer *)recognizer : nil; + if (hold != nil && hold.minimumPressDuration >= RNCPageCurlIntentHoldSeconds) { + if (![_knownHolds containsObject:hold]) { + [_knownHolds addObject:hold]; + RNCPageCurlLog(@"[page-curl] curl pan waits on %@ (%.2fs, %.0fpt)", NSStringFromClass([hold class]), hold.minimumPressDuration, + hold.allowableMovement); + } + [_pan requireGestureRecognizerToFail:hold]; + holds += 1; + } else if (hold != nil || [recognizer isKindOfClass:[UIPanGestureRecognizer class]]) { [recognizer requireGestureRecognizerToFail:_pan]; recognizers += 1; } } [stack addObjectsFromArray:view.subviews]; } - RNCPageCurlLog(@"[page-curl] web scrolling off: %lu scroll views changed, %lu recognizers yield to the curl pan", - (unsigned long)scrollViews, (unsigned long)recognizers); + RNCPageCurlLog(@"[page-curl] web scrolling off: %lu scroll views changed, %lu recognizers yield to the curl pan, which waits on %lu holds", + (unsigned long)scrollViews, (unsigned long)recognizers, (unsigned long)holds); } - (void)setSpine:(NSString *)spine @@ -540,6 +541,7 @@ - (void)teardown if (_pan != nil) { [_pan.view removeGestureRecognizer:_pan]; _pan = nil; + _knownHolds = nil; } if (_touchObserver != nil) { [_touchObserver.view removeGestureRecognizer:_touchObserver]; From 832355c622ce14369f4089238960d21e196d9f7d Mon Sep 17 00:00:00 2001 From: Cameron Webb Date: Wed, 28 Oct 2026 15:57:05 -0500 Subject: [PATCH 4/5] Page curl: drop the recognizer state dump at touch end It was there to diagnose the hold arbitration and has served that. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01635Bx126DsG1CbDzDxxBJx --- apple/RNCWebViewPageCurl.m | 8 -------- 1 file changed, 8 deletions(-) diff --git a/apple/RNCWebViewPageCurl.m b/apple/RNCWebViewPageCurl.m index f3d30c5c0..cd71ac1af 100644 --- a/apple/RNCWebViewPageCurl.m +++ b/apple/RNCWebViewPageCurl.m @@ -438,14 +438,6 @@ - (void)setEnabled:(BOOL)enabled BOOL moved = strongSelf->_touchObserver.moved; RNCPageCurlLog(@"[page-curl] touches ended; moved=%d state=%@ reachedWebView=%d", moved, RNCPageCurlStateName(strongSelf->_machine.state), strongSelf->_touchReachedWebView); - if (RNCPageCurlLoggingEnabled) { - NSMutableArray *states = [NSMutableArray array]; - [states addObject:[NSString stringWithFormat:@"pan=%ld", (long)strongSelf->_pan.state]]; - for (UIGestureRecognizer *hold in strongSelf->_knownHolds) { - [states addObject:[NSString stringWithFormat:@"%@=%ld", NSStringFromClass([hold class]), (long)hold.state]]; - } - RNCPageCurlLog(@"[page-curl] recognizer states at lift: %@", [states componentsJoinedByString:@" "]); - } if (!moved && strongSelf->_touchReachedWebView && strongSelf->_machine.state == RNCPageCurlStateIdle) { [strongSelf apply:[strongSelf->_machine tapped]]; [strongSelf emit:@"tap" direction:nil detail:nil]; From f491b7b342f3e35bf74f033baea42fcda374324e Mon Sep 17 00:00:00 2001 From: Cameron Webb Date: Wed, 28 Oct 2026 16:42:16 -0500 Subject: [PATCH 5/5] Page curl: grab the sheet under the finger, not at its outer edge A turn started mid-page moved the far corner, so the fold trailed well to the right of the finger. The grab point is now the touch, held no closer to the spine than grabFloor of the page width, and the fold sweeps in from the outer edge to meet it at sweepGain times the finger's speed. grabFloor 1 is the old edge grab exactly. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01635Bx126DsG1CbDzDxxBJx --- apple/RNCWebViewPageCurl.m | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/apple/RNCWebViewPageCurl.m b/apple/RNCWebViewPageCurl.m index cd71ac1af..cd63af7b2 100644 --- a/apple/RNCWebViewPageCurl.m +++ b/apple/RNCWebViewPageCurl.m @@ -18,6 +18,8 @@ @"radiusFraction": @0.12, @"radiusMax": @90, @"bendInDistance": @30, + @"grabFloor": @0.3, + @"sweepGain": @2, @"settleGain": @2, @"tiltSoftness": @120, @"castWidthFloor": @0.5, @@ -1131,9 +1133,10 @@ - (void)beginTurn:(NSString *)direction from:(CGPoint)start CGFloat height = _turnSheetRect.size.height; _turnRadiusMax = MIN([self tune:@"radiusMax"], width * [self tune:@"radiusFraction"]); _renderer.curlRadiusMax = _turnRadiusMax; - // the sheet is grabbed at its outer edge on the touch's row, wherever the touch lands, and that - // edge moves exactly as far as the finger does - _turnStart = CGPointMake(width, MIN(MAX(start.y - _turnSheetRect.origin.y, 0), height)); + // the sheet is grabbed under the finger, no closer to the spine than grabFloor of the width, and + // that point moves exactly as far as the finger does + CGFloat grabX = MIN(MAX([self localXFor:start.x], width * [self tune:@"grabFloor"]), width); + _turnStart = CGPointMake(grabX, MIN(MAX(start.y - _turnSheetRect.origin.y, 0), height)); _turnRest = _turnReversed ? [self turnedFinger] : _turnStart; _turnTouchDown = CGPointMake([self localXFor:start.x], start.y - _turnSheetRect.origin.y); _turnDrag = 0; @@ -1228,11 +1231,13 @@ - (void)applyFinger:(CGPoint)finger finger = CGPointMake(_turnStart.x - nx * distance, _turnStart.y - ny * distance); } CGFloat radius = MIN(wantedRadius, MAX(2 * (room - distance / 2) / M_PI, 0)); - // a zero drag is a flat sheet: while the bend forms, the fold starts past the outer edge and sweeps - // in, instead of sitting at the grab point with everything beyond it mirrored onto itself - CGFloat bendIn = MAX([self tune:@"bendInDistance"], 1); - CGFloat settle = MAX(0, 1 - distance / bendIn); - CGFloat foldOffset = settle * (_turnSheetRect.size.width - _turnStart.x + M_PI * radius / 2 + 4); + // a zero drag is a flat sheet: the fold starts past the outer edge and sweeps in to the grab point + // at sweepGain times the finger's speed, instead of appearing at the grab point with everything + // beyond it already folded over + CGFloat beyond = _turnSheetRect.size.width - _turnStart.x; + CGFloat sweepIn = MAX(MAX([self tune:@"bendInDistance"], 1), beyond / MAX([self tune:@"sweepGain"], 0.01)); + CGFloat settle = MAX(0, 1 - distance / sweepIn); + CGFloat foldOffset = settle * (beyond + M_PI * radius / 2 + 4); CGFloat foldX = _turnStart.x + nx * foldOffset - nx * (distance + M_PI * radius) / 2; _renderer.curlStart = CGPointMake(_turnStart.x + nx * foldOffset, _turnStart.y + ny * foldOffset); _renderer.curlFinger = CGPointMake(finger.x + nx * foldOffset, finger.y + ny * foldOffset);