diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac4f95cc..bf903bc6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -214,6 +214,7 @@ jobs: path: | android/app/build/reports/androidTests/connected/ android/app/build/outputs/androidTest-results/connected/ + android/app/build/outputs/connected_android_test_additional_output/ android/macrobenchmark/build/reports/androidTests/connected/ android/macrobenchmark/build/outputs/androidTest-results/connected/ android/macrobenchmark/build/outputs/connected_android_test_additional_output/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 19af7986..1f37b19f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -388,16 +388,14 @@ jobs: ref: main path: pam-cli persist-credentials: false - - uses: actions/checkout@v7 - with: - repository: push-in/pam-native-ui - ref: 20ae14301c019fa0ca43c6977f80e74a80eb1bf6 - path: pam-native-ui-candidate - persist-credentials: false - uses: actions/download-artifact@v8 with: name: pam-native-cli-linux-x86_64 path: candidate-cli + - uses: actions/download-artifact@v8 + with: + name: pam-native-android + path: candidate-renderer - uses: actions/setup-java@v5 with: distribution: temurin @@ -426,16 +424,25 @@ jobs: candidate=$(find candidate-cli -maxdepth 1 -type f -name 'pam-native-cli-*-linux-x86_64' -print -quit) test -n "${candidate}" chmod +x "${candidate}" + candidate_package=$(mktemp -d "${RUNNER_TEMP}/pam-native-package.XXXXXXXX") + git archive HEAD | tar -x -C "${candidate_package}" + version=${PAM_RELEASE_TAG#v} + renderer="pam-native-android-renderer-${version}.tar.gz" + (cd candidate-renderer && sha256sum --check "${renderer}.sha256") + tar -xzf "candidate-renderer/${renderer}" -C "${candidate_package}" \ + --strip-components=1 \ + "pam-native-android-renderer-${version}/target/aarch64-linux-android/release/libpam_native_engine.a" \ + "pam-native-android-renderer-${version}/target/x86_64-linux-android/release/libpam_native_engine.a" + printf '%s\n' "${version}" >"${candidate_package}/target/pam-native-engine-version" printf 'PAM_BIN=%s\n' "${GITHUB_WORKSPACE}/pam-cli/target/release/pam" >>"${GITHUB_ENV}" printf 'PAM_HOME=%s\n' "${pam_home}" >>"${GITHUB_ENV}" printf 'PAM_NATIVE_CLI=%s\n' "$(realpath "${candidate}")" >>"${GITHUB_ENV}" + printf 'PAM_NATIVE_PACKAGE_PATH=%s\n' "${candidate_package}" >>"${GITHUB_ENV}" - name: Prove every documented pam init and pam dev journey uses: reactivecircus/android-emulator-runner@v2 env: - PAM_NATIVE_PACKAGE_PATH: ${{ github.workspace }}/packages/native + PAM_NATIVE_PACKAGE_PATH: ${{ env.PAM_NATIVE_PACKAGE_PATH }} PAM_HOME: ${{ env.PAM_HOME }} - PAM_NATIVE_HOME: ${{ github.workspace }} - PAM_MOBILE_UI_PACKAGE_PATH: ${{ github.workspace }}/pam-native-ui-candidate PAM_NATIVE_CLI: ${{ env.PAM_NATIVE_CLI }} PAM_COMMUNITY_GATE_TIMEOUT_SECONDS: '1200' with: diff --git a/CHANGELOG.md b/CHANGELOG.md index c1e3f014..86eef938 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 1.0.30 - 2026-10-03 + +- Resolve the SDK from the installed Composer package and install the Android + renderer for the exact PAM Native version, preventing host and engine ABI + mismatches during clean first runs. +- Complete iOS input editing contracts for keyboard modes, secure and readonly + fields, length limits, selection, submit, blur and autofocus. Preserve native + child visibility and responsive layout behavior across Android and iOS. +- Reuse Gradle dependencies across projects while removing project build outputs + after each command. Certify both Core and official UI starters with rendered + screen evidence and the exact candidate Android renderer. + ## 1.0.29 - 2026-09-22 - Restore intrinsic row and text measurement: rows of flexible controls keep diff --git a/Cargo.lock b/Cargo.lock index 2c37914c..66e86945 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -76,7 +76,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "pam-native-cli" -version = "1.0.29" +version = "1.0.30" dependencies = [ "serde", "serde_json", @@ -85,7 +85,7 @@ dependencies = [ [[package]] name = "pam-native-engine" -version = "1.0.29" +version = "1.0.30" dependencies = [ "pam-native-protocol", "ttf-parser", @@ -93,7 +93,7 @@ dependencies = [ [[package]] name = "pam-native-protocol" -version = "1.0.29" +version = "1.0.30" [[package]] name = "proc-macro2" diff --git a/Cargo.toml b/Cargo.toml index 4473d7b2..81a59737 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ exclude = ["pam-cli"] [workspace.package] -version = "1.0.29" +version = "1.0.30" edition = "2024" rust-version = "1.88" license = "Apache-2.0" diff --git a/README.md b/README.md index d9825a89..7b5775a1 100644 --- a/README.md +++ b/README.md @@ -217,7 +217,7 @@ installation is intended for custom hosts and framework contributors; most applications should start with `pam init` or `pam add`: ```bash -pam composer require pushinbr/pam-native:^0.7 +pam composer require pushinbr/pam-native:^1.0 ``` ## Community diff --git a/android/app/src/androidTest/java/dev/pam/nativeapp/render/PamModalHostInstrumentedTest.kt b/android/app/src/androidTest/java/dev/pam/nativeapp/render/PamModalHostInstrumentedTest.kt index 15a16bb5..98013ab3 100644 --- a/android/app/src/androidTest/java/dev/pam/nativeapp/render/PamModalHostInstrumentedTest.kt +++ b/android/app/src/androidTest/java/dev/pam/nativeapp/render/PamModalHostInstrumentedTest.kt @@ -3,12 +3,31 @@ package dev.pam.nativeapp.render import android.view.Gravity import android.view.ViewGroup import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class PamModalHostInstrumentedTest { + @Test + fun windowViewportOwnershipTracksPresentationChanges() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.runOnMainSync { + val host = PamModalHost(instrumentation.targetContext) + host.setVisible(false) + for (presentation in listOf(1, 2, 3, 1)) { + host.setPresentation(presentation) + assertEquals( + "Window ownership for presentation $presentation", + presentation == 1, + host.usesWindowSizedContent(), + ) + } + host.close() + } + } + @Test fun dialogContentKeepsItsIntrinsicCardSizeAndIsCentered() { val params = modalChildLayoutParams(presentation = 2, sheetHeight = 640) diff --git a/android/app/src/androidTest/java/dev/pam/nativeapp/render/PamRendererInstrumentedTest.kt b/android/app/src/androidTest/java/dev/pam/nativeapp/render/PamRendererInstrumentedTest.kt index da9f0776..4e5aff9a 100644 --- a/android/app/src/androidTest/java/dev/pam/nativeapp/render/PamRendererInstrumentedTest.kt +++ b/android/app/src/androidTest/java/dev/pam/nativeapp/render/PamRendererInstrumentedTest.kt @@ -50,6 +50,232 @@ import java.util.concurrent.TimeUnit @RunWith(AndroidJUnit4::class) class PamRendererInstrumentedTest { + @Test + fun decimalKeyboardAcceptsSignedValuesButDigitKeyboardDoesNot() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val activity = launchActivity(instrumentation) + try { + onMain(instrumentation) { + val renderer = PamRenderer(activity, activity.host) { _, _, _ -> } + try { + renderer.commit(listOf(listOf( + Mutation.Create(node(1, 0, NodeKind.SCREEN)), + Mutation.Create(node(2, 1, NodeKind.INPUT, mapOf( + PropKey.TEST_ID to PropValue.Text("signed-decimal"), + PropKey.KEYBOARD_TYPE to PropValue.Integer(5), + ))), + Mutation.Layout(1, Frame(0f, 0f, 360f, 720f)), + Mutation.Layout(2, Frame(16f, 40f, 200f, 56f)), + Mutation.SetRoot(1), + ))) + val input = requireNotNull(activity.host.findByTransitionName("signed-decimal")) as EditText + input.text.replace(0, input.text.length, "-7.5") + assertEquals("-7.5", input.text.toString()) + renderer.commit(listOf(listOf(Mutation.Update(2, PropKey.KEYBOARD_TYPE, PropValue.Integer(3))))) + input.text.clear() + input.text.replace(0, 0, "-75") + assertEquals("75", input.text.toString()) + } finally { + renderer.close() + } + } + } finally { + onMain(instrumentation) { activity.finish() } + } + } + + @Test + fun deferredAuthoredValueAppliesOnBlurWithoutOverwritingNewerTyping() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val activity = launchActivity(instrumentation) + try { + onMain(instrumentation) { + val renderer = PamRenderer(activity, activity.host) { _, _, _ -> } + try { + renderer.commit(listOf(listOf( + Mutation.Create(node(1, 0, NodeKind.SCREEN)), + Mutation.Create(node(2, 1, NodeKind.INPUT, mapOf( + PropKey.TEST_ID to PropValue.Text("deferred-value"), + PropKey.VALUE to PropValue.Text("8"), + ))), + Mutation.Create(node(3, 1, NodeKind.INPUT, mapOf( + PropKey.TEST_ID to PropValue.Text("sibling-value"), + PropKey.VALUE to PropValue.Text("3"), + ))), + Mutation.Layout(1, Frame(0f, 0f, 360f, 720f)), + Mutation.Layout(2, Frame(16f, 40f, 200f, 56f)), + Mutation.Layout(3, Frame(16f, 112f, 200f, 56f)), + Mutation.SetRoot(1), + ))) + val input = requireNotNull(activity.host.findByTransitionName("deferred-value")) as EditText + val sibling = requireNotNull(activity.host.findByTransitionName("sibling-value")) as EditText + assertTrue(input.requestFocus()) + input.setText("731") + input.setSelection(1) + renderer.commit(listOf(listOf(Mutation.Update(2, PropKey.VALUE, PropValue.Text("20"))))) + assertEquals("731", input.text.toString()) + assertEquals(1, input.selectionStart) + assertTrue(sibling.requestFocus()) + assertEquals("20", input.text.toString()) + assertEquals("3", sibling.text.toString()) + + assertTrue(input.requestFocus()) + input.setText("732") + renderer.commit(listOf(listOf(Mutation.Update(2, PropKey.VALUE, PropValue.Text("21"))))) + input.setText("12") + assertTrue(sibling.requestFocus()) + assertEquals("12", input.text.toString()) + } finally { + renderer.close() + } + } + } finally { + onMain(instrumentation) { activity.finish() } + } + } + + @Test + fun virtualListRolesPreserveNativeAccessibilityScrollActions() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val activity = launchActivity(instrumentation) + lateinit var renderer: PamRenderer + lateinit var list: PamRecyclerList + try { + onMain(instrumentation) { + renderer = PamRenderer(activity, activity.host) { _, _, _ -> } + val mutations = mutableListOf( + Mutation.Create(node(1, 0, NodeKind.SCREEN)), + Mutation.Create(node(2, 1, NodeKind.VIRTUAL_LIST, mapOf( + PropKey.ACCESSIBILITY_ROLE to PropValue.Integer(31), + ))), + Mutation.Layout(1, Frame(0f, 0f, 300f, 400f)), + Mutation.Layout(2, Frame(0f, 0f, 300f, 144f)), + ) + repeat(20) { index -> + val id = index.toLong() + 3 + mutations += Mutation.Create(node(id, 2, NodeKind.TEXT, mapOf( + PropKey.TEXT to PropValue.Text("Record $index"), + ))) + mutations += Mutation.Layout(id, Frame(0f, index * 48f, 300f, 48f)) + } + mutations += Mutation.SetRoot(1) + renderer.commit(listOf(mutations)) + val field = PamRenderer::class.java.getDeclaredField("views").apply { isAccessible = true } + @Suppress("UNCHECKED_CAST") + val views = field.get(renderer) as android.util.LongSparseArray + list = views[2] as PamRecyclerList + list.measure( + View.MeasureSpec.makeMeasureSpec(dp(list, 300f), View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(dp(list, 144f), View.MeasureSpec.EXACTLY), + ) + list.layout(0, 0, dp(list, 300f), dp(list, 144f)) + val info = list.createAccessibilityNodeInfo() + assertTrue(info.isScrollable) + assertTrue(info.actionList.any { it.id == AccessibilityNodeInfo.ACTION_SCROLL_FORWARD }) + assertTrue(list.performAccessibilityAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD, null)) + } + instrumentation.waitForIdleSync() + SystemClock.sleep(600) + onMain(instrumentation) { + val manager = list.layoutManager as androidx.recyclerview.widget.LinearLayoutManager + assertTrue(manager.findFirstVisibleItemPosition() > 0) + } + } finally { + onMain(instrumentation) { renderer.close(); activity.finish() } + } + } + + @Test + fun partiallyVisibleVirtualRowsRemainAccessible() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val activity = launchActivity(instrumentation) + try { + onMain(instrumentation) { + val list = PamRecyclerList(activity) + activity.host.addView(list) + list.setRichItems(listOf(1L, 2L), mapOf(1L to 48f, 2L to 48f), + { id, holder -> holder.addView(TextView(activity).apply { text = "Row $id" }) }, + { _, holder -> holder.removeAllViews() }, + ) + list.measure( + View.MeasureSpec.makeMeasureSpec(dp(list, 300f), View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(dp(list, 72f), View.MeasureSpec.EXACTLY), + ) + list.layout(0, 0, dp(list, 300f), dp(list, 72f)) + val second = requireNotNull(list.findViewHolderForAdapterPosition(1)).itemView + assertTrue(second.bottom > list.height) + assertTrue(second.top < list.height) + assertFalse(second.importantForAccessibility == View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS) + // A row larger than the entire viewport must remain reachable too. + list.measure( + View.MeasureSpec.makeMeasureSpec(dp(list, 300f), View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(dp(list, 24f), View.MeasureSpec.EXACTLY), + ) + list.layout(0, 0, dp(list, 300f), dp(list, 24f)) + val first = requireNotNull(list.findViewHolderForAdapterPosition(0)).itemView + assertTrue(first.height > list.height) + assertFalse(first.importantForAccessibility == View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS) + } + } finally { + onMain(instrumentation) { activity.finish() } + } + } + + @Test + fun richVirtualCellMountsInsertedChildrenWithoutChangingRowExtent() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val activity = launchActivity(instrumentation) + try { + onMain(instrumentation) { + val renderer = PamRenderer(activity, activity.host) { _, _, _ -> } + try { + renderer.commit(listOf(listOf( + Mutation.Create(node(1, 0, NodeKind.SCREEN)), + Mutation.Create(node(2, 1, NodeKind.VIRTUAL_LIST)), + Mutation.Create(node(3, 2, NodeKind.PRESSABLE)), + Mutation.Layout(1, Frame(0f, 0f, 300f, 400f)), + Mutation.Layout(2, Frame(0f, 0f, 300f, 400f)), + Mutation.Layout(3, Frame(0f, 0f, 300f, 48f)), + Mutation.SetRoot(1), + ))) + val field = PamRenderer::class.java.getDeclaredField("views") + field.isAccessible = true + @Suppress("UNCHECKED_CAST") + val views = field.get(renderer) as android.util.LongSparseArray + val list = views[2] as PamRecyclerList + list.measure( + View.MeasureSpec.makeMeasureSpec(dp(list, 300f), View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(dp(list, 400f), View.MeasureSpec.EXACTLY), + ) + list.layout(0, 0, dp(list, 300f), dp(list, 400f)) + val row = requireNotNull(views[3]) as ViewGroup + val holder = requireNotNull(list.findViewHolderForAdapterPosition(0)).itemView + repeat(2) { + renderer.commit(listOf(listOf( + Mutation.Create(node(4, 3, NodeKind.TEXT, mapOf( + PropKey.TEXT to PropValue.Text("Selected"), + ))), + Mutation.Layout(4, Frame(8f, 8f, 80f, 24f)), + ))) + val inserted = requireNotNull(views[4]) as TextView + assertSame(row, inserted.parent) + assertEquals("Selected", inserted.text.toString()) + assertEquals(dp(list, 24f), inserted.layoutParams.height) + assertSame(holder, list.findViewHolderForAdapterPosition(0)?.itemView) + assertEquals(1, row.childCount) + renderer.commit(listOf(listOf(Mutation.Remove(4)))) + assertNull(views[4]) + assertEquals(0, row.childCount) + } + } finally { + renderer.close() + } + } + } finally { + onMain(instrumentation) { activity.finish() } + } + } + @Test fun flattenedRowButtonsKeepHeightWhenColumnViewportShrinks() { val instrumentation = InstrumentationRegistry.getInstrumentation() @@ -100,6 +326,59 @@ class PamRendererInstrumentedTest { } } + @Test + fun fullWindowModalChildIgnoresStaleActivityFrameAfterViewportResize() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val activity = launchActivity(instrumentation) + try { + onMain(instrumentation) { + val renderer = PamRenderer(activity, activity.host) { _, _, _ -> } + try { + renderer.commit(listOf(listOf( + Mutation.Create(node(1, 0, NodeKind.SCREEN)), + Mutation.Create(node(2, 1, NodeKind.MODAL, mapOf( + PropKey.VISIBLE to PropValue.Flag(false), + PropKey.MODAL_PRESENTATION to PropValue.Integer(1), + ))), + Mutation.Create(node(3, 2, NodeKind.VIEW, mapOf( + PropKey.BACKGROUND_COLOR to PropValue.Integer(0xFFFFFFFFL), + ))), + Mutation.Layout(1, Frame(0f, 0f, 360f, 760f)), + Mutation.Layout(2, Frame(0f, 0f, 360f, 760f)), + Mutation.Layout(3, Frame(0f, 0f, 360f, 760f)), + Mutation.SetRoot(1), + ))) + // The closed modal is not in the activity view tree. Inspect + // its actual renderer-owned child without exposing a public API. + val field = PamRenderer::class.java.getDeclaredField("views") + field.isAccessible = true + @Suppress("UNCHECKED_CAST") + val views = field.get(renderer) as android.util.LongSparseArray + val child = requireNotNull(views[3]) + val content = child.parent as ViewGroup + for (viewportHeight in listOf(1930, 1177, 1930)) { + renderer.commit(listOf(listOf( + Mutation.Layout(3, Frame(0f, 0f, 360f, 760f)), + ))) + assertEquals(ViewGroup.LayoutParams.MATCH_PARENT, child.layoutParams.height) + content.measure( + View.MeasureSpec.makeMeasureSpec(1080, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(viewportHeight, View.MeasureSpec.EXACTLY), + ) + content.layout(0, 0, 1080, viewportHeight) + assertEquals(viewportHeight, child.height) + assertEquals(0, child.top) + assertEquals(1080, child.width) + } + } finally { + renderer.close() + } + } + } finally { + onMain(instrumentation) { activity.finish() } + } + } + @Test fun physicalCellFrameIsNotMirroredAgainByAnRtlHolder() { val instrumentation = InstrumentationRegistry.getInstrumentation() @@ -152,6 +431,89 @@ class PamRendererInstrumentedTest { } } + @Test + fun passwordVisibilityPreservesCursorAndSelectionUnlessExplicitlyControlled() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val activity = launchActivity(instrumentation) + try { + onMain(instrumentation) { + val renderer = PamRenderer(activity, activity.host) { _, _, _ -> } + renderer.commit(listOf(listOf( + Mutation.Create(node(1, 0, NodeKind.SCREEN)), + Mutation.Create(node(2, 1, NodeKind.INPUT, mapOf( + PropKey.SECURE to PropValue.Flag(true), + PropKey.TEST_ID to PropValue.Text("password-selection"), + ))), + Mutation.Layout(1, Frame(0f, 0f, 360f, 720f)), + Mutation.Layout(2, Frame(16f, 40f, 328f, 56f)), + Mutation.SetRoot(1), + ))) + val input = requireNotNull(activity.host.findByTransitionName("password-selection")) as EditText + input.requestFocus() + input.setText("PAM_AUDIT_2026") + for ((start, end) in listOf(13 to 13, 4 to 9)) { + input.setSelection(start, end) + for (secure in listOf(false, true)) { + renderer.commit(listOf(listOf(Mutation.Update(2, PropKey.SECURE, PropValue.Flag(secure))))) + assertEquals("PAM_AUDIT_2026", input.text.toString()) + assertEquals(start, input.selectionStart) + assertEquals(end, input.selectionEnd) + } + } + renderer.commit(listOf(listOf( + Mutation.Update(2, PropKey.INPUT_SELECTION_START, PropValue.Integer(2)), + Mutation.Update(2, PropKey.INPUT_SELECTION_END, PropValue.Integer(5)), + Mutation.Update(2, PropKey.SECURE, PropValue.Flag(false)), + ))) + assertEquals(2, input.selectionStart) + assertEquals(5, input.selectionEnd) + renderer.close() + } + } finally { + onMain(instrumentation) { activity.finish() } + } + } + + @Test + fun inactiveInputStartsAtLeadingTextAndHonorsControlledSelection() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val activity = launchActivity(instrumentation) + try { + onMain(instrumentation) { + val renderer = PamRenderer(activity, activity.host) { _, _, _ -> } + renderer.commit(listOf(listOf( + Mutation.Create(node(1, 0, NodeKind.SCREEN)), + Mutation.Create(node(2, 1, NodeKind.INPUT, mapOf( + PropKey.TEST_ID to PropValue.Text("inactive-value"), + ))), + Mutation.Layout(1, Frame(0f, 0f, 360f, 720f)), + Mutation.Layout(2, Frame(16f, 40f, 200f, 56f)), + Mutation.SetRoot(1), + ))) + val input = requireNotNull(activity.host.findByTransitionName("inactive-value")) as EditText + input.isFocusable = false + renderer.commit(listOf(listOf(Mutation.Update(2, PropKey.VALUE, + PropValue.Text("Search invoices, customers and documents across all workspaces"))))) + assertEquals(0, input.selectionStart) + assertEquals(0, input.selectionEnd) + input.isFocusableInTouchMode = true + assertTrue(input.requestFocus()) + renderer.commit(listOf(listOf(Mutation.Update(2, PropKey.VALUE, PropValue.Text("Edited query"))))) + assertEquals(input.text.length, input.selectionStart) + renderer.commit(listOf(listOf( + Mutation.Update(2, PropKey.INPUT_SELECTION_START, PropValue.Integer(2)), + Mutation.Update(2, PropKey.INPUT_SELECTION_END, PropValue.Integer(5)), + Mutation.Update(2, PropKey.VALUE, PropValue.Text("Controlled query")), + ))) + assertEquals(2, input.selectionStart) + assertEquals(5, input.selectionEnd) + renderer.close() + } + } finally { + onMain(instrumentation) { activity.finish() } + } + } + @Test fun currencyInputKeepsNumericTypingAtTheTrailingMinorUnit() { val instrumentation = InstrumentationRegistry.getInstrumentation() @@ -1218,6 +1580,188 @@ class PamRendererInstrumentedTest { } } + @Test + fun rendererKeepsPersistentHorizontalIndicatorVisibleBelowContent() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val activity = launchActivity(instrumentation) + lateinit var renderer: PamRenderer + lateinit var scroll: PamScrollContainer + var initialWindow: Bitmap? = null + try { + onMain(instrumentation) { + renderer = PamRenderer(activity, activity.host) { _, _, _ -> } + renderer.commit(listOf(listOf( + Mutation.Create(node(1, 0, NodeKind.SCREEN, mapOf( + // Keep the canvas distinct from the gray child. The + // renderer otherwise inherits its first descendant's + // background, making the child-pixel check vacuous. + PropKey.BACKGROUND_COLOR to PropValue.Integer(Color.WHITE.toLong()), + ))), + Mutation.Create(node(5, 1, NodeKind.SCROLL, mapOf( + PropKey.TEST_ID to PropValue.Text("indicator-parent-scroll"), + ))), + Mutation.Create(node(6, 5, NodeKind.COLUMN)), + Mutation.Create(node(2, 6, NodeKind.SCROLL, mapOf( + PropKey.TEST_ID to PropValue.Text("indicator-scroll"), + PropKey.SHOWS_SCROLL_INDICATOR to PropValue.Flag(true), + PropKey.SCROLL_HORIZONTAL to PropValue.Flag(true), + PropKey.SCROLL_FILL_VIEWPORT to PropValue.Flag(true), + PropKey.SCROLL_NESTED_ENABLED to PropValue.Flag(true), + PropKey.SCROLL_FADING_EDGE_LENGTH to PropValue.Decimal(12.0), + PropKey.SCROLL_PERSISTENT_SCROLLBAR to PropValue.Flag(true), + PropKey.SCROLL_INDICATOR_STYLE to PropValue.Integer(ScrollIndicatorStyle.DARK.wireValue.toLong()), + ))), + Mutation.Create(node(3, 2, NodeKind.ROW)), + Mutation.Create(node(4, 3, NodeKind.VIEW, mapOf( + PropKey.BACKGROUND_COLOR to PropValue.Integer(Color.LTGRAY.toLong()), + ))), + Mutation.Layout(1, Frame(0f, 0f, 360f, 720f)), + Mutation.Layout(5, Frame(0f, 0f, 360f, 720f)), + Mutation.Layout(6, Frame(0f, 0f, 360f, 1400f)), + Mutation.Layout(2, Frame(16f, 980f, 300f, 52f)), + Mutation.Layout(3, Frame(16f, 980f, 900f, 52f)), + Mutation.Layout(4, Frame(16f, 980f, 900f, 48f)), + Mutation.SetRoot(1), + ))) + scroll = activity.host.findByTransitionName("indicator-scroll") as PamScrollContainer + } + instrumentation.waitForIdleSync() + // Showcase samples start outside the vertical viewport. Reveal the + // row without ever touching its horizontal scroll or drawing it in + // software first. + // Outlive the platform's initial fade delay: a persistent indicator + // must remain available when users reach a later showcase sample. + SystemClock.sleep(1800) + requireNotNull(instrumentation.uiAutomation.takeScreenshot()).recycle() + onMain(instrumentation) { + val parent = activity.host.findByTransitionName("indicator-parent-scroll") as PamScrollContainer + parent.setContentOffsetY(500f) + } + instrumentation.waitForIdleSync() + // Idle on the UI thread does not guarantee that the compositor has + // presented the newly revealed content. Wait for the gray child, + // never for the indicator itself, without drawing/toggling the view. + // Keep the final frame on timeout so the existing content assertion + // still fails with captured evidence if rendering never happens. + val initialFrameDeadline = SystemClock.uptimeMillis() + 2000L + val initialLocation = IntArray(2) + do { + onMain(instrumentation) { scroll.getLocationOnScreen(initialLocation) } + initialWindow?.recycle() + val frame = requireNotNull(instrumentation.uiAutomation.takeScreenshot()) + initialWindow = frame + val x = initialLocation[0] + 10 + val y = initialLocation[1] + 10 + if (x in 0 until frame.width && y in 0 until frame.height + && frame.getPixel(x, y) == Color.LTGRAY) break + SystemClock.sleep(50) + } while (SystemClock.uptimeMillis() < initialFrameDeadline) + onMain(instrumentation) { + assertTrue("Renderer fixture must overflow", scroll.getChildAt(0).canScrollHorizontally(1)) + val shown = Bitmap.createBitmap(scroll.width, scroll.height, Bitmap.Config.ARGB_8888) + val hidden = Bitmap.createBitmap(scroll.width, scroll.height, Bitmap.Config.ARGB_8888) + try { + shown.eraseColor(Color.WHITE) + hidden.eraseColor(Color.WHITE) + scroll.draw(Canvas(shown)) + scroll.setShowsScrollIndicator(false) + scroll.draw(Canvas(hidden)) + var difference = 0 + for (y in dp(scroll, 48f) until scroll.height) { + for (x in 0 until scroll.width) { + if (shown.getPixel(x, y) != hidden.getPixel(x, y)) difference++ + } + } + assertTrue("Persistent indicator must occupy the reserved strip", difference > 0) + } finally { + shown.recycle() + hidden.recycle() + } + } + val location = IntArray(2) + var width = 0 + var height = 0 + var trackTop = 0 + onMain(instrumentation) { + scroll.setShowsScrollIndicator(true) + scroll.getLocationOnScreen(location) + width = scroll.width + height = scroll.height + trackTop = dp(scroll, 48f) + } + instrumentation.waitForIdleSync() + SystemClock.sleep(100) + val screenShown = requireNotNull(instrumentation.uiAutomation.takeScreenshot()) + onMain(instrumentation) { scroll.setShowsScrollIndicator(false) } + instrumentation.waitForIdleSync() + SystemClock.sleep(100) + val screenHidden = requireNotNull(instrumentation.uiAutomation.takeScreenshot()) + try { + // Gradle collects this directory before uninstalling test APKs. + // Keep all three frames even on success so collection itself + // can be verified without intentionally breaking the renderer. + val directory = InstrumentationRegistry.getArguments() + .getString("additionalTestOutputDir") + ?.takeIf { it.isNotBlank() } + ?.let { java.io.File(it) } + ?: requireNotNull(instrumentation.context.getExternalFilesDir("renderer-evidence")) + check(directory.isDirectory || directory.mkdirs()) + mapOf( + "indicator-initial.png" to requireNotNull(initialWindow), + "indicator-shown.png" to screenShown, + "indicator-hidden.png" to screenHidden, + ).forEach { (name, bitmap) -> + java.io.File(directory, name).outputStream().use { output -> + check(bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)) + } + } + assertEquals( + "Window capture must include the rendered fixture", + Color.LTGRAY, + screenShown.getPixel(location[0] + 10, location[1] + 10), + ) + var difference = 0 + var initialDifference = 0 + val initial = requireNotNull(initialWindow) + assertEquals("Fixture canvas must be distinct from the child", Color.WHITE, + initial.getPixel(location[0] - 1, location[1] + 10)) + assertEquals(Color.LTGRAY, initial.getPixel(location[0] + 10, location[1] + 10)) + for (y in location[1] + trackTop until location[1] + height) { + for (x in location[0] until location[0] + width) { + if (screenShown.getPixel(x, y) != screenHidden.getPixel(x, y)) difference++ + if (initial.getPixel(x, y) != screenHidden.getPixel(x, y)) initialDifference++ + } + } + assertTrue("Indicator must also appear in the actual window capture", difference > 0) + assertTrue("Indicator must appear before any software draw or toggle", initialDifference > 0) + var contrastingPixels = 0 + for (y in location[1] + trackTop until location[1] + height) { + for (x in location[0] until location[0] + width) { + val pixel = initial.getPixel(x, y) + if (Color.red(pixel) < 128 && Color.green(pixel) < 128 && Color.blue(pixel) < 128) { + contrastingPixels++ + } + } + } + assertTrue("Dark indicator must have visible contrast, not just a pixel difference", contrastingPixels > width) + } catch (failure: AssertionError) { + throw AssertionError( + "${failure.message}; fixture=${location.contentToString()} " + + "size=${width}x$height trackTop=$trackTop " + + "capture=${screenShown.width}x${screenShown.height}", + failure, + ) + } finally { + screenShown.recycle() + screenHidden.recycle() + onMain(instrumentation) { renderer.close() } + } + } finally { + initialWindow?.recycle() + activity.finish() + } + } + @Suppress("DEPRECATION") @Test fun statusBarConfigurationFollowsTheActiveRetainedRoute() { diff --git a/android/app/src/androidTest/java/dev/pam/nativeapp/render/PamScrollContainerInstrumentedTest.kt b/android/app/src/androidTest/java/dev/pam/nativeapp/render/PamScrollContainerInstrumentedTest.kt index d6b2ee7a..65cdb944 100644 --- a/android/app/src/androidTest/java/dev/pam/nativeapp/render/PamScrollContainerInstrumentedTest.kt +++ b/android/app/src/androidTest/java/dev/pam/nativeapp/render/PamScrollContainerInstrumentedTest.kt @@ -2,6 +2,9 @@ package dev.pam.nativeapp.render import android.app.Instrumentation import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color import android.view.View import android.view.ViewGroup import android.widget.FrameLayout @@ -15,6 +18,92 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class PamScrollContainerInstrumentedTest { + @Test + fun indicatorAppearanceChangePreservesOffsetAndDrawsOnDarkSurface() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val activity = launchActivity(instrumentation) + lateinit var scroll: PamScrollContainer + try { + onMain(instrumentation) { + scroll = PamScrollContainer(activity).apply { + setHorizontal(true) + setIndicatorStyle(ScrollIndicatorStyle.DARK.wireValue) + setShowsScrollIndicator(true) + setPersistentScrollbar(true) + insert(View(activity).apply { + layoutParams = FrameLayout.LayoutParams(900, 80) + }) + } + activity.host.addView(scroll, FrameLayout.LayoutParams(300, 80)) + activity.host.measure(exactly(300), exactly(80)) + activity.host.layout(0, 0, 300, 80) + scroll.getChildAt(0).scrollTo(120, 0) + scroll.setIndicatorStyle(ScrollIndicatorStyle.LIGHT.wireValue) + activity.host.measure(exactly(300), exactly(80)) + activity.host.layout(0, 0, 300, 80) + } + instrumentation.waitForIdleSync() + onMain(instrumentation) { + assertEquals(120, scroll.snapshotOffsetPixels().first) + val bitmap = Bitmap.createBitmap(scroll.width, scroll.height, Bitmap.Config.ARGB_8888) + try { + bitmap.eraseColor(Color.BLACK) + scroll.draw(Canvas(bitmap)) + var contrastingPixels = 0 + for (y in scroll.height - 12 until scroll.height) { + for (x in 0 until scroll.width) { + if (Color.red(bitmap.getPixel(x, y)) > 128) contrastingPixels++ + } + } + assertTrue("Light indicator must contrast against a dark surface", contrastingPixels > 100) + } finally { + bitmap.recycle() + } + } + } finally { + activity.finish() + } + } + + @Test + fun persistentHorizontalIndicatorDrawsBeforeFirstGesture() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val activity = launchActivity(instrumentation) + try { + onMain(instrumentation) { + val scroll = PamScrollContainer(activity).apply { + setHorizontal(true) + setShowsScrollIndicator(true) + setPersistentScrollbar(true) + insert(View(activity).apply { + layoutParams = FrameLayout.LayoutParams(900, 80) + }) + } + activity.host.addView(scroll, FrameLayout.LayoutParams(300, 80)) + activity.host.measure(exactly(300), exactly(80)) + activity.host.layout(0, 0, 300, 80) + val nativeScroll = scroll.getChildAt(0) + assertTrue("Fixture must overflow horizontally", nativeScroll.canScrollHorizontally(1)) + val bitmap = Bitmap.createBitmap(300, 80, Bitmap.Config.ARGB_8888) + try { + bitmap.eraseColor(Color.WHITE) + scroll.draw(Canvas(bitmap)) + var indicatorPixels = 0 + for (y in 60 until 80) { + for (x in 0 until 300) { + if (bitmap.getPixel(x, y) != Color.WHITE) indicatorPixels++ + } + } + assertTrue("A persistent indicator must draw without requiring a gesture", indicatorPixels > 0) + } finally { + bitmap.recycle() + } + } + } finally { + activity.finish() + } + } + @Test fun requestedOffsetIsReappliedAfterContentGrows() { val instrumentation = InstrumentationRegistry.getInstrumentation() diff --git a/android/app/src/main/cpp/pam_android_bridge.cpp b/android/app/src/main/cpp/pam_android_bridge.cpp index 5d9bd31f..f2b9e097 100644 --- a/android/app/src/main/cpp/pam_android_bridge.cpp +++ b/android/app/src/main/cpp/pam_android_bridge.cpp @@ -789,6 +789,28 @@ Java_dev_pam_nativeapp_PamRuntime_nativeRelayout( publish_batch(state, batch); } +extern "C" JNIEXPORT void JNICALL +Java_dev_pam_nativeapp_PamRuntime_nativeSetChildVisibility( + JNIEnv*, jobject, jlong handle, jlong owner, jlong child, jboolean visible +) { + RuntimeState* state = from_handle(handle); + if (state == nullptr || owner <= 0 || child <= 0) return; + PamNativeBuffer batch{nullptr, 0, 0}; + PamStatus status; + { + std::lock_guard lock(state->engine_mutex); + status = pam_native_engine_set_native_child_visibility( + state->engine, static_cast(owner), static_cast(child), + visible == JNI_TRUE ? 1 : 0, &batch + ); + } + if (status == PAM_STATUS_SUCCESS) { + publish_batch(state, batch); + } else { + pam_native_buffer_free(batch); + } +} + extern "C" JNIEXPORT void JNICALL Java_dev_pam_nativeapp_PamRuntime_nativeSetRefreshRate( JNIEnv*, diff --git a/android/app/src/main/java/dev/pam/nativeapp/PamRuntime.kt b/android/app/src/main/java/dev/pam/nativeapp/PamRuntime.kt index c074fd21..d5a978bd 100644 --- a/android/app/src/main/java/dev/pam/nativeapp/PamRuntime.kt +++ b/android/app/src/main/java/dev/pam/nativeapp/PamRuntime.kt @@ -48,6 +48,16 @@ class PamRuntime( @Volatile private var handle = 0L + init { + renderer.onNativeChildVisibility = { owner, child, visible -> + synchronized(handleLock) { + if (!closed.get() && handle != 0L) { + nativeSetChildVisibility(handle, owner, child, visible) + } + } + } + } + fun start( entry: File, widthDp: Float, @@ -195,6 +205,7 @@ class PamRuntime( override fun close() { if (!closed.compareAndSet(false, true)) return + renderer.onNativeChildVisibility = null synchronized(handleLock) { val active = handle handle = 0L @@ -383,6 +394,8 @@ class PamRuntime( textScale: Float, darkAppearance: Boolean, ) + private external fun nativeSetChildVisibility(handle: Long, owner: Long, child: Long, visible: Boolean) + private external fun nativeSetRefreshRate(handle: Long, refreshRateHz: Double) private external fun nativeDispatchEvent( diff --git a/android/app/src/main/java/dev/pam/nativeapp/protocol/PamProtocol.kt b/android/app/src/main/java/dev/pam/nativeapp/protocol/PamProtocol.kt index a11a81b2..28e8e265 100644 --- a/android/app/src/main/java/dev/pam/nativeapp/protocol/PamProtocol.kt +++ b/android/app/src/main/java/dev/pam/nativeapp/protocol/PamProtocol.kt @@ -600,7 +600,13 @@ enum class PropKey(val value: Int) { INPUT_FORMAT_DECIMAL_DIGITS(461), GESTURE_NATIVE_TRANSLATION_LIMIT_X(462), GESTURE_NATIVE_RESET_ON_END(463), - INPUT_FORMAT_LOCALE(464); + INPUT_FORMAT_LOCALE(464), + SCROLL_INDICATOR_STYLE(465), + GRID_MIN_COLUMN_WIDTH(466), + GRID_TEMPLATE(467), + GRID_SPAN2XL(468), + GRID_OFFSET2XL(469), + GRID_ORDER2XL(470); companion object { fun from(value: Int): PropKey = diff --git a/android/app/src/main/java/dev/pam/nativeapp/render/PamModalHost.kt b/android/app/src/main/java/dev/pam/nativeapp/render/PamModalHost.kt index 7b9a5766..d5e783bd 100644 --- a/android/app/src/main/java/dev/pam/nativeapp/render/PamModalHost.kt +++ b/android/app/src/main/java/dev/pam/nativeapp/render/PamModalHost.kt @@ -163,6 +163,8 @@ internal class PamModalHost @JvmOverloads constructor( updateBottomSheetChrome() } + internal fun usesWindowSizedContent(): Boolean = presentation != 2 && presentation != 3 + fun setPresentation(value: Int) { if (presentation == value) return presentation = value diff --git a/android/app/src/main/java/dev/pam/nativeapp/render/PamRecyclerList.kt b/android/app/src/main/java/dev/pam/nativeapp/render/PamRecyclerList.kt index 59c3e2a2..4b77b314 100644 --- a/android/app/src/main/java/dev/pam/nativeapp/render/PamRecyclerList.kt +++ b/android/app/src/main/java/dev/pam/nativeapp/render/PamRecyclerList.kt @@ -75,7 +75,7 @@ internal class PamRecyclerList(context: Context) : RecyclerView(context) { } override fun onChildDetachedFromWindow(child: View) { - accessibilityModes.remove(child) + accessibilityModes.remove(child)?.let { child.importantForAccessibility = it } super.onChildDetachedFromWindow(child) } @@ -88,8 +88,9 @@ internal class PamRecyclerList(context: Context) : RecyclerView(context) { * RecyclerView deliberately lays out prefetched rows beyond its clipped * viewport. Android's accessibility snapshot can otherwise intersect a * descendant with the clip and publish an inverted rectangle. Keep those - * rows mounted for performance, but expose semantics only after the whole - * row is visible; scrolling restores the holder's original mode. + * rows mounted for performance, but hide only fully offscreen rows. + * Partially visible rows must remain reachable, including rows taller than + * the viewport. Restore the original mode before a holder is recycled. */ private fun updateAccessibilityVisibility() { val viewportLeft = paddingLeft @@ -101,11 +102,11 @@ internal class PamRecyclerList(context: Context) : RecyclerView(context) { val original = accessibilityModes[child] ?: child.importantForAccessibility.also { accessibilityModes[child] = it } - val fullyVisible = child.left >= viewportLeft && - child.top >= viewportTop && - child.right <= viewportRight && - child.bottom <= viewportBottom - val desired = if (fullyVisible) { + val intersectsViewport = child.right > viewportLeft && + child.bottom > viewportTop && + child.left < viewportRight && + child.top < viewportBottom + val desired = if (intersectsViewport) { original } else { View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS diff --git a/android/app/src/main/java/dev/pam/nativeapp/render/PamRenderer.kt b/android/app/src/main/java/dev/pam/nativeapp/render/PamRenderer.kt index daa8e628..ed85a319 100644 --- a/android/app/src/main/java/dev/pam/nativeapp/render/PamRenderer.kt +++ b/android/app/src/main/java/dev/pam/nativeapp/render/PamRenderer.kt @@ -81,6 +81,7 @@ import dev.pam.nativeapp.protocol.WireMap import dev.pam.nativeapp.protocol.WireValue import dev.pam.nativeapp.R import dev.pam.nativeapp.views.NativeViewRegistry +import dev.pam.nativeapp.views.NativeChildVisibilityHost import org.json.JSONArray import java.nio.ByteOrder import java.math.BigDecimal @@ -385,6 +386,7 @@ class PamRenderer( private val host: FrameLayout, private val dispatchEvent: (Long, Int, ByteArray) -> Unit, ) : AutoCloseable { + var onNativeChildVisibility: ((Long, Long, Boolean) -> Unit)? = null private val main = Handler(Looper.getMainLooper()) private val views = LongSparseArray() private val nodes = LongSparseArray() @@ -456,10 +458,14 @@ class PamRenderer( } } val dirtyLayouts = LinkedHashSet() + val createdNodes = LinkedHashSet() batches.forEach { batch -> batch.forEach { mutation -> when (mutation) { - is Mutation.Create -> create(mutation.node) + is Mutation.Create -> { + create(mutation.node) + createdNodes += mutation.node.id + } is Mutation.Remove -> remove(mutation.id) is Mutation.Update -> update(mutation.id, mutation.key, mutation.value) is Mutation.Move -> move(mutation.id, mutation.parent, mutation.index) @@ -478,6 +484,13 @@ class PamRenderer( // authored StatusBar color must win at the end of every commit. applyMergedStatusBar() syncVirtualLists() + // A stable row ID/extent does not trigger a RecyclerView rebind when + // conditional descendants are inserted. Materialize only affected, + // already-mounted cells after all nodes and frames have arrived. + createdNodes.mapNotNull(::virtualCellRoot).toSet().forEach { cellRoot -> + val holder = virtualCellHolder(cellRoot) + if (holder != null) materializeCell(cellRoot, holder) + } dirtyLayouts.forEach(::applyLayout) retainedScrollOffsets.forEach { (id, offset) -> if (id !in explicitlyUpdatedScrollOffsets) { @@ -716,7 +729,9 @@ class PamRenderer( override fun close() { check(Looper.myLooper() == Looper.getMainLooper()) + onNativeChildVisibility = null for (position in 0 until views.size()) { + (views.valueAt(position) as? NativeChildVisibilityHost)?.onChildVisibilityChanged = null (views.valueAt(position) as? PamModalHost)?.close() } for (position in 0 until nodes.size()) { @@ -801,7 +816,15 @@ class PamRenderer( } NodeKind.IMAGE -> PamImageView(context) NodeKind.IMAGE_BACKGROUND -> PamImageBackground(context) - NodeKind.SCROLL -> PamScrollContainer(context) + NodeKind.SCROLL -> PamScrollContainer( + context, + initialHorizontal = state?.flag(PropKey.SCROLL_HORIZONTAL, false) ?: false, + initialPersistentScrollbar = state?.flag(PropKey.SCROLL_PERSISTENT_SCROLLBAR, false) ?: false, + initialIndicatorStyle = ScrollIndicatorStyle.fromWire( + state?.integer(PropKey.SCROLL_INDICATOR_STYLE, ScrollIndicatorStyle.AUTO.wireValue.toLong()) + ?.toInt() ?: ScrollIndicatorStyle.AUTO.wireValue, + ), + ) NodeKind.LIST, NodeKind.SECTION_LIST, NodeKind.VIRTUAL_LIST, @@ -848,6 +871,19 @@ class PamRenderer( if (eventProperty != null && custom.properties[eventProperty] != null) { dispatchBytes(custom.id, kind, payload) } + }.also { nativeView -> + if (nativeView is NativeChildVisibilityHost) { + nativeView.onChildVisibilityChanged = { child, visible -> + main.post { + if (views[custom.id] === nativeView) { + val childId = children[custom.id]?.firstOrNull { views[it] === child } + if (childId != null && nodes[childId]?.parent == custom.id) { + onNativeChildVisibility?.invoke(custom.id, childId, visible) + } + } + } + } + } } } } @@ -856,6 +892,7 @@ class PamRenderer( val state = nodes[id] ?: return val removedStatusBar = state.kind == NodeKind.STATUS_BAR val view = views[id] + (view as? NativeChildVisibilityHost)?.onChildVisibilityChanged = null deferredViewportLayouts.remove(id)?.let { (parent, listener) -> parent.removeOnLayoutChangeListener(listener) } @@ -1505,6 +1542,14 @@ class PamRenderer( height = (height - reduction).coerceAtLeast(0) }, ) + // Full-window modal content belongs to the Dialog viewport, not the + // activity's engine frame. IME resize can make those heights differ. + if (parentView is PamModalHost && parentView.usesWindowSizedContent()) { + width = ViewGroup.LayoutParams.MATCH_PARENT + height = ViewGroup.LayoutParams.MATCH_PARENT + leftPx = 0 + topPx = 0 + } val current = view.layoutParams as? ViewGroup.MarginLayoutParams val layoutChanged = @@ -2206,6 +2251,8 @@ class PamRenderer( ) PropKey.SCROLL_PERSISTENT_SCROLLBAR -> (view as? PamScrollContainer)?.setPersistentScrollbar(value.flag()) + PropKey.SCROLL_INDICATOR_STYLE -> + (view as? PamScrollContainer)?.setIndicatorStyle(value.integer().toInt()) PropKey.SCROLL_PAGING_ENABLED -> (view as? PamScrollContainer)?.setPagingEnabled(value.flag()) PropKey.SCROLL_SNAP_INTERVAL -> @@ -2511,6 +2558,11 @@ class PamRenderer( PropKey.MAX_HEIGHT_PERCENT, PropKey.MARGIN_LEFT_AUTO, PropKey.GRID_COLUMNS, + PropKey.GRID_MIN_COLUMN_WIDTH, + PropKey.GRID_TEMPLATE, + PropKey.GRID_SPAN2XL, + PropKey.GRID_OFFSET2XL, + PropKey.GRID_ORDER2XL, PropKey.GRID_SPAN, PropKey.GRID_SPAN_SM, PropKey.GRID_SPAN_MD, @@ -2786,6 +2838,8 @@ class PamRenderer( (view as? PamScrollContainer)?.setFadingEdgeLength(0f) PropKey.SCROLL_PERSISTENT_SCROLLBAR -> (view as? PamScrollContainer)?.setPersistentScrollbar(false) + PropKey.SCROLL_INDICATOR_STYLE -> + (view as? PamScrollContainer)?.setIndicatorStyle(ScrollIndicatorStyle.AUTO.wireValue) PropKey.SCROLL_PAGING_ENABLED -> (view as? PamScrollContainer)?.setPagingEnabled(false) PropKey.SCROLL_SNAP_INTERVAL -> @@ -4264,6 +4318,7 @@ class PamRenderer( state.updating = false } state.nativeValue = formatted + state.deferredInputValue = null state.nativeValueAcknowledged = false if (state.properties[PropKey.ON_CHANGE] == null) return when (state.inputSyncMode()) { @@ -4284,6 +4339,11 @@ class PamRenderer( lastFocusedInput = input if (state.properties[PropKey.ON_FOCUS] != null) dispatch(state.id, EVENT_FOCUS) } else { + // A normalized authored value may have arrived while editing. + // Apply it only if no newer keystroke invalidated that value. + state.deferredInputValue?.let { next -> + applyInputValue(input, state, next) + } if (state.inputSyncMode() == INPUT_SYNC_NATIVE || state.inputSyncMode() == INPUT_SYNC_BLUR) { dispatchInput(state) } @@ -4567,15 +4627,26 @@ class PamRenderer( formattedNext != state.nativeValue && !state.nativeValueAcknowledged ) { + state.deferredInputValue = next return } + state.deferredInputValue = null if (input.text.toString() == formattedNext) { state.nativeValueAcknowledged = true return } state.updating = true input.setText(formattedNext) - input.setSelection(input.text.length) + // An inactive field should reveal the beginning of its value, not scroll + // to a trailing cursor before the user has interacted with it. + val requestedStart = state.integerOrNull(PropKey.INPUT_SELECTION_START)?.toInt() + val start = (requestedStart ?: if (input.hasFocus()) input.text.length else 0) + .coerceIn(0, input.text.length) + val end = if (requestedStart != null) { + (state.integerOrNull(PropKey.INPUT_SELECTION_END)?.toInt() ?: start) + .coerceIn(start, input.text.length) + } else start + input.setSelection(start, end) state.nativeValue = formattedNext state.nativeValueAcknowledged = true state.updating = false @@ -6487,7 +6558,7 @@ class PamRenderer( 2 -> InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS 3 -> InputType.TYPE_CLASS_NUMBER 4 -> InputType.TYPE_CLASS_PHONE - 5 -> InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL + 5 -> InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL or InputType.TYPE_NUMBER_FLAG_SIGNED 6 -> InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI else -> InputType.TYPE_CLASS_TEXT } @@ -6496,12 +6567,14 @@ class PamRenderer( input: PamEditText, state: NodeState, ) { + val previousSelectionStart = input.selectionStart + val previousSelectionEnd = input.selectionEnd val multiline = state.flag(PropKey.MULTILINE, false) val secure = state.flag(PropKey.SECURE, false) && !multiline val inputMode = state.integer(PropKey.INPUT_MODE, 0L).toInt() var type = when (inputMode) { INPUT_MODE_DECIMAL -> - InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL + InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL or InputType.TYPE_NUMBER_FLAG_SIGNED INPUT_MODE_NUMERIC -> InputType.TYPE_CLASS_NUMBER INPUT_MODE_TEL -> InputType.TYPE_CLASS_PHONE INPUT_MODE_EMAIL -> @@ -6639,12 +6712,15 @@ class PamRenderer( ) input.setEditableValue(state.flag(PropKey.INPUT_EDITABLE, true)) - val selectionStart = state.integerOrNull(PropKey.INPUT_SELECTION_START) - ?.toInt() + val requestedStart = state.integerOrNull(PropKey.INPUT_SELECTION_START)?.toInt() + val selectionStart = requestedStart + ?: previousSelectionStart.takeIf { it >= 0 } ?: return - val selectionEnd = state.integerOrNull(PropKey.INPUT_SELECTION_END) - ?.toInt() - ?: selectionStart + val selectionEnd = if (requestedStart != null) { + state.integerOrNull(PropKey.INPUT_SELECTION_END)?.toInt() ?: selectionStart + } else { + previousSelectionEnd + } val length = input.text.length val safeStart = selectionStart.coerceIn(0, length) val safeEnd = selectionEnd.coerceIn(safeStart, length) @@ -6717,6 +6793,7 @@ class PamRenderer( } private fun configureAccessibilityDelegate(view: View, state: NodeState) { + val nativeListDelegate = (view as? PamRecyclerList)?.compatAccessibilityDelegate val role = state.integer(PropKey.ACCESSIBILITY_ROLE, 1L).toInt() val actions = if (state.properties[PropKey.ON_ACCESSIBILITY_ACTION] != null) { accessibilityActions(state.textOrNull(PropKey.ACCESSIBILITY_ACTIONS)) @@ -6726,7 +6803,7 @@ class PamRenderer( val hint = state.textOrNull(PropKey.ACCESSIBILITY_HINT) ?.takeIf(String::isNotEmpty) if (role == 1 && actions.isEmpty() && hint == null) { - view.accessibilityDelegate = null + androidx.core.view.ViewCompat.setAccessibilityDelegate(view, nativeListDelegate) return } if (actions.isNotEmpty()) { @@ -6737,8 +6814,14 @@ class PamRenderer( host: View, info: AccessibilityNodeInfo, ) { - super.onInitializeAccessibilityNodeInfo(host, info) - info.className = accessibilityClass(role) + if (nativeListDelegate != null) { + nativeListDelegate.onInitializeAccessibilityNodeInfo( + host, androidx.core.view.accessibility.AccessibilityNodeInfoCompat.wrap(info), + ) + } else { + super.onInitializeAccessibilityNodeInfo(host, info) + } + if (role != 1) info.className = accessibilityClass(role) if (hint != null) { info.hintText = hint } @@ -6767,7 +6850,8 @@ class PamRenderer( ) return true } - return super.performAccessibilityAction(host, actionId, arguments) + return nativeListDelegate?.performAccessibilityAction(host, actionId, arguments) + ?: super.performAccessibilityAction(host, actionId, arguments) } } } @@ -7024,6 +7108,7 @@ class PamRenderer( var pendingChange: Runnable? = null, var nativeValue: String = "", var nativeValueAcknowledged: Boolean = true, + var deferredInputValue: String? = null, var baseText: String = "", var pressOpacity: Float = 0.72f, var pressScale: Float = 1f, diff --git a/android/app/src/main/java/dev/pam/nativeapp/render/PamScrollContainer.kt b/android/app/src/main/java/dev/pam/nativeapp/render/PamScrollContainer.kt index 623237e5..9a09a035 100644 --- a/android/app/src/main/java/dev/pam/nativeapp/render/PamScrollContainer.kt +++ b/android/app/src/main/java/dev/pam/nativeapp/render/PamScrollContainer.kt @@ -4,6 +4,7 @@ import android.content.Context import android.graphics.Color import android.graphics.Rect import android.view.MotionEvent +import android.view.ContextThemeWrapper import android.view.View import android.view.ViewConfiguration import android.view.ViewGroup @@ -15,15 +16,21 @@ import kotlin.math.abs import kotlin.math.max import kotlin.math.roundToInt -internal class PamScrollContainer(context: Context) : FrameLayout(context) { - private var horizontal = false +internal class PamScrollContainer @JvmOverloads constructor( + context: Context, + initialHorizontal: Boolean = false, + initialPersistentScrollbar: Boolean = false, + initialIndicatorStyle: ScrollIndicatorStyle = ScrollIndicatorStyle.AUTO, +) : FrameLayout(context) { + private var horizontal = initialHorizontal private var scrollEnabled = true private var showsIndicator = false private var fillViewport = true private var nestedScrollEnabled = true private var configuredOverScrollMode = OVER_SCROLL_IF_CONTENT_SCROLLS private var fadingEdgeLengthPx = 0 - private var persistentScrollbar = false + private var persistentScrollbar = initialPersistentScrollbar + private var indicatorStyle = initialIndicatorStyle private var pagingEnabled = false private var snapIntervalPx = 0 private var decelerationRate = NORMAL_DECELERATION_RATE @@ -54,7 +61,7 @@ internal class PamScrollContainer(context: Context) : FrameLayout(context) { clipToPadding = false setBackgroundColor(Color.TRANSPARENT) } - private var activeScroll: ViewGroup = createVerticalScroll() + private var activeScroll: ViewGroup = if (horizontal) createHorizontalScroll() else createVerticalScroll() init { clipChildren = true @@ -73,20 +80,40 @@ internal class PamScrollContainer(context: Context) : FrameLayout(context) { fun setHorizontal(value: Boolean) { if (horizontal == value) return + horizontal = value + replaceActiveScroll() + } + + fun setIndicatorStyle(value: Int) { + val next = ScrollIndicatorStyle.fromWire(value) + if (indicatorStyle == next) return + indicatorStyle = next + replaceActiveScroll() + } + + private fun replaceActiveScroll() { val previous = activeScroll val previousX = scrollXOf(previous) val previousY = scrollYOf(previous) + val focused = content.findFocus() + previous.setOnScrollChangeListener(null as View.OnScrollChangeListener?) previous.removeView(content) removeView(previous) - horizontal = value - activeScroll = if (value) createHorizontalScroll() else createVerticalScroll() + activeScroll = if (horizontal) createHorizontalScroll() else createVerticalScroll() activeScroll.setBackgroundColor(Color.TRANSPARENT) activeScroll.addView(content, contentLayout()) - addView(activeScroll, matchParentLayout()) + // Attaching a fading viewport schedules the platform's fade callback. + // Disabling fading later does not cancel that already queued callback. + // Configure before attachment so persistent indicators never enqueue it. applyConfiguration() + addView(activeScroll, matchParentLayout()) if (!hasRequestedOffsetX) requestedOffsetX = previousX if (!hasRequestedOffsetY) requestedOffsetY = previousY applyRequestedOffset() + focused?.requestFocus() + // Theme changes must not jump back to the beginning. Let the new + // platform viewport measure before restoring uncontrolled offsets. + restoreOffsetPixels(previousX, previousY) } fun isHorizontal(): Boolean = horizontal @@ -105,6 +132,9 @@ internal class PamScrollContainer(context: Context) : FrameLayout(context) { showsIndicator = value activeScroll.isHorizontalScrollBarEnabled = value && horizontal activeScroll.isVerticalScrollBarEnabled = value && !horizontal + // The framework flags can change without invalidating the hardware + // display list: Canvas.draw sees them while the window stays stale. + activeScroll.invalidate() } fun setFillViewport(value: Boolean) { @@ -135,8 +165,11 @@ internal class PamScrollContainer(context: Context) : FrameLayout(context) { } fun setPersistentScrollbar(value: Boolean) { + if (persistentScrollbar == value) return persistentScrollbar = value - activeScroll.isScrollbarFadingEnabled = !value + // A fresh viewport drops any fade callback queued by the old one. + // Content, focus and scroll offsets are retained by replacement. + replaceActiveScroll() } fun setPagingEnabled(value: Boolean) { @@ -420,19 +453,23 @@ internal class PamScrollContainer(context: Context) : FrameLayout(context) { } private fun createVerticalScroll(): ViewGroup = - PamVerticalScrollView(context, ::dismissKeyboard).apply { + PamVerticalScrollView(indicatorContext(), ::dismissKeyboard).apply { setOnScrollChangeListener { _, scrollX, scrollY, _, _ -> dispatchViewport(scrollX, scrollY) } } private fun createHorizontalScroll(): ViewGroup = - PamHorizontalScrollView(context, ::dismissKeyboard).apply { + PamHorizontalScrollView(indicatorContext(), ::dismissKeyboard).apply { setOnScrollChangeListener { _, scrollX, scrollY, _, _ -> dispatchViewport(scrollX, scrollY) } } + private fun indicatorContext(): Context = + if (indicatorStyle == ScrollIndicatorStyle.AUTO) context + else ContextThemeWrapper(context, indicatorStyle.themeResource) + private fun applyConfiguration() { activeScroll.clipChildren = true activeScroll.clipToPadding = true diff --git a/android/app/src/main/java/dev/pam/nativeapp/render/ScrollIndicatorStyle.kt b/android/app/src/main/java/dev/pam/nativeapp/render/ScrollIndicatorStyle.kt new file mode 100644 index 00000000..66b0b7f6 --- /dev/null +++ b/android/app/src/main/java/dev/pam/nativeapp/render/ScrollIndicatorStyle.kt @@ -0,0 +1,14 @@ +package dev.pam.nativeapp.render + +import dev.pam.nativeapp.R + +internal enum class ScrollIndicatorStyle(val wireValue: Int, val themeResource: Int) { + AUTO(1, 0), + DARK(2, R.style.PamScrollIndicator_Dark), + LIGHT(3, R.style.PamScrollIndicator_Light); + + companion object { + fun fromWire(value: Int): ScrollIndicatorStyle = + entries.firstOrNull { it.wireValue == value } ?: AUTO + } +} diff --git a/android/app/src/main/res/drawable/pam_scroll_indicator_dark.xml b/android/app/src/main/res/drawable/pam_scroll_indicator_dark.xml new file mode 100644 index 00000000..279e5e55 --- /dev/null +++ b/android/app/src/main/res/drawable/pam_scroll_indicator_dark.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/pam_scroll_indicator_light.xml b/android/app/src/main/res/drawable/pam_scroll_indicator_light.xml new file mode 100644 index 00000000..96a4865c --- /dev/null +++ b/android/app/src/main/res/drawable/pam_scroll_indicator_light.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/values/scroll_indicators.xml b/android/app/src/main/res/values/scroll_indicators.xml new file mode 100644 index 00000000..7df14353 --- /dev/null +++ b/android/app/src/main/res/values/scroll_indicators.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/test/java/dev/pam/nativeapp/protocol/PamProtocolTest.kt b/android/app/src/test/java/dev/pam/nativeapp/protocol/PamProtocolTest.kt index 71b68a9d..c890e409 100644 --- a/android/app/src/test/java/dev/pam/nativeapp/protocol/PamProtocolTest.kt +++ b/android/app/src/test/java/dev/pam/nativeapp/protocol/PamProtocolTest.kt @@ -11,7 +11,7 @@ class PamProtocolTest { fun protocolEnumsRemainSequentialAndAppendOnly() { assertEquals((1..31).toList(), NodeKind.entries.map(NodeKind::value)) assertEquals((1..65).toList(), EventKind.entries.map(EventKind::value)) - assertEquals((1..464).toList(), PropKey.entries.map(PropKey::value)) + assertEquals((1..470).toList(), PropKey.entries.map(PropKey::value)) assertEquals( (1..19).toList(), dev.pam.nativeapp.modules.NativeOperation.entries.map { it.value }, diff --git a/android/app/src/test/java/dev/pam/nativeapp/render/OverlayCollisionResolverTest.kt b/android/app/src/test/java/dev/pam/nativeapp/render/OverlayCollisionResolverTest.kt new file mode 100644 index 00000000..ad364f45 --- /dev/null +++ b/android/app/src/test/java/dev/pam/nativeapp/render/OverlayCollisionResolverTest.kt @@ -0,0 +1,32 @@ +package dev.pam.nativeapp.render + +import dev.pam.nativeapp.views.OverlayBounds +import dev.pam.nativeapp.views.OverlayCollisionResolver +import org.junit.Assert.assertEquals +import org.junit.Test + +class OverlayCollisionResolverTest { + private val viewport = OverlayBounds(8f, 24f, 352f, 760f) + private val anchor = OverlayBounds(16f, 300f, 192f, 348f) + + @Test fun lateralOverflowFallsBackWithoutCoveringAnchor() { + assertEquals(2, OverlayCollisionResolver.selectCandidate(anchor, viewport, 248f, 160f, + listOf(-240f to 244f, 200f to 244f, 16f to 356f, 16f to 132f))) + } + + @Test fun fittingPreferredPositionRemainsPreferred() { + assertEquals(0, OverlayCollisionResolver.selectCandidate(anchor, viewport, 120f, 80f, + listOf(200f to 284f, 16f to 356f))) + } + + @Test fun belowScreenUsesTopWhenItAvoidsAnchor() { + val lowAnchor = OverlayBounds(16f, 690f, 192f, 738f) + assertEquals(1, OverlayCollisionResolver.selectCandidate(lowAnchor, viewport, 248f, 160f, + listOf(16f to 746f, 16f to 522f))) + } + + @Test(expected = IllegalArgumentException::class) + fun emptyCandidatesAreRejected() { + OverlayCollisionResolver.selectCandidate(anchor, viewport, 248f, 160f, emptyList()) + } +} diff --git a/android/plugin-api/src/main/java/dev/pam/nativeapp/views/NativeChildVisibilityHost.kt b/android/plugin-api/src/main/java/dev/pam/nativeapp/views/NativeChildVisibilityHost.kt new file mode 100644 index 00000000..84ccd875 --- /dev/null +++ b/android/plugin-api/src/main/java/dev/pam/nativeapp/views/NativeChildVisibilityHost.kt @@ -0,0 +1,11 @@ +package dev.pam.nativeapp.views + +import android.view.View + +/** Optional capability for custom views owning collapsible declarative children. + * Invoke on the UI thread, with a direct declarative child. PAM recalculates + * layout after the current render transaction; hosts must not call during draw. + */ +interface NativeChildVisibilityHost { + var onChildVisibilityChanged: ((View, Boolean) -> Unit)? +} diff --git a/android/plugin-api/src/main/java/dev/pam/nativeapp/views/OverlayCollisionResolver.kt b/android/plugin-api/src/main/java/dev/pam/nativeapp/views/OverlayCollisionResolver.kt new file mode 100644 index 00000000..6d17e7d7 --- /dev/null +++ b/android/plugin-api/src/main/java/dev/pam/nativeapp/views/OverlayCollisionResolver.kt @@ -0,0 +1,45 @@ +package dev.pam.nativeapp.views + +import kotlin.math.max +import kotlin.math.min + +enum class OverlayPlacement(val code: Int) { + Top(1), TopStart(2), TopEnd(3), Bottom(4), BottomStart(5), BottomEnd(6), + Left(7), LeftTop(8), LeftBottom(9), Right(10), RightTop(11), RightBottom(12), Center(13), +} + +/** Screen-space bounds shared by native overlay hosts. */ +data class OverlayBounds(val left: Float, val top: Float, val right: Float, val bottom: Float) + +/** Selects a preferred origin without hiding its anchor when another candidate fits. */ +object OverlayCollisionResolver { + fun selectCandidate( + anchor: OverlayBounds, + viewport: OverlayBounds, + width: Float, + height: Float, + candidates: List>, + ): Int { + require(candidates.isNotEmpty()) + var bestIndex = 0 + var bestOverlap = Double.POSITIVE_INFINITY + var bestOverflow = Double.POSITIVE_INFINITY + candidates.forEachIndexed { index, origin -> + val x = origin.first.coerceIn(viewport.left, max(viewport.left, viewport.right - width)) + val y = origin.second.coerceIn(viewport.top, max(viewport.top, viewport.bottom - height)) + val overlapWidth = max(0f, min(x + width, anchor.right) - max(x, anchor.left)) + val overlapHeight = max(0f, min(y + height, anchor.bottom) - max(y, anchor.top)) + val overlap = overlapWidth.toDouble() * overlapHeight.toDouble() + val overflow = max(0f, viewport.left - origin.first).toDouble() + + max(0f, origin.first + width - viewport.right) + + max(0f, viewport.top - origin.second) + + max(0f, origin.second + height - viewport.bottom) + if (overlap < bestOverlap || (overlap == bestOverlap && overflow < bestOverflow)) { + bestIndex = index + bestOverlap = overlap + bestOverflow = overflow + } + } + return bestIndex + } +} diff --git a/crates/pam-native-cli/src/mobile.rs b/crates/pam-native-cli/src/mobile.rs index bd4a07a1..75a3c74b 100644 --- a/crates/pam-native-cli/src/mobile.rs +++ b/crates/pam-native-cli/src/mobile.rs @@ -2374,9 +2374,15 @@ pub fn repair_android(project_path: &Path) -> Result<(), String> { let runtime = resolve_runtime(&project, &pam_home)?; if default_abis() .into_iter() - .any(|abi| !runtime_ready_at(&runtime.root, abi) || !engine_ready_at(&native_home, abi)) + .any(|abi| !runtime_ready_at(&runtime.root, abi)) { - install_android_runtime_bundle(&project, &pam_home, &native_home)?; + install_android_runtime_bundle(&project, &pam_home)?; + } + if default_abis() + .into_iter() + .any(|abi| !engine_ready_at(&native_home, abi)) + { + install_android_renderer_bundle(&native_home)?; } write_runtime_lock(&project, &runtime)?; @@ -2394,11 +2400,7 @@ fn install_android_runtime(project_path: PathBuf) -> Result { Ok(0) } -fn install_android_runtime_bundle( - project: &Project, - pam_home: &Path, - native_home: &Path, -) -> Result<(), String> { +fn install_android_runtime_bundle(project: &Project, pam_home: &Path) -> Result<(), String> { let asset = "pam-android-runtime.tar.gz"; let configured_base = std::env::var("PAM_RELEASE_BASE_URL").ok(); let base = android_runtime_release_base(configured_base.as_deref()); @@ -2469,23 +2471,12 @@ fn install_android_runtime_bundle( abi.android() )); } - if !engine_ready_at(&extracted.join("native"), abi) { - return Err(format!( - "Android runtime archive is missing the PAM Native engine for {}", - abi.android() - )); - } } copy_tree( &extracted.join("runtime/android"), &pam_home.join("runtime/android"), &[], )?; - copy_tree( - &extracted.join("native/target"), - &native_home.join("target"), - &[], - )?; Ok(()) })(); let cleanup = fs::remove_dir_all(&temporary); @@ -2498,6 +2489,122 @@ fn install_android_runtime_bundle( } } +fn install_android_renderer_bundle(native_home: &Path) -> Result<(), String> { + let version = env!("CARGO_PKG_VERSION"); + let asset = format!("pam-native-android-renderer-{version}.tar.gz"); + let base = format!("https://github.com/push-in/pam-native/releases/download/v{version}"); + let temporary = std::env::temp_dir().join(format!( + "pam-native-renderer-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_err(|error| error.to_string())? + .as_nanos() + )); + fs::create_dir(&temporary) + .map_err(|error| format!("cannot create {}: {error}", temporary.display()))?; + let result = (|| { + let archive = temporary.join(&asset); + let checksum = temporary.join(format!("{asset}.sha256")); + download_release_asset( + &format!("{base}/{asset}"), + &archive, + MAX_ANDROID_RUNTIME_ARCHIVE_BYTES, + )?; + download_release_asset( + &format!("{base}/{asset}.sha256"), + &checksum, + MAX_CHECKSUM_BYTES, + )?; + verify_release_checksum(&archive, &checksum, &asset)?; + + let root = format!("pam-native-android-renderer-{version}"); + let paths = default_abis() + .into_iter() + .map(|abi| { + format!( + "{root}/target/{}/release/libpam_native_engine.a", + abi.rust_target() + ) + }) + .collect::>(); + let listing = Command::new("tar") + .args(["-tzf"]) + .arg(&archive) + .output() + .map_err(|error| format!("cannot inspect Native renderer archive: {error}"))?; + if !listing.status.success() { + return Err(format!( + "Native renderer archive inspection failed: {}", + listing.status + )); + } + for line in String::from_utf8_lossy(&listing.stdout).lines() { + if !safe_android_renderer_archive_path(Path::new(line), &root) { + return Err(format!("unsafe Native renderer archive path: {line}")); + } + } + let extracted = temporary.join("extracted"); + fs::create_dir(&extracted) + .map_err(|error| format!("cannot create {}: {error}", extracted.display()))?; + let status = Command::new("tar") + .args(["-xzf"]) + .arg(&archive) + .arg("-C") + .arg(&extracted) + .args(&paths) + .status() + .map_err(|error| format!("cannot extract Native renderer engines: {error}"))?; + if !status.success() { + return Err(format!( + "Native renderer engine extraction failed: {status}" + )); + } + for abi in default_abis() { + let relative = PathBuf::from(format!( + "target/{}/release/libpam_native_engine.a", + abi.rust_target() + )); + let source = extracted.join(&root).join(&relative); + let metadata = fs::symlink_metadata(&source) + .map_err(|error| format!("cannot inspect {}: {error}", source.display()))?; + if !metadata.file_type().is_file() || metadata.len() == 0 { + return Err(format!( + "Native renderer engine is not a regular file: {}", + source.display() + )); + } + let destination = native_home.join(&relative); + fs::create_dir_all(destination.parent().expect("engine has a parent")) + .map_err(|error| format!("cannot create engine directory: {error}"))?; + let staged = destination.with_extension(format!("a.tmp-{}", std::process::id())); + fs::copy(&source, &staged) + .map_err(|error| format!("cannot stage {}: {error}", destination.display()))?; + fs::rename(&staged, &destination) + .map_err(|error| format!("cannot activate {}: {error}", destination.display()))?; + } + write_atomic( + &native_home.join("target/pam-native-engine-version"), + format!("{version}\n").as_bytes(), + ) + })(); + let cleanup = fs::remove_dir_all(&temporary); + match (result, cleanup) { + (Err(error), _) => Err(error), + (Ok(()), Err(error)) => Err(format!( + "Native renderer installed, but temporary files remain: {error}" + )), + (Ok(()), Ok(())) => Ok(()), + } +} + +fn safe_android_renderer_archive_path(path: &Path, root: &str) -> bool { + path.starts_with(root) + && path + .components() + .all(|component| matches!(component, Component::Normal(_))) +} + fn android_runtime_release_base(configured: Option<&str>) -> String { configured .unwrap_or("https://github.com/push-in/pam/releases/latest/download") @@ -6060,6 +6167,20 @@ fn android_gradle_user_home(project_root: &Path) -> PathBuf { std::env::var_os("PAM_NATIVE_GRADLE_HOME") .or_else(|| std::env::var_os("GRADLE_USER_HOME")) .map(PathBuf::from) + .or_else(|| { + std::env::var_os("XDG_CACHE_HOME") + .filter(|path| !path.is_empty()) + .map(PathBuf::from) + .filter(|cache| cache.is_absolute()) + .map(|cache| cache.join("pam-native/gradle")) + }) + .or_else(|| { + std::env::var_os("HOME") + .filter(|path| !path.is_empty()) + .map(PathBuf::from) + .filter(|home| home.is_absolute()) + .map(|home| home.join(".cache/pam-native/gradle")) + }) .unwrap_or_else(|| project_root.join(".pam-native/gradle-home")) } @@ -6929,7 +7050,12 @@ fn native_engine_path(native_home: &Path, abi: AndroidAbi) -> PathBuf { } fn engine_ready_at(native_home: &Path, abi: AndroidAbi) -> bool { - native_engine_path(native_home, abi).is_file() + if !native_engine_path(native_home, abi).is_file() { + return false; + } + native_home.join(".git").is_dir() + || fs::read_to_string(native_home.join("target/pam-native-engine-version")) + .is_ok_and(|version| version.trim() == env!("CARGO_PKG_VERSION")) } fn installed_rust_targets() -> Result, String> { @@ -8502,6 +8628,50 @@ mod tests { assert!(!android_runtime_release_base(None).contains(env!("CARGO_PKG_VERSION"))); } + #[test] + fn native_renderer_archive_is_confined_to_its_exact_release_root() { + let root = format!("pam-native-android-renderer-{}", env!("CARGO_PKG_VERSION")); + assert!(safe_android_renderer_archive_path( + Path::new(&format!( + "{root}/target/aarch64-linux-android/release/libpam_native_engine.a" + )), + &root, + )); + assert!(!safe_android_renderer_archive_path( + Path::new(&format!("{root}/../outside")), + &root, + )); + assert!(!safe_android_renderer_archive_path( + Path::new("pam-native-android-renderer-old/target/libpam_native_engine.a"), + &root, + )); + } + + #[test] + fn packaged_native_engine_requires_matching_version_marker() { + let root = std::env::temp_dir().join(format!( + "pam-native-engine-version-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + let engine = native_engine_path(&root, AndroidAbi::Arm64); + fs::create_dir_all(engine.parent().expect("engine parent")).expect("directory"); + fs::write(&engine, b"engine").expect("engine"); + assert!(!engine_ready_at(&root, AndroidAbi::Arm64)); + fs::write(root.join("target/pam-native-engine-version"), b"0.0.0\n").expect("wrong marker"); + assert!(!engine_ready_at(&root, AndroidAbi::Arm64)); + fs::write( + root.join("target/pam-native-engine-version"), + format!("{}\n", env!("CARGO_PKG_VERSION")), + ) + .expect("matching marker"); + assert!(engine_ready_at(&root, AndroidAbi::Arm64)); + fs::remove_dir_all(root).expect("cleanup"); + } + #[test] fn retries_only_transient_adb_package_service_failures() { assert!(transient_adb_install_failure( diff --git a/crates/pam-native-engine/src/ffi.rs b/crates/pam-native-engine/src/ffi.rs index 617e628b..71852774 100644 --- a/crates/pam-native-engine/src/ffi.rs +++ b/crates/pam-native-engine/src/ffi.rs @@ -247,6 +247,44 @@ pub unsafe extern "C" fn pam_native_engine_relayout( } } +#[unsafe(no_mangle)] +/// Changes a custom host's direct child visibility and returns layout mutations. +/// `visible` must be zero or one. Authored property commits remain authoritative. +/// +/// # Safety +/// `handle` must be live and exclusively borrowed. `output` must point to writable +/// storage with no outstanding buffer lease. Release successful output normally. +pub unsafe extern "C" fn pam_native_engine_set_native_child_visibility( + handle: *mut PamNativeEngineHandle, + owner: u64, + child: u64, + visible: u8, + output: *mut PamNativeBuffer, +) -> PamStatus { + let Some(output) = (unsafe { output.as_mut() }) else { + return PamStatus::InvalidArgument; + }; + *output = PamNativeBuffer::default(); + let Some(handle) = (unsafe { handle.as_mut() }) else { + return PamStatus::InvalidArgument; + }; + if visible > 1 { + return PamStatus::InvalidArgument; + } + match catch_unwind(AssertUnwindSafe(|| { + handle + .engine + .set_native_child_visibility(owner, child, visible == 1) + })) { + Ok(Ok(batch)) => { + *output = lease_buffer(batch); + PamStatus::Success + } + Ok(Err(_)) => PamStatus::InvalidArgument, + Err(_) => PamStatus::Panic, + } +} + #[unsafe(no_mangle)] /// Changes viewport and text metrics and returns retained-tree layout mutations. /// @@ -532,6 +570,88 @@ mod tests { unsafe { pam_native_engine_free(handle) }; } + #[test] + fn native_child_visibility_abi_validates_inputs_and_leases_output() { + let _pool_guard = lock_shared_buffer_pool(); + let tree = Tree { + root: 1, + nodes: BTreeMap::from([ + ( + 1, + Node { + id: 1, + parent: 0, + index: 0, + kind: NodeKind::CustomView, + properties: BTreeMap::new(), + }, + ), + ( + 2, + Node { + id: 2, + parent: 1, + index: 0, + kind: NodeKind::Column, + properties: BTreeMap::new(), + }, + ), + ]), + } + .encode() + .expect("tree"); + let handle = pam_native_engine_new(); + let mut output = PamNativeBuffer::default(); + // SAFETY: Live exclusive handle, valid input and uniquely owned buffers. + unsafe { + assert_eq!( + pam_native_engine_commit(handle, tree.as_ptr(), tree.len(), &mut output), + PamStatus::Success + ); + pam_native_buffer_free(output); + output = PamNativeBuffer::default(); + assert_eq!( + pam_native_engine_set_native_child_visibility( + ptr::null_mut(), + 1, + 2, + 0, + &mut output + ), + PamStatus::InvalidArgument + ); + assert_eq!( + pam_native_engine_set_native_child_visibility(handle, 1, 2, 2, &mut output), + PamStatus::InvalidArgument + ); + assert_eq!( + pam_native_engine_set_native_child_visibility(handle, 2, 1, 0, &mut output), + PamStatus::InvalidArgument + ); + assert!(output.data.is_null()); + assert_eq!(output.lease, 0); + for visible in [0, 1] { + assert_eq!( + pam_native_engine_set_native_child_visibility( + handle, + 1, + 2, + visible, + &mut output + ), + PamStatus::Success + ); + assert!(output.length > 0); + assert_ne!(output.lease, 0); + let bytes = std::slice::from_raw_parts(output.data, output.length); + assert!(pam_native_protocol::decode_batch(bytes).is_ok()); + pam_native_buffer_free(output); + output = PamNativeBuffer::default(); + } + pam_native_engine_free(handle); + } + } + #[test] fn ffi_exposes_the_last_commit_error() { let _pool_guard = lock_shared_buffer_pool(); diff --git a/crates/pam-native-engine/src/grid_template.rs b/crates/pam-native-engine/src/grid_template.rs new file mode 100644 index 00000000..eb8b4451 --- /dev/null +++ b/crates/pam-native-engine/src/grid_template.rs @@ -0,0 +1,156 @@ +//! Validated container-relative grid plans, independent of a UI library. +//! Wire form: `minimum_width,columns,column_gap,row_gap;...`. + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct GridBreakpoint { + pub minimum_width: f32, + pub columns: usize, + pub column_gap: f32, + pub row_gap: f32, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct GridTemplate { + levels: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GridTemplateError { + InvalidShape, + InvalidNumber, + InvalidColumns, + InvalidThresholds, +} + +impl GridTemplate { + pub fn parse(wire: &str) -> Result { + // Bound parsing work before allocating, including adversarial wire data. + if wire.is_empty() || wire.len() > 1024 { + return Err(GridTemplateError::InvalidShape); + } + let mut levels = Vec::with_capacity(6); + for row in wire.split(';') { + if levels.len() == 6 { + return Err(GridTemplateError::InvalidShape); + } + let mut fields = row.split(','); + let minimum_width = parse_dimension(fields.next())?; + let columns = fields + .next() + .ok_or(GridTemplateError::InvalidShape)? + .parse::() + .map_err(|_| GridTemplateError::InvalidColumns)?; + if !(1..=64).contains(&columns) { + return Err(GridTemplateError::InvalidColumns); + } + let column_gap = parse_dimension(fields.next())?; + let row_gap = parse_dimension(fields.next())?; + if fields.next().is_some() { + return Err(GridTemplateError::InvalidShape); + } + if levels + .last() + .map_or(minimum_width != 0.0, |previous: &GridBreakpoint| { + minimum_width <= previous.minimum_width + }) + { + return Err(GridTemplateError::InvalidThresholds); + } + levels.push(GridBreakpoint { + minimum_width, + columns, + column_gap, + row_gap, + }); + } + Ok(Self { levels }) + } + + pub fn resolve(&self, width: f32) -> Result<(usize, GridBreakpoint), GridTemplateError> { + if !width.is_finite() || width < 0.0 { + return Err(GridTemplateError::InvalidNumber); + } + // Construction guarantees a base level at zero and ordered thresholds. + let index = self + .levels + .partition_point(|level| level.minimum_width <= width) + - 1; + Ok((index, self.levels[index])) + } +} + +fn parse_dimension(value: Option<&str>) -> Result { + let parsed = value + .ok_or(GridTemplateError::InvalidShape)? + .parse::() + .map_err(|_| GridTemplateError::InvalidNumber)?; + if !parsed.is_finite() || parsed < 0.0 { + return Err(GridTemplateError::InvalidNumber); + } + Ok(parsed) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preserves_all_six_ui_thresholds_and_independent_gutters() { + let template = GridTemplate::parse( + "0,1,8,4;640,2,12,8;768,3,16,12;1024,4,20,16;1280,5,24,20;1536,6,28,24", + ) + .unwrap(); + for (index, width) in [0.0, 640.0, 768.0, 1024.0, 1280.0, 1536.0] + .into_iter() + .enumerate() + { + let (level, plan) = template.resolve(width).unwrap(); + assert_eq!(level, index); + assert_eq!(plan.columns, index + 1); + assert_eq!(plan.column_gap, 8.0 + index as f32 * 4.0); + assert_eq!(plan.row_gap, 4.0 + index as f32 * 4.0); + if index > 0 { + assert_eq!(template.resolve(width - 0.25).unwrap().0, index - 1); + } + } + assert_eq!(template.resolve(4096.0).unwrap().0, 5); + } + + #[test] + fn supports_native_thresholds_without_reinterpreting_them() { + let template = + GridTemplate::parse("0,12,12,8;600,12,16,8;840,12,24,12;1200,12,24,16;1600,12,32,16") + .unwrap(); + assert_eq!(template.resolve(768.0).unwrap().0, 1); + assert_eq!(template.resolve(840.0).unwrap().0, 2); + } + + #[test] + fn rejects_invalid_or_unbounded_plans() { + for wire in [ + "", + "0,1,0", + "0,1,0,0,0", + "1,1,0,0", + "0,1,0,0;0,2,0,0", + "0,0,0,0", + "0,65,0,0", + "0,1,-1,0", + "0,1,NaN,0", + "0,1,inf,0", + "0,1,0,0;600,2,0,0;500,3,0,0", + "0,1,0,0;", + "NaN,1,0,0", + ] { + assert!(GridTemplate::parse(wire).is_err(), "accepted {wire}"); + } + assert!(GridTemplate::parse(&"0".repeat(1025)).is_err()); + assert!( + GridTemplate::parse("0,1,0,0;1,1,0,0;2,1,0,0;3,1,0,0;4,1,0,0;5,1,0,0;6,1,0,0").is_err() + ); + let template = GridTemplate::parse("0,1,0,0").unwrap(); + for width in [-1.0, f32::NAN, f32::INFINITY] { + assert!(template.resolve(width).is_err()); + } + } +} diff --git a/crates/pam-native-engine/src/layout.rs b/crates/pam-native-engine/src/layout.rs index cbac42a1..a330771a 100644 --- a/crates/pam-native-engine/src/layout.rs +++ b/crates/pam-native-engine/src/layout.rs @@ -560,7 +560,7 @@ fn layout_node( } return Ok(()); } - if integer(node, PropKey::GridColumns).unwrap_or(0) > 0 { + if is_grid(node) { layout_grid(context, node, node_children, inner, gap, depth, output)?; return Ok(()); } @@ -1029,6 +1029,44 @@ fn layout_wrapped_children( Ok(()) } +fn is_grid(node: &Node) -> bool { + integer(node, PropKey::GridColumns).unwrap_or(0) > 0 + || node.properties.contains_key(&PropKey::GridTemplate) +} + +fn resolved_grid_plan( + node: &Node, + width: f32, + fallback_gap: f32, +) -> Result<(usize, f32, f32, Option), LayoutError> { + let (maximum, gap, row_gap, level) = match node.properties.get(&PropKey::GridTemplate) { + Some(pam_native_protocol::PropValue::String(wire)) => { + let template = crate::grid_template::GridTemplate::parse(wire) + .map_err(|_| LayoutError::InvalidGridTemplate)?; + let (level, plan) = template + .resolve(width) + .map_err(|_| LayoutError::InvalidGridTemplate)?; + (plan.columns, plan.column_gap, plan.row_gap, Some(level)) + } + Some(_) => return Err(LayoutError::InvalidGridTemplate), + None => ( + integer(node, PropKey::GridColumns) + .unwrap_or(12) + .clamp(1, 64) as usize, + finite_non_negative(number(node, PropKey::GridColumnGap).unwrap_or(fallback_gap))?, + finite_non_negative(number(node, PropKey::GridRowGap).unwrap_or(fallback_gap))?, + None, + ), + }; + let minimum = finite_non_negative(number(node, PropKey::GridMinColumnWidth).unwrap_or(0.0))?; + if minimum == 0.0 { + return Ok((maximum, gap, row_gap, level)); + } + // A narrower container still gets one column; never force horizontal overflow. + let fitting = ((width.max(0.0) + gap) / (minimum + gap)).floor() as usize; + Ok((fitting.clamp(1, maximum), gap, row_gap, level)) +} + fn layout_grid( context: &LayoutContext<'_>, node: &Node, @@ -1038,12 +1076,8 @@ fn layout_grid( depth: usize, output: &mut BTreeMap, ) -> Result<(), LayoutError> { - let columns = integer(node, PropKey::GridColumns) - .unwrap_or(12) - .clamp(1, 64) as usize; - let column_gap = - finite_non_negative(number(node, PropKey::GridColumnGap).unwrap_or(fallback_gap))?; - let row_gap = finite_non_negative(number(node, PropKey::GridRowGap).unwrap_or(fallback_gap))?; + let (columns, column_gap, row_gap, level) = + resolved_grid_plan(node, inner.width, fallback_gap)?; let unit = ((inner.width - column_gap * columns.saturating_sub(1) as f32).max(0.0)) / columns as f32; let mut children = node_children @@ -1057,7 +1091,7 @@ fn layout_grid( .collect::>(); children.sort_by_key(|child| { ( - responsive_grid_value(child, inner.width, GridValue::Order, 0), + responsive_grid_value(child, inner.width, GridValue::Order, 0, level), child.index, ) }); @@ -1067,9 +1101,9 @@ fn layout_grid( let mut cursor = 0_usize; let mut row_heights = Vec::::new(); for child in children { - let span = responsive_grid_value(child, inner.width, GridValue::Span, columns as i64) + let span = responsive_grid_value(child, inner.width, GridValue::Span, columns as i64, level) .clamp(1, columns as i64) as usize; - let offset = responsive_grid_value(child, inner.width, GridValue::Offset, 0) + let offset = responsive_grid_value(child, inner.width, GridValue::Offset, 0, level) .clamp(0, columns.saturating_sub(1) as i64) as usize; if cursor > 0 && cursor + offset + span > columns { row += 1; @@ -1114,14 +1148,46 @@ fn layout_grid( y += *height + row_gap; } for placement in placements { - let (margin_top, _) = margin_main(placement.child, Axis::Vertical); + let (margin_top, margin_bottom) = margin_main(placement.child, Axis::Vertical); let (margin_left, margin_right) = margin_cross(placement.child, Axis::Vertical); + let alignment = integer(placement.child, PropKey::AlignSelf) + .map(cross_alignment) + .unwrap_or_else(|| cross_alignment(integer(node, PropKey::AlignItems).unwrap_or(4))); + let row_height = row_heights[placement.row]; + let height = if alignment == CrossAlignment::Stretch + && dimension( + placement.child, + PropKey::Height, + PropKey::HeightPercent, + inner.height, + ) + .is_none() + { + constrained( + (row_height - margin_top - margin_bottom).max(0.0), + number(placement.child, PropKey::MinHeight), + dimension( + placement.child, + PropKey::MaxHeight, + PropKey::MaxHeightPercent, + inner.height, + ), + )? + } else { + placement.height + }; + let offset = match alignment { + CrossAlignment::Center => (row_height - height + margin_top - margin_bottom) / 2.0, + CrossAlignment::End => row_height - height - margin_bottom, + _ => margin_top, + } + .max(0.0); let x = inner.x + placement.column as f32 * (unit + column_gap) + margin_left; let frame = Layout { x, - y: inner.y + row_offsets[placement.row] + margin_top, + y: inner.y + row_offsets[placement.row] + offset, width: (placement.width - margin_left - margin_right).max(0.0), - height: placement.height, + height, }; layout_node(context, placement.child.id, frame, false, depth + 1, output)?; } @@ -1170,7 +1236,13 @@ enum GridValue { Order, } -fn responsive_grid_value(node: &Node, width: f32, value: GridValue, default: i64) -> i64 { +fn responsive_grid_value( + node: &Node, + width: f32, + value: GridValue, + default: i64, + template_level: Option, +) -> i64 { let keys = match value { GridValue::Span => [ PropKey::GridSpan, @@ -1178,6 +1250,7 @@ fn responsive_grid_value(node: &Node, width: f32, value: GridValue, default: i64 PropKey::GridSpanMd, PropKey::GridSpanLg, PropKey::GridSpanXl, + PropKey::GridSpan2xl, ], GridValue::Offset => [ PropKey::GridOffset, @@ -1185,6 +1258,7 @@ fn responsive_grid_value(node: &Node, width: f32, value: GridValue, default: i64 PropKey::GridOffsetMd, PropKey::GridOffsetLg, PropKey::GridOffsetXl, + PropKey::GridOffset2xl, ], GridValue::Order => [ PropKey::GridOrder, @@ -1192,9 +1266,10 @@ fn responsive_grid_value(node: &Node, width: f32, value: GridValue, default: i64 PropKey::GridOrderMd, PropKey::GridOrderLg, PropKey::GridOrderXl, + PropKey::GridOrder2xl, ], }; - let level = if width >= 1600.0 { + let level = template_level.unwrap_or(if width >= 1600.0 { 4 } else if width >= 1200.0 { 3 @@ -1204,7 +1279,7 @@ fn responsive_grid_value(node: &Node, width: f32, value: GridValue, default: i64 1 } else { 0 - }; + }); (0..=level) .rev() .find_map(|index| integer(node, keys[index])) @@ -1243,7 +1318,7 @@ fn natural_scroll_extent( if let Some(explicit) = explicit { return finite_non_negative(explicit); } - if integer(node, PropKey::GridColumns).unwrap_or(0) > 0 { + if is_grid(node) { return intrinsic_extent( children, node, @@ -1282,7 +1357,9 @@ fn natural_scroll_extent( if axis == Axis::Vertical { available_cross } else { - available_main + // The scrolling axis is unconstrained. Measuring a text leaf + // at viewport width would wrap it before computing overflow. + f32::INFINITY }, if axis == Axis::Vertical { available_main @@ -1323,6 +1400,9 @@ fn natural_scroll_extent( } }; let mut extents = Vec::with_capacity(visible_children.len()); + let mut grow_total = 0.0_f32; + let mut grow_unit = 0.0_f32; + let mut fixed_extent = 0.0_f32; for child in visible_children { let extent = natural_scroll_extent( children, @@ -1336,10 +1416,25 @@ fn natural_scroll_extent( )?; let (before, after) = margin_main(child, axis); extents.push(extent + before + after); + let grow = number(child, PropKey::FlexGrow).unwrap_or(0.0).max(0.0); + if grow > 0.0 { + grow_total += grow; + grow_unit = grow_unit.max((extent + before + after) / grow); + } else { + fixed_extent += extent + before + after; + } } let content = if node_axis == axis { let gap = finite(number(node, PropKey::Gap).unwrap_or(0.0))?; - extents.iter().sum::() + gap * extents.len().saturating_sub(1) as f32 + // Zero-basis grow items share the content width by weight. A sum of + // their different intrinsic widths would give the widest item too + // little space, even inside an unconstrained horizontal scroller. + let main = if axis == Axis::Horizontal && grow_total > 0.0 { + fixed_extent + grow_unit * grow_total + } else { + extents.iter().sum::() + }; + main + gap * extents.len().saturating_sub(1) as f32 } else { extents.into_iter().fold(0.0, f32::max) }; @@ -1414,7 +1509,10 @@ fn intrinsic_extent( && integer(child, PropKey::PositionType).unwrap_or(1) != 2 }) .collect::>(); - if node_children.is_empty() || !content_sized(node) { + let horizontal_scroll_height = node.kind == NodeKind::Scroll + && boolean(node, PropKey::ScrollHorizontal) + && requested_axis == Axis::Vertical; + if node_children.is_empty() || (!content_sized(node) && !horizontal_scroll_height) { return finite_non_negative(leaf_intrinsic( node, requested_axis, @@ -1438,7 +1536,44 @@ fn intrinsic_extent( finite_non_negative(number(node, PropKey::PaddingBottom).unwrap_or(padding_vertical))?; let inner_width = (available_width - padding_left - padding_right).max(0.0); let inner_height = (available_height - padding_top - padding_bottom).max(0.0); - if integer(node, PropKey::GridColumns).unwrap_or(0) > 0 { + if horizontal_scroll_height { + // Measure at the same natural content width used by layout_node, not + // the viewport width: scrolling content must not wrap to fit the view. + let fill_viewport = !matches!( + node.properties.get(&PropKey::ScrollFillViewport), + Some(pam_native_protocol::PropValue::Boolean(false)) + ); + let mut height = 0.0_f32; + for child in node_children { + let natural_width = natural_scroll_extent( + children, + child, + Axis::Horizontal, + inner_width, + inner_height, + text_scale, + text_metrics, + depth + 1, + )?; + let width = if fill_viewport { + natural_width.max(inner_width) + } else { + natural_width + }; + height = height.max(constrained_intrinsic_extent( + children, + child, + Axis::Vertical, + width, + inner_height, + text_scale, + text_metrics, + depth + 1, + )?); + } + return finite_non_negative(height + padding_top + padding_bottom); + } + if is_grid(node) { let content = match requested_axis { Axis::Horizontal => inner_width, Axis::Vertical => grid_intrinsic_height( @@ -1692,13 +1827,8 @@ fn grid_intrinsic_height( text_metrics: &TextMetrics, depth: usize, ) -> Result { - let columns = integer(node, PropKey::GridColumns) - .unwrap_or(12) - .clamp(1, 64) as usize; let fallback_gap = finite(number(node, PropKey::Gap).unwrap_or(0.0))?; - let column_gap = - finite_non_negative(number(node, PropKey::GridColumnGap).unwrap_or(fallback_gap))?; - let row_gap = finite_non_negative(number(node, PropKey::GridRowGap).unwrap_or(fallback_gap))?; + let (columns, column_gap, row_gap, level) = resolved_grid_plan(node, width, fallback_gap)?; let unit = ((width - column_gap * columns.saturating_sub(1) as f32).max(0.0)) / columns as f32; let mut flow = node_children .iter() @@ -1711,7 +1841,7 @@ fn grid_intrinsic_height( .collect::>(); flow.sort_by_key(|child| { ( - responsive_grid_value(child, width, GridValue::Order, 0), + responsive_grid_value(child, width, GridValue::Order, 0, level), child.index, ) }); @@ -1719,9 +1849,9 @@ fn grid_intrinsic_height( let mut row = 0_usize; let mut cursor = 0_usize; for child in flow { - let span = responsive_grid_value(child, width, GridValue::Span, columns as i64) + let span = responsive_grid_value(child, width, GridValue::Span, columns as i64, level) .clamp(1, columns as i64) as usize; - let offset = responsive_grid_value(child, width, GridValue::Offset, 0) + let offset = responsive_grid_value(child, width, GridValue::Offset, 0, level) .clamp(0, columns.saturating_sub(1) as i64) as usize; if cursor > 0 && cursor + offset + span > columns { row += 1; @@ -2622,6 +2752,7 @@ fn finite(value: f32) -> Result { #[derive(Debug)] pub enum LayoutError { + InvalidGridTemplate, InvalidDimension, DepthExceeded, MissingNode(u64), @@ -2630,6 +2761,7 @@ pub enum LayoutError { impl std::fmt::Display for LayoutError { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { + Self::InvalidGridTemplate => formatter.write_str("invalid responsive grid template"), Self::InvalidDimension => { formatter.write_str("layout dimensions must be finite and non-negative") } @@ -3597,6 +3729,156 @@ mod tests { assert_eq!(layouts[&3].x, 0.0); } + #[test] + fn horizontal_scroll_auto_height_follows_content_and_padding() { + let mut tree = Tree { + root: 1, + nodes: BTreeMap::from([ + (1, node(1, 0, 0, NodeKind::Column, [])), + ( + 2, + node( + 2, + 1, + 0, + NodeKind::Scroll, + [ + (PropKey::ScrollHorizontal, PropValue::Boolean(true)), + (PropKey::PaddingVertical, PropValue::Float(8.0)), + ], + ), + ), + (3, node(3, 2, 0, NodeKind::Row, [])), + ( + 4, + node( + 4, + 3, + 0, + NodeKind::View, + [ + (PropKey::Width, PropValue::Float(400.0)), + (PropKey::Height, PropValue::Float(56.0)), + ], + ), + ), + ( + 5, + node( + 5, + 1, + 1, + NodeKind::View, + [(PropKey::Height, PropValue::Float(24.0))], + ), + ), + ]), + }; + let viewport = Size { + width: 300.0, + height: 500.0, + }; + let layouts = calculate(&tree, viewport).expect("auto horizontal scroll"); + assert_eq!(layouts[&2].height, 72.0); + assert_eq!(layouts[&3].width, 400.0); + assert_eq!(layouts[&3].height, 56.0); + assert_eq!(layouts[&5].y, 72.0); + tree.nodes + .get_mut(&2) + .unwrap() + .properties + .insert(PropKey::Height, PropValue::Float(100.0)); + let layouts = calculate(&tree, viewport).expect("explicit horizontal scroll"); + assert_eq!(layouts[&2].height, 100.0); + assert_eq!(layouts[&5].y, 100.0); + tree.nodes + .get_mut(&2) + .unwrap() + .properties + .remove(&PropKey::Height); + tree.nodes.insert( + 4, + node( + 4, + 3, + 0, + NodeKind::Text, + [ + ( + PropKey::Text, + PropValue::String( + "A long scrolling label that must not wrap to the viewport width" + .to_owned(), + ), + ), + (PropKey::FontSize, PropValue::Float(14.0)), + (PropKey::LineHeight, PropValue::Float(20.0)), + ], + ), + ); + let layouts = calculate(&tree, viewport).expect("natural text width in horizontal scroll"); + assert!(layouts[&3].width > viewport.width); + assert_eq!(layouts[&4].height, 20.0); + assert_eq!(layouts[&2].height, 36.0); + } + + #[test] + fn horizontal_scroll_reserves_widest_grow_share() { + let tree = Tree { + root: 1, + nodes: BTreeMap::from([ + ( + 1, + node( + 1, + 0, + 0, + NodeKind::Scroll, + [(PropKey::ScrollHorizontal, PropValue::Boolean(true))], + ), + ), + (2, node(2, 1, 0, NodeKind::Row, [])), + ( + 3, + node( + 3, + 2, + 0, + NodeKind::View, + [ + (PropKey::Width, PropValue::Float(120.0)), + (PropKey::FlexGrow, PropValue::Float(1.0)), + ], + ), + ), + ( + 4, + node( + 4, + 2, + 1, + NodeKind::View, + [ + (PropKey::Width, PropValue::Float(180.0)), + (PropKey::FlexGrow, PropValue::Float(1.0)), + ], + ), + ), + ]), + }; + let layouts = calculate( + &tree, + Size { + width: 200.0, + height: 56.0, + }, + ) + .expect("equal grow content remains readable"); + assert_eq!(layouts[&2].width, 360.0); + assert_eq!(layouts[&3].width, 180.0); + assert_eq!(layouts[&4].width, 180.0); + } + #[test] fn scroll_content_keeps_its_natural_extent_on_both_axes() { let horizontal = Tree { @@ -4737,6 +5019,274 @@ mod tests { assert_eq!(tablet[&4].y, 0.0); } + #[test] + fn grid_template_reflows_columns_gutters_and_sixth_tier_spans() { + let mut nodes = BTreeMap::new(); + nodes.insert(100, node(100, 0, 0, NodeKind::Column, [])); + nodes.insert( + 1, + node( + 1, + 100, + 0, + NodeKind::Column, + [( + PropKey::GridTemplate, + PropValue::String( + "0,2,10,8;640,3,12,8;768,3,12,8;1024,3,12,8;1280,3,12,8;1536,4,16,8".into(), + ), + )], + ), + ); + for id in 2..=4 { + nodes.insert( + id, + node( + id, + 1, + (id - 2) as u32, + NodeKind::View, + [ + (PropKey::GridSpan, PropValue::Integer(1)), + ( + PropKey::GridSpan2xl, + PropValue::Integer(if id == 2 { 2 } else { 1 }), + ), + (PropKey::Height, PropValue::Float(40.0)), + ], + ), + ); + } + let mut tree = Tree { root: 100, nodes }; + let narrow = calculate( + &tree, + Size { + width: 639.0, + height: 500.0, + }, + ) + .unwrap(); + assert_eq!(narrow[&2].width, 314.5); + assert_eq!(narrow[&4].y, 48.0); + assert_eq!(narrow[&1].height, 88.0); + let wide = calculate( + &tree, + Size { + width: 640.0, + height: 500.0, + }, + ) + .unwrap(); + assert_eq!(wide[&4].y, 0.0); + assert_eq!(wide[&1].height, 40.0); + let sixth = calculate( + &tree, + Size { + width: 1536.0, + height: 500.0, + }, + ) + .unwrap(); + assert_eq!(sixth[&2].width, 760.0); + assert_eq!(sixth[&3].x, 776.0); + assert_eq!(sixth[&4].x, 1164.0); + tree.nodes + .get_mut(&1) + .unwrap() + .properties + .insert(PropKey::GridMinColumnWidth, PropValue::Float(500.0)); + let fitted = calculate( + &tree, + Size { + width: 1536.0, + height: 500.0, + }, + ) + .unwrap(); + assert_eq!(fitted[&4].y, 48.0); + assert_eq!(fitted[&1].height, 88.0); + tree.nodes + .get_mut(&1) + .unwrap() + .properties + .insert(PropKey::GridTemplate, PropValue::String("broken".into())); + assert!(matches!( + calculate( + &tree, + Size { + width: 640.0, + height: 500.0 + } + ), + Err(LayoutError::InvalidGridTemplate) + )); + } + + #[test] + fn minimum_grid_column_width_reflows_columns_and_intrinsic_height_together() { + let mut nodes = BTreeMap::from([ + (1, node(1, 0, 0, NodeKind::Column, [])), + ( + 2, + node( + 2, + 1, + 0, + NodeKind::Column, + [ + (PropKey::GridColumns, PropValue::Integer(4)), + (PropKey::GridMinColumnWidth, PropValue::Float(120.0)), + (PropKey::GridColumnGap, PropValue::Float(8.0)), + (PropKey::GridRowGap, PropValue::Float(8.0)), + ], + ), + ), + ]); + for index in 0..4 { + let id = index + 3; + nodes.insert( + id, + node( + id, + 2, + index as u32, + NodeKind::View, + [ + (PropKey::GridSpan, PropValue::Integer(1)), + (PropKey::Height, PropValue::Float(40.0)), + ], + ), + ); + } + let tree = Tree { root: 1, nodes }; + for (width, columns, height) in [ + (100.0, 1, 184.0), + (247.0, 1, 184.0), + (248.0, 2, 88.0), + (360.0, 2, 88.0), + (600.0, 4, 40.0), + (2000.0, 4, 40.0), + ] { + let frames = calculate( + &tree, + Size { + width, + height: 800.0, + }, + ) + .expect("auto-fit grid"); + let cell_width = (width - (columns - 1) as f32 * 8.0) / columns as f32; + assert_eq!(frames[&2].height, height); + assert_eq!(frames[&3].width, cell_width); + assert!(frames[&6].x + frames[&6].width <= width); + assert_eq!(frames[&6].y, ((4 - 1) / columns) as f32 * 48.0); + } + } + + #[test] + fn grid_rows_stretch_auto_height_and_respect_explicit_alignment_and_limits() { + let tree = Tree { + root: 1, + nodes: BTreeMap::from([ + ( + 1, + node( + 1, + 0, + 0, + NodeKind::Column, + [(PropKey::GridColumns, PropValue::Integer(5))], + ), + ), + ( + 2, + node( + 2, + 1, + 0, + NodeKind::View, + [ + (PropKey::GridSpan, PropValue::Integer(1)), + (PropKey::Height, PropValue::Float(80.0)), + ], + ), + ), + ( + 3, + node( + 3, + 1, + 1, + NodeKind::View, + [ + (PropKey::GridSpan, PropValue::Integer(1)), + (PropKey::MinHeight, PropValue::Float(20.0)), + (PropKey::MarginTop, PropValue::Float(4.0)), + (PropKey::MarginBottom, PropValue::Float(8.0)), + ], + ), + ), + ( + 4, + node( + 4, + 1, + 2, + NodeKind::View, + [ + (PropKey::GridSpan, PropValue::Integer(1)), + (PropKey::Height, PropValue::Float(20.0)), + (PropKey::AlignSelf, PropValue::Integer(2)), + ], + ), + ), + ( + 5, + node( + 5, + 1, + 3, + NodeKind::View, + [ + (PropKey::GridSpan, PropValue::Integer(1)), + (PropKey::MinHeight, PropValue::Float(20.0)), + (PropKey::MaxHeight, PropValue::Float(40.0)), + ], + ), + ), + ( + 6, + node( + 6, + 1, + 4, + NodeKind::View, + [ + (PropKey::GridSpan, PropValue::Integer(1)), + (PropKey::Height, PropValue::Float(20.0)), + (PropKey::AlignSelf, PropValue::Integer(3)), + ], + ), + ), + ]), + }; + let frames = calculate( + &tree, + Size { + width: 500.0, + height: 200.0, + }, + ) + .expect("grid alignment"); + assert_eq!(frames[&2].height, 80.0); + assert_eq!(frames[&3].height, 68.0); + assert_eq!(frames[&3].y, 4.0); + assert_eq!(frames[&4].height, 20.0); + assert_eq!(frames[&4].y, 30.0); + assert_eq!(frames[&5].height, 40.0); + assert_eq!(frames[&6].y, 60.0); + } + #[test] fn virtual_grid_cells_receive_real_column_bounds() { let mut nodes = BTreeMap::new(); diff --git a/crates/pam-native-engine/src/lib.rs b/crates/pam-native-engine/src/lib.rs index 671f52b3..607613c3 100644 --- a/crates/pam-native-engine/src/lib.rs +++ b/crates/pam-native-engine/src/lib.rs @@ -2,6 +2,7 @@ pub mod bridge_v2; mod ffi; pub mod fiber; mod font_metrics; +pub mod grid_template; mod layout; pub mod performance; pub mod reactive; @@ -22,8 +23,9 @@ pub use ffi::{ PamNativeBuffer, PamNativeEngineHandle, PamNativeStats, PamStatus, pam_native_buffer_free, pam_native_engine_commit, pam_native_engine_free, pam_native_engine_last_error, pam_native_engine_new, pam_native_engine_relayout, pam_native_engine_relayout_with_metrics, - pam_native_engine_set_asset_root, pam_native_engine_set_refresh_rate, - pam_native_engine_set_text_scale, pam_native_engine_set_viewport, pam_native_engine_stats, + pam_native_engine_set_asset_root, pam_native_engine_set_native_child_visibility, + pam_native_engine_set_refresh_rate, pam_native_engine_set_text_scale, + pam_native_engine_set_viewport, pam_native_engine_stats, }; #[derive(Debug)] @@ -174,6 +176,39 @@ impl Engine { Ok(output) } + /// Synchronizes a custom host's direct child with engine-owned layout. + /// Declarative commits remain authoritative: a later authored Visible + /// update or full tree replaces this native-owned value normally. + pub fn set_native_child_visibility( + &mut self, + owner: u64, + child: u64, + visible: bool, + ) -> Result, EngineError> { + let current = self.current.as_ref().ok_or(EngineError::PatchWithoutTree)?; + let host = current + .nodes + .get(&owner) + .ok_or(EngineError::UnknownPatchNode(owner))?; + let node = current + .nodes + .get(&child) + .ok_or(EngineError::UnknownPatchNode(child))?; + if host.kind != pam_native_protocol::NodeKind::CustomView || node.parent != owner { + return Err(EngineError::InvalidNativeChild { owner, child }); + } + let patch = Patch { + operations: vec![PatchOperation::Update(PropertyPatch { + id: child, + key: PropKey::Visible, + value: Some(pam_native_protocol::PropValue::Boolean(visible)), + })], + } + .encode() + .map_err(EngineError::Protocol)?; + self.commit(&patch) + } + pub fn commit_into(&mut self, frame: &[u8], output: &mut Vec) -> Result<(), EngineError> { let frame_started = Instant::now(); output.clear(); @@ -555,6 +590,7 @@ pub enum EngineError { InvalidViewport, PatchWithoutTree, UnknownPatchNode(u64), + InvalidNativeChild { owner: u64, child: u64 }, } impl std::fmt::Display for EngineError { @@ -565,6 +601,12 @@ impl std::fmt::Display for EngineError { Self::InvalidViewport => formatter.write_str("viewport must be finite and positive"), Self::PatchWithoutTree => formatter.write_str("patch requires an initial tree"), Self::UnknownPatchNode(id) => write!(formatter, "patch references unknown node {id}"), + Self::InvalidNativeChild { owner, child } => { + write!( + formatter, + "node {child} is not a direct child of custom host {owner}" + ) + } } } } @@ -787,6 +829,11 @@ fn affects_layout(key: PropKey) -> bool { | PropKey::ScrollHorizontal | PropKey::ScrollFillViewport | PropKey::GridColumns + | PropKey::GridMinColumnWidth + | PropKey::GridTemplate + | PropKey::GridSpan2xl + | PropKey::GridOffset2xl + | PropKey::GridOrder2xl | PropKey::GridSpan | PropKey::GridSpanSm | PropKey::GridSpanMd @@ -948,6 +995,48 @@ mod tests { ); } + #[test] + fn native_child_visibility_reflows_and_rejects_unowned_nodes() { + let mut tree = Tree::decode(&frame("A", true)).expect("tree"); + tree.nodes.get_mut(&2).expect("host").kind = NodeKind::CustomView; + let mut engine = Engine::new(); + engine + .commit(&tree.encode().expect("frame")) + .expect("initial"); + let before = engine.layouts.clone(); + assert!(engine.set_native_child_visibility(1, 3, false).is_err()); + assert!(engine.set_native_child_visibility(2, 1, false).is_err()); + assert_eq!(engine.layouts, before); + let hidden = engine + .set_native_child_visibility(2, 3, false) + .expect("hide"); + let mutations = decode_batch(&hidden).expect("batch"); + assert!(mutations.iter().any(|mutation| matches!( + mutation, + Mutation::Update { + id: 3, + key: PropKey::Visible, + .. + } + ))); + assert_ne!( + engine.layouts.get(&4), + before.get(&4), + "sibling must reflow" + ); + engine + .set_native_child_visibility(2, 3, true) + .expect("show"); + assert_eq!(engine.layouts, before); + engine + .set_native_child_visibility(2, 3, false) + .expect("hide again"); + engine + .commit(&tree.encode().expect("authored frame")) + .expect("author override"); + assert_eq!(engine.layouts, before); + } + #[test] fn repeated_identical_frames_do_not_grow_retained_state() { let mut engine = Engine::new(); diff --git a/crates/pam-native-protocol/src/lib.rs b/crates/pam-native-protocol/src/lib.rs index 87e593f8..d34fd8bc 100644 --- a/crates/pam-native-protocol/src/lib.rs +++ b/crates/pam-native-protocol/src/lib.rs @@ -642,6 +642,12 @@ pub enum PropKey { GestureNativeTranslationLimitX = 462, GestureNativeResetOnEnd = 463, InputFormatLocale = 464, + ScrollIndicatorStyle = 465, + GridMinColumnWidth = 466, + GridTemplate = 467, + GridSpan2xl = 468, + GridOffset2xl = 469, + GridOrder2xl = 470, } impl TryFrom for PropKey { @@ -1113,6 +1119,12 @@ impl TryFrom for PropKey { 462 => Ok(Self::GestureNativeTranslationLimitX), 463 => Ok(Self::GestureNativeResetOnEnd), 464 => Ok(Self::InputFormatLocale), + 465 => Ok(Self::ScrollIndicatorStyle), + 466 => Ok(Self::GridMinColumnWidth), + 467 => Ok(Self::GridTemplate), + 468 => Ok(Self::GridSpan2xl), + 469 => Ok(Self::GridOffset2xl), + 470 => Ok(Self::GridOrder2xl), other => Err(ProtocolError::UnknownProperty(other)), } } @@ -1988,10 +2000,10 @@ mod tests { } assert!(NodeKind::try_from(32).is_err()); - for value in 1..=464 { + for value in 1..=470 { assert!(PropKey::try_from(value).is_ok(), "missing property {value}"); } - assert!(PropKey::try_from(465).is_err()); + assert!(PropKey::try_from(471).is_err()); } #[test] diff --git a/docs/accessibility-evidence-e963ef5-2026-09-14.json b/docs/accessibility-evidence-e963ef5-2026-09-14.json new file mode 100644 index 00000000..9b39710f --- /dev/null +++ b/docs/accessibility-evidence-e963ef5-2026-09-14.json @@ -0,0 +1,118 @@ +{ + "schemaVersion": 1, + "resultCode": 1, + "revision": "e963ef5dd2cdbca9ea232a030dfc2e2a1a911776", + "environments": [ + { + "environmentCode": 1, + "platformCode": 1, + "resultCode": 1, + "sourceBytes": 19267, + "sourceSha256": "850679f714dde680880db04bb45ca3d6f0a137e5ce3bc244866384ea188323af", + "checks": [ + { + "checkCode": 1, + "resultCode": 1 + }, + { + "checkCode": 2, + "resultCode": 1 + }, + { + "checkCode": 3, + "resultCode": 1 + }, + { + "checkCode": 4, + "resultCode": 1 + }, + { + "checkCode": 5, + "resultCode": 1 + }, + { + "checkCode": 6, + "resultCode": 1 + }, + { + "checkCode": 7, + "resultCode": 1 + } + ] + }, + { + "environmentCode": 2, + "platformCode": 1, + "resultCode": 1, + "sourceBytes": 20671, + "sourceSha256": "a65870ba5b87ede9552ec9b8798dc6d143a0587b88dbbfdb3986ddddbf526e91", + "checks": [ + { + "checkCode": 1, + "resultCode": 1 + }, + { + "checkCode": 2, + "resultCode": 1 + }, + { + "checkCode": 3, + "resultCode": 1 + }, + { + "checkCode": 4, + "resultCode": 1 + }, + { + "checkCode": 5, + "resultCode": 1 + }, + { + "checkCode": 6, + "resultCode": 1 + }, + { + "checkCode": 7, + "resultCode": 1 + } + ] + }, + { + "environmentCode": 3, + "platformCode": 2, + "resultCode": 1, + "sourceBytes": 47920, + "sourceSha256": "76cc27f6b931df6bff2c55bf30dbe5f22044c60cef8627473c9bd7d1dbf6eda6", + "checks": [ + { + "checkCode": 1, + "resultCode": 1 + }, + { + "checkCode": 2, + "resultCode": 1 + }, + { + "checkCode": 3, + "resultCode": 1 + }, + { + "checkCode": 4, + "resultCode": 1 + }, + { + "checkCode": 5, + "resultCode": 1 + }, + { + "checkCode": 6, + "resultCode": 1 + }, + { + "checkCode": 7, + "resultCode": 1 + } + ] + } + ] +} diff --git a/docs/build-hygiene.md b/docs/build-hygiene.md index f46b33a1..18cba696 100644 --- a/docs/build-hygiene.md +++ b/docs/build-hygiene.md @@ -18,6 +18,12 @@ application data, credentials, screenshots, release evidence or `dist`. A cleanup failure fails a successful build; when the build itself failed, PAM preserves that original error and also reports the cleanup failure. +Gradle dependencies and its distribution are reused across projects from +`$XDG_CACHE_HOME/pam-native/gradle` (or `$HOME/.cache/pam-native/gradle`). +Project build outputs are still removed on exit. Set `PAM_NATIVE_GRADLE_HOME` +or `GRADLE_USER_HOME` when a separate cache is required; Gradle manages the +shared cache's own retention. + Official packages and contributors must follow the same invariant. A workflow that builds native code without an unconditional cleanup step is not eligible to publish a release. Repository workflows use diff --git a/docs/components.md b/docs/components.md index 23a60900..7a8205f7 100644 --- a/docs/components.md +++ b/docs/components.md @@ -424,6 +424,30 @@ sized or growing text keeps normal start alignment unless `text-align` is authored. `text-align` accepts the familiar CSS values `left`, `center`, and `right`, plus the logical aliases `start` and `end`. +### Input candidate implementation notes + +The September 2026 candidate adds the following reusable input behavior. These +notes describe candidate source and scoped tests, not a published release or +complete cross-platform UI approval. + +| Capability | Candidate behavior and remaining verification | +| --- | --- | +| Decimal entry on Android | Accepts negative decimals; Number/Numeric remain digit-only. Native acceptance and showcase lower-bound confirmation passed. | +| Normalized controlled text on Android | Preserves active editing, applies deferred normalization on blur and invalidates it on newer typing. Scoped cursor/isolation tests passed. | +| iOS keyboard and text traits | Applies keyboardType/inputMode, secure entry, autocorrection and capitalization. UIKit mapping/reset tests passed. | +| iOS read-only | Blocks mutation without disabling the field. UIKit regression passed; real selection/copy gestures remain to verify. | +| iOS maximum length | Bounds paste without splitting Unicode characters and applies the limit after composition. UIKit regression passed; real multilingual IME sessions remain to verify. | +| iOS autofill and action key | Maps standard hints and platform key glyphs. UIKit mapping tests passed; OS suggestions and complete form flows remain to verify. | +| iOS submit lifecycle | Current-text payload and Submit/BlurAndSubmit fixes are in candidate validation. Do not treat a key-label test as evidence of event delivery. | + +The iOS single-line implementation does not provide full multiline editing. +`Newline` must not silently become Submit; a multiline native editor still needs +its own implementation and interaction validation. Platform autofill hints are +requests to the operating system, not guarantees that suggestions will appear. + +See [input runtime evidence](input-runtime-fixes-2026-09-14.md) for revisions and +the exact scope of the existing checks. + `Input keyboardType` accepts the concise PAM values `text`, `email`, `number`, `phone`, `decimal`, and `url`. React Native-compatible aliases are also valid: `default`, `email-address`, `number-pad`, `numeric`, `phone-pad`, @@ -1040,7 +1064,7 @@ pam make:component MetricCard These commands generate `src/Screens/Orders.pam` and `src/Components/MetricCard.pam` without overwriting existing files. Legacy `.pam.php` components remain supported throughout the 1.x compatibility line. -`pam init --template mobile` still starts with the explicit PHP tree so the +`pam init community-app --template native` still starts with the explicit PHP tree so the lowest-level model is always visible and available. ## Switch sizing diff --git a/docs/dynamic-style-validation-2026-09-14.md b/docs/dynamic-style-validation-2026-09-14.md new file mode 100644 index 00000000..e699cb42 --- /dev/null +++ b/docs/dynamic-style-validation-2026-09-14.md @@ -0,0 +1,31 @@ +# Dynamic style validation — 2026-09-14 + +This change belongs to PAM Native: UI consumers share the same compiled style +environment and must not each implement their own dimension validation. + +Container width/height used by CSS expressions now require finite nonnegative +numbers. Numeric strings remain accepted for compatibility. Arrays, objects, +booleans, negative values, infinity and NaN fail with InvalidArgumentException +instead of being cast into misleading geometry. Numeric font-scale/root-font-size +overrides receive the same bound check. Nonfinite numeric environment overrides +are rejected before expression evaluation. + +The media-cache attribute map and recursively checked style metadata now have +explicit iterable annotations. An unreachable array-key type check was removed: +PHP array keys already can only be integers or strings. No suppression or baseline +was added to hide diagnostics. + +The SDK suite passed, including new public template-rendering regressions for +horizontal/vertical percentage math, numeric-string compatibility, seven invalid +dimension values on both axes, and nonfinite font/inset overrides. + +PHPStan level 9 completed and still fails on the wider TemplateRenderer file: +83 diagnostics remain, down from the previously recorded 89. None are in the +changed dimension-validation/media-cache/metadata functions. Full diagnostic JSON +is `/tmp/pam-template-static-20260914.json`. Remaining groups include typed cascade +selectors, reactive style metadata, responsive merge inputs, keyframes and font +metadata. These are open work, not a passing release gate. + +No Android rebuild was made for this internal validation batch, and no device, +performance, iOS or publication approval is claimed. The last installed showcase +does not yet contain this change. diff --git a/docs/grid-auto-fit-2026-09-14.md b/docs/grid-auto-fit-2026-09-14.md new file mode 100644 index 00000000..c2930451 --- /dev/null +++ b/docs/grid-auto-fit-2026-09-14.md @@ -0,0 +1,32 @@ +# Minimum-width native grids + +`Style(gridColumns: 4, gridMinColumnWidth: 120.0, gridColumnGap: 12.0)` +requests at most four columns. The shared Rust engine resolves the number that +fits the grid's inner width, including gutters. Below the minimum, one column +uses the available width instead of forcing horizontal overflow. Zero or an +omitted minimum preserves the existing fixed column count. Values are logical +layout units; the minimum does not automatically scale with system font size. + +Use `gridSpan: 1` for individual cells. Existing explicit spans are clamped to +the resolved column count. Text measurement, row placement and intrinsic grid +height all use the same resolved count; no Android-host post-layout resize is +needed. Changes to the property are classified as layout-affecting mutations. + +PHP templates also accept `gridMinColumnWidth="120"` on Grid. Protocol property +466 is appended consistently in PHP, Rust, Kotlin and Swift. This capability +requires a runtime including that property; it is not yet a published release. + +Validation: + +- All 79 engine tests passed. Auto-fit regression covers widths 100, 247, 248, + 360, 600 and 2000, verifying the fitting threshold, maximum column count, + cell width, last-cell placement and intrinsic grid height together. +- All 12 protocol tests and cross-language protocol parity passed. +- PHP SDK suite passed, including templates and typed Style mapping. +- Android unit suite passed; arm64 candidate built and installed on Samsung. +- UI's auto-fit audit confirms 2x2 layout on Samsung at font scale 1.1. Evidence + and SHA are in the matching PAM Native UI collections batch. + +Remaining gates include device resize/orientation, larger font scales, RTL and +iOS execution. Protocol parity is not an iOS runtime test. Explicit breakpoint +column/gutter migration is separate and is not completed by this capability. diff --git a/docs/grid-row-alignment-2026-09-14.md b/docs/grid-row-alignment-2026-09-14.md new file mode 100644 index 00000000..10904a71 --- /dev/null +++ b/docs/grid-row-alignment-2026-09-14.md @@ -0,0 +1,14 @@ +# Shared grid row alignment + +The UI integration of existing GridColumns exposed a native layout omission: +cells always retained intrinsic height, ignoring row alignment. Automatic-height +cells now stretch by default to their row height after subtracting vertical margins. +Explicit heights remain explicit; MaxHeight constrains stretching. AlignSelf +overrides AlignItems, including center and end positioning within the row. + +Regression: `grid_rows_stretch_auto_height_and_respect_explicit_alignment_and_limits` +covers explicit 80px height, stretched height with asymmetric margins, centered +20px height, maximum 40px height and end alignment. `cargo test -p pam-native-engine +--offline`: 78 passed, including all four grid tests. This is shared Rust layout, +not platform-specific UI compensation. Android candidate validation is recorded in +PAM Native UI's collections batch; iOS/device-wide approval remains outstanding. diff --git a/docs/inactive-input-leading-text-2026-09-14.md b/docs/inactive-input-leading-text-2026-09-14.md new file mode 100644 index 00000000..eb4d9be9 --- /dev/null +++ b/docs/inactive-input-leading-text-2026-09-14.md @@ -0,0 +1,31 @@ +# Inactive input leading text + +Android's shared input value application previously moved every changed value's +selection to its end, even while the field was inactive. The UI Search Bar's long +query specimen consequently showed its trailing text before interaction. + +The renderer now defaults inactive value changes to selection zero, retaining +the trailing selection for focused editing. Explicit selection start/end take +precedence and are clamped to the new text length. No UI-specific cursor reset or +new native control was introduced. + +The instrumented regression covers inactive replacement, focused replacement and +an explicit selection range followed by a value change. It does not itself prove +final post-layout scroll position, RTL behavior or iOS parity. A showcase build +and visual verification of the initial long query remain required. + +The targeted API 36 instrumented test passed: +`PamRendererInstrumentedTest#inactiveInputStartsAtLeadingTextAndHonorsControlledSelection`. +Its build/test run completed in 10 seconds. No iOS execution or release approval +is implied by this Android result. + +## Showcase verification + +API 36 optimized showcase APK +`cc1dd39816dd6a20823e398fcca73ff01cd3b13b2d60f290a47a91acc268421e` +includes this change. `/tmp/pam-leading-query-20260914/report.json` records the +Search Bar editing check passing. Its raw `search-long.png` was viewed: the +inactive long query now displays the beginning (`Search invoices, customers...`) +instead of the trailing text. This verifies the post-layout presentation for the +LTR search specimen, not every input or RTL. The build completed in 25 seconds +and cleaned 96.7 MiB of regenerable development artifacts. No publication. diff --git a/docs/indicator-frame-readiness-2026-09-13.md b/docs/indicator-frame-readiness-2026-09-13.md new file mode 100644 index 00000000..dfc722b3 --- /dev/null +++ b/docs/indicator-frame-readiness-2026-09-13.md @@ -0,0 +1,29 @@ +# Persistent horizontal indicator: frame readiness + +CI run 34790637153 at 69ee5cc failed the initial child-color assertion on +Android APIs 26 and 36. Inspection of the API 26 artifact confirms that the +initial window is blank white, whereas the subsequent shown frame contains +the gray child and dark indicator. UI-thread idle alone did not prove the +newly revealed scene had been presented. + +The test now captures for at most two seconds until the gray **content** pixel +appears, without calling draw, toggling the indicator, or waiting for indicator +pixels. If content never appears, the last capture is retained and the original +assertion still fails. All initial-indicator presence and contrast assertions +remain unchanged. Discarded bitmaps are recycled. + +Validation: offline compileDebugAndroidTestKotlin passed; the single +rendererKeepsPersistentHorizontalIndicatorVisibleBelowContent instrumentation +case passed on local emulator API 36 (one test, nine-second Gradle invocation). +This is a test synchronization change, not a production renderer correction. +Remote API 26/36 verification remains required; no CI success is claimed yet. + +## Remote confirmation + +Run https://github.com/push-in/pam-native/actions/runs/34795404445 subsequently +passed at `46bfe50645d149345df30796793346be824b7dfc`: Android API 26 and API 36 +renderer contracts, the API 36 macrobenchmark stage, Swift/UIKit, Rust/PHP, +Android build/unit contracts and cross-platform accessibility evidence. This +confirms the synchronization fix on both CI API levels without removing the +initial-indicator or contrast assertions. It does not approve every PAM Native UI +specimen or its visual documentation; those have separate release gates. diff --git a/docs/input-runtime-fixes-2026-09-14.md b/docs/input-runtime-fixes-2026-09-14.md new file mode 100644 index 00000000..5bf12a81 --- /dev/null +++ b/docs/input-runtime-fixes-2026-09-14.md @@ -0,0 +1,90 @@ +# Input runtime fixes: ownership and evidence + +These are reusable PAM Native fixes, not UI-owned native implementations. +Numeric bounds/step/precision remain in PAM Native UI. + +- `defacc0`: Android retains normalized authored text while focused and applies + it on blur. Newer typing invalidates the deferred value. The targeted renderer + test passed on API 36: cursor preservation, normalization, sibling isolation + and newer-typing protection. Showcase checks passed for `731 -> 20` and + `3.6 -> 4` with step 1. +- `91d515d`: Android Decimal keyboard/input mode accepts signed numbers; Number + and Numeric stay digit-only. Targeted instrumentation passed for `-7.5` versus + digit-only `-75 -> 75`. The rebuilt showcase passed `-7 -> 0` on blur with an + unchanged sibling, APK `98844101c40ae6e0eba8f49439ce5b61f5a6348ec83df44b0ac8c87203d34ab9`. +- `4882875`: UIKit applies keyboardType and inputMode, including mode priority + and resetting properties. CI 34851408115 compiled Swift but failed two + assertions in keyboard suppression/reset: optional `.none` was interpreted + as absence, not the explicit enum case. No iOS approval for this revision. +- `820b42d`: fixes that ambiguity with `PamInputModeKind.none`, applies secure + text entry while preserving selection, and maps autocorrection/capitalization + with reset behavior. Additional UIKit regressions are authored; awaiting CI. + +Local evidence: `/tmp/pam-number-confirmation-fixed-20260914`, +`/tmp/pam-number-rounding-fixed-20260914`, and +`/tmp/pam-number-signed-minimum-fixed-20260914`. These are scoped interaction +results, not complete component approval, physical Samsung evidence or release +media. No public package release has been made for this batch. + +Follow-up UIKit maxLength handling is authored locally: apply/reset property, +bounded paste without splitting grapheme clusters, rejection when no insertion +fits, and post-composition/end-editing length enforcement. Delegate checks defer +while marked text is active. Tests cover paste, full-capacity insertion, +deletion, Unicode boundaries, unrestricted reset and post-unmark normalization. +These tests have not run yet and do not simulate a real multilingual IME session. + +UIKit read-only follow-up: inputEditable now blocks delegate edits, direct +insertion/deletion and marked-text composition, while keeping the field enabled +for selection. Its keyboard is suppressed without losing keyboardType/inputMode +configuration; resetting editable restores editing. Programmatic renderer values +remain allowed. The new regression checks these mutation paths and property reset; +actual copy/selection gestures still need simulator/device interaction evidence. + +CI 34852153837 at `820b42d` passed its Swift/UIKit job, including the keyboard +suppression correction, secure-toggle selection retention and text traits tests. +This does not cover the subsequent maxLength or read-only changes. + +UIKit input completion follow-up maps existing autoComplete hints (including +password/new-password/one-time-code) to UITextContentType and existing return +key values to platform glyphs. UIKit has no previous/none glyph equivalent; +those use its default key. This mapping does not implement previous-field focus +navigation or guarantee that the OS offers an autofill suggestion. Tests cover +OTP/Done, switching to new-password/Search, and clearing both properties. +The maxLength Swift/UIKit job passed in run 34852470963 at `f8d2551`; actual +multilingual IME interaction remains distinct from that unit coverage. + +Submit payload follow-up: EventBridge owns a separate weak submitField reference +instead of reusing the input-callback owner. It reads the current editor text +and clears the reference on detach, without clearing another bridge's input +callbacks. The added test requires one current-value payload and no further +submission after removing onSubmit. Pending macOS execution. + +Submit lifecycle follow-up now dispatches one primary action explicitly, keeps +focus for Submit, and resigns for BlurAndSubmit. End-editing is emitted only +through the actual end-editing callback rather than on every Return. Read-only +Return is ignored. Newline does not become Submit; actual multiline editing is +not implemented by this single-line UITextField change and remains a separate +platform gap. The added unit regression covers single dispatch, no false end +event and blocked/read-only/newline submission, pending macOS execution. + +Controlled selection follow-up applies InputSelectionStart/End to UIKit, +clamps offsets to current UTF-16 length and reapplies after value/secure changes. +Initial mounting reapplies after all properties, avoiding dictionary-order +dependence. Removing the start releases explicit selection control. Regression +covers initial range, secure toggle, shortened value, end removal, negative +offset and subsequent unmanaged cursor. Pending CI. + +CI 34853751298 compiled but the submit-payload test failed because SwiftPM's +hostless XCTest process had no UIApplicationMain for target/action dispatch. +The revised unit test invokes the selectors registered for primaryActionTriggered +and still verifies payload and target removal. This verifies registration and +callback implementation, not actual keyboard Return delivery; hosted interaction +coverage remains necessary and the test is not presented as its replacement. + +Autofocus follow-up queues a weak field request after mounting, checks visible +interactive ancestors and consumes successful focus once. Relayout does not +steal focus back after the user leaves. Disabling autofocus cancels a pending +request; keyboard suppression also respects InputShowSoftInputOnFocus without +disabling selection. The simulator regression checks deferred mount focus and +no refocus after relayout; it is authored but not yet executed. Conditional +ancestor reveal and real keyboard presentation remain interaction checks. diff --git a/docs/input-selection-preservation-2026-09-14.md b/docs/input-selection-preservation-2026-09-14.md new file mode 100644 index 00000000..d7ee664a --- /dev/null +++ b/docs/input-selection-preservation-2026-09-14.md @@ -0,0 +1,21 @@ +# Preserve selection while reconfiguring Android inputs + +`applyInputConfiguration` assigned inputType/transformation without retaining the +current selection unless explicit InputSelectionStart/End properties existed. +Showing/hiding a password therefore kept the value but moved its cursor to zero. +The consolidated Samsung form review exposed this in the screenshot after hiding. + +The renderer now snapshots the native selection before configuration and restores +it afterwards, clamped to the current text. Explicit selection properties still +take priority. This belongs to the reusable native input, not a UI-only toggle fix. + +`passwordVisibilityPreservesCursorAndSelectionUnlessExplicitlyControlled` passed +on API 36. It checks repeated secure transitions at a cursor position and over a +selected range, retained text, and an explicit controlled selection override. +The Android unit suite also passed. + +Samsung's strengthened password audit adds a character after revealing and another +after hiding, without moving the cursor. A final reveal must expose the original +value plus both trailing characters. That scoped test passed; batch evidence and +APK SHA are recorded in PAM Native UI's forms audit. Broader IME composition, +reverse selections and iOS behavior are not approved by this regression. diff --git a/docs/modal-window-viewport-2026-09-14.md b/docs/modal-window-viewport-2026-09-14.md new file mode 100644 index 00000000..aac2530c --- /dev/null +++ b/docs/modal-window-viewport-2026-09-14.md @@ -0,0 +1,35 @@ +# Full-window modal viewport correction + +PAM Native's renderer now retains MATCH_PARENT dimensions and zero engine margins +for direct content hosted by a full-window modal. Previously a subsequent engine +frame could replace the dialog-owned layout with the activity's fixed height. +When Android resized the dialog for the IME, the child stayed taller than its +window, placing bottom-aligned content below the visible area. + +Ownership is restricted by PamModalHost's current presentation. Centered dialogs +and native sheets retain their existing intrinsic/snap sizing. No UI-specific +command or selection behavior has been added to PAM Native. + +Evidence: + +- Samsung SM-G973F, showcase APK + `e9583f894901d0432135d2141e4b81e143b5c77adeebf377de6df897f4248b06`: + keyboard focus, filtering, command selection and dismissal pass in + `/tmp/pam-ui-command-native-viewport-20260914/report.json`. +- API 36 emulator: all five PamModalHostInstrumentedTest cases pass, including + presentation changes 1 → 2 → 3 → 1, centered dialog dimensions, native sheet + dimensions, dismissal policy and backdrop bounds. +- Diagnostic geometry before the fix: child height 2280px, parent height 1177px, + content top 1513px. Temporary logging was removed after diagnosis. + +The renderer regression +`fullWindowModalChildIgnoresStaleActivityFrameAfterViewportResize` also passes on +API 36: a real materialized child retains MATCH_PARENT and zero top offset through +1930 → 1177 → 1930px viewport changes while the old activity frame is reapplied. +It measures and lays out the actual dialog content parent, rather than merely +testing a sizing helper. The first fixture used a layout-only View that was +eliminated by optimization; adding a background materialized the test child. + +These are scoped checks, not complete cross-platform modal approval. Rotation +and other IME configurations still need coverage before release. No iOS change +or validation is claimed. diff --git a/docs/native-child-visibility-2026-09-14.md b/docs/native-child-visibility-2026-09-14.md new file mode 100644 index 00000000..2ca4c5c0 --- /dev/null +++ b/docs/native-child-visibility-2026-09-14.md @@ -0,0 +1,65 @@ +# Native-owned child visibility synchronization + +Status: engine, C ABI and Android integration implemented and tested; UIKit +integration authored locally, pending macOS compilation/tests. Not published. +PAM Native UI's original uncontrolled Treeview reproducer hid descendants +on Android but leaves its 987px engine layout unchanged. Platform-only GONE does +not change the retained declarative tree. + +`Engine::set_native_child_visibility(owner, child, visible)` updates Visible +through the existing property-patch path. It accepts only direct children of a +CustomView owner. Unknown nodes, non-custom owners and unrelated nodes are +rejected before mutation. This keeps component expansion policy in UI while +sharing layout synchronization in PAM Native. + +The focused Rust regression passed offline with the existing lockfile: hide +reflows a sibling, show restores the original layouts, invalid ownership leaves +layouts unchanged, and a subsequent authored full tree restores authority. +Command: `cargo test -p pam-native-engine --lib native_child_visibility --offline --locked`. + +The C header/export now exposes `pam_native_engine_set_native_child_visibility`. +It accepts only 0/1 visibility, validates output and handle pointers, and returns +a standard releasable mutation buffer. The additional ABI regression passed, +including invalid handle/flag/ownership, empty error output, and valid decoded +hide/show batches with released leases. Both targeted tests pass offline. + +Remaining implementation: Android runtime/JNI and UIKit runtime; bind a host-scoped child visibility +callback to registered custom views; call it from UI expansion state changes; +verify lifecycle teardown, absent/removed targets, repeated changes, nested +folders and controlled/uncontrolled transitions. Platform callbacks must not +enter an in-progress engine transaction recursively. Do not claim the Treeview +defect fixed until the native-only device geometry roundtrip passes. + +Android runtime/JNI wiring is now authored locally (not yet compiled): optional +`NativeChildVisibilityHost` receives a callback from PamRenderer. Requests are +posted to the UI queue, then revalidated against the same owner view and its +direct declarative children. Removed/replaced hosts are ignored. PamRuntime +uses its existing handle lock; JNI uses the engine mutex and publishes standard +mutation batches. Removal and shutdown clear callbacks. UIKit and UI usage are +still absent. Compile/instrument this integration before treating it as delivered. + +Android integration has now compiled with the UI consumer and installed on +emulator-5554: engine optimized build 8.74s, Android build 20s, automatic cleanup +96.8 MiB. All 85 engine library tests passed offline and the UI PHP matrix passed. +UI FileTreeFolder invokes the optional callback for internal expansion only; +controlledExpansion remains driven by authored PHP visibility. +The previously failing uncontrolled geometry roundtrip is running with output +`/tmp/pam-tree-native-layout-fixed-20260914`. No device result yet. UIKit bridge +and consumer remain unimplemented, and this combined source is not released. + +The uncontrolled Android roundtrip completed successfully: 987px expanded -> +357px collapsed -> 987px restored, descendants returned and the sibling instance +kept its selection. APK SHA-256: +`f2b057e675f814d716aee1496f5c945a0a5bb0c36e6fe5a0cd107ab6bb0c5468`. +The same test previously failed with unchanged 987px height. This proves the +normal-font Android reproduction is fixed, not all variants or UIKit behavior. + +UIKit now has the same optional `NativeChildVisibilityHost` capability, queued +renderer validation against the original host and its direct child, lifecycle +callback cleanup, runtime entry point and C bridge to the shared engine. The UI +consumer requests layout only when its content identity or expansion changes; +controlled expansion stays PHP-owned. Both simulator test-runtime shims expose +the new symbol. The UI regression checks hide/show requests, no extra request +on selection, controlled suppression and callback release. These Swift changes +are not yet compiled or executed: this Linux host has no Swift/UIKit toolchain. +Android passing evidence above is retained, not rerun for iOS-only edits. diff --git a/docs/native-ui-candidate-rust-validation-2026-09-14.md b/docs/native-ui-candidate-rust-validation-2026-09-14.md new file mode 100644 index 00000000..beb5da9f --- /dev/null +++ b/docs/native-ui-candidate-rust-validation-2026-09-14.md @@ -0,0 +1,67 @@ +# Native UI candidate — Rust validation + +## Corrected 1.0.28 candidate: complete CI pass + +[Run 34838564521](https://github.com/push-in/pam-native/actions/runs/34838564521) +completed successfully on exact revision +`e963ef5dd2cdbca9ea232a030dfc2e2a1a911776`. All six jobs passed: Rust/PHP/protocol, +Android build/unit contracts, Swift/UIKit (69 tests, zero failures), Android API +26 runtime contracts, Android API 36 runtime contracts and cross-platform +accessibility evidence. The earlier formatting failure below is historical and +does not remain open for this revision. + +The downloaded accessibility report from artifact `10345169624` was inspected +and retained as [revision-specific evidence](accessibility-evidence-e963ef5-2026-09-14.json). +It identifies this exact revision and seven successful automated checks in each +of three environments, preserving the source-result byte counts and digests. +This is not manual TalkBack/VoiceOver approval of all UI components. Core +performance/fuzz checks passed in the Rust job; device gesture/frame budgets and +the UI package's full visual release gate remain separate requirements. + +No package/tag publication was performed. UI can use this verified Native +candidate while its own later UI changes complete verification. + +## Remote platform results + +Run https://github.com/push-in/pam-native/actions/runs/34831107281, SHA +`8e8c3524f63fae19c87630547d67942e43019d7e`: + +- Swift and UIKit contracts, job 103934418750: success. The downloaded log + explicitly reports 69 iOS Simulator tests, zero failures and TEST SUCCEEDED. +- Android renderer build and unit contracts, job 103934419065: success. +- Android API 26/36 instrumented jobs 103935521825 and 103935521831: success. + API 36 also completed the four-test generated-showcase macrobenchmark run. + Completion does not by itself establish physical-device frame-time budgets. +- Cross-platform accessibility evidence, job 103939984190: success. Its report + records revision `8e8c352`, seven required checks for each of Android API 26, + Android API 36 and UIKit. The checker matches actual test results for semantic + role/state/value, custom actions, reduced motion, font scaling, semantic text + colors, minimum touch targets and keyboard focus. This is automated native + contract evidence, not a manual TalkBack/VoiceOver audit of all UI components. + Artifact: https://github.com/push-in/pam-native/actions/runs/34831107281/artifacts/10343490301 +- Rust/PHP contracts failed at formatting, as described below. Local success + on the corrected SHA does not make that remote job green retroactively. + +This supersedes the earlier observation that both platform jobs were running. +It validates Native's executed contract suite, not all UI component interactions, +visual design, accessibility or frame-time budgets. UI has a separate candidate +Verify runs; their results must be tracked separately from this SDK run. + +Candidate `2e35530` applies rustfmt to responsive-grid code/tests in layout.rs. +It follows `8e8c352`, whose CI run 34831107281 stopped the Rust/PHP job at the +formatting check before executing Rust tests. Android and Swift/UIKit jobs +were still running at the last observation; this file does not approve them. + +Local checks on 2026-09-14, using the existing dependency cache: + +- `cargo fmt --all -- --check`: pass. +- `cargo clippy --locked --offline --all-targets -- -D warnings`: pass. +- `cargo test --locked --offline --all-targets`: pass, 39 CLI tests, + 83 engine tests and 12 protocol tests (134 total). + +Tests include responsive grid alignment/reflow, intrinsic text measurement, +font scaling, virtualized layout and bounded binary protocol behavior. They +do not establish device visual quality, iOS runtime correctness or release +approval. No package installation or lockfile change was needed. The original +remote run still targets the pre-format commit and must not be described as +having tested this corrected SHA. diff --git a/docs/overlay-collision-resolution-2026-09-14.md b/docs/overlay-collision-resolution-2026-09-14.md new file mode 100644 index 00000000..6a24eca0 --- /dev/null +++ b/docs/overlay-collision-resolution-2026-09-14.md @@ -0,0 +1,26 @@ +# Reusable Android overlay collision resolution + +The Android plugin API now exposes OverlayBounds, OverlayPlacement and +OverlayCollisionResolver. Hosts provide ordered candidate origins, measured +content dimensions, viewport bounds and anchor bounds. The resolver compares +anchor intersection after viewport clamping, then original viewport overflow; +equal candidates preserve caller preference. It returns the chosen candidate +index, leaving rendering and placement policy to the host. + +This addresses the case where neither lateral placement fits: simply clamping +the less-overflowing side can cover the trigger. A host may now supply vertical +alternatives and select one that does not cover it. PAM Native UI integrates the +resolver only when flipping is enabled, trigger overlap is not requested, and +placement is not Center. The UI still owns visual tokens and candidate ordering. + +Four targeted JVM regressions pass: lateral fallback, preferred fitting origin, +bottom-edge fallback and rejection of an empty candidate list. The JUnit report +records four tests, zero skipped/failures/errors (0.018 seconds test time): +`android/app/build/test-results/testDebugUnitTest/TEST-dev.pam.nativeapp.render.OverlayCollisionResolverTest.xml`. +The Gradle invocation took 18 seconds. This is not a full SDK/platform approval. + +The UI integration requires this plugin API source/version; publish the native +capability before any UI release that depends on it, and only after release +gates. No iOS implementation or platform parity is claimed by this Android API. +Device evidence for the UI consumer is tracked in the sibling UI repository's +`docs/anchored-overlays-batch-2026-09-14.md`. diff --git a/docs/reactive-style-metadata-2026-09-14.md b/docs/reactive-style-metadata-2026-09-14.md new file mode 100644 index 00000000..8116dd59 --- /dev/null +++ b/docs/reactive-style-metadata-2026-09-14.md @@ -0,0 +1,26 @@ +# Reactive style metadata — 2026-09-14 + +PAM Native now validates reactive variable maps, cascade containers and declaration +containers before using them. Updates preserve declaration metadata while changing +only the compiled value. Scoped font data reuses the existing font-face validator, +so malformed families/faces do not reach font selection as unchecked arrays. + +The fallback reactive cache identity previously depended only on variable rules, +and an empty fingerprint was accepted as an identity. Two sheets with the same +rules but different base variables could therefore share a cached result. Empty +or missing fingerprints now use the complete sheet content as the fallback key. +Normal nonempty compiler fingerprints keep their existing fast path. + +SDK tests passed, including existing public reactive-style rendering plus new +internal regressions for valid font faces, malformed font metadata, malformed +variable/rule maps, and separation of sheets with different base variables and +empty fingerprints. StyleVariables is restored after the test cases. + +The first level-9 analysis completed with 74 remaining TemplateRenderer +diagnostics (previous batch: 83), none inside reactiveStyleSheet or styleSheetFonts. +The wider file is not statically approved. The final font implementation reuses +validatedFontFaces instead of duplicating its checks; SDK tests passed again. +Diagnostic artifact: `/tmp/pam-template-metadata-static-20260914.json`. + +This is native SDK work, not another UI host implementation. No Android build, +iOS execution, performance claim or publication is included in this batch. diff --git a/docs/releasing.md b/docs/releasing.md index 5a0675d3..9ec7d661 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -4,7 +4,7 @@ No release may be published merely because source tests and package builds are green. The release graph must create a project in a new temporary directory -through `pam init --template mobile`, allow the generated Composer project and +through `pam init community-app --template native`, allow the generated Composer project and native toolchain to install their own declared dependencies, execute `pam dev`, launch on an Android emulator, inspect process logs for runtime/plugin failures, and capture a valid PNG. The same contract covers `native-ui`; PAM owns the diff --git a/docs/responsive-grid-template-contract.md b/docs/responsive-grid-template-contract.md new file mode 100644 index 00000000..ba4656ad --- /dev/null +++ b/docs/responsive-grid-template-contract.md @@ -0,0 +1,96 @@ +# Responsive grid template contract — integration in progress + +`grid_template::GridTemplate` validates a bounded container-relative plan in the +shared Rust engine. It is independent of PAM Native UI. Each level contains +minimum width, maximum columns, column gap and row gap. The compact transport +representation is `minimum,columns,columnGap,rowGap;...`. + +Rules: one to six levels, initial threshold zero, strictly increasing thresholds, +1–64 columns, finite nonnegative dimensions and at most 1024 bytes. Resolution +uses the highest threshold not exceeding the container width and returns both +the plan and level index. This index can drive spans/offsets/orders consistently. +Negative/nonfinite container widths are rejected. No dependency was added. + +Tests prove exact boundaries and values immediately below each of the UI's +640/768/1024/1280/1536 thresholds, independent gutters, compatibility with native +600/840/1200/1600 plans, and rejection of invalid/oversized plans. All 82 engine +tests passed. This is a validated contract, not yet a wired rendering feature. + +Remaining integration must be completed before public release: + +1. Append a protocol property for the template without renumbering existing IDs; + keep PHP/Rust/Android/UIKit parity and add sixth-tier span/offset/order keys. +2. Provide typed SDK construction/serialization and template attribute mapping. +3. Resolve one plan for both grid intrinsic measurement and final layout, including + auto-fit maximum columns, gutters, and child breakpoint properties. Existing + grids without a template retain their current native thresholds. +4. Map UI responsive columns/gutters/spans to the shared plan, preserving all six + existing UI levels. Remove host fallback only where equivalent behavior is + implemented; reversed/column-direction behavior still requires explicit work. +5. Exercise resize, wrapped text, spans, offsets/order, large fonts and RTL in the + engine and Android showcase. Do not mark this parser as responsive UI approval. + +## Protocol and layout integration + +Appended IDs 467–470 are GridTemplate, GridSpan2xl, GridOffset2xl and GridOrder2xl +in PHP, Rust, Android and UIKit. They invalidate layout when changed. No previous +ID was renumbered. Android treats them as engine-owned geometry properties. + +The shared engine now activates grid layout from GridTemplate alone, rejects +malformed template values, and uses the same resolved plan for intrinsic height +and final placement. Auto-fit is bounded by that level's column count. Child +span/offset/order resolution receives the template level (including 2xl), while +grids without templates retain their previous native breakpoint behavior. + +83 engine tests, 12 protocol tests, protocol parity and Android unit tests passed. +The added integrated test covers the 639→640 boundary, six-level span resolution +at 1536, auto-fit changing intrinsic height, and invalid templates. Its initial +root-height assertion was corrected to use a nested auto-height grid: the screen +root correctly fills the viewport and cannot prove intrinsic content height. + +SDK typed construction, template attributes, UI migration and device visual checks +remain pending. UIKit constants are synchronized but iOS execution is unverified. +No publication or full responsive-grid approval is claimed. + +Later checkpoint: the TemplateRenderer static-analysis debt described below was +closed by the subsequent validation batches. See +[the file-level closure report](template-renderer-static-closure-2026-09-14.md). +Device, iOS and full release gates remain separate and incomplete. + +## SDK and UI integration — 2026-09-14 + +The SDK now provides immutable `GridBreakpoint` and `GridTemplate` values. +`Style(gridTemplate: ...)` serializes them; native templates accept `gridTemplate`, +`span2xl`, `offset2xl` and `order2xl`. Threshold ordering is checked at float32 +precision, so two distinct PHP doubles cannot collapse into invalid equal engine +thresholds. Kotlin names for the digit-bearing properties match SDK enum-name +normalization. Existing Style positional arguments remain unchanged. + +```php +$plan = new \Pam\Native\GridTemplate( + new \Pam\Native\GridBreakpoint(0, 2, 8, 12), + new \Pam\Native\GridBreakpoint(768, 3, 16, 12), +); +$style = new \Pam\Native\Style(gridTemplate: $plan, gridMinColumnWidth: 120); +``` + +UI row grids now map responsive columns, independent gutters and all six child +span levels into this contract. Responsive auto-fit is supported. Fixed grids +keep the existing compact properties; non-row directions retain their host path. +Items 1–4 above are implemented for row direction, not all direction variants. + +SDK tests, protocol parity, 83 engine tests and 12 protocol tests passed. The +Android release-mode candidate built and ran on API 36. Narrow-phone responsive +2×2 labels were checked after scrolling, with raw screenshot inspection: +`/tmp/pam-grid-template-20260914/report.json`, candidate SHA-256 +`9078f1ea97dc2613882b3e20c47496f7168e468a7693b638453408935f4551ac`. +Samsung was authentication-locked; no unlock was attempted. + +Static analysis of the wider TemplateRenderer file reports 89 diagnostics outside +the grid patch. This is a release-gate debt, not a clean-file approval. The new +GridTemplate redundant array conversion identified in the same run was removed. +The focused level-9 run then passed for GridBreakpoint, GridTemplate, Style, +UI ComponentRenderer, the material matrix and the showcase route. TemplateRenderer +was not included in that clean run and its wider debt remains open. +Resize, RTL, large text, sixth-tier offsets/order on device and iOS execution +remain unverified. Nothing was published. diff --git a/docs/responsive-style-precedence-2026-09-14.md b/docs/responsive-style-precedence-2026-09-14.md new file mode 100644 index 00000000..207cfe60 --- /dev/null +++ b/docs/responsive-style-precedence-2026-09-14.md @@ -0,0 +1,26 @@ +# Responsive style precedence — 2026-09-14 + +PAM Native's responsive sheet merge validates incoming sheet maps and reuses +validatedStyleRules for class/tag declarations. It merges typed local maps rather +than unpacking unchecked nested metadata. Existing declarations not replaced by a +matching query remain intact; unmatched queries leave the sheet unchanged. + +Responsive cascade order now starts after the maximum base rule order, not after +the number of base rules. For example, one base rule with order 9 followed by an +incoming rule with local order 2 now produces order 12, not order 3. Negative, +noninteger and overflowing rule orders fail explicitly. + +Query environments passed to the query compiler are limited to named scalar/null +entries. The existing integer StyleQueryKind enum is used in regression fixtures. +No new string discriminator or native UI host implementation was introduced. + +SDK tests passed. New internal merge regressions cover preserving declarations, +sparse order precedence, unmatched queries, malformed class/cascade containers, +noninteger orders and the maximum-integer overflow boundary. Existing public +Language 2 rendering tests also passed in the same suite. + +PHPStan level 9 completed with 65 remaining TemplateRenderer diagnostics, down +from 74. It reported none in responsiveStyleSheet, styleMap or queryMatches. +Full diagnostic output: `/tmp/pam-template-responsive-static-20260914.json`. +This is not a clean-file or release approval. No Android rebuild, visual claim, +iOS execution or publication was performed for this internal metadata batch. diff --git a/docs/selector-cascade-validation-2026-09-14.md b/docs/selector-cascade-validation-2026-09-14.md new file mode 100644 index 00000000..3a1cf228 --- /dev/null +++ b/docs/selector-cascade-validation-2026-09-14.md @@ -0,0 +1,29 @@ +# Selector and cascade validation — 2026-09-14 + +The native template cascade now compares specificity as separate integer fields +instead of a weighted packed score. An ID selector cannot lose to 1,001 class +selectors because of a numeric-score collision. Importance remains first and +source order remains the final tie breaker. + +Selector matching normalizes sparse compound/ancestor lists and validates nested +class, pseudo and attribute structures before using them. Nonscalar attribute +values no longer reach string casts. Malformed structures fail matching instead +of causing invalid array operations. Cascade declarations and specificity have +explicit validation; scoped class maps reuse the existing declaration validator. + +SDK tests passed. Added regressions cover ID-versus-many-classes precedence, +important overrides, sparse child-selector ancestry, malformed selector metadata +and nonscalar attribute comparisons. Existing public Language 2 tests cover +compound/child/descendant/attribute selectors and important cascade behavior. + +PHPStan level 9 completed with 20 remaining TemplateRenderer diagnostics, down +from 65. None are in the selector matching, descriptor, class-map or cascade +functions changed here. Artifact: `/tmp/pam-template-selectors-static-20260914.json`. +Remaining work is concentrated in scoped state/recipe attributes, event attribute +types, keyframe metadata and error-message formatting. No diagnostic suppression +or passing release status was added. + +The existing SDK performance script was inspected, but it measures tree encoding +and theme application, not this template selector path. It was not used to claim +selector performance. No Android rebuild, iOS verification or publication was +performed for this batch. diff --git a/docs/showcase.md b/docs/showcase.md index 83f10b72..c8ca065e 100644 --- a/docs/showcase.md +++ b/docs/showcase.md @@ -35,20 +35,19 @@ through the production retained-tree, Rust layout and native renderer paths. ## Run it -From the repository root: +From the PAM Native repository root: ```bash -pam composer install --working-dir pam-native/examples/showcase -pam mobile doctor pam-native/examples/showcase -pam mobile dev pam-native/examples/showcase +pam composer install --working-dir examples/showcase +pam mobile doctor examples/showcase +pam mobile dev examples/showcase ``` Build an installable debug APK: ```bash -pam mobile build pam-native/examples/showcase -adb install -r \ - pam-native/examples/showcase/.pam-native/android/app/build/outputs/apk/debug/app-debug.apk +pam mobile build examples/showcase +adb install -r examples/showcase/dist/pam-native-showcase-0.1.0-android-debug.apk ``` ## What to try diff --git a/docs/template-renderer-static-closure-2026-09-14.md b/docs/template-renderer-static-closure-2026-09-14.md new file mode 100644 index 00000000..ab8655b4 --- /dev/null +++ b/docs/template-renderer-static-closure-2026-09-14.md @@ -0,0 +1,32 @@ +# TemplateRenderer static-analysis closure — 2026-09-14 + +The targeted PHPStan level-9 run now reports zero errors for TemplateRenderer. +This closes the previously recorded 89-diagnostic file-level debt; it does not +prove the entire SDK, platforms or UI release gates are complete. + +The final group validates inherited/scoped/state attribute maps, preserves finite +fractional style values, checks recipe/state metadata before indexing, and gives +explicit diagnostics for numeric event expressions. Type annotations now reflect +the integer/float attributes genuinely produced by style resolution. + +Named keyframes validate frame objects, finite offsets in [0,1] and style maps. +Frame styles cannot overwrite the timeline offset. Media errors format already +validated strings rather than interpolating arbitrary values. + +Evidence: + +- PHPStan JSON: `/tmp/pam-template-final-static-20260914.json`, errors 0 and + file_errors 0, no ignores or baseline added. +- Full SDK test runner passed, including Language 2 public rendering regressions. +- New tests cover scalar/fractional attributes, malformed maps, numeric event + metadata, valid animation serialization, protected timeline offsets and invalid + keyframes. The first keyframe fixture omitted required classes/tags containers; + the fixture was corrected and assertions now require a relevant keyframe error. +- UI material matrix against this SDK passed: 114 components, 32,832 style cases, + 456 renders. These counts are not Android interaction approvals. + +No APK was rebuilt for these internal batches. The installed showcase candidate +`393e1558d85b6b9f3a747b01a664249dabb5e81d057643f01908d487b708baff` +predates this SDK work. The next Android candidate must sync the current native +PHP TemplateRenderer into staging, which the native build does not do itself. +No publication, iOS approval or broad performance claim is made. diff --git a/docs/virtual-cell-insertion-2026-09-14.md b/docs/virtual-cell-insertion-2026-09-14.md new file mode 100644 index 00000000..ea50b830 --- /dev/null +++ b/docs/virtual-cell-insertion-2026-09-14.md @@ -0,0 +1,34 @@ +# Conditional descendants in mounted virtual cells + +Android deferred child view creation under VirtualList until holder binding. +A conditional child insertion leaves the row ID and extent unchanged, so the +adapter correctly avoids rebinding, but the new child previously stayed unmounted. + +The renderer now collects created IDs per commit, resolves unique virtual cell +roots after node/frame mutations, and materializes missing descendants only for +affected cells with existing holders. Offscreen cells remain deferred. Existing +views are retained rather than rebuilding each visible row. + +`richVirtualCellMountsInsertedChildrenWithoutChangingRowExtent` passed on Android +16/API 36 (`emulator-5554`) via `:app:connectedDebugAndroidTest`, filtered to this +method. Build/test completed in 17 seconds. It inserts/removes a text child twice, +asserting native parent, content, height, retained holder and no duplicate children. + +The UI showcase select/deselect report `/tmp/pam-grid-insertion-20260914.json` +also passed; visual inspection confirms the formerly missing check is visible. +This does not establish all virtualization, accessibility, performance or iOS +requirements. No release approval follows from this scoped regression. + +## UIKit parity review + +UIKit already invokes `materializeSubtree` for every visible cell during +`syncVirtualList`. `materialize` skips existing views, but recursion still visits +their children, so its source does not share Android's unchanged-holder bind gap. +No speculative UIKit runtime change was made. + +`PamVirtualCellInsertionTests.testInsertedDescendantsMountWithoutReplacingVisibleCell` +adds the corresponding two-cycle insertion/removal regression, checking retained +cell identity, label content/size, parent and absence of duplicate descendants. +It has **not been compiled or executed**: this Linux environment exposes neither +Swift nor xcodebuild. Source review and the Android pass do not constitute an +iOS pass. Run this UIKit test on an Apple host before asserting platform parity. diff --git a/docs/virtual-list-accessibility-delegate-2026-09-14.md b/docs/virtual-list-accessibility-delegate-2026-09-14.md new file mode 100644 index 00000000..7582690c --- /dev/null +++ b/docs/virtual-list-accessibility-delegate-2026-09-14.md @@ -0,0 +1,32 @@ +# Preserve RecyclerView accessibility behavior + +Applying PAM accessibility roles, hints or custom actions replaced the +RecyclerView delegate with a plain View delegate. Touch scrolling still worked, +but list metadata and native accessibility scroll actions were lost. Removing +those properties also cleared the delegate instead of restoring the native one. + +`configureAccessibilityDelegate` now initializes node information through the +list's existing compat delegate before applying PAM semantics, and forwards +unhandled actions to it. The no-customization path restores that delegate. +An unspecified role no longer overwrites the native class name. + +This follows the responsibility of AndroidX's +[RecyclerViewAccessibilityDelegate](https://developer.android.com/reference/androidx/recyclerview/widget/RecyclerViewAccessibilityDelegate): +the platform list owns native collection information and accessibility actions; +PAM augments rather than reimplements that behavior. UI composition is unchanged. + +The instrumented test +`virtualListRolesPreserveNativeAccessibilityScrollActions` passed on +emulator-5554/Android 16/API 36. A rendered 20-row native list with an explicit +list role reports scrollability and ACTION_SCROLL_FORWARD. Performing that +accessibility action advances the first visible item without a touch gesture. + +Showcase candidate `ae46a7202efdad26432b8897967ec395f68cc5932ff34d48528c6adf99166ce2` +also passes the three-route integrated check in +`/tmp/pam-scroll-semantics-20260914.json`: Virtual List, Section List and virtual +Data Table expose `scrollable=true` and retain internal touch scrolling in both +directions. The generic audit now rejects missing native scroll semantics. + +This focused regression does not establish full TalkBack navigation, custom +action combinations, horizontal/reversed lists, disabled scrolling, event +announcement behavior or iOS accessibility parity. diff --git a/docs/virtual-row-accessibility-2026-09-14.md b/docs/virtual-row-accessibility-2026-09-14.md new file mode 100644 index 00000000..caa860f9 --- /dev/null +++ b/docs/virtual-row-accessibility-2026-09-14.md @@ -0,0 +1,22 @@ +# Partially visible virtual rows + +`PamRecyclerList` previously hid a holder and all its accessibility descendants +unless the entire holder fitted inside the viewport. This excluded visible +controls in clipped rows and made rows taller than the viewport unreachable. + +The visibility filter now excludes only holders that do not intersect the +viewport. Detachment restores the original accessibility mode so a recycled +holder does not inherit `NO_HIDE_DESCENDANTS` as its permanent original mode. +Virtualization and offscreen prefetch remain enabled. + +The Android instrumented regression +`PamRendererInstrumentedTest#partiallyVisibleVirtualRowsRemainAccessible` +passed on emulator-5554, Android 16/API 36: both a partially visible second row +and a first row taller than the viewport retain accessibility descendants. +The first staging invocation ran zero tests because a relative copy failed; +it is not validation. After copying the changed sources using absolute paths, +the invocation compiled the changes and ran one passing test. + +This is native runtime behavior shared by virtualized UI compositions, not a +Data Grid-specific workaround. Full TalkBack traversal, horizontal scrolling, +recycling stress and iOS are not established by this focused test. diff --git a/ios/Sources/PamNative/Bridge/pam_native_ios_bridge.cpp b/ios/Sources/PamNative/Bridge/pam_native_ios_bridge.cpp index f88b2f02..2e77d712 100644 --- a/ios/Sources/PamNative/Bridge/pam_native_ios_bridge.cpp +++ b/ios/Sources/PamNative/Bridge/pam_native_ios_bridge.cpp @@ -684,6 +684,26 @@ void pam_native_runtime_relayout( publish_batch(state, batch); } +void pam_native_runtime_set_child_visibility(uint64_t handle, uint64_t owner, uint64_t child, bool visible) { + RuntimeState* state = from_handle(handle); + if (state == nullptr || owner == 0 || child == 0) { + return; + } + PamNativeBuffer batch{nullptr, 0, 0}; + PamStatus status; + { + std::lock_guard lock(state->engine_mutex); + status = pam_native_engine_set_native_child_visibility( + state->engine, owner, child, visible ? 1 : 0, &batch + ); + } + if (status == PAM_STATUS_SUCCESS) { + publish_batch(state, batch); + } else { + pam_native_buffer_free(batch); + } +} + void pam_native_runtime_set_refresh_rate(uint64_t handle, double refresh_rate_hz) { RuntimeState* state = from_handle(handle); if (state == nullptr || refresh_rate_hz <= 0) { diff --git a/ios/Sources/PamNative/Protocol/PamProtocol.swift b/ios/Sources/PamNative/Protocol/PamProtocol.swift index f14f7461..5ff145cb 100644 --- a/ios/Sources/PamNative/Protocol/PamProtocol.swift +++ b/ios/Sources/PamNative/Protocol/PamProtocol.swift @@ -322,6 +322,20 @@ public enum PamConstants { public static let text = 1 public static let value = 2 public static let placeholder = 3 + public static let keyboardType = 47 + public static let secure = 46 + public static let inputMode = 218 + public static let inputAutoCorrect = 211 + public static let inputAutoCapitalize = 212 + public static let maxLength = 93 + public static let inputEditable = 210 + public static let autoComplete = 48 + public static let returnKeyType = 95 + public static let inputSubmitBehavior = 224 + public static let inputSelectionStart = 221 + public static let inputSelectionEnd = 222 + public static let autoFocus = 94 + public static let inputShowSoftInputOnFocus = 223 public static let source = 4 public static let width = 5 public static let height = 6 @@ -440,6 +454,12 @@ public enum PamConstants { public static let gestureNativeTranslationLimitX = 462 public static let gestureNativeResetOnEnd = 463 public static let inputFormatLocale = 464 + public static let scrollIndicatorStyle = 465 + public static let gridMinColumnWidth = 466 + public static let gridTemplate = 467 + public static let gridSpan2xl = 468 + public static let gridOffset2xl = 469 + public static let gridOrder2xl = 470 public static let sectionItems = 80 public static let hostProperties = 100 public static let opacity = 38 diff --git a/ios/Sources/PamNative/Render/PamRenderComponents.swift b/ios/Sources/PamNative/Render/PamRenderComponents.swift index db2526b8..c6d51011 100644 --- a/ios/Sources/PamNative/Render/PamRenderComponents.swift +++ b/ios/Sources/PamNative/Render/PamRenderComponents.swift @@ -707,7 +707,44 @@ final class PamSafeAreaView: UIView { } } +enum PamInputSubmitBehavior: Int64 { + case submit = 1, blurAndSubmit, newline +} + final class PamInputField: UITextField, UITextFieldDelegate { + var submitBehavior: PamInputSubmitBehavior = .blurAndSubmit + var maximumLength: Int? + var isInputEditable = true + var autoFocusRequested = false { + didSet { + if !autoFocusRequested { automaticFocusApplied = false } + scheduleAutomaticFocus() + } + } + private var automaticFocusApplied = false + private var automaticFocusScheduled = false + + private func scheduleAutomaticFocus() { + guard autoFocusRequested, !automaticFocusApplied, !automaticFocusScheduled else { return } + automaticFocusScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.automaticFocusScheduled = false + guard self.autoFocusRequested, !self.automaticFocusApplied, + self.window != nil, self.isEnabled else { return } + var ancestor: UIView? = self + while let view = ancestor { + guard !view.isHidden, view.alpha > 0.01, view.isUserInteractionEnabled else { return } + ancestor = view.superview + } + self.automaticFocusApplied = self.becomeFirstResponder() + } + } + + override func didMoveToWindow() { + super.didMoveToWindow() + scheduleAutomaticFocus() + } private static let maxKeyBytes = 64 var onSelectionChange: ((Int, Int) -> Void)? @@ -811,14 +848,46 @@ final class PamInputField: UITextField, UITextFieldDelegate { super.layoutSubviews() syncFontCache() scheduleContentSizeUpdate() + scheduleAutomaticFocus() } override func deleteBackward() { + guard isInputEditable else { return } onKeyPress?("Backspace") super.deleteBackward() } + override func unmarkText() { + super.unmarkText() + enforceCommittedLength() + } + + override func setMarkedText(_ markedText: String?, selectedRange: NSRange) { + guard isInputEditable else { return } + super.setMarkedText(markedText, selectedRange: selectedRange) + } + + private func enforceCommittedLength() { + guard isInputEditable, markedTextRange == nil, let limit = maximumLength, + let current = text, current.utf16.count > limit else { return } + var bounded = "" + var remaining = limit + for character in current { + let length = String(character).utf16.count + guard length <= remaining else { break } + bounded.append(character) + remaining -= length + } + let cursor = selectedTextRange.map { offset(from: beginningOfDocument, to: $0.start) } + setTextFromRenderer(bounded) + if let position = position(from: beginningOfDocument, offset: min(cursor ?? bounded.utf16.count, bounded.utf16.count)) { + selectedTextRange = textRange(from: position, to: position) + } + sendActions(for: .editingChanged) + } + override func insertText(_ text: String) { + guard isInputEditable else { return } if text.count > 0 { let key = text == "\n" ? "Enter" : String(text.prefix(Self.maxKeyBytes)) if !key.isEmpty { @@ -829,6 +898,30 @@ final class PamInputField: UITextField, UITextFieldDelegate { } func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + guard isInputEditable else { return false } + if let limit = maximumLength, textField.markedTextRange == nil, + let current = textField.text, let editRange = Range(range, in: current) { + let retainedLength = current.utf16.count - range.length + let available = max(0, limit - retainedLength) + if string.utf16.count > available { + var accepted = "" + var remaining = available + for character in string { + let length = String(character).utf16.count + guard length <= remaining else { break } + accepted.append(character) + remaining -= length + } + guard !accepted.isEmpty else { return false } + let proposed = current.replacingCharacters(in: editRange, with: accepted) + setTextFromRenderer(formatInput(proposed)) + if let cursor = position(from: beginningOfDocument, offset: range.location + accepted.utf16.count) { + selectedTextRange = textRange(from: cursor, to: cursor) + } + sendActions(for: .editingChanged) + return false + } + } if string.isEmpty && range.length > 0 { onKeyPress?("Backspace") } else if !string.isEmpty { @@ -940,14 +1033,17 @@ final class PamInputField: UITextField, UITextFieldDelegate { } func textFieldDidEndEditing(_ textField: UITextField) { + enforceCommittedLength() onInputEndEditing?(text ?? "") } func textFieldShouldReturn(_ textField: UITextField) -> Bool { - if let value = textField.text { - onInputEndEditing?(value) - } - return true + guard isInputEditable, submitBehavior != .newline else { return false } + enforceCommittedLength() + sendActions(for: .primaryActionTriggered) + if submitBehavior == .blurAndSubmit { resignFirstResponder() } + // We dispatched explicitly; UIKit must not dispatch another action. + return false } func syncFontCache() { diff --git a/ios/Sources/PamNative/Render/PamRenderer.swift b/ios/Sources/PamNative/Render/PamRenderer.swift index c098812d..7e4570e4 100644 --- a/ios/Sources/PamNative/Render/PamRenderer.swift +++ b/ios/Sources/PamNative/Render/PamRenderer.swift @@ -2,7 +2,24 @@ import Foundation import ImageIO import UIKit +private enum PamKeyboardKind: Int64 { + case text = 1, email, number, phone, decimal, url +} + +private enum PamInputModeKind: Int64 { + case text = 1, none, decimal, numeric, tel, search, email, url +} + +private enum PamInputCapitalization: Int64 { + case none = 1, sentences, words, characters +} + +private enum PamReturnKey: Int64 { + case standard = 1, done, go, next, search, send, none, previous +} + public final class PamRenderer { + var onNativeChildVisibility: ((Int64, Int64, Bool) -> Void)? private let fontLoader = PamFontLoader() private let host: UIView private let dispatchEvent: (Int64, Int, Data) -> Void @@ -174,7 +191,9 @@ public final class PamRenderer { imageSession.invalidateAndCancel() imageLoadContexts.removeAll() + onNativeChildVisibility = nil for (nodeId, view) in views { + (view as? NativeChildVisibilityHost)?.onChildVisibilityChanged = nil nativeViews.release(view: view) view.removeFromSuperview() nodes[nodeId]?.childrenNeedRethrow = nil @@ -286,6 +305,7 @@ public final class PamRenderer { cancelImageLoad(for: state) if let view = views[id] { + (view as? NativeChildVisibilityHost)?.onChildVisibilityChanged = nil nativeViews.release(view: view) if let navigation = views[state.parent] as? PamNavigationHost { navigation.removeRoute(view) @@ -322,6 +342,9 @@ public final class PamRenderer { if let value { applyProperty(view: view, nodeId: id, key: key, value: value) + if key == PamConstants.value || key == PamConstants.text || key == PamConstants.secure { + applyInputSelection(view: view, nodeId: id) + } } else { resetProperty(view: view, nodeId: id, key: key, state: state) if key == PamConstants.source, @@ -399,6 +422,7 @@ public final class PamRenderer { for (key, value) in state.properties { applyProperty(view: view, nodeId: state.id, key: key, value: value) } + applyInputSelection(view: view, nodeId: state.id) installEvents(for: state.id) applyLayout(state.id) } @@ -756,7 +780,7 @@ public final class PamRenderer { private func createView(for spec: NodeSpec) -> UIView { if spec.kind == .customView { - return nativeViews.create(name: hostName(for: NodeState( + let nativeView = nativeViews.create(name: hostName(for: NodeState( id: spec.id, parent: spec.parent, index: spec.index, @@ -772,6 +796,16 @@ public final class PamRenderer { ))) { [weak self] kind, payload in self?.dispatchNativeViewEvent(nodeId: spec.id, kind: kind, payload: payload) } + (nativeView as? NativeChildVisibilityHost)?.onChildVisibilityChanged = { [weak self, weak nativeView] child, visible in + DispatchQueue.main.async { [weak self, weak nativeView, weak child] in + guard let self, let nativeView, let child, + self.views[spec.id] === nativeView, + let childId = self.children[spec.id]?.first(where: { self.views[$0] === child }), + self.nodes[childId]?.parent == spec.id else { return } + self.onNativeChildVisibility?(spec.id, childId, visible) + } + } + return nativeView } switch spec.kind { @@ -1426,6 +1460,130 @@ public final class PamRenderer { ) } + private func applyInputSelection(view: UIView, nodeId: Int64) { + guard let field = view as? UITextField, let state = nodes[nodeId], + let requested = state.properties[PamConstants.inputSelectionStart]?.integerOrNil() else { return } + let length = (field.text ?? "").utf16.count + let start = min(length, max(0, Int(clamping: requested))) + let end = min(length, max(start, Int(clamping: + state.properties[PamConstants.inputSelectionEnd]?.integerOrNil() ?? Int64(start)))) + guard let from = field.position(from: field.beginningOfDocument, offset: start), + let to = field.position(from: field.beginningOfDocument, offset: end) else { return } + field.selectedTextRange = field.textRange(from: from, to: to) + } + + private func applyInputCompletion(view: UIView, nodeId: Int64) { + guard let field = view as? UITextField, let state = nodes[nodeId] else { return } + let hint = state.properties[PamConstants.autoComplete]?.textOrNil()?.lowercased() + let content: UITextContentType? + switch hint { + case "email": content = .emailAddress + case "tel": content = .telephoneNumber + case "password", "current-password": content = .password + case "new-password", "password-new": content = .newPassword + case "username", "username-new": content = .username + case "one-time-code", "sms-otp": content = .oneTimeCode + case "name": content = .name + case "given-name": content = .givenName + case "family-name": content = .familyName + case "postal-code": content = .postalCode + case "street-address", "postal-address": content = .fullStreetAddress + case "cc-number": content = .creditCardNumber + default: content = nil + } + let key = (state.properties[PamConstants.returnKeyType]?.integerOrNil()) + .flatMap(PamReturnKey.init(rawValue:)) ?? .standard + let action: UIReturnKeyType + switch key { + case .done: action = .done + case .go: action = .go + case .next: action = .next + case .search: action = .search + case .send: action = .send + // UIKit has no previous/none return-key glyph equivalents. + case .standard, .none, .previous: action = .default + } + let changed = field.textContentType != content || field.returnKeyType != action + field.textContentType = content + field.returnKeyType = action + if changed && field.isFirstResponder { field.reloadInputViews() } + } + + private func applyInputTextTraits(view: UIView, nodeId: Int64) { + guard let field = view as? UITextField, let state = nodes[nodeId] else { return } + let autoCorrect = state.properties[PamConstants.inputAutoCorrect]?.boolOrNil() + let correction: UITextAutocorrectionType = autoCorrect.map { $0 ? .yes : .no } ?? .default + let mode = (state.properties[PamConstants.inputAutoCapitalize]?.integerOrNil()) + .flatMap(PamInputCapitalization.init(rawValue:)) ?? .sentences + let capitalization: UITextAutocapitalizationType + switch mode { + case .none: capitalization = .none + case .sentences: capitalization = .sentences + case .words: capitalization = .words + case .characters: capitalization = .allCharacters + } + let changed = field.autocorrectionType != correction || field.autocapitalizationType != capitalization + field.autocorrectionType = correction + field.autocapitalizationType = capitalization + if changed && field.isFirstResponder { field.reloadInputViews() } + } + + private func applyInputSecurity(view: UIView, nodeId: Int64) { + guard let field = view as? UITextField else { return } + let secure = nodes[nodeId]?.properties[PamConstants.secure]?.boolOrNil() ?? false + guard field.isSecureTextEntry != secure else { return } + let selection = field.selectedTextRange.map { + (field.offset(from: field.beginningOfDocument, to: $0.start), + field.offset(from: field.beginningOfDocument, to: $0.end)) + } + field.isSecureTextEntry = secure + if let (start, end) = selection, + let from = field.position(from: field.beginningOfDocument, offset: start), + let to = field.position(from: field.beginningOfDocument, offset: end) { + field.selectedTextRange = field.textRange(from: from, to: to) + } + } + + private func applyInputKeyboard(view: UIView, nodeId: Int64) { + guard let field = view as? UITextField, let state = nodes[nodeId] else { return } + let mode = (state.properties[PamConstants.inputMode]?.integerOrNil()).flatMap(PamInputModeKind.init(rawValue:)) + let keyboard = (state.properties[PamConstants.keyboardType]?.integerOrNil()).flatMap(PamKeyboardKind.init(rawValue:)) ?? .text + let type: UIKeyboardType + if let mode { + switch mode { + case .text, .none: type = .default + case .decimal: type = .numbersAndPunctuation + case .numeric: type = .numberPad + case .tel: type = .phonePad + case .search: type = .webSearch + case .email: type = .emailAddress + case .url: type = .URL + } + } else { + switch keyboard { + case .text: type = .default + case .email: type = .emailAddress + case .number: type = .numberPad + case .phone: type = .phonePad + // Unlike decimalPad, this system keyboard exposes the minus sign. + case .decimal: type = .numbersAndPunctuation + case .url: type = .URL + } + } + let editable = state.properties[PamConstants.inputEditable]?.boolOrNil() ?? true + (field as? PamInputField)?.isInputEditable = editable + let hideKeyboard = mode == PamInputModeKind.none || !editable + || state.properties[PamConstants.inputShowSoftInputOnFocus]?.boolOrNil() == false + let changed = field.keyboardType != type || (field.inputView != nil) != hideKeyboard + field.keyboardType = type + if hideKeyboard { + if field.inputView == nil { field.inputView = UIView(frame: .zero) } + } else { + field.inputView = nil + } + if changed && field.isFirstResponder { field.reloadInputViews() } + } + private func applyProperty(view: UIView, nodeId: Int64, key: Int, value: PropValue) { switch key { case PamConstants.text: @@ -1440,6 +1598,24 @@ public final class PamRenderer { field.text = textValue } } + case PamConstants.keyboardType, PamConstants.inputMode, PamConstants.inputEditable, + PamConstants.inputShowSoftInputOnFocus: + applyInputKeyboard(view: view, nodeId: nodeId) + case PamConstants.autoFocus: + (view as? PamInputField)?.autoFocusRequested = value.boolOrNil() ?? false + case PamConstants.secure: + applyInputSecurity(view: view, nodeId: nodeId) + case PamConstants.maxLength: + (view as? PamInputField)?.maximumLength = value.integerOrNil().map { max(0, Int(clamping: $0)) } + case PamConstants.inputSubmitBehavior: + (view as? PamInputField)?.submitBehavior = value.integerOrNil() + .flatMap(PamInputSubmitBehavior.init(rawValue:)) ?? .blurAndSubmit + case PamConstants.inputAutoCorrect, PamConstants.inputAutoCapitalize: + applyInputTextTraits(view: view, nodeId: nodeId) + case PamConstants.autoComplete, PamConstants.returnKeyType: + applyInputCompletion(view: view, nodeId: nodeId) + case PamConstants.inputSelectionStart, PamConstants.inputSelectionEnd: + applyInputSelection(view: view, nodeId: nodeId) case PamConstants.value: if let textValue = value.textOrNil(), let drawing = view as? PamDrawingCanvas { drawing.setDrawing(textValue) @@ -1798,6 +1974,9 @@ public final class PamRenderer { (view as? PamRefreshContainer)?.setProgressViewOffset(Float(value.decimalOrZero())) case PamConstants.refreshIndicatorSize: (view as? PamRefreshContainer)?.setIndicatorSize(Int(value.integerOrNil() ?? 1)) + case PamConstants.scrollIndicatorStyle: + (view as? UIScrollView)?.indicatorStyle = + (PamScrollIndicatorStyle(rawValue: Int(value.integerOrNil() ?? 1)) ?? .auto).native case PamConstants.scrollHorizontal: if let scroll = view as? UIScrollView { configureScrollView(scroll, horizontal: value.boolOrNil() ?? false) @@ -1920,6 +2099,23 @@ public final class PamRenderer { view.backgroundColor = .clear case PamConstants.nativeBackgroundColorResource: view.backgroundColor = .clear + case PamConstants.keyboardType, PamConstants.inputMode, PamConstants.inputEditable, + PamConstants.inputShowSoftInputOnFocus: + applyInputKeyboard(view: view, nodeId: nodeId) + case PamConstants.autoFocus: + (view as? PamInputField)?.autoFocusRequested = false + case PamConstants.secure: + applyInputSecurity(view: view, nodeId: nodeId) + case PamConstants.maxLength: + (view as? PamInputField)?.maximumLength = nil + case PamConstants.inputSubmitBehavior: + (view as? PamInputField)?.submitBehavior = .blurAndSubmit + case PamConstants.inputAutoCorrect, PamConstants.inputAutoCapitalize: + applyInputTextTraits(view: view, nodeId: nodeId) + case PamConstants.autoComplete, PamConstants.returnKeyType: + applyInputCompletion(view: view, nodeId: nodeId) + case PamConstants.inputSelectionStart, PamConstants.inputSelectionEnd: + applyInputSelection(view: view, nodeId: nodeId) case PamConstants.nativeStateStyles: (view as? PamPressButton)?.pamStateStyles = [:] case PamConstants.imageFit: @@ -2111,6 +2307,8 @@ public final class PamRenderer { if let scroll = view as? UIScrollView { configureScrollView(scroll, horizontal: false) } + case PamConstants.scrollIndicatorStyle: + (view as? UIScrollView)?.indicatorStyle = PamScrollIndicatorStyle.auto.native case PamConstants.scrollPagingEnabled: (view as? PamAnchoredScrollView)?.pamPagingEnabled = false case PamConstants.scrollSnapInterval: @@ -3568,6 +3766,7 @@ public final class PamRenderer { private var emitsTouchEnd = false private var lastIntersection: Bool? private weak var textField: PamInputField? + private weak var submitField: UITextField? private var focusField: PamInputField? private weak var control: UIControl? private weak var scrollView: UIScrollView? @@ -3620,6 +3819,7 @@ public final class PamRenderer { } func attachSubmit(_ control: UIControl) { + self.submitField = control as? UITextField control.addTarget(self, action: #selector(onSubmit), for: .touchUpInside) control.addTarget(self, action: #selector(onSubmit), for: .primaryActionTriggered) self.control = control @@ -4080,6 +4280,7 @@ public final class PamRenderer { self.rippleOverlay = nil self.directiveView = nil self.textField = nil + self.submitField = nil self.control = nil self.scrollView = nil self.refreshControl = nil @@ -4609,7 +4810,7 @@ public final class PamRenderer { @objc private func onSubmit() { let payload = (try? WireMap.encode([ - "value": .text(textField?.text ?? ""), + "value": .text(submitField?.text ?? ""), ])) ?? Data() dispatchEvent(nodeId, EventKind.submit.rawValue, payload) } diff --git a/ios/Sources/PamNative/Render/PamScrollIndicatorStyle.swift b/ios/Sources/PamNative/Render/PamScrollIndicatorStyle.swift new file mode 100644 index 00000000..55080b73 --- /dev/null +++ b/ios/Sources/PamNative/Render/PamScrollIndicatorStyle.swift @@ -0,0 +1,15 @@ +import UIKit + +enum PamScrollIndicatorStyle: Int { + case auto = 1 + case dark = 2 + case light = 3 + + var native: UIScrollView.IndicatorStyle { + switch self { + case .auto: return .default + case .dark: return .black + case .light: return .white + } + } +} diff --git a/ios/Sources/PamNative/Runtime/PamRuntime.swift b/ios/Sources/PamNative/Runtime/PamRuntime.swift index 539d3055..2ddc2ecc 100644 --- a/ios/Sources/PamNative/Runtime/PamRuntime.swift +++ b/ios/Sources/PamNative/Runtime/PamRuntime.swift @@ -63,6 +63,14 @@ private func pam_native_runtime_set_refresh_rate( _ refresh_rate_hz: Double, ) +@_silgen_name("pam_native_runtime_set_child_visibility") +private func pam_native_runtime_set_child_visibility( + _ handle: UInt64, + _ owner: UInt64, + _ child: UInt64, + _ visible: Bool, +) + @_silgen_name("pam_native_runtime_dispatch_event") private func pam_native_runtime_dispatch_event( _ handle: UInt64, @@ -286,6 +294,13 @@ public final class PamRuntime { self?.dispatchEvent(nodeId, kind: kind, payload: payload) } + self.renderer.onNativeChildVisibility = { [weak self] owner, child, visible in + guard let self, owner > 0, child > 0 else { return } + let activeHandle = self.currentHandle() + guard activeHandle != 0 else { return } + pam_native_runtime_set_child_visibility(activeHandle, UInt64(owner), UInt64(child), visible) + } + let target = PamRuntimeDisplayLinkTarget(runtime: self) displayLinkTarget = target lifecycleObservers = [ diff --git a/ios/Sources/PamNative/Views/NativeViewContracts.swift b/ios/Sources/PamNative/Views/NativeViewContracts.swift index f4c2fab7..6eeb91c9 100644 --- a/ios/Sources/PamNative/Views/NativeViewContracts.swift +++ b/ios/Sources/PamNative/Views/NativeViewContracts.swift @@ -26,6 +26,12 @@ public protocol NativeViewEmitter { func emit(kind: NativeViewEventKind, payload: Data) } +/// Optional capability for custom hosts that toggle a direct declarative child. +/// Invoke on the main thread; the renderer schedules the engine layout update. +public protocol NativeChildVisibilityHost: AnyObject { + var onChildVisibilityChanged: ((UIView, Bool) -> Void)? { get set } +} + public protocol NativeViewFactory { func create(context: AnyObject?, emit: @escaping (Data) -> Void) -> UIView func create(context: AnyObject?, emitter: NativeViewEmitter) -> UIView diff --git a/ios/Sources/PamNativeTestRuntimeShims/runtime_shims.c b/ios/Sources/PamNativeTestRuntimeShims/runtime_shims.c index fa618ba4..7515d484 100644 --- a/ios/Sources/PamNativeTestRuntimeShims/runtime_shims.c +++ b/ios/Sources/PamNativeTestRuntimeShims/runtime_shims.c @@ -7,6 +7,7 @@ uint64_t pam_native_runtime_start(void) { void pam_native_runtime_relayout(void) {} void pam_native_runtime_set_refresh_rate(void) {} +void pam_native_runtime_set_child_visibility(void) {} void pam_native_runtime_dispatch_event(void) {} diff --git a/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift b/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift index ad4484c2..f036f7c3 100644 --- a/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift +++ b/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift @@ -17,8 +17,352 @@ private final class PluginFixtureViewFactory: NativeViewFactory { func close() {} } +@MainActor +private final class VisibilityFixtureView: UIView, NativeChildVisibilityHost { + var onChildVisibilityChanged: ((UIView, Bool) -> Void)? +} + +@MainActor +private final class VisibilityFixtureFactory: NativeViewFactory { + let view = VisibilityFixtureView() + func create(context: AnyObject?, emit: @escaping (Data) -> Void) -> UIView { view } + func update(view: UIView, properties: [String: WireValue]) {} +} + @MainActor final class CapabilityIntegrationTests: XCTestCase { + func testAutofocusWaitsForMountAndDoesNotStealFocusOnRelayout() { + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + let controller = UIViewController() + window.rootViewController = controller + let field = PamInputField(frame: CGRect(x: 16, y: 60, width: 300, height: 56)) + field.inputView = UIView() + field.autoFocusRequested = true + XCTAssertFalse(field.isFirstResponder) + controller.view.addSubview(field) + window.makeKeyAndVisible() + defer { field.resignFirstResponder(); window.isHidden = true } + let mounted = expectation(description: "autofocus after mounting") + DispatchQueue.main.async { mounted.fulfill() } + wait(for: [mounted], timeout: 1) + XCTAssertTrue(field.isFirstResponder) + field.resignFirstResponder() + field.setNeedsLayout() + field.layoutIfNeeded() + let relayout = expectation(description: "autofocus remains consumed") + DispatchQueue.main.async { relayout.fulfill() } + wait(for: [relayout], timeout: 1) + XCTAssertFalse(field.isFirstResponder) + } + + func testControlledSelectionClampsAndSurvivesValueAndSecureUpdates() throws { + let host = UIView() + let renderer = PamRenderer(hostView: host) { _, _, _ in } + defer { renderer.close() } + renderer.commit([[ + .create(NodeSpec(id: 1, parent: 0, index: 0, kind: .screen, properties: [:])), + .create(NodeSpec(id: 2, parent: 1, index: 0, kind: .input, properties: [ + PamConstants.testId: .text("selection-fixture"), + PamConstants.value: .text("abcdef"), + PamConstants.inputSelectionStart: .integer(2), + PamConstants.inputSelectionEnd: .integer(5), + ])), + .setRoot(1), + ]]) + let field = try XCTUnwrap(host.descendant(accessibilityIdentifier: "selection-fixture") as? UITextField) + func assertSelection(_ start: Int, _ end: Int) throws { + let selection = try XCTUnwrap(field.selectedTextRange) + XCTAssertEqual(field.offset(from: field.beginningOfDocument, to: selection.start), start) + XCTAssertEqual(field.offset(from: field.beginningOfDocument, to: selection.end), end) + } + try assertSelection(2, 5) + renderer.commit([[.update(id: 2, key: PamConstants.secure, value: .flag(true))]]) + try assertSelection(2, 5) + renderer.commit([[.update(id: 2, key: PamConstants.value, value: .text("abc"))]]) + try assertSelection(2, 3) + renderer.commit([[.update(id: 2, key: PamConstants.inputSelectionEnd, value: nil)]]) + try assertSelection(2, 2) + renderer.commit([[.update(id: 2, key: PamConstants.inputSelectionStart, value: .integer(-4))]]) + try assertSelection(0, 0) + renderer.commit([[.update(id: 2, key: PamConstants.inputSelectionStart, value: nil)]]) + let cursor = try XCTUnwrap(field.position(from: field.beginningOfDocument, offset: 1)) + field.selectedTextRange = field.textRange(from: cursor, to: cursor) + renderer.commit([[.update(id: 2, key: PamConstants.secure, value: .flag(false))]]) + try assertSelection(1, 1) + } + + func testSubmitBehaviorControlsActualFirstResponderLifecycle() { + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + let controller = UIViewController() + window.rootViewController = controller + let field = PamInputField(frame: CGRect(x: 16, y: 60, width: 300, height: 56)) + controller.view.addSubview(field) + window.makeKeyAndVisible() + defer { + field.resignFirstResponder() + window.isHidden = true + } + var submits = 0 + var ends = 0 + field.onInputEndEditing = { _ in ends += 1 } + field.addAction(UIAction { _ in submits += 1 }, for: .primaryActionTriggered) + XCTAssertTrue(field.becomeFirstResponder()) + field.submitBehavior = .submit + XCTAssertFalse(field.textFieldShouldReturn(field)) + XCTAssertTrue(field.isFirstResponder) + XCTAssertEqual(submits, 1) + XCTAssertEqual(ends, 0) + field.submitBehavior = .blurAndSubmit + XCTAssertFalse(field.textFieldShouldReturn(field)) + XCTAssertFalse(field.isFirstResponder) + XCTAssertEqual(submits, 2) + XCTAssertEqual(ends, 1) + } + + func testSubmitOnlyDoesNotPretendEditingEnded() { + let field = PamInputField() + var submits = 0 + var ends = 0 + field.onInputEndEditing = { _ in ends += 1 } + field.addAction(UIAction { _ in submits += 1 }, for: .primaryActionTriggered) + field.submitBehavior = .submit + XCTAssertFalse(field.textFieldShouldReturn(field)) + XCTAssertEqual(submits, 1) + XCTAssertEqual(ends, 0) + field.isInputEditable = false + XCTAssertFalse(field.textFieldShouldReturn(field)) + XCTAssertEqual(submits, 1) + field.isInputEditable = true + field.submitBehavior = .newline + XCTAssertFalse(field.textFieldShouldReturn(field)) + XCTAssertEqual(submits, 1, "Newline must not become a submit event") + } + + func testSubmitEventCarriesCurrentEditorValue() throws { + let host = UIView() + var payloads: [Data] = [] + let renderer = PamRenderer(hostView: host) { node, kind, payload in + if kind == EventKind.submit.rawValue { + XCTAssertEqual(node, 2) + payloads.append(payload) + } + } + defer { renderer.close() } + renderer.commit([[ + .create(NodeSpec(id: 1, parent: 0, index: 0, kind: .screen, properties: [:])), + .create(NodeSpec(id: 2, parent: 1, index: 0, kind: .input, properties: [ + PamConstants.testId: .text("submit-fixture"), + PamConstants.value: .text("Original"), + PamConstants.onSubmit: .flag(true), + ])), + .setRoot(1), + ]]) + let field = try XCTUnwrap(host.descendant(accessibilityIdentifier: "submit-fixture") as? UITextField) + func invokeRegisteredSubmitTargets() { + // SwiftPM runs without UIApplicationMain. Exercise the registered + // target selectors directly instead of asking UIApplication to send. + for target in field.allTargets { + guard let object = target.base as? NSObject else { continue } + for action in field.actions(forTarget: object, forControlEvent: .primaryActionTriggered) ?? [] { + _ = object.perform(NSSelectorFromString(action)) + } + } + } + field.text = "Edited value" + invokeRegisteredSubmitTargets() + XCTAssertEqual(payloads.count, 1) + XCTAssertEqual(try WireMap.decode(XCTUnwrap(payloads.first))["value"], .text("Edited value")) + renderer.commit([[.update(id: 2, key: PamConstants.onSubmit, value: nil)]]) + invokeRegisteredSubmitTargets() + XCTAssertEqual(payloads.count, 1, "Removing submit must detach its target") + } + + func testReadonlyInputRejectsMutationWithoutDisablingSelection() throws { + let host = UIView() + let renderer = PamRenderer(hostView: host) { _, _, _ in } + defer { renderer.close() } + renderer.commit([[ + .create(NodeSpec(id: 1, parent: 0, index: 0, kind: .screen, properties: [:])), + .create(NodeSpec(id: 2, parent: 1, index: 0, kind: .input, properties: [ + PamConstants.testId: .text("readonly-fixture"), + PamConstants.value: .text("Reference"), + PamConstants.inputEditable: .flag(false), + ])), + .setRoot(1), + ]]) + let field = try XCTUnwrap(host.descendant(accessibilityIdentifier: "readonly-fixture") as? PamInputField) + XCTAssertTrue(field.isEnabled) + XCTAssertTrue(field.isUserInteractionEnabled) + XCTAssertNotNil(field.inputView) + XCTAssertFalse(field.textField(field, shouldChangeCharactersIn: NSRange(location: 0, length: 9), replacementString: "Changed")) + field.insertText("X") + field.deleteBackward() + field.setMarkedText("Changed", selectedRange: NSRange(location: 0, length: 0)) + XCTAssertEqual(field.text, "Reference") + renderer.commit([[.update(id: 2, key: PamConstants.inputEditable, value: nil)]]) + XCTAssertNil(field.inputView) + XCTAssertTrue(field.textField(field, shouldChangeCharactersIn: NSRange(location: 0, length: 9), replacementString: "Changed")) + } + + func testInputLengthLimitTruncatesPasteWithoutSplittingUnicode() throws { + let field = PamInputField() + field.maximumLength = 4 + field.setTextFromRenderer("12") + XCTAssertFalse(field.textField(field, shouldChangeCharactersIn: NSRange(location: 2, length: 0), replacementString: "3456")) + XCTAssertEqual(field.text, "1234") + XCTAssertFalse(field.textField(field, shouldChangeCharactersIn: NSRange(location: 4, length: 0), replacementString: "5")) + XCTAssertTrue(field.textField(field, shouldChangeCharactersIn: NSRange(location: 3, length: 1), replacementString: "")) + field.maximumLength = 3 + field.setTextFromRenderer("ab") + XCTAssertFalse(field.textField(field, shouldChangeCharactersIn: NSRange(location: 2, length: 0), replacementString: "😀")) + XCTAssertEqual(field.text, "ab") + field.maximumLength = 2 + field.setTextFromRenderer("漢字入力") + field.unmarkText() + XCTAssertEqual(field.text, "漢字") + field.maximumLength = 1 + field.setTextFromRenderer("😀a") + field.unmarkText() + XCTAssertEqual(field.text, "") + field.maximumLength = nil + field.setTextFromRenderer("ab") + XCTAssertTrue(field.textField(field, shouldChangeCharactersIn: NSRange(location: 2, length: 0), replacementString: "long text")) + } + + func testInputTextTraitsApplyAndReset() throws { + let host = UIView() + let renderer = PamRenderer(hostView: host) { _, _, _ in } + defer { renderer.close() } + renderer.commit([[ + .create(NodeSpec(id: 1, parent: 0, index: 0, kind: .screen, properties: [:])), + .create(NodeSpec(id: 2, parent: 1, index: 0, kind: .input, properties: [ + PamConstants.testId: .text("traits-fixture"), + PamConstants.autoComplete: .text("one-time-code"), + PamConstants.returnKeyType: .integer(2), + PamConstants.inputAutoCorrect: .flag(false), + PamConstants.inputAutoCapitalize: .integer(1), + ])), + .setRoot(1), + ]]) + let field = try XCTUnwrap(host.descendant(accessibilityIdentifier: "traits-fixture") as? UITextField) + XCTAssertEqual(field.autocorrectionType, .no) + XCTAssertEqual(field.textContentType, .oneTimeCode) + XCTAssertEqual(field.returnKeyType, .done) + XCTAssertEqual(field.autocapitalizationType, .none) + renderer.commit([[ + .update(id: 2, key: PamConstants.inputAutoCorrect, value: .flag(true)), + .update(id: 2, key: PamConstants.autoComplete, value: .text("new-password")), + .update(id: 2, key: PamConstants.returnKeyType, value: .integer(5)), + .update(id: 2, key: PamConstants.inputAutoCapitalize, value: .integer(3)), + ]]) + XCTAssertEqual(field.autocorrectionType, .yes) + XCTAssertEqual(field.textContentType, .newPassword) + XCTAssertEqual(field.returnKeyType, .search) + XCTAssertEqual(field.autocapitalizationType, .words) + renderer.commit([[ + .update(id: 2, key: PamConstants.inputAutoCorrect, value: nil), + .update(id: 2, key: PamConstants.autoComplete, value: nil), + .update(id: 2, key: PamConstants.returnKeyType, value: nil), + .update(id: 2, key: PamConstants.inputAutoCapitalize, value: nil), + ]]) + XCTAssertEqual(field.autocorrectionType, .default) + XCTAssertNil(field.textContentType) + XCTAssertEqual(field.returnKeyType, .default) + XCTAssertEqual(field.autocapitalizationType, .sentences) + } + + func testSecureInputTogglePreservesTextAndSelection() throws { + let host = UIView() + let renderer = PamRenderer(hostView: host) { _, _, _ in } + defer { renderer.close() } + renderer.commit([[ + .create(NodeSpec(id: 1, parent: 0, index: 0, kind: .screen, properties: [:])), + .create(NodeSpec(id: 2, parent: 1, index: 0, kind: .input, properties: [ + PamConstants.testId: .text("secure-fixture"), + PamConstants.value: .text("sample-secret"), + PamConstants.secure: .flag(true), + ])), + .setRoot(1), + ]]) + let field = try XCTUnwrap(host.descendant(accessibilityIdentifier: "secure-fixture") as? UITextField) + XCTAssertTrue(field.isSecureTextEntry) + let start = try XCTUnwrap(field.position(from: field.beginningOfDocument, offset: 2)) + let end = try XCTUnwrap(field.position(from: field.beginningOfDocument, offset: 5)) + field.selectedTextRange = field.textRange(from: start, to: end) + for secure in [false, true, false] { + renderer.commit([[.update(id: 2, key: PamConstants.secure, value: .flag(secure))]]) + XCTAssertEqual(field.isSecureTextEntry, secure) + XCTAssertEqual(field.text, "sample-secret") + let selection = try XCTUnwrap(field.selectedTextRange) + XCTAssertEqual(field.offset(from: field.beginningOfDocument, to: selection.start), 2) + XCTAssertEqual(field.offset(from: field.beginningOfDocument, to: selection.end), 5) + } + renderer.commit([[.update(id: 2, key: PamConstants.secure, value: .flag(true))]]) + renderer.commit([[.update(id: 2, key: PamConstants.secure, value: nil)]]) + XCTAssertFalse(field.isSecureTextEntry) + } + + func testInputKeyboardMappingPrecedenceAndRemoval() throws { + let host = UIView() + let renderer = PamRenderer(hostView: host) { _, _, _ in } + defer { renderer.close() } + renderer.commit([[ + .create(NodeSpec(id: 1, parent: 0, index: 0, kind: .screen, properties: [:])), + .create(NodeSpec(id: 2, parent: 1, index: 0, kind: .input, properties: [ + PamConstants.testId: .text("keyboard-fixture"), + PamConstants.keyboardType: .integer(5), + ])), + .setRoot(1), + ]]) + let field = try XCTUnwrap(host.descendant(accessibilityIdentifier: "keyboard-fixture") as? UITextField) + XCTAssertEqual(field.keyboardType, .numbersAndPunctuation) + renderer.commit([[.update(id: 2, key: PamConstants.inputMode, value: .integer(4))]]) + XCTAssertEqual(field.keyboardType, .numberPad) + renderer.commit([[.update(id: 2, key: PamConstants.inputMode, value: .integer(2))]]) + XCTAssertNotNil(field.inputView) + renderer.commit([[.update(id: 2, key: PamConstants.inputMode, value: nil)]]) + XCTAssertNil(field.inputView) + XCTAssertEqual(field.keyboardType, .numbersAndPunctuation) + renderer.commit([[.update(id: 2, key: PamConstants.keyboardType, value: nil)]]) + XCTAssertEqual(field.keyboardType, .default) + } + + func testNativeVisibilityRejectsForeignAndRemovedChildren() throws { + let host = UIView() + let factory = VisibilityFixtureFactory() + let renderer = PamRenderer(hostView: host, nativeViews: ["fixture.visibility": factory]) { _, _, _ in } + renderer.commit([[ + .create(NodeSpec(id: 1, parent: 0, index: 0, kind: .screen, properties: [:])), + .create(NodeSpec(id: 2, parent: 1, index: 0, kind: .customView, + properties: [PamConstants.hostName: .text("fixture.visibility")])), + .create(NodeSpec(id: 3, parent: 2, index: 0, kind: .column, properties: [:])), + .setRoot(1), + ]]) + let child = try XCTUnwrap(factory.view.subviews.first) + var requests: [Bool] = [] + renderer.onNativeChildVisibility = { owner, childId, visible in + XCTAssertEqual(owner, 2) + XCTAssertEqual(childId, 3) + requests.append(visible) + } + let foreignChild = UIView() + factory.view.onChildVisibilityChanged?(foreignChild, false) + factory.view.onChildVisibilityChanged?(child, false) + factory.view.onChildVisibilityChanged?(child, true) + let drained = expectation(description: "queued visibility requests") + DispatchQueue.main.async { drained.fulfill() } + wait(for: [drained], timeout: 1) + XCTAssertEqual(requests, [false, true]) + + factory.view.onChildVisibilityChanged?(child, false) + renderer.close() + XCTAssertNil(factory.view.onChildVisibilityChanged) + let closedQueue = expectation(description: "stale visibility request discarded") + DispatchQueue.main.async { closedQueue.fulfill() } + wait(for: [closedQueue], timeout: 1) + XCTAssertEqual(requests, [false, true]) + } + func testVoiceOverExposesSemanticRoleStateValueAndImportance() throws { let host = UIView(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) let renderer = PamRenderer(hostView: host) { _, _, _ in } diff --git a/ios/Tests/PamNativeTests/PamScrollIndicatorStyleTests.swift b/ios/Tests/PamNativeTests/PamScrollIndicatorStyleTests.swift new file mode 100644 index 00000000..cd54bf2e --- /dev/null +++ b/ios/Tests/PamNativeTests/PamScrollIndicatorStyleTests.swift @@ -0,0 +1,16 @@ +import XCTest +@testable import PamNative + +final class PamScrollIndicatorStyleTests: XCTestCase { + func testAppearanceUsesSequentialWireValuesAndNativeStyles() { + XCTAssertEqual(PamConstants.scrollIndicatorStyle, 465) + XCTAssertEqual(PamScrollIndicatorStyle.auto.rawValue, 1) + XCTAssertEqual(PamScrollIndicatorStyle.dark.rawValue, 2) + XCTAssertEqual(PamScrollIndicatorStyle.light.rawValue, 3) + XCTAssertEqual(PamScrollIndicatorStyle.auto.native, .default) + XCTAssertEqual(PamScrollIndicatorStyle.dark.native, .black) + XCTAssertEqual(PamScrollIndicatorStyle.light.native, .white) + XCTAssertNil(PamScrollIndicatorStyle(rawValue: 0)) + XCTAssertNil(PamScrollIndicatorStyle(rawValue: 4)) + } +} diff --git a/ios/Tests/PamNativeTests/PamVirtualCellInsertionTests.swift b/ios/Tests/PamNativeTests/PamVirtualCellInsertionTests.swift new file mode 100644 index 00000000..d7fb7c48 --- /dev/null +++ b/ios/Tests/PamNativeTests/PamVirtualCellInsertionTests.swift @@ -0,0 +1,38 @@ +import XCTest +import UIKit +@testable import PamNative + +final class PamVirtualCellInsertionTests: XCTestCase { + @MainActor + func testInsertedDescendantsMountWithoutReplacingVisibleCell() throws { + let host = UIView(frame: CGRect(x: 0, y: 0, width: 300, height: 400)) + let renderer = PamRenderer(hostView: host) { _, _, _ in } + defer { renderer.close() } + renderer.commit([[ + .create(NodeSpec(id: 1, parent: 0, index: 0, kind: .screen, properties: [:])), + .create(NodeSpec(id: 2, parent: 1, index: 0, kind: .virtualList, properties: [:])), + .create(NodeSpec(id: 3, parent: 2, index: 0, kind: .pressable, properties: [:])), + .layout(id: 1, frame: Frame(x: 0, y: 0, width: 300, height: 400)), + .layout(id: 2, frame: Frame(x: 0, y: 0, width: 300, height: 400)), + .layout(id: 3, frame: Frame(x: 0, y: 0, width: 300, height: 48)), + .setRoot(1), + ]]) + let row = try XCTUnwrap(host.viewWithTag(3)) + for _ in 0..<2 { + renderer.commit([[ + .create(NodeSpec(id: 4, parent: 3, index: 0, kind: .text, properties: [ + PamConstants.text: .text("Selected"), + ])), + .layout(id: 4, frame: Frame(x: 8, y: 8, width: 80, height: 24)), + ]]) + let label = try XCTUnwrap(host.viewWithTag(4) as? UILabel) + XCTAssertTrue(label.superview === row) + XCTAssertTrue(host.viewWithTag(3) === row) + XCTAssertEqual(label.text, "Selected") + XCTAssertEqual(label.frame.size, CGSize(width: 80, height: 24)) + XCTAssertEqual(row.subviews.filter { $0.tag == 4 }.count, 1) + renderer.commit([[.remove(4)]]) + XCTAssertNil(host.viewWithTag(4)) + } + } +} diff --git a/ios/include/pam_native_ios_bridge.h b/ios/include/pam_native_ios_bridge.h index 3a493736..9a8ec753 100644 --- a/ios/include/pam_native_ios_bridge.h +++ b/ios/include/pam_native_ios_bridge.h @@ -73,6 +73,13 @@ void pam_native_runtime_set_refresh_rate( double refresh_rate_hz ); +void pam_native_runtime_set_child_visibility( + uint64_t handle, + uint64_t owner, + uint64_t child, + bool visible +); + void pam_native_runtime_dispatch_event( uint64_t handle, int64_t node_id, diff --git a/native/pam_native_engine.h b/native/pam_native_engine.h index 0073595c..df936b12 100644 --- a/native/pam_native_engine.h +++ b/native/pam_native_engine.h @@ -86,6 +86,14 @@ PamStatus pam_native_engine_commit( size_t input_length, PamNativeBuffer *output ); +/* visible is 0 or 1; child must be a direct child of the custom-view owner. */ +PamStatus pam_native_engine_set_native_child_visibility( + PamNativeEngineHandle *handle, + uint64_t owner, + uint64_t child, + uint8_t visible, + PamNativeBuffer *output +); PamStatus pam_native_engine_last_error( const PamNativeEngineHandle *handle, PamNativeBuffer *output diff --git a/packages/native/README.md b/packages/native/README.md index 3d693042..3cf9651c 100644 --- a/packages/native/README.md +++ b/packages/native/README.md @@ -393,6 +393,14 @@ Scroll::make($content) ->onScroll($rememberOffset); ``` +Use `->showsIndicator()->indicatorStyle(ScrollIndicatorStyle::Dark)` for a dark +indicator on a light surface, or `ScrollIndicatorStyle::Light` on a dark surface. +`Auto` retains the platform default (which may be low contrast on custom +surfaces). The typed enum uses wire values Auto=1, Dark=2, Light=3. Templates +accept `scrollIndicatorStyle="dark"` / `"light"` / `"auto"`. Android keeps its +system scrollbar geometry, fading and interaction, including API 26; UIKit +uses its native indicator styles. Appearance changes preserve scroll position. + Declarative components use `ScrollView` with direct children. PAM inserts the correct native content container, so compact horizontal items keep their authored widths and loops may render any number of children: diff --git a/packages/native/bin/pam-native b/packages/native/bin/pam-native index 9bec81fb..26ae8880 100755 --- a/packages/native/bin/pam-native +++ b/packages/native/bin/pam-native @@ -19,6 +19,32 @@ if ($platform === null) { exit(69); } +// The Composer distribution includes the native SDK beside this wrapper. +// A downloaded CLI has no reliable compile-time path back to that package. +$nativeHome = dirname(__DIR__, 3); +$configuredNativeHome = getenv('PAM_NATIVE_HOME'); +$configuredPamHome = getenv('PAM_HOME'); +$bundledNativeHome = is_string($configuredPamHome) && $configuredPamHome !== '' + ? realpath($configuredPamHome . '/native') : false; +$selectedNativeHome = is_string($configuredNativeHome) && $configuredNativeHome !== '' + ? realpath($configuredNativeHome) : false; +// Older PAM launchers inject their bundled SDK even when Composer resolved a +// newer Native package. Prefer the package version over that automatic default. +$usesBundledDefault = is_string($bundledNativeHome) && $selectedNativeHome === $bundledNativeHome; +if ((!is_string($configuredNativeHome) || $configuredNativeHome === '' || $usesBundledDefault) + && is_file($nativeHome . '/android/settings.gradle.kts')) { + putenv('PAM_NATIVE_HOME=' . $nativeHome); +} + +$pamBinary = getenv('PAM_BINARY'); +if ((!is_string($configuredPamHome) || $configuredPamHome === '') + && is_string($pamBinary) && $pamBinary !== '') { + $runtimeHome = dirname($pamBinary) . '/../share/pam'; + if (is_file($runtimeHome . '/runtime/catalog.json')) { + putenv('PAM_HOME=' . (realpath($runtimeHome) ?: $runtimeHome)); + } +} + $extension = PHP_OS_FAMILY === 'Windows' ? '.exe' : ''; $version = Protocol::SDK_VERSION; $artifact = "pam-native-cli-{$version}-{$platform}{$extension}"; diff --git a/packages/native/src/GridBreakpoint.php b/packages/native/src/GridBreakpoint.php new file mode 100644 index 00000000..5f4d29b5 --- /dev/null +++ b/packages/native/src/GridBreakpoint.php @@ -0,0 +1,34 @@ + 3.402823466e38) { + throw new InvalidArgumentException('Grid dimensions must be finite, nonnegative float32 values.'); + } + } + if ($columns < 1 || $columns > 64) { + throw new InvalidArgumentException('Grid columns must be between 1 and 64.'); + } + } + + public function toWire(): string + { + return implode(',', array_map( + static fn (int|float $value): string => json_encode($value, JSON_THROW_ON_ERROR), + [$this->minimumWidth, $this->columns, $this->columnGap, $this->rowGap], + )); + } +} diff --git a/packages/native/src/GridTemplate.php b/packages/native/src/GridTemplate.php new file mode 100644 index 00000000..65633252 --- /dev/null +++ b/packages/native/src/GridTemplate.php @@ -0,0 +1,57 @@ + */ + public array $levels; + + public function __construct(GridBreakpoint ...$levels) + { + $levels = array_values($levels); + if ($levels === [] || count($levels) > 6 || $levels[0]->minimumWidth !== 0.0) { + throw new InvalidArgumentException('Grid templates require one to six levels starting at zero.'); + } + $previous = -1.0; + foreach ($levels as $level) { + // The shared engine compares float32 thresholds, not PHP doubles. + $decoded = unpack('fwidth', pack('f', $level->minimumWidth)); + $width = $decoded === false ? null : $decoded['width']; + if (!is_float($width) || $width <= $previous) { + throw new InvalidArgumentException('Grid thresholds must be strictly increasing.'); + } + $previous = $width; + } + $this->levels = $levels; + if (strlen($this->toWire()) > 1024) { + throw new InvalidArgumentException('Grid template exceeds the native wire limit.'); + } + } + + public function toWire(): string + { + return implode(';', array_map(static fn (GridBreakpoint $level): string => $level->toWire(), $this->levels)); + } + + public static function fromWire(string $wire): self + { + if ($wire === '' || strlen($wire) > 1024 || substr_count($wire, ';') > 5) { + throw new InvalidArgumentException('Invalid grid template shape.'); + } + $levels = []; + foreach (explode(';', $wire) as $row) { + $fields = explode(',', $row); + if (count($fields) !== 4 || !ctype_digit($fields[1]) + || !is_numeric($fields[0]) || !is_numeric($fields[2]) || !is_numeric($fields[3])) { + throw new InvalidArgumentException('Invalid grid template level.'); + } + $levels[] = new GridBreakpoint((float) $fields[0], (int) $fields[1], (float) $fields[2], (float) $fields[3]); + } + return new self(...$levels); + } +} diff --git a/packages/native/src/Internal/TemplateRenderer.php b/packages/native/src/Internal/TemplateRenderer.php index 8c9684c7..666ae71d 100644 --- a/packages/native/src/Internal/TemplateRenderer.php +++ b/packages/native/src/Internal/TemplateRenderer.php @@ -38,6 +38,7 @@ use Pam\Native\InputAutoCapitalize; use Pam\Native\InputAutofillImportance; use Pam\Native\InputFormat; +use Pam\Native\ScrollIndicatorStyle; use Pam\Native\InputMode; use Pam\Native\InputSubmitBehavior; use Pam\Native\InputSyncMode; @@ -258,6 +259,7 @@ final class TemplateRenderer 'refreshIndicatorSize' => PropKey::RefreshIndicatorSize, 'scrollEnabled' => PropKey::ScrollEnabled, 'showsScrollIndicator' => PropKey::ShowsScrollIndicator, + 'scrollIndicatorStyle' => PropKey::ScrollIndicatorStyle, 'showsHorizontalScrollIndicator' => PropKey::ShowsScrollIndicator, 'showsVerticalScrollIndicator' => PropKey::ShowsScrollIndicator, 'contentOffsetX' => PropKey::ScrollContentOffsetX, @@ -443,6 +445,11 @@ final class TemplateRenderer 'keyboardVerticalOffset' => PropKey::KeyboardVerticalOffset, 'keyboardAvoidingEnabled' => PropKey::KeyboardAvoidingEnabled, 'columns' => PropKey::GridColumns, + 'gridMinColumnWidth' => PropKey::GridMinColumnWidth, + 'gridTemplate' => PropKey::GridTemplate, + 'span2xl' => PropKey::GridSpan2xl, + 'offset2xl' => PropKey::GridOffset2xl, + 'order2xl' => PropKey::GridOrder2xl, 'span' => PropKey::GridSpan, 'spanSm' => PropKey::GridSpanSm, 'spanMd' => PropKey::GridSpanMd, @@ -915,7 +922,7 @@ private static function tag( $inheritedStyles = []; } $attributes = [ - ...$inheritedStyles, + ...self::styleAttributes($inheritedStyles, 'inherited styles'), ...self::scopedStyleAttributes( $tag, $resolvedClass, @@ -1051,8 +1058,12 @@ private static function tag( $ownHandlers = []; foreach (self::EVENTS as $name => $event) { if (isset($attributes[$name])) { + $eventRaw = $attributes[$name]; + if (!is_string($eventRaw) && !is_bool($eventRaw)) { + throw new RuntimeException("Invalid template event expression {$name}."); + } $ownHandlers[$event->value] = self::handler( - $attributes[$name], + $eventRaw, $event, $scope, $data, @@ -1065,6 +1076,9 @@ private static function tag( if (!str_starts_with($name, '@')) { continue; } + if (!is_string($raw) && !is_bool($raw)) { + throw new RuntimeException("Invalid component event expression {$name}."); + } $event = substr($name, 1); if ( $event === '' @@ -1156,17 +1170,26 @@ private static function tag( } if ($tag === 'Animated' && isset($values['animation'])) { $animation = self::stringValue($values['animation'], 'Animated animation'); - $keyframes = $data['__pamStyles']['keyframes'][$animation] ?? null; + $animationSheet = $data['__pamStyles'] ?? null; + $knownKeyframes = is_array($animationSheet) ? ($animationSheet['keyframes'] ?? null) : null; + $keyframes = is_array($knownKeyframes) ? ($knownKeyframes[$animation] ?? null) : null; if (!is_array($keyframes)) { throw new RuntimeException("Unknown PAM keyframes {$animation}."); } - $values['keyframes'] = array_map( - static fn (array $frame): array => [ - 'offset' => $frame['offset'] ?? 0.0, - ...(is_array($frame['styles'] ?? null) ? $frame['styles'] : []), - ], - $keyframes, - ); + $frames = []; + foreach ($keyframes as $frame) { + if (!is_array($frame)) throw new RuntimeException('Invalid PAM keyframe.'); + $offset = $frame['offset'] ?? 0.0; + if ((!is_int($offset) && !is_float($offset)) || !is_finite((float) $offset) + || $offset < 0.0 || $offset > 1.0) { + throw new RuntimeException('Invalid PAM keyframe offset.'); + } + $frames[] = [ + ...self::styleAttributes($frame['styles'] ?? [], 'keyframe styles'), + 'offset' => $offset, + ]; + } + $values['keyframes'] = $frames; unset($values['animation']); } $contract = $factory !== null ? TemplateRegistry::tagContract($tag) : null; @@ -1483,6 +1506,7 @@ private static function tag( return $element; } + /** @param array $values */ private static function mediaCacheAttributes( Image|MediaPlayer $element, array $values, @@ -1582,7 +1606,7 @@ private static function mediaCachePolicy(mixed $value): MediaCachePolicy 'networkfirst' => MediaCachePolicy::NetworkFirst, 'cacheonly' => MediaCachePolicy::CacheOnly, 'stalewhilerevalidate' => MediaCachePolicy::StaleWhileRevalidate, - default => throw new RuntimeException("Unknown media cache policy {$value}."), + default => throw new RuntimeException("Unknown media cache policy {$normalized}."), }; } @@ -1596,7 +1620,7 @@ private static function mediaPriority(mixed $value): MediaPriority 'normal' => MediaPriority::Normal, 'visible' => MediaPriority::Visible, 'immediate' => MediaPriority::Immediate, - default => throw new RuntimeException("Unknown media priority {$value}."), + default => throw new RuntimeException("Unknown media priority {$normalized}."), }; } @@ -1976,6 +2000,11 @@ private static function propertyAppliesToKind( private static function propertyValue(PropKey $key, mixed $value): string|int|float|bool|null { return match ($key) { + PropKey::ScrollIndicatorStyle => self::named($value, [ + 'auto' => ScrollIndicatorStyle::Auto->value, + 'dark' => ScrollIndicatorStyle::Dark->value, + 'light' => ScrollIndicatorStyle::Light->value, + ]), PropKey::BackgroundColor, PropKey::TextColor, PropKey::BorderColor, @@ -2336,26 +2365,35 @@ private static function propertyValue(PropKey $key, mixed $value): string|int|fl 'none' => AnimationKind::None->value, 'pulse' => AnimationKind::Pulse->value, ]), + PropKey::GridTemplate => ($value instanceof \Pam\Native\GridTemplate + ? $value + : \Pam\Native\GridTemplate::fromWire(is_string($value) + ? $value + : throw new InvalidArgumentException('gridTemplate requires a GridTemplate or serialized string.')))->toWire(), PropKey::GridColumns, PropKey::GridSpan, PropKey::GridSpanSm, PropKey::GridSpanMd, PropKey::GridSpanLg, PropKey::GridSpanXl, + PropKey::GridSpan2xl, => max(1, min(64, (int) self::floatValue($value, "Grid {$key->name}"))), PropKey::GridOffset, PropKey::GridOffsetSm, PropKey::GridOffsetMd, PropKey::GridOffsetLg, PropKey::GridOffsetXl, + PropKey::GridOffset2xl, PropKey::GridOrder, PropKey::GridOrderSm, PropKey::GridOrderMd, PropKey::GridOrderLg, PropKey::GridOrderXl, + PropKey::GridOrder2xl, => max(0, (int) self::floatValue($value, "Grid {$key->name}")), PropKey::GridColumnGap, PropKey::GridRowGap, + PropKey::GridMinColumnWidth, => max(0.0, self::floatValue($value, "Grid {$key->name}")), PropKey::PressAndroidDisableSound, PropKey::RippleBorderless, @@ -2558,6 +2596,7 @@ private static function styleSheet(CompiledTemplateNode $tree): array ]; } + /** @return array */ private static function safeStyleMetadata(mixed $value, string $source, int $depth = 0): array { if (!is_array($value) || $depth > 12 || count($value) > 10_000) { @@ -2565,9 +2604,6 @@ private static function safeStyleMetadata(mixed $value, string $source, int $dep } $safe = []; foreach ($value as $key => $entry) { - if (!is_string($key) && !is_int($key)) { - throw new RuntimeException("Invalid Language 2 style IR key in {$source}."); - } if (is_array($entry)) { $safe[$key] = self::safeStyleMetadata($entry, $source, $depth + 1); } elseif (is_string($entry) || is_int($entry) || is_float($entry) || is_bool($entry) || $entry === null) { @@ -2581,17 +2617,23 @@ private static function safeStyleMetadata(mixed $value, string $source, int $dep /** * @param array $data - * @return array> + * @return array> */ private static function styleSheetClasses(array $data): array { $sheet = $data['__pamStyles'] ?? null; $classes = is_array($sheet) ? ($sheet['classes'] ?? null) : null; - $known = is_array($classes) ? $classes : []; + $known = self::validatedStyleRules(is_array($classes) ? $classes : [], ''); if (is_array($sheet)) { - foreach (($sheet['cascadeRules'] ?? []) as $rule) { - foreach (($rule['selector']['compounds'] ?? []) as $compound) { - foreach (($compound['classes'] ?? []) as $class) { + $rules = $sheet['cascadeRules'] ?? []; + if (!is_array($rules)) throw new RuntimeException('Invalid scoped class rules.'); + foreach ($rules as $rule) { + if (!is_array($rule) || !is_array($rule['selector'] ?? null)) continue; + $compounds = $rule['selector']['compounds'] ?? []; + if (!is_array($compounds)) continue; + foreach ($compounds as $compound) { + if (!is_array($compound) || !is_array($compound['classes'] ?? null)) continue; + foreach ($compound['classes'] as $class) { if (is_string($class) && $class !== '') { $known[$class] ??= []; } @@ -2604,7 +2646,8 @@ private static function styleSheetClasses(array $data): array /** * @param array $data - * @return array + * @param array $rawAttributes + * @return array */ private static function scopedStyleAttributes( string $tag, @@ -2617,7 +2660,7 @@ private static function scopedStyleAttributes( if (!is_array($sheet)) { return []; } - $sheet = self::responsiveStyleSheet(self::reactiveStyleSheet($sheet), $data); + $sheet = self::responsiveStyleSheet(self::reactiveStyleSheet(self::styleMap($sheet, 'scoped sheet')), $data); $descriptor = self::styleNodeDescriptor($tag, $classes, $rawAttributes, $scope, $data); $cascadeRules = is_array($sheet['cascadeRules'] ?? null) ? $sheet['cascadeRules'] : []; $attributes = $cascadeRules !== [] @@ -2701,7 +2744,9 @@ private static function scopedStyleAttributes( self::value($rawValue, $scope, $data), "recipe variant {$variant}", ); - $styles = $recipe['variants'][$variant][$choice] ?? null; + $recipeVariants = $recipe['variants'] ?? null; + $choices = is_array($recipeVariants) ? ($recipeVariants[$variant] ?? null) : null; + $styles = is_array($choices) ? ($choices[$choice] ?? null) : null; if (!is_array($styles)) { throw new RuntimeException( "Unknown PAM recipe variant {$recipeName}.{$variant}={$choice}.", @@ -2717,7 +2762,8 @@ private static function scopedStyleAttributes( $selectors[] = '.'.$class; } foreach ($selectors as $selector) { - $pressed = $stateRules[$selector]['pressed'] ?? null; + $selectorStates = $stateRules[$selector] ?? null; + $pressed = is_array($selectorStates) ? ($selectorStates['pressed'] ?? null) : null; if (!is_array($pressed)) { continue; } @@ -2732,7 +2778,9 @@ private static function scopedStyleAttributes( } } $nativeStates = []; - foreach (($sheet['stateRules'] ?? []) as $stateRule) { + $compiledStateRules = $sheet['stateRules'] ?? []; + if (!is_array($compiledStateRules)) throw new RuntimeException('Invalid compiled state rules.'); + foreach ($compiledStateRules as $stateRule) { if (!is_array($stateRule) || !is_array($stateRule['selector'] ?? null) || !self::styleSelectorMatches( @@ -2745,7 +2793,7 @@ private static function scopedStyleAttributes( $state = $stateRule['state'] ?? null; $declarations = $stateRule['declarations'] ?? []; if (!is_string($state) || !is_array($declarations)) continue; - $declarations = self::resolveDynamicStyles($declarations, $data); + $declarations = self::resolveDynamicStyles(self::styleAttributes($declarations, 'state declarations'), $data); if ($state === 'pressed') { if (isset($declarations['opacity'])) $attributes['pressedOpacity'] = $declarations['opacity']; if (isset($declarations['scaleX'], $declarations['scaleY']) && $declarations['scaleX'] === $declarations['scaleY']) { @@ -2769,7 +2817,7 @@ private static function scopedStyleAttributes( $attributes = [...$attributes, ...$declarations]; } foreach ($declarations as $attribute => $value) { - $property = is_string($attribute) ? (self::PROPERTIES[$attribute] ?? null) : null; + $property = self::PROPERTIES[$attribute] ?? null; if ($property !== null && in_array($property, [ PropKey::Opacity, PropKey::ScaleX, @@ -2792,10 +2840,14 @@ private static function scopedStyleAttributes( ); } - return self::resolveDynamicStyles($attributes, $data); + return self::resolveDynamicStyles(self::styleAttributes($attributes, 'scoped attributes'), $data); } - /** @param array $raw @param array $data @return array */ + /** + * @param array $raw + * @param array $data + * @return array{tag: string, id: string|null, classes: list, attributes: array, pseudos: list} + */ private static function styleNodeDescriptor(string $tag, ?string $classes, array $raw, ?object $scope, array $data): array { $attributes = []; @@ -2820,10 +2872,16 @@ private static function styleNodeDescriptor(string $tag, ?string $classes, array if (($attributes['value'] ?? null) === '' || ($attributes['items'] ?? null) === []) { $pseudos[] = 'empty'; } - return ['tag' => $tag, 'id' => isset($attributes['id']) ? (string) $attributes['id'] : null, 'classes' => $classList, 'attributes' => $attributes, 'pseudos' => $pseudos]; + $id = $attributes['id'] ?? null; + return ['tag' => $tag, 'id' => is_scalar($id) ? (string) $id : null, 'classes' => $classList, 'attributes' => $attributes, 'pseudos' => $pseudos]; } - /** @param list $rules @param array $node @param array $data @return array */ + /** + * @param array $rules + * @param array $node + * @param array $data + * @return array + */ private static function cascadeStyleAttributes(array $rules, array $node, array $data): array { $ancestors = is_array($data['__pamStyleAncestors'] ?? null) ? $data['__pamStyleAncestors'] : []; @@ -2833,29 +2891,50 @@ private static function cascadeStyleAttributes(array $rules, array $node, array continue; } $specificity = $rule['selector']['specificity'] ?? [0, 0, 0]; - $score = ((int) ($specificity[0] ?? 0) * 1_000_000) + ((int) ($specificity[1] ?? 0) * 1_000) + (int) ($specificity[2] ?? 0); - foreach (($rule['declarations'] ?? []) as $attribute => $entry) { + if (!is_array($specificity) || count($specificity) !== 3 + || !is_int($specificity[0] ?? null) || !is_int($specificity[1] ?? null) + || !is_int($specificity[2] ?? null) || min($specificity) < 0) { + throw new RuntimeException('Invalid selector specificity.'); + } + $declarations = $rule['declarations'] ?? []; + $order = $rule['order'] ?? 0; + if (!is_array($declarations) || !is_int($order)) { + throw new RuntimeException('Invalid cascade declarations or order.'); + } + foreach ($declarations as $attribute => $entry) { if (!is_array($entry) || !array_key_exists('value', $entry)) { continue; } - $rank = [(bool) ($entry['important'] ?? false) ? 1 : 0, $score, (int) ($rule['order'] ?? 0)]; + $value = $entry['value']; + if (!is_string($attribute) || (!is_string($value) && !is_int($value) && !is_bool($value))) { + throw new RuntimeException('Invalid cascade declaration value.'); + } + $rank = [(bool) ($entry['important'] ?? false) ? 1 : 0, + $specificity[0], $specificity[1], $specificity[2], $order]; if (!isset($winners[$attribute]) || $rank >= $winners[$attribute]['rank']) { - $winners[$attribute] = ['rank' => $rank, 'value' => $entry['value']]; + $winners[$attribute] = ['rank' => $rank, 'value' => $value]; } } } - return array_map(static fn (array $winner): mixed => $winner['value'], $winners); + return array_map(static fn (array $winner): string|int|bool => $winner['value'], $winners); } - /** @param array $selector @param array $node @param list $ancestors */ + /** + * @param array $selector + * @param array $node + * @param array $ancestors + */ private static function styleSelectorMatches(array $selector, array $node, array $ancestors): bool { $compounds = $selector['compounds'] ?? null; if (!is_array($compounds) || $compounds === []) return false; + $compounds = array_values($compounds); + $ancestors = array_values($ancestors); $index = count($compounds) - 1; if (!self::styleCompoundMatches($compounds[$index], $node)) return false; $ancestorIndex = count($ancestors) - 1; while ($index > 0) { + if (!is_array($compounds[$index])) return false; $relation = $compounds[$index]['combinator'] ?? 'descendant'; $index--; if ($relation === 'child') { @@ -2873,24 +2952,35 @@ private static function styleSelectorMatches(array $selector, array $node, array return true; } - /** @param array $compound */ - private static function styleCompoundMatches(array $compound, mixed $node): bool + private static function styleCompoundMatches(mixed $compound, mixed $node): bool { - if (!is_array($node)) return false; + if (!is_array($compound) || !is_array($node)) return false; $tag = $compound['tag'] ?? null; - if ($tag !== null && $tag !== '*' && strcasecmp((string) ($node['tag'] ?? ''), (string) $tag) !== 0) return false; + $nodeTag = $node['tag'] ?? ''; + if ($tag !== null && $tag !== '*' && (!is_string($tag) || !is_string($nodeTag) || strcasecmp($nodeTag, $tag) !== 0)) return false; if (($compound['id'] ?? null) !== null && ($node['id'] ?? null) !== $compound['id']) return false; - foreach (($compound['classes'] ?? []) as $class) if (!in_array($class, $node['classes'] ?? [], true)) return false; - foreach (($compound['pseudos'] ?? []) as $pseudo) { - if (in_array($pseudo, ['pressed', 'hover', 'focus', 'focus-visible', 'first-child', 'last-child'], true) || !in_array($pseudo, $node['pseudos'] ?? [], true)) return false; - } - foreach (($compound['attributes'] ?? []) as $condition) { + $classes = $compound['classes'] ?? []; + $nodeClasses = $node['classes'] ?? []; + $pseudos = $compound['pseudos'] ?? []; + $nodePseudos = $node['pseudos'] ?? []; + $conditions = $compound['attributes'] ?? []; + if (!is_array($classes) || !is_array($nodeClasses) || !is_array($pseudos) + || !is_array($nodePseudos) || !is_array($conditions)) return false; + foreach ($classes as $class) if (!is_string($class) || !in_array($class, $nodeClasses, true)) return false; + foreach ($pseudos as $pseudo) { + if (!is_string($pseudo) || in_array($pseudo, ['pressed', 'hover', 'focus', 'focus-visible', 'first-child', 'last-child'], true) || !in_array($pseudo, $nodePseudos, true)) return false; + } + foreach ($conditions as $condition) { $nodeAttributes = $node['attributes'] ?? []; - if (!is_array($nodeAttributes) || !array_key_exists($condition['name'], $nodeAttributes)) return false; + if (!is_array($condition) || !is_string($condition['name'] ?? null) + || !is_array($nodeAttributes) || !array_key_exists($condition['name'], $nodeAttributes)) return false; $operator = $condition['operator'] ?? ''; if ($operator === '') continue; - $actual = (string) $nodeAttributes[$condition['name']]; - $expected = (string) ($condition['value'] ?? ''); + $actualValue = $nodeAttributes[$condition['name']]; + $expectedValue = $condition['value'] ?? ''; + if ((!is_scalar($actualValue) && $actualValue !== null) || !is_scalar($expectedValue)) return false; + $actual = (string) $actualValue; + $expected = (string) $expectedValue; $matches = match ($operator) { '=' => $actual === $expected, '~=' => in_array($expected, preg_split('/\s+/', $actual) ?: [], true), @@ -2904,15 +2994,15 @@ private static function styleCompoundMatches(array $compound, mixed $node): bool } /** - * @param array $attributes + * @param array $attributes * @param array $data * @return array */ private static function resolveDynamicStyles(array $attributes, array $data): array { $metrics = Runtime::windowMetrics(); - $containerWidth = $data['__pamContainerWidth'] ?? $metrics->width; - $containerHeight = $data['__pamContainerHeight'] ?? $metrics->height; + $containerWidth = self::styleDimension($data['__pamContainerWidth'] ?? $metrics->width, 'container width'); + $containerHeight = self::styleDimension($data['__pamContainerHeight'] ?? $metrics->height, 'container height'); $provided = is_array($data['__pamStyleEnvironment'] ?? null) ? $data['__pamStyleEnvironment'] : []; @@ -2920,10 +3010,10 @@ private static function resolveDynamicStyles(array $attributes, array $data): ar 'width' => $metrics->width, 'height' => $metrics->height, 'fontScale' => is_numeric($provided['fontScale'] ?? null) - ? (float) $provided['fontScale'] + ? self::styleDimension($provided['fontScale'], 'font scale') : $metrics->fontScale, 'rootFontSize' => is_numeric($provided['rootFontSize'] ?? null) - ? (float) $provided['rootFontSize'] + ? self::styleDimension($provided['rootFontSize'], 'root font size') : 16.0, 'env.safe-area-inset-top' => $metrics->safeAreaTop, 'env.safe-area-inset-right' => $metrics->safeAreaRight, @@ -2932,6 +3022,9 @@ private static function resolveDynamicStyles(array $attributes, array $data): ar ]; foreach ($provided as $name => $value) { if (is_string($name) && (is_int($value) || is_float($value))) { + if (!is_finite((float) $value)) { + throw new InvalidArgumentException("Nonfinite style environment value: {$name}."); + } $environment[$name] = $value; } } @@ -2952,26 +3045,51 @@ private static function resolveDynamicStyles(array $attributes, array $data): ar continue; } $environment['reference'] = isset($vertical[$name]) - ? (float) $containerHeight - : (float) $containerWidth; + ? $containerHeight + : $containerWidth; $attributes[$name] = StyleValueCompiler::resolve($value, $environment); } return $attributes; } - /** @param array $sheet @return array */ + private static function styleDimension(mixed $value, string $label): float + { + if (!is_numeric($value) || !is_finite((float) $value) || (float) $value < 0.0) { + throw new InvalidArgumentException("Invalid style {$label}: expected a finite nonnegative number."); + } + return (float) $value; + } + + /** + * @param array $sheet + * @return array + */ private static function reactiveStyleSheet(array $sheet): array { $overrides = StyleVariables::all(); $bindings = $sheet['variableRules'] ?? []; if ($overrides === [] || !is_array($bindings) || $bindings === []) return $sheet; - $fingerprint = is_string($sheet['styleFingerprint'] ?? null) + $fingerprint = is_string($sheet['styleFingerprint'] ?? null) && $sheet['styleFingerprint'] !== '' ? $sheet['styleFingerprint'] - : hash('sha256', serialize($bindings)); + : hash('sha256', serialize($sheet)); $key = $fingerprint.':'.StyleVariables::revision(); if (isset(self::$reactiveStyleCache[$key])) return self::$reactiveStyleCache[$key]; - $variables = is_array($sheet['variables'] ?? null) ? $sheet['variables'] : []; + $variables = []; + $rawVariables = $sheet['variables'] ?? []; + if (!is_array($rawVariables)) { + throw new RuntimeException('Invalid reactive style variables.'); + } + foreach ($rawVariables as $name => $value) { + if (!is_string($name) || !is_string($value)) { + throw new RuntimeException('Reactive style variables require string names and values.'); + } + $variables[$name] = $value; + } + $rules = $sheet['cascadeRules'] ?? []; + if (!is_array($rules)) { + throw new RuntimeException('Invalid reactive style cascade rules.'); + } foreach ($overrides as $name => $value) $variables[$name] = $value; foreach ($bindings as $binding) { if (!is_array($binding) @@ -2983,20 +3101,37 @@ private static function reactiveStyleSheet(array $sheet): array $variables, '', ); - foreach (($sheet['cascadeRules'] ?? []) as $index => $rule) { + foreach ($rules as $index => $rule) { if (!is_array($rule) || ($rule['order'] ?? null) !== $binding['order'] + || !is_array($rule['selector'] ?? null) || ($rule['selector']['source'] ?? null) !== $binding['selector']) continue; + $ruleDeclarations = $rule['declarations'] ?? []; + if (!is_array($ruleDeclarations)) { + throw new RuntimeException('Invalid reactive style declarations.'); + } foreach ($declarations as $attribute => $value) { - $sheet['cascadeRules'][$index]['declarations'][$attribute]['value'] = $value; + $declaration = $ruleDeclarations[$attribute] ?? []; + if (!is_array($declaration)) { + throw new RuntimeException('Invalid reactive style declaration.'); + } + $declaration['value'] = $value; + $ruleDeclarations[$attribute] = $declaration; } + $rule['declarations'] = $ruleDeclarations; + $rules[$index] = $rule; } } + $sheet['cascadeRules'] = $rules; if (count(self::$reactiveStyleCache) >= 64) self::$reactiveStyleCache = []; return self::$reactiveStyleCache[$key] = $sheet; } - /** @param array $sheet @param array $data */ + /** + * @param array $sheet + * @param array $data + * @return array + */ private static function responsiveStyleSheet(array $sheet, array $data): array { $queries = $sheet['queries'] ?? []; @@ -3043,31 +3178,41 @@ private static function responsiveStyleSheet(array $sheet, array $data): array if (!self::queryMatches($condition, $ast, $environment)) { continue; } - $queryStyles = self::reactiveStyleSheet($query['styles']); + $queryStyles = self::reactiveStyleSheet(self::styleMap($query['styles'], 'responsive sheet')); foreach (['classes', 'tags'] as $group) { - $incoming = $queryStyles[$group] ?? []; - if (!is_array($incoming)) { - continue; - } + $incoming = self::validatedStyleRules($queryStyles[$group] ?? [], ''); + $base = self::validatedStyleRules($sheet[$group] ?? [], ''); foreach ($incoming as $selector => $styles) { - if (is_string($selector) && is_array($styles)) { - $sheet[$group][$selector] = [ - ...($sheet[$group][$selector] ?? []), - ...$styles, - ]; - } + $base[$selector] = [...($base[$selector] ?? []), ...$styles]; } + $sheet[$group] = $base; } $incomingRules = $queryStyles['cascadeRules'] ?? []; if (is_array($incomingRules)) { - $baseOrder = count(is_array($sheet['cascadeRules'] ?? null) ? $sheet['cascadeRules'] : []); + $baseRules = $sheet['cascadeRules'] ?? []; + if (!is_array($baseRules)) { + throw new RuntimeException('Invalid responsive base cascade.'); + } + $baseOrder = 0; + foreach ($baseRules as $baseRule) { + $order = is_array($baseRule) ? ($baseRule['order'] ?? 0) : null; + if (!is_int($order) || $order < 0 || $order === PHP_INT_MAX) { + throw new RuntimeException('Invalid responsive base rule order.'); + } + $baseOrder = max($baseOrder, $order + 1); + } foreach ($incomingRules as $incomingRule) { if (!is_array($incomingRule)) { continue; } - $incomingRule['order'] = $baseOrder + (int) ($incomingRule['order'] ?? 0); - $sheet['cascadeRules'][] = $incomingRule; + $order = $incomingRule['order'] ?? 0; + if (!is_int($order) || $order < 0 || $order > PHP_INT_MAX - $baseOrder) { + throw new RuntimeException('Invalid responsive rule order.'); + } + $incomingRule['order'] = $baseOrder + $order; + $baseRules[] = $incomingRule; } + $sheet['cascadeRules'] = $baseRules; } // Query rules are later than base rules by definition. $sheet['classCascade'] = []; @@ -3075,13 +3220,49 @@ private static function responsiveStyleSheet(array $sheet, array $data): array return $sheet; } + /** @return array */ + private static function styleAttributes(mixed $value, string $label): array + { + if (!is_array($value)) throw new RuntimeException("Invalid {$label}."); + $attributes = []; + foreach ($value as $key => $entry) { + if (!is_string($key) || !is_scalar($entry) || (is_float($entry) && !is_finite($entry))) { + throw new RuntimeException("Invalid {$label} attribute."); + } + $attributes[$key] = $entry; + } + return $attributes; + } + + /** @return array */ + private static function styleMap(mixed $value, string $label): array + { + if (!is_array($value)) throw new RuntimeException("Invalid {$label}."); + $map = []; + foreach ($value as $key => $entry) { + if (!is_string($key)) throw new RuntimeException("Invalid {$label} key."); + $map[$key] = $entry; + } + return $map; + } + + /** + * @param array|null $ast + * @param array $environment + */ private static function queryMatches( string $condition, ?array $ast, array $environment, ): bool { if ($ast !== null) { - return StyleQueryCompiler::matches($ast, $environment); + $queryEnvironment = []; + foreach ($environment as $key => $value) { + if (is_string($key) && (is_scalar($value) || $value === null)) { + $queryEnvironment[$key] = $value; + } + } + return StyleQueryCompiler::matches($ast, $queryEnvironment); } if (preg_match('/\((min|max)-(width|height):\s*([0-9]+(?:\.[0-9]+)?)(?:dp|px)\)/D', $condition, $match) !== 1) { return false; @@ -3103,13 +3284,13 @@ private static function styleSheetFonts(array $data): array $sheet = $data['__pamStyles'] ?? null; $fonts = is_array($sheet) ? ($sheet['fonts'] ?? null) : null; - return is_array($fonts) ? $fonts : []; + return self::validatedFontFaces(is_array($fonts) ? $fonts : [], ''); } /** - * @param array $attributes + * @param array $attributes * @param array> $fonts - * @return array + * @return array */ private static function resolveScopedFont(array $attributes, array $fonts): array { @@ -3330,8 +3511,8 @@ private static function custom( } /** - * @param array $attributes - * @return array + * @param array $attributes + * @return array */ private static function nativeEventAliases(array $attributes): array { @@ -3397,7 +3578,7 @@ private static function componentSlotNodes(array $nodes): array } /** - * @param array $attributes + * @param array $attributes * @param array $data */ private static function classValue( diff --git a/packages/native/src/PropKey.php b/packages/native/src/PropKey.php index ce7a3d64..0ee7fe71 100644 --- a/packages/native/src/PropKey.php +++ b/packages/native/src/PropKey.php @@ -470,4 +470,10 @@ enum PropKey: int case GestureNativeTranslationLimitX = 462; case GestureNativeResetOnEnd = 463; case InputFormatLocale = 464; + case ScrollIndicatorStyle = 465; + case GridMinColumnWidth = 466; + case GridTemplate = 467; + case GridSpan2xl = 468; + case GridOffset2xl = 469; + case GridOrder2xl = 470; } diff --git a/packages/native/src/Protocol.php b/packages/native/src/Protocol.php index 7b1165c8..1b48fec6 100644 --- a/packages/native/src/Protocol.php +++ b/packages/native/src/Protocol.php @@ -6,7 +6,7 @@ final class Protocol { - public const string SDK_VERSION = '1.0.29'; + public const string SDK_VERSION = '1.0.30'; public const int ABI_VERSION = 1; public const int MINIMUM_VERSION = 1; public const int VERSION = 1; diff --git a/packages/native/src/ScrollIndicatorStyle.php b/packages/native/src/ScrollIndicatorStyle.php new file mode 100644 index 00000000..1a4c91ab --- /dev/null +++ b/packages/native/src/ScrollIndicatorStyle.php @@ -0,0 +1,13 @@ +value => $this->translationXPercent, PropKey::AnimationKind->value => $this->animationKind?->value, PropKey::GridColumns->value => $this->gridColumns, + PropKey::GridMinColumnWidth->value => $this->gridMinColumnWidth, + PropKey::GridTemplate->value => $this->gridTemplate?->toWire(), + PropKey::GridSpan2xl->value => $this->gridSpan2xl, + PropKey::GridOffset2xl->value => $this->gridOffset2xl, + PropKey::GridOrder2xl->value => $this->gridOrder2xl, PropKey::GridSpan->value => $this->gridSpan, PropKey::GridSpanSm->value => $this->gridSpanSm, PropKey::GridSpanMd->value => $this->gridSpanMd, diff --git a/packages/native/src/UI/Scroll.php b/packages/native/src/UI/Scroll.php index 06acde53..59108820 100644 --- a/packages/native/src/UI/Scroll.php +++ b/packages/native/src/UI/Scroll.php @@ -11,6 +11,7 @@ use Pam\Native\PropKey; use Pam\Native\Renderable; use Pam\Native\ScrollKeyboardDismissMode; +use Pam\Native\ScrollIndicatorStyle; use Pam\Native\ScrollOverScrollMode; use Pam\Native\ScrollTargetAlignment; @@ -36,6 +37,11 @@ public function horizontal(bool $horizontal = true): self return $this->withProperty(PropKey::ScrollHorizontal, $horizontal); } + public function indicatorStyle(ScrollIndicatorStyle $style): self + { + return $this->withProperty(PropKey::ScrollIndicatorStyle, $style->value); + } + public function contentOffset(float $x = 0.0, float $y = 0.0): self { return $this diff --git a/packages/native/tests/language2.php b/packages/native/tests/language2.php index 8b26234c..e7c3f1a2 100644 --- a/packages/native/tests/language2.php +++ b/packages/native/tests/language2.php @@ -251,6 +251,44 @@ final class ModuleStyleExample {} 'calc(env(safe-area-inset-top) + 8dp)', 'SafeInset.pam', ); +$containerStyles = ScopedStyleCompiler::compile( + '.relative { width: calc(50% + 8px); height: calc(25% + 4px); }', + 'ContainerDimensions.pam', +); +$containerTemplate = TemplateCompiler::compile('', 'ContainerDimensions.pam', LanguageVersion::Language2); +$containerRoot = new CompiledTemplateNode( + kind: $containerTemplate->kind, + name: $containerTemplate->name, + attributes: ['__pamStyles' => json_encode($containerStyles, JSON_THROW_ON_ERROR)], + source: $containerTemplate->source, + line: $containerTemplate->line, + column: $containerTemplate->column, +); +$containerRoot->children = $containerTemplate->children; +$relativeElement = TemplateRenderer::render($containerRoot, null, [ + '__pamContainerWidth' => '400', '__pamContainerHeight' => 800.0, +]); +$assert(($relativeElement->properties()[PropKey::Width->value] ?? null) === 208.0 + && ($relativeElement->properties()[PropKey::Height->value] ?? null) === 204.0, + 'Container math must use finite horizontal/vertical references and preserve numeric-string compatibility.'); +foreach ([[], new stdClass(), true, -1, INF, NAN, 'invalid'] as $invalidDimension) { + foreach (['__pamContainerWidth', '__pamContainerHeight'] as $dimensionKey) { + try { + TemplateRenderer::render($containerRoot, null, [$dimensionKey => $invalidDimension]); + throw new RuntimeException('Invalid container dimensions reached style evaluation.'); + } catch (InvalidArgumentException) { + $assert(true, 'Malformed container dimensions fail before style evaluation.'); + } + } +} +foreach (['fontScale', 'rootFontSize', 'env.safe-area-inset-top'] as $environmentKey) { + try { + TemplateRenderer::render($containerRoot, null, ['__pamStyleEnvironment' => [$environmentKey => INF]]); + throw new RuntimeException('Nonfinite style environment reached style evaluation.'); + } catch (InvalidArgumentException) { + $assert(true, 'Nonfinite style environment values fail before native dispatch.'); + } +} $assert( StyleValueCompiler::resolve($safeInset, [ 'width' => 400.0, @@ -310,6 +348,141 @@ final class ModuleStyleExample {} ); StyleVariables::replace([]); +$reactiveMethod = new ReflectionMethod(TemplateRenderer::class, 'reactiveStyleSheet'); +$attributesMethod = new ReflectionMethod(TemplateRenderer::class, 'styleAttributes'); +$assert($attributesMethod->invoke(null, ['width' => 12.5, 'fontWeight' => 600, 'visible' => false], 'test') + === ['width' => 12.5, 'fontWeight' => 600, 'visible' => false], + 'Style attribute validation must preserve fractional dimensions, integers and booleans.'); +foreach ([['width' => []], ['width' => new stdClass()], ['width' => INF], [0 => 'value']] as $invalidAttributes) { + try { + $attributesMethod->invoke(null, $invalidAttributes, 'test'); + throw new LogicException('Invalid style attributes were accepted.'); + } catch (RuntimeException) { + $assert(true, 'Invalid attribute maps fail before native conversion.'); + } +} +$tagMethod = new ReflectionMethod(TemplateRenderer::class, 'tag'); +try { + $tagMethod->invoke(null, 'Button', ['on:press' => 42], [], null, []); + throw new LogicException('Numeric event expression was accepted.'); +} catch (RuntimeException $eventError) { + $assert(str_contains($eventError->getMessage(), 'event expression'), 'Invalid event metadata has an explicit diagnostic.'); +} +$animationTemplate = TemplateCompiler::compile('', 'KeyframeValidation.pam', LanguageVersion::Language2); +foreach ([null, false, ['offset' => -0.1], ['offset' => 1.1], ['offset' => '0.5'], + ['offset' => 0.0, 'styles' => ['opacity' => []]]] as $invalidFrame) { + $frames = [ + $invalidFrame ?? ['offset' => 0.0, 'styles' => ['opacity' => 0.0, 'offset' => 0.8]], + ['offset' => 1.0, 'styles' => ['opacity' => 1.0]], + ]; + $animationRoot = new CompiledTemplateNode( + kind: $animationTemplate->kind, name: $animationTemplate->name, + attributes: ['__pamStyles' => json_encode(['classes' => [], 'tags' => [], 'keyframes' => ['enter' => $frames]], JSON_THROW_ON_ERROR)], + source: $animationTemplate->source, line: $animationTemplate->line, column: $animationTemplate->column, + ); + $animationRoot->children = $animationTemplate->children; + try { + $animationElement = TemplateRenderer::render($animationRoot, null, []); + if ($invalidFrame !== null) throw new LogicException('Malformed keyframe was accepted.'); + $animationPayload = $animationElement->properties()[PropKey::AnimationKeyframes->value] ?? null; + $assert($animationPayload instanceof \Pam\Native\Internal\BinaryValue + && json_decode($animationPayload->bytes, true, flags: JSON_THROW_ON_ERROR)[0]['offset'] === 0.0, + 'Valid keyframes render and frame styles cannot overwrite their timeline offset.'); + } catch (RuntimeException $keyframeError) { + if ($invalidFrame === null) throw $keyframeError; + $assert(str_contains($keyframeError->getMessage(), 'keyframe'), 'Malformed keyframes fail with a relevant template diagnostic.'); + } +} +$cascadeMethod = new ReflectionMethod(TemplateRenderer::class, 'cascadeStyleAttributes'); +$selectorMethod = new ReflectionMethod(TemplateRenderer::class, 'styleSelectorMatches'); +$specificityRules = [ + ['selector' => ['specificity' => [1, 0, 0], 'compounds' => [['id' => 'target']]], + 'order' => 0, 'declarations' => ['width' => ['value' => '10']]], + ['selector' => ['specificity' => [0, 1001, 0], 'compounds' => [['classes' => array_fill(0, 1001, 'a')]]], + 'order' => 1, 'declarations' => ['width' => ['value' => '20']]], +]; +$selectorNode = ['tag' => 'View', 'id' => 'target', 'classes' => ['a']]; +$assert($cascadeMethod->invoke(null, $specificityRules, $selectorNode, []) === ['width' => '10'], + 'One ID selector must outrank any number of class selectors without packed-score collisions.'); +$specificityRules[1]['declarations']['width']['important'] = true; +$assert($cascadeMethod->invoke(null, $specificityRules, $selectorNode, []) === ['width' => '20'], + 'Important declarations must retain precedence over selector specificity.'); +$assert($selectorMethod->invoke(null, ['compounds' => [ + 3 => ['tag' => 'Column'], 7 => ['tag' => 'Text', 'combinator' => 'child'], +]], ['tag' => 'Text'], [9 => ['tag' => 'Column']]) === true, + 'Selector and ancestor lists must match correctly with sparse storage keys.'); +foreach ([['compounds' => [false]], ['compounds' => [['classes' => false]]], + ['compounds' => [['attributes' => [['name' => []]]]]], + ['compounds' => [['attributes' => [['name' => 'value', 'operator' => '=', 'value' => 'x']]]]]] as $invalidSelector) { + $assert($selectorMethod->invoke(null, $invalidSelector, ['attributes' => ['value' => []]], []) === false, + 'Malformed selectors and nonscalar attribute comparisons must fail without invalid array/string operations.'); +} +$responsiveMethod = new ReflectionMethod(TemplateRenderer::class, 'responsiveStyleSheet'); +$responsiveSheet = [ + 'classes' => ['box' => ['width' => '20', 'fontSize' => '14']], + 'cascadeRules' => [['order' => 9]], + 'queries' => [[ + 'kind' => \Pam\Native\Style\StyleQueryKind::Container->value, + 'condition' => '(min-width: 100px)', + 'styles' => [ + 'classes' => ['box' => ['width' => '40']], + 'cascadeRules' => [['order' => 2]], + ], + ]], +]; +$responsiveResult = $responsiveMethod->invoke(null, $responsiveSheet, ['__pamContainerWidth' => 200.0]); +$assert(is_array($responsiveResult) + && ($responsiveResult['classes']['box'] ?? null) === ['width' => '40', 'fontSize' => '14'] + && ($responsiveResult['cascadeRules'][1]['order'] ?? null) === 12, + 'Responsive merges preserve base declarations and order incoming rules after sparse base orders.'); +$assert($responsiveMethod->invoke(null, $responsiveSheet, ['__pamContainerWidth' => 50.0]) === $responsiveSheet, + 'Unmatched container queries must leave the base sheet unchanged.'); +foreach ([['classes' => ['box' => false]], ['cascadeRules' => false], + ['cascadeRules' => [['order' => []]]], ['cascadeRules' => [['order' => PHP_INT_MAX]]]] as $invalidBase) { + try { + $responsiveMethod->invoke(null, [...$responsiveSheet, ...$invalidBase], ['__pamContainerWidth' => 200.0]); + throw new LogicException('Invalid responsive base metadata was accepted.'); + } catch (RuntimeException) { + $assert(true, 'Invalid responsive merge metadata fails before cascade application.'); + } +} +$fontsMethod = new ReflectionMethod(TemplateRenderer::class, 'styleSheetFonts'); +$fontFaces = $fontsMethod->invoke(null, ['__pamStyles' => ['fonts' => ['Display' => [ + 4 => ['source' => 'fonts/display.ttf', 'weight' => '700', 'style' => 'normal'], +]]]]); +$assert($fontFaces === ['Display' => [ + ['source' => 'fonts/display.ttf', 'weight' => '700', 'style' => 'normal'], +]], 'Font metadata must preserve valid faces and normalize the face list.'); +foreach ([['Display' => false], ['Display' => [['source' => []]]], [0 => []]] as $invalidFonts) { + try { + $fontsMethod->invoke(null, ['__pamStyles' => ['fonts' => $invalidFonts]]); + throw new LogicException('Invalid font metadata was accepted.'); + } catch (RuntimeException) { + $assert(true, 'Malformed font metadata fails before font resolution.'); + } +} +StyleVariables::replace(['space' => '20px']); +try { + $firstSheet = $reactiveStyles; + $firstSheet['styleFingerprint'] = ''; + $secondSheet = $firstSheet; + $secondSheet['variables']['surface'] = '#222222'; + $firstResolved = $reactiveMethod->invoke(null, $firstSheet); + $secondResolved = $reactiveMethod->invoke(null, $secondSheet); + $assert($firstResolved !== $secondResolved, + 'Sheets without fingerprints must not share reactive results when their base variables differ.'); + foreach ([['variables' => false], ['variables' => ['surface' => []]], ['cascadeRules' => false]] as $invalidSheetPart) { + try { + $reactiveMethod->invoke(null, [...$firstSheet, ...$invalidSheetPart]); + throw new LogicException('Invalid reactive metadata was accepted.'); + } catch (RuntimeException) { + $assert(true, 'Malformed reactive metadata fails before cascade evaluation.'); + } + } +} finally { + StyleVariables::replace([]); +} + $nativeResourceStyles = ScopedStyleCompiler::compile( '.native-theme { -pam-native-background-color: colorSurface; -pam-native-text-color: label_primary; -pam-native-border-color: accent-color; }', 'NativeResources.pam', diff --git a/packages/native/tests/run.php b/packages/native/tests/run.php index 8e45f4ca..0a35cc48 100644 --- a/packages/native/tests/run.php +++ b/packages/native/tests/run.php @@ -133,6 +133,7 @@ use Pam\Native\SafeAreaMode; use Pam\Native\ScrollKeyboardDismissMode; use Pam\Native\ScrollOverScrollMode; +use Pam\Native\ScrollIndicatorStyle; use Pam\Native\ScrollTargetAlignment; use Pam\Native\ServerDriven\ServerDrivenUi; use Pam\Native\PositionType; @@ -1620,7 +1621,7 @@ static function (InputKeyEvent $event) use (&$inputKey): void { ); $scrollViewElement = TemplateRenderer::render( TemplateCompiler::compile( - '' + '' .'New' .'Saved' .'', @@ -1631,6 +1632,7 @@ static function (InputKeyEvent $event) use (&$inputKey): void { $scrollContent = $scrollViewElement->children()[0] ?? null; $assert( $scrollViewElement->kind() === NodeKind::Scroll + && $scrollViewElement->properties()[PropKey::ScrollIndicatorStyle->value] === ScrollIndicatorStyle::Dark->value && $scrollContent instanceof \Pam\Native\Element && $scrollContent->kind() === NodeKind::Row && count($scrollContent->children()) === 2 @@ -1984,10 +1986,12 @@ static function (ModalOrientation $orientation) use ( ->scrollRequest(7, 'first-unread', 143.5, ScrollTargetAlignment::Center) ->scrollEnabled(false) ->showsIndicator() + ->indicatorStyle(ScrollIndicatorStyle::Dark) ->onScroll(static function (): void { }); $assert( $scrollElement->properties()[PropKey::ScrollHorizontal->value] === true + && $scrollElement->properties()[PropKey::ScrollIndicatorStyle->value] === ScrollIndicatorStyle::Dark->value && $scrollElement->properties()[PropKey::ScrollContentOffsetX->value] === 24.0 && $scrollElement->properties()[PropKey::ScrollContentOffsetY->value] === 8.0 && $scrollElement->properties()[PropKey::ScrollFillViewport->value] === false @@ -2827,6 +2831,40 @@ public function selectAction(string $name): void && $gridElement->children()[0]->children()[1]->kind() === NodeKind::Pressable, 'Responsive grids must compile rich cells identically from explicit properties and utility classes.', ); +$autoFitTemplate = TemplateRenderer::render( + TemplateCompiler::compile('Adaptive'), + null, + [], +); +$assert( + $autoFitTemplate->properties()[PropKey::GridMinColumnWidth->value] === 120.0 + && (new \Pam\Native\Style(gridMinColumnWidth: 120.0))->properties()[PropKey::GridMinColumnWidth->value] === 120.0, + 'Auto-fit minimum width must compile through templates and typed Style.', +); +$virtualGridTemplate = TemplateRenderer::render( + TemplateCompiler::compile(''), + null, + [], +); +$gridWire = '0,2,8,4;640,3,12,8;768,4,16,12;1024,4,20,16;1280,5,24,20;1536,6,28,24'; +$typedGridPlan = \Pam\Native\GridTemplate::fromWire($gridWire); +$typedGridStyle = new \Pam\Native\Style(gridTemplate: $typedGridPlan, gridSpan2xl: 2, gridOffset2xl: 1, gridOrder2xl: 3); +$assert($typedGridPlan->toWire() === $gridWire + && $typedGridStyle->properties()[PropKey::GridTemplate->value] === $gridWire + && $virtualGridTemplate->properties()[PropKey::GridTemplate->value] === $gridWire + && $virtualGridTemplate->children()[0]->properties()[PropKey::GridSpan2xl->value] === 2 + && $virtualGridTemplate->children()[0]->properties()[PropKey::GridOffset2xl->value] === 1 + && $virtualGridTemplate->children()[0]->properties()[PropKey::GridOrder2xl->value] === 3, + 'Typed and template responsive plans must preserve all six levels and sixth-tier child properties.'); +foreach (['', '1,2,0,0', '0,0,0,0', '0,65,0,0', '0,2,-1,0', '0,2,INF,0', '0,2,0,0;0,3,0,0', '0,2,0,0;', + '0,2,0,0;640,3,0,0;640.000001,4,0,0', '0,2,0,0;1e-100,3,0,0'] as $invalidGridWire) { + try { + \Pam\Native\GridTemplate::fromWire($invalidGridWire); + throw new RuntimeException('Invalid grid plan was accepted.'); + } catch (InvalidArgumentException) { + $assert(true, 'Invalid grid plans fail before native dispatch.'); + } +} $virtualGridTemplate = TemplateRenderer::render( TemplateCompiler::compile( 'One', @@ -6648,8 +6686,8 @@ public function render(): Renderable 'Permanent drawer callbacks must not leak an open modal drawer into the compact layout after rotation.', ); $assert( - \Pam\Native\Protocol::SDK_VERSION === '1.0.29', - 'The runtime SDK contract must match the 1.0.29 release candidate.', + \Pam\Native\Protocol::SDK_VERSION === '1.0.30', + 'The runtime SDK contract must match the 1.0.30 release candidate.', ); $protocolReport = \Pam\Native\Protocol::negotiate(new \Pam\Native\ProtocolHandshake( abiVersion: 1,