craft-native 0.0.187 → 0.0.191

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -771,6 +771,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
771
771
  private var gridAutoRows: CraftNativeLength? = null
772
772
  private var reverse = false
773
773
  private var wrapReverse = false
774
+ private var unboundedWidth = false
774
775
  private var authoredPadding = intArrayOf(0, 0, 0, 0)
775
776
  private var safeAreaPadding = intArrayOf(0, 0, 0, 0)
776
777
  private var applyingPadding = false
@@ -947,6 +948,13 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
947
948
  invalidateMeasurements()
948
949
  }
949
950
 
951
+ /** A horizontal scroll host measures its content beyond the viewport. */
952
+ fun setUnboundedWidth(value: Boolean) {
953
+ if (unboundedWidth == value) return
954
+ unboundedWidth = value
955
+ invalidateMeasurements()
956
+ }
957
+
950
958
  fun setChildStyle(view: View, style: JSONObject, density: Float) {
951
959
  styles[view] = CraftNativeLayoutStyle.from(style, density)
952
960
  invalidateMeasurements()
@@ -1237,15 +1245,197 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1237
1245
 
1238
1246
  private fun sharedLength(value: CraftNativeLength?, base: Int?): Float = value?.resolve(base)?.toFloat() ?: Float.NaN
1239
1247
 
1248
+ /** A measured node in the host-neutral tree ABI, retaining its native view
1249
+ * so the returned absolute frames can be mounted back into the hierarchy. */
1250
+ private data class SharedTreeNode(
1251
+ val view: View?,
1252
+ val style: CraftNativeLayoutStyle,
1253
+ val intrinsicWidth: Float,
1254
+ val intrinsicHeight: Float,
1255
+ val baseline: Float,
1256
+ val direction: Int,
1257
+ val gap: Float,
1258
+ val rowGap: Float,
1259
+ val columnGap: Float,
1260
+ val wrap: Boolean,
1261
+ val justify: String,
1262
+ val alignItems: String,
1263
+ val alignContent: String,
1264
+ val padding: IntArray,
1265
+ val margins: IntArray,
1266
+ val grid: Boolean,
1267
+ val gridColumns: Int,
1268
+ val reverse: Boolean,
1269
+ val wrapReverse: Boolean,
1270
+ val children: List<SharedTreeNode>,
1271
+ )
1272
+
1273
+ private data class SharedTreeResult(
1274
+ val nodes: List<SharedTreeNode>,
1275
+ val parents: IntArray,
1276
+ val frames: FloatArray,
1277
+ )
1278
+
1279
+ /**
1280
+ * Flatten a flex or equal-track grid subtree for the shared ABI. The local
1281
+ * layout stays authoritative for grid-auto-rows,
1282
+ * absolute/modal children, and scroll hosts; those cases cannot be
1283
+ * represented without changing their native measurement contract.
1284
+ */
1285
+ private fun sharedTreeChildren(width: Int, height: Int): List<Entry>? {
1286
+ if (gridAutoRows != null) return null
1287
+ if (grid) {
1288
+ val layout = gridLayout(width, height)
1289
+ if (layout.absolute.isNotEmpty()) return null
1290
+ return layout.rows.flatten()
1291
+ }
1292
+ val (lines, absolute) = entries(width, height)
1293
+ if (absolute.isNotEmpty()) return null
1294
+ return lines.flatMap { it.entries }
1295
+ }
1296
+
1297
+ private fun sharedTreeNode(entry: Entry): SharedTreeNode? {
1298
+ val nested = entry.view as? CraftNativeFlexLayout
1299
+ val children = if (nested == null) {
1300
+ emptyList()
1301
+ } else {
1302
+ nested.sharedTreeChildren(entry.width, entry.height) ?: return null
1303
+ }
1304
+ val lp = entry.view.layoutParams as? MarginLayoutParams
1305
+ return SharedTreeNode(
1306
+ view = entry.view,
1307
+ style = entry.style,
1308
+ intrinsicWidth = entry.width.toFloat(),
1309
+ intrinsicHeight = entry.height.toFloat(),
1310
+ baseline = baselineOffset(entry.view, entry.height).toFloat(),
1311
+ direction = nested?.let { if (it.orientation == HORIZONTAL) 0 else 1 } ?: 0,
1312
+ gap = nested?.let { if (it.orientation == HORIZONTAL) it.columnGap else it.rowGap } ?: 0f,
1313
+ rowGap = nested?.rowGap ?: 0f,
1314
+ columnGap = nested?.columnGap ?: 0f,
1315
+ wrap = nested?.wrap ?: false,
1316
+ justify = nested?.justifyContent ?: "flex-start",
1317
+ alignItems = nested?.alignItems ?: "stretch",
1318
+ alignContent = nested?.alignContent ?: "flex-start",
1319
+ padding = nested?.let { intArrayOf(it.paddingLeft, it.paddingRight, it.paddingTop, it.paddingBottom) }
1320
+ ?: intArrayOf(0, 0, 0, 0),
1321
+ margins = intArrayOf(lp?.leftMargin ?: 0, lp?.rightMargin ?: 0, lp?.topMargin ?: 0, lp?.bottomMargin ?: 0),
1322
+ grid = nested?.grid ?: false,
1323
+ gridColumns = nested?.gridColumns ?: 1,
1324
+ reverse = nested?.reverse ?: false,
1325
+ wrapReverse = nested?.wrapReverse ?: false,
1326
+ children = children.map { child -> sharedTreeNode(child) ?: return null },
1327
+ )
1328
+ }
1329
+
1330
+ private fun sharedTreeFrames(entries: List<Entry>, width: Int, height: Int): SharedTreeResult? {
1331
+ if (entries.isEmpty() || width < 0 || height < 0 || gridAutoRows != null) return null
1332
+ val children = entries.map { sharedTreeNode(it) ?: return null }
1333
+ val root = SharedTreeNode(
1334
+ view = null,
1335
+ style = CraftNativeLayoutStyle(),
1336
+ intrinsicWidth = 0f,
1337
+ intrinsicHeight = 0f,
1338
+ baseline = 0f,
1339
+ direction = if (orientation == HORIZONTAL) 0 else 1,
1340
+ gap = if (orientation == HORIZONTAL) columnGap else rowGap,
1341
+ rowGap = rowGap,
1342
+ columnGap = columnGap,
1343
+ wrap = wrap,
1344
+ justify = justifyContent,
1345
+ alignItems = alignItems,
1346
+ alignContent = alignContent,
1347
+ padding = intArrayOf(paddingLeft, paddingRight, paddingTop, paddingBottom),
1348
+ margins = intArrayOf(0, 0, 0, 0),
1349
+ grid = grid,
1350
+ gridColumns = gridColumns,
1351
+ reverse = reverse,
1352
+ wrapReverse = wrapReverse,
1353
+ children = children,
1354
+ )
1355
+ val nodes = mutableListOf<SharedTreeNode>()
1356
+ val parents = mutableListOf<Int>()
1357
+ fun append(node: SharedTreeNode, parent: Int) {
1358
+ val index = nodes.size
1359
+ nodes += node
1360
+ parents += parent
1361
+ node.children.forEach { child -> append(child, index) }
1362
+ }
1363
+ append(root, -1)
1364
+ val reverseAware = nodes.any { it.reverse || it.wrapReverse }
1365
+ val wrapReverseAware = nodes.any { it.wrapReverse }
1366
+ val fields = if (reverseAware) 33 else 32
1367
+ val nodeData = FloatArray(nodes.size * fields) { Float.NaN }
1368
+ val treeMeta = FloatArray(nodes.size * 2)
1369
+ fun enumBits(value: Int): Float = Float.fromBits(value)
1370
+ fun put(base: Int, index: Int, value: Float) { nodeData[base + index] = value }
1371
+ fun putEnum(base: Int, index: Int, value: Int) { put(base, index, enumBits(value)) }
1372
+ var cursor = 0
1373
+ fun write(node: SharedTreeNode, parentWidth: Int, parentHeight: Int, rootNode: Boolean) {
1374
+ val index = cursor++
1375
+ val base = index * fields
1376
+ val measuredWidth = if (rootNode) width else node.style.width?.resolve(parentWidth) ?: node.intrinsicWidth.roundToInt()
1377
+ val measuredHeight = if (rootNode) height else node.style.height?.resolve(parentHeight) ?: node.intrinsicHeight.roundToInt()
1378
+ val contentWidth = max(0, measuredWidth - node.padding[0] - node.padding[1])
1379
+ val contentHeight = max(0, measuredHeight - node.padding[2] - node.padding[3])
1380
+ val mainSize = if (node.direction == 0) contentWidth else contentHeight
1381
+ putEnum(base, 0, node.direction)
1382
+ put(base, 1, if (rootNode) width.toFloat() else sharedLength(node.style.width, parentWidth))
1383
+ put(base, 2, if (rootNode) height.toFloat() else sharedLength(node.style.height, parentHeight))
1384
+ put(base, 3, if (rootNode) Float.NaN else node.intrinsicWidth)
1385
+ put(base, 4, if (rootNode) Float.NaN else node.intrinsicHeight)
1386
+ put(base, 5, if (rootNode) Float.NaN else node.baseline)
1387
+ put(base, 6, node.style.aspectRatio?.toFloat() ?: Float.NaN)
1388
+ put(base, 7, sharedLength(node.style.flexBasis, mainSize))
1389
+ put(base, 8, if (rootNode) 0f else node.style.flexGrow.toFloat().coerceAtLeast(0f))
1390
+ put(base, 9, if (rootNode) 0f else (node.style.flexShrink ?: 0.0).toFloat().coerceAtLeast(0f))
1391
+ put(base, 10, sharedLength(node.style.minWidth, contentWidth))
1392
+ put(base, 11, sharedLength(node.style.maxWidth, contentWidth))
1393
+ put(base, 12, sharedLength(node.style.minHeight, contentHeight))
1394
+ put(base, 13, sharedLength(node.style.maxHeight, contentHeight))
1395
+ put(base, 14, node.gap)
1396
+ putEnum(base, 15, sharedJustifyCode(node.justify))
1397
+ putEnum(base, 16, sharedAlignItemsCode(node.alignItems))
1398
+ putEnum(base, 17, if (rootNode) 0 else sharedAlignSelfCode(node.style.alignSelf))
1399
+ putEnum(base, 18, if (node.wrap) 1 else 0)
1400
+ putEnum(base, 19, sharedAlignContentCode(node.alignContent))
1401
+ put(base, 20, node.margins[2].toFloat())
1402
+ put(base, 21, node.margins[1].toFloat())
1403
+ put(base, 22, node.margins[3].toFloat())
1404
+ put(base, 23, node.margins[0].toFloat())
1405
+ put(base, 24, node.padding[2].toFloat())
1406
+ put(base, 25, node.padding[1].toFloat())
1407
+ put(base, 26, node.padding[3].toFloat())
1408
+ put(base, 27, node.padding[0].toFloat())
1409
+ putEnum(base, 28, if (node.grid) 1 else 0)
1410
+ put(base, 29, node.gridColumns.toFloat())
1411
+ put(base, 30, node.rowGap)
1412
+ put(base, 31, node.columnGap)
1413
+ if (reverseAware) putEnum(base, 32, (if (node.reverse) 1 else 0) or (if (node.wrapReverse) 2 else 0))
1414
+ val firstChild = cursor
1415
+ node.children.forEach { child -> write(child, contentWidth, contentHeight, false) }
1416
+ treeMeta[index * 2] = firstChild.toFloat()
1417
+ treeMeta[index * 2 + 1] = (cursor - firstChild).toFloat()
1418
+ }
1419
+ write(root, width, height, true)
1420
+ val frames = (if (wrapReverseAware) {
1421
+ CraftNative.layoutTreeV3(width.toFloat(), height.toFloat(), nodeData, treeMeta)
1422
+ } else if (reverseAware) {
1423
+ CraftNative.layoutTreeV2(width.toFloat(), height.toFloat(), nodeData, treeMeta)
1424
+ } else {
1425
+ CraftNative.layoutTree(width.toFloat(), height.toFloat(), nodeData, treeMeta)
1426
+ }) ?: return null
1427
+ return SharedTreeResult(nodes, parents.toIntArray(), frames)
1428
+ }
1429
+
1240
1430
  /**
1241
1431
  * Use the host-neutral tree ABI when platform measurements are complete:
1242
- * definite, non-grid direct-child containers, including wrapped flows with
1243
- * independent row and column gaps. Reverse flows, absolute children,
1244
- * missing runtimes, and malformed results deliberately fall through to
1245
- * the established Kotlin layout below.
1432
+ * definite direct-child containers, including wrapped flows, equal-track
1433
+ * grids, and independent row and column gaps. Grid
1434
+ * auto-rows, absolute children, missing runtimes, and malformed results
1435
+ * deliberately fall through to the established Kotlin layout below.
1246
1436
  */
1247
1437
  private fun sharedDirectFrames(entries: List<Entry>, width: Int, height: Int): FloatArray? {
1248
- if (entries.isEmpty() || width < 0 || height < 0 || grid || reverse || wrapReverse) return null
1438
+ if (entries.isEmpty() || width < 0 || height < 0 || grid || reverse || wrapReverse || gridAutoRows != null) return null
1249
1439
  val count = entries.size + 1
1250
1440
  val fields = 32
1251
1441
  val nodeData = FloatArray(count * fields) { Float.NaN }
@@ -1300,7 +1490,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1300
1490
  put(base, 26, padding[3].toFloat())
1301
1491
  put(base, 27, padding[0].toFloat())
1302
1492
  putEnum(base, 28, 0)
1303
- putEnum(base, 29, 1)
1493
+ put(base, 29, 1f)
1304
1494
  put(base, 30, if (root) rowGapValue else 0f)
1305
1495
  put(base, 31, if (root) columnGapValue else 0f)
1306
1496
  }
@@ -1337,6 +1527,15 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1337
1527
  else -> 0
1338
1528
  }
