Logo FS-Skia-UI

Elmish / MVU Runtime

The FS.Skia.UI.Elmish package is the thin bridge between a standard Elmish Model-View-Update program and the Skia/Vulkan viewer host. It does not reimplement the Elmish loop; it wraps your own model/msg together with the viewer's state so a single update folds both your application logic and the rendered scene forward, and it adds one interpreter-edge helper — the animation tick — for time-driven redraws. This page explains the runtime for a newcomer: the public types, how a message flows from dispatch through update to effects and back to a re-rendered scene, and where the boundaries between pure logic and host side effects sit.

This is a small package by design. The two source files are Elmish.fs (the adapter) and AnimationTick.fs (the tick subscription). The adapter contract is published in Elmish.fsi.

Where this sits

The Elmish runtime is one part of a layered framework. Below it the scene layer supplies the immutable drawing vocabulary, the layout engine resolves geometry, and input turns host events into messages. The controls suite composes over all three. The Elmish package is the seam that lets a pure MVU program drive the host without the program ever touching Vulkan, Skia, or window events directly.

The adapter contract

The whole adapter is three types and two functions. The model and message types wrap your own:

type ElmishAdapterModel<'model> =
    { UserModel: 'model
      Scene: SceneNode
      Viewer: ViewerModel }

type ElmishAdapterMsg<'msg> =
    | UserMsg of 'msg
    | ViewerMsg of ViewerMsg

type ElmishAdapterEffect<'msg> =
    | DispatchUser of 'msg
    | DispatchViewer of ViewerEffect

The module ElmishAdapter holds the two functions. init builds the combined model from ViewerOptions, your initial user model, and an initial scene, returning the model plus the viewer's startup effects:

val init:
    viewerOptions: ViewerOptions ->
    userModel: 'model ->
    scene: SceneNode ->
        ElmishAdapterModel<'model> * ElmishAdapterEffect<'msg> list

update folds one envelope into the model, using a supplied render: 'model -> SceneNode to refresh the scene:

val update:
    render: ('model -> SceneNode) ->
    msg: ElmishAdapterMsg<'msg> ->
    model: ElmishAdapterModel<'model> ->
        ElmishAdapterModel<'model> * ElmishAdapterEffect<'msg> list

How a message flows

The control flow is deliberately literal. The entire update body is:

let update render msg model =
    match msg with
    | UserMsg userMsg -> model, [ DispatchUser userMsg ]
    | ViewerMsg viewerMsg ->
        let viewer, effects = Viewer.update viewerMsg model.Viewer
        let next = { model with Viewer = viewer; Scene = render model.UserModel }
        next, (effects |> List.map DispatchViewer)

Two distinct paths follow from this:

  1. A user message (UserMsg) is not interpreted by the adapter. The adapter leaves the model untouched and emits a single DispatchUser userMsg effect. That effect re-enters the host's dispatch loop, where your own update is responsible for advancing UserModel. In other words the adapter does not own your reducer — it forwards your message and stays out of the way.
  2. A viewer message (ViewerMsg) is delegated to Viewer.update, which advances the ViewerModel and returns ViewerEffects. Crucially, the adapter re-renders here: it rebuilds Scene by calling render model.UserModel, so the displayed scene tracks the latest user model on every viewer turn. The viewer effects are wrapped as DispatchViewer and handed back for the host to interpret.

So the round trip is: a host event becomes a ViewerMsg; update advances the viewer and rebuilds the scene from the user model; the new scene lives in the adapter model; the viewer effects flow to the host interpreter, which draws the frame. This mirrors the broader viewer program contract documented in Runtime Design, where applications own Model/Msg/init/update/view and the viewer owns the interpreter for ViewerEffect. The adapter is the glue that keeps your render function in lockstep with viewer turns without your code calling the renderer.

Effects and the boundary

The adapter never performs a side effect itself. It only emits descriptions — DispatchUser and DispatchViewer — that the host loop interprets. This keeps the package pure and testable: init and update are ordinary functions over immutable records, with no GPU surface, window, or timer involved. The actual Vulkan/Skia work, screenshot capture, and shutdown live behind ViewerEffect in the host (see Runtime Design for the event→effect→interpreter pipeline). The configuration of the governance tooling that decides which gates validate such changes is itself compiled F# rather than runtime-parsed data, per ADR 0005; the same "describe, don't perform" discipline shows up in the adapter's effect lists.

The animation tick

The one moving part beyond the adapter is the animation tick in AnimationTick.fs (feature 073). It is the only interpreter-edge component of the animation slice: it advances time by emitting frame-delta messages, and it gates redraws so that the host stops requesting frames once the UI settles.

type AnimationTick = AnimationTick of TimeSpan

module Animation =
    val tickSubscription:
        isAnimating: ('model -> bool) ->
        toMsg: (TimeSpan -> 'msg) ->
        interval: TimeSpan ->
        model: 'model ->
            Sub<'msg>

Animation.tickSubscription is shaped to plug directly into Elmish's Program.withSubscription. Its behaviour:

The tick is additive: it is a subscription the author opts into, and the carried delta flows through the same update/effect path as any other message. Nothing about animation special-cases the adapter.

Putting it together

A minimal usage (from the package README) shows the shape end to end:

open FS.Skia.UI.Scene
open FS.Skia.UI.SkiaViewer
open FS.Skia.UI.Elmish

type Model = { Count: int }
type Msg = Increment

let render (model: Model) : SceneNode =
    Text((20.0, 40.0), $"Count: {model.Count}", Colors.black)

let options = { Title = "Counter"; InitialSize = { Width = 640; Height = 480 } }

let initial, effects =
    ElmishAdapter.init options { Count = 0 } (render { Count = 0 })

let next, _ =
    ElmishAdapter.update render (UserMsg Increment) initial

For controls-aware authoring there is a parallel, richer adapter in FS.Skia.UI.Controls.Elmish that lowers control runtime, keyboard, and pointer effects into Elmish commands — see the controls suite page.

Related pages

Analysis

Implementation strengths

Implementation weaknesses

Design pros

Design cons

type ElmishAdapterModel<'model> = { UserModel: 'model Scene: obj Viewer: obj }
'model
type ElmishAdapterMsg<'msg> = | UserMsg of 'msg | ViewerMsg of obj
'msg
type ElmishAdapterEffect<'msg> = | DispatchUser of 'msg | DispatchViewer of obj
type 'T list = List<'T>
union case ElmishAdapterMsg.UserMsg: 'msg -> ElmishAdapterMsg<'msg>
union case ElmishAdapterMsg.ViewerMsg: obj -> ElmishAdapterMsg<'msg>
Multiple items
module List from Microsoft.FSharp.Collections

--------------------
type List<'T> = | op_Nil | op_ColonColon of Head: 'T * Tail: 'T list interface IReadOnlyList<'T> interface IReadOnlyCollection<'T> interface IEnumerable interface IEnumerable<'T> member GetReverseIndex: rank: int * offset: int -> int member GetSlice: startIndex: int option * endIndex: int option -> 'T list static member Cons: head: 'T * tail: 'T list -> 'T list member Head: 'T member IsEmpty: bool member Item: index: int -> 'T with get ...
val map: mapping: ('T -> 'U) -> list: 'T list -> 'U list
type bool = System.Boolean
Multiple items
val int: value: 'T -> int (requires member op_Explicit)

--------------------
type int = int32

--------------------
type int<'Measure> = int

Type something to start searching.