Logo FS-Skia-UI

Typed controls & the MVU front door

This literate script is evaluated at documentation-build time (fsdocs build --eval), so the code here is compiler-verified against the real FS.Skia.UI.Controls API — if it stopped compiling, the docs build would surface it.

It shows the typed Props/MVU front door under FS.Skia.UI.Controls.Typed: you author an immutable Props record per control, compose Widget<'msg> values, and the front door lowers them to the same legacy Control<'msg> IR the framework already renders. The lowering is the single seam — everything below the seam is unchanged.

We reference the locally built assemblies (the same ones the API reference is generated from). FS.Skia.UI.Scene / FS.Skia.UI.Layout are copied next to FS.Skia.UI.Controls in its build output, so one include path covers all three.

#I "../../src/Controls/bin/Debug/net10.0"
#r "FS.Skia.UI.Scene.dll"
#r "FS.Skia.UI.Layout.dll"
#r "FS.Skia.UI.Controls.dll"

open FS.Skia.UI.Controls
open FS.Skia.UI.Controls.Typed

The MVU pieces

A control's interactions are expressed as your own Msg type — the typed front door carries 'msg end to end. OnClick/OnChanged are 'msg option, so omitting a handler lowers to no event binding rather than a default message.

type Msg =
    | Increment
    | ToggleDone of bool

type Model = { Count: int; Done: bool }

let init = { Count = 0; Done = false }

/// A pure `update` — no I/O, no mutation (Principle IV).
let update (msg: Msg) (model: Model) : Model =
    match msg with
    | Increment -> { model with Count = model.Count + 1 }
    | ToggleDone v -> { model with Done = v }

Authoring with typed Props

Each control exposes a defaults record and a view. You override only the fields you care about with F#'s record-with syntax — the compiler checks every field name and type. Composing children in a Stack unifies the 'msg type across the whole tree.

let view (model: Model) : Widget<Msg> =
    Stack.view
        { Stack.defaults with
            Orientation = Vertical
            Spacing = 8.0
            Children =
                [ TextBlock.view { TextBlock.defaults with Text = $"Count: {model.Count}" }
                  // OnClick = Some -> a real event binding to your Msg.
                  Button.view
                      { Button.defaults with
                          Text = "+1"
                          Intent = Primary
                          OnClick = Some Increment }
                  CheckBox.view
                      { CheckBox.defaults with
                          Text = "Done"
                          Checked = model.Done
                          OnChanged = Some ToggleDone }
                  // OnClick = None -> lowers to NO binding, not a default message.
                  Button.view { Button.defaults with Text = "(disabled)"; Enabled = false } ] }

Lowering to the legacy IR

Widget.toControl is the explicit seam to the existing Control<'msg> record that the renderer and the Elmish adapter consume. We can inspect it directly — it is a plain immutable record (Kind, Children, Content, Attributes), so no rendering / GPU is involved.

let lowered : Control<Msg> = Widget.toControl (view init)

printfn "root kind     : %s" lowered.Kind
printfn "child count   : %d" (List.length lowered.Children)

lowered.Children
|> List.iteri (fun i child ->
    printfn "  child %d -> kind=%s content=%A attrs=%d" i child.Kind child.Content (List.length child.Attributes))

The +1 button lowers with an extra event-binding attribute, while the disabled button (no OnClick) does not — the typed front door faithfully reflects "omitted handler = no binding". Driving the model is ordinary MVU:

let afterClick = update Increment init
printfn "after one +1  : Count=%d Done=%b" afterClick.Count afterClick.Done

That is the whole front door: typed Props in, Widget<'msg> composed, lowered to the legacy IR for rendering — proven structurally equal to the hand-written builders by per-control parity tests. See the typed front-door deep dive and the API reference.

namespace FS
namespace FS.Skia
namespace FS.Skia.UI
namespace FS.Skia.UI.Controls
namespace FS.Skia.UI.Controls.Typed
type Msg = | Increment | ToggleDone of bool
type bool = System.Boolean
type Model = { Count: int Done: bool }
Multiple items
val int: value: 'T -> int (requires member op_Explicit)

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

--------------------
type int<'Measure> = int
val init: Model
val update: msg: Msg -> model: Model -> Model
 A pure `update` — no I/O, no mutation (Principle IV).
val msg: Msg
val model: Model
union case Msg.Increment: Msg
Model.Count: int
union case Msg.ToggleDone: bool -> Msg
val v: bool
val view: model: Model -> Widget<Msg>
Multiple items
module Widget from FS.Skia.UI.Controls
<summary> The typed-tree lowering bridge: `ofControl`/`toControl` convert to and from the legacy IR, and `render` paints a `Widget&lt;'msg&gt;`. </summary>

