Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
75e72c8
docs(truapi): state the layout rules a renderer tree is drawn by
valentinfernandez1 Oct 15, 2026
2a323e3
fix(ios): lay out renderer trees by the shared rules
valentinfernandez1 Oct 15, 2026
54520f7
fix(ios): load SVG images in faces and draw FgSuccess in its own colour
valentinfernandez1 Oct 15, 2026
2f9a504
fix(android): lay out renderer trees by the shared rules
valentinfernandez1 Oct 15, 2026
2b1f3ce
fix(hosts): keep sized buttons and images in their space
valentinfernandez1 Oct 15, 2026
4f2e6d5
fix(hosts): size by fixed and filled sizes, not minimums, on both hosts
valentinfernandez1 Oct 15, 2026
9ef2b7f
Merge remote-tracking branch 'origin/main' into fix/pocket-face-rende…
valentinfernandez1 Oct 16, 2026
ba0cda5
test(hosts): merge the renderer layout tests and drop comments the pr…
valentinfernandez1 Oct 16, 2026
71b36cd
fix(ios): make ImageFit Sendable so the layout test cases carry it wi…
valentinfernandez1 Oct 16, 2026
cf9cc84
Merge remote-tracking branch 'origin/main' into fix/pocket-face-rende…
valentinfernandez1 Oct 19, 2026
9cfaac0
Merge remote-tracking branch 'origin/main' into fix/pocket-face-rende…
valentinfernandez1 Oct 26, 2026
a63e02e
fix(android): keep a Spacer's minimum, arrange only in a real size, c…
valentinfernandez1 Oct 26, 2026
a44304d
fix(ios): read a face's SVG back from the disk cache at its declared …
valentinfernandez1 Oct 26, 2026
25cf39d
fix(hosts): draw a button as wide as its space unless sized, as #1480…
valentinfernandez1 Oct 26, 2026
2b7a8ff
Merge branch 'main' into fix/pocket-face-render-parity
valentinfernandez1 Oct 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/pocket-face-layout-rules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@parity/truapi": patch
---

