compose-rust 0.0.4

Declarative UI in Rust, rendered by an AOT-compiled Compose Multiplatform engine.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
package dev.darkpyonix.composerust.ui

import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.layout.absoluteOffset
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.composed
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalDensity
import dev.darkpyonix.composerust.protocol.WindowHeightClass
import dev.darkpyonix.composerust.protocol.WindowSizeClass
import dev.darkpyonix.composerust.runtime.windowHeightClassOf
import dev.darkpyonix.composerust.runtime.windowSizeClassOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlurEffect
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.ClipOp
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.drawOutline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathOperation
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import kotlin.math.ceil
import kotlin.math.max
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import dev.darkpyonix.composerust.protocol.HostEvent
import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier
import dev.darkpyonix.composerust.design.ResolvedTheme
import dev.darkpyonix.composerust.design.glassLift
import dev.darkpyonix.composerust.design.glassSurface
import dev.darkpyonix.composerust.runtime.EventDispatcher
import dev.darkpyonix.composerust.ui.node.TableError

/**
 * Rebuilds a Compose `Modifier` chain from the Host's modifier value list.
 *
 * List order is chain order, so `[Padding(16), FillMaxWidth]` and the reverse differ exactly
 * as they do in hand-written Compose.
 *
 * Roles are resolved here against the active design system's token table:
 * the Host sent a role, the Renderer decides what it measures.
 */
