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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
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.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
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,
): 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)
}
}
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 ->
chain.background(brush, shape)
} ?: 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 ->
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
}
}
}
/**
* 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)
else -> Unit
}
}
return RectangleShape
}
/** 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,
),
)
}
}
}