Repository navigation
fix(hosts): draw Pocket faces and chat widgets the same on iOS and Android #1447
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 2a323e3
fix(ios): lay out renderer trees by the shared rules
valentinfernandez1 54520f7
fix(ios): load SVG images in faces and draw FgSuccess in its own colour
valentinfernandez1 2f9a504
fix(android): lay out renderer trees by the shared rules
valentinfernandez1 2b1f3ce
fix(hosts): keep sized buttons and images in their space
valentinfernandez1 4f2e6d5
fix(hosts): size by fixed and filled sizes, not minimums, on both hosts
valentinfernandez1 9ef2b7f
Merge remote-tracking branch 'origin/main' into fix/pocket-face-rende…
valentinfernandez1 ba0cda5
test(hosts): merge the renderer layout tests and drop comments the pr…
valentinfernandez1 71b36cd
fix(ios): make ImageFit Sendable so the layout test cases carry it wi…
valentinfernandez1 cf9cc84
Merge remote-tracking branch 'origin/main' into fix/pocket-face-rende…
valentinfernandez1 9cfaac0
Merge remote-tracking branch 'origin/main' into fix/pocket-face-rende…
valentinfernandez1 a63e02e
fix(android): keep a Spacer's minimum, arrange only in a real size, c…
valentinfernandez1 a44304d
fix(ios): read a face's SVG back from the disk cache at its declared …
valentinfernandez1 25cf39d
fix(hosts): draw a button as wide as its space unless sized, as #1480…
valentinfernandez1 2b7a8ff
Merge branch 'main' into fix/pocket-face-render-parity
valentinfernandez1 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
204 changes: 204 additions & 0 deletions
204
.../io/paritytech/polkadotapp/feature_products_api/presentation/widget/JsWidgetLayoutTest.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,204 @@ | ||
| package io.paritytech.polkadotapp.feature_products_api.presentation.widget | ||
|
|
||
| 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) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.