1339
1529
 
1530
+ private fun sharedJustifyCode(value: String): Int = when (value) {
1531
+ "center" -> 1
1532
+ "flex-end" -> 2
1533
+ "space-between" -> 3
1534
+ "space-around" -> 4
1535
+ "space-evenly" -> 5
1536
+ else -> 0
1537
+ }
1538
+
1340
1539
  private fun alignContentCode(): Int = when (alignContent) {
1341
1540
  "center" -> 1
1342
1541
  "flex-end" -> 2
@@ -1347,6 +1546,16 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1347
1546
  else -> 0
1348
1547
  }
1349
1548
 
1549
+ private fun sharedAlignContentCode(value: String): Int = when (value) {
1550
+ "center" -> 1
1551
+ "flex-end" -> 2
1552
+ "space-between" -> 3
1553
+ "space-around" -> 4
1554
+ "stretch" -> 5
1555
+ "space-evenly" -> 6
1556
+ else -> 0
1557
+ }
1558
+
1350
1559
  /** Keep the old-runtime shim on the same bounded-flex water-fill rule as the Zig bridge. */
1351
1560
  private fun fallbackFlexWidths(line: Line, available: Int, gap: Float): IntArray {
1352
1561
  val horizontal = orientation == HORIZONTAL
@@ -1410,7 +1619,8 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1410
1619
  }
