diff --git a/android/src/androidTest/java/com/keyflow/KeyflowPressFeedbackTest.kt b/android/src/androidTest/java/com/keyflow/KeyflowPressFeedbackTest.kt index a483629..ee7a889 100644 --- a/android/src/androidTest/java/com/keyflow/KeyflowPressFeedbackTest.kt +++ b/android/src/androidTest/java/com/keyflow/KeyflowPressFeedbackTest.kt @@ -236,6 +236,67 @@ class KeyflowPressFeedbackTest { } } + @Test fun spaceTrackpadKeepsPressedColorUntilRelease() = assertTrackpadFeedback("flat", false) + + @Test fun raisedSpaceTrackpadKeepsPressedColor() = assertTrackpadFeedback("raised", false) + + @Test fun spaceTrackpadCancellationRestoresColor() = assertTrackpadFeedback("flat", true) + + private fun assertTrackpadFeedback(material: String, cancel: Boolean) { + ActivityScenario.launch(KeyflowTestActivity::class.java).use { scenario -> + var clicks = 0 + val moves = mutableListOf() + scenario.onActivity { activity -> + val key = KeyflowKeyView(activity, "space", "") { clicks++ } + key.onSlide = { moves += it } + val host = FrameLayout(activity) + host.addView(key, FrameLayout.LayoutParams(240, 180)) + activity.setContentView(host) + key.layout(0, 0, 240, 180) + key.applyTheme( + KeyflowTheme( + JSONObject() + .put("material", material) + .put("keyBackground", "#17324F") + .put("pressedKeyBackground", "#A13FC5") + ) + ) + val normal = pixels(key) + val down = SystemClock.uptimeMillis() + fun touch(action: Int, x: Float, y: Float = 90f) { + MotionEvent.obtain(down, SystemClock.uptimeMillis(), action, x, y, 0).also { + key.dispatchTouchEvent(it) + it.recycle() + } + } + touch(MotionEvent.ACTION_DOWN, 120f) + val pressed = pixels(key) + assertFalse(normal.sameAs(pressed)) + val step = 24 * activity.resources.displayMetrics.density + for ((x, y) in listOf(120f + step to 90f, key.width + step to -20f, 120f to 90f)) { + touch(MotionEvent.ACTION_MOVE, x, y) + assertTrue("Space must remain pressed while moving the cursor", key.isPressed) + val active = pixels(key) + assertTrue( + "Trackpad must keep the configured pressed face in $material", + pressed.sameAs(active), + ) + active.recycle() + } + assertTrue("The gesture must actually move the cursor", moves.isNotEmpty()) + touch(if (cancel) MotionEvent.ACTION_CANCEL else MotionEvent.ACTION_UP, 120f) + assertFalse(key.isPressed) + val released = pixels(key) + assertTrue("Restore the resting color immediately", normal.sameAs(released)) + normal.recycle() + pressed.recycle() + released.recycle() + } + SystemClock.sleep(100) + scenario.onActivity { assertEquals("Cursor movement must not insert a space", 0, clicks) } + } + } + private fun pixels(key: KeyflowKeyView): Bitmap = Bitmap.createBitmap(key.width, key.height, Bitmap.Config.ARGB_8888).also { key.background.setBounds(0, 0, key.width, key.height) diff --git a/android/src/main/java/com/keyflow/KeyflowKeyView.kt b/android/src/main/java/com/keyflow/KeyflowKeyView.kt index 45066e4..fe2b58a 100644 --- a/android/src/main/java/com/keyflow/KeyflowKeyView.kt +++ b/android/src/main/java/com/keyflow/KeyflowKeyView.kt @@ -536,7 +536,7 @@ internal class KeyflowKeyView( sliding = true touchX += steps * 12 * density onSlide?.invoke(steps) - isPressed = false + isPressed = true } } MotionEvent.ACTION_UP -> diff --git a/docs/coverage.md b/docs/coverage.md index 31c1890..736f9ab 100644 --- a/docs/coverage.md +++ b/docs/coverage.md @@ -4,20 +4,20 @@ The device suite retains the pre-release test inventory recorded in [`baseline-tests.json`](../scripts/ci/baseline-tests.json), -plus four Android regressions for disappearing glyphs, eight for Android modifier states, and five for iOS modifier states. The tests run against the +plus four Android regressions for disappearing glyphs, eight for Android modifier states, five for iOS modifier states, three iOS held-delete cases, and nine space/trackpad feedback cases and one iPad stationary currency-hold case. The tests run against the current keyboard implementation. There is no optional expanded suite or runtime filter hiding additional cases. -| Device | Required cases before platform-specific skips | -| -------------- | ------------------------------------------------ | -| Android phone | 48 original + 4 glyph + 8 Shift regressions = 60 | -| Android tablet | 48 original + 4 glyph + 8 Shift regressions = 60 | -| iPhone | 43 rendering + 60 interaction cases | -| iPad | 43 rendering + 60 interaction cases | +| Device | Required cases before platform-specific skips | +| -------------- | ------------------------------------------------------------- | +| Android phone | 48 original + 4 glyph + 8 Shift + 3 trackpad regressions = 63 | +| Android tablet | 48 original + 4 glyph + 8 Shift + 3 trackpad regressions = 63 | +| iPhone | 50 rendering + 60 interaction cases | +| iPad | 50 rendering + 60 interaction cases | `scripts/ci/baseline-tests.json` records the original inventory for verification. A fast workflow test checks that the actual device sources contain precisely that -inventory plus the seventeen retained glyph/modifier cases and three held-delete lifecycle cases. It does not select or skip tests. +inventory plus the seventeen retained glyph/modifier cases plus three held-delete lifecycle cases and nine space/trackpad feedback cases and one iPad stationary currency-hold case. It does not select or skip tests. iOS coverage includes typing, symbol pages, long presses, accent selection, number pads, tablet layouts, customization, transparency and transition diagnostics. @@ -25,6 +25,8 @@ Android native coverage includes layouts, input, accent interactions and press-r styling. The four additional cases verify actual visible glyph pixels in flat and raised materials, both at rest and while pressed. Shift regressions check left/right activation, filled-arrow pixels, one-letter reset, independent Android Caps Lock/Shift activation, shifted punctuation output, and the separate automatic-capitalization state. iPad regressions verify its distinct modifier behavior and `! ?` punctuation, including accessibility labels and rendered attachments. Phone regressions check persistent uppercase and the distinct Caps Lock glyph on both platforms. +Space/trackpad regressions verify iOS touch-down fill contrast, normal release and custom-color cancellation, legend and key-face fading, restoration during an interrupted fade, and restoration after cursor dragging. Android tests compare rendered space-bar pixels during cursor movement in flat and raised materials, and verify that release or cancellation restores the resting color without inserting a space. + The newer Android React Native app automation, additional accessibility/window suites and iOS allocation/performance/background cases were removed. Android example-level lifecycle, transparency and transition performance are therefore @@ -81,3 +83,5 @@ harness-only changes, run focused local regressions and use GitHub to validate i runner-specific conditions. Broaden local testing when production keyboard changes or a concrete failure warrants it. Evidence is saved under `artifacts/` and is not published with the library. + +The iPad dollar UI comparison explicitly slides into the currency popup before release: Apple's stationary dollar hold can show a highlighted choice yet commit nothing on CI. The non-empty native result and exact Keyflow output comparison remain required. A separate rendering regression preserves coverage of Keyflow's stationary dollar hold and release. diff --git a/example/assets/backdrops/transparency-0.png b/example/assets/backdrops/transparency-0.png deleted file mode 100644 index 6ab9b4f..0000000 Binary files a/example/assets/backdrops/transparency-0.png and /dev/null differ diff --git a/example/assets/backdrops/transparency-1.png b/example/assets/backdrops/transparency-1.png deleted file mode 100644 index d768b38..0000000 Binary files a/example/assets/backdrops/transparency-1.png and /dev/null differ diff --git a/ios/KeyflowKey.swift b/ios/KeyflowKey.swift index 053d7c1..01ca721 100644 --- a/ios/KeyflowKey.swift +++ b/ios/KeyflowKey.swift @@ -51,6 +51,7 @@ final class KeyflowKey: UIView { label.alpha = hidesLegend || !preview.isHidden ? 0 : (isDisabledKey ? 0.35 : 1) padSubtitle.alpha = hidesLegend ? 0 : (isDisabledKey ? 0.35 : 1) icon.alpha = hidesLegend ? 0 : 1 + face.alpha = hidesLegend ? 0.5 : 1 } } var allowsPreview = true diff --git a/ios/KeyflowKeyboardView.swift b/ios/KeyflowKeyboardView.swift index f15b11a..3dc355e 100644 --- a/ios/KeyflowKeyboardView.swift +++ b/ios/KeyflowKeyboardView.swift @@ -460,9 +460,8 @@ final class KeyflowKeyboardView: UIView { guard let self, let key, touchesByID[id] === key else { return } key.isPressed = false if value == " " { - cursorMode = true cursorLastX = heldOrigin.x - rows.flatMap { $0 }.forEach { $0.hidesLegend = true } + setCursorMode(true) if hapticsEnabled { haptic.impactOccurred() } } else { showAccents(for: key, value: value) @@ -724,11 +723,23 @@ final class KeyflowKeyboardView: UIView { return true } + private func setCursorMode(_ active: Bool) { + guard cursorMode != active else { return } + cursorMode = active + UIView.animate( + withDuration: UIAccessibility.isReduceMotionEnabled ? 0 : 0.2, + delay: 0, + options: [.beginFromCurrentState, .allowUserInteraction, .curveEaseOut] + ) { + self.rows.flatMap { $0 }.forEach { $0.hidesLegend = active } + } + } + private func cancelTouches() { holdWork?.cancel() holdWork = nil heldTouch = nil - cursorMode = false + setCursorMode(false) accentKeys.forEach { $0.removeFromSuperview() } accentKeys = [] selectedAccent = nil @@ -736,7 +747,6 @@ final class KeyflowKeyboardView: UIView { accentSelectionIndicator.isHidden = true accentItemWidth = 0 updateAccessibleKeys() - rows.flatMap { $0 }.forEach { $0.hidesLegend = false } touchesByID.values.forEach { $0.isPressed = false } touchesByID.removeAll() originalKeysByID.removeAll() diff --git a/ios/KeyflowTheme.swift b/ios/KeyflowTheme.swift index 8582905..4aeed64 100644 --- a/ios/KeyflowTheme.swift +++ b/ios/KeyflowTheme.swift @@ -8,7 +8,7 @@ struct KeyflowTheme: Decodable, Equatable { var background = "#E0E2E7" var keyBackground = "#FFFFFF" var keyForeground = "#000000" - var pressedKeyBackground = "#FFFFFF" + var pressedKeyBackground = "#C1C3C6" var selectedKeyBackground = "#008FFF" var selectedKeyForeground = "#FFFFFF" var specialKeyBackground = "#FFFFFF" diff --git a/scripts/ci/workflow.test.mjs b/scripts/ci/workflow.test.mjs index 19f7c3d..75b5a65 100644 --- a/scripts/ci/workflow.test.mjs +++ b/scripts/ci/workflow.test.mjs @@ -140,6 +140,13 @@ test('device sources contain the proven inventory plus glyph and Shift regressio group === 'iosRendering' ? [ 'testPhoneDoubleShiftLocksCaseAndShowsLockGlyph', + 'testSpacePressChangesDefaultFillAndRestoresOnRelease', + 'testSpacePressRestoresCustomFillOnCancellation', + 'testSpaceTrackpadSoftensFacesAndRestoresCustomColors', + 'testTabletDollarHoldCommitsInitialChoiceWithoutDrag', + 'testSpaceTrackpadLegendsFadeOnEntry', + 'testSpaceTrackpadReleaseRestoresLegendsDuringFade', + 'testSpaceTrackpadCancellationRestoresLegends', 'testHeldDeleteStopsOnRelease', 'testHeldDeleteStopsOnCancellation', 'testHeldDeleteStopsOutsideKey', @@ -172,6 +179,9 @@ test('device sources contain the proven inventory plus glyph and Shift regressio 'tabletShiftThenCapsRemainSelected', 'shiftedCommaDisplaysAndInsertsPlatformValue', 'shiftedPeriodDisplaysAndInsertsPlatformValue', + 'spaceTrackpadKeepsPressedColorUntilRelease', + 'raisedSpaceTrackpadKeepsPressedColor', + 'spaceTrackpadCancellationRestoresColor', 'flatPressedGlyphActuallyRenders', 'raisedPressedGlyphActuallyRenders', 'flatRestingGlyphActuallyRenders', diff --git a/scripts/generate-transparency-fixtures.mjs b/scripts/generate-transparency-fixtures.mjs deleted file mode 100644 index 188733e..0000000 --- a/scripts/generate-transparency-fixtures.mjs +++ /dev/null @@ -1,41 +0,0 @@ -import { mkdirSync, writeFileSync } from 'node:fs'; -import { PNG } from 'pngjs'; - -// Deterministic image fixtures: hard edges expose blur/refraction and opaque backing. -mkdirSync('example/assets/backdrops', { recursive: true }); -for (const variant of [0, 1]) { - const png = new PNG({ width: 804, height: 1748 }); - const colors = variant - ? [ - [251, 188, 113], - [237, 135, 143], - [143, 196, 247], - [228, 208, 255], - ] - : [ - [182, 234, 214], - [108, 208, 165], - [148, 202, 237], - [238, 226, 180], - ]; - for (let y = 0; y < png.height; y++) { - for (let x = 0; x < png.width; x++) { - const i = (y * png.width + x) * 4; - const band = Math.floor((x + y * 0.35) / 140) % colors.length; - const grid = x % 80 < 3 || y % 80 < 3; - const circle = Math.hypot(x - 470, y - 1340) < 220; - const color = circle - ? variant - ? [157, 169, 236] - : [54, 191, 91] - : colors[band]; - for (let c = 0; c < 3; c++) - png.data[i + c] = grid ? Math.round(color[c] * 0.75) : color[c]; - png.data[i + 3] = 255; - } - } - writeFileSync( - `example/assets/backdrops/transparency-${variant}.png`, - PNG.sync.write(png), - ); -} diff --git a/scripts/ios-tests/KeyflowQwertyTests.swift b/scripts/ios-tests/KeyflowQwertyTests.swift index 150d2a1..3b98c64 100644 --- a/scripts/ios-tests/KeyflowQwertyTests.swift +++ b/scripts/ios-tests/KeyflowQwertyTests.swift @@ -303,10 +303,23 @@ final class KeyflowQwertyTests: XCTestCase { key(["numbers", "123"]).tap() let editMenu = app.descendants(matching: .any).matching(NSPredicate(format: "label == 'Select All' OR label == 'AutoFill'")).firstMatch XCTAssertFalse(editMenu.exists && editMenu.isHittable, "Punctuation hold must start without an edit menu") - key([symbol]).press(forDuration: 1.2) + let source = key([symbol]) + if tablet && symbol == "$" { + // iPadOS can highlight cents yet cancel a stationary release at the + // original dollar key. Explicitly enter the popup before releasing. + // Derive the target from the key frame, not a device-specific point. + let origin = source.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + let choice = source.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: -0.5)) + origin.press(forDuration: 1.2, thenDragTo: choice, withVelocity: .slow, thenHoldForDuration: 0.1) + } else { + source.press(forDuration: 1.2) + } if native { expected = text XCTAssertNotEqual(expected, "alpha beta", "Native hold must insert a character") + if tablet && symbol == "$" { + XCTAssertEqual(expected, "alpha beta¢", "The currency gesture must select cents, not merely type a dollar") + } } else { XCTAssertEqual(text, expected, "Held \(symbol) release must match Apple") } capture("\(native ? "apple" : "keyflow")-punctuation-\(symbol.unicodeScalars.first!.value)") diff --git a/scripts/ios-tests/KeyflowRenderingTests.swift b/scripts/ios-tests/KeyflowRenderingTests.swift index cbbdb18..89f8666 100644 --- a/scripts/ios-tests/KeyflowRenderingTests.swift +++ b/scripts/ios-tests/KeyflowRenderingTests.swift @@ -217,18 +217,168 @@ final class KeyflowRenderingTests: XCTestCase { wait(for: [stopped], timeout: 0.2) } + func testSpacePressChangesDefaultFillAndRestoresOnRelease() throws { + try assertSpacePress(theme: KeyflowTheme(), cancel: false) + } + + func testSpacePressRestoresCustomFillOnCancellation() throws { + var theme = KeyflowTheme() + theme.keyBackground = "#17324F" + theme.pressedKeyBackground = "#A13FC5" + try assertSpacePress(theme: theme, cancel: true) + } + + private func assertSpacePress(theme: KeyflowTheme, cancel: Bool) throws { + let keyboard = KeyflowKeyboardView() + keyboard.theme = theme + let screen = UIScreen.main.bounds.size + keyboard.updateViewport(screen, insets: .zero) + keyboard.frame = CGRect(x: 0, y: 0, width: screen.width, height: keyboard.intrinsicContentSize.height) + keyboard.layoutIfNeeded() + let space = try XCTUnwrap(keyboard.subviews.compactMap { $0 as? KeyflowKey }.first { $0.action == .text(" ") }) + let resting = space.face.backgroundColor + var actions: [KeyflowAction] = [] + keyboard.onAction = { actions.append($0) } + let touch = AccentTouch() + touch.point = CGPoint(x: space.frame.midX, y: space.frame.midY) + keyboard.touchesBegan([touch], with: nil) + XCTAssertNotEqual(space.face.backgroundColor, resting, "Space must visibly change fill on touch-down") + XCTAssertEqual(space.face.backgroundColor, UIColor(keyflowHex: theme.pressedKeyBackground)) + if cancel { keyboard.touchesCancelled([touch], with: nil) } + else { keyboard.touchesEnded([touch], with: nil) } + XCTAssertEqual(space.face.backgroundColor, resting) + XCTAssertEqual(actions, cancel ? [] : [.text(" ")]) + } + + func testSpaceTrackpadSoftensFacesAndRestoresCustomColors() throws { + try withTrackpad { keyboard, touch, _ in + let space = try XCTUnwrap(keyboard.subviews.compactMap { $0 as? KeyflowKey }.first { $0.action == .text(" ") }) + XCTAssertEqual(space.face.alpha, 0.5) + XCTAssertNotNil(space.face.layer.animation(forKey: "opacity"), "Trackpad faces must fade with the legends") + var theme = keyboard.theme + theme.keyBackground = "#17324F" + theme.pressedKeyBackground = "#A13FC5" + space.theme = theme + XCTAssertEqual(space.face.backgroundColor, UIColor(keyflowHex: theme.keyBackground)) + XCTAssertEqual(space.face.alpha, 0.5, "Trackpad must preserve custom fills while softening them") + touch.point.x -= 24 + keyboard.touchesMoved([touch], with: nil) + XCTAssertEqual(space.face.alpha, 0.5) + keyboard.touchesEnded([touch], with: nil) + XCTAssertEqual(space.face.alpha, 1) + XCTAssertEqual(space.face.backgroundColor, UIColor(keyflowHex: theme.keyBackground)) + } + } + + func testSpaceTrackpadLegendsFadeOnEntry() throws { + try withTrackpad { keyboard, touch, legends in + for legend in legends { + XCTAssertEqual(legend.alpha, 0) + let animation = try XCTUnwrap(legend.layer.animation(forKey: "opacity"), "Trackpad entry must fade instead of hiding instantly") + XCTAssertGreaterThan(animation.duration, 0) + XCTAssertLessThanOrEqual(animation.duration, 0.3) + } + var moves: [KeyflowAction] = [] + keyboard.onAction = { moves.append($0) } + touch.point.x += 24 + keyboard.touchesMoved([touch], with: nil) + XCTAssertEqual(moves, [.moveCursor(3)], "Fading must not block cursor movement") + } + } + + func testSpaceTrackpadReleaseRestoresLegendsDuringFade() throws { + try withTrackpad { keyboard, touch, legends in + keyboard.touchesEnded([touch], with: nil) + for legend in legends { XCTAssertEqual(legend.alpha, 1) } + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.3)) + for legend in legends { XCTAssertEqual(legend.layer.presentation()?.opacity ?? legend.layer.opacity, 1, accuracy: 0.01) } + } + } + + func testSpaceTrackpadCancellationRestoresLegends() throws { + try withTrackpad { keyboard, touch, legends in + keyboard.touchesCancelled([touch], with: nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.3)) + for legend in legends { XCTAssertEqual(legend.layer.presentation()?.opacity ?? legend.layer.opacity, 1, accuracy: 0.01) } + XCTAssertFalse(keyboard.subviews.compactMap { $0 as? KeyflowKey }.contains { $0.hidesLegend || $0.face.alpha != 1 }) + } + } + + private func withTrackpad(_ check: (KeyflowKeyboardView, AccentTouch, [UIView]) throws -> Void) throws { + try XCTSkipIf(UIAccessibility.isReduceMotionEnabled, "Fade timing requires standard motion settings") + let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first + let window = scene.map { UIWindow(windowScene: $0) } ?? UIWindow(frame: UIScreen.main.bounds) + let controller = UIViewController() + window.rootViewController = controller + window.makeKeyAndVisible() + defer { window.isHidden = true } + let keyboard = KeyflowKeyboardView() + keyboard.updateViewport(window.bounds.size, insets: .zero) + keyboard.frame = CGRect(x: 0, y: 100, width: window.bounds.width, height: keyboard.intrinsicContentSize.height) + controller.view.addSubview(keyboard) + NSLayoutConstraint.activate([ + keyboard.leadingAnchor.constraint(equalTo: controller.view.leadingAnchor), + keyboard.trailingAnchor.constraint(equalTo: controller.view.trailingAnchor), + keyboard.topAnchor.constraint(equalTo: controller.view.topAnchor, constant: 100), + ]) + window.layoutIfNeeded() + keyboard.layoutIfNeeded() + CATransaction.flush() + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.03)) + let keys = keyboard.subviews.compactMap { $0 as? KeyflowKey } + let letter = try XCTUnwrap(keys.first { $0.action == .text("a") }) + let shift = try XCTUnwrap(keys.first { $0.action == .shift }) + let space = try XCTUnwrap(keys.first { $0.action == .text(" ") }) + func view(_ key: KeyflowKey, _ property: String) throws -> UIView { + try XCTUnwrap(Mirror(reflecting: key).children.first { $0.label == property }?.value as? UIView) + } + var legends = try [view(letter, "label"), view(shift, "icon")] + if letter.tabletAlternate != nil { legends.append(try view(letter, "padSubtitle")) } + CATransaction.flush() + for legend in legends { XCTAssertEqual(legend.alpha, 1) } + let touch = AccentTouch() + touch.point = CGPoint(x: space.frame.midX, y: space.frame.midY) + keyboard.touchesBegan([touch], with: nil) + defer { keyboard.touchesCancelled([touch], with: nil) } + XCTAssertTrue(space.isPressed, "Space touch must enter the pressed state") + // Sample immediately after the real hold changes the legend model opacity. + let deadline = Date(timeIntervalSinceNow: 1) + while !letter.hidesLegend && Date() < deadline { + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.005)) + } + XCTAssertTrue(letter.hidesLegend, "Space hold must activate trackpad mode") + CATransaction.flush() + try check(keyboard, touch, legends) + } + private final class AccentTouch: UITouch { var point = CGPoint.zero override func location(in view: UIView?) -> CGPoint { point } } - private func withAccentPopup(_ check: (KeyflowKeyboardView, AccentTouch, [KeyflowKey], UIView) throws -> Void) throws { + func testTabletDollarHoldCommitsInitialChoiceWithoutDrag() throws { + try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .pad, "iPad currency popup") + try withAccentPopup(value: "$") { keyboard, touch, choices, _ in + XCTAssertEqual(choices.first { $0.isPressed }?.action, .text("¢")) + var actions: [KeyflowAction] = [] + keyboard.onAction = { actions.append($0) } + keyboard.touchesEnded([touch], with: nil) + XCTAssertEqual(actions, [.text("¢")], "Stationary release must commit the initial highlighted currency") + } + } + + private func withAccentPopup(value: String = "e", _ check: (KeyflowKeyboardView, AccentTouch, [KeyflowKey], UIView) throws -> Void) throws { let keyboard = KeyflowKeyboardView() let screen = UIScreen.main.bounds.size keyboard.updateViewport(screen, insets: .zero) keyboard.frame = CGRect(x: 0, y: 0, width: screen.width, height: keyboard.intrinsicContentSize.height) keyboard.layoutIfNeeded() - let source = try XCTUnwrap(keyboard.subviews.compactMap { $0 as? KeyflowKey }.first { $0.action == .text("e") }) + if value == "$" { + let numbers = try XCTUnwrap(keyboard.subviews.compactMap { $0 as? KeyflowKey }.first { $0.action == .numbers }) + XCTAssertTrue(numbers.accessibilityActivate()) + keyboard.layoutIfNeeded() + } + let source = try XCTUnwrap(keyboard.subviews.compactMap { $0 as? KeyflowKey }.first { $0.action == .text(value) }) let touch = AccentTouch() touch.point = CGPoint(x: source.frame.midX, y: source.frame.midY) keyboard.touchesBegan([touch], with: nil) diff --git a/scripts/verify-transparency.mjs b/scripts/verify-transparency.mjs deleted file mode 100644 index a369182..0000000 --- a/scripts/verify-transparency.mjs +++ /dev/null @@ -1,54 +0,0 @@ -/** Compare full-size screenshots from Transparency with both Background and Keys at 0%, - * with its keyboard shown/hidden and both backgrounds. This fails on an opaque - * OR blurred backdrop. The default 70% preset intentionally has visible fills. - * node scripts/verify-transparency.mjs ios|android - */ -import { readFileSync, writeFileSync } from 'node:fs'; -import { PNG } from 'pngjs'; -const platform = process.argv[2]; -if (!['ios', 'android'].includes(platform)) - throw new Error('Supply ios or android'); -const read = (name, index) => - PNG.sync.read( - readFileSync(`artifacts/${name}-${platform}-image${index}.png`), - ); -const transparent = [read('transparent', 0), read('transparent', 1)]; -const background = [read('background', 0), read('background', 1)]; -const reference = transparent[0]; -for (const frame of [...transparent, ...background]) - if (frame.width !== reference.width || frame.height !== reference.height) - throw new Error('Capture sizes must match'); -const logicalWidth = platform === 'ios' ? 402 : 411; -const scale = reference.width / logicalWidth; -const region = platform === 'ios' ? [8, 600, 394, 790] : [8, 657, 403, 818]; -let count = 0, - clear = [0, 0], - changed = 0; -const delta = (a, b, p) => - Math.max(...[0, 1, 2].map((c) => Math.abs(a.data[p + c] - b.data[p + c]))); -for (let y = Math.round(region[1] * scale); y < region[3] * scale; y++) - for (let x = Math.round(region[0] * scale); x < region[2] * scale; x++) { - const p = (y * reference.width + x) * 4; - count++; - for (let i = 0; i < 2; i++) - if (delta(transparent[i], background[i], p) <= 5) clear[i]++; - if (delta(transparent[0], transparent[1], p) > 15) changed++; - } -const sharpBackgroundFraction = clear.map((n) => n / count); -const imageSwapFraction = changed / count; -const pass = - sharpBackgroundFraction.every((n) => n > 0.85) && imageSwapFraction > 0.75; -const result = { - result: pass ? 'PASS' : 'FAIL', - platform, - region, - sharpBackgroundFraction, - imageSwapFraction, - note: 'Sharp-match threshold allows only key glyphs and controls to obscure the reference.', -}; -writeFileSync( - `artifacts/native-parity/${platform}/transparency.json`, - JSON.stringify(result, null, 2), -); -console.log(JSON.stringify(result, null, 2)); -if (!pass) process.exitCode = 1; diff --git a/scripts/visual/raised-surface.mjs b/scripts/visual/raised-surface.mjs deleted file mode 100644 index 336ddbd..0000000 --- a/scripts/visual/raised-surface.mjs +++ /dev/null @@ -1,103 +0,0 @@ -/** Open Customize fonts & test layouts in portrait, with QWERTY selected. - * Checks the shared raised preset's actual colors, including special keys. - * AGENT_DEVICE=/path/to/agent-device node scripts/visual/raised-surface.mjs ios|android session - */ -import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { PNG } from 'pngjs'; -const [platform, session] = process.argv.slice(2); -assert(['ios', 'android'].includes(platform) && session); -const agent = process.env.AGENT_DEVICE || 'agent-device'; -const dir = `artifacts/features/${platform}/raised-surface`; -mkdirSync(dir, { recursive: true }); -const run = (...args) => - JSON.parse( - execFileSync(agent, [...args, '--session', session, '--json'], { - encoding: 'utf8', - maxBuffer: 16000000, - }), - ).data; -const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); -const inspect = async () => { - run('press', 'id="customization-probe"', '--settle'); - const node = run('snapshot').nodes.find( - (n) => n.identifier === 'customization-probe', - ); - assert(node); - const result = JSON.parse(node.label.slice(node.label.indexOf('{'))); - assert.equal(result.result, 'PASS', result.error); - assert.equal(result.keyboardType, 'default'); - assert.equal(result.landscape, false); - return result; -}; -try { - run('press', 'label="raised"', '--settle'); - await pause(400); - const raised = await inspect(); - const path = `${dir}/raised.png`; - run('screenshot', path); - const png = PNG.sync.read(readFileSync(path)); - const scale = png.width / raised.width; - const samples = []; - for (const [name, labels, rgb] of [ - ['space', ['space', ''], [0, 60, 105]], - ['delete', ['Delete'], [248, 210, 64]], - ['return', ['return', 'Submit'], [255, 69, 34]], - ]) { - const key = raised.keyFrames.find((k) => labels.includes(k.label)); - assert(key, `Missing ${name}`); - // Blank strip near the top of each face, below its rounded corners. - // Android's old highlight gradient contaminated this strip on all keys. - let matching = 0, - total = 0; - for ( - let y = Math.ceil((key.y + 9) * scale); - y < (key.y + 11) * scale; - y++ - ) { - for ( - let x = Math.ceil((key.x + key.width * 0.3) * scale); - x < (key.x + key.width * 0.7) * scale; - x++ - ) { - const i = (y * png.width + x) * 4; - if (rgb.every((v, c) => Math.abs(png.data[i + c] - v) <= 2)) matching++; - total++; - } - } - assert(total > 0); - samples.push({ name, rgb, matching, total }); - assert( - matching / total > 0.98, - `${name}: raised material changed the configured face color (${matching}/${total} pixels)`, - ); - } - run('press', 'label="flat"', '--settle'); - const flat = await inspect(); - const geometry = (metrics) => - metrics.keyFrames.map(({ label, ...rect }) => rect); - assert.deepEqual( - geometry(raised), - geometry(flat), - 'Material changed key layout', - ); - run('press', 'label="raised"', '--settle'); - writeFileSync( - `${dir}/results.json`, - JSON.stringify( - { result: 'PASS', platform, samples, raised, flat }, - null, - 2, - ), - ); - console.log( - `${platform}: PASS solid raised colors for space/delete/return and unchanged layout`, - ); -} catch (error) { - writeFileSync( - `${dir}/results.json`, - JSON.stringify({ result: 'FAIL', platform, error: String(error) }, null, 2), - ); - throw error; -} diff --git a/src/__tests__/sections.test.ts b/src/__tests__/sections.test.ts index 08733a8..bd454f0 100644 --- a/src/__tests__/sections.test.ts +++ b/src/__tests__/sections.test.ts @@ -3,6 +3,7 @@ import { createKeyboardTheme, androidKeyboardTheme, androidDarkKeyboardTheme, + darkKeyboardTheme, } from '../theme'; test('Android previews use native bubble fills and accept independent customization', () => { @@ -92,3 +93,21 @@ test('focused long-press colors remain independent through serialization', () => expect(native.sections.preview.background).toBe('#173E42'); expect(native.sections.keys.background).toBe('#FFFFFF'); }); + +test('iOS pressed faces contrast with resting keys while previews retain their own fill', () => { + const light = createKeyboardTheme(); + expect(light.sections?.keys?.pressedBackground).toBe('#C1C3C6'); + expect(light.sections?.keys?.pressedBackground).not.toBe( + light.sections?.keys?.background, + ); + expect(light.sections?.preview?.background).toBe('#FFFFFF'); + expect( + createKeyboardTheme({}, darkKeyboardTheme).sections?.preview?.background, + ).toBe('#8E8E93'); + const custom = createKeyboardTheme({ + keys: { pressedBackground: '#123456' }, + preview: { background: '#654321' }, + }); + expect(custom.sections?.keys?.pressedBackground).toBe('#123456'); + expect(custom.sections?.preview?.background).toBe('#654321'); +}); diff --git a/src/theme.ts b/src/theme.ts index 688ceba..753daa7 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -15,7 +15,10 @@ export const lightKeyboardTheme: KeyboardTheme = Object.freeze({ background: '#E0E2E7', keyBackground: '#FFFFFF', keyForeground: '#000000', - pressedKeyBackground: '#FFFFFF', + pressedKeyBackground: '#C1C3C6', + sectionOverrides: Object.freeze({ + preview: Object.freeze({ background: '#FFFFFF' as const }), + }), selectedKeyBackground: '#008FFF', selectedKeyForeground: '#FFFFFF', specialKeyBackground: '#FFFFFF', @@ -40,6 +43,9 @@ export const darkKeyboardTheme: KeyboardTheme = Object.freeze({ keyBackground: '#5E5F61', keyForeground: '#FFFFFF', pressedKeyBackground: '#8E8E93', + sectionOverrides: Object.freeze({ + preview: Object.freeze({ background: '#8E8E93' as const }), + }), specialKeyBackground: '#5E5F61', actionKeyBackground: '#5E5F61', actionKeyForeground: '#FFFFFF',