internal fun List<ProtocolModifier>.toComposeModifier(
    nodeId: Int,
    dispatcher: EventDispatcher,
    theme: ResolvedTheme,
    animated: (() -> dev.darkpyonix.composerust.ui.node.AnimatedNode)? = null,
): Modifier {
    // The last Shape or ShapeRole in the list is what clips, what the border
    // follows and what the background fills, whatever their order in the chain.
    val shape = resolvedShape(theme)
    // What this node's surface is made of is its surface, so it is drawn round the whole
    // node the way a background is: under the padding, and outside the clip so a surface
    // that floats can cast its lift beyond its own edge. Taken at its place in the list it
    // came last, after the padding and the clip, and a glass composer was drawn as a
    // capsule inside its own padding with the lift cut off at the edge.
    val material = lastOrNull { it is ProtocolModifier.Material } as ProtocolModifier.Material?
    val surface = if (material == null) {
        Modifier
    } else {
        // Resolved by the running design system, which answers with blur where it blurs
        // and with a lifted or flat fill where it does not. Glass floats, so it lifts off
        // what is behind it before it is painted: a glass capsule on a page of its own
        // colour is otherwise a rim and nothing else.
        Modifier.composed {
            val resolved = theme.rules.material(material.role, theme)
            glassLift(resolved, shape).glassSurface(resolved, shape)
        }
    }
    // The corners an HTML box asked for, which its background, border, shadow and clip all
    // follow. Null where the node named no corners of its own.
    val corners = cornerEachShape()
    // The first opacity, background, transform and border of a node are where an animation the
    // Renderer plays on it is drawn. They read the animated value in the draw phase, so
    // the animation never recomposes or remeasures the node.
    var alphaSeen = false
    var backgroundSeen = false
    var transformSeen = false
    var borderSeen = false
    return fold(surface) { chain, value ->
        when (value) {
            is ProtocolModifier.Empty -> chain
            is ProtocolModifier.Padding -> chain.padding(value.value.dp)
            is ProtocolModifier.FillMaxWidth -> chain.fillMaxWidth()
            is ProtocolModifier.FillMaxHeight -> chain.fillMaxHeight()
            is ProtocolModifier.Width -> chain.width(value.value.dp)
            is ProtocolModifier.Height -> chain.height(value.value.dp)
            is ProtocolModifier.Size -> chain.size(value.width.dp, value.height.dp)
            // A gradient where the paint named one, a flat colour otherwise. A paint
            // that names a brush nobody registered is reported and left unpainted: a
            // guess would leave a screen subtly wrong with nothing to read about why.
            is ProtocolModifier.Background -> theme.brush(value.paint)?.let { brush ->
                val holder = if (!backgroundSeen) animated?.invoke() else null
                backgroundSeen = true
                if (holder == null) chain.background(brush, shape) else chain.animatedBackground(brush, shape, holder)
            } ?: chain.composed { reportUnknownBrush(nodeId, value.paint, dispatcher) }
            is ProtocolModifier.Clickable -> chain.hostClickable(nodeId, value.handlerId, dispatcher)

            is ProtocolModifier.PaddingEach ->
                chain.padding(value.start.dp, value.top.dp, value.end.dp, value.bottom.dp)
            is ProtocolModifier.PaddingRole -> chain.padding(theme.space(value.role))
            is ProtocolModifier.Shape -> chain.clip(shape)
            is ProtocolModifier.ShapeRole -> chain.clip(shape)
            is ProtocolModifier.Border -> theme.brush(value.paint)?.let { brush ->
                val holder = if (!borderSeen) animated?.invoke() else null
                borderSeen = true
                // Compose's own border until an animation first plays on this one, then the
                // layer that can repaint it in the draw phase.
                if (holder != null && holder.borderAnimated.value) {
                    chain.animatedBorder(value.width.dp, brush, shape, holder)
                } else {
                    chain.border(value.width.dp, brush, shape)
                }
            } ?: chain.composed { reportUnknownBrush(nodeId, value.paint, dispatcher) }
            is ProtocolModifier.Elevation ->
                theme.rules.elevation(chain, value.value.dp, shape, theme)

            // Only a node that asked is measured. Nothing is attached to a node without
            // this modifier, so a tree that observes nothing is laid out exactly as it
            // was before any of this existed.
            is ProtocolModifier.ObserveSize -> chain.reportSizeTo(nodeId, dispatcher)

            // How important this node's changes are. What that means in milliseconds and
            // along which curve is the running design system's answer, and a system the
            // user has asked to hold still answers every role with no run at all.
            is ProtocolModifier.Motion -> chain.animateContentSize(theme.motion(value.role))

            // What this node's surface is made of, drawn above at the outside of the chain.
            is ProtocolModifier.Material -> chain

            // Weight is parent data: it is applied by the Column or Row that owns this node,
            // not here. See `weightOf` and `Children` in RenderNode.kt.
            is ProtocolModifier.Weight -> chain

            // The elements an HTML and CSS screen is drawn with. The Host's layout engine has
            // already decided where every box goes and how big it is, so these place and
            // size without negotiating, and the decoration follows CSS rather than the
            // design system: a page's author chose these colours and radii.
            //
            // Absolute, not relative to the reading direction: the Host's coordinates are
            // the screen's, and a right-to-left page has already been laid out as one.
            is ProtocolModifier.Offset -> chain.absoluteOffset(value.x.dp, value.y.dp)
            is ProtocolModifier.RequiredSize -> chain.requiredSize(value.width.dp, value.height.dp)
            is ProtocolModifier.BorderEach -> {
                val paints = listOf(value.topPaint, value.rightPaint, value.bottomPaint, value.leftPaint)
                val brushes = paints.map { theme.brush(it) }
                val missing = paints.indices.firstOrNull { brushes[it] == null }
                if (missing != null) {
                    chain.composed { reportUnknownBrush(nodeId, paints[missing], dispatcher) }
                } else {
                    val holder = if (!borderSeen) animated?.invoke() else null
                    borderSeen = true
                    chain.borderEach(value, brushes.map { it!! }, corners, holder)
                }
            }

            // Read by the background, the border, the shadow and the clip, through `corners`
            // above. On its own it draws nothing and cuts nothing, as in CSS, where a radius
            // with no overflow rule rounds the decoration and leaves the content alone.
            is ProtocolModifier.CornerEach -> chain
            is ProtocolModifier.Shadow -> theme.brush(value.paint)?.let { brush ->
                chain.boxShadow(value, brush, corners)
            } ?: chain.composed { reportUnknownBrush(nodeId, value.paint, dispatcher) }

            // `overflow: hidden`: the rounded box where the node has corners, its rectangle
            // where it has none.
            is ProtocolModifier.Clip ->
                if (value.enabled) chain.clip(corners ?: RectangleShape) else chain

            // CSS `transform`: drawn through layers, so the layout is untouched and a
            // pointer is tested against the shape that is drawn.
            is ProtocolModifier.Transform -> {
                val holder = if (!transformSeen) animated?.invoke() else null
                transformSeen = true
                chain.affineTransform(
                    value.originX,
                    value.originY,
                    AffineParts.isOneLayer(value.a, value.b, value.c, value.d) &&
                        holder?.twoLayers?.value != true,
                ) { matrix ->
                    // Read so a layer redraws when an animation writes a new matrix.
                    val shown = holder != null && holder.transformVersion.intValue >= 0 && holder.transformShown
                    if (shown) {
                        holder!!.matrix.copyInto(matrix)
                    } else {
                        matrix[0] = value.a
                        matrix[1] = value.b
                        matrix[2] = value.c
                        matrix[3] = value.d
                        matrix[4] = value.e
                        matrix[5] = value.f
                    }
                }
            }

            // The node and everything in it are drawn into one layer first, and the layer is
            // faded once. Fading each child separately would show where they overlap, which
            // CSS `opacity` does not.
            is ProtocolModifier.Alpha -> {
                val holder = if (!alphaSeen) animated?.invoke() else null
                alphaSeen = true
                if (holder == null) {
                    chain.groupAlpha { value.value }
                } else {
                    chain.groupAlpha {
                        val shown = holder.alpha.floatValue
                        if (shown.isNaN()) value.value else shown
                    }
                }
            }
        }
    }
}

