diff --git a/Sources/KeyPathAppKit/UI/KeyboardStage/KeyboardStageInteraction.swift b/Sources/KeyPathAppKit/UI/KeyboardStage/KeyboardStageInteraction.swift index 97f78f8f4..f8b30fc42 100644 --- a/Sources/KeyPathAppKit/UI/KeyboardStage/KeyboardStageInteraction.swift +++ b/Sources/KeyPathAppKit/UI/KeyboardStage/KeyboardStageInteraction.swift @@ -157,12 +157,17 @@ extension KeyboardStageScene { copy.keys = keys.map { key in let pressLevel = interaction.pressed[key.keyCode] ?? 0 let holdLevel = interaction.held[key.keyCode] ?? 0 - let level = max(pressLevel, holdLevel) - guard abs(level) > 0.001 else { return key } + // Gate on the signed components, not their collapsed max: during + // the rebound a plain tap has pressLevel slightly negative and no + // hold level, and max(negative, 0) would discard exactly the + // frames the above-rest lift exists for. + guard abs(pressLevel) > 0.001 || abs(holdLevel) > 0.001 else { + return key + } var responsiveKey = key responsiveKey.opacity = max(responsiveKey.opacity, 0.98) - responsiveKey.interactionLevel = max(0, level) + responsiveKey.interactionLevel = max(0, max(pressLevel, holdLevel)) let positivePress = max(0, pressLevel) let positiveHold = max(0, holdLevel) // The sub-linear press term makes the glow linger behind the diff --git a/Sources/KeyPathAppKit/UI/KeyboardStage/KeyboardStageSceneBuilder.swift b/Sources/KeyPathAppKit/UI/KeyboardStage/KeyboardStageSceneBuilder.swift index 1a04aeacc..5cf2ee35c 100644 --- a/Sources/KeyPathAppKit/UI/KeyboardStage/KeyboardStageSceneBuilder.swift +++ b/Sources/KeyPathAppKit/UI/KeyboardStage/KeyboardStageSceneBuilder.swift @@ -124,11 +124,15 @@ enum KeyboardStageSceneBuilder { dim(keys: &keys, except: [capsKeyID], opacity: 0.88) keys[capsIndex].role = .escape keys[capsIndex].pressure = 0.12 - keys[capsIndex].translation.y = 0.12 * 0.023 keys[capsIndex].glow = 0.85 keys[capsIndex].opacity = 1 keys[capsIndex].scale = 0.78 - keys[capsIndex].translation = KeyboardStagePoint(x: -0.48, y: -0.52) + // The authored transform pose owns the base translation; the + // physical press travel is applied on top of it. + keys[capsIndex].translation = KeyboardStagePoint( + x: -0.48, + y: -0.52 + 0.12 * 0.023 + ) keys[capsIndex].legend = KeyboardStageLegend( primary: "esc", previous: "caps lock", diff --git a/Tests/KeyPathTests/KeyboardStage/KeyboardStageSceneTests.swift b/Tests/KeyPathTests/KeyboardStage/KeyboardStageSceneTests.swift index f44029f24..1aa530b5c 100644 --- a/Tests/KeyPathTests/KeyboardStage/KeyboardStageSceneTests.swift +++ b/Tests/KeyPathTests/KeyboardStage/KeyboardStageSceneTests.swift @@ -521,6 +521,21 @@ final class KeyboardStageSceneTests: XCTestCase { XCTAssertEqual(otherKey, originalOtherKey) } + func testReleaseReboundLiftsTheCapAboveRest() throws { + let scene = makeScene(moment: .capsInstalled) + let responsiveScene = scene.applyingInteraction( + KeyboardStageInteractionLevels(pressed: [57: -0.025], held: [:]) + ) + let caps = try capsKey(in: responsiveScene) + let baseCaps = try capsKey(in: scene) + + // The underdamped release dips slightly negative; the cap must rise + // a hair above its rest position, with no residual press styling. + XCTAssertLessThan(caps.translation.y, baseCaps.translation.y) + XCTAssertEqual(caps.interactionLevel, 0) + XCTAssertEqual(caps.pressure, baseCaps.pressure) + } + func testLivePressRespondsOnANeutralNonCapsKey() throws { let scene = makeScene(moment: .capsInstalled) let neutral = try XCTUnwrap(scene.keys.first { key in