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..da9f0776 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,9 +45,113 @@ 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 { + @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 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() + 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() @@ -126,7 +232,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 +246,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() } @@ -1344,6 +1450,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"), ), ), @@ -1371,6 +1478,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 { @@ -2002,6 +2111,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) } 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..daa8e628 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,21 @@ 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 + // 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, + ) + topMargin = if (id == rootId) 0 else engineFrameMargin( + vertical.offset, paddedHost?.paddingTop ?: 0, + ) } } @@ -1403,7 +1418,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), @@ -1455,8 +1477,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, @@ -5055,7 +5086,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/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)) diff --git a/crates/pam-native-engine/src/layout.rs b/crates/pam-native-engine/src/layout.rs index d3ab430c..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,42 @@ 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 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 { + inner_width + }; let child_extent = constrained_intrinsic_extent( children, child, requested_axis, - inner_width, + child_available_width, inner_height, text_scale, text_metrics, @@ -1993,8 +1967,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); } @@ -2003,7 +1977,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); @@ -2017,14 +1994,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()); } @@ -2041,6 +2018,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; @@ -2050,7 +2039,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 @@ -2085,17 +2078,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, @@ -2130,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, @@ -2628,6 +2718,111 @@ 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 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)] { + let mut 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); + 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" + ); + } + } + #[test] fn row_baseline_aligns_text_with_different_font_sizes() { let tree = Tree { @@ -2847,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 { 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. 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.