/**
 * A background that an animation can repaint without recomposing the node: the same fill
 * `Modifier.background` draws, with the animated colour read in the draw phase in its
 * place while one plays.
 */
private fun Modifier.animatedBackground(
    brush: Brush,
    shape: Shape,
    holder: dev.darkpyonix.composerust.ui.node.AnimatedNode,
): Modifier = drawWithCache {
    val outline = if (shape === RectangleShape) null else shape.createOutline(size, layoutDirection, this)
    onDrawBehind {
        val shown = holder.backgroundOverride()
        when {
            shown != null && outline == null -> drawRect(color = shown)
            shown != null -> drawOutline(outline!!, color = shown)
            outline == null -> drawRect(brush)
            else -> drawOutline(outline, brush)
        }
    }
}

/**
 * A uniform border that an animation can repaint without recomposing the node: a stroke of
 * [width] along the inside of the node's [shape], drawn over the content as
 * `Modifier.border` draws it, in the animated colour while one plays.
 */
private fun Modifier.animatedBorder(
    width: Dp,
    brush: Brush,
    shape: Shape,
    holder: dev.darkpyonix.composerust.ui.node.AnimatedNode,
): Modifier = drawWithCache {
    val stroke = width.toPx().coerceIn(0f, minOf(size.width, size.height) / 2f)
    val half = stroke / 2f
    val inner = Size((size.width - stroke).coerceAtLeast(0f), (size.height - stroke).coerceAtLeast(0f))
    val outline = shape.createOutline(inner, layoutDirection, this)
    val style = Stroke(stroke)
    onDrawWithContent {
        drawContent()
        if (stroke > 0f) {
            val shown = holder.borderOverride()
            translate(half, half) {
                if (shown != null) {
                    drawOutline(outline, color = shown, style = style)
                } else {
                    drawOutline(outline, brush, style = style)
                }
            }
        }
    }
}

/**
 * Fades this node and everything drawn inside it as one group, the way CSS `opacity` does.
 *
 * The content is drawn into a layer of its own and the layer is drawn once at [alpha], so
 * where two children overlap the overlap is no darker than either of them. The layer has
 * no edge of its own: a child that overflows the node, or content a transform has turned
 * past the node's rectangle, is faded rather than cut off. A Compose layer with an
 * offscreen buffer would cut it at the node's bounds, which CSS never does.
 *
 * [alpha] is read while drawing, so a value that changes every frame only redraws.
 */
