Skip to content

PatchyWeb.kt

src/jvmMain/kotlin/afield/app/server/PatchyWeb.kt · 169 lines

@file:afield.runtime.completion.CompletionCategory("Graphics")
package afield.app.server

import afield.data.table.div
import afield.data.table.get
import afield.data.table.minus
import afield.data.table.plus
import afield.data.table.rem
import afield.data.table.times
import afield.document.graph.box
import afield.runtime.completion.CompletionCategory
import afield.runtime.execution.plus
import afield.runtime.lifecycle.plus
import afield.runtime.lifecycle.rem
import afield.ui.notation.with

import afield.app.protocol.put
import afield.audio.synth.*
import afield.document.graph.Box
import afield.document.graph.registerStopHook
import afield.graphics.vulkan.*
import afield.media.stream.*
import afield.media.video.*
import afield.ui.output.displayOpen
import afield.ui.output.history
import afield.ui.output.outputRequests
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.*
import java.io.File
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue

Drive the browser build of the Patchy editor from box code: Box.patchyWeb shows Patchy in an output channel and returns a PatchyWebSession.

The session's suspend methods (PatchyWebSession.state, PatchyWebSession.script, PatchyWebSession.newDocument, PatchyWebSession.addLayer, PatchyWebSession.fillRect, PatchyWebSession.undo, PatchyWebSession.redo) send tool calls to the Patchy page and wait for its replies, so call them inside go { ... }.

fun Box.patchyWeb

Show the Field build of Patchy, with its visible canvas controlled by this box's Kotlin.

fun Box.patchyWeb(
    channel: String = "patchy", width: Int = 1200, height: Int = 800,
    documentFile: File? = null, autosave: Boolean = documentFile != null,
): PatchyWebSession {
    require(channel.matches(Regex("[A-Za-z0-9_-]{1,40}")))
    require(width in 400..2400 && height in 300..1800)
    require(!autosave || documentFile != null) { "autosave needs a documentFile" }
    val session = PatchyWebSession()
    val binding = outputRequests("patchy-web-$channel") { session.receive(it) }
    if (documentFile != null) PatchyWebStorage.register(binding, File(inside.filename), documentFile)
    registerStopHook(Runnable { session.close(); PatchyWebStorage.unregister(binding.id) })
    val storage = if (documentFile == null) "" else "&stored=1&autosave=${if (autosave) 1 else 0}"
    display(channel, """<iframe title="Patchy editor" src="/patchy-field.html?binding=${binding.id}$storage" style="border:0;display:block;background:#111;width:${width}px;height:${height}px"></iframe>""")
    displayOpen(channel)
    return session
}

class PatchyWebSession internal constructor() : AutoCloseable

One live browser Patchy session. Call suspend methods inside go { ... }.

class PatchyWebSession internal constructor() : AutoCloseable {
    private val outgoing = ConcurrentLinkedQueue<JsonObject>()
    private val pending = ConcurrentHashMap<String, CompletableDeferred<JsonObject>>()
    private var page: String? = null
    private var lastPoll = 0L
    @Volatile private var closed = false

@Volatile var connected

    @Volatile var connected = false
        private set

internal fun receive(request: JsonElement): JsonElement

body — 42 lines, from line 74
    internal fun receive(request: JsonElement): JsonElement {
        check(!closed) { "Patchy output is closed" }
        val data = request.jsonObject
        val source = data["page"]?.jsonPrimitive?.content ?: error("Missing Patchy page")
        val now = System.nanoTime()
        if (page != source && (page == null || now - lastPoll > 3_000_000_000L)) {
            page = source
            connected = false
        }
        if (page != source) return buildJsonObject { put("commands", JsonArray(emptyList())) }
        lastPoll = now
        connected = data["ready"]?.jsonPrimitive?.booleanOrNull == true
        (data["result"] as? JsonObject)?.let { result ->
            val id = result["id"]?.jsonPrimitive?.contentOrNull
            if (id != null) pending.remove(id)?.complete(result)
        }
        val next = if (connected) outgoing.poll() else null
        return buildJsonObject { put("commands", if (next == null) JsonArray(emptyList()) else JsonArray(listOf(next))) }
    }

