@craft-native/android 0.0.187 → 0.0.189

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.
package/README.md CHANGED
@@ -147,6 +147,10 @@ content box, keeping responsive sizing aligned with the iOS renderer.
147
147
  When `flexShrink` is omitted, horizontal text labels and scroll hosts use the
148
148
  same shrink defaults as the iOS renderer; buttons and editable fields retain
149
149
  their intrinsic width unless explicitly configured.
150
+ An exact horizontal scroll viewport still measures its content unbounded and
151
+ retains that measured child width for the platform's second pass, so a wider
152
+ carousel keeps its overflow and receives the horizontal gesture before the tab
153
+ shell.
150
154
  Wrapped containers also honor `alignContent` (`center`, `flex-end`,
151
155
  `space-between`, `space-around`, `space-evenly`, and `stretch`) consistently
152
156
  across hosts.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/android",
3
- "version": "0.0.187",
3
+ "version": "0.0.189",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/android#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -341,6 +341,18 @@ object CraftNative {
341
341
  nodeData: FloatArray,
342
342
  treeMeta: FloatArray,
343
343
  ): FloatArray?
344
+ private external fun nativeLayoutTreeV2(
345
+ width: Float,
346
+ height: Float,
347
+ nodeData: FloatArray,
348
+ treeMeta: FloatArray,
349
+ ): FloatArray?
350
+ private external fun nativeLayoutTreeV3(
351
+ width: Float,
352
+ height: Float,
353
+ nodeData: FloatArray,
354
+ treeMeta: FloatArray,
355
+ ): FloatArray?
344
356
  private external fun nativeClipboardRead(activity: Activity): String?
345
357
  private external fun nativeClipboardWrite(activity: Activity, text: String): Boolean
346
358
  private external fun nativeOpenUrl(activity: Activity, url: String): Boolean
@@ -601,6 +613,28 @@ object CraftNative {
601
613
  }
602
614
  }
603
615
 