internal fun Modifier.groupAlpha(alpha: () -> Float): Modifier {
    val paint = Paint()
    return drawWithContent {
        val opacity = alpha().coerceIn(0f, 1f)
        when {
            opacity >= 1f -> drawContent()
            opacity <= 0f -> Unit
            else -> {
                paint.alpha = opacity
                drawContext.canvas.saveLayer(UNBOUNDED_GROUP, paint)
                drawContent()
                drawContext.canvas.restore()
            }
        }
    }
}

/**
 * A layer as large as anything a screen can draw. The layer that is allocated is only as
 * large as what is visible, because drawing is limited to the clip before it starts.
 */
private val UNBOUNDED_GROUP = Rect(-1e7f, -1e7f, 1e7f, 1e7f)

/**
 * Says that a paint named a brush that is not registered, and paints nothing.
 *
 * Once per node and per id, the same way a missing picture is reported. The node keeps
 * its place in the layout: a surface that vanished would take its children with it.
 */
@Composable
private fun Modifier.reportUnknownBrush(
    nodeId: Int,
    paint: dev.darkpyonix.composerust.protocol.Paint,
    dispatcher: EventDispatcher,
): Modifier {
    val assetId = (paint as? dev.darkpyonix.composerust.protocol.Paint.Asset)?.assetId ?: return this
    LaunchedEffect(nodeId, assetId) {
        dispatcher.dispatch(
            HostEvent.ProtocolError(
                nodeId = nodeId,
                handlerId = 0,
                code = TableError.UNKNOWN_ASSET,
                message = "brush $assetId is not registered, so node $nodeId was not painted",
            ),
        )
    }
    return this
}

/** The shape this node's clip, border and background all use. */
internal fun List<ProtocolModifier>.resolvedShape(theme: ResolvedTheme): Shape {
    for (index in indices.reversed()) {
        when (val value = this[index]) {
            // A radius the Host named is still cut the way the running design system cuts
            // corners. The Host asked for a size, not for an arc.
            is ProtocolModifier.Shape -> return theme.shapeOfRadii(
                topStart = value.topStart,
                topEnd = value.topEnd,
                bottomEnd = value.bottomEnd,
                bottomStart = value.bottomStart,
            )

            is ProtocolModifier.ShapeRole -> return theme.shape(value.role)

            // Radii an HTML box named, cut as plain arcs the way CSS cuts them. Unlike a
            // `Shape`, these are not the design system's to reinterpret: the page's author
            // chose them, and the screen has to match what a browser draws.
            is ProtocolModifier.CornerEach -> return value.toShape()
            else -> Unit
        }
    }
    return RectangleShape
}

/** The corners this node named for itself, or null. The last one in the list stands. */
internal fun List<ProtocolModifier>.cornerEachShape(): CornerEachShape? =
    (lastOrNull { it is ProtocolModifier.CornerEach } as ProtocolModifier.CornerEach?)?.toShape()

private fun ProtocolModifier.CornerEach.toShape() =
    CornerEachShape(topLeft, topRight, bottomRight, bottomLeft)

/**
 * A rectangle with its own radius at each corner, in dp, cut the way CSS cuts
 * `border-radius`.
 *
 * Where two radii on one side add up to more than the side, CSS shrinks every radius by
 * the same factor until they fit, so the shape keeps its proportions. Compose's own
 * rounded shapes clamp each corner on its own, which draws a different curve, so this does
 * the scaling itself.
 */
