compose-rust 0.0.1

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

import dev.darkpyonix.composerust.protocol.HostEvent
import dev.darkpyonix.composerust.protocol.Mutation
import dev.darkpyonix.composerust.protocol.NotificationImportance
import dev.darkpyonix.composerust.protocol.NotificationPermission
import dev.darkpyonix.composerust.protocol.NotificationPresentation
import dev.darkpyonix.composerust.runtime.EventDispatcher
import kotlinx.coroutines.channels.Channel

/**
 * One notification, as the platform is asked to show it.
 *
 * The key is never empty here: a notification the Host left unnamed has been given a name
 * of this side's own by the time a platform sees it, so every platform can treat the key as
 * the identity that replacing and withdrawing go by.
 *
 * An empty action label is no button. Action 1 and action 2 keep their numbers whatever is
 * on either side of them, because the number is what comes back when one is pressed.
 */
data class PlatformNotification(
    val key: String,
    val title: String,
    val body: String,
    val channel: String,
    val action1: String,
    val action2: String,
    val importance: NotificationImportance,
)

/**
 * Where a platform tells the interpreter what happened. Safe from any thread.
 *
 * A notification centre answers on threads of its own choosing: a permission prompt
 * completes on a background queue, a toast is activated on a COM thread, a D-Bus signal
 * arrives on a reader. None of those is the thread the Host lives on, so nothing reported
 * here reaches the Host directly. It waits, a frame is asked for, and the frame delivers
 * it on the UI thread.
 */
interface NotificationReports {
    /** What the platform now says about permission. Repeats are fine; only changes travel. */
    fun permission(state: NotificationPermission)

    /**
     * The user pressed a notification: the body for action 0, a button for 1 or 2.
     *
     * Call it once per press. The platform has already done what a press on the body asks
     * of it, which is to bring the application's window to the front; a press on a button
     * brings nothing up, because a button is there to be dealt with without opening the
     * window.
     */
    fun activated(key: String, action: Int)
}

/**
 * One platform's notification centre.
 *
 * Every call is made on the UI thread. Answers go back through the [NotificationReports]
 * handed to [start], from whichever thread the platform answers on.
 */
interface NotificationPlatform {
    /**
     * Called once, before anything else. The platform keeps [reports] and says through it
     * what the permission is, now or when it finds out.
     */
    fun start(reports: NotificationReports)

    /** Shows the platform's own permission request. The answer goes through the reports. */
    fun requestPermission()

    /** Shows [notification], replacing any still showing under the same key. */
    fun post(notification: PlatformNotification)

    /** Takes back the notification showing under [key], if there is one. */
    fun withdraw(key: String)

    /**
     * Asks again what the permission is.
     *
     * The user can change it in the system's settings while the window is somewhere else,
     * and coming back to the window is when that is looked for.
     */
    fun refreshPermission()

    /**
     * The process is ending.
     *
     * A desktop takes back what it posted: a notification left behind would point at work
     * the next process does not know about, and pressing it would start that process for
     * nothing. A phone leaves them, because a phone is where pressing one is how the
     * application starts.
     */
    fun processEnding()

    /**
     * Collects what the platform has queued on its own side and reports it.
     *
     * Called on the UI thread at the top of every delivery, and on every turn of a loop
     * that has no other way to hear from the platform. A platform whose answers already
     * arrive through the reports leaves it alone; one that keeps them in a C queue, or
     * reads them off a socket, reports them from here.
     */
    fun pump() {}
}

/**
 * A platform with no way to show a notification, or a run that cannot: a development shell
 * with no bundle identifier, a Windows executable with no AppUserModelID, a Linux session
 * with no notification daemon.
 *
 * Every request is accepted and nothing happens. That is not a protocol error, because a
 * platform without the feature is not a malformed batch, and the process carries on.
 */
object UnsupportedNotifications : NotificationPlatform {
    override fun start(reports: NotificationReports) =
        reports.permission(NotificationPermission.Unsupported)

    override fun requestPermission() = Unit

    override fun post(notification: PlatformNotification) = Unit

    override fun withdraw(key: String) = Unit

    override fun refreshPermission() = Unit

    override fun processEnding() = Unit
}

/**
 * The notification centre this process uses.
 *
 * Installed by each platform's entry point before the Host starts, the way the frame
 * request source is: there is one process, one window and one Host, and a platform knows
 * which centre it has before it has anything to show. A run nobody installed one for has
 * none, and says so.
 */
object Notifications {
    var platform: NotificationPlatform = UnsupportedNotifications
}

/**
 * What the interpreter does with the notification records, and the one route the platform's
 * answers take back to the Host.
 *
 * The decisions that belong to whoever has the window are made here rather than by the Host:
 * whether the window is the active one, whether permission has been asked for yet, what a
 * notification nobody named is called. The platforms underneath only show, take back and
 * report.
 */