    private suspend fun tool(name: String, arguments: JsonObject = buildJsonObject {}): JsonObject {
        check(!closed) { "Patchy output is closed" }
        val id = UUID.randomUUID().toString()
        val result = CompletableDeferred<JsonObject>()
        pending[id] = result
        outgoing.add(buildJsonObject {
            put("jsonrpc", "2.0"); put("id", id); put("method", "tools/call")
            put("params", buildJsonObject { put("name", name); put("arguments", arguments) })
        })
        try {
            val reply = withTimeout(90_000) { result.await() }
            reply["error"]?.let { error("Patchy $name: $it") }
            val body = reply["result"]?.jsonObject ?: error("Patchy $name returned no result")
            val content = body["structuredContent"]?.jsonObject ?: error("Patchy $name returned no data")
            if (body["isError"]?.jsonPrimitive?.booleanOrNull == true) {
                error("Patchy $name: ${content["message"] ?: content["error"] ?: content}")
            }
            return content
        } finally {
            pending.remove(id)
        }
    }

suspend fun state(): JsonObject = tool("get_state")

Read live documents, layer IDs and the edit token.

suspend fun script(code: String): JsonElement?

Run Patchy's documented JavaScript API in the visible editor.

    suspend fun script(code: String): JsonElement? {
        require(code.isNotBlank() && code.length <= 4 * 1024 * 1024)
        val token = state()["stateToken"]?.jsonPrimitive?.content ?: error("Patchy returned no state token")
        return tool("execute_script", buildJsonObject {
            put("code", code); put("name", "Field Kotlin"); put("expectedState", token)
        })["result"]
    }

suspend fun newDocument(width: Int, height: Int): String

Create a document in the visible editor and return its stable ID.

    suspend fun newDocument(width: Int, height: Int): String {
        require(width in 1..32768 && height in 1..32768)
        return script("patchy.setResult(app.newDocument($width, $height).id);")?.jsonPrimitive?.content
            ?: error("Patchy returned no document ID")
    }

suspend fun addLayer(documentId: String, name: String): String

Add a paint layer to an open document.

    suspend fun addLayer(documentId: String, name: String): String =
        script("patchy.setResult(app.getDocument(${Json.encodeToString(documentId)})." +
            "addLayer(${Json.encodeToString(name)}).id);")?.jsonPrimitive?.content
            ?: error("Patchy returned no layer ID")

suspend fun fillRect(documentId: String, layerId: String, x: Int, y: Int, width: Int, height: Int, color: String)

Paint a solid rectangle through Patchy's layer API.

    suspend fun fillRect(documentId: String, layerId: String, x: Int, y: Int, width: Int, height: Int, color: String) {
        require(width > 0 && height > 0)
        script("app.getDocument(${Json.encodeToString(documentId)})." +
            "getLayer(${Json.encodeToString(layerId)}).fillRect($x, $y, $width, $height, ${Json.encodeToString(color)});")
    }

suspend fun undo(documentId: String): Boolean = history("undo", documentId)

Undo the last edit in a live document.

suspend fun redo(documentId: String): Boolean = history("redo", documentId)

Redo the last undone edit in a live document.

    private suspend fun history(name: String, documentId: String): Boolean {
        val token = state()["stateToken"]?.jsonPrimitive?.content ?: error("Patchy returned no state token")
        val reply = tool(name, buildJsonObject { put("documentId", documentId); put("expectedState", token) })
        return reply["changed"]?.jsonPrimitive?.booleanOrNull == true
    }

override fun close()

    override fun close() {
        closed = true
        connected = false
        outgoing.clear()
        for (waiting in pending.values) waiting.cancel()
        pending.clear()
    }
}