internal data class CornerEachShape(
    val topLeft: Float,
    val topRight: Float,
    val bottomRight: Float,
    val bottomLeft: Float,
) : Shape {
    override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline =
        Outline.Rounded(roundRect(size, density))

    /** The outline at the origin, with the radii in pixels and scaled to fit. */
    fun roundRect(size: Size, density: Density): RoundRect {
        val px = with(density) { floatArrayOf(topLeft.dp.toPx(), topRight.dp.toPx(), bottomRight.dp.toPx(), bottomLeft.dp.toPx()) }
        var scale = 1f
        fun fit(side: Float, first: Float, second: Float) {
            val sum = first + second
            if (sum > 0f && side < sum * scale) scale = side / sum
        }
        fit(size.width, px[0], px[1])
        fit(size.width, px[3], px[2])
        fit(size.height, px[0], px[3])
        fit(size.height, px[1], px[2])
        return RoundRect(
            left = 0f,
            top = 0f,
            right = size.width,
            bottom = size.height,
            topLeftCornerRadius = CornerRadius(max(0f, px[0] * scale)),
            topRightCornerRadius = CornerRadius(max(0f, px[1] * scale)),
            bottomRightCornerRadius = CornerRadius(max(0f, px[2] * scale)),
            bottomLeftCornerRadius = CornerRadius(max(0f, px[3] * scale)),
        )
    }
}

/** The node's outline at the origin: its corners where it has them, its rectangle otherwise. */
private fun boxOutline(size: Size, density: Density, corners: CornerEachShape?): RoundRect =
    corners?.roundRect(size, density) ?: RoundRect(0f, 0f, size.width, size.height)

/**
 * A border with its own width and paint on each side, drawn the way CSS draws one.
 *
 * The border is the ring between the node's outline and the same outline brought in by each
 * side's width, with every inner radius reduced by the widths next to it. Each side paints
 * the part of the ring on its side of the two lines that run from the outer corners through
 * the inner ones, which is where CSS puts the join between two sides of different colours.
 *
 * Drawn behind the content, over the background, as CSS draws it. Nothing about the layout
 * changes: CSS's border box is the size the Host already sent.
 */
private fun Modifier.borderEach(
    value: ProtocolModifier.BorderEach,
    brushes: List<Brush>,
    corners: CornerEachShape?,
    /** Where an animation of the border's colour writes the colour every side shows. */
    holder: dev.darkpyonix.composerust.ui.node.AnimatedNode? = null,
): Modifier = drawWithCache {
    val width = size.width
    val height = size.height
    val top = value.top.dp.toPx().coerceAtLeast(0f)
    val right = value.right.dp.toPx().coerceAtLeast(0f)
    val bottom = value.bottom.dp.toPx().coerceAtLeast(0f)
    val left = value.left.dp.toPx().coerceAtLeast(0f)
    val outer = boxOutline(size, this, corners)
    val inner = RoundRect(
        left = left,
        top = top,
        right = max(left, width - right),
        bottom = max(top, height - bottom),
        topLeftCornerRadius = CornerRadius(
            max(0f, outer.topLeftCornerRadius.x - left),
            max(0f, outer.topLeftCornerRadius.y - top),
        ),
        topRightCornerRadius = CornerRadius(
            max(0f, outer.topRightCornerRadius.x - right),
            max(0f, outer.topRightCornerRadius.y - top),
        ),
        bottomRightCornerRadius = CornerRadius(
            max(0f, outer.bottomRightCornerRadius.x - right),
            max(0f, outer.bottomRightCornerRadius.y - bottom),
        ),
        bottomLeftCornerRadius = CornerRadius(
            max(0f, outer.bottomLeftCornerRadius.x - left),
            max(0f, outer.bottomLeftCornerRadius.y - bottom),
        ),
    )
    val ring = Path.combine(
        PathOperation.Difference,
        Path().apply { addRoundRect(outer) },
        Path().apply { addRoundRect(inner) },
    )
    val middleX = width / 2f
    val middleY = height / 2f
    // Each side's share of the ring: from its two outer corners along the joins towards the
    // middle of the box. The joins run through the inner corners, so a thick side takes
    // more of the corner than a thin one, as in a browser.
    fun polygon(vararg xy: Float) = Path().apply {
        moveTo(xy[0], xy[1])
        for (index in 2 until xy.size step 2) lineTo(xy[index], xy[index + 1])
        close()
    }
    val regions = listOf(
        if (top > 0f) polygon(
            0f, 0f,
            width, 0f,
            width - right * middleY / top, middleY,
            left * middleY / top, middleY,
        ) else null,
        if (right > 0f) polygon(
            width, 0f,
            width, height,
            middleX, height - bottom * middleX / right,
            middleX, top * middleX / right,
        ) else null,
        if (bottom > 0f) polygon(
            width, height,
            0f, height,
            left * middleY / bottom, middleY,
            width - right * middleY / bottom, middleY,
        ) else null,
        if (left > 0f) polygon(
            0f, height,
            0f, 0f,
            middleX, top * middleX / left,
            middleX, height - bottom * middleX / left,
        ) else null,
    )
    onDrawBehind {
        val shown = holder?.borderOverride()
        regions.forEachIndexed { index, region ->
            if (region != null) {
                clipPath(region) {
                    if (shown != null) drawPath(ring, color = shown) else drawPath(ring, brushes[index])
                }
            }
        }
    }
}