class NotificationCenter(
    private val platform: NotificationPlatform = Notifications.platform,
) {
    private sealed interface Report {
        data class Permission(val state: NotificationPermission) : Report
        data class Activated(val key: String, val action: Int) : Report
    }

    /**
     * What the platform has said and the Host has not yet been told.
     *
     * A channel because it is the one queue every target this compiles for can share across
     * threads without a lock of its own, and unlimited because what goes in is a handful of
     * presses and answers, never a stream.
     */
    private val inbox = Channel<Report>(Channel.UNLIMITED)

    /**
     * How a report asks for the frame that delivers it.
     *
     * The frame loop installs its own request; until it has, a report waits for the next
     * thing that drains, which the Host's start does.
     */
    var wake: () -> Unit = {}

    private val reports = object : NotificationReports {
        override fun permission(state: NotificationPermission) {
            inbox.trySend(Report.Permission(state))
            wake()
        }

        override fun activated(key: String, action: Int) {
            inbox.trySend(Report.Activated(key, action))
            wake()
        }
    }

    private var started = false
    private var dispatcher: EventDispatcher? = null

    /** What the platform last said. */
    var permission: NotificationPermission = NotificationPermission.NotDetermined
        private set

    /** What the Host was last told. Both sides start from not determined. */
    private var reported = NotificationPermission.NotDetermined

    /** Whether the platform's prompt is up. One at a time, and never again after a no. */
    private var asking = false

    /** Posted while permission was being asked for, shown if the answer is yes. */
    private val waiting = mutableListOf<PlatformNotification>()

    /** Whether the application's window is the active one. */
    var windowActive: Boolean = false
        private set

    private var unnamed = 0L

    /** Presses that arrived before there was a Host to hand them to. */
    private val early = mutableListOf<Report.Activated>()

    private fun ensureStarted() {
        if (started) return
        started = true
        platform.start(reports)
    }

    /**
     * Connects the centre to the Host, once the Host's first batch has been applied.
     *
     * A press that started the application, which is how a phone launches one from its
     * notification centre, has been waiting for this: there was no Host to send it to. It is
     * the first thing the Host hears after its first batch, before anything the window
     * reports about itself.
     */
    fun attach(host: EventDispatcher) {
        dispatcher = host
        ensureStarted()
        drain()
    }

    /** The process is ending. */
    fun shutdown() {
        dispatcher = null
        if (started) platform.processEnding()
    }

    /** The interpreter's half of the three notification records. */
    fun apply(mutation: Mutation.PostNotification) {
        ensureStarted()
        // Dropped rather than shown when the window is the active one and the application
        // asked for that. The user is looking at the window, and calling them from outside
        // it to say what is already in front of them is noise.
        if (mutation.presentation == NotificationPresentation.WhenInactive && windowActive) return
        val notification = PlatformNotification(
            key = mutation.key.ifEmpty { "compose-rust/${++unnamed}" },
            title = mutation.title,
            body = mutation.body,
            channel = mutation.channel,
            action1 = mutation.action1,
            action2 = mutation.action2,
            importance = mutation.importance,
        )
        when (permission) {
            NotificationPermission.Granted -> platform.post(notification)
            // The first post is when permission is asked for: asking the moment the
            // application starts is asking before the user knows what for, which is when a
            // no is likeliest. The notification waits for the answer.
            NotificationPermission.NotDetermined -> {
                waiting.removeAll { it.key == notification.key }
                waiting += notification
                ask()
            }
            // A no is not asked again, and a run that cannot show one does nothing. Neither
            // is an error. Nor does a message appear in the window instead: the moment a
            // notification is for is the moment nobody is looking at the window.
            NotificationPermission.Denied, NotificationPermission.Unsupported -> Unit
        }
    }

    fun withdraw(key: String) {
        ensureStarted()
        waiting.removeAll { it.key == key }
        if (permission == NotificationPermission.Granted) platform.withdraw(key)
    }

    /**
     * Asks for permission because the application asked, for instance from a settings
     * switch the user just turned on.
     *
     * Applied inside the call that carried it, which is inside the call that handled the
     * press, so a browser still counts it as something the user did.
     */
    fun requestPermission() {
        ensureStarted()
        if (permission == NotificationPermission.NotDetermined) ask()
    }

    private fun ask() {
        if (asking) return
        asking = true
        platform.requestPermission()
    }

    /**
     * Whether the window is the one in use, as the composition last saw it.
     *
     * Becoming active is also when the permission is looked at again, because the user may
     * have changed it in the system's settings while the window was not.
     */
    fun windowFocusChanged(active: Boolean) {
        val becameActive = active && !windowActive
        windowActive = active
        if (becameActive && started) platform.refreshPermission()
    }

    /** One turn of a loop that hears the platform by asking it. */
    fun pump() {
        if (started) platform.pump()
    }

    /**
     * Hands everything the platform reported to the Host, on the UI thread.
     *
     * Called where the Host can be entered: after its start, and at the top of every frame
     * that a report asked for.
     */
    fun drain() {
        // A platform that queues its answers on its own side is asked for them first, so a
        // frame its own wake asked for delivers what it was asked for.
        if (started) platform.pump()
        dispatcher?.let { host ->
            if (early.isNotEmpty()) {
                val held = early.toList()
                early.clear()
                held.forEach { activate(host, it) }
            }
        }
        while (true) {
            val report = inbox.tryReceive().getOrNull() ?: break
            when (report) {
                is Report.Permission -> answered(report.state)
                is Report.Activated -> {
                    val host = dispatcher
                    if (host == null) early += report else activate(host, report)
                }
            }
        }
        val host = dispatcher ?: return
        if (reported != permission) {
            reported = permission
            host.dispatch(
                HostEvent.NotificationPermissionChanged(nodeId = 0, handlerId = 0, state = permission),
            )
        }
    }

    private fun answered(state: NotificationPermission) {
        permission = state
        if (state != NotificationPermission.NotDetermined) asking = false
        when (state) {
            NotificationPermission.Granted -> {
                val held = waiting.toList()
                waiting.clear()
                held.forEach(platform::post)
            }
            NotificationPermission.Denied, NotificationPermission.Unsupported -> waiting.clear()
            NotificationPermission.NotDetermined -> Unit
        }
    }

    private fun activate(host: EventDispatcher, report: Report.Activated) {
        host.dispatch(
            HostEvent.NotificationActivated(
                nodeId = 0,
                handlerId = 0,
                action = report.action,
                key = report.key,
            ),
        )
    }
}