From b79f5b456d9fd87192a102372846f00476ea5109 Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Sun, 13 Sep 2026 14:40:25 -0300 Subject: [PATCH 01/58] fix: measure intrinsic text height at child cross-axis width --- crates/pam-native-engine/src/layout.rs | 70 ++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/crates/pam-native-engine/src/layout.rs b/crates/pam-native-engine/src/layout.rs index d3ab430c..1324c02c 100644 --- a/crates/pam-native-engine/src/layout.rs +++ b/crates/pam-native-engine/src/layout.rs @@ -1791,6 +1791,15 @@ fn constrained_intrinsic_extent( text_metrics: &TextMetrics, depth: usize, ) -> Result { + // Height depends on wrapping at the child's own width, not the whole + // parent's width. The cross-axis containing width is definite here even + // when the height being measured is intrinsically sized. + let available_width = if axis == Axis::Vertical { + dimension(node, PropKey::Width, PropKey::WidthPercent, available_width) + .unwrap_or(available_width) + } else { + available_width + }; let extent = intrinsic_extent( children, node, @@ -2628,6 +2637,67 @@ mod tests { assert_eq!(child.y, 200.0); } + #[test] + fn auto_row_height_respects_nested_text_column_width() { + for (width_key, width_value) in [(PropKey::Width, 180.0), (PropKey::WidthPercent, 45.0)] { + let tree = Tree { + root: 1, + nodes: BTreeMap::from([ + (1, node(1, 0, 0, NodeKind::Screen, [])), + (2, node(2, 1, 0, NodeKind::Row, [])), + ( + 3, + node( + 3, + 2, + 0, + NodeKind::Column, + [(width_key, PropValue::Float(width_value))], + ), + ), + ( + 4, + node( + 4, + 3, + 0, + NodeKind::Text, + [ + (PropKey::Text, PropValue::String("Currency Field".into())), + (PropKey::FontSize, PropValue::Float(24.0)), + (PropKey::LineHeight, PropValue::Float(30.0)), + ], + ), + ), + ( + 5, + node( + 5, + 2, + 1, + NodeKind::View, + [ + (PropKey::Width, PropValue::Float(48.0)), + (PropKey::Height, PropValue::Float(48.0)), + ], + ), + ), + ]), + }; + let layouts = calculate_with_text_scale( + &tree, + Size { + width: 400.0, + height: 800.0, + }, + 2.0, + ) + .expect("scaled header layout"); + assert!(layouts[&4].height >= 120.0); + assert!(layouts[&2].height >= layouts[&4].height); + } + } + #[test] fn row_baseline_aligns_text_with_different_font_sizes() { let tree = Tree { From ef981e64aba43b28d919f36c5db38b52d7eae85d Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Sun, 13 Sep 2026 14:42:01 -0300 Subject: [PATCH 02/58] fix: resolve intrinsic percentage widths only at containing block --- crates/pam-native-engine/src/layout.rs | 41 +++++++++++++++++++------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/crates/pam-native-engine/src/layout.rs b/crates/pam-native-engine/src/layout.rs index 1324c02c..f0e6b0bd 100644 --- a/crates/pam-native-engine/src/layout.rs +++ b/crates/pam-native-engine/src/layout.rs @@ -1554,11 +1554,21 @@ fn intrinsic_extent( } let mut child_extents = Vec::with_capacity(node_children.len()); for child in &node_children { + // Resolve a child's width against its containing block before + // measuring wrapped height. Final-layout callers already pass the + // allocated width, so this must not live in the shared measurement + // helper (which would apply percentages a second time). + let child_available_width = if requested_axis == Axis::Vertical { + dimension(child, PropKey::Width, PropKey::WidthPercent, inner_width) + .unwrap_or(inner_width) + } else { + inner_width + }; let child_extent = constrained_intrinsic_extent( children, child, requested_axis, - inner_width, + child_available_width, inner_height, text_scale, text_metrics, @@ -1791,15 +1801,6 @@ fn constrained_intrinsic_extent( text_metrics: &TextMetrics, depth: usize, ) -> Result { - // Height depends on wrapping at the child's own width, not the whole - // parent's width. The cross-axis containing width is definite here even - // when the height being measured is intrinsically sized. - let available_width = if axis == Axis::Vertical { - dimension(node, PropKey::Width, PropKey::WidthPercent, available_width) - .unwrap_or(available_width) - } else { - available_width - }; let extent = intrinsic_extent( children, node, @@ -2640,7 +2641,7 @@ mod tests { #[test] fn auto_row_height_respects_nested_text_column_width() { for (width_key, width_value) in [(PropKey::Width, 180.0), (PropKey::WidthPercent, 45.0)] { - let tree = Tree { + let mut tree = Tree { root: 1, nodes: BTreeMap::from([ (1, node(1, 0, 0, NodeKind::Screen, [])), @@ -2695,6 +2696,24 @@ mod tests { .expect("scaled header layout"); assert!(layouts[&4].height >= 120.0); assert!(layouts[&2].height >= layouts[&4].height); + let row = tree.nodes.get_mut(&2).expect("header row"); + row.properties + .insert(PropKey::Height, PropValue::Float(400.0)); + row.properties + .insert(PropKey::AlignItems, PropValue::Integer(1)); + let layouts = calculate_with_text_scale( + &tree, + Size { + width: 400.0, + height: 800.0, + }, + 2.0, + ) + .expect("fixed header layout"); + assert_eq!( + layouts[&3].height, layouts[&4].height, + "final child width must not apply its percentage twice" + ); } } From 451e296e0847ba5fc1079e87cca95132bbc49eca Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Sun, 13 Sep 2026 14:51:09 -0300 Subject: [PATCH 03/58] fix: use consistent platform metrics for text wrapping --- crates/pam-native-engine/src/layout.rs | 39 ++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/crates/pam-native-engine/src/layout.rs b/crates/pam-native-engine/src/layout.rs index f0e6b0bd..73062cdd 100644 --- a/crates/pam-native-engine/src/layout.rs +++ b/crates/pam-native-engine/src/layout.rs @@ -2003,8 +2003,8 @@ fn wrapped_text_lines_with_metrics<'a>( let mut last_break = None; let mut width = 0.0; for (offset, character) in hard_line.char_indices() { - let advance = - measured_character_width(character, font_size, glyph_advances) + letter_spacing; + let glyph_width = measured_character_width(character, font_size, glyph_advances); + let advance = glyph_width + if offset > start { letter_spacing } else { 0.0 }; if character.is_whitespace() { last_break = Some(offset); } @@ -2013,7 +2013,10 @@ fn wrapped_text_lines_with_metrics<'a>( // that child again does not wrap its last word onto a phantom // second line. let fit_tolerance = 0.5_f32.max(font_size * 0.02); - if width + advance > available_width + fit_tolerance && offset > start { + if platform_text_width(width + advance, glyph_advances.is_some()) + > available_width + fit_tolerance + && offset > start + { let end = last_break .filter(|position| *position > start) .unwrap_or(offset); @@ -2027,14 +2030,14 @@ fn wrapped_text_lines_with_metrics<'a>( offset }; last_break = None; - width = measured_text_width( + width = raw_text_width( &hard_line[start..offset], font_size, letter_spacing, glyph_advances, ); } - width += advance; + width += glyph_width + if offset > start { letter_spacing } else { 0.0 }; } result.push(hard_line[start..].trim_end()); } @@ -2051,6 +2054,18 @@ fn measured_text_width( font_size: f32, letter_spacing: f32, glyph_advances: Option<&BTreeMap>, +) -> f32 { + platform_text_width( + raw_text_width(text, font_size, letter_spacing, glyph_advances), + glyph_advances.is_some(), + ) +} + +fn raw_text_width( + text: &str, + font_size: f32, + letter_spacing: f32, + glyph_advances: Option<&BTreeMap>, ) -> f32 { let mut characters = text.chars().peekable(); let mut width = 0.0; @@ -2060,7 +2075,11 @@ fn measured_text_width( width += letter_spacing; } } - if glyph_advances.is_some() { + width +} + +fn platform_text_width(width: f32, has_glyph_metrics: bool) -> f32 { + if has_glyph_metrics { // TTF advances describe the ideal unhinted run. Android's TextView can // shape and hint the same run slightly wider when it builds its // StaticLayout. A fixed half-pixel guard was insufficient for longer @@ -2638,6 +2657,14 @@ mod tests { assert_eq!(child.y, 200.0); } + #[test] + fn wrapping_uses_the_same_platform_width_as_intrinsic_measurement() { + let label = "Currency Field"; + let width = 303.0; + assert!(measured_text_width(label, 48.0, 0.0, None) > width); + assert_eq!(wrapped_text_lines(label, 48.0, 0.0, width).len(), 2); + } + #[test] fn auto_row_height_respects_nested_text_column_width() { for (width_key, width_value) in [(PropKey::Width, 180.0), (PropKey::WidthPercent, 45.0)] { From 7137867d93d31cd718c3cff35a286d2e867b8b58 Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Sun, 13 Sep 2026 15:05:12 -0300 Subject: [PATCH 04/58] test: await formatted input delivery on the UI thread --- .../render/PamRendererInstrumentedTest.kt | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) 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 bdd06b77..26e5b7c0 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 @@ -11,6 +11,8 @@ import android.graphics.drawable.ColorDrawable import android.graphics.drawable.RippleDrawable import android.os.Build import android.os.SystemClock +import android.text.Editable +import android.text.TextWatcher import android.view.Gravity import android.view.KeyEvent import android.view.View @@ -43,6 +45,8 @@ import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import kotlin.math.roundToInt +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit @RunWith(AndroidJUnit4::class) class PamRendererInstrumentedTest { @@ -126,7 +130,7 @@ class PamRendererInstrumentedTest { repeat(24) { instrumentation.sendKeyDownUpSync(KeyEvent.KEYCODE_DEL) } instrumentation.sendStringSync("73125") instrumentation.waitForIdleSync() - assertEquals("731,25", currency.text.toString()) + assertInputTextArrives(instrumentation, currency, "731,25") lateinit var mask: EditText onMain(instrumentation) { @@ -140,7 +144,7 @@ class PamRendererInstrumentedTest { repeat(24) { instrumentation.sendKeyDownUpSync(KeyEvent.KEYCODE_DEL) } instrumentation.sendStringSync("21912345678") instrumentation.waitForIdleSync() - assertEquals("(21) 91234-5678", mask.text.toString()) + assertInputTextArrives(instrumentation, mask, "(21) 91234-5678") onMain(instrumentation) { renderer.close() } } finally { onMain(instrumentation) { activity.finish() } @@ -2002,6 +2006,37 @@ class PamRendererInstrumentedTest { }, ) as PamTestActivity + private fun assertInputTextArrives( + instrumentation: Instrumentation, + input: EditText, + expected: String, + ) { + // Input injection completion and app queue idleness do not guarantee + // that an IME has finished updating the editor. Observe the actual + // value without slowing the key burst or reading Views off-main. + val arrived = CountDownLatch(1) + val watcher = object : TextWatcher { + override fun beforeTextChanged(text: CharSequence?, start: Int, count: Int, after: Int) = Unit + override fun onTextChanged(text: CharSequence?, start: Int, before: Int, count: Int) = Unit + override fun afterTextChanged(text: Editable?) { + if (text.toString() == expected) arrived.countDown() + } + } + onMain(instrumentation) { + input.addTextChangedListener(watcher) + if (input.text.toString() == expected) arrived.countDown() + } + try { + val completed = arrived.await(2, TimeUnit.SECONDS) + onMain(instrumentation) { + assertTrue("Input did not retain the complete burst: ${input.text}", completed) + assertEquals(expected, input.text.toString()) + } + } finally { + onMain(instrumentation) { input.removeTextChangedListener(watcher) } + } + } + private fun onMain(instrumentation: Instrumentation, block: () -> Unit) { instrumentation.runOnMainSync(block) } From d6a6a8772e315551d0e4b3d3bca37d9c9ccb8ff5 Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Sun, 13 Sep 2026 15:22:39 -0300 Subject: [PATCH 05/58] fix: measure uppercase fallback glyphs without clipping short labels --- crates/pam-native-engine/src/layout.rs | 44 +++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/crates/pam-native-engine/src/layout.rs b/crates/pam-native-engine/src/layout.rs index 73062cdd..29015b3d 100644 --- a/crates/pam-native-engine/src/layout.rs +++ b/crates/pam-native-engine/src/layout.rs @@ -2114,17 +2114,35 @@ fn estimated_character_width(character: char, font_size: f32) -> f32 { let em = match character { ' ' | '\t' => 0.25, 'i' | '!' | '.' | ',' | ':' | ';' | '\'' => 0.23, - 'l' | 'I' | '|' => 0.21, + 'l' | '|' => 0.21, 'r' => 0.32, 'f' | 't' => 0.42, 'm' => 0.78, 'w' => 0.74, - 'M' => 0.82, - 'W' => 0.87, + // Rounded regular Roboto advances, before the platform guard. A + // single 0.59-em uppercase class under-measured labels such as PHP + // and HTTP, even with the guard, clipping intrinsic-width chips. + // Packaged fonts still use their actual per-weight TTF metrics. + 'A' | 'C' | 'U' => 0.653, + 'B' | 'K' | 'X' => 0.628, + 'D' => 0.657, + 'E' => 0.569, + 'F' | 'J' => 0.553, + 'G' => 0.682, + 'H' | 'N' => 0.714, + 'I' => 0.272, + 'L' => 0.539, + 'M' => 0.874, + 'O' | 'Q' => 0.688, + 'P' => 0.631, + 'R' => 0.617, + 'S' => 0.594, + 'T' | 'Y' | 'Z' => 0.601, + 'V' => 0.637, + 'W' => 0.888, '@' => 0.95, '%' => 0.74, '&' => 0.56, - character if character.is_ascii_uppercase() => 0.59, character if character.is_ascii_digit() => 0.56, character if character.is_ascii() => 0.54, _ => 1.0, @@ -2665,6 +2683,24 @@ mod tests { assert_eq!(wrapped_text_lines(label, 48.0, 0.0, width).len(), 2); } + #[test] + fn fallback_uppercase_labels_fit_platform_glyph_advances() { + // Roboto variable font measured at weight 600 on the API 36 device. + // At 200% text scale the old PHP estimate was 46.8032 dp, less than + // the actual 47.789063 dp; Android wrapped its final P out of view. + for scale in [1.0, 1.3, 2.0] { + let width = measured_text_width("PHP", 12.0 * scale, 0.0, None); + assert!( + width >= 23.894531 * scale, + "PHP width at scale {scale}: {width}" + ); + assert_eq!(wrapped_text_lines("PHP", 12.0 * scale, 0.0, width).len(), 1); + } + // Preserve a genuinely narrow capital I instead of widening every + // uppercase letter to compensate for H, N and M. + assert!(estimated_character_width('I', 24.0) < estimated_character_width('H', 24.0)); + } + #[test] fn auto_row_height_respects_nested_text_column_width() { for (width_key, width_value) in [(PropKey::Width, 180.0), (PropKey::WidthPercent, 45.0)] { From f83eaa3d8df5c95b6c1b0ffc6475a895691792cb Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Sun, 13 Sep 2026 16:33:19 -0300 Subject: [PATCH 06/58] fix(android): avoid double padding in custom host child frames --- .../render/PamRendererInstrumentedTest.kt | 25 ++++++++++++++++ .../dev/pam/nativeapp/render/PamRenderer.kt | 29 ++++++++++++++++--- .../render/PamCustomViewPaddingTest.kt | 10 +++++++ 3 files changed, 60 insertions(+), 4 deletions(-) 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 26e5b7c0..66eba0b3 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,31 @@ import java.util.concurrent.TimeUnit @RunWith(AndroidJUnit4::class) class PamRendererInstrumentedTest { + @Test + fun engineChildCoordinatesSurviveCustomFrameLayoutPadding() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + onMain(instrumentation) { + val host = FrameLayout(instrumentation.targetContext) + host.setPadding(21, 13, 21, 13) + val child = View(instrumentation.targetContext) + host.addView(child, FrameLayout.LayoutParams(258, 54).apply { + gravity = Gravity.TOP or Gravity.LEFT + leftMargin = engineFrameMargin(21, host.paddingLeft) + topMargin = engineFrameMargin(13, host.paddingTop) + }) + host.measure( + View.MeasureSpec.makeMeasureSpec(300, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(80, View.MeasureSpec.EXACTLY), + ) + host.layout(0, 0, 300, 80) + assertEquals(21, child.left) + assertEquals(13, child.top) + assertEquals(21, host.width - child.right) + assertEquals(13, host.height - child.bottom) + assertEquals(21, host.paddingLeft) + } + } + @Test fun currencyInputKeepsNumericTypingAtTheTrailingMinorUnit() { val instrumentation = InstrumentationRegistry.getInstrumentation() 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 afc70f06..d6ebe26f 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 @@ -260,6 +260,9 @@ internal fun hostedContentExtent( internal fun usesNativeViewGroupPadding(kind: NodeKind): Boolean = kind == NodeKind.CUSTOM_VIEW +internal fun engineFrameMargin(offset: Int, nativeFramePadding: Int): Int = + offset - nativeFramePadding + internal fun resolvedAndroidLetterSpacing( logicalSpacing: Float, logicalFontSize: Float, @@ -1133,9 +1136,18 @@ class PamRenderer( val density = resourcesDensity() val horizontal = snappedPixelSpan(frame.x, frame.width, parentFrame.x, density) val vertical = snappedPixelSpan(frame.y, frame.height, parentFrame.y, density) + val paddedHost = if (nodes[hostedParent]?.kind == NodeKind.CUSTOM_VIEW) { + views[hostedParent] as? FrameLayout + } else { + null + } view.layoutParams = FrameLayout.LayoutParams(horizontal.extent, vertical.extent).apply { - leftMargin = if (id == rootId) 0 else horizontal.offset - topMargin = if (id == rootId) 0 else vertical.offset + leftMargin = if (id == rootId) 0 else engineFrameMargin( + horizontal.offset, paddedHost?.paddingLeft ?: 0, + ) + topMargin = if (id == rootId) 0 else engineFrameMargin( + vertical.offset, paddedHost?.paddingTop ?: 0, + ) } } @@ -1455,8 +1467,17 @@ class PamRenderer( resize = state.kind == NodeKind.KEYBOARD_AVOIDING_VIEW && keyboardAvoidingBehaviorReducesViewport(state.keyboardBehavior), ) - var leftPx = horizontal.offset + safeLeft - var topPx = vertical.offset + safeTop + // Engine frames already include authored padding. FrameLayout adds + // its padding to child margins again, unlike engine-owned containers + // whose Android padding is zero. Preserve native host padding while + // expressing engine positions relative to that padded origin. + val paddedHost = if (hostedParentState?.kind == NodeKind.CUSTOM_VIEW) { + parentView as? FrameLayout + } else { + null + } + var leftPx = engineFrameMargin(horizontal.offset, paddedHost?.paddingLeft ?: 0) + safeLeft + var topPx = engineFrameMargin(vertical.offset, paddedHost?.paddingTop ?: 0) + safeTop compensateFlexParentViewportReduction( state = state, parentState = parentState, diff --git a/android/app/src/test/java/dev/pam/nativeapp/render/PamCustomViewPaddingTest.kt b/android/app/src/test/java/dev/pam/nativeapp/render/PamCustomViewPaddingTest.kt index c5ecebe3..976cd785 100644 --- a/android/app/src/test/java/dev/pam/nativeapp/render/PamCustomViewPaddingTest.kt +++ b/android/app/src/test/java/dev/pam/nativeapp/render/PamCustomViewPaddingTest.kt @@ -2,10 +2,20 @@ package dev.pam.nativeapp.render import dev.pam.nativeapp.protocol.NodeKind import org.junit.Assert.assertFalse +import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test class PamCustomViewPaddingTest { + @Test + fun engineFramesDoNotApplyNativeHostPaddingTwice() { + assertEquals(0, engineFrameMargin(21, 21)) + assertEquals(42, engineFrameMargin(63, 21)) + assertEquals(63, engineFrameMargin(63, 0)) + // An absolute child may intentionally start before the padded origin. + assertEquals(-21, engineFrameMargin(0, 21)) + } + @Test fun customNativeViewGroupsOwnTheirAuthoredContentInsets() { assertTrue(usesNativeViewGroupPadding(NodeKind.CUSTOM_VIEW)) From 3f6e17d38f4b3552c7f9161cace8883dd0d000da Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Sun, 13 Sep 2026 16:59:44 -0300 Subject: [PATCH 07/58] fix(android): keep virtual cell frames physical in RTL holders --- .../render/PamRendererInstrumentedTest.kt | 27 +++++++++++++++++++ .../dev/pam/nativeapp/render/PamRenderer.kt | 3 +++ 2 files changed, 30 insertions(+) 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 66eba0b3..5e4ac455 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,33 @@ import java.util.concurrent.TimeUnit @RunWith(AndroidJUnit4::class) class PamRendererInstrumentedTest { + @Test + fun physicalCellFrameIsNotMirroredAgainByAnRtlHolder() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + onMain(instrumentation) { + for (direction in listOf(View.LAYOUT_DIRECTION_LTR, View.LAYOUT_DIRECTION_RTL)) { + val holder = FrameLayout(instrumentation.targetContext).apply { + layoutDirection = direction + setPadding(21, 13, 21, 13) + } + val child = View(instrumentation.targetContext) + holder.addView(child, FrameLayout.LayoutParams(100, 40).apply { + gravity = PAM_PHYSICAL_FRAME_GRAVITY + leftMargin = engineFrameMargin(51, holder.paddingLeft) + topMargin = engineFrameMargin(23, holder.paddingTop) + }) + holder.measure( + View.MeasureSpec.makeMeasureSpec(300, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(100, View.MeasureSpec.EXACTLY), + ) + holder.layout(0, 0, 300, 100) + assertEquals("physical x in direction $direction", 51, child.left) + assertEquals("physical y in direction $direction", 23, child.top) + assertEquals(151, child.right) + } + } + } + @Test fun engineChildCoordinatesSurviveCustomFrameLayoutPadding() { val instrumentation = InstrumentationRegistry.getInstrumentation() 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 d6ebe26f..abbdfe1d 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 @@ -1142,6 +1142,9 @@ class PamRenderer( null } view.layoutParams = FrameLayout.LayoutParams(horizontal.extent, vertical.extent).apply { + // Cell frames are physical engine coordinates too; START would + // mirror them a second time inside an RTL holder. + gravity = PAM_PHYSICAL_FRAME_GRAVITY leftMargin = if (id == rootId) 0 else engineFrameMargin( horizontal.offset, paddedHost?.paddingLeft ?: 0, ) From 634ef3702ac9e2cf6bee04e6009f3adb1d85b260 Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Sun, 13 Sep 2026 17:51:05 -0300 Subject: [PATCH 08/58] fix(layout): measure row height with resolved flex child widths --- crates/pam-native-engine/src/layout.rs | 272 ++++++++++++++++++------- 1 file changed, 196 insertions(+), 76 deletions(-) diff --git a/crates/pam-native-engine/src/layout.rs b/crates/pam-native-engine/src/layout.rs index 29015b3d..cbac42a1 100644 --- a/crates/pam-native-engine/src/layout.rs +++ b/crates/pam-native-engine/src/layout.rs @@ -626,75 +626,25 @@ fn layout_node( ordered_children.clear(); } let total_gap = main_gap * flow_children.len().saturating_sub(1) as f32; - let total_flex = flow_children - .iter() - .map(|child| number(child, PropKey::FlexGrow).unwrap_or(0.0).max(0.0)) - .sum::(); - let mut base_main = BTreeMap::new(); - for child in &flow_children { - base_main.insert( - child.id, - child_main( - context.children, - child, - axis, - available_main, - available_cross, - context.text_scale, - context.text_metrics, - depth + 1, - )?, - ); - } - let fixed = flow_children - .iter() - .filter(|child| number(child, PropKey::FlexGrow).unwrap_or(0.0) <= 0.0) - .map(|child| { - let (before, after) = margin_main(child, axis); - base_main[&child.id] + before + after - }) - .sum::(); - let flex_margins = flow_children - .iter() - .filter(|child| number(child, PropKey::FlexGrow).unwrap_or(0.0) > 0.0) - .map(|child| { - let (before, after) = margin_main(child, axis); - before + after - }) - .sum::(); - let overflow = (fixed + flex_margins + total_gap - available_main).max(0.0); - let shrink_weight = flow_children - .iter() - .filter(|child| number(child, PropKey::FlexGrow).unwrap_or(0.0) <= 0.0) - .map(|child| { - number(child, PropKey::FlexShrink).unwrap_or(0.0).max(0.0) * base_main[&child.id] - }) - .sum::(); - let resolved_main = flow_children - .iter() - .map(|child| { - let base = base_main[&child.id]; - let weight = number(child, PropKey::FlexShrink).unwrap_or(0.0).max(0.0) * base; - let main = if overflow > 0.0 && shrink_weight > 0.0 { - (base - overflow * weight / shrink_weight).max(0.0) - } else { - base - }; - (child.id, main) - }) - .collect::>(); - let resolved_fixed = flow_children - .iter() - .filter(|child| number(child, PropKey::FlexGrow).unwrap_or(0.0) <= 0.0) - .map(|child| { - let (before, after) = margin_main(child, axis); - resolved_main[&child.id] + before + after - }) - .sum::(); - let remaining = (available_main - resolved_fixed - flex_margins - total_gap).max(0.0); - let flex_allocations = allocate_flex_main(&flow_children, axis, available_main, remaining)?; - let flex_consumed = flex_allocations.values().sum::(); - let consumed = resolved_fixed + flex_margins + total_gap + flex_consumed; + let resolved_main = resolved_child_main_sizes( + context.children, + &flow_children, + axis, + available_main, + available_cross, + main_gap, + context.text_scale, + context.text_metrics, + depth + 1, + )?; + let consumed = total_gap + + flow_children + .iter() + .map(|child| { + let (before, after) = margin_main(child, axis); + resolved_main[&child.id] + before + after + }) + .sum::(); let free = (available_main - consumed).max(0.0); let auto_margin_count = if axis == Axis::Horizontal { flow_children @@ -753,12 +703,7 @@ fn layout_node( }; for child in ordered_children { - let flex = number(child, PropKey::FlexGrow).unwrap_or(0.0).max(0.0); - let main = if flex > 0.0 && total_flex > 0.0 { - flex_allocations.get(&child.id).copied().unwrap_or(0.0) - } else { - resolved_main[&child.id] - }; + let main = resolved_main[&child.id]; let (mut main_before, main_after) = margin_main(child, axis); if axis == Axis::Horizontal && boolean(child, PropKey::MarginLeftAuto) { main_before += auto_margin; @@ -1552,13 +1497,32 @@ fn intrinsic_extent( }; return finite_non_negative(content + padding_extent); } + // Height measurement must use the same allocated widths as final layout. + // Measuring every flex child against the whole row misses wrapped lines. + let allocated_widths = if flow_axis == Axis::Horizontal && requested_axis == Axis::Vertical { + Some(resolved_child_main_sizes( + children, + &node_children, + flow_axis, + inner_width, + inner_height, + main_gap, + text_scale, + text_metrics, + depth + 1, + )?) + } else { + None + }; let mut child_extents = Vec::with_capacity(node_children.len()); for child in &node_children { // Resolve a child's width against its containing block before // measuring wrapped height. Final-layout callers already pass the // allocated width, so this must not live in the shared measurement // helper (which would apply percentages a second time). - let child_available_width = if requested_axis == Axis::Vertical { + let child_available_width = if let Some(widths) = &allocated_widths { + widths[&child.id] + } else if requested_axis == Axis::Vertical { dimension(child, PropKey::Width, PropKey::WidthPercent, inner_width) .unwrap_or(inner_width) } else { @@ -2177,6 +2141,85 @@ fn intrinsic_cross( ) } +#[allow(clippy::too_many_arguments)] +fn resolved_child_main_sizes( + children_index: &BTreeMap>, + children: &[&Node], + axis: Axis, + available_main: f32, + available_cross: f32, + main_gap: f32, + text_scale: f32, + text_metrics: &TextMetrics, + depth: usize, +) -> Result, LayoutError> { + let total_gap = main_gap * children.len().saturating_sub(1) as f32; + let mut sizes = BTreeMap::new(); + for child in children { + sizes.insert( + child.id, + child_main( + children_index, + child, + axis, + available_main, + available_cross, + text_scale, + text_metrics, + depth, + )?, + ); + } + let fixed = children + .iter() + .filter(|child| number(child, PropKey::FlexGrow).unwrap_or(0.0) <= 0.0) + .map(|child| { + let (before, after) = margin_main(child, axis); + sizes[&child.id] + before + after + }) + .sum::(); + let flex_margins = children + .iter() + .filter(|child| number(child, PropKey::FlexGrow).unwrap_or(0.0) > 0.0) + .map(|child| { + let (before, after) = margin_main(child, axis); + before + after + }) + .sum::(); + let overflow = (fixed + flex_margins + total_gap - available_main).max(0.0); + let shrink_weight = children + .iter() + .filter(|child| number(child, PropKey::FlexGrow).unwrap_or(0.0) <= 0.0) + .map(|child| number(child, PropKey::FlexShrink).unwrap_or(0.0).max(0.0) * sizes[&child.id]) + .sum::(); + if overflow > 0.0 && shrink_weight > 0.0 { + for child in children { + let base = sizes[&child.id]; + let weight = number(child, PropKey::FlexShrink).unwrap_or(0.0).max(0.0) * base; + sizes.insert( + child.id, + (base - overflow * weight / shrink_weight).max(0.0), + ); + } + } + let resolved_fixed = children + .iter() + .filter(|child| number(child, PropKey::FlexGrow).unwrap_or(0.0) <= 0.0) + .map(|child| { + let (before, after) = margin_main(child, axis); + sizes[&child.id] + before + after + }) + .sum::(); + let remaining = (available_main - resolved_fixed - flex_margins - total_gap).max(0.0); + sizes.extend(allocate_flex_main( + children, + axis, + available_main, + remaining, + )?); + Ok(sizes) +} + fn allocate_flex_main( children: &[&Node], axis: Axis, @@ -2999,6 +3042,83 @@ mod tests { assert_eq!(layouts[&3].x, 50.0); } + #[test] + fn auto_row_measures_text_height_at_allocated_flex_width() { + let mut nodes = BTreeMap::from([ + (1, node(1, 0, 0, NodeKind::Column, [])), + ( + 2, + node( + 2, + 1, + 0, + NodeKind::Row, + [ + (PropKey::WidthPercent, PropValue::Float(100.0)), + (PropKey::AlignItems, PropValue::Integer(2)), + ], + ), + ), + ]); + for index in 0..3_u32 { + let button_id = 3 + u64::from(index); + let text_id = 6 + u64::from(index); + nodes.insert( + button_id, + node( + button_id, + 2, + index, + NodeKind::Pressable, + [ + (PropKey::FlexGrow, PropValue::Float(1.0)), + (PropKey::MinWidth, PropValue::Float(0.0)), + (PropKey::MinHeight, PropValue::Float(40.0)), + (PropKey::PaddingHorizontal, PropValue::Float(16.0)), + (PropKey::PaddingVertical, PropValue::Float(8.0)), + (PropKey::FlexDirection, PropValue::Integer(2)), + ], + ), + ); + nodes.insert( + text_id, + node( + text_id, + button_id, + 0, + NodeKind::Text, + [ + (PropKey::Text, PropValue::String("7 days".into())), + (PropKey::FontSize, PropValue::Float(14.0)), + (PropKey::LineHeight, PropValue::Float(20.0)), + (PropKey::FlexShrink, PropValue::Float(1.0)), + ], + ), + ); + } + let layouts = calculate_with_text_scale( + &Tree { root: 1, nodes }, + Size { + width: 300.0, + height: 500.0, + }, + 2.0, + ) + .expect("layout"); + assert_eq!(layouts[&3].width, 100.0); + assert!(layouts[&2].height >= 96.0, "row height: {:?}", layouts[&2]); + assert!( + layouts[&3].height >= 96.0, + "button height: {:?}", + layouts[&3] + ); + assert!( + layouts[&6].height >= 80.0, + "wrapped text: {:?}", + layouts[&6] + ); + } + #[test] fn flex_wrap_breaks_rows_and_reports_intrinsic_cross_extent() { let tree = Tree { From ebd00a32e67f6b31272a2af8efc920910f2d2b93 Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Sun, 13 Sep 2026 18:47:16 -0300 Subject: [PATCH 09/58] fix(layout): size horizontal scroll content without label compression --- crates/pam-native-engine/src/layout.rs | 216 ++++++++++++++++++++++++- 1 file changed, 213 insertions(+), 3 deletions(-) diff --git a/crates/pam-native-engine/src/layout.rs b/crates/pam-native-engine/src/layout.rs index cbac42a1..78543c8f 100644 --- a/crates/pam-native-engine/src/layout.rs +++ b/crates/pam-native-engine/src/layout.rs @@ -1282,7 +1282,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 +1325,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 +1341,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 +1434,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,6 +1461,43 @@ 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 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 integer(node, PropKey::GridColumns).unwrap_or(0) > 0 { let content = match requested_axis { Axis::Horizontal => inner_width, @@ -3597,6 +3657,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 { From aa9a9df4b71e143f4079c42a3a88611ff1a3b8cc Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Sun, 13 Sep 2026 19:12:58 -0300 Subject: [PATCH 10/58] fix(android): redraw scroll indicators when visibility changes --- .../render/PamRendererInstrumentedTest.kt | 93 +++++++++++++++++++ .../PamScrollContainerInstrumentedTest.kt | 42 +++++++++ .../nativeapp/render/PamScrollContainer.kt | 4 + 3 files changed, 139 insertions(+) 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 5e4ac455..03f83e53 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 @@ -1168,6 +1168,99 @@ class PamRendererInstrumentedTest { } } + @Test + fun rendererKeepsPersistentHorizontalIndicatorVisibleBelowContent() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val activity = launchActivity(instrumentation) + lateinit var renderer: PamRenderer + lateinit var scroll: PamScrollContainer + try { + onMain(instrumentation) { + renderer = PamRenderer(activity, activity.host) { _, _, _ -> } + renderer.commit(listOf(listOf( + Mutation.Create(node(1, 0, NodeKind.SCREEN)), + Mutation.Create(node(2, 1, NodeKind.SCROLL, mapOf( + PropKey.TEST_ID to PropValue.Text("indicator-scroll"), + PropKey.SCROLL_HORIZONTAL to PropValue.Flag(true), + PropKey.SHOWS_SCROLL_INDICATOR to PropValue.Flag(true), + PropKey.SCROLL_PERSISTENT_SCROLLBAR to PropValue.Flag(true), + ))), + 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(2, Frame(0f, 0f, 300f, 52f)), + Mutation.Layout(3, Frame(0f, 0f, 900f, 52f)), + Mutation.Layout(4, Frame(0f, 0f, 900f, 48f)), + Mutation.SetRoot(1), + ))) + scroll = activity.host.findByTransitionName("indicator-scroll") as PamScrollContainer + } + instrumentation.waitForIdleSync() + 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 { + assertEquals( + "Window capture must include the rendered fixture", + Color.LTGRAY, + screenShown.getPixel(location[0] + 10, location[1] + 10), + ) + var difference = 0 + 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++ + } + } + assertTrue("Indicator must also appear in the actual window capture", difference > 0) + } finally { + screenShown.recycle() + screenHidden.recycle() + onMain(instrumentation) { renderer.close() } + } + } finally { + 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..0fc619d7 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,45 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class PamScrollContainerInstrumentedTest { + @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/java/dev/pam/nativeapp/render/PamScrollContainer.kt b/android/app/src/main/java/dev/pam/nativeapp/render/PamScrollContainer.kt index 623237e5..c779a1e9 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 @@ -105,6 +105,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) { @@ -137,6 +140,7 @@ internal class PamScrollContainer(context: Context) : FrameLayout(context) { fun setPersistentScrollbar(value: Boolean) { persistentScrollbar = value activeScroll.isScrollbarFadingEnabled = !value + activeScroll.invalidate() } fun setPagingEnabled(value: Boolean) { From 7470fbcf48ebd93d269da77860b8980aaff25ed5 Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Sun, 13 Sep 2026 19:56:56 -0300 Subject: [PATCH 11/58] fix(scroll): preserve persistent indicators and expose native appearance --- .../render/PamRendererInstrumentedTest.kt | 51 +++++++++++++++-- .../PamScrollContainerInstrumentedTest.kt | 47 ++++++++++++++++ .../dev/pam/nativeapp/protocol/PamProtocol.kt | 3 +- .../dev/pam/nativeapp/render/PamRenderer.kt | 14 ++++- .../nativeapp/render/PamScrollContainer.kt | 55 +++++++++++++++---- .../nativeapp/render/ScrollIndicatorStyle.kt | 14 +++++ .../drawable/pam_scroll_indicator_dark.xml | 6 ++ .../drawable/pam_scroll_indicator_light.xml | 6 ++ .../src/main/res/values/scroll_indicators.xml | 11 ++++ .../pam/nativeapp/protocol/PamProtocolTest.kt | 2 +- crates/pam-native-protocol/src/lib.rs | 6 +- .../PamNative/Protocol/PamProtocol.swift | 1 + .../PamNative/Render/PamRenderer.swift | 5 ++ .../Render/PamScrollIndicatorStyle.swift | 15 +++++ .../PamScrollIndicatorStyleTests.swift | 16 ++++++ packages/native/README.md | 8 +++ .../native/src/Internal/TemplateRenderer.php | 7 +++ packages/native/src/PropKey.php | 1 + packages/native/src/ScrollIndicatorStyle.php | 13 +++++ packages/native/src/UI/Scroll.php | 6 ++ packages/native/tests/run.php | 6 +- 21 files changed, 271 insertions(+), 22 deletions(-) create mode 100644 android/app/src/main/java/dev/pam/nativeapp/render/ScrollIndicatorStyle.kt create mode 100644 android/app/src/main/res/drawable/pam_scroll_indicator_dark.xml create mode 100644 android/app/src/main/res/drawable/pam_scroll_indicator_light.xml create mode 100644 android/app/src/main/res/values/scroll_indicators.xml create mode 100644 ios/Sources/PamNative/Render/PamScrollIndicatorStyle.swift create mode 100644 ios/Tests/PamNativeTests/PamScrollIndicatorStyleTests.swift create mode 100644 packages/native/src/ScrollIndicatorStyle.php 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 03f83e53..51223855 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 @@ -1174,30 +1174,55 @@ class PamRendererInstrumentedTest { 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)), - Mutation.Create(node(2, 1, NodeKind.SCROLL, mapOf( + 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.SCROLL_HORIZONTAL to PropValue.Flag(true), 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(2, Frame(0f, 0f, 300f, 52f)), - Mutation.Layout(3, Frame(0f, 0f, 900f, 52f)), - Mutation.Layout(4, Frame(0f, 0f, 900f, 48f)), + 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() + SystemClock.sleep(100) + initialWindow = requireNotNull(instrumentation.uiAutomation.takeScreenshot()) onMain(instrumentation) { assertTrue("Renderer fixture must overflow", scroll.getChildAt(0).canScrollHorizontally(1)) val shown = Bitmap.createBitmap(scroll.width, scroll.height, Bitmap.Config.ARGB_8888) @@ -1245,18 +1270,34 @@ class PamRendererInstrumentedTest { screenShown.getPixel(location[0] + 10, location[1] + 10), ) var difference = 0 + var initialDifference = 0 + val initial = requireNotNull(initialWindow) + 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) } finally { screenShown.recycle() screenHidden.recycle() onMain(instrumentation) { renderer.close() } } } finally { + initialWindow?.recycle() activity.finish() } } 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 0fc619d7..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 @@ -18,6 +18,53 @@ 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() 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..47d81107 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,8 @@ 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); companion object { fun from(value: Int): PropKey = 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 abbdfe1d..2fb9b361 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 @@ -801,7 +801,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, @@ -2199,6 +2207,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 -> @@ -2779,6 +2789,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 -> 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 c779a1e9..c4dc8b15 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( + 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 @@ -138,9 +165,11 @@ internal class PamScrollContainer(context: Context) : FrameLayout(context) { } fun setPersistentScrollbar(value: Boolean) { + if (persistentScrollbar == value) return persistentScrollbar = value - activeScroll.isScrollbarFadingEnabled = !value - activeScroll.invalidate() + // 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) { @@ -424,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..0aa75b90 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..465).toList(), PropKey.entries.map(PropKey::value)) assertEquals( (1..19).toList(), dev.pam.nativeapp.modules.NativeOperation.entries.map { it.value }, diff --git a/crates/pam-native-protocol/src/lib.rs b/crates/pam-native-protocol/src/lib.rs index 87e593f8..bff37b17 100644 --- a/crates/pam-native-protocol/src/lib.rs +++ b/crates/pam-native-protocol/src/lib.rs @@ -642,6 +642,7 @@ pub enum PropKey { GestureNativeTranslationLimitX = 462, GestureNativeResetOnEnd = 463, InputFormatLocale = 464, + ScrollIndicatorStyle = 465, } impl TryFrom for PropKey { @@ -1113,6 +1114,7 @@ impl TryFrom for PropKey { 462 => Ok(Self::GestureNativeTranslationLimitX), 463 => Ok(Self::GestureNativeResetOnEnd), 464 => Ok(Self::InputFormatLocale), + 465 => Ok(Self::ScrollIndicatorStyle), other => Err(ProtocolError::UnknownProperty(other)), } } @@ -1988,10 +1990,10 @@ mod tests { } assert!(NodeKind::try_from(32).is_err()); - for value in 1..=464 { + for value in 1..=465 { assert!(PropKey::try_from(value).is_ok(), "missing property {value}"); } - assert!(PropKey::try_from(465).is_err()); + assert!(PropKey::try_from(466).is_err()); } #[test] diff --git a/ios/Sources/PamNative/Protocol/PamProtocol.swift b/ios/Sources/PamNative/Protocol/PamProtocol.swift index f14f7461..ef3ebc4a 100644 --- a/ios/Sources/PamNative/Protocol/PamProtocol.swift +++ b/ios/Sources/PamNative/Protocol/PamProtocol.swift @@ -440,6 +440,7 @@ 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 sectionItems = 80 public static let hostProperties = 100 public static let opacity = 38 diff --git a/ios/Sources/PamNative/Render/PamRenderer.swift b/ios/Sources/PamNative/Render/PamRenderer.swift index c098812d..257e5189 100644 --- a/ios/Sources/PamNative/Render/PamRenderer.swift +++ b/ios/Sources/PamNative/Render/PamRenderer.swift @@ -1798,6 +1798,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) @@ -2111,6 +2114,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: 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/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/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/src/Internal/TemplateRenderer.php b/packages/native/src/Internal/TemplateRenderer.php index 8c9684c7..eeb44a8f 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, @@ -1976,6 +1978,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, diff --git a/packages/native/src/PropKey.php b/packages/native/src/PropKey.php index ce7a3d64..6e99eb25 100644 --- a/packages/native/src/PropKey.php +++ b/packages/native/src/PropKey.php @@ -470,4 +470,5 @@ enum PropKey: int case GestureNativeTranslationLimitX = 462; case GestureNativeResetOnEnd = 463; case InputFormatLocale = 464; + case ScrollIndicatorStyle = 465; } 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 @@ +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/run.php b/packages/native/tests/run.php index 230da33d..c36a8669 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 From c223515feef0603efbc4b0e74b1013473ff0ffcb Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Sun, 13 Sep 2026 20:03:05 -0300 Subject: [PATCH 12/58] fix(android): expose tooling constructor for scroll container --- .../main/java/dev/pam/nativeapp/render/PamScrollContainer.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c4dc8b15..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 @@ -16,7 +16,7 @@ import kotlin.math.abs import kotlin.math.max import kotlin.math.roundToInt -internal class PamScrollContainer( +internal class PamScrollContainer @JvmOverloads constructor( context: Context, initialHorizontal: Boolean = false, initialPersistentScrollbar: Boolean = false, From c2548cc823d4058a0ad16fb19b9fa34cd596f76f Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Sun, 13 Sep 2026 20:22:01 -0300 Subject: [PATCH 13/58] test(android): retain failing persistent-indicator window captures --- .github/workflows/ci.yml | 3 ++- .../render/PamRendererInstrumentedTest.kt | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac4f95cc..547620fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -203,7 +203,7 @@ jobs: profile: pixel_6 disable-animations: true script: | - ./android/gradlew -p android -PpamNativeRoot="$GITHUB_WORKSPACE" :app:connectedDebugAndroidTest + trap 'test_exit=$?; mkdir -p android/app/build/renderer-evidence; adb pull /sdcard/Android/data/dev.pam.nativeapp.debug/files/renderer-evidence/. android/app/build/renderer-evidence/ || true; exit "$test_exit"' EXIT; ./android/gradlew -p android -PpamNativeRoot="$GITHUB_WORKSPACE" :app:connectedDebugAndroidTest if [ "${{ matrix.api-level }}" = "36" ]; then composer install --working-dir=examples/showcase --no-interaction --prefer-dist --no-progress; fi if [ "${{ matrix.api-level }}" = "36" ]; then PAM_HOME="$GITHUB_WORKSPACE" PAM_NATIVE_HOME="$GITHUB_WORKSPACE" cargo run --locked --release --package pam-native-cli -- prepare examples/showcase; fi if [ "${{ matrix.api-level }}" = "36" ]; then ./examples/showcase/.pam-native/android/gradlew -p examples/showcase/.pam-native/android -Pandroid.testInstrumentationRunnerArguments.androidx.benchmark.suppressErrors=EMULATOR :macrobenchmark:connectedBenchmarkAndroidTest; fi @@ -214,6 +214,7 @@ jobs: path: | android/app/build/reports/androidTests/connected/ android/app/build/outputs/androidTest-results/connected/ + android/app/build/renderer-evidence/ 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/android/app/src/androidTest/java/dev/pam/nativeapp/render/PamRendererInstrumentedTest.kt b/android/app/src/androidTest/java/dev/pam/nativeapp/render/PamRendererInstrumentedTest.kt index 51223855..5a3a7001 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 @@ -1291,6 +1291,28 @@ class PamRendererInstrumentedTest { } } assertTrue("Dark indicator must have visible contrast, not just a pixel difference", contrastingPixels > width) + } catch (failure: AssertionError) { + // Keep the exact frames that failed, without requesting a + // redraw or retrying until a broken indicator happens to show. + val directory = requireNotNull( + instrumentation.targetContext.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)) + } + } + 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() From a904d22fc80a12ef4663674e558e000ee7424eeb Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Sun, 13 Sep 2026 20:34:24 -0300 Subject: [PATCH 14/58] test(android): collect renderer frames through Gradle test output --- .github/workflows/ci.yml | 4 +-- .../render/PamRendererInstrumentedTest.kt | 33 ++++++++++--------- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 547620fa..bf903bc6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -203,7 +203,7 @@ jobs: profile: pixel_6 disable-animations: true script: | - trap 'test_exit=$?; mkdir -p android/app/build/renderer-evidence; adb pull /sdcard/Android/data/dev.pam.nativeapp.debug/files/renderer-evidence/. android/app/build/renderer-evidence/ || true; exit "$test_exit"' EXIT; ./android/gradlew -p android -PpamNativeRoot="$GITHUB_WORKSPACE" :app:connectedDebugAndroidTest + ./android/gradlew -p android -PpamNativeRoot="$GITHUB_WORKSPACE" :app:connectedDebugAndroidTest if [ "${{ matrix.api-level }}" = "36" ]; then composer install --working-dir=examples/showcase --no-interaction --prefer-dist --no-progress; fi if [ "${{ matrix.api-level }}" = "36" ]; then PAM_HOME="$GITHUB_WORKSPACE" PAM_NATIVE_HOME="$GITHUB_WORKSPACE" cargo run --locked --release --package pam-native-cli -- prepare examples/showcase; fi if [ "${{ matrix.api-level }}" = "36" ]; then ./examples/showcase/.pam-native/android/gradlew -p examples/showcase/.pam-native/android -Pandroid.testInstrumentationRunnerArguments.androidx.benchmark.suppressErrors=EMULATOR :macrobenchmark:connectedBenchmarkAndroidTest; fi @@ -214,7 +214,7 @@ jobs: path: | android/app/build/reports/androidTests/connected/ android/app/build/outputs/androidTest-results/connected/ - android/app/build/renderer-evidence/ + 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/android/app/src/androidTest/java/dev/pam/nativeapp/render/PamRendererInstrumentedTest.kt b/android/app/src/androidTest/java/dev/pam/nativeapp/render/PamRendererInstrumentedTest.kt index 5a3a7001..5b839378 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 @@ -1264,6 +1264,24 @@ class PamRendererInstrumentedTest { 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, @@ -1292,21 +1310,6 @@ class PamRendererInstrumentedTest { } assertTrue("Dark indicator must have visible contrast, not just a pixel difference", contrastingPixels > width) } catch (failure: AssertionError) { - // Keep the exact frames that failed, without requesting a - // redraw or retrying until a broken indicator happens to show. - val directory = requireNotNull( - instrumentation.targetContext.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)) - } - } throw AssertionError( "${failure.message}; fixture=${location.contentToString()} " + "size=${width}x$height trackTop=$trackTop " + From 69ee5cc7dad6918be6224a5a7b7d752b703cd5ff Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Sun, 13 Sep 2026 20:43:38 -0300 Subject: [PATCH 15/58] test(android): distinguish fixture content from inherited canvas color --- .../pam/nativeapp/render/PamRendererInstrumentedTest.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 5b839378..8203761a 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 @@ -1179,7 +1179,12 @@ class PamRendererInstrumentedTest { onMain(instrumentation) { renderer = PamRenderer(activity, activity.host) { _, _, _ -> } renderer.commit(listOf(listOf( - Mutation.Create(node(1, 0, NodeKind.SCREEN)), + 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"), ))), @@ -1290,6 +1295,8 @@ class PamRendererInstrumentedTest { 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) { From 46bfe50645d149345df30796793346be824b7dfc Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Sun, 13 Sep 2026 22:16:08 -0300 Subject: [PATCH 16/58] test(android): await presented content before indicator capture --- .../render/PamRendererInstrumentedTest.kt | 20 +++++++++++++++++-- docs/indicator-frame-readiness-2026-09-13.md | 19 ++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 docs/indicator-frame-readiness-2026-09-13.md 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 8203761a..29ca599c 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 @@ -1226,8 +1226,24 @@ class PamRendererInstrumentedTest { parent.setContentOffsetY(500f) } instrumentation.waitForIdleSync() - SystemClock.sleep(100) - initialWindow = requireNotNull(instrumentation.uiAutomation.takeScreenshot()) + // 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) 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..d6fc8ce9 --- /dev/null +++ b/docs/indicator-frame-readiness-2026-09-13.md @@ -0,0 +1,19 @@ +# 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. From 61ad1a2447766ede0328151209ccaef9c7575a7d Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Sun, 13 Sep 2026 22:38:17 -0300 Subject: [PATCH 17/58] docs: confirm indicator readiness fix across Android CI gates --- docs/indicator-frame-readiness-2026-09-13.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/indicator-frame-readiness-2026-09-13.md b/docs/indicator-frame-readiness-2026-09-13.md index d6fc8ce9..dfc722b3 100644 --- a/docs/indicator-frame-readiness-2026-09-13.md +++ b/docs/indicator-frame-readiness-2026-09-13.md @@ -17,3 +17,13 @@ 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. From f22f558eab586491ea94ec2f089722f1ee379a04 Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Mon, 14 Sep 2026 01:18:24 -0300 Subject: [PATCH 18/58] fix(android): retain dialog viewport sizing for full-window modal content --- .../render/PamModalHostInstrumentedTest.kt | 19 +++++++ .../render/PamRendererInstrumentedTest.kt | 53 +++++++++++++++++++ .../dev/pam/nativeapp/render/PamModalHost.kt | 2 + .../dev/pam/nativeapp/render/PamRenderer.kt | 8 +++ docs/modal-window-viewport-2026-09-14.md | 35 ++++++++++++ 5 files changed, 117 insertions(+) create mode 100644 docs/modal-window-viewport-2026-09-14.md 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 29ca599c..6b229bf0 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,59 @@ import java.util.concurrent.TimeUnit @RunWith(AndroidJUnit4::class) 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() 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/PamRenderer.kt b/android/app/src/main/java/dev/pam/nativeapp/render/PamRenderer.kt index 2fb9b361..702d3817 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 @@ -1506,6 +1506,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 = 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. From 3e62949c2b2d4edd3a21c416672d7d22472df1c3 Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Mon, 14 Sep 2026 01:53:18 -0300 Subject: [PATCH 19/58] fix(android): preserve flattened row controls during viewport reduction --- .../render/PamRendererInstrumentedTest.kt | 50 +++++++++++++++++++ .../dev/pam/nativeapp/render/PamRenderer.kt | 9 +++- docs/flattened-row-viewport-2026-09-14.md | 16 ++++++ 3 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 docs/flattened-row-viewport-2026-09-14.md 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 6b229bf0..5d4d7d47 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,56 @@ import java.util.concurrent.TimeUnit @RunWith(AndroidJUnit4::class) class PamRendererInstrumentedTest { + @Test + fun flattenedRowButtonsKeepHeightWhenColumnViewportShrinks() { + 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.COLUMN, mapOf( + PropKey.BACKGROUND_COLOR to PropValue.Integer(0xFFFFFFFFL), + ))), + Mutation.Create(node(3, 2, NodeKind.ROW)), + Mutation.Create(node(4, 3, NodeKind.PRESSABLE)), + Mutation.Layout(1, Frame(0f, 0f, 360f, 760f)), + Mutation.Layout(2, Frame(0f, 0f, 360f, 760f)), + Mutation.Layout(3, Frame(0f, 100f, 360f, 48f)), + Mutation.Layout(4, Frame(0f, 100f, 112f, 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 + assertNull("row must be flattened for this regression", views[3]) + val host = requireNotNull(views[2]) + val button = requireNotNull(views[4]) + val density = activity.resources.displayMetrics.density + val width = (360 * density).roundToInt() + val height = (640 * density).roundToInt() + host.layoutParams.height = height + host.measure( + View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY), + ) + host.layout(0, 0, width, height) + renderer.commit(listOf(listOf( + Mutation.Layout(4, Frame(0f, 100f, 112f, 48f)), + ))) + assertEquals((48 * density).roundToInt(), button.layoutParams.height) + } finally { + renderer.close() + } + } + } finally { + onMain(instrumentation) { activity.finish() } + } + } + @Test fun fullWindowModalChildIgnoresStaleActivityFrameAfterViewportResize() { val instrumentation = InstrumentationRegistry.getInstrumentation() 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 702d3817..e93e9deb 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 @@ -1426,7 +1426,14 @@ class PamRenderer( } ?: 0 val (measuredHorizontalReduction, measuredVerticalReduction) = measuredCrossAxisViewportReduction( - mainAxisHorizontal = parentMainAxisHorizontal, + // These dimensions belong to the materialized host, not a + // flattened Row between it and this child. Mixing the Row's + // axis with a full-height Column's viewport can subtract the + // system-bar/IME height from each button and collapse it to 0. + mainAxisHorizontal = hostedParentState?.integer( + PropKey.FLEX_DIRECTION, + if (hostedParentState.kind == NodeKind.ROW) 2L else 1L, + )?.toInt() in listOf(2, 4), engineWidth = dp(parentFrame?.width ?: 0f), measuredWidth = measuredParentWidth, engineHeight = dp(parentFrame?.height ?: 0f), diff --git a/docs/flattened-row-viewport-2026-09-14.md b/docs/flattened-row-viewport-2026-09-14.md new file mode 100644 index 00000000..02d5d1b1 --- /dev/null +++ b/docs/flattened-row-viewport-2026-09-14.md @@ -0,0 +1,16 @@ +# Flattened Row viewport correction + +Measured cross-axis compensation must use the axis of the materialized ancestor +whose frame and measured size are being compared. A virtual Row inside a tall +Column must not cause that Column's lost viewport height to be subtracted from +each Row button. This produced zero-height native buttons with text still drawn +outside their bounds, making visually present actions unavailable to accessibility. + +The renderer correction is covered by +`PamRendererInstrumentedTest#flattenedRowButtonsKeepHeightWhenColumnViewportShrinks` +(API 36 pass). Samsung showcase fullscreen-dialog checks then exposed and clicked +both actions at font scales 1.0 and 1.1. Exact APK and artifacts are recorded in +PAM Native UI's `docs/dialog-batch-2026-09-14.md`. + +No new UI-specific native API or dependency was introduced. Broader viewport, +accessibility and release validation remains required before publication. From 19f1c0e771310430d77a3e67a542ecfeca19dfeb Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Mon, 14 Sep 2026 02:24:37 -0300 Subject: [PATCH 20/58] fix(layout): align and stretch native grid rows consistently --- crates/pam-native-engine/src/layout.rs | 69 ++++++++++++++++++++++++-- docs/grid-row-alignment-2026-09-14.md | 14 ++++++ 2 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 docs/grid-row-alignment-2026-09-14.md diff --git a/crates/pam-native-engine/src/layout.rs b/crates/pam-native-engine/src/layout.rs index 78543c8f..8ec7825a 100644 --- a/crates/pam-native-engine/src/layout.rs +++ b/crates/pam-native-engine/src/layout.rs @@ -1114,14 +1114,34 @@ 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)?; } @@ -4947,6 +4967,49 @@ mod tests { assert_eq!(tablet[&4].y, 0.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/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. From d9d5b3045056568c9017cec38321e07987e2fffb Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Mon, 14 Sep 2026 02:30:45 -0300 Subject: [PATCH 21/58] fix(android): stabilize intrinsic text baselines while preserving centered controls --- .../render/PamRendererInstrumentedTest.kt | 3 ++ .../dev/pam/nativeapp/render/PamRenderer.kt | 19 ++++++++++- docs/intrinsic-text-baseline-2026-09-14.md | 32 +++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 docs/intrinsic-text-baseline-2026-09-14.md 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 5d4d7d47..943e43f0 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 @@ -1685,6 +1685,7 @@ class PamRendererInstrumentedTest { mapOf( PropKey.TEXT to PropValue.Text("Allocated"), PropKey.WIDTH to PropValue.Decimal(120.0), + PropKey.HEIGHT to PropValue.Decimal(40.0), PropKey.TEST_ID to PropValue.Text("allocated-start"), ), ), @@ -1712,6 +1713,8 @@ class PamRendererInstrumentedTest { Gravity.START, allocated.gravity and Gravity.RELATIVE_HORIZONTAL_GRAVITY_MASK, ) + assertEquals(Gravity.TOP, intrinsic.gravity and Gravity.VERTICAL_GRAVITY_MASK) + assertEquals(Gravity.CENTER_VERTICAL, allocated.gravity and Gravity.VERTICAL_GRAVITY_MASK) renderer.close() } } finally { 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 e93e9deb..e57fca0d 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 @@ -5106,7 +5106,24 @@ class PamRenderer( } } } - view.gravity = horizontal or Gravity.CENTER_VERTICAL + // Intrinsic text must keep its first baseline stable when the engine's + // conservative wrapping estimate reserves an extra line. Explicit text + // boxes (button labels, badges, etc.) retain vertical centering. + val allocatedHeight = state.properties.containsKey(PropKey.HEIGHT) || + state.properties.containsKey(PropKey.HEIGHT_PERCENT) || + state.properties.containsKey(PropKey.MIN_HEIGHT) + val parent = nodes[state.parent] + val parentDirection = parent?.integer( + PropKey.FLEX_DIRECTION, + if (parent.kind == NodeKind.ROW) 2L else 1L, + )?.toInt() ?: 1 + val centeredByParent = if (parentDirection == 2 || parentDirection == 4) { + (state.properties[PropKey.ALIGN_SELF]?.integer() + ?: parent?.integer(PropKey.ALIGN_ITEMS, 4L)) == 2L + } else { + parent?.integer(PropKey.JUSTIFY_CONTENT, 1L) == 2L + } + view.gravity = horizontal or if (allocatedHeight || centeredByParent) Gravity.CENTER_VERTICAL else Gravity.TOP } private fun applyLineHeight(view: TextView, state: NodeState) { diff --git a/docs/intrinsic-text-baseline-2026-09-14.md b/docs/intrinsic-text-baseline-2026-09-14.md new file mode 100644 index 00000000..a285da8c --- /dev/null +++ b/docs/intrinsic-text-baseline-2026-09-14.md @@ -0,0 +1,32 @@ +# Android intrinsic text alignment + +Android previously used CENTER_VERTICAL for every TextView. When Rust's +conservative fallback-font measurement reserved two lines but Android shaped +the word on one line, its first baseline shifted downward. This was visible on +the four-column showcase's `Create` label even after row heights were corrected. + +Intrinsic flow text now uses TOP vertical gravity. Text with authored Height, +HeightPercent or MinHeight retains CENTER_VERTICAL, as do labels whose parent +explicitly centers them vertically (row AlignItems/AlignSelf or column +JustifyContent). Horizontal alignment is +unchanged, and PamEditText keeps its existing input alignment behavior. +The rule runs again when a layout frame is applied, so later height changes are +not tied to the initial view creation. + +The existing `intrinsicTextFollowsTheParentsRelevantCenteringAxis` instrumented +regression now also checks both vertical contracts. It passed on API 36. +This fixes baseline placement, not the precision of fallback glyph estimates; +actual system-font shaping/metrics parity remains a separate open requirement. +No iOS behavior is changed and no cross-platform approval is implied. + +Samsung final candidate SHA-256: +`186e4582e80da13fc343826cbf3d2974ecf78dee8273b473f01e0b7e4330f43a`. +Evidence: `/tmp/pam-text-baseline-final-20260914.json` and its matching directory. +Viewed grid and chip captures: first-line grid titles align, the header badge +remains centered, and the chip press changes the rendered state to `Release opened`. +Earlier candidate `579f738d...` also exercised a button press but exposed the +minimum-height badge regression; it is superseded, not publication evidence. +Android unit tests passed before the minimum-height/parent-centering refinement; +the instrumented alignment regression passed again after that refinement. +Narrow-grid word breaks, fallback font precision, complete component interaction +coverage, accessibility scaling/RTL and iOS remain open gates. From 5d1fbab49bbedc5926d0622a295b9dfc989a51ad Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Mon, 14 Sep 2026 02:38:43 -0300 Subject: [PATCH 22/58] feat(layout): add shared minimum-width auto-fit grid columns --- .../dev/pam/nativeapp/protocol/PamProtocol.kt | 3 +- .../dev/pam/nativeapp/render/PamRenderer.kt | 1 + .../pam/nativeapp/protocol/PamProtocolTest.kt | 2 +- crates/pam-native-engine/src/layout.rs | 48 ++++++++++++++++--- crates/pam-native-engine/src/lib.rs | 1 + crates/pam-native-protocol/src/lib.rs | 6 ++- docs/grid-auto-fit-2026-09-14.md | 32 +++++++++++++ .../PamNative/Protocol/PamProtocol.swift | 1 + .../native/src/Internal/TemplateRenderer.php | 2 + packages/native/src/PropKey.php | 1 + packages/native/src/Style.php | 2 + packages/native/tests/run.php | 10 ++++ 12 files changed, 99 insertions(+), 10 deletions(-) create mode 100644 docs/grid-auto-fit-2026-09-14.md 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 47d81107..0e1cf06e 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 @@ -601,7 +601,8 @@ enum class PropKey(val value: Int) { GESTURE_NATIVE_TRANSLATION_LIMIT_X(462), GESTURE_NATIVE_RESET_ON_END(463), INPUT_FORMAT_LOCALE(464), - SCROLL_INDICATOR_STYLE(465); + SCROLL_INDICATOR_STYLE(465), + GRID_MIN_COLUMN_WIDTH(466); companion object { fun from(value: Int): PropKey = 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 e57fca0d..b34f90a7 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 @@ -2529,6 +2529,7 @@ class PamRenderer( PropKey.MAX_HEIGHT_PERCENT, PropKey.MARGIN_LEFT_AUTO, PropKey.GRID_COLUMNS, + PropKey.GRID_MIN_COLUMN_WIDTH, PropKey.GRID_SPAN, PropKey.GRID_SPAN_SM, PropKey.GRID_SPAN_MD, 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 0aa75b90..185f9519 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..465).toList(), PropKey.entries.map(PropKey::value)) + assertEquals((1..466).toList(), PropKey.entries.map(PropKey::value)) assertEquals( (1..19).toList(), dev.pam.nativeapp.modules.NativeOperation.entries.map { it.value }, diff --git a/crates/pam-native-engine/src/layout.rs b/crates/pam-native-engine/src/layout.rs index 8ec7825a..7e2be6dc 100644 --- a/crates/pam-native-engine/src/layout.rs +++ b/crates/pam-native-engine/src/layout.rs @@ -1029,6 +1029,17 @@ fn layout_wrapped_children( Ok(()) } +fn resolved_grid_columns(node: &Node, width: f32, gap: f32) -> Result { + let maximum = integer(node, PropKey::GridColumns).unwrap_or(12).clamp(1, 64) as usize; + let minimum = finite_non_negative(number(node, PropKey::GridMinColumnWidth).unwrap_or(0.0))?; + if minimum == 0.0 { + return Ok(maximum); + } + // 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)) +} + fn layout_grid( context: &LayoutContext<'_>, node: &Node, @@ -1038,11 +1049,9 @@ 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 columns = resolved_grid_columns(node, inner.width, column_gap)?; let row_gap = finite_non_negative(number(node, PropKey::GridRowGap).unwrap_or(fallback_gap))?; let unit = ((inner.width - column_gap * columns.saturating_sub(1) as f32).max(0.0)) / columns as f32; @@ -1772,12 +1781,10 @@ 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 columns = resolved_grid_columns(node, width, column_gap)?; let row_gap = finite_non_negative(number(node, PropKey::GridRowGap).unwrap_or(fallback_gap))?; let unit = ((width - column_gap * columns.saturating_sub(1) as f32).max(0.0)) / columns as f32; let mut flow = node_children @@ -4967,6 +4974,35 @@ mod tests { assert_eq!(tablet[&4].y, 0.0); } + #[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 { diff --git a/crates/pam-native-engine/src/lib.rs b/crates/pam-native-engine/src/lib.rs index 671f52b3..4721e9ef 100644 --- a/crates/pam-native-engine/src/lib.rs +++ b/crates/pam-native-engine/src/lib.rs @@ -787,6 +787,7 @@ fn affects_layout(key: PropKey) -> bool { | PropKey::ScrollHorizontal | PropKey::ScrollFillViewport | PropKey::GridColumns + | PropKey::GridMinColumnWidth | PropKey::GridSpan | PropKey::GridSpanSm | PropKey::GridSpanMd diff --git a/crates/pam-native-protocol/src/lib.rs b/crates/pam-native-protocol/src/lib.rs index bff37b17..16d97302 100644 --- a/crates/pam-native-protocol/src/lib.rs +++ b/crates/pam-native-protocol/src/lib.rs @@ -643,6 +643,7 @@ pub enum PropKey { GestureNativeResetOnEnd = 463, InputFormatLocale = 464, ScrollIndicatorStyle = 465, + GridMinColumnWidth = 466, } impl TryFrom for PropKey { @@ -1115,6 +1116,7 @@ impl TryFrom for PropKey { 463 => Ok(Self::GestureNativeResetOnEnd), 464 => Ok(Self::InputFormatLocale), 465 => Ok(Self::ScrollIndicatorStyle), + 466 => Ok(Self::GridMinColumnWidth), other => Err(ProtocolError::UnknownProperty(other)), } } @@ -1990,10 +1992,10 @@ mod tests { } assert!(NodeKind::try_from(32).is_err()); - for value in 1..=465 { + for value in 1..=466 { assert!(PropKey::try_from(value).is_ok(), "missing property {value}"); } - assert!(PropKey::try_from(466).is_err()); + assert!(PropKey::try_from(467).is_err()); } #[test] 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/ios/Sources/PamNative/Protocol/PamProtocol.swift b/ios/Sources/PamNative/Protocol/PamProtocol.swift index ef3ebc4a..a42ef575 100644 --- a/ios/Sources/PamNative/Protocol/PamProtocol.swift +++ b/ios/Sources/PamNative/Protocol/PamProtocol.swift @@ -441,6 +441,7 @@ public enum PamConstants { public static let gestureNativeResetOnEnd = 463 public static let inputFormatLocale = 464 public static let scrollIndicatorStyle = 465 + public static let gridMinColumnWidth = 466 public static let sectionItems = 80 public static let hostProperties = 100 public static let opacity = 38 diff --git a/packages/native/src/Internal/TemplateRenderer.php b/packages/native/src/Internal/TemplateRenderer.php index eeb44a8f..a359ae73 100644 --- a/packages/native/src/Internal/TemplateRenderer.php +++ b/packages/native/src/Internal/TemplateRenderer.php @@ -445,6 +445,7 @@ final class TemplateRenderer 'keyboardVerticalOffset' => PropKey::KeyboardVerticalOffset, 'keyboardAvoidingEnabled' => PropKey::KeyboardAvoidingEnabled, 'columns' => PropKey::GridColumns, + 'gridMinColumnWidth' => PropKey::GridMinColumnWidth, 'span' => PropKey::GridSpan, 'spanSm' => PropKey::GridSpanSm, 'spanMd' => PropKey::GridSpanMd, @@ -2363,6 +2364,7 @@ private static function propertyValue(PropKey $key, mixed $value): string|int|fl => 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, diff --git a/packages/native/src/PropKey.php b/packages/native/src/PropKey.php index 6e99eb25..a41ba14d 100644 --- a/packages/native/src/PropKey.php +++ b/packages/native/src/PropKey.php @@ -471,4 +471,5 @@ enum PropKey: int case GestureNativeResetOnEnd = 463; case InputFormatLocale = 464; case ScrollIndicatorStyle = 465; + case GridMinColumnWidth = 466; } diff --git a/packages/native/src/Style.php b/packages/native/src/Style.php index f3cf80da..a1ab5beb 100644 --- a/packages/native/src/Style.php +++ b/packages/native/src/Style.php @@ -118,6 +118,7 @@ public function __construct( public ?float $shadowBlurRadius = null, public ?float $shadowSpreadRadius = null, public ?int $shadowColor = null, + public ?float $gridMinColumnWidth = null, ) { } @@ -210,6 +211,7 @@ public function properties(): array PropKey::TranslationXPercent->value => $this->translationXPercent, PropKey::AnimationKind->value => $this->animationKind?->value, PropKey::GridColumns->value => $this->gridColumns, + PropKey::GridMinColumnWidth->value => $this->gridMinColumnWidth, PropKey::GridSpan->value => $this->gridSpan, PropKey::GridSpanSm->value => $this->gridSpanSm, PropKey::GridSpanMd->value => $this->gridSpanMd, diff --git a/packages/native/tests/run.php b/packages/native/tests/run.php index c36a8669..8f749cce 100644 --- a/packages/native/tests/run.php +++ b/packages/native/tests/run.php @@ -2831,6 +2831,16 @@ 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( 'One', From e5e7628e756e7aecfc3916a306bf9db46641147e Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Mon, 14 Sep 2026 02:48:31 -0300 Subject: [PATCH 23/58] fix(android): preserve input selection across password visibility changes --- .../render/PamRendererInstrumentedTest.kt | 43 +++++++++++++++++++ .../dev/pam/nativeapp/render/PamRenderer.kt | 15 ++++--- ...input-selection-preservation-2026-09-14.md | 21 +++++++++ 3 files changed, 74 insertions(+), 5 deletions(-) create mode 100644 docs/input-selection-preservation-2026-09-14.md 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 943e43f0..732922b4 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 @@ -205,6 +205,49 @@ 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 currencyInputKeepsNumericTypingAtTheTrailingMinorUnit() { val instrumentation = InstrumentationRegistry.getInstrumentation() 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 b34f90a7..8007dbcf 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 @@ -6517,6 +6517,8 @@ 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() @@ -6660,12 +6662,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) 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. From 896ddaea86d1b590be68e9097b249428f611220b Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Mon, 14 Sep 2026 03:26:45 -0300 Subject: [PATCH 24/58] fix(android): keep inactive input values at their leading text --- .../render/PamRendererInstrumentedTest.kt | 40 +++++++++++++++++++ .../dev/pam/nativeapp/render/PamRenderer.kt | 11 ++++- .../inactive-input-leading-text-2026-09-14.md | 20 ++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 docs/inactive-input-leading-text-2026-09-14.md 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 732922b4..306e3ebf 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 @@ -248,6 +248,46 @@ class PamRendererInstrumentedTest { } } + @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() 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 8007dbcf..efa3011d 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 @@ -4596,7 +4596,16 @@ class PamRenderer( } 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 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..956166a6 --- /dev/null +++ b/docs/inactive-input-leading-text-2026-09-14.md @@ -0,0 +1,20 @@ +# 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. From 1d879edfa55b039063caac246ca90e6784fb20fe Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Mon, 14 Sep 2026 03:28:27 -0300 Subject: [PATCH 25/58] docs: verify leading input text in Android showcase --- docs/inactive-input-leading-text-2026-09-14.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/inactive-input-leading-text-2026-09-14.md b/docs/inactive-input-leading-text-2026-09-14.md index 956166a6..eb4d9be9 100644 --- a/docs/inactive-input-leading-text-2026-09-14.md +++ b/docs/inactive-input-leading-text-2026-09-14.md @@ -18,3 +18,14 @@ 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. From 704549bdd070fd3c97ef6e66250c00ac43595eed Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Mon, 14 Sep 2026 03:33:40 -0300 Subject: [PATCH 26/58] feat(engine): add validated responsive grid template contract --- crates/pam-native-engine/src/grid_template.rs | 156 ++++++++++++++++++ crates/pam-native-engine/src/lib.rs | 1 + docs/responsive-grid-template-contract.md | 31 ++++ 3 files changed, 188 insertions(+) create mode 100644 crates/pam-native-engine/src/grid_template.rs create mode 100644 docs/responsive-grid-template-contract.md 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/lib.rs b/crates/pam-native-engine/src/lib.rs index 4721e9ef..c9ea504f 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; diff --git a/docs/responsive-grid-template-contract.md b/docs/responsive-grid-template-contract.md new file mode 100644 index 00000000..b5b20600 --- /dev/null +++ b/docs/responsive-grid-template-contract.md @@ -0,0 +1,31 @@ +# 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. From 663957caf4c67e98323ec3b35e91090bf3f27bf3 Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Mon, 14 Sep 2026 03:38:18 -0300 Subject: [PATCH 27/58] feat(native): integrate responsive grid templates into shared layout --- .../dev/pam/nativeapp/protocol/PamProtocol.kt | 6 +- .../dev/pam/nativeapp/render/PamRenderer.kt | 4 + .../pam/nativeapp/protocol/PamProtocolTest.kt | 2 +- crates/pam-native-engine/src/layout.rs | 100 +++++++++++++----- crates/pam-native-engine/src/lib.rs | 4 + crates/pam-native-protocol/src/lib.rs | 12 ++- docs/responsive-grid-template-contract.md | 22 ++++ .../PamNative/Protocol/PamProtocol.swift | 4 + packages/native/src/PropKey.php | 4 + 9 files changed, 130 insertions(+), 28 deletions(-) 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 0e1cf06e..7c25408e 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 @@ -602,7 +602,11 @@ enum class PropKey(val value: Int) { GESTURE_NATIVE_RESET_ON_END(463), INPUT_FORMAT_LOCALE(464), SCROLL_INDICATOR_STYLE(465), - GRID_MIN_COLUMN_WIDTH(466); + GRID_MIN_COLUMN_WIDTH(466), + GRID_TEMPLATE(467), + GRID_SPAN_2XL(468), + GRID_OFFSET_2XL(469), + GRID_ORDER_2XL(470); companion object { fun from(value: Int): PropKey = 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 efa3011d..794b431f 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 @@ -2530,6 +2530,10 @@ class PamRenderer( PropKey.MARGIN_LEFT_AUTO, PropKey.GRID_COLUMNS, PropKey.GRID_MIN_COLUMN_WIDTH, + PropKey.GRID_TEMPLATE, + PropKey.GRID_SPAN_2XL, + PropKey.GRID_OFFSET_2XL, + PropKey.GRID_ORDER_2XL, PropKey.GRID_SPAN, PropKey.GRID_SPAN_SM, PropKey.GRID_SPAN_MD, 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 185f9519..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..466).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/crates/pam-native-engine/src/layout.rs b/crates/pam-native-engine/src/layout.rs index 7e2be6dc..d95cd4e2 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,15 +1029,34 @@ fn layout_wrapped_children( Ok(()) } -fn resolved_grid_columns(node: &Node, width: f32, gap: f32) -> Result { - let maximum = integer(node, PropKey::GridColumns).unwrap_or(12).clamp(1, 64) as usize; +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); + 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)) + Ok((fitting.clamp(1, maximum), gap, row_gap, level)) } fn layout_grid( @@ -1049,10 +1068,7 @@ fn layout_grid( depth: usize, output: &mut BTreeMap, ) -> Result<(), LayoutError> { - let column_gap = - finite_non_negative(number(node, PropKey::GridColumnGap).unwrap_or(fallback_gap))?; - let columns = resolved_grid_columns(node, inner.width, column_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 @@ -1066,7 +1082,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, ) }); @@ -1076,9 +1092,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; @@ -1199,7 +1215,7 @@ 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, @@ -1207,6 +1223,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, @@ -1214,6 +1231,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, @@ -1221,9 +1239,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 @@ -1233,7 +1252,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])) @@ -1272,7 +1291,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, @@ -1527,7 +1546,7 @@ fn intrinsic_extent( } return finite_non_negative(height + padding_top + padding_bottom); } - if integer(node, PropKey::GridColumns).unwrap_or(0) > 0 { + if is_grid(node) { let content = match requested_axis { Axis::Horizontal => inner_width, Axis::Vertical => grid_intrinsic_height( @@ -1782,10 +1801,7 @@ fn grid_intrinsic_height( depth: usize, ) -> Result { 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 columns = resolved_grid_columns(node, width, column_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() @@ -1798,7 +1814,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, ) }); @@ -1806,9 +1822,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; @@ -2709,6 +2725,7 @@ fn finite(value: f32) -> Result { #[derive(Debug)] pub enum LayoutError { + InvalidGridTemplate, InvalidDimension, DepthExceeded, MissingNode(u64), @@ -2717,6 +2734,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") } @@ -4974,6 +4992,40 @@ 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([ diff --git a/crates/pam-native-engine/src/lib.rs b/crates/pam-native-engine/src/lib.rs index c9ea504f..9685b520 100644 --- a/crates/pam-native-engine/src/lib.rs +++ b/crates/pam-native-engine/src/lib.rs @@ -789,6 +789,10 @@ fn affects_layout(key: PropKey) -> bool { | PropKey::ScrollFillViewport | PropKey::GridColumns | PropKey::GridMinColumnWidth + | PropKey::GridTemplate + | PropKey::GridSpan2xl + | PropKey::GridOffset2xl + | PropKey::GridOrder2xl | PropKey::GridSpan | PropKey::GridSpanSm | PropKey::GridSpanMd diff --git a/crates/pam-native-protocol/src/lib.rs b/crates/pam-native-protocol/src/lib.rs index 16d97302..d34fd8bc 100644 --- a/crates/pam-native-protocol/src/lib.rs +++ b/crates/pam-native-protocol/src/lib.rs @@ -644,6 +644,10 @@ pub enum PropKey { InputFormatLocale = 464, ScrollIndicatorStyle = 465, GridMinColumnWidth = 466, + GridTemplate = 467, + GridSpan2xl = 468, + GridOffset2xl = 469, + GridOrder2xl = 470, } impl TryFrom for PropKey { @@ -1117,6 +1121,10 @@ impl TryFrom for PropKey { 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)), } } @@ -1992,10 +2000,10 @@ mod tests { } assert!(NodeKind::try_from(32).is_err()); - for value in 1..=466 { + for value in 1..=470 { assert!(PropKey::try_from(value).is_ok(), "missing property {value}"); } - assert!(PropKey::try_from(467).is_err()); + assert!(PropKey::try_from(471).is_err()); } #[test] diff --git a/docs/responsive-grid-template-contract.md b/docs/responsive-grid-template-contract.md index b5b20600..dc3a76ee 100644 --- a/docs/responsive-grid-template-contract.md +++ b/docs/responsive-grid-template-contract.md @@ -29,3 +29,25 @@ Remaining integration must be completed before public release: 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. diff --git a/ios/Sources/PamNative/Protocol/PamProtocol.swift b/ios/Sources/PamNative/Protocol/PamProtocol.swift index a42ef575..d32fd0b3 100644 --- a/ios/Sources/PamNative/Protocol/PamProtocol.swift +++ b/ios/Sources/PamNative/Protocol/PamProtocol.swift @@ -442,6 +442,10 @@ public enum PamConstants { 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/packages/native/src/PropKey.php b/packages/native/src/PropKey.php index a41ba14d..0ee7fe71 100644 --- a/packages/native/src/PropKey.php +++ b/packages/native/src/PropKey.php @@ -472,4 +472,8 @@ enum PropKey: int case InputFormatLocale = 464; case ScrollIndicatorStyle = 465; case GridMinColumnWidth = 466; + case GridTemplate = 467; + case GridSpan2xl = 468; + case GridOffset2xl = 469; + case GridOrder2xl = 470; } From 6a54f53052d037cda857b84abb198680db60bd23 Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Mon, 14 Sep 2026 03:50:46 -0300 Subject: [PATCH 28/58] feat(native): expose validated responsive grid plans in PHP SDK --- .../dev/pam/nativeapp/protocol/PamProtocol.kt | 6 +- .../dev/pam/nativeapp/render/PamRenderer.kt | 6 +- docs/responsive-grid-template-contract.md | 38 +++++++++++++ packages/native/src/GridBreakpoint.php | 34 +++++++++++ packages/native/src/GridTemplate.php | 57 +++++++++++++++++++ .../native/src/Internal/TemplateRenderer.php | 12 ++++ packages/native/src/Style.php | 8 +++ packages/native/tests/run.php | 24 ++++++++ 8 files changed, 179 insertions(+), 6 deletions(-) create mode 100644 packages/native/src/GridBreakpoint.php create mode 100644 packages/native/src/GridTemplate.php 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 7c25408e..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 @@ -604,9 +604,9 @@ enum class PropKey(val value: Int) { SCROLL_INDICATOR_STYLE(465), GRID_MIN_COLUMN_WIDTH(466), GRID_TEMPLATE(467), - GRID_SPAN_2XL(468), - GRID_OFFSET_2XL(469), - GRID_ORDER_2XL(470); + 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/PamRenderer.kt b/android/app/src/main/java/dev/pam/nativeapp/render/PamRenderer.kt index 794b431f..f8bc54b7 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 @@ -2531,9 +2531,9 @@ class PamRenderer( PropKey.GRID_COLUMNS, PropKey.GRID_MIN_COLUMN_WIDTH, PropKey.GRID_TEMPLATE, - PropKey.GRID_SPAN_2XL, - PropKey.GRID_OFFSET_2XL, - PropKey.GRID_ORDER_2XL, + PropKey.GRID_SPAN2XL, + PropKey.GRID_OFFSET2XL, + PropKey.GRID_ORDER2XL, PropKey.GRID_SPAN, PropKey.GRID_SPAN_SM, PropKey.GRID_SPAN_MD, diff --git a/docs/responsive-grid-template-contract.md b/docs/responsive-grid-template-contract.md index dc3a76ee..a13d0ad5 100644 --- a/docs/responsive-grid-template-contract.md +++ b/docs/responsive-grid-template-contract.md @@ -51,3 +51,41 @@ 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. + +## 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/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 a359ae73..09204f72 100644 --- a/packages/native/src/Internal/TemplateRenderer.php +++ b/packages/native/src/Internal/TemplateRenderer.php @@ -446,6 +446,10 @@ final class TemplateRenderer '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, @@ -2344,23 +2348,31 @@ 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, diff --git a/packages/native/src/Style.php b/packages/native/src/Style.php index a1ab5beb..df36e4b7 100644 --- a/packages/native/src/Style.php +++ b/packages/native/src/Style.php @@ -119,6 +119,10 @@ public function __construct( public ?float $shadowSpreadRadius = null, public ?int $shadowColor = null, public ?float $gridMinColumnWidth = null, + public ?GridTemplate $gridTemplate = null, + public ?int $gridSpan2xl = null, + public ?int $gridOffset2xl = null, + public ?int $gridOrder2xl = null, ) { } @@ -212,6 +216,10 @@ public function properties(): array 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/tests/run.php b/packages/native/tests/run.php index 8f749cce..457de806 100644 --- a/packages/native/tests/run.php +++ b/packages/native/tests/run.php @@ -2841,6 +2841,30 @@ public function selectAction(string $name): void && (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', From 649ec191d4c3a3ca591f3f5e396a201e2af207e5 Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Mon, 14 Sep 2026 04:10:38 -0300 Subject: [PATCH 29/58] fix(native): reject invalid dynamic style dimensions --- docs/dynamic-style-validation-2026-09-14.md | 31 +++++++++++++++ .../native/src/Internal/TemplateRenderer.php | 28 +++++++++----- packages/native/tests/language2.php | 38 +++++++++++++++++++ 3 files changed, 88 insertions(+), 9 deletions(-) create mode 100644 docs/dynamic-style-validation-2026-09-14.md 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/packages/native/src/Internal/TemplateRenderer.php b/packages/native/src/Internal/TemplateRenderer.php index 09204f72..9cc74a9c 100644 --- a/packages/native/src/Internal/TemplateRenderer.php +++ b/packages/native/src/Internal/TemplateRenderer.php @@ -1490,6 +1490,7 @@ private static function tag( return $element; } + /** @param array $values */ private static function mediaCacheAttributes( Image|MediaPlayer $element, array $values, @@ -2579,6 +2580,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) { @@ -2586,9 +2588,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) { @@ -2932,8 +2931,8 @@ private static function styleCompoundMatches(array $compound, mixed $node): bool 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'] : []; @@ -2941,10 +2940,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, @@ -2953,6 +2952,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; } } @@ -2973,14 +2975,22 @@ 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; } + 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 { diff --git a/packages/native/tests/language2.php b/packages/native/tests/language2.php index 8b26234c..09df2174 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, From e21d417972624f6457217228fcb39d5b2096990a Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Mon, 14 Sep 2026 04:13:49 -0300 Subject: [PATCH 30/58] fix(native): isolate reactive style caches and validate metadata --- docs/reactive-style-metadata-2026-09-14.md | 26 +++++++++++ .../native/src/Internal/TemplateRenderer.php | 44 ++++++++++++++++--- packages/native/tests/language2.php | 38 ++++++++++++++++ 3 files changed, 101 insertions(+), 7 deletions(-) create mode 100644 docs/reactive-style-metadata-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/packages/native/src/Internal/TemplateRenderer.php b/packages/native/src/Internal/TemplateRenderer.php index 9cc74a9c..ba824770 100644 --- a/packages/native/src/Internal/TemplateRenderer.php +++ b/packages/native/src/Internal/TemplateRenderer.php @@ -2991,18 +2991,35 @@ private static function styleDimension(mixed $value, string $label): float return (float) $value; } - /** @param array $sheet @return array */ + /** + * @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) @@ -3014,15 +3031,28 @@ 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; } @@ -3134,7 +3164,7 @@ 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 : [], ''); } /** diff --git a/packages/native/tests/language2.php b/packages/native/tests/language2.php index 09df2174..24332313 100644 --- a/packages/native/tests/language2.php +++ b/packages/native/tests/language2.php @@ -348,6 +348,44 @@ final class ModuleStyleExample {} ); StyleVariables::replace([]); +$reactiveMethod = new ReflectionMethod(TemplateRenderer::class, 'reactiveStyleSheet'); +$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', From a3b551f84e31e8feffad13dddf183be3866ecadb Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Mon, 14 Sep 2026 04:16:54 -0300 Subject: [PATCH 31/58] fix(native): preserve responsive cascade precedence and validate merges --- .../responsive-style-precedence-2026-09-14.md | 26 +++++++ .../native/src/Internal/TemplateRenderer.php | 68 ++++++++++++++----- packages/native/tests/language2.php | 29 ++++++++ 3 files changed, 107 insertions(+), 16 deletions(-) create mode 100644 docs/responsive-style-precedence-2026-09-14.md 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/packages/native/src/Internal/TemplateRenderer.php b/packages/native/src/Internal/TemplateRenderer.php index ba824770..770e86c9 100644 --- a/packages/native/src/Internal/TemplateRenderer.php +++ b/packages/native/src/Internal/TemplateRenderer.php @@ -3057,7 +3057,11 @@ private static function reactiveStyleSheet(array $sheet): array 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'] ?? []; @@ -3104,31 +3108,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'] = []; @@ -3136,13 +3150,35 @@ private static function responsiveStyleSheet(array $sheet, array $data): array return $sheet; } + /** @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; diff --git a/packages/native/tests/language2.php b/packages/native/tests/language2.php index 24332313..3bf67f1f 100644 --- a/packages/native/tests/language2.php +++ b/packages/native/tests/language2.php @@ -349,6 +349,35 @@ final class ModuleStyleExample {} StyleVariables::replace([]); $reactiveMethod = new ReflectionMethod(TemplateRenderer::class, 'reactiveStyleSheet'); +$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'], From 7d463a85f750ba2c5f7d37479d4de04f8755c995 Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Mon, 14 Sep 2026 04:20:34 -0300 Subject: [PATCH 32/58] fix(native): validate selectors and compare cascade specificity exactly --- .../selector-cascade-validation-2026-09-14.md | 29 +++++ .../native/src/Internal/TemplateRenderer.php | 100 +++++++++++++----- packages/native/tests/language2.php | 24 +++++ 3 files changed, 127 insertions(+), 26 deletions(-) create mode 100644 docs/selector-cascade-validation-2026-09-14.md 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/packages/native/src/Internal/TemplateRenderer.php b/packages/native/src/Internal/TemplateRenderer.php index 770e86c9..8d4707e6 100644 --- a/packages/native/src/Internal/TemplateRenderer.php +++ b/packages/native/src/Internal/TemplateRenderer.php @@ -2601,17 +2601,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] ??= []; } @@ -2815,7 +2821,11 @@ private static function scopedStyleAttributes( return self::resolveDynamicStyles($attributes, $data); } - /** @param array $raw @param array $data @return array */ + /** + * @param array $raw + * @param array $data + * @return array + */ private static function styleNodeDescriptor(string $tag, ?string $classes, array $raw, ?object $scope, array $data): array { $attributes = []; @@ -2840,10 +2850,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'] : []; @@ -2853,29 +2869,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') { @@ -2893,24 +2930,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), diff --git a/packages/native/tests/language2.php b/packages/native/tests/language2.php index 3bf67f1f..17bf8219 100644 --- a/packages/native/tests/language2.php +++ b/packages/native/tests/language2.php @@ -349,6 +349,30 @@ final class ModuleStyleExample {} StyleVariables::replace([]); $reactiveMethod = new ReflectionMethod(TemplateRenderer::class, 'reactiveStyleSheet'); +$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']], From 2654c90bb0e3d64b239cac3faf4667319e861eea Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Mon, 14 Sep 2026 04:26:58 -0300 Subject: [PATCH 33/58] fix(native): validate template metadata and close renderer static errors --- docs/responsive-grid-template-contract.md | 5 ++ ...late-renderer-static-closure-2026-09-14.md | 32 +++++++ .../native/src/Internal/TemplateRenderer.php | 90 +++++++++++++------ packages/native/tests/language2.php | 44 +++++++++ 4 files changed, 144 insertions(+), 27 deletions(-) create mode 100644 docs/template-renderer-static-closure-2026-09-14.md diff --git a/docs/responsive-grid-template-contract.md b/docs/responsive-grid-template-contract.md index a13d0ad5..ba4656ad 100644 --- a/docs/responsive-grid-template-contract.md +++ b/docs/responsive-grid-template-contract.md @@ -52,6 +52,11 @@ SDK typed construction, template attributes, UI migration and device visual chec 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. 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/packages/native/src/Internal/TemplateRenderer.php b/packages/native/src/Internal/TemplateRenderer.php index 8d4707e6..666ae71d 100644 --- a/packages/native/src/Internal/TemplateRenderer.php +++ b/packages/native/src/Internal/TemplateRenderer.php @@ -922,7 +922,7 @@ private static function tag( $inheritedStyles = []; } $attributes = [ - ...$inheritedStyles, + ...self::styleAttributes($inheritedStyles, 'inherited styles'), ...self::scopedStyleAttributes( $tag, $resolvedClass, @@ -1058,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, @@ -1072,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 === '' @@ -1163,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; @@ -1590,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}."), }; } @@ -1604,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}."), }; } @@ -2630,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, @@ -2643,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 !== [] @@ -2727,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}.", @@ -2743,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; } @@ -2758,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( @@ -2771,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']) { @@ -2795,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, @@ -2818,13 +2840,13 @@ 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 + * @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 { @@ -2972,7 +2994,7 @@ private static function styleCompoundMatches(mixed $compound, mixed $node): bool } /** - * @param array $attributes + * @param array $attributes * @param array $data * @return array */ @@ -3198,6 +3220,20 @@ 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 { @@ -3252,9 +3288,9 @@ private static function styleSheetFonts(array $data): array } /** - * @param array $attributes + * @param array $attributes * @param array> $fonts - * @return array + * @return array */ private static function resolveScopedFont(array $attributes, array $fonts): array { @@ -3475,8 +3511,8 @@ private static function custom( } /** - * @param array $attributes - * @return array + * @param array $attributes + * @return array */ private static function nativeEventAliases(array $attributes): array { @@ -3542,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/tests/language2.php b/packages/native/tests/language2.php index 17bf8219..e7c3f1a2 100644 --- a/packages/native/tests/language2.php +++ b/packages/native/tests/language2.php @@ -349,6 +349,50 @@ 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 = [ From 8338bec7847c44d3c16e977c5b9efb9e56f81e57 Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Mon, 14 Sep 2026 04:43:17 -0300 Subject: [PATCH 34/58] fix(android): mount inserted descendants in retained virtual cells --- .../render/PamRendererInstrumentedTest.kt | 55 +++++++++++++++++++ .../dev/pam/nativeapp/render/PamRenderer.kt | 13 ++++- docs/virtual-cell-insertion-2026-09-14.md | 20 +++++++ 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 docs/virtual-cell-insertion-2026-09-14.md 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 306e3ebf..629aa853 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,61 @@ import java.util.concurrent.TimeUnit @RunWith(AndroidJUnit4::class) class PamRendererInstrumentedTest { + @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() 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 f8bc54b7..50200ad1 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 @@ -456,10 +456,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 +482,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) { 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..35854b60 --- /dev/null +++ b/docs/virtual-cell-insertion-2026-09-14.md @@ -0,0 +1,20 @@ +# 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. From b84bd2ee5569e1a3f889d03542128a37543dc935 Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Mon, 14 Sep 2026 04:52:58 -0300 Subject: [PATCH 35/58] test(ios): cover conditional insertion in retained virtual cells --- docs/virtual-cell-insertion-2026-09-14.md | 14 +++++++ .../PamVirtualCellInsertionTests.swift | 38 +++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 ios/Tests/PamNativeTests/PamVirtualCellInsertionTests.swift diff --git a/docs/virtual-cell-insertion-2026-09-14.md b/docs/virtual-cell-insertion-2026-09-14.md index 35854b60..ea50b830 100644 --- a/docs/virtual-cell-insertion-2026-09-14.md +++ b/docs/virtual-cell-insertion-2026-09-14.md @@ -18,3 +18,17 @@ 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/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)) + } + } +} From e8401417fb0ae7c0adbe530887151cdf5f867602 Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Mon, 14 Sep 2026 05:27:30 -0300 Subject: [PATCH 36/58] fix(android): preserve accessibility for partially visible virtual rows --- .../render/PamRendererInstrumentedTest.kt | 36 +++++++++++++++++++ .../pam/nativeapp/render/PamRecyclerList.kt | 17 ++++----- docs/virtual-row-accessibility-2026-09-14.md | 22 ++++++++++++ 3 files changed, 67 insertions(+), 8 deletions(-) create mode 100644 docs/virtual-row-accessibility-2026-09-14.md 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 629aa853..99cad240 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,42 @@ import java.util.concurrent.TimeUnit @RunWith(AndroidJUnit4::class) class PamRendererInstrumentedTest { + @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() 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/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. From 807b76cc5034790e429c671b14dd1190cbcbc359 Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Mon, 14 Sep 2026 05:35:25 -0300 Subject: [PATCH 37/58] fix(android): retain native list accessibility delegate behavior --- .../render/PamRendererInstrumentedTest.kt | 51 +++++++++++++++++++ .../dev/pam/nativeapp/render/PamRenderer.kt | 16 ++++-- ...-list-accessibility-delegate-2026-09-14.md | 32 ++++++++++++ 3 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 docs/virtual-list-accessibility-delegate-2026-09-14.md 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 99cad240..a410ed02 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,57 @@ import java.util.concurrent.TimeUnit @RunWith(AndroidJUnit4::class) class PamRendererInstrumentedTest { + @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() 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 50200ad1..745beeed 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 @@ -6767,6 +6767,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)) @@ -6776,7 +6777,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()) { @@ -6787,8 +6788,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 } @@ -6817,7 +6824,8 @@ class PamRenderer( ) return true } - return super.performAccessibilityAction(host, actionId, arguments) + return nativeListDelegate?.performAccessibilityAction(host, actionId, arguments) + ?: super.performAccessibilityAction(host, actionId, arguments) } } } 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. From 8e8c3524f63fae19c87630547d67942e43019d7e Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Mon, 14 Sep 2026 06:30:49 -0300 Subject: [PATCH 38/58] feat(android): expose reusable overlay collision resolution --- .../render/OverlayCollisionResolverTest.kt | 32 +++++++++++++ .../views/OverlayCollisionResolver.kt | 45 +++++++++++++++++++ ...overlay-collision-resolution-2026-09-14.md | 26 +++++++++++ 3 files changed, 103 insertions(+) create mode 100644 android/app/src/test/java/dev/pam/nativeapp/render/OverlayCollisionResolverTest.kt create mode 100644 android/plugin-api/src/main/java/dev/pam/nativeapp/views/OverlayCollisionResolver.kt create mode 100644 docs/overlay-collision-resolution-2026-09-14.md 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/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/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`. From 2e355305d4def4bc160c7b9ee5ee23646f6c19ba Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Mon, 14 Sep 2026 07:04:03 -0300 Subject: [PATCH 39/58] style(engine): format responsive grid layout and regressions --- crates/pam-native-engine/src/layout.rs | 313 ++++++++++++++++++++----- 1 file changed, 251 insertions(+), 62 deletions(-) diff --git a/crates/pam-native-engine/src/layout.rs b/crates/pam-native-engine/src/layout.rs index d95cd4e2..a330771a 100644 --- a/crates/pam-native-engine/src/layout.rs +++ b/crates/pam-native-engine/src/layout.rs @@ -1034,17 +1034,25 @@ fn is_grid(node: &Node) -> bool { || node.properties.contains_key(&PropKey::GridTemplate) } -fn resolved_grid_plan(node: &Node, width: f32, fallback_gap: f32) -> Result<(usize, f32, f32, Option), LayoutError> { +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)?; + 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, + 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, @@ -1068,7 +1076,8 @@ fn layout_grid( depth: usize, output: &mut BTreeMap, ) -> Result<(), LayoutError> { - let (columns, column_gap, row_gap, level) = resolved_grid_plan(node, inner.width, 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 @@ -1146,12 +1155,23 @@ fn layout_grid( .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() + && 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), + dimension( + placement.child, + PropKey::MaxHeight, + PropKey::MaxHeightPercent, + inner.height, + ), )? } else { placement.height @@ -1160,7 +1180,8 @@ fn layout_grid( CrossAlignment::Center => (row_height - height + margin_top - margin_bottom) / 2.0, CrossAlignment::End => row_height - height - margin_bottom, _ => margin_top, - }.max(0.0); + } + .max(0.0); let x = inner.x + placement.column as f32 * (unit + column_gap) + margin_left; let frame = Layout { x, @@ -1215,7 +1236,13 @@ enum GridValue { Order, } -fn responsive_grid_value(node: &Node, width: f32, value: GridValue, default: i64, template_level: Option) -> 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, @@ -4996,57 +5023,158 @@ mod tests { 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())), - ])); + 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)), - ])); + 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(); + 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(); + 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(); + 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(); + 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))); + 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)), - ])), + ( + 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)), - ])); + 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"); + 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); @@ -5060,35 +5188,96 @@ mod tests { 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)), - ])), + ( + 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"); + 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); From e963ef5dd2cdbca9ea232a030dfc2e2a1a911776 Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Mon, 14 Sep 2026 07:09:10 -0300 Subject: [PATCH 40/58] chore(native): prepare 1.0.28 candidate for expanded plugin API --- Cargo.lock | 6 +-- Cargo.toml | 2 +- ...ui-candidate-rust-validation-2026-09-14.md | 37 +++++++++++++++++++ packages/native/src/Protocol.php | 2 +- packages/native/tests/run.php | 4 +- 5 files changed, 44 insertions(+), 7 deletions(-) create mode 100644 docs/native-ui-candidate-rust-validation-2026-09-14.md diff --git a/Cargo.lock b/Cargo.lock index 0a2c8d2b..3966209b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -76,7 +76,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "pam-native-cli" -version = "1.0.27" +version = "1.0.28" dependencies = [ "serde", "serde_json", @@ -85,7 +85,7 @@ dependencies = [ [[package]] name = "pam-native-engine" -version = "1.0.27" +version = "1.0.28" dependencies = [ "pam-native-protocol", "ttf-parser", @@ -93,7 +93,7 @@ dependencies = [ [[package]] name = "pam-native-protocol" -version = "1.0.27" +version = "1.0.28" [[package]] name = "proc-macro2" diff --git a/Cargo.toml b/Cargo.toml index a8f5b8b2..45506215 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ exclude = ["pam-cli"] [workspace.package] -version = "1.0.27" +version = "1.0.28" edition = "2024" rust-version = "1.88" license = "Apache-2.0" 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..808ae512 --- /dev/null +++ b/docs/native-ui-candidate-rust-validation-2026-09-14.md @@ -0,0 +1,37 @@ +# Native UI candidate — Rust validation + +## 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 were still in progress when recorded. +- 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 run 34831423557 that was still running at this checkpoint. + +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/packages/native/src/Protocol.php b/packages/native/src/Protocol.php index 7d5acd29..5a6c17bd 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.27'; + public const string SDK_VERSION = '1.0.28'; public const int ABI_VERSION = 1; public const int MINIMUM_VERSION = 1; public const int VERSION = 1; diff --git a/packages/native/tests/run.php b/packages/native/tests/run.php index 457de806..5ed18683 100644 --- a/packages/native/tests/run.php +++ b/packages/native/tests/run.php @@ -6648,8 +6648,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.27', - 'The runtime SDK contract must match the 1.0.27 release candidate.', + \Pam\Native\Protocol::SDK_VERSION === '1.0.28', + 'The runtime SDK contract must match the 1.0.28 release candidate.', ); $protocolReport = \Pam\Native\Protocol::negotiate(new \Pam\Native\ProtocolHandshake( abiVersion: 1, From 8346f69978e5bf117d6be8513eca60f3b29cd5cb Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Mon, 14 Sep 2026 07:31:11 -0300 Subject: [PATCH 41/58] docs(native): record completed platform and accessibility contracts --- ...ative-ui-candidate-rust-validation-2026-09-14.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/native-ui-candidate-rust-validation-2026-09-14.md b/docs/native-ui-candidate-rust-validation-2026-09-14.md index 808ae512..4e9d4c83 100644 --- a/docs/native-ui-candidate-rust-validation-2026-09-14.md +++ b/docs/native-ui-candidate-rust-validation-2026-09-14.md @@ -8,14 +8,23 @@ Run https://github.com/push-in/pam-native/actions/runs/34831107281, SHA - 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 were still in progress when recorded. +- 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 run 34831423557 that was still running at this checkpoint. +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 From f3f231120875061aff2d63fa1e75c28b254d7c5b Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Mon, 14 Sep 2026 08:55:11 -0300 Subject: [PATCH 42/58] docs(native): retain verified 1.0.28 candidate evidence --- ...ssibility-evidence-e963ef5-2026-09-14.json | 118 ++++++++++++++++++ ...ui-candidate-rust-validation-2026-09-14.md | 21 ++++ 2 files changed, 139 insertions(+) create mode 100644 docs/accessibility-evidence-e963ef5-2026-09-14.json 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/native-ui-candidate-rust-validation-2026-09-14.md b/docs/native-ui-candidate-rust-validation-2026-09-14.md index 4e9d4c83..beb5da9f 100644 --- a/docs/native-ui-candidate-rust-validation-2026-09-14.md +++ b/docs/native-ui-candidate-rust-validation-2026-09-14.md @@ -1,5 +1,26 @@ # 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 From 72653164644da739c05dc4b9d7ebbce8471e14d8 Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Mon, 14 Sep 2026 10:15:08 -0300 Subject: [PATCH 43/58] feat(native): add scoped child visibility layout operation --- crates/pam-native-engine/src/ffi.rs | 120 +++++++++++++++++++++ crates/pam-native-engine/src/lib.rs | 87 ++++++++++++++- docs/native-child-visibility-2026-09-14.md | 30 ++++++ native/pam_native_engine.h | 8 ++ 4 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 docs/native-child-visibility-2026-09-14.md 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/lib.rs b/crates/pam-native-engine/src/lib.rs index 9685b520..607613c3 100644 --- a/crates/pam-native-engine/src/lib.rs +++ b/crates/pam-native-engine/src/lib.rs @@ -23,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)] @@ -175,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(); @@ -556,6 +590,7 @@ pub enum EngineError { InvalidViewport, PatchWithoutTree, UnknownPatchNode(u64), + InvalidNativeChild { owner: u64, child: u64 }, } impl std::fmt::Display for EngineError { @@ -566,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}" + ) + } } } } @@ -954,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/docs/native-child-visibility-2026-09-14.md b/docs/native-child-visibility-2026-09-14.md new file mode 100644 index 00000000..2ac779ce --- /dev/null +++ b/docs/native-child-visibility-2026-09-14.md @@ -0,0 +1,30 @@ +# Native-owned child visibility synchronization + +Status: engine and C ABI implementation; not yet connected to Android/UIKit or +published. PAM Native UI's uncontrolled Treeview reproducer hides 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. 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 From 1d3cb56f75bd69dd8189c39fc1ab649a4a10cb9e Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Mon, 14 Sep 2026 10:29:28 -0300 Subject: [PATCH 44/58] feat(native): wire scoped child visibility on Android and UIKit --- .../app/src/main/cpp/pam_android_bridge.cpp | 22 +++++++++ .../main/java/dev/pam/nativeapp/PamRuntime.kt | 13 +++++ .../dev/pam/nativeapp/render/PamRenderer.kt | 18 +++++++ .../views/NativeChildVisibilityHost.kt | 11 +++++ docs/native-child-visibility-2026-09-14.md | 39 ++++++++++++++- .../Bridge/pam_native_ios_bridge.cpp | 20 ++++++++ .../PamNative/Render/PamRenderer.swift | 16 ++++++- .../PamNative/Runtime/PamRuntime.swift | 15 ++++++ .../PamNative/Views/NativeViewContracts.swift | 6 +++ .../PamNativeTestRuntimeShims/runtime_shims.c | 1 + .../CapabilityIntegrationTests.swift | 48 +++++++++++++++++++ ios/include/pam_native_ios_bridge.h | 7 +++ 12 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 android/plugin-api/src/main/java/dev/pam/nativeapp/views/NativeChildVisibilityHost.kt 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/render/PamRenderer.kt b/android/app/src/main/java/dev/pam/nativeapp/render/PamRenderer.kt index 745beeed..dde3a485 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() @@ -727,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()) { @@ -867,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) + } + } + } + } + } } } } @@ -875,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) } 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/docs/native-child-visibility-2026-09-14.md b/docs/native-child-visibility-2026-09-14.md index 2ac779ce..2ca4c5c0 100644 --- a/docs/native-child-visibility-2026-09-14.md +++ b/docs/native-child-visibility-2026-09-14.md @@ -1,7 +1,8 @@ # Native-owned child visibility synchronization -Status: engine and C ABI implementation; not yet connected to Android/UIKit or -published. PAM Native UI's uncontrolled Treeview reproducer hides descendants +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. @@ -28,3 +29,37 @@ 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/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/Render/PamRenderer.swift b/ios/Sources/PamNative/Render/PamRenderer.swift index 257e5189..e7859578 100644 --- a/ios/Sources/PamNative/Render/PamRenderer.swift +++ b/ios/Sources/PamNative/Render/PamRenderer.swift @@ -3,6 +3,7 @@ import ImageIO import UIKit 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 +175,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 +289,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) @@ -756,7 +760,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 +776,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 { 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..2571c75f 100644 --- a/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift +++ b/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift @@ -17,8 +17,56 @@ 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 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/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, From defacc037dffd799e28f338c5671bf133094577f Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Mon, 14 Sep 2026 10:39:21 -0300 Subject: [PATCH 45/58] fix(android): reconcile deferred authored input values on blur --- .../render/PamRendererInstrumentedTest.kt | 50 +++++++++++++++++++ .../dev/pam/nativeapp/render/PamRenderer.kt | 9 ++++ 2 files changed, 59 insertions(+) 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 a410ed02..6e569c75 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,56 @@ import java.util.concurrent.TimeUnit @RunWith(AndroidJUnit4::class) class PamRendererInstrumentedTest { + @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() 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 dde3a485..77301d01 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 @@ -4318,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()) { @@ -4338,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) } @@ -4621,8 +4627,10 @@ class PamRenderer( formattedNext != state.nativeValue && !state.nativeValueAcknowledged ) { + state.deferredInputValue = next return } + state.deferredInputValue = null if (input.text.toString() == formattedNext) { state.nativeValueAcknowledged = true return @@ -7100,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, From 91d515d0d57401ed057890f805463c404159bddf Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Mon, 14 Sep 2026 10:44:19 -0300 Subject: [PATCH 46/58] fix(android): accept signed decimal input without changing digit keyboards --- .../render/PamRendererInstrumentedTest.kt | 34 +++++++++++++++++++ .../dev/pam/nativeapp/render/PamRenderer.kt | 4 +-- 2 files changed, 36 insertions(+), 2 deletions(-) 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 6e569c75..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,40 @@ 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() 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 77301d01..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 @@ -6558,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 } @@ -6574,7 +6574,7 @@ class PamRenderer( 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 -> From 4882875b2a3f1c14cca7b00ba72d54e8a1beda91 Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Mon, 14 Sep 2026 10:46:54 -0300 Subject: [PATCH 47/58] fix(ios): honor keyboard type and input mode contracts --- .../PamNative/Protocol/PamProtocol.swift | 2 + .../PamNative/Render/PamRenderer.swift | 49 +++++++++++++++++++ .../CapabilityIntegrationTests.swift | 25 ++++++++++ 3 files changed, 76 insertions(+) diff --git a/ios/Sources/PamNative/Protocol/PamProtocol.swift b/ios/Sources/PamNative/Protocol/PamProtocol.swift index d32fd0b3..1a82eb77 100644 --- a/ios/Sources/PamNative/Protocol/PamProtocol.swift +++ b/ios/Sources/PamNative/Protocol/PamProtocol.swift @@ -322,6 +322,8 @@ 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 inputMode = 218 public static let source = 4 public static let width = 5 public static let height = 6 diff --git a/ios/Sources/PamNative/Render/PamRenderer.swift b/ios/Sources/PamNative/Render/PamRenderer.swift index e7859578..c1e4b16f 100644 --- a/ios/Sources/PamNative/Render/PamRenderer.swift +++ b/ios/Sources/PamNative/Render/PamRenderer.swift @@ -2,6 +2,14 @@ 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 +} + public final class PamRenderer { var onNativeChildVisibility: ((Int64, Int64, Bool) -> Void)? private let fontLoader = PamFontLoader() @@ -1440,6 +1448,43 @@ public final class PamRenderer { ) } + 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 hideKeyboard = mode == .none + 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: @@ -1454,6 +1499,8 @@ public final class PamRenderer { field.text = textValue } } + case PamConstants.keyboardType, PamConstants.inputMode: + applyInputKeyboard(view: view, nodeId: nodeId) case PamConstants.value: if let textValue = value.textOrNil(), let drawing = view as? PamDrawingCanvas { drawing.setDrawing(textValue) @@ -1937,6 +1984,8 @@ public final class PamRenderer { view.backgroundColor = .clear case PamConstants.nativeBackgroundColorResource: view.backgroundColor = .clear + case PamConstants.keyboardType, PamConstants.inputMode: + applyInputKeyboard(view: view, nodeId: nodeId) case PamConstants.nativeStateStyles: (view as? PamPressButton)?.pamStateStyles = [:] case PamConstants.imageFit: diff --git a/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift b/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift index 2571c75f..8935f9e5 100644 --- a/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift +++ b/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift @@ -31,6 +31,31 @@ private final class VisibilityFixtureFactory: NativeViewFactory { @MainActor final class CapabilityIntegrationTests: XCTestCase { + 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() From 820b42dd10a5b6b957a4c6bd9077aee5b3b53c28 Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Mon, 14 Sep 2026 10:53:20 -0300 Subject: [PATCH 48/58] fix(ios): apply secure text traits and disambiguate no-keyboard mode --- .../PamNative/Protocol/PamProtocol.swift | 3 + .../PamNative/Render/PamRenderer.swift | 49 ++++++++++++++- .../CapabilityIntegrationTests.swift | 61 +++++++++++++++++++ 3 files changed, 112 insertions(+), 1 deletion(-) diff --git a/ios/Sources/PamNative/Protocol/PamProtocol.swift b/ios/Sources/PamNative/Protocol/PamProtocol.swift index 1a82eb77..99127d02 100644 --- a/ios/Sources/PamNative/Protocol/PamProtocol.swift +++ b/ios/Sources/PamNative/Protocol/PamProtocol.swift @@ -323,7 +323,10 @@ public enum PamConstants { 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 source = 4 public static let width = 5 public static let height = 6 diff --git a/ios/Sources/PamNative/Render/PamRenderer.swift b/ios/Sources/PamNative/Render/PamRenderer.swift index c1e4b16f..6c9fd818 100644 --- a/ios/Sources/PamNative/Render/PamRenderer.swift +++ b/ios/Sources/PamNative/Render/PamRenderer.swift @@ -10,6 +10,10 @@ private enum PamInputModeKind: Int64 { case text = 1, none, decimal, numeric, tel, search, email, url } +private enum PamInputCapitalization: Int64 { + case none = 1, sentences, words, characters +} + public final class PamRenderer { var onNativeChildVisibility: ((Int64, Int64, Bool) -> Void)? private let fontLoader = PamFontLoader() @@ -1448,6 +1452,41 @@ public final class PamRenderer { ) } + 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:)) @@ -1474,7 +1513,7 @@ public final class PamRenderer { case .url: type = .URL } } - let hideKeyboard = mode == .none + let hideKeyboard = mode == PamInputModeKind.none let changed = field.keyboardType != type || (field.inputView != nil) != hideKeyboard field.keyboardType = type if hideKeyboard { @@ -1501,6 +1540,10 @@ public final class PamRenderer { } case PamConstants.keyboardType, PamConstants.inputMode: applyInputKeyboard(view: view, nodeId: nodeId) + case PamConstants.secure: + applyInputSecurity(view: view, nodeId: nodeId) + case PamConstants.inputAutoCorrect, PamConstants.inputAutoCapitalize: + applyInputTextTraits(view: view, nodeId: nodeId) case PamConstants.value: if let textValue = value.textOrNil(), let drawing = view as? PamDrawingCanvas { drawing.setDrawing(textValue) @@ -1986,6 +2029,10 @@ public final class PamRenderer { view.backgroundColor = .clear case PamConstants.keyboardType, PamConstants.inputMode: applyInputKeyboard(view: view, nodeId: nodeId) + case PamConstants.secure: + applyInputSecurity(view: view, nodeId: nodeId) + case PamConstants.inputAutoCorrect, PamConstants.inputAutoCapitalize: + applyInputTextTraits(view: view, nodeId: nodeId) case PamConstants.nativeStateStyles: (view as? PamPressButton)?.pamStateStyles = [:] case PamConstants.imageFit: diff --git a/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift b/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift index 8935f9e5..7a33a732 100644 --- a/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift +++ b/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift @@ -31,6 +31,67 @@ private final class VisibilityFixtureFactory: NativeViewFactory { @MainActor final class CapabilityIntegrationTests: XCTestCase { + 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.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.autocapitalizationType, .none) + renderer.commit([[ + .update(id: 2, key: PamConstants.inputAutoCorrect, value: .flag(true)), + .update(id: 2, key: PamConstants.inputAutoCapitalize, value: .integer(3)), + ]]) + XCTAssertEqual(field.autocorrectionType, .yes) + XCTAssertEqual(field.autocapitalizationType, .words) + renderer.commit([[ + .update(id: 2, key: PamConstants.inputAutoCorrect, value: nil), + .update(id: 2, key: PamConstants.inputAutoCapitalize, value: nil), + ]]) + XCTAssertEqual(field.autocorrectionType, .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 } From f8d2551fbe2a4fae47fc46066e31b28057b21a83 Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Mon, 14 Sep 2026 10:56:47 -0300 Subject: [PATCH 49/58] fix(ios): enforce input length on paste and composition commit --- docs/input-runtime-fixes-2026-09-14.md | 34 +++++++++++++ .../PamNative/Protocol/PamProtocol.swift | 1 + .../Render/PamRenderComponents.swift | 49 +++++++++++++++++++ .../PamNative/Render/PamRenderer.swift | 4 ++ .../CapabilityIntegrationTests.swift | 25 ++++++++++ 5 files changed, 113 insertions(+) create mode 100644 docs/input-runtime-fixes-2026-09-14.md 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..c0bebd6c --- /dev/null +++ b/docs/input-runtime-fixes-2026-09-14.md @@ -0,0 +1,34 @@ +# 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. diff --git a/ios/Sources/PamNative/Protocol/PamProtocol.swift b/ios/Sources/PamNative/Protocol/PamProtocol.swift index 99127d02..1d0d30ba 100644 --- a/ios/Sources/PamNative/Protocol/PamProtocol.swift +++ b/ios/Sources/PamNative/Protocol/PamProtocol.swift @@ -327,6 +327,7 @@ public enum PamConstants { public static let inputMode = 218 public static let inputAutoCorrect = 211 public static let inputAutoCapitalize = 212 + public static let maxLength = 93 public static let source = 4 public static let width = 5 public static let height = 6 diff --git a/ios/Sources/PamNative/Render/PamRenderComponents.swift b/ios/Sources/PamNative/Render/PamRenderComponents.swift index db2526b8..731d4375 100644 --- a/ios/Sources/PamNative/Render/PamRenderComponents.swift +++ b/ios/Sources/PamNative/Render/PamRenderComponents.swift @@ -708,6 +708,7 @@ final class PamSafeAreaView: UIView { } final class PamInputField: UITextField, UITextFieldDelegate { + var maximumLength: Int? private static let maxKeyBytes = 64 var onSelectionChange: ((Int, Int) -> Void)? @@ -818,6 +819,30 @@ final class PamInputField: UITextField, UITextFieldDelegate { super.deleteBackward() } + override func unmarkText() { + super.unmarkText() + enforceCommittedLength() + } + + private func enforceCommittedLength() { + guard 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) { if text.count > 0 { let key = text == "\n" ? "Enter" : String(text.prefix(Self.maxKeyBytes)) @@ -829,6 +854,29 @@ final class PamInputField: UITextField, UITextFieldDelegate { } func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + 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,6 +988,7 @@ final class PamInputField: UITextField, UITextFieldDelegate { } func textFieldDidEndEditing(_ textField: UITextField) { + enforceCommittedLength() onInputEndEditing?(text ?? "") } diff --git a/ios/Sources/PamNative/Render/PamRenderer.swift b/ios/Sources/PamNative/Render/PamRenderer.swift index 6c9fd818..80fdf353 100644 --- a/ios/Sources/PamNative/Render/PamRenderer.swift +++ b/ios/Sources/PamNative/Render/PamRenderer.swift @@ -1542,6 +1542,8 @@ public final class PamRenderer { applyInputKeyboard(view: view, nodeId: nodeId) 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.inputAutoCorrect, PamConstants.inputAutoCapitalize: applyInputTextTraits(view: view, nodeId: nodeId) case PamConstants.value: @@ -2031,6 +2033,8 @@ public final class PamRenderer { applyInputKeyboard(view: view, nodeId: nodeId) case PamConstants.secure: applyInputSecurity(view: view, nodeId: nodeId) + case PamConstants.maxLength: + (view as? PamInputField)?.maximumLength = nil case PamConstants.inputAutoCorrect, PamConstants.inputAutoCapitalize: applyInputTextTraits(view: view, nodeId: nodeId) case PamConstants.nativeStateStyles: diff --git a/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift b/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift index 7a33a732..fc91acdc 100644 --- a/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift +++ b/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift @@ -31,6 +31,31 @@ private final class VisibilityFixtureFactory: NativeViewFactory { @MainActor final class CapabilityIntegrationTests: XCTestCase { + 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 } From 7a9bee7d55bbabe5356f8b3cd3419f6aa85fe280 Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Mon, 14 Sep 2026 10:59:56 -0300 Subject: [PATCH 50/58] fix(ios): preserve readonly input semantics across editing paths --- docs/input-runtime-fixes-2026-09-14.md | 11 ++++++++ .../PamNative/Protocol/PamProtocol.swift | 1 + .../Render/PamRenderComponents.swift | 11 +++++++- .../PamNative/Render/PamRenderer.swift | 8 +++--- .../CapabilityIntegrationTests.swift | 27 +++++++++++++++++++ 5 files changed, 54 insertions(+), 4 deletions(-) diff --git a/docs/input-runtime-fixes-2026-09-14.md b/docs/input-runtime-fixes-2026-09-14.md index c0bebd6c..9f0a600f 100644 --- a/docs/input-runtime-fixes-2026-09-14.md +++ b/docs/input-runtime-fixes-2026-09-14.md @@ -32,3 +32,14 @@ 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. diff --git a/ios/Sources/PamNative/Protocol/PamProtocol.swift b/ios/Sources/PamNative/Protocol/PamProtocol.swift index 1d0d30ba..23c0129f 100644 --- a/ios/Sources/PamNative/Protocol/PamProtocol.swift +++ b/ios/Sources/PamNative/Protocol/PamProtocol.swift @@ -328,6 +328,7 @@ public enum PamConstants { public static let inputAutoCorrect = 211 public static let inputAutoCapitalize = 212 public static let maxLength = 93 + public static let inputEditable = 210 public static let source = 4 public static let width = 5 public static let height = 6 diff --git a/ios/Sources/PamNative/Render/PamRenderComponents.swift b/ios/Sources/PamNative/Render/PamRenderComponents.swift index 731d4375..09a291a1 100644 --- a/ios/Sources/PamNative/Render/PamRenderComponents.swift +++ b/ios/Sources/PamNative/Render/PamRenderComponents.swift @@ -709,6 +709,7 @@ final class PamSafeAreaView: UIView { final class PamInputField: UITextField, UITextFieldDelegate { var maximumLength: Int? + var isInputEditable = true private static let maxKeyBytes = 64 var onSelectionChange: ((Int, Int) -> Void)? @@ -815,6 +816,7 @@ final class PamInputField: UITextField, UITextFieldDelegate { } override func deleteBackward() { + guard isInputEditable else { return } onKeyPress?("Backspace") super.deleteBackward() } @@ -824,8 +826,13 @@ final class PamInputField: UITextField, UITextFieldDelegate { enforceCommittedLength() } + override func setMarkedText(_ markedText: String?, selectedRange: NSRange) { + guard isInputEditable else { return } + super.setMarkedText(markedText, selectedRange: selectedRange) + } + private func enforceCommittedLength() { - guard markedTextRange == nil, let limit = maximumLength, + guard isInputEditable, markedTextRange == nil, let limit = maximumLength, let current = text, current.utf16.count > limit else { return } var bounded = "" var remaining = limit @@ -844,6 +851,7 @@ final class PamInputField: UITextField, UITextFieldDelegate { } 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 { @@ -854,6 +862,7 @@ 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 diff --git a/ios/Sources/PamNative/Render/PamRenderer.swift b/ios/Sources/PamNative/Render/PamRenderer.swift index 80fdf353..4600f091 100644 --- a/ios/Sources/PamNative/Render/PamRenderer.swift +++ b/ios/Sources/PamNative/Render/PamRenderer.swift @@ -1513,7 +1513,9 @@ public final class PamRenderer { case .url: type = .URL } } - let hideKeyboard = mode == PamInputModeKind.none + let editable = state.properties[PamConstants.inputEditable]?.boolOrNil() ?? true + (field as? PamInputField)?.isInputEditable = editable + let hideKeyboard = mode == PamInputModeKind.none || !editable let changed = field.keyboardType != type || (field.inputView != nil) != hideKeyboard field.keyboardType = type if hideKeyboard { @@ -1538,7 +1540,7 @@ public final class PamRenderer { field.text = textValue } } - case PamConstants.keyboardType, PamConstants.inputMode: + case PamConstants.keyboardType, PamConstants.inputMode, PamConstants.inputEditable: applyInputKeyboard(view: view, nodeId: nodeId) case PamConstants.secure: applyInputSecurity(view: view, nodeId: nodeId) @@ -2029,7 +2031,7 @@ public final class PamRenderer { view.backgroundColor = .clear case PamConstants.nativeBackgroundColorResource: view.backgroundColor = .clear - case PamConstants.keyboardType, PamConstants.inputMode: + case PamConstants.keyboardType, PamConstants.inputMode, PamConstants.inputEditable: applyInputKeyboard(view: view, nodeId: nodeId) case PamConstants.secure: applyInputSecurity(view: view, nodeId: nodeId) diff --git a/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift b/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift index fc91acdc..e2e77d1a 100644 --- a/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift +++ b/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift @@ -31,6 +31,33 @@ private final class VisibilityFixtureFactory: NativeViewFactory { @MainActor final class CapabilityIntegrationTests: XCTestCase { + 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 From cef35fcb2610f29028fac2dd4dc1d720aca52c55 Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Mon, 14 Sep 2026 11:02:49 -0300 Subject: [PATCH 51/58] fix(ios): map input completion hints and return key traits --- docs/input-runtime-fixes-2026-09-14.md | 9 ++++ .../PamNative/Protocol/PamProtocol.swift | 2 + .../PamNative/Render/PamRenderer.swift | 45 +++++++++++++++++++ .../CapabilityIntegrationTests.swift | 12 +++++ 4 files changed, 68 insertions(+) diff --git a/docs/input-runtime-fixes-2026-09-14.md b/docs/input-runtime-fixes-2026-09-14.md index 9f0a600f..4f4910ac 100644 --- a/docs/input-runtime-fixes-2026-09-14.md +++ b/docs/input-runtime-fixes-2026-09-14.md @@ -43,3 +43,12 @@ 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. diff --git a/ios/Sources/PamNative/Protocol/PamProtocol.swift b/ios/Sources/PamNative/Protocol/PamProtocol.swift index 23c0129f..db4c30bb 100644 --- a/ios/Sources/PamNative/Protocol/PamProtocol.swift +++ b/ios/Sources/PamNative/Protocol/PamProtocol.swift @@ -329,6 +329,8 @@ public enum PamConstants { 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 source = 4 public static let width = 5 public static let height = 6 diff --git a/ios/Sources/PamNative/Render/PamRenderer.swift b/ios/Sources/PamNative/Render/PamRenderer.swift index 4600f091..e5d0e3f5 100644 --- a/ios/Sources/PamNative/Render/PamRenderer.swift +++ b/ios/Sources/PamNative/Render/PamRenderer.swift @@ -14,6 +14,10 @@ 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() @@ -1452,6 +1456,43 @@ public final class PamRenderer { ) } + 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() @@ -1548,6 +1589,8 @@ public final class PamRenderer { (view as? PamInputField)?.maximumLength = value.integerOrNil().map { max(0, Int(clamping: $0)) } case PamConstants.inputAutoCorrect, PamConstants.inputAutoCapitalize: applyInputTextTraits(view: view, nodeId: nodeId) + case PamConstants.autoComplete, PamConstants.returnKeyType: + applyInputCompletion(view: view, nodeId: nodeId) case PamConstants.value: if let textValue = value.textOrNil(), let drawing = view as? PamDrawingCanvas { drawing.setDrawing(textValue) @@ -2039,6 +2082,8 @@ public final class PamRenderer { (view as? PamInputField)?.maximumLength = nil case PamConstants.inputAutoCorrect, PamConstants.inputAutoCapitalize: applyInputTextTraits(view: view, nodeId: nodeId) + case PamConstants.autoComplete, PamConstants.returnKeyType: + applyInputCompletion(view: view, nodeId: nodeId) case PamConstants.nativeStateStyles: (view as? PamPressButton)?.pamStateStyles = [:] case PamConstants.imageFit: diff --git a/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift b/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift index e2e77d1a..b7d8a672 100644 --- a/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift +++ b/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift @@ -91,6 +91,8 @@ final class CapabilityIntegrationTests: XCTestCase { .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), ])), @@ -98,18 +100,28 @@ final class CapabilityIntegrationTests: XCTestCase { ]]) 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) } From 68d50adf09565011944bb1469a936d9a1a8de5e6 Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Mon, 14 Sep 2026 11:06:36 -0300 Subject: [PATCH 52/58] fix(ios): retain current submit text with isolated callback ownership --- docs/input-runtime-fixes-2026-09-14.md | 6 ++++ .../PamNative/Render/PamRenderer.swift | 5 +++- .../CapabilityIntegrationTests.swift | 29 +++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/docs/input-runtime-fixes-2026-09-14.md b/docs/input-runtime-fixes-2026-09-14.md index 4f4910ac..9a95893b 100644 --- a/docs/input-runtime-fixes-2026-09-14.md +++ b/docs/input-runtime-fixes-2026-09-14.md @@ -52,3 +52,9 @@ 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. diff --git a/ios/Sources/PamNative/Render/PamRenderer.swift b/ios/Sources/PamNative/Render/PamRenderer.swift index e5d0e3f5..faa5797f 100644 --- a/ios/Sources/PamNative/Render/PamRenderer.swift +++ b/ios/Sources/PamNative/Render/PamRenderer.swift @@ -3734,6 +3734,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? @@ -3786,6 +3787,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 @@ -4246,6 +4248,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 @@ -4775,7 +4778,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/Tests/PamNativeTests/CapabilityIntegrationTests.swift b/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift index b7d8a672..4678b00b 100644 --- a/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift +++ b/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift @@ -31,6 +31,35 @@ private final class VisibilityFixtureFactory: NativeViewFactory { @MainActor final class CapabilityIntegrationTests: XCTestCase { + 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) + field.text = "Edited value" + field.sendActions(for: .primaryActionTriggered) + 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)]]) + field.sendActions(for: .primaryActionTriggered) + XCTAssertEqual(payloads.count, 1, "Removing submit must detach its target") + } + func testReadonlyInputRejectsMutationWithoutDisablingSelection() throws { let host = UIView() let renderer = PamRenderer(hostView: host) { _, _, _ in } From 48e55326251464b1cd7fd10ada1774f7be7374fd Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Mon, 14 Sep 2026 11:08:56 -0300 Subject: [PATCH 53/58] fix(ios): honor input submit and blur lifecycle semantics --- docs/input-runtime-fixes-2026-09-14.md | 8 ++++ .../PamNative/Protocol/PamProtocol.swift | 1 + .../Render/PamRenderComponents.swift | 15 ++++-- .../PamNative/Render/PamRenderer.swift | 5 ++ .../CapabilityIntegrationTests.swift | 47 +++++++++++++++++++ 5 files changed, 72 insertions(+), 4 deletions(-) diff --git a/docs/input-runtime-fixes-2026-09-14.md b/docs/input-runtime-fixes-2026-09-14.md index 9a95893b..96d95be7 100644 --- a/docs/input-runtime-fixes-2026-09-14.md +++ b/docs/input-runtime-fixes-2026-09-14.md @@ -58,3 +58,11 @@ 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. diff --git a/ios/Sources/PamNative/Protocol/PamProtocol.swift b/ios/Sources/PamNative/Protocol/PamProtocol.swift index db4c30bb..a3e2923d 100644 --- a/ios/Sources/PamNative/Protocol/PamProtocol.swift +++ b/ios/Sources/PamNative/Protocol/PamProtocol.swift @@ -331,6 +331,7 @@ public enum PamConstants { public static let inputEditable = 210 public static let autoComplete = 48 public static let returnKeyType = 95 + public static let inputSubmitBehavior = 224 public static let source = 4 public static let width = 5 public static let height = 6 diff --git a/ios/Sources/PamNative/Render/PamRenderComponents.swift b/ios/Sources/PamNative/Render/PamRenderComponents.swift index 09a291a1..7edba5a6 100644 --- a/ios/Sources/PamNative/Render/PamRenderComponents.swift +++ b/ios/Sources/PamNative/Render/PamRenderComponents.swift @@ -707,7 +707,12 @@ 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 private static let maxKeyBytes = 64 @@ -1002,10 +1007,12 @@ final class PamInputField: UITextField, UITextFieldDelegate { } 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 faa5797f..c06c2144 100644 --- a/ios/Sources/PamNative/Render/PamRenderer.swift +++ b/ios/Sources/PamNative/Render/PamRenderer.swift @@ -1587,6 +1587,9 @@ public final class PamRenderer { 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: @@ -2080,6 +2083,8 @@ public final class PamRenderer { 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: diff --git a/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift b/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift index 4678b00b..d407fd24 100644 --- a/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift +++ b/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift @@ -31,6 +31,53 @@ private final class VisibilityFixtureFactory: NativeViewFactory { @MainActor final class CapabilityIntegrationTests: XCTestCase { + 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] = [] From 431a586d467ea0c3a637953a27aa5f93c97dc74f Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Mon, 14 Sep 2026 11:14:26 -0300 Subject: [PATCH 54/58] fix(ios): apply controlled input selection and verify submit registration --- docs/components.md | 24 +++++++++ docs/input-runtime-fixes-2026-09-14.md | 14 ++++++ .../PamNative/Protocol/PamProtocol.swift | 2 + .../PamNative/Render/PamRenderer.swift | 20 ++++++++ .../CapabilityIntegrationTests.swift | 50 ++++++++++++++++++- 5 files changed, 108 insertions(+), 2 deletions(-) diff --git a/docs/components.md b/docs/components.md index cbdd25a5..761e9c71 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`, diff --git a/docs/input-runtime-fixes-2026-09-14.md b/docs/input-runtime-fixes-2026-09-14.md index 96d95be7..87455ec9 100644 --- a/docs/input-runtime-fixes-2026-09-14.md +++ b/docs/input-runtime-fixes-2026-09-14.md @@ -66,3 +66,17 @@ 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. diff --git a/ios/Sources/PamNative/Protocol/PamProtocol.swift b/ios/Sources/PamNative/Protocol/PamProtocol.swift index a3e2923d..e8184733 100644 --- a/ios/Sources/PamNative/Protocol/PamProtocol.swift +++ b/ios/Sources/PamNative/Protocol/PamProtocol.swift @@ -332,6 +332,8 @@ public enum PamConstants { 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 source = 4 public static let width = 5 public static let height = 6 diff --git a/ios/Sources/PamNative/Render/PamRenderer.swift b/ios/Sources/PamNative/Render/PamRenderer.swift index c06c2144..2c87201e 100644 --- a/ios/Sources/PamNative/Render/PamRenderer.swift +++ b/ios/Sources/PamNative/Render/PamRenderer.swift @@ -342,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, @@ -419,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) } @@ -1456,6 +1460,18 @@ 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() @@ -1594,6 +1610,8 @@ public final class PamRenderer { 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) @@ -2089,6 +2107,8 @@ public final class PamRenderer { 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: diff --git a/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift b/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift index d407fd24..52f10d59 100644 --- a/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift +++ b/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift @@ -31,6 +31,42 @@ private final class VisibilityFixtureFactory: NativeViewFactory { @MainActor final class CapabilityIntegrationTests: XCTestCase { + 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() @@ -98,12 +134,22 @@ final class CapabilityIntegrationTests: XCTestCase { .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" - field.sendActions(for: .primaryActionTriggered) + 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)]]) - field.sendActions(for: .primaryActionTriggered) + invokeRegisteredSubmitTargets() XCTAssertEqual(payloads.count, 1, "Removing submit must detach its target") } From 7130ee9dc557cd05eab014d9f615d270acc686a8 Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Mon, 14 Sep 2026 11:16:48 -0300 Subject: [PATCH 55/58] fix(ios): honor mounted autofocus and software keyboard suppression --- docs/input-runtime-fixes-2026-09-14.md | 8 +++++ .../PamNative/Protocol/PamProtocol.swift | 2 ++ .../Render/PamRenderComponents.swift | 31 +++++++++++++++++++ .../PamNative/Render/PamRenderer.swift | 11 +++++-- .../CapabilityIntegrationTests.swift | 24 ++++++++++++++ 5 files changed, 74 insertions(+), 2 deletions(-) diff --git a/docs/input-runtime-fixes-2026-09-14.md b/docs/input-runtime-fixes-2026-09-14.md index 87455ec9..5bf12a81 100644 --- a/docs/input-runtime-fixes-2026-09-14.md +++ b/docs/input-runtime-fixes-2026-09-14.md @@ -80,3 +80,11 @@ The revised unit test invokes the selectors registered for primaryActionTriggere 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/ios/Sources/PamNative/Protocol/PamProtocol.swift b/ios/Sources/PamNative/Protocol/PamProtocol.swift index e8184733..5ff145cb 100644 --- a/ios/Sources/PamNative/Protocol/PamProtocol.swift +++ b/ios/Sources/PamNative/Protocol/PamProtocol.swift @@ -334,6 +334,8 @@ public enum PamConstants { 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 diff --git a/ios/Sources/PamNative/Render/PamRenderComponents.swift b/ios/Sources/PamNative/Render/PamRenderComponents.swift index 7edba5a6..c6d51011 100644 --- a/ios/Sources/PamNative/Render/PamRenderComponents.swift +++ b/ios/Sources/PamNative/Render/PamRenderComponents.swift @@ -715,6 +715,36 @@ 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)? @@ -818,6 +848,7 @@ final class PamInputField: UITextField, UITextFieldDelegate { super.layoutSubviews() syncFontCache() scheduleContentSizeUpdate() + scheduleAutomaticFocus() } override func deleteBackward() { diff --git a/ios/Sources/PamNative/Render/PamRenderer.swift b/ios/Sources/PamNative/Render/PamRenderer.swift index 2c87201e..7e4570e4 100644 --- a/ios/Sources/PamNative/Render/PamRenderer.swift +++ b/ios/Sources/PamNative/Render/PamRenderer.swift @@ -1573,6 +1573,7 @@ public final class PamRenderer { 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 { @@ -1597,8 +1598,11 @@ public final class PamRenderer { field.text = textValue } } - case PamConstants.keyboardType, PamConstants.inputMode, PamConstants.inputEditable: + 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: @@ -2095,8 +2099,11 @@ public final class PamRenderer { view.backgroundColor = .clear case PamConstants.nativeBackgroundColorResource: view.backgroundColor = .clear - case PamConstants.keyboardType, PamConstants.inputMode, PamConstants.inputEditable: + 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: diff --git a/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift b/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift index 52f10d59..f036f7c3 100644 --- a/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift +++ b/ios/Tests/PamNativeTests/CapabilityIntegrationTests.swift @@ -31,6 +31,30 @@ private final class VisibilityFixtureFactory: NativeViewFactory { @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 } From 1f791f6b52978efb7ef6dc59010cd920d9ce37a0 Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Sat, 3 Oct 2026 21:53:02 -0300 Subject: [PATCH 56/58] fix(native): resolve Composer SDK and pin Android renderer to release --- README.md | 2 +- crates/pam-native-cli/src/mobile.rs | 194 +++++++++++++++++++++++++--- docs/components.md | 2 +- docs/releasing.md | 2 +- packages/native/bin/pam-native | 26 ++++ 5 files changed, 204 insertions(+), 22 deletions(-) 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/crates/pam-native-cli/src/mobile.rs b/crates/pam-native-cli/src/mobile.rs index bd4a07a1..d9a61ebf 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") @@ -6929,7 +7036,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 +8614,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/docs/components.md b/docs/components.md index 23a60900..175b48b5 100644 --- a/docs/components.md +++ b/docs/components.md @@ -1040,7 +1040,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/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/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}"; From 76f72bf766986c4ee16d103239ed5b86807383a4 Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Sat, 3 Oct 2026 22:17:57 -0300 Subject: [PATCH 57/58] perf(native): share Gradle cache and certify exact release renderer --- .github/workflows/release.yml | 25 ++++++++++++++++--------- crates/pam-native-cli/src/mobile.rs | 14 ++++++++++++++ docs/build-hygiene.md | 6 ++++++ docs/showcase.md | 13 ++++++------- 4 files changed, 42 insertions(+), 16 deletions(-) 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/crates/pam-native-cli/src/mobile.rs b/crates/pam-native-cli/src/mobile.rs index d9a61ebf..75a3c74b 100644 --- a/crates/pam-native-cli/src/mobile.rs +++ b/crates/pam-native-cli/src/mobile.rs @@ -6167,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")) } 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/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 From 5d14bd3c29d49fe6893b0b538726939ba289d3b8 Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Sat, 3 Oct 2026 22:19:35 -0300 Subject: [PATCH 58/58] release: prepare PAM Native 1.0.30 --- CHANGELOG.md | 12 ++++++++++++ Cargo.lock | 6 +++--- Cargo.toml | 2 +- packages/native/src/Protocol.php | 2 +- packages/native/tests/run.php | 4 ++-- 5 files changed, 19 insertions(+), 7 deletions(-) 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/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/tests/run.php b/packages/native/tests/run.php index 2abed1b1..0a35cc48 100644 --- a/packages/native/tests/run.php +++ b/packages/native/tests/run.php @@ -6686,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,