/**
 * One CSS `box-shadow`: the node's outline, grown by the spread, moved by the offset and
 * blurred, and drawn only outside the node.
 *
 * CSS cuts the shadow out from under the box it belongs to, so a translucent box does not
 * show its own shadow through itself. That is why this is drawn here, behind the content
 * with the box cut away, rather than by Compose's own shadow modifiers, which leave the
 * shadow under the box.
 *
 * Later shadows draw over earlier ones, because a modifier list draws in its order. CSS
 * draws the first shadow in its list on top, so a Host sends a CSS list last to first.
 */
private fun Modifier.boxShadow(
    value: ProtocolModifier.Shadow,
    brush: Brush,
    corners: CornerEachShape?,
): Modifier = drawWithCache {
    val spread = value.spread.dp.toPx()
    val blur = value.blur.dp.toPx().coerceAtLeast(0f)
    val dx = value.x.dp.toPx()
    val dy = value.y.dp.toPx()
    val box = boxOutline(size, this, corners)
    val boxPath = Path().apply { addRoundRect(box) }
    val shadowWidth = size.width + 2f * spread
    val shadowHeight = size.height + 2f * spread
    if (shadowWidth <= 0f || shadowHeight <= 0f) {
        return@drawWithCache onDrawBehind { }
    }
    // The spread grows each corner's radius with it, by the rule CSS gives so that a small
    // radius does not jump to a large one when the spread is large.
    fun grown(radius: CornerRadius) = CornerRadius(spreadRadius(radius.x, spread), spreadRadius(radius.y, spread))
    val shape = RoundRect(
        left = 0f,
        top = 0f,
        right = shadowWidth,
        bottom = shadowHeight,
        topLeftCornerRadius = grown(box.topLeftCornerRadius),
        topRightCornerRadius = grown(box.topRightCornerRadius),
        bottomRightCornerRadius = grown(box.bottomRightCornerRadius),
        bottomLeftCornerRadius = grown(box.bottomLeftCornerRadius),
    )
    val shapePath = Path().apply { addRoundRect(shape) }
    if (blur == 0f) {
        return@drawWithCache onDrawBehind {
            clipPath(boxPath, ClipOp.Difference) {
                translate(dx - spread, dy - spread) { drawPath(shapePath, brush) }
            }
        }
    }
    // CSS's blur radius is twice the standard deviation of the Gaussian it blurs with. The
    // shape is drawn into a layer with room for the blur around it, and the layer is
    // blurred as a whole.
    val sigma = blur / 2f
    val margin = ceil(3f * sigma)
    val layer = obtainGraphicsLayer()
    layer.record(
        this,
        layoutDirection,
        IntSize(ceil(shadowWidth + 2f * margin).toInt(), ceil(shadowHeight + 2f * margin).toInt()),
    ) {
        translate(margin, margin) { drawPath(shapePath, brush) }
    }
    layer.renderEffect = BlurEffect(composeBlurRadius(sigma), composeBlurRadius(sigma), TileMode.Decal)
    onDrawBehind {
        clipPath(boxPath, ClipOp.Difference) {
            translate(dx - spread - margin, dy - spread - margin) { drawLayer(layer) }
        }
    }
}