616
+ /** Resolve a reverse-aware tree in Zig, or null for Kotlin fallback. */
617
+ fun layoutTreeV2(width: Float, height: Float, nodeData: FloatArray, treeMeta: FloatArray): FloatArray? {
618
+ if (!isAvailable || nodeData.isEmpty() || treeMeta.isEmpty()) return null
619
+ return try {
620
+ nativeLayoutTreeV2(width, height, nodeData, treeMeta)
621
+ ?.takeIf { it.size >= treeMeta.size / 2 * 4 && it.all { value -> value.isFinite() } }
622
+ } catch (e: UnsatisfiedLinkError) {
623
+ null
624
+ }
625
+ }
626
+
627
+ /** Resolve a wrap-reverse-aware tree in Zig, or null for Kotlin fallback. */
628
+ fun layoutTreeV3(width: Float, height: Float, nodeData: FloatArray, treeMeta: FloatArray): FloatArray? {
629
+ if (!isAvailable || nodeData.isEmpty() || treeMeta.isEmpty()) return null
630
+ return try {
631
+ nativeLayoutTreeV3(width, height, nodeData, treeMeta)
632
+ ?.takeIf { it.size >= treeMeta.size / 2 * 4 && it.all { value -> value.isFinite() } }
633
+ } catch (e: UnsatisfiedLinkError) {
634
+ null
635
+ }
636
+ }
637
+
604
638
  /**
605
639
  * The clipboard's text, or null to use the Kotlin implementation.
606
640
  *
@@ -104,6 +104,7 @@ internal class CraftNativeCapabilities(
104
104
  val maxPixels: Int,
105
105
  val quality: Float,
106
106
  val completion: (CraftNativeCapabilityResult) -> Unit,
107
+ val videoLimit: Long = 0,
107
108
  )
108
109
 
109
110
  init {
@@ -166,6 +167,7 @@ internal class CraftNativeCapabilities(
166
167
  }
167
168
  "Files" -> when (method) {
168
169
  "pickImage" -> pickImage(requestToken, args, completion)
170
+ "pickVideo" -> pickVideo(requestToken, args, completion)
169
171
  "download", "write", "remove" -> networkExecutor.execute {
170
172
  complete(requestToken, files(requestToken, method, args), completion)
171
173
  }
@@ -238,21 +240,28 @@ internal class CraftNativeCapabilities(
238
240
  }
239
241
  }
240
242
  if (body != null || multipart is FileResult.Multipart) doOutput = true
241
- if (multipart is FileResult.Multipart) setRequestProperty("Content-Type", "multipart/form-data; boundary=${multipart.boundary}")
243
+ if (multipart is FileResult.Multipart) {
244
+ setRequestProperty("Content-Type", "multipart/form-data; boundary=${multipart.boundary}")
245
+ setFixedLengthStreamingMode(multipart.body.length())
246
+ readTimeout = 600_000
247
+ }
242
248
  }
243
249
  } catch (error: IllegalArgumentException) {
250
+ if (multipart is FileResult.Multipart) multipart.body.parentFile?.deleteRecursively()
244
251
  return failure("INVALID_ARGUMENT", error.message ?: "fetch request is invalid")
245
252
  } catch (error: Exception) {
253
+ if (multipart is FileResult.Multipart) multipart.body.parentFile?.deleteRecursively()
246
254
  return failure("NETWORK_ERROR", error.message ?: "Network request failed")
247
255
  }
248
256
  fetchConnections[requestToken] = connection
249
257
  if (cancelled.contains(requestToken)) {
250
258
  fetchConnections.remove(requestToken)?.disconnect()
259
+ if (multipart is FileResult.Multipart) multipart.body.parentFile?.deleteRecursively()
251
260
  return failure("CANCELLED", "Network request was cancelled")
252
261
  }
253
262
  return try {
254
263
  if (body != null) connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
255
- if (multipart is FileResult.Multipart) connection.outputStream.use { it.write(multipart.body) }
264
+ if (multipart is FileResult.Multipart) connection.outputStream.use { output -> multipart.body.inputStream().use { it.copyTo(output, 64 * 1024) } }
256
265
  val status = connection.responseCode
257
266
  val input = if (status >= 400) connection.errorStream else connection.inputStream
258
267
  val output = ByteArrayOutputStream()
@@ -283,17 +292,21 @@ internal class CraftNativeCapabilities(
283
292
  failure("NETWORK_ERROR", error.message ?: "Network request failed")
284
293
  } finally {
285
294
  fetchConnections.remove(requestToken)?.disconnect()
295
+ if (multipart is FileResult.Multipart) multipart.body.parentFile?.deleteRecursively()
286
296
  }
287
297
  }
288
298
 
289
299
  private sealed interface FileResult {
290
- data class Multipart(val body: ByteArray, val boundary: String) : FileResult
300
+ data class Multipart(val body: File, val boundary: String) : FileResult
291
301
  data class Failure(val code: String, val message: String) : FileResult
292
302
  }
293
303
 
294
304
  private fun multipart(parts: JSONArray): FileResult {
295
305
  val boundary = "craft-${UUID.randomUUID()}"
296
- val body = ByteArrayOutputStream()
306
+ val file = File(fileRoot, UUID.randomUUID().toString()).apply { mkdirs() }.resolve("upload.multipart")
307
+ var kept = false
308
+ try {
309
+ FileOutputStream(file).use { body ->
297
310
  fun append(value: String) { body.write(value.toByteArray(Charsets.UTF_8)) }
298
311
  for (index in 0 until parts.length()) {
299
312
  val part = parts.optJSONObject(index) ?: return FileResult.Failure("INVALID_ARGUMENT", "A form part must be an object")
@@ -303,14 +316,11 @@ internal class CraftNativeCapabilities(
303
316
  val path = part.optString("path")
304
317
  if (path.isNotBlank()) {
305
318
  val file = ownedFile(path) ?: return FileResult.Failure("INVALID_ARGUMENT", "A form file must be one craft.files made, and still there")
306
- val bytes = runCatching { file.readBytes() }.getOrElse {
307
- return FileResult.Failure("INVALID_ARGUMENT", "A form file could not be read")
308
- }
309
319
  val filename = safeFileName(part.optString("filename").ifBlank { file.name })
310
320
  val type = part.optString("type").ifBlank { URLConnection.guessContentTypeFromName(filename) ?: "application/octet-stream" }
311
321
  append("Content-Disposition: form-data; name=\"${quoted(name)}\"; filename=\"${quoted(filename)}\"\r\n")
312
322
  append("Content-Type: ${headerValue(type)}\r\n\r\n")
313
- body.write(bytes)
323
+ file.inputStream().use { it.copyTo(body, 64 * 1024) }
314
324
  append("\r\n")
315
325
  } else {
316
326
  append("Content-Disposition: form-data; name=\"${quoted(name)}\"\r\n\r\n")
@@ -319,7 +329,14 @@ internal class CraftNativeCapabilities(
319
329
  }
320
330
  }
321
331
  append("--$boundary--\r\n")
322
- return FileResult.Multipart(body.toByteArray(), boundary)
332
+ }
333
+ kept = true
334
+ return FileResult.Multipart(file, boundary)
335
+ } catch (error: Exception) {
336
+ return FileResult.Failure("WRITE_ERROR", error.message ?: "A form file could not be read")
337
+ } finally {
338
+ if (!kept) file.parentFile?.deleteRecursively()
339
+ }
323
340
  }
324
341
 
325
342
  private fun files(requestToken: String, method: String, args: JSONArray): CraftNativeCapabilityResult {
@@ -417,6 +434,64 @@ internal class CraftNativeCapabilities(
417
434
  return success(JSONObject().put("ok", false).put("status", status).put("body", body.toString(Charsets.UTF_8.name())))
418
435
  }
419
436
 
437
+ private fun pickVideo(requestToken: String, args: JSONArray, completion: (CraftNativeCapabilityResult) -> Unit) {
438
+ mainHandler.post {
439
+ val host = activity ?: return@post complete(requestToken, failure("NEEDS_ACTIVITY", "No screen is showing to pick from"), completion)
440
+ if (pendingFilePick != null) {
441
+ complete(requestToken, failure("BUSY", "Another file picker is already open"), completion)
442
+ return@post
443
+ }
444
+ val options = args.optJSONObject(0) ?: JSONObject()
445
+ val limit = options.optLong("maxBytes", 512L * 1024 * 1024).coerceIn(1, 512L * 1024 * 1024)
446
+ pendingFilePick = PendingFilePick(requestToken, "video", 0, 1f, completion, limit)
447
+ val photos = options.optString("source", "library") != "files" && Build.VERSION.SDK_INT >= 33
448
+ val intent = Intent(if (photos) MediaStore.ACTION_PICK_IMAGES else Intent.ACTION_OPEN_DOCUMENT).apply {
449
+ type = "video/*"
450
+ if (!photos) addCategory(Intent.CATEGORY_OPENABLE)
451
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
452
+ }
453
+ try { host.startActivityForResult(intent, CRAFT_NATIVE_FILE_PICK_REQUEST) }
454
+ catch (error: Exception) {
455
+ pendingFilePick = null
456
+ complete(requestToken, failure("PICKER_UNAVAILABLE", error.message ?: "Video picker could not be opened"), completion)
457
+ }
458
+ }
459
+ }
460
+
461
+ /** Copy with a bound even when a cloud provider does not report its size. */
462
+ private fun copyVideo(uri: Uri, limit: Long): CraftNativeCapabilityResult {
463
+ var target: File? = null
464
+ return try {
465
+ val name = context.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
466
+ if (it.moveToFirst()) it.getString(0) else null
467
+ } ?: "video.mp4"
468
+ val types = mapOf("mp4" to "video/mp4", "m4v" to "video/x-m4v", "mov" to "video/quicktime", "webm" to "video/webm")
469
+ val type = types[name.substringAfterLast('.', "").lowercase()]
470
+ ?: return failure("INVALID_VIDEO", "Choose an MP4, MOV or WebM video.")
471
+ val destination = File(fileRoot, UUID.randomUUID().toString()).apply { mkdirs() }.resolve(safeFileName(name))
472
+ target = destination
473
+ val input = context.contentResolver.openInputStream(uri) ?: throw IllegalArgumentException("The video could not be read")
474
+ var size = 0L
475
+ input.use { source ->
476
+ destination.outputStream().use { output ->
477
+ val buffer = ByteArray(64 * 1024)
478
+ while (true) {
479
+ val count = source.read(buffer)
480
+ if (count < 0) break
481
+ size += count
482
+ require(size <= limit) { "Choose a video under ${limit / 1024 / 1024}MB." }
483
+ output.write(buffer, 0, count)
484
+ }
485
+ }
486
+ }
487
+ require(size > 0) { "Choose a nonempty video." }
488
+ success(descriptor(destination, type))
489
+ } catch (error: Exception) {
490
+ target?.parentFile?.deleteRecursively()
491
+ failure("READ_ERROR", error.message ?: "The video could not be read")
492
+ }
493
+ }
494
+
420
495
  private fun pickImage(
421
496
  requestToken: String,
422
497
  args: JSONArray,
@@ -465,6 +540,14 @@ internal class CraftNativeCapabilities(
465
540
  complete(pending.requestToken, success(JSONObject.NULL), pending.completion)
466
541
  return true
467
542
  }
543
+ if (pending.source == "video") {
544
+ networkExecutor.execute {
545
+ val result = data?.data?.let { copyVideo(it, pending.videoLimit) }
546
+ ?: failure("PICKER_FAILED", "The selected video could not be read")
547
+ complete(pending.requestToken, result, pending.completion)
548
+ }
549
+ return true
550
+ }
468
551
  val descriptor = if (pending.source == "camera") {
469
552
  val bitmap = data?.extras?.get("data") as? Bitmap
470
553
  bitmap?.let { writeImage(it, pending.maxPixels, pending.quality, "photo.jpg") }
@@ -580,6 +580,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
580
580
  private var selectedTab = 0
581
581
  private var swipeTracking = false
582
582
  private var swipeIntercepted = false
583
+ private var horizontalScrollDeferred = false
583
584
  private var swipeDownX = 0f
584
585
  private var swipeDownY = 0f
585
586
  private val swipeSlop = ViewConfiguration.get(context).scaledTouchSlop
@@ -590,6 +591,8 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
590
591
  var onTabSwipeStart: ((Float, Float, Float) -> Boolean)? = null
591
592
  var onTabSwipeMove: ((Float) -> Unit)? = null
592
593
  var onTabSwipeEnd: ((Float, Boolean) -> Unit)? = null
594
+ /** Lets a native horizontal scroller own the gesture from its first down. */
595
+ var onTabSwipeShouldDefer: ((Float, Float) -> Boolean)? = null
593
596
  var tabSwipeEnabled = true
594
597
 
595
598
  init {
@@ -654,7 +657,10 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
654
657
  override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
655
658
  when (event.actionMasked) {
656
659
  MotionEvent.ACTION_DOWN -> {
657
- swipeTracking = tabs.size > 1 && tabSwipeEnabled && tabBar.visibility == View.VISIBLE && event.y < tabBar.top
660
+ val overHorizontalScroll = onTabSwipeShouldDefer?.invoke(event.x, event.y) == true
661
+ horizontalScrollDeferred = overHorizontalScroll
662
+ if (overHorizontalScroll) parent?.requestDisallowInterceptTouchEvent(true)
663
+ swipeTracking = tabs.size > 1 && tabSwipeEnabled && !overHorizontalScroll && tabBar.visibility == View.VISIBLE && event.y < tabBar.top
658
664
  swipeIntercepted = false
659
665
  swipeDownX = event.x
660
666
  swipeDownY = event.y
@@ -668,7 +674,13 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
668
674
  return swipeIntercepted
669
675
  }
670
676
  }
671
- MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> swipeTracking = false
677
+ MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> {
678
+ swipeTracking = false
679
+ if (horizontalScrollDeferred) {
680
+ horizontalScrollDeferred = false
681
+ parent?.requestDisallowInterceptTouchEvent(false)
682
+ }
683
+ }
672
684
  }
673
685
  return swipeIntercepted
674
686
  }
@@ -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))
@@ -3272,6 +3522,16 @@ class MainActivity : AppCompatActivity() {
3272
3522
  bars.onTabSwipeStart = { dx, x, y -> beginTabSwipe(dx, x, y) }
3273
3523
  bars.onTabSwipeMove = { dx -> updateTabSwipe(dx) }
3274
3524
  bars.onTabSwipeEnd = { dx, cancelled -> finishTabSwipe(dx, cancelled) }
3525
+ bars.onTabSwipeShouldDefer = { x, y ->
3526
+ val location = IntArray(2)
3527
+ bars.getLocationOnScreen(location)
3528
+ val screen = visibleScreen
3529
+ screen != null && horizontalScrollAtPoint(
3530
+ screen.view,
3531
+ location[0] + x,
3532
+ location[1] + y,
3533
+ )
3534
+ }
3275
3535
  root.addView(bars)
3276
3536
  chrome = bars
3277
3537
  tabStacks.clear()
@@ -3320,7 +3580,9 @@ class MainActivity : AppCompatActivity() {
3320
3580
  val target = ensureTabRoot(targetIndex) ?: return false
3321
3581
  val host = chrome?.content ?: return false
3322
3582
  if (target === current || host.width <= 0) return false
3323
- if (horizontalScrollCanConsume(current.view, host, x, y, dx)) return false
3583
+ val chromeLocation = IntArray(2)
3584
+ chrome?.getLocationOnScreen(chromeLocation)
3585
+ if (horizontalScrollCanConsume(current.view, chromeLocation[0] + x, chromeLocation[1] + y, dx)) return false
3324
3586
  (target.view.parent as? ViewGroup)?.removeView(target.view)
3325
3587
  host.addView(target.view, FrameLayout.LayoutParams(-1, -1))
3326
3588
  target.view.translationX = if (direction > 0) host.width.toFloat() else -host.width.toFloat()
@@ -3375,15 +3637,36 @@ class MainActivity : AppCompatActivity() {
3375
3637
  }.start()
3376
3638
  }
3377
3639
 
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
3640
+ /**
3641
+ * Returns true when the exact screen gesture point is over a horizontal
3642
+ * child that still has room in the gesture's direction. Screen coordinates
3643
+ * keep nested scroll offsets and translated parents out of the calculation.
3644
+ */
3645
+ private fun horizontalScrollCanConsume(view: View, screenX: Float, screenY: Float, dx: Float): Boolean {
3646
+ if (!view.isShown) return false
3647
+ val location = IntArray(2)
3648
+ view.getLocationOnScreen(location)
3649
+ if (screenX < location[0] || screenY < location[1]
3650
+ || screenX > location[0] + view.width || screenY > location[1] + view.height) return false
3651
+ if (view is android.widget.HorizontalScrollView && view.canScrollHorizontally(if (dx < 0) 1 else -1)) return true
3652
+ if (view !is ViewGroup) return false
3653
+ for (index in view.childCount - 1 downTo 0) {
3654
+ if (horizontalScrollCanConsume(view.getChildAt(index), screenX, screenY, dx)) return true
3655
+ }
3656
+ return false
3657
+ }
3658
+
3659
+ /** Returns true when a native horizontal scroll host owns the touch point. */
3660
+ private fun horizontalScrollAtPoint(view: View, screenX: Float, screenY: Float): Boolean {
3661
+ if (!view.isShown) return false
3662
+ val location = IntArray(2)
3663
+ view.getLocationOnScreen(location)
3664
+ if (screenX < location[0] || screenY < location[1]
3665
+ || screenX > location[0] + view.width || screenY > location[1] + view.height) return false
3666
+ if (view is android.widget.HorizontalScrollView) return true
3667
+ if (view !is ViewGroup) return false
3382
3668
  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
3669
+ if (horizontalScrollAtPoint(view.getChildAt(index), screenX, screenY)) return true
3387
3670
  }
3388
3671
  return false
3389
3672
  }