1411
1620
 
1412
1621
  override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
1413
- val widthMode = View.MeasureSpec.getMode(widthMeasureSpec)
1622
+ val unboundedHorizontal = unboundedWidth && orientation == HORIZONTAL
1623
+ val widthMode = if (unboundedHorizontal) View.MeasureSpec.UNSPECIFIED else View.MeasureSpec.getMode(widthMeasureSpec)
1414
1624
  widthDefinite = widthMode == View.MeasureSpec.EXACTLY
1415
1625
  val width = View.MeasureSpec.getSize(widthMeasureSpec).takeUnless { widthMode == View.MeasureSpec.UNSPECIFIED } ?: 1_000_000
1416
1626
  val height = View.MeasureSpec.getSize(heightMeasureSpec).takeUnless { View.MeasureSpec.getMode(heightMeasureSpec) == View.MeasureSpec.UNSPECIFIED } ?: 1_000_000
@@ -1437,7 +1647,8 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1437
1647
  val absWidth = absolute.maxOfOrNull { it.width } ?: 0
1438
1648
  val absHeight = absolute.maxOfOrNull { it.height } ?: 0
1439
1649
  setMeasuredDimension(
1440
- resolveSize(max(desiredWidth + paddingLeft + paddingRight, absWidth + paddingLeft + paddingRight), widthMeasureSpec),
1650
+ if (unboundedHorizontal) max(desiredWidth + paddingLeft + paddingRight, absWidth + paddingLeft + paddingRight)
1651
+ else resolveSize(max(desiredWidth + paddingLeft + paddingRight, absWidth + paddingLeft + paddingRight), widthMeasureSpec),
1441
1652
  resolveSize(max(desiredHeight + paddingTop + paddingBottom, absHeight + paddingTop + paddingBottom), heightMeasureSpec),
1442
1653
  )
