compose-rust 0.0.4

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

import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFontFamilyResolver
import androidx.compose.ui.text.LinkAnnotation
import dev.darkpyonix.composerust.design.ResolvedTheme
import dev.darkpyonix.composerust.protocol.HostEvent
import dev.darkpyonix.composerust.protocol.Paint
import dev.darkpyonix.composerust.protocol.SpanRecords
import dev.darkpyonix.composerust.protocol.TypeRole
import dev.darkpyonix.composerust.runtime.EventDispatcher
import dev.darkpyonix.composerust.ui.node.AssetCache
import dev.darkpyonix.composerust.ui.node.Node
import dev.darkpyonix.composerust.ui.node.TableError

/**
 * One run of different treatment inside a string.
 *
 * Offsets are in bytes of the UTF-8 the Host measured, because that is what the string is
 * measured in on that side. They are turned into character offsets here, where the string
 * is a sequence of characters, and a run that does not land on a character boundary is a
 * malformed run rather than a run that draws oddly.
 */
internal data class TextRun(
    val start: Int,
    val length: Int,
    val role: TypeRole?,
    val color: Paint?,
    val bold: Boolean,
    val italic: Boolean,
    val underline: Boolean,
    val strikethrough: Boolean,
    val handlerId: Long,
    /**
     * What is painted behind this run's letters, or null for nothing. A wrapped run is
     * painted on each line it reaches, and nowhere between its letters and the next run's.
     */
    val background: Paint? = null,
)

/**
 * Reads the blob the Host sent. A blob that is not a whole number of records is refused.
 *
 * The record layout is the generated one, so the byte offsets are the Host's and nobody
 * counts them by hand on this side.
 */
internal fun decodeRuns(bytes: ByteArray): List<TextRun>? =
    SpanRecords.decode(bytes)?.map { record ->
        TextRun(
            start = record.start,
            length = record.length,
            role = record.typeRole,
            color = record.color,
            bold = record.bold,
            italic = record.italic,
            underline = record.underline,
            strikethrough = record.strikethrough,
            handlerId = record.handlerId,
            background = record.background,
        )
    }

/**
 * The colour painted behind a run, or Unspecified for none.
 *
 * A brush has no flat colour to give a run of letters, so it stands for the colour it
 * starts from, the way it does wherever only a colour can be taken.
 */
internal fun runBackground(run: TextRun, theme: ResolvedTheme): androidx.compose.ui.graphics.Color =
    run.background?.let(theme::color) ?: androidx.compose.ui.graphics.Color.Unspecified

/**
 * What is wrong with a set of runs, or null when nothing is.
 *
 * Checked rather than trusted, and reported rather than thrown: a run reaching past the
 * end of the string is a Host that miscounted, and that must not take the window down
 * with it.
 */
internal fun runsProblem(runs: List<TextRun>, byteLength: Int): String? {
    var previousEnd = 0
    for (run in runs) {
        if (run.length <= 0) {
            return "a run of ${run.length} bytes at ${run.start} covers nothing"
        }
        if (run.start < previousEnd) {
            return "a run at ${run.start} starts inside the one before it, which ended at $previousEnd"
        }
        if (run.start + run.length > byteLength) {
            return "a run covering bytes ${run.start} to ${run.start + run.length} reaches past a string of $byteLength"
        }
        previousEnd = run.start + run.length
    }
    return null
}

/**
 * A string whose runs are drawn differently, and whose link runs report a press.
 *
 * One widget rather than three, because a paragraph with a bold phrase in it is one piece
 * of text: split into pieces it would wrap at the seams, and the phrase would never share
 * a line with the words around it.
 *
 * How it is set is decided by [resolveText], the same function a measure request is
 * answered with, so the size the Host was told is the size that is drawn.
 */
@Composable
internal fun HostRichText(
    node: Node,
    modifier: Modifier,
    dispatcher: EventDispatcher,
    theme: ResolvedTheme,
    assets: AssetCache,
    /** The colour an animation shows in the draw phase, or the style's own where none does. */
    color: androidx.compose.ui.graphics.ColorProducer? = null,
) {
    val input = node.textInput()
    val density = textDensity(input, LocalDensity.current)
    val resolved = resolveText(
        input,
        theme,
        assets,
        density,
        LocalFontFamilyResolver.current,
        link = { run ->
            // A link is a press on a range, and a press is the event the boundary already
            // has. No new event tag for a thing that is already a click.
            LinkAnnotation.Clickable(
                tag = "compose-link-${run.start}",
                linkInteractionListener = {
                    dispatcher.dispatch(HostEvent.Clicked(node.id, run.handlerId))
                },
            )
        },
    )
    val runs = node.runsDecodeProblem() ?: resolved.runsProblem
    val fonts = node.fontListProblem() ?: resolved.problems.firstOrNull()
    val problem = when {
        runs != null -> "text runs on node ${node.id}: $runs; the string is drawn without them"
        fonts != null -> "text fonts on node ${node.id}: $fonts"
        else -> null
    }
    if (problem != null) ReportRuns(node.id, problem, dispatcher)
    // Text in CSS pixels is laid out in a density whose font scale is one, so the system's
    // text size does not reach it. Everything else is laid out where it stands.
    CompositionLocalProvider(LocalDensity provides density) {
        BasicText(
            text = resolved.text,
            modifier = modifier,
            style = resolved.style,
            overflow = resolved.overflow,
            softWrap = resolved.softWrap,
            maxLines = resolved.maxLines,
            inlineContent = resolved.inlineContent,
            color = color,
        )
    }
}

/**
 * Says a set of runs is wrong, and goes on drawing the string without them.
 *
 * The report goes to the Host as a `ProtocolError`, like every other protocol error, so the
 * application that miscounted can find out; it never ends the process, because the reader
 * still wants to read the paragraph. Once per problem, after composition and on the thread
 * composition runs on, which is the thread the Host was started on.
 */
@Composable
private fun ReportRuns(nodeId: Int, problem: String, dispatcher: EventDispatcher) {
    val said = remember(nodeId) { arrayOfNulls<String>(1) }
    SideEffect {
        if (said[0] == problem) return@SideEffect
        said[0] = problem
        dispatcher.dispatch(
            HostEvent.ProtocolError(
                nodeId = nodeId,
                handlerId = 0,
                code = TableError.UNSUPPORTED_PROPERTY,
                message = problem,
            ),
        )
    }
}