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.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import dev.darkpyonix.composerust.design.HostPlatform
import dev.darkpyonix.composerust.design.ResolvedTheme
import dev.darkpyonix.composerust.foundation.code.CodeEditorCallbacks
import dev.darkpyonix.composerust.foundation.code.CodeEditorLook
import dev.darkpyonix.composerust.foundation.code.CodeEditorModel
import dev.darkpyonix.composerust.foundation.code.CodeEditorSurface
import dev.darkpyonix.composerust.foundation.code.EditorOutput
import dev.darkpyonix.composerust.protocol.HostEvent
import dev.darkpyonix.composerust.protocol.HoverPhase
import dev.darkpyonix.composerust.protocol.PropertyKind
import dev.darkpyonix.composerust.protocol.PropertyValue
import dev.darkpyonix.composerust.protocol.TypeRole
import dev.darkpyonix.composerust.runtime.EventDispatcher
import dev.darkpyonix.composerust.ui.node.Node
import dev.darkpyonix.composerust.ui.textStyle

/**
 * A code editor bound to its node.
 *
 * The document, the selection, the undo history and the decorations live in the node's
 * [CodeEditorModel], which the batches the Host sends feed and the surface edits. This only
 * resolves how the editor looks from the design system and carries what the model and the
 * surface have to say to the Host: each committed change, an edit refused, a lens or a gutter
 * mark pressed or a suggestion accepted, a pointer at rest, the save shortcut, and what the
 * Host listens to about the editor itself: the selection, the lines on screen, focus, folds
 * and whether an input method is composing.
 */
@Composable
internal fun HostCodeEditor(
    node: Node,
    modifier: Modifier,
    dispatcher: EventDispatcher,
    theme: ResolvedTheme,
) {
    val model = node.code ?: return
    val tabWidth = (node.property(PropertyKind.TabWidth) as? PropertyValue.Integer)?.value
        ?.toInt()
        ?.takeIf { it > 0 }
    val look = remember(theme, tabWidth) { codeEditorLook(node, theme, tabWidth) }
    val currentDispatcher by rememberUpdatedState(dispatcher)
    val callbacks = remember(node, model) {
        CodeEditorCallbacks(
            onOutput = { node.sendCodeOutput(model.drainOutput(includeReports = true), currentDispatcher) },
            onSave = {
                val handler = node.handler(PropertyKind.OnSave)
                if (handler != null) {
                    currentDispatcher.dispatch(
                        HostEvent.CodeSaveRequested(node.id, handler, model.document.version),
                    )
                }
            },
            onHover = { decoration, position, rest ->
                val handler = node.handler(PropertyKind.OnHover)
                if (handler != null) {
                    currentDispatcher.dispatch(
                        HostEvent.CodeHovered(
                            node.id,
                            handler,
                            decoration,
                            position.line,
                            position.column,
                            if (rest) HoverPhase.Rest else HoverPhase.Leave,
                        ),
                    )
                }
            },
        )
    }
    CodeEditorSurface(model, look, callbacks, modifier)
}

/** What the design system says an editor looks like here, resolved to plain values. */
internal fun codeEditorLook(node: Node, theme: ResolvedTheme, tabWidth: Int?): CodeEditorLook {
    val style = theme.rules.codeEditor(theme)
    return CodeEditorLook(
        textStyle = node.textStyle(theme, TypeRole.Mono).copy(color = style.text),
        container = style.container,
        gutter = style.gutter,
        lineNumber = style.lineNumber,
        currentLineNumber = style.currentLineNumber,
        currentLine = style.currentLine,
        currentLineBorder = style.currentLineBorder,
        gutterDivider = style.gutterDivider,
        gutterDividerWidth = style.gutterDividerWidth,
        gutterPadding = style.gutterPadding,
        textInset = style.textInset,
        selection = style.selection,
        cursor = style.cursor,
        underlineWidth = style.underlineWidth,
        underline = { decoration ->
            style.underlineColor(decoration.severity, decoration.color, theme) to
                style.underlineShape(decoration.severity)
        },
        paint = { paint -> theme.color(paint) },
        ghostTextAlpha = style.ghostTextAlpha,
        lens = style.lens,
        tabWidth = tabWidth ?: style.tabWidth,
        hoverDelayMillis = style.hoverDelayMillis,
        commandIsMeta = theme.platform == HostPlatform.MacOs || theme.platform == HostPlatform.Ios,
        // A mark takes the role the Host named, and otherwise the lens's quiet ink.
        gutterMark = { role -> if (role == null) style.lens else theme.color(role) },
        foldMarker = style.lineNumber,
    )
}