/**
 * The radius Compose's blur takes for a Gaussian of standard deviation [sigma].
 *
 * Compose turns a blur radius r into a deviation of 0.57735 r + 0.5, the conversion Skia
 * and Android both use, so this is that conversion run backwards. Below half a pixel there
 * is nothing to blur.
 */
private fun composeBlurRadius(sigma: Float): Float = max(0f, (sigma - 0.5f) / 0.57735f)

/**
 * A corner radius after a shadow's spread, as CSS Backgrounds 3 gives it: a radius of zero
 * stays sharp, a negative spread shrinks the radius, and a positive spread grows it by less
 * than the spread when the radius is smaller than the spread.
 */
private fun spreadRadius(radius: Float, spread: Float): Float = when {
    radius <= 0f -> 0f
    spread < 0f -> max(0f, radius + spread)
    spread == 0f || radius >= spread -> radius + spread
    else -> {
        val ratio = radius / spread - 1f
        radius + spread * (1f + ratio * ratio * ratio)
    }
}

/** The weight this node asked its parent layout for, or null. */
internal fun List<ProtocolModifier>.weightOf(): Float? =
    lastOrNull { it is ProtocolModifier.Weight }
        ?.let { (it as ProtocolModifier.Weight).value }
        ?.takeIf { it > 0f }

/**
 * Pointer handling for `Modifier.Clickable`.
 *
 * The Host handler runs synchronously inside the gesture, and its result decides whether the
 * pointer change is consumed. A Host that does not consume leaves the gesture
 * available to whatever is underneath.
 */
private fun Modifier.hostClickable(
    nodeId: Int,
    handlerId: Long,
    dispatcher: EventDispatcher,
): Modifier = pointerInput(nodeId, handlerId, dispatcher) {
    awaitEachGesture {
        val down = awaitFirstDown(requireUnconsumed = true)
        val up = waitForUpOrCancellation() ?: return@awaitEachGesture
        if (dispatcher.dispatch(HostEvent.Clicked(nodeId, handlerId))) {
            down.consume()
            up.consume()
        }
    }
}

/**
 * Reports this node's width when the class it falls in changes.
 *
 * The same event the window's own size travels on, with this node's id instead of zero:
 * a node being narrow or wide means what it means for a window, and a second way of
 * saying it would be a second thing to keep in step.
 *
 * The class is remembered per node rather than the size, so a drag that widens a panel
 * without crossing a boundary reports nothing at all.
 */
private fun Modifier.reportSizeTo(nodeId: Int, dispatcher: EventDispatcher): Modifier =
    composed {
        val density = LocalDensity.current
        val reportedWidth = remember(nodeId) { arrayOfNulls<WindowSizeClass>(1) }
        val reportedHeight = remember(nodeId) { arrayOfNulls<WindowHeightClass>(1) }
        onSizeChanged { size ->
            val widthDp = with(density) { size.width.toDp().value }
            val heightDp = with(density) { size.height.toDp().value }
            val sizeClass = windowSizeClassOf(widthDp)
            val heightClass = windowHeightClassOf(heightDp)
            // Either axis changing is one event, because one record holds both. Sending
            // two would mean two boundary crossings for one resize.
            if (reportedWidth[0] != sizeClass || reportedHeight[0] != heightClass) {
                reportedWidth[0] = sizeClass
                reportedHeight[0] = heightClass
                dispatcher.dispatch(
                    HostEvent.WindowSizeChanged(
                        nodeId = nodeId,
                        handlerId = 0,
                        widthDp = widthDp,
                        heightDp = heightDp,
                        sizeClass = sizeClass,
                        heightClass = heightClass,
                    ),
                )
            }
        }
    }