1443
1654
  }
@@ -1547,6 +1758,23 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1547
1758
  val (lines, absolute) = entries(width, height)
1548
1759
  if (absolute.isEmpty() && lines.isNotEmpty() && (lines.size == 1 || wrap)) {
1549
1760
  val sharedEntries = if (wrap) lines.flatMap { it.entries } else lines[0].entries
1761
+ val sharedTree = sharedTreeFrames(sharedEntries, width, height)
1762
+ if (sharedTree != null && (sharedTree.nodes.size > sharedEntries.size + 1 || grid)) {
1763
+ for (index in 1 until sharedTree.nodes.size) {
1764
+ val view = sharedTree.nodes[index].view ?: continue
1765
+ val offset = index * 4
1766
+ val parentOffset = sharedTree.parents[index] * 4
1767
+ val parentX = sharedTree.frames[parentOffset]
1768
+ val parentY = sharedTree.frames[parentOffset + 1]
1769
+ val x = (sharedTree.frames[offset] - parentX).roundToInt()
1770
+ val y = (sharedTree.frames[offset + 1] - parentY).roundToInt()
1771
+ val childWidth = sharedTree.frames[offset + 2].roundToInt()
1772
+ val childHeight = sharedTree.frames[offset + 3].roundToInt()
1773
+ place(view, x, y, x + childWidth, y + childHeight)
1774
+ }
1775
+ onLayoutChanged?.invoke()
1776
+ return
1777
+ }
1550
1778
  val sharedFrames = sharedDirectFrames(sharedEntries, width, height)
1551
1779
  if (sharedFrames != null) {
1552
1780
  for ((index, entry) in sharedEntries.withIndex()) {
@@ -1808,10 +2036,18 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
1808
2036
  vertical.isFillViewport = true
1809
2037
  horizontal.isFillViewport = true
1810
2038
  setOnRefreshListener { refreshHandler?.invoke() }
1811
- isEnabled = false
2039
+ // The outer refresh host must stay touch-enabled so its real scroll
2040
+ // child can receive gestures even when no pull-to-refresh handler is
2041
+ // configured. Interception is gated below instead.
2042
+ isEnabled = true
1812
2043
  setHorizontal(false)
1813
2044
  }
1814
2045
 
2046
+ override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
2047
+ if (refreshHandler == null) return false
2048
+ return super.onInterceptTouchEvent(event)
2049
+ }
2050
+
1815
2051
  /**
1816
2052
  * Along an axis its parent does not fix, a scroll view is as big as its
1817
2053
  * content (up to what the parent allows): a horizontal strip is as tall
@@ -1824,10 +2060,11 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
1824
2060
  val heightMode = MeasureSpec.getMode(heightMeasureSpec)
1825
2061
  val widthSize = MeasureSpec.getSize(widthMeasureSpec)
1826
2062
  val heightSize = MeasureSpec.getSize(heightMeasureSpec)
1827
- if (widthMode == MeasureSpec.EXACTLY && heightMode == MeasureSpec.EXACTLY) {
1828
- super.onMeasure(widthMeasureSpec, heightMeasureSpec)
1829
- return
1830
- }
2063
+ // Even when the viewport itself is exact, a horizontal scroll must
2064
+ // measure its content with an unspecified width. Returning straight
2065
+ // to SwipeRefreshLayout here makes HorizontalScrollView remeasure the
2066
+ // content at the viewport width, erasing overflow and disabling the
2067
+ // nested carousel gesture.
1831
2068
  val sideways = horizontalMode == true
1832
2069
  val unspecified = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
1833
2070
  val horizontalPadding = paddingLeft + paddingRight
@@ -1838,6 +2075,18 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
1838
2075
  if (!sideways || heightMode == MeasureSpec.UNSPECIFIED) unspecified
1839
2076
  else MeasureSpec.makeMeasureSpec(max(0, heightSize - verticalPadding), MeasureSpec.AT_MOST),
1840
2077
  )
2078
+ if (sideways) {
2079
+ // HorizontalScrollView may turn a WRAP_CONTENT child into an
2080
+ // AT_MOST viewport measurement during its own pass. Preserve the
2081
+ // intrinsic width we just measured as the child's layout width so
2082
+ // the second pass cannot collapse a carousel back to the viewport.
2083
+ val params = content.layoutParams ?: ViewGroup.LayoutParams(
2084
+ ViewGroup.LayoutParams.WRAP_CONTENT,
2085
+ ViewGroup.LayoutParams.MATCH_PARENT,
2086
+ )
2087
+ params.width = max(params.width, content.measuredWidth)
2088
+ content.layoutParams = params
2089
+ }
1841
2090
  fun fit(mode: Int, size: Int, wanted: Int): Int = when (mode) {
1842
2091
  MeasureSpec.EXACTLY -> size
1843
2092
  MeasureSpec.AT_MOST -> min(size, wanted)
@@ -1853,7 +2102,7 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
1853
2102
 
1854
2103
  fun setRefreshHandler(handler: (() -> Unit)?, refreshing: Boolean) {
1855
2104
  refreshHandler = handler
1856
- isEnabled = handler != null
2105
+ isEnabled = true
1857
2106
  isRefreshing = handler != null && refreshing
1858
2107
  if (handler == null) isRefreshing = false
1859
2108
  }
@@ -1884,6 +2133,7 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
1884
2133
  val scroller: ViewGroup = if (value) horizontal else vertical
1885
2134
  scroller.removeAllViews()
1886
2135
  content.orientation = if (value) LinearLayout.HORIZONTAL else LinearLayout.VERTICAL
2136
+ content.setUnboundedWidth(value)
1887
2137
  scroller.addView(content, if (value)
1888
2138
  ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT)
1889
2139
  else ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
@@ -2403,6 +2653,37 @@ private class CraftNativeDatePickerView(context: Context) : FrameLayout(context)
2403
2653
  }
2404
2654
  }
2405
2655
 
2656
+ /** A field's pending native edits and the props acknowledged by the screen. */
2657
+ internal class CraftNativeInputEcho {
2658
+ private val pending = ArrayDeque<String>()
2659
+ private var rendered: String? = null
2660
+ private var initialized = false
2661
+
2662
+ fun record(value: String) {
2663
+ pending.addLast(value)
2664
+ while (pending.size > 64) pending.removeFirst()
2665
+ }
2666
+
2667
+ fun accept(value: String?, nativeValue: String): Boolean {
2668
+ if (value == null) {
2669
+ initialized = false
2670
+ rendered = null
2671
+ pending.clear()
2672
+ return false
2673
+ }
2674
+ // A retained field can render the same prop more than once, after
2675
+ // its echo was consumed. That repeated prop must not overwrite a
2676
+ // keystroke that has not made the native/JS round trip yet.
2677
+ val changed = !initialized || rendered != value
2678
+ initialized = true
2679
+ rendered = value
2680
+ if (!changed) return false
2681
+ val index = pending.indexOf(value)
2682
+ if (index >= 0) repeat(index + 1) { pending.removeFirst() }
2683
+ return nativeValue != value && index < 0
2684
+ }
2685
+ }
2686
+
2406
2687
  /**
2407
2688
  * A native screen's `<Video>`: the platform's VideoView.
2408
2689
  *
@@ -3011,7 +3292,7 @@ class MainActivity : AppCompatActivity() {
3011
3292
  * the screen answer with a value the field has already moved past;
3012
3293
  * that echo is not written back over what was typed since.
3013
3294
  */
3014
- val inputEchoes = mutableMapOf<EditText, ArrayDeque<String>>()
3295
+ val inputEchoes = mutableMapOf<EditText, CraftNativeInputEcho>()
3015
3296
  val controls = mutableMapOf<String, View>()
3016
3297
  // Protocol ids identify mutation targets; authored keys identify the
3017
3298
  // retained view. Keep both mappings so a targeted update can recover
@@ -3272,6 +3553,16 @@ class MainActivity : AppCompatActivity() {
3272
3553
  bars.onTabSwipeStart = { dx, x, y -> beginTabSwipe(dx, x, y) }
3273
3554
  bars.onTabSwipeMove = { dx -> updateTabSwipe(dx) }
3274
3555
  bars.onTabSwipeEnd = { dx, cancelled -> finishTabSwipe(dx, cancelled) }
3556
+ bars.onTabSwipeShouldDefer = { x, y ->
3557
+ val location = IntArray(2)
3558
+ bars.getLocationOnScreen(location)
3559
+ val screen = visibleScreen
3560
+ screen != null && horizontalScrollAtPoint(
3561
+ screen.view,
3562
+ location[0] + x,
3563
+ location[1] + y,
3564
+ )
3565
+ }
3275
3566
  root.addView(bars)
3276
3567
  chrome = bars
3277
3568
  tabStacks.clear()
@@ -3320,7 +3611,9 @@ class MainActivity : AppCompatActivity() {
3320
3611
  val target = ensureTabRoot(targetIndex) ?: return false
3321
3612
  val host = chrome?.content ?: return false
3322
3613
  if (target === current || host.width <= 0) return false
3323
- if (horizontalScrollCanConsume(current.view, host, x, y, dx)) return false
3614
+ val chromeLocation = IntArray(2)
3615
+ chrome?.getLocationOnScreen(chromeLocation)
3616
+ if (horizontalScrollCanConsume(current.view, chromeLocation[0] + x, chromeLocation[1] + y, dx)) return false
3324
3617
  (target.view.parent as? ViewGroup)?.removeView(target.view)
3325
3618
  host.addView(target.view, FrameLayout.LayoutParams(-1, -1))
3326
3619
  target.view.translationX = if (direction > 0) host.width.toFloat() else -host.width.toFloat()
@@ -3375,15 +3668,36 @@ class MainActivity : AppCompatActivity() {
3375
3668
  }.start()
3376
3669
  }
3377
3670
 
3378
- private fun horizontalScrollCanConsume(view: View, host: ViewGroup, x: Float, y: Float, dx: Float): Boolean {
3379
- val localX = x - view.left
3380
- val localY = y - host.top - view.top
3381
- if (view !is ViewGroup || localX < 0 || localY < 0 || localX > view.width || localY > view.height) return false
3671
+ /**
3672
+ * Returns true when the exact screen gesture point is over a horizontal
3673
+ * child that still has room in the gesture's direction. Screen coordinates
3674
+ * keep nested scroll offsets and translated parents out of the calculation.
3675
+ */
3676
+ private fun horizontalScrollCanConsume(view: View, screenX: Float, screenY: Float, dx: Float): Boolean {
3677
+ if (!view.isShown) return false
3678
+ val location = IntArray(2)
3679
+ view.getLocationOnScreen(location)
3680
+ if (screenX < location[0] || screenY < location[1]
3681
+ || screenX > location[0] + view.width || screenY > location[1] + view.height) return false
3682
+ if (view is android.widget.HorizontalScrollView && view.canScrollHorizontally(if (dx < 0) 1 else -1)) return true
3683
+ if (view !is ViewGroup) return false
3684
+ for (index in view.childCount - 1 downTo 0) {
3685
+ if (horizontalScrollCanConsume(view.getChildAt(index), screenX, screenY, dx)) return true
3686
+ }
3687
+ return false
3688
+ }
3689
+
3690
+ /** Returns true when a native horizontal scroll host owns the touch point. */
3691
+ private fun horizontalScrollAtPoint(view: View, screenX: Float, screenY: Float): Boolean {
3692
+ if (!view.isShown) return false
3693
+ val location = IntArray(2)
3694
+ view.getLocationOnScreen(location)
3695
+ if (screenX < location[0] || screenY < location[1]
3696
+ || screenX > location[0] + view.width || screenY > location[1] + view.height) return false
3697
+ if (view is android.widget.HorizontalScrollView) return true
3698
+ if (view !is ViewGroup) return false
3382
3699
  for (index in view.childCount - 1 downTo 0) {
3383
- val child = view.getChildAt(index)
3384
- if (!child.isShown || localX < child.left || localX > child.right || localY < child.top || localY > child.bottom) continue
3385
- if (child is android.widget.HorizontalScrollView && child.canScrollHorizontally(if (dx < 0) 1 else -1)) return true
3386
- if (horizontalScrollCanConsume(child, host, localX - child.left, localY - child.top, dx)) return true
3700
+ if (horizontalScrollAtPoint(view.getChildAt(index), screenX, screenY)) return true
3387
3701
  }
3388
3702
  return false
3389
3703
  }
@@ -4318,10 +4632,8 @@ class MainActivity : AppCompatActivity() {
4318
4632
  val autofillHint = autofillHintFor(props)
4319
4633
  if (autofillHint == null) setAutofillHints(*emptyArray()) else setAutofillHints(autofillHint)
4320
4634
  val controlledValue = props.opt("value")?.takeUnless { it == JSONObject.NULL }?.toString()
4321
- val echoes = screen.inputEchoes[this]
4322
- val echoIndex = if (controlledValue == null || echoes == null) -1 else echoes.indexOf(controlledValue)
4323
- if (echoIndex >= 0) repeat(echoIndex + 1) { echoes!!.removeFirst() }
4324
- if (controlledValue != null && text.toString() != controlledValue && echoIndex < 0) {
4635
+ val echoes = screen.inputEchoes.getOrPut(this) { CraftNativeInputEcho() }
4636
+ if (echoes.accept(controlledValue, text.toString()) && controlledValue != null) {
4325
4637
  screen.suppressedTextChanges.add(this)
4326
4638
  setTextPreservingSelection(this, controlledValue)
4327
4639
  }
@@ -4379,9 +4691,7 @@ class MainActivity : AppCompatActivity() {
4379
4691
  (screen.inputIdentities[this@apply] ?: identity).let { screen.drafts[it] = updated }
4380
4692
  if (screen.suppressedTextChanges.remove(this@apply)) return
4381
4693
  val current = screen.handlers[this@apply] ?: return
4382
- val echoes = screen.inputEchoes.getOrPut(this@apply) { ArrayDeque() }
4383
- echoes.addLast(updated)
4384
- while (echoes.size > 64) echoes.removeFirst()
4694
+ screen.inputEchoes.getOrPut(this@apply) { CraftNativeInputEcho() }.record(updated)
4385
4695
  send(screen, "EVENT", JSONObject()
4386
4696
  .put("handlerName", current)
4387
4697
  .put("nativeEvent", JSONObject().put("text", updated)))
@@ -4514,7 +4824,7 @@ class MainActivity : AppCompatActivity() {
4514
4824
  )
4515
4825
  } ?: run { trackTintList = null }
4516
4826
  craftNativeOptionalString(props.opt("thumbColor"))
4517
- ?.let { ColorStateList.valueOf(color(it, Color.WHITE)) }
4827
+ ?.let { thumbTintList = ColorStateList.valueOf(color(it, Color.WHITE)) }
4518
4828
  ?: run { thumbTintList = null }
4519
4829
  val handler = craftNativeEventName(events, "onValueChange", "onChange")
4520
4830
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
@@ -4751,6 +5061,11 @@ class MainActivity : AppCompatActivity() {
4751
5061
  }
4752
5062
  }
4753
5063
  configureContainer(content, contentStyle)
5064
+ // Content-style reconciliation configures the flex host again;
5065
+ // restore the scroll axis after it so horizontal children are
5066
+ // measured beyond the viewport rather than clamped to it.
5067
+ content.orientation = if (horizontal) LinearLayout.HORIZONTAL else LinearLayout.VERTICAL
5068
+ content.setUnboundedWidth(horizontal)
4754
5069
  val contentChildren = JSONArray()
4755
5070
  for (index in 0 until children.length()) {
4756
5071
  val child = children.opt(index)
@@ -5142,6 +5457,11 @@ class MainActivity : AppCompatActivity() {
5142
5457
  view.elevation = dp(craftNativeNonNegativeDouble(style, "elevation", 0.0)).toFloat()
5143
5458
  view.z = craftNativeFiniteFloat(style, "zIndex", 0f)
5144
5459
  val background = style.has("backgroundColor") && !style.isNull("backgroundColor")
5460
+ // VideoView's Surface is behind this window. An opaque View
5461
+ // background paints over the decoded picture (only rounded corners
5462
+ // showed it). Keep the outline/borders, with a transparent fill.
5463
+ val backgroundColor = if (view is CraftNativeVideoView) Color.TRANSPARENT
5464
+ else color(style.optString("backgroundColor"), Color.TRANSPARENT)
5145
5465
  val border = craftNativeNonNegativeDouble(style, "borderWidth", 0.0)
5146
5466
  val radius = craftNativeNonNegativeDouble(style, "borderRadius", 0.0)
5147
5467
  val sideBorderKeys = listOf("borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth")
@@ -5164,14 +5484,14 @@ class MainActivity : AppCompatActivity() {
5164
5484
  if (background || border > 0 || radius > 0 || hasSideBorders) {
5165
5485
  if (hasSideBorders) {
5166
5486
  view.background = CraftNativeSideBorderDrawable(
5167
- color(style.optString("backgroundColor"), Color.TRANSPARENT),
5487
+ backgroundColor,
5168
5488
  cornerRadii,
5169
5489
  sideWidths,
5170
5490
  sideColors,
5171
5491
  )
5172
5492
  } else {
5173
5493
  view.background = GradientDrawable().apply {
5174
- setColor(color(style.optString("backgroundColor"), Color.TRANSPARENT))
5494
+ setColor(backgroundColor)
5175
5495
  if (hasCornerRadii) setCornerRadii(cornerRadii) else cornerRadius = dp(radius).toFloat()
5176
5496
  if (border > 0) {
5177
5497
  val borderColor = color(style.optString("borderColor"), Color.TRANSPARENT)
@@ -0,0 +1,20 @@
1
+ export interface BrowserDialogOptions {
2
+ title?: string;
3
+ message?: string;
4
+ confirmLabel?: string;
5
+ cancelLabel?: string;
6
+ buttons?: Array<{
7
+ id: string;
8
+ title?: string;
9
+ label?: string;
10
+ style?: string;
11
+ }>;
12
+ actions?: Array<{
13
+ id: string;
14
+ title?: string;
15
+ label?: string;
16
+ style?: string;
17
+ }>;
18
+ }
19
+ /** A user-owned browser dialog, with keyboard focus and cancellation, not a timed prompt. */
20
+ export declare function showBrowserDialog(kind: string, options: BrowserDialogOptions, signal: AbortSignal): Promise<string | boolean | undefined>;
@@ -0,0 +1,31 @@
1
+ export interface BrowserStorageOptions {
2
+ storage: Storage;
3
+ namespace: string;
4
+ tokenKey: string;
5
+ /** Existing website identity keys to forget whenever its token changes. */
6
+ accountKeys?: string[];
7
+ hash: (value: string) => Promise<string>;
8
+ changed: (name: string) => void;
9
+ }
10
+ export interface BrowserStorage {
11
+ craft: {
12
+ storage: {
13
+ getSync: (key: string) => unknown;
14
+ setSync: (key: string, value: unknown) => void;
15
+ };
16
+ secureStorage: {
17
+ getSync: (key: string) => string | null;
18
+ };
19
+ snapshots: {
20
+ get: (name: string) => unknown;
21
+ set: (name: string, value: unknown) => Promise<boolean>;
22
+ clear: (keep?: string[]) => Promise<boolean>;
23
+ keys: () => string[];
24
+ };
25
+ };
26
+ call: (module: string, method: string, args: unknown[]) => Promise<unknown>;
27
+ accountChanged: () => Promise<void>;
28
+ scope: () => string;
29
+ }
30
+ /** Browser persistence has browser security semantics; it does not claim to be a Keychain. */
31
+ export declare function createBrowserStorage(options: BrowserStorageOptions): Promise<BrowserStorage>;
@@ -0,0 +1,22 @@
1
+ export interface BrowserPlatformOptions {
2
+ namespace?: string;
3
+ tokenKey?: string;
4
+ accountKeys?: string[];
5
+ /** App metadata shared with native hosts; no device capabilities are implied. */
6
+ appURL?: string;
7
+ locale?: string;
8
+ /** Application-owned deep-link schemes; arbitrary executable schemes are rejected. */
9
+ urlSchemes?: string[];
10
+ }
11
+ export interface BrowserPlatform {
12
+ craft: Record<string, unknown>;
13
+ capabilities: string[];
14
+ call: (module: string, method: string, args: unknown[], signal: AbortSignal) => Promise<unknown>;
15
+ navigate: (path: string, replace?: boolean) => void;
16
+ emit: (listener: (type: string, payload: unknown) => void) => () => void;
17
+ resolveMedia: (url: string) => Promise<string>;
18
+ resolveFile: (value: unknown) => unknown;
19
+ dispose: () => void;
20
+ }
21
+ /** Real browser counterparts for the portable Craft services. Device-only APIs remain absent. */
22
+ export declare function createBrowserPlatform(options?: BrowserPlatformOptions): Promise<BrowserPlatform>;