/**
 * Turns what an editor's model queued into events for the Host, in the order it was queued.
 *
 * An event goes only where the application listens: a change with no `on_change` handler
 * has nobody to tell. A record the Host sent that could not be honoured is reported through
 * [onError] instead, so it becomes a protocol error.
 */
internal fun Node.codeEvents(outputs: List<EditorOutput>, onError: (String) -> Unit): List<HostEvent> {
    if (outputs.isEmpty()) return emptyList()
    val events = ArrayList<HostEvent>(outputs.size)
    for (output in outputs) {
        when (output) {
            is EditorOutput.Changed -> {
                val handler = handler(PropertyKind.OnValueChange) ?: continue
                val change = output.change
                events += HostEvent.CodeChanged(
                    id,
                    handler,
                    change.version,
                    change.start.line,
                    change.start.column,
                    change.end.line,
                    change.end.column,
                    change.text,
                )
            }
            is EditorOutput.Rejected -> {
                val handler = handler(PropertyKind.OnEditRejected) ?: continue
                events += HostEvent.CodeEditRejected(
                    id,
                    handler,
                    output.requestId,
                    output.baseVersion,
                    output.currentVersion,
                    output.start.line,
                    output.start.column,
                    output.end.line,
                    output.end.column,
                )
            }
            is EditorOutput.Activated -> {
                val handler = handler(PropertyKind.OnDecorationClick) ?: continue
                events += HostEvent.DecorationActivated(id, handler, output.id)
            }
            is EditorOutput.SelectionMoved -> {
                val handler = handler(PropertyKind.OnSelectionChange) ?: continue
                val selection = output.selection
                events += HostEvent.SelectionChanged(
                    id,
                    handler,
                    output.version,
                    selection.anchor.line,
                    selection.anchor.column,
                    selection.caret.line,
                    selection.caret.column,
                )
            }
            is EditorOutput.VisibleRange -> {
                val handler = handler(PropertyKind.OnVisibleRangeChange) ?: continue
                events += HostEvent.VisibleRangeChanged(id, handler, output.version, output.first, output.last)
            }
            is EditorOutput.Focus -> {
                val handler = handler(PropertyKind.OnEditorFocusChange) ?: continue
                events += HostEvent.EditorFocusChanged(id, handler, output.focused)
            }
            is EditorOutput.FoldToggled -> {
                val handler = handler(PropertyKind.OnFoldChange) ?: continue
                events += HostEvent.FoldChanged(id, handler, output.startLine, output.collapsed)
            }
            is EditorOutput.Composing -> {
                val handler = handler(PropertyKind.OnCompositionChange) ?: continue
                events += HostEvent.CompositionChanged(id, handler, output.composing)
            }
            is EditorOutput.Error -> onError(output.message)
        }
    }
    return events
}

/** Sends what the surface made the model queue, straight away, on the UI thread. */
private fun Node.sendCodeOutput(outputs: List<EditorOutput>, dispatcher: EventDispatcher) {
    val events = codeEvents(outputs) { message ->
        dispatcher.dispatch(
            HostEvent.ProtocolError(
                nodeId = 0,
                handlerId = 0,
                code = CODE_EDITOR_ERROR,
                message = "code editor $id: $message",
            ),
        )
    }
    for (event in events) dispatcher.dispatch(event)
}

/** The protocol error code a code editor reports a record it could not honour with. */
internal const val CODE_EDITOR_ERROR = 9