diff --git a/spark-screenshot-testing/src/test/kotlin/com/adevinta/spark/placeholder/PlaceholderDocumentationScreenshots.kt b/spark-screenshot-testing/src/test/kotlin/com/adevinta/spark/placeholder/PlaceholderDocumentationScreenshots.kt new file mode 100644 index 000000000..c85978d9c --- /dev/null +++ b/spark-screenshot-testing/src/test/kotlin/com/adevinta/spark/placeholder/PlaceholderDocumentationScreenshots.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Adevinta + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package com.adevinta.spark.placeholder + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.adevinta.spark.DefaultTestDevices +import com.adevinta.spark.components.placeholder.textPlaceholder +import com.adevinta.spark.components.text.Text +import com.adevinta.spark.paparazziRule +import com.adevinta.spark.sparkDocSnapshot +import com.android.ide.common.rendering.api.SessionParams.RenderingMode.SHRINK +import org.junit.Rule +import org.junit.Test + +internal class PlaceholderDocumentationScreenshots { + + @get:Rule + val paparazzi = paparazziRule( + renderingMode = SHRINK, + deviceConfig = DefaultTestDevices.DocPhone, + ) + + @Test + fun textPlaceholder() = paparazzi.sparkDocSnapshot { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Short", + modifier = Modifier.textPlaceholder(visible = true), + ) + Text( + text = "A longer placeholder line", + modifier = Modifier.textPlaceholder(visible = true), + ) + Text( + text = "Another distinct width here", + modifier = Modifier.textPlaceholder(visible = true), + ) + Text( + text = "A multi-line placeholder that wraps across several lines so each " + + "text line draws its own squiggle instead of one stroke for the whole block", + modifier = Modifier.textPlaceholder(visible = true), + ) + } + } +} diff --git a/spark-screenshot-testing/src/test/snapshots/images/com.adevinta.spark.components.placeholder_PlaceholderDocumentationScreenshots_textPlaceholder.png b/spark-screenshot-testing/src/test/snapshots/images/com.adevinta.spark.components.placeholder_PlaceholderDocumentationScreenshots_textPlaceholder.png index 0948402ed..12a75e5c4 100644 --- a/spark-screenshot-testing/src/test/snapshots/images/com.adevinta.spark.components.placeholder_PlaceholderDocumentationScreenshots_textPlaceholder.png +++ b/spark-screenshot-testing/src/test/snapshots/images/com.adevinta.spark.components.placeholder_PlaceholderDocumentationScreenshots_textPlaceholder.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4e270a7cb7d124fd26148837d2d8b12cc7f433b214e4b4ee9f4a73a71b40a51b -size 7944 +oid sha256:96d46327263fdced30d15d5a477dccfbe124d60534377251632ea7f6fa4dd0d4 +size 10187 diff --git a/spark-screenshot-testing/src/test/snapshots/images/com.adevinta.spark.placeholder_PlaceholderDocumentationScreenshots_textPlaceholder.png b/spark-screenshot-testing/src/test/snapshots/images/com.adevinta.spark.placeholder_PlaceholderDocumentationScreenshots_textPlaceholder.png new file mode 100644 index 000000000..564a78ce5 --- /dev/null +++ b/spark-screenshot-testing/src/test/snapshots/images/com.adevinta.spark.placeholder_PlaceholderDocumentationScreenshots_textPlaceholder.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2bdfe160ec8c252a6c73277f5801b97eea48cc2d12a41d8d53d40e35366ef9d8 +size 57056 diff --git a/spark/src/main/kotlin/com/adevinta/spark/components/placeholder/HandDrawnTextPlaceholder.kt b/spark/src/main/kotlin/com/adevinta/spark/components/placeholder/HandDrawnTextPlaceholder.kt new file mode 100644 index 000000000..fe467a7a5 --- /dev/null +++ b/spark/src/main/kotlin/com/adevinta/spark/components/placeholder/HandDrawnTextPlaceholder.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2023 Adevinta + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package com.adevinta.spark.components.placeholder + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke +import kotlin.math.max +import kotlin.math.roundToInt +import kotlin.random.Random + +private const val STROKE_WIDTH_FRACTION = 0.42f +private const val AMPLITUDE_FRACTION = 0.14f +private const val MIN_STROKE_WIDTH_PX = 3f + +/** + * Builds the squiggle points for each text line, one inner list per line. + * + * The placeholder box is split into [lineCount] equal horizontal bands, one per text line. Each + * band holds one squiggle centred on the band. The seed is derived purely from the integer-pixel + * dimensions and the line index, so two placeholders with identical width, height and line count + * always produce the same geometry with no per-frame randomness, and each line differs from the + * others. + * + * - The first and last points of a line sit on the band centre line. + * - Interior points are displaced vertically by a seeded random fraction of the amplitude. + * - Segment count grows with the band aspect ratio, so wider placeholders have more waves. + */ +internal fun buildSquigglePoints(widthPx: Float, heightPx: Float, lineCount: Int): List> { + val lines = max(1, lineCount) + val band = heightPx / lines + val amplitude = band * AMPLITUDE_FRACTION + return List(lines) { line -> + val seed = widthPx.roundToInt().toLong() * 100_000L + heightPx.roundToInt().toLong() * 31L + line + val rng = Random(seed) + val centreY = band * (line + 0.5f) + val segmentCount = max(2, (widthPx / band).roundToInt()) + List(segmentCount + 1) { i -> + val x = i * widthPx / segmentCount + val y = when (i) { + 0, segmentCount -> centreY + else -> centreY + (rng.nextFloat() * 2f - 1f) * amplitude + } + Offset(x, y) + } + } +} + +/** + * Builds a smooth [Path] through the squiggle points using quadratic Bézier midpoint subdivision. + * + * The path holds one squiggle sub-path per text line. Each point acts as a Bézier control point; + * the midpoint between it and its successor is the on-curve destination. Each line finishes with a + * straight segment to its last point, which sits on the band centre line. + */ +internal fun buildSquigglePath(widthPx: Float, heightPx: Float, lineCount: Int): Path { + val lines = buildSquigglePoints(widthPx, heightPx, lineCount) + return Path().apply { + for (points in lines) { + moveTo(points.first().x, points.first().y) + for (i in 0 until points.size - 1) { + val midX = (points[i].x + points[i + 1].x) / 2f + val midY = (points[i].y + points[i + 1].y) / 2f + quadraticTo(points[i].x, points[i].y, midX, midY) + } + lineTo(points.last().x, points.last().y) + } + } +} + +/** + * Returns a [Stroke] proportional to one text line height, with rounded caps and joins to + * reinforce the hand-drawn character of the squiggle. + */ +internal fun buildSquiggleStroke(lineHeightPx: Float): Stroke = Stroke( + width = max(MIN_STROKE_WIDTH_PX, lineHeightPx * STROKE_WIDTH_FRACTION), + cap = StrokeCap.Round, + join = StrokeJoin.Round, +) diff --git a/spark/src/main/kotlin/com/adevinta/spark/components/placeholder/Placeholder.kt b/spark/src/main/kotlin/com/adevinta/spark/components/placeholder/Placeholder.kt index 8573fb3cb..f42e064c7 100644 --- a/spark/src/main/kotlin/com/adevinta/spark/components/placeholder/Placeholder.kt +++ b/spark/src/main/kotlin/com/adevinta/spark/components/placeholder/Placeholder.kt @@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.tooling.preview.PreviewFontScale import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import com.adevinta.spark.PreviewTheme @@ -59,7 +60,9 @@ public fun Modifier.placeholder(visible: Boolean): Modifier = basePlaceholder(vi /** * Draws some skeleton UI which is typically used whilst content is 'loading'. * - * The shape is not customizable as it's meant to display the same style for each text placeholders. + * The modifier draws a hand-drawn squiggle stroke. The geometry is derived only from the placeholder + * size: two placeholders with the same integer pixel width and height draw the same squiggle, and + * any size difference draws a different squiggle. * * A cross-fade transition will be applied to the content and placeholder UI when the [visible] * value changes. @@ -76,7 +79,7 @@ public fun Modifier.placeholder(visible: Boolean): Modifier = basePlaceholder(vi */ public fun Modifier.textPlaceholder(visible: Boolean): Modifier = basePlaceholder( visible = visible, - useFullShape = true, + handDrawnText = true, ) /** @@ -109,6 +112,7 @@ public fun Modifier.illustrationPlaceholder( ) @PreviewLightDark +@PreviewFontScale @Composable internal fun PreviewPlaceHolder() { PreviewTheme { diff --git a/spark/src/main/kotlin/com/adevinta/spark/components/placeholder/Placeholder.md b/spark/src/main/kotlin/com/adevinta/spark/components/placeholder/Placeholder.md index 173a83823..94f0d51a5 100644 --- a/spark/src/main/kotlin/com/adevinta/spark/components/placeholder/Placeholder.md +++ b/spark/src/main/kotlin/com/adevinta/spark/components/placeholder/Placeholder.md @@ -40,8 +40,10 @@ Box( #### `Modifier.textPlaceholder` -Skeleton for text content. Uses `SparkTheme.shapes.full` (pill shape) to mirror the rounded -profile of text runs. Animation is a **fade**. +Skeleton for text content. Draws a hand-drawn squiggle stroke using `SparkTheme` colour tokens. +The geometry is derived deterministically from the placeholder size: two placeholders with the +same integer pixel width and height always draw the same stroke, and any size difference draws a +different stroke. Animation is a **fade**. ```kotlin Text( diff --git a/spark/src/main/kotlin/com/adevinta/spark/components/placeholder/PlaceholderFoundation.kt b/spark/src/main/kotlin/com/adevinta/spark/components/placeholder/PlaceholderFoundation.kt index a75a5aec7..c89a066ab 100644 --- a/spark/src/main/kotlin/com/adevinta/spark/components/placeholder/PlaceholderFoundation.kt +++ b/spark/src/main/kotlin/com/adevinta/spark/components/placeholder/PlaceholderFoundation.kt @@ -27,6 +27,7 @@ import androidx.compose.animation.core.animate import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween +import androidx.compose.material3.LocalTextStyle import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.ui.Modifier @@ -35,12 +36,14 @@ import androidx.compose.ui.geometry.toRect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Outline import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.graphics.drawOutline import androidx.compose.ui.graphics.drawscope.ContentDrawScope import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.node.CompositionLocalConsumerModifierNode @@ -50,6 +53,7 @@ import androidx.compose.ui.node.currentValueOf import androidx.compose.ui.node.invalidateDraw import androidx.compose.ui.platform.InspectorInfo import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.TextUnitType import com.adevinta.spark.SparkTheme import com.adevinta.spark.tokens.LocalSparkColors import com.adevinta.spark.tokens.LocalSparkShapes @@ -57,6 +61,8 @@ import com.adevinta.spark.tokens.SparkColors import com.adevinta.spark.tokens.contentColorFor import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import kotlin.math.max +import kotlin.math.roundToInt /** * Contains default values used by [Modifier.placeholder] and [PlaceholderHighlight]. @@ -114,6 +120,7 @@ internal fun Modifier.placeholder( highlight = highlight, useShimmerHighlight = false, useFullShape = false, + handDrawnText = false, ) /** @@ -127,6 +134,7 @@ internal fun Modifier.basePlaceholder( highlight: PlaceholderHighlight? = null, useShimmerHighlight: Boolean = false, useFullShape: Boolean = false, + handDrawnText: Boolean = false, ): Modifier = this then PlaceholderElement( visible = visible, color = color, @@ -134,6 +142,7 @@ internal fun Modifier.basePlaceholder( highlight = highlight, useShimmerHighlight = useShimmerHighlight, useFullShape = useFullShape, + handDrawnText = handDrawnText, ) internal class PlaceholderElement( @@ -143,6 +152,7 @@ internal class PlaceholderElement( val highlight: PlaceholderHighlight?, val useShimmerHighlight: Boolean, val useFullShape: Boolean, + val handDrawnText: Boolean, ) : ModifierNodeElement() { override fun create(): PlaceholderNode = PlaceholderNode( @@ -152,6 +162,7 @@ internal class PlaceholderElement( highlight = highlight, useShimmerHighlight = useShimmerHighlight, useFullShape = useFullShape, + handDrawnText = handDrawnText, ) override fun update(node: PlaceholderNode) { @@ -162,6 +173,7 @@ internal class PlaceholderElement( highlight = highlight, useShimmerHighlight = useShimmerHighlight, useFullShape = useFullShape, + handDrawnText = handDrawnText, ) } @@ -182,7 +194,8 @@ internal class PlaceholderElement( shape == other.shape && highlight == other.highlight && useShimmerHighlight == other.useShimmerHighlight && - useFullShape == other.useFullShape + useFullShape == other.useFullShape && + handDrawnText == other.handDrawnText } override fun hashCode(): Int { @@ -192,6 +205,7 @@ internal class PlaceholderElement( result = 31 * result + (highlight?.hashCode() ?: 0) result = 31 * result + useShimmerHighlight.hashCode() result = 31 * result + useFullShape.hashCode() + result = 31 * result + handDrawnText.hashCode() return result } } @@ -203,6 +217,7 @@ internal class PlaceholderNode( private var highlight: PlaceholderHighlight?, private var useShimmerHighlight: Boolean, private var useFullShape: Boolean, + private var handDrawnText: Boolean, ) : Modifier.Node(), DrawModifierNode, CompositionLocalConsumerModifierNode { @@ -218,6 +233,10 @@ internal class PlaceholderNode( private var lastSize: Size? = null private var lastLayoutDirection: LayoutDirection? = null private var lastOutline: Outline? = null + private var lastSquigglePath: Path? = null + private var lastSquiggleStroke: Stroke? = null + private var lastLineCount: Int = 0 + private var lastBand: Float = 0f override fun onAttach() { startHighlightAnimation() @@ -230,6 +249,7 @@ internal class PlaceholderNode( highlight: PlaceholderHighlight?, useShimmerHighlight: Boolean, useFullShape: Boolean, + handDrawnText: Boolean, ) { val visibleChanged = this.visible != visible val highlightChanged = this.highlight != highlight || @@ -240,6 +260,7 @@ internal class PlaceholderNode( this.highlight = highlight this.useShimmerHighlight = useShimmerHighlight this.useFullShape = useFullShape + this.handDrawnText = handDrawnText if (visibleChanged) { this.visible = visible @@ -251,6 +272,10 @@ internal class PlaceholderNode( } lastOutline = null + lastSquigglePath = null + lastSquiggleStroke = null + lastLineCount = 0 + lastBand = 0f invalidateDraw() } @@ -302,12 +327,30 @@ internal class PlaceholderNode( if (placeholderAlpha >= 0.01f) { val resolvedColor = resolveColor() - val resolvedShape = resolveShape() val resolvedHighlight = resolveHighlight() - if (placeholderAlpha in 0.01f..0.99f) { - paint.alpha = placeholderAlpha - withLayer(paint) { + if (handDrawnText) { + if (placeholderAlpha in 0.01f..0.99f) { + paint.alpha = placeholderAlpha + withLayer(paint) { + drawSquigglePlaceholder(resolvedColor, resolvedHighlight, highlightProgress) + } + } else { + drawSquigglePlaceholder(resolvedColor, resolvedHighlight, highlightProgress) + } + } else { + val resolvedShape = resolveShape() + if (placeholderAlpha in 0.01f..0.99f) { + paint.alpha = placeholderAlpha + withLayer(paint) { + lastOutline = drawPlaceholder( + shape = resolvedShape, + color = resolvedColor, + highlight = resolvedHighlight, + progress = highlightProgress, + ) + } + } else { lastOutline = drawPlaceholder( shape = resolvedShape, color = resolvedColor, @@ -315,13 +358,6 @@ internal class PlaceholderNode( progress = highlightProgress, ) } - } else { - lastOutline = drawPlaceholder( - shape = resolvedShape, - color = resolvedColor, - highlight = resolvedHighlight, - progress = highlightProgress, - ) } } @@ -394,6 +430,42 @@ internal class PlaceholderNode( return outline } + private fun DrawScope.drawSquigglePlaceholder( + color: Color, + highlight: PlaceholderHighlight?, + progress: Float, + ) { + if (size != lastSize) { + val lineHeightPx = resolveTextLineHeightPx() + lastLineCount = max(1, (size.height / lineHeightPx).roundToInt()) + lastBand = size.height / lastLineCount + lastSquigglePath = buildSquigglePath(size.width, size.height, lastLineCount) + lastSquiggleStroke = buildSquiggleStroke(lastBand) + } + val path = lastSquigglePath ?: buildSquigglePath(size.width, size.height, lastLineCount) + val stroke = lastSquiggleStroke ?: buildSquiggleStroke(lastBand) + + drawPath(path = path, color = color, style = stroke) + + if (highlight != null) { + drawPath( + path = path, + brush = highlight.brush(progress, size), + alpha = highlight.alpha(progress), + style = stroke, + ) + } + } + + private fun DrawScope.resolveTextLineHeightPx(): Float { + val style = currentValueOf(LocalTextStyle) + return when { + style.lineHeight.type == TextUnitType.Sp -> style.lineHeight.toPx() + style.fontSize.type == TextUnitType.Sp -> style.fontSize.toPx() * 1.4f + else -> size.height + } + } + private inline fun DrawScope.withLayer( paint: Paint, drawBlock: DrawScope.() -> Unit, diff --git a/spark/src/test/kotlin/com/adevinta/spark/placeholder/HandDrawnTextPlaceholderTest.kt b/spark/src/test/kotlin/com/adevinta/spark/placeholder/HandDrawnTextPlaceholderTest.kt new file mode 100644 index 000000000..be9dacd47 --- /dev/null +++ b/spark/src/test/kotlin/com/adevinta/spark/placeholder/HandDrawnTextPlaceholderTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2023 Adevinta + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package com.adevinta.spark.placeholder + +import com.adevinta.spark.components.placeholder.buildSquigglePoints +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +/** + * Unit tests for [buildSquigglePoints]. + * + * The function is pure and seeded only by the integer-pixel dimensions, so the tests run on the + * JVM without Robolectric or a Compose rule. + */ +@RunWith(JUnit4::class) +class HandDrawnTextPlaceholderTest { + + @Test + fun sameDimensionsProduceIdenticalPoints() { + // Calling with the same size and line count twice must yield an equal list. + val first = buildSquigglePoints(widthPx = 300f, heightPx = 16f, lineCount = 1) + val second = buildSquigglePoints(widthPx = 300f, heightPx = 16f, lineCount = 1) + assertThat(first).isEqualTo(second) + } + + @Test + fun differentWidthProducesDifferentPoints() { + // A change in width alters the seed, so the resulting geometry must differ. + val baseline = buildSquigglePoints(widthPx = 300f, heightPx = 16f, lineCount = 1) + val narrower = buildSquigglePoints(widthPx = 280f, heightPx = 16f, lineCount = 1) + assertThat(baseline).isNotEqualTo(narrower) + } + + @Test + fun differentHeightProducesDifferentPoints() { + // A change in height alters the seed, so the resulting geometry must differ. + val baseline = buildSquigglePoints(widthPx = 300f, heightPx = 16f, lineCount = 1) + val taller = buildSquigglePoints(widthPx = 300f, heightPx = 20f, lineCount = 1) + assertThat(baseline).isNotEqualTo(taller) + } + + @Test + fun lineCountControlsTheNumberOfLines() { + // One inner list per text line, and each line differs from the others. + val twoLines = buildSquigglePoints(widthPx = 300f, heightPx = 48f, lineCount = 2) + assertThat(twoLines).hasSize(2) + assertThat(twoLines[0]).isNotEqualTo(twoLines[1]) + } +}