@@ -4514,7 +4797,7 @@ class MainActivity : AppCompatActivity() {
4514
4797
  )
4515
4798
  } ?: run { trackTintList = null }
4516
4799
  craftNativeOptionalString(props.opt("thumbColor"))
4517
- ?.let { ColorStateList.valueOf(color(it, Color.WHITE)) }
4800
+ ?.let { thumbTintList = ColorStateList.valueOf(color(it, Color.WHITE)) }
4518
4801
  ?: run { thumbTintList = null }
4519
4802
  val handler = craftNativeEventName(events, "onValueChange", "onChange")
4520
4803
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
@@ -4751,6 +5034,11 @@ class MainActivity : AppCompatActivity() {
4751
5034
  }
4752
5035
  }
4753
5036
  configureContainer(content, contentStyle)
5037
+ // Content-style reconciliation configures the flex host again;
5038
+ // restore the scroll axis after it so horizontal children are
5039
+ // measured beyond the viewport rather than clamped to it.
5040
+ content.orientation = if (horizontal) LinearLayout.HORIZONTAL else LinearLayout.VERTICAL
5041
+ content.setUnboundedWidth(horizontal)
4754
5042
  val contentChildren = JSONArray()
4755
5043
  for (index in 0 until children.length()) {
4756
5044
  val child = children.opt(index)