Document the layout rules every host draws a renderer tree by: padding goes around a fixed size, a `Box` places its children at the top start, stacks add no gap, a `Spacer` is flexible, borders sit inside the node, a shaped background clips its content, an image's natural size is one pixel to a point, and images may be PNG, JPEG, WebP or SVG.
14 changes: 9 additions & 5 deletions docs/rfcs/unified-renderer.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ pub enum BlendingMode {
pub enum Modifier {
/// Outer spacing.
Margin(Dimensions),
/// Inner spacing.
/// Inner spacing, around the content: a fixed or minimum size is the content's.
Padding(Dimensions),
Background(Background),
Border(BorderStyle),
Expand Down Expand Up @@ -296,6 +296,7 @@ pub struct ButtonProps {
}

/// Where image bytes come from. The host fetches them; the tree carries no URL.
/// The bytes are PNG, JPEG, WebP or SVG.
pub enum ImageSource {
/// A Bulletin chain blob, addressed by its CID.
Bulletin(String),
Expand All @@ -305,7 +306,7 @@ pub enum ImageSource {
}

pub enum ImageFit {
/// The image is not resized.
/// The image is not resized: one image pixel to a point.
None,
/// Resized to fill the container without preserving the aspect ratio.
Fill,
Expand Down Expand Up @@ -369,7 +370,8 @@ pub enum RendererNode {
props: RowProps,
children: Vec<RendererNode>,
},
/// Flexible space.
/// Flexible space: shares out what its stack has left, unless sized along
/// the stack's axis.
Spacer {
modifiers: Vec<Modifier>,
},
Expand All @@ -389,12 +391,14 @@ pub enum RendererNode {
modifiers: Vec<Modifier>,
props: TextFieldProps,
},
/// Image, sized by modifiers.
/// Image, sized by modifiers. Without any it takes its natural size, one
/// image pixel to a point.
Image {
modifiers: Vec<Modifier>,
props: ImageProps,
},
/// Applies its effect to its children.
/// Applies its effect to its children. As large as its children, which it
/// places at the top start like a `Box`.
Effect {
props: EffectProps,
children: Vec<RendererNode>,
Expand Down
6 changes: 6 additions & 0 deletions hosts/android/feature/products/api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,10 @@ dependencies {
implementation(libs.nova.substrate.serialization)

testImplementation(libs.junit)

androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(libs.androidx.compose.ui.test.manifest)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.test.runner)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
package io.paritytech.polkadotapp.feature_products_api.presentation.widget
Comment thread
valentinfernandez1 marked this conversation as resolved.

import android.graphics.Bitmap
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toPixelMap
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import coil.imageLoader
import io.paritytech.polkadotapp.design.theme.PolkadotTheme
import io.paritytech.polkadotapp.designsystem.themes.PolkadotAppTheme
import io.paritytech.polkadotapp.feature_products_api.model.JsArrangement
import io.paritytech.polkadotapp.feature_products_api.model.JsColor
import io.paritytech.polkadotapp.feature_products_api.model.JsImageFit
import io.paritytech.polkadotapp.feature_products_api.model.JsImageSource
import io.paritytech.polkadotapp.feature_products_api.model.JsModifier
import io.paritytech.polkadotapp.feature_products_api.model.JsWidget
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import kotlin.math.abs

/** The layout rules of `renderer.rs`, which iOS draws the same way. */
@RunWith(AndroidJUnit4::class)
class JsWidgetLayoutTest {
@get:Rule
val compose = createComposeRule()

@Test
fun aSpacerWithNoSizeTakesWhatItsRowHasLeft() {
draw(
JsWidget.Row(
modifiers = listOf(JsModifier.Size(width = 100)),
children = listOf(block(JsColor.FG_ERROR), JsWidget.Spacer(), block(JsColor.FG_SUCCESS)),
),
)

assertEquals(80, boundsOf(success)?.left?.inDp())
}

@Test
fun aSpacerKeepsItsMinimumWhenItsRowIsShortOfRoom() {
draw(
JsWidget.Row(
modifiers = listOf(JsModifier.Size(width = 100)),
children = listOf(
block(JsColor.FG_ERROR),
JsWidget.Spacer(modifiers = listOf(JsModifier.Size(minWidth = 30))),
JsWidget.Box(
modifiers = listOf(JsModifier.Background(JsColor.FG_SUCCESS)),
children = listOf(JsWidget.Text("enough words to want more room than the row has")),
),
),
),
)

assertEquals(50, boundsOf(success)?.left?.inDp())
}

@Test
fun aRowWithOnlyAMinimumWidthKeepsItsChildrenAtTheStart() {
draw(
JsWidget.Row(
modifiers = listOf(JsModifier.Size(minWidth = 100)),
horizontalArrangement = JsArrangement.END,
children = listOf(block(JsColor.FG_SUCCESS)),
),
)

assertEquals(0, boundsOf(success)?.left?.inDp())
}

@Test
fun aChildFillingTheHeightOfARowWithNoHeightIsAsTallAsTheTallestChild() {
draw(
JsWidget.Row(
children = listOf(
block(JsColor.FG_ERROR),
JsWidget.Box(
modifiers = listOf(
JsModifier.Size(width = 20),
JsModifier.FillMaxHeight(),
JsModifier.Background(JsColor.FG_SUCCESS),
),
),
),
),
)

assertEquals(20, boundsOf(success)?.height()?.inDp())
}

@Test
fun aPictureWithNoSizeOfItsOwnTakesOneDpPerPixel() {
val image = JsWidget.Image(source = JsImageSource.Archive("small.png"), fit = JsImageFit.CONTAIN)

val drawn = drawPicture(30, 20, image)

assertEquals(listOf(30, 20), listOf(drawn.width().inDp(), drawn.height().inDp()))
}

@Test
fun aLargePictureWithNoSizeOfItsOwnIsShrunkToFit() {
val image = JsWidget.Image(source = JsImageSource.Archive("large.png"), fit = JsImageFit.FILL)

val drawn = drawPicture(600, 300, image)

assertEquals(listOf(100, 50), listOf(drawn.width().inDp(), drawn.height().inDp()))
}

@Test
fun aPictureSizedOnOneSideTakesTheOtherFromItsProportions() {
val image = JsWidget.Image(
source = JsImageSource.Archive("wide.png"),
fit = JsImageFit.COVER,
modifiers = listOf(JsModifier.Size(width = 40)),
)

val drawn = drawPicture(80, 40, image)

assertEquals(listOf(40, 20), listOf(drawn.width().inDp(), drawn.height().inDp()))
}

/** A product can send any size of picture, and one is never drawn larger than the screen. */
@Test
fun aPictureLargerThanTheScreenIsDecodedNoLargerThanTheScreen() {
val metrics = context.resources.displayMetrics
val screen = maxOf(metrics.widthPixels, metrics.heightPixels)
val image = JsWidget.Image(source = JsImageSource.Archive("huge.png"), fit = JsImageFit.CONTAIN)

drawPicture(screen * 2, screen / 2, image)

val memory = checkNotNull(context.imageLoader.memoryCache)
val decodedWidths = memory.keys.mapNotNull { memory[it]?.bitmap?.width }
assertTrue("decoded $decodedWidths for a screen of $screen", decodedWidths.isNotEmpty() && decodedWidths.all { it <= screen })
}

/**
* Draws `image` showing a red picture of this many pixels, read from a PNG file as a product's
* images are, and returns where it landed.
*/
private fun drawPicture(width: Int, height: Int, image: JsWidget.Image): android.graphics.Rect {
val picture = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
picture.eraseColor(android.graphics.Color.RED)
val file = File(context.cacheDir, "${width}x$height.png")
file.outputStream().use { picture.compress(Bitmap.CompressFormat.PNG, 100, it) }
draw(image, images = JsImageResolver { Uri.fromFile(file) })
compose.waitUntil(timeoutMillis = 5_000) { boundsOf(pureRed) != null }

return checkNotNull(boundsOf(pureRed))
}

private fun draw(widget: JsWidget, images: JsImageResolver = JsImageResolver { null }) {
compose.setContent {
PolkadotTheme(theme = PolkadotAppTheme.BerlinNight) {
CompositionLocalProvider(LocalJsImageResolver provides images) {
Box(Modifier.size(100.dp).background(Color.Black)) {
JsWidgetRenderer(widget = widget, jsEventHandler = { _, _ -> })
}
}
}
}
}

/** Where the pixels of `colour` lie, in pixels, or null if none were drawn. */
private fun boundsOf(colour: Color): android.graphics.Rect? {
val pixels = compose.onRoot().captureToImage().toPixelMap()
var found: android.graphics.Rect? = null
for (y in 0 until pixels.height) {
for (x in 0 until pixels.width) {
if (pixels[x, y].isCloseTo(colour)) {
found = (found ?: android.graphics.Rect(x, y, x, y)).apply { union(x, y) }
}
}
}
return found?.apply { right += 1; bottom += 1 }
}

private val context get() = InstrumentationRegistry.getInstrumentation().targetContext

private fun Int.inDp(): Int = Math.round(this / compose.density.density)

private fun Color.isCloseTo(other: Color) =
abs(red - other.red) < 0.05f && abs(green - other.green) < 0.05f && abs(blue - other.blue) < 0.05f

private fun block(color: JsColor) = JsWidget.Box(
modifiers = listOf(JsModifier.Size(width = 20, height = 20), JsModifier.Background(color)),
)

private val success = Color(red = 34, green = 197, blue = 94)
private val pureRed = Color(red = 255, green = 0, blue = 0)
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ fun List<JsModifier>.toComposeModifier(): Modifier =
* keeps an opaque background and a blended one never blends the surface it paints.
*
* The rest follows the frame: margin outside, then the surface, then the content's padding and size.
* A shaped background also clips the content to its shape, after the border so the border stays whole.
*/
internal fun List<JsModifier>.orderedForCompose(): List<JsModifier> = sortedWith(
internal fun List<JsModifier>.orderedForCompose(): List<JsModifier> = (this + backgroundClips()).sortedWith(
compareBy {
when (it) {
is JsModifier.Margin -> 0
Expand All @@ -59,6 +60,9 @@ internal fun List<JsModifier>.orderedForCompose(): List<JsModifier> = sortedWith
}
)

private fun List<JsModifier>.backgroundClips(): List<JsModifier> =
filterIsInstance<JsModifier.Background>().mapNotNull { it.shape }.map { JsModifier.Clip(it) }

@Composable
fun JsModifier.toComposeModifier(): Modifier = when (this) {
is JsModifier.Margin -> toMarginModifier()
Expand Down
Loading
Loading