compose-rust 0.0.1

Declarative UI in Rust, rendered by an AOT-compiled Compose Multiplatform engine.
Documentation
package dev.darkpyonix.composerust.ui.node

import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.ui.graphics.Brush as ComposeBrush
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.text.font.FontFamily
import dev.darkpyonix.composerust.protocol.AssetKind
import dev.darkpyonix.composerust.protocol.IconRole
import java.lang.InterruptedException

/**
 * What one registered asset became once the Renderer read it.
 *
 * Nothing here points back at the batch buffer. The buffer is only valid for the call that
 * carried it, and an asset has to outlive the frame that draws it, so registration is where
 * the bytes stop being borrowed: a raster is decoded into a bitmap, a vector is parsed into
 * a document, and a symbol keeps nothing but the meaning it named.
 */
sealed interface Asset {
    /** A decoded PNG or JPEG. */
    class Raster(val bitmap: ImageBitmap) : Asset

    /** A parsed vector document, drawn at whatever size the modifier chain gives it. */
    class Vector(val document: VectorDocument) : Asset

    /**
     * A face the application shipped, which a type role can be resolved to.
     *
     * The family and not the bytes: reading a font file is done once at registration, and
     * every piece of text in that role after it is a lookup.
     */
    class Font(val family: FontFamily) : Asset

    /**
     * A gradient or a picture laid out as a fill, ready for anywhere a colour can go.
     *
     * Read once at registration. A frame painted with it is a lookup and a draw.
     */
    class Brush(val brush: ComposeBrush) : Asset

    /**
     * One meaning out of the closed set, with no artwork attached.
     *
     * The artwork belongs to the design system: this is what lets the same registration
     * come out as an SF Symbols shape under Cupertino and a Material Symbols shape under
     * Material 3, which a picture or a system icon name could never do.
     */
    data class Symbol(val role: IconRole) : Asset
}

/**
 * The assets the Host registered, by id.
 *
 * The Host owns the lifetime: `RegisterAsset` puts one in, `ReleaseAsset` takes it out, and
 * an id that names neither is a reported protocol error rather than a guess or a crash.
 *
 * Reading is a map lookup and allocates nothing, so a frame that draws the same picture
 * again costs a lookup and the draw.
 */
class AssetCache {
    private val entries = mutableStateMapOf<Int, Asset>()

    fun asset(assetId: Int): Asset? = entries[assetId]

    /** Registered ids, for tests that check what the cache is holding. */
    internal val size: Int get() = entries.size

    /**
     * Reads the bytes and keeps the result. Returns the error to report, or null.
     *
     * `kindTag` is the raw wire tag: a kind this Renderer cannot read has to be reported
     * without stopping the rest of the batch.
     */
    internal fun register(assetId: Int, kindTag: Int, bytes: ByteArray): TableError? {
        val kind = AssetKind.entries.getOrNull(kindTag - 1)
            ?: return TableError(
                TableError.UNSUPPORTED_ASSET,
                "asset $assetId has kind tag $kindTag, which this renderer cannot read",
            )
        val asset = try {
            decode(kind, bytes)
        } catch (error: Throwable) {
            // A corrupt or truncated file is the Host's mistake and gets reported as one.
            // Letting the decoder's exception out would take the composition down with it.
            if (error is InterruptedException) throw error
            return TableError(
                TableError.UNREADABLE_ASSET,
                "asset $assetId could not be read as $kind: ${error.message ?: "unreadable"}",
            )
        }
        if (asset == null) {
            val reason = if (kind == AssetKind.VectorIcon) {
                "does not name one of the icons this renderer draws"
            } else {
                "could not be read as $kind by this platform's graphics"
            }
            return TableError(TableError.UNREADABLE_ASSET, "asset $assetId $reason")
        }
        entries[assetId] = asset
        return null
    }

    /**
     * Forgets every registration, for a Renderer that is throwing its tree away and asking
     * the Host to send the whole of it again. The ids in the new batch are the Host's to
     * assign from nothing, so keeping the old entries would leave pictures nobody names.
     */
    internal fun clear() = entries.clear()

    internal fun release(assetId: Int): TableError? {
        if (entries.remove(assetId) == null) {
            return TableError(
                TableError.UNKNOWN_ASSET,
                "ReleaseAsset for asset $assetId, which is not registered",
            )
        }
        return null
    }

    private fun decode(kind: AssetKind, bytes: ByteArray): Asset? = when (kind) {
        AssetKind.Png, AssetKind.Jpeg -> decodeRasterAsset(bytes)?.let(Asset::Raster)

        AssetKind.Svg -> decodeVectorAsset(bytes)?.let(Asset::Vector)

        AssetKind.Font -> decodeFontAsset(bytes)?.let(Asset::Font)

        // An image brush names a picture registered in its own right, so the lookup is
        // into what this cache is already holding. A brush registered before the picture
        // it names is unreadable and says so, rather than drawing nothing quietly.
        AssetKind.Brush -> decodeBrushAsset(bytes, entries::get)?.let(Asset::Brush)

        // A vector icon registers a meaning, so its bytes are the role tag and nothing
        // else. There is no artwork on the wire and no icon name to look up at run time.
        AssetKind.VectorIcon -> iconRole(bytes)?.let(Asset::Symbol)
    }

    private fun iconRole(bytes: ByteArray): IconRole? {
        if (bytes.size < 2) return null
        val tag = (bytes[0].toInt() and 0xFF) or ((bytes[1].toInt() and 0xFF) shl 8)
        return IconRole.entries.getOrNull(tag - 1)
    }
}