--------------------
type Widget<'msg>
<summary> Opaque public return type of every typed `view`. Wraps the lowered `Control&lt;'msg&gt;` IR. The internal representation (`{ Lowered: Control&lt;'msg&gt; }`) stays in the implementation — Principle II. Additive-only: nothing in the existing public surface changes. </summary>
Multiple items
module Stack from FS.Skia.UI.Controls.Typed
<summary> Typed Props front door for the `Stack` control. </summary>

--------------------
module Stack from FS.Skia.UI.Controls
<summary> Builders for the `Stack` container — lays its children single-file along one axis (vertical by default; see `Stack.orientation`). </summary>
val view: props: StackProps<'msg> -> Widget<'msg>
<summary> Lowers children via `Widget.toControl` into `Stack.children`, order preserved. </summary>
val defaults: StackProps<'msg>
<summary> Authoring defaults; optional fields take their value from here. </summary>
union case StackOrientation.Vertical: StackOrientation
union case AttrCategory.Children: AttrCategory
Multiple items
module TextBlock from FS.Skia.UI.Controls.Typed
<summary> Typed Props front door for the `TextBlock` control. </summary>

--------------------
module TextBlock from FS.Skia.UI.Controls
<summary> Builders for the `TextBlock` control — a multi-line, wrapping run of body text. </summary>
val view: props: TextBlockProps<'msg> -> Widget<'msg>
<summary> Lowers structurally equal to `TextBlock.create [ TextBlock.text props.Text ]`. </summary>
val defaults: TextBlockProps<'msg>
<summary> Authoring defaults; optional fields take their value from here. </summary>
Multiple items
union case AccessibilityRole.Button: AccessibilityRole

--------------------
module Button from FS.Skia.UI.Controls.Typed
<summary> Typed Props front door for the `Button` control. </summary>

--------------------
module Button from FS.Skia.UI.Controls
<summary> Builders for the `Button` control — a clickable command surface with a text label. </summary>
val view: props: ButtonProps<'msg> -> Widget<'msg>
<summary> Lowers structurally equal to the legacy `Button.create` attrs; `OnClick = None` lowers to no event binding. </summary>
val defaults: ButtonProps<'msg>
<summary> Authoring defaults; optional fields take their value from here. </summary>
union case ButtonIntent.Primary: ButtonIntent
union case Option.Some: Value: 'T -> Option<'T>
Multiple items
union case AccessibilityRole.CheckBox: AccessibilityRole

--------------------
module CheckBox from FS.Skia.UI.Controls.Typed
<summary> Typed Props front door for the `CheckBox` control. </summary>

--------------------
module CheckBox from FS.Skia.UI.Controls
<summary> Builders for the `CheckBox` control — a labelled boolean toggle with a tick box. </summary>
val view: props: CheckBoxProps<'msg> -> Widget<'msg>
<summary> Lowers structurally equal to the legacy `CheckBox.create` attrs; `OnChanged = None` lowers to no event binding. </summary>
val defaults: CheckBoxProps<'msg>
<summary> Authoring defaults; optional fields take their value from here. </summary>
Multiple items
module Checked from Microsoft.FSharp.Core.Operators

--------------------
module Checked from Microsoft.FSharp.Core.ExtraTopLevelOperators
Model.Done: bool
val lowered: Control<Msg>
Multiple items
module Control from FS.Skia.UI.Controls
<summary> Core authoring and rendering verbs for `Control&lt;'msg&gt;` — construction, standard/custom lowering, keying, single-control preview `render` and nested `renderTree`. </summary>

--------------------
namespace Microsoft.FSharp.Control

--------------------
type Control<'msg> = { Kind: ControlKind Key: ControlId option Attributes: Attr<'msg> list Children: Control<'msg> list Content: string option Accessibility: AccessibilityMetadata option }
<summary> The core declarative control node (`Control&lt;'msg&gt;`): its `Kind`, optional stable `Key` identity, its `Attributes` and `Children`, optional text `Content`, and optional `Accessibility` metadata. The unit of the authoring tree and the reconciler diff. </summary>
val toControl: widget: Widget<'msg> -> Control<'msg>
<summary> Lowering accessor — the single, explicit seam to the existing IR. Used by render and the Elmish adapter. FR-002. Invariant: toControl (ofControl c) = c. </summary>
val printfn: format: Printf.TextWriterFormat<'T> -> 'T
Control.Kind: ControlKind
Multiple items
union case AccessibilityRole.List: AccessibilityRole

--------------------
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 length: list: 'T list -> int
Control.Children: Control<Msg> list
val iteri: action: (int -> 'T -> unit) -> list: 'T list -> unit
val i: int
val child: Control<Msg>
Control.Content: string option
Control.Attributes: Attr<Msg> list
val afterClick: Model

Type something to start searching.