Logo FS-Skia-UI

Controls Suite

Looking for the list of controls? The Controls Catalog is the authoritative, generated reference of every supported control — grouped by category, each with a detail page and API link. This page explains the architecture behind that catalog.

The control suite is two published packages that share one story. FS.Skia.UI.Controls is the declarative, Elmish-shaped widget layer: you build an immutable tree of Control<'msg> values and render it against a Theme to get back a scene, a layout, diagnostics, and event bindings. FS.Skia.UI.Controls.Elmish is the adapter that wires that tree's runtime effects — control interaction, keyboard commands, and pointer interactions — into a standard Elmish Cmd/Sub program. This page explains both for a newcomer: the core control vocabulary, how controls compose over the scene, layout, and input layers, how rendering and event dispatch work, and how the adapter lowers effects into messages. Detail on the typed front door (the Widget<'msg> Props/MVU surface) and the Penpot/design-token flow lives in its own deep dive — see Typed control front door & Penpot flow — so it is kept light here.

The supported public surface is owned by the .fsi files under src/Controls and the governed catalog (catalog.yml plus Catalog.supportedControls). Background and the boundary history are in the Controls report and the Controls boundary refactor process report.

Where this sits

Controls is the high-level authoring path. It composes over three lower layers: Scene (the immutable drawing vocabulary a render produces), Layout (the geometry a render resolves), and Input (pointer and keyboard events that drive interaction). Persistent application state stays in your own Elmish model and message types; the controls layer is a projection of that state, not a store. For products that do not adopt Controls, the Controls report documents the lower-level packages (Scene, Layout, KeyboardInput, SkiaViewer, Elmish) that remain supported on their own.

The core control vocabulary

Everything is built from one record. From Types.fsi:

type Control<'msg> =
    { Kind: ControlKind
      Key: ControlId option
      Attributes: Attr<'msg> list
      Children: Control<'msg> list
      Content: string option
      Accessibility: AccessibilityMetadata option }

and Attr<'msg> =
    { Name: string; Category: AttrCategory; Value: AttrValue<'msg> }

A Control<'msg> is a kind tag, an optional stable Key, a list of typed attributes, child controls, optional text content, and optional accessibility metadata. The AttrValue<'msg> union is where the type parameter earns its keep: alongside data cases (TextValue, BoolValue, FloatValue, StringListValue, ValidationValue, ThemeValue, child/children cases) it carries MessageValue of 'msg and EventValue of (ControlEvent -> 'msg) — so an attribute can hold a message to dispatch on interaction. There is also a deliberate escape hatch, UntypedValue of obj, for custom controls.

Authors rarely touch the record directly. Each control has a module with a create: Attr<'msg> list -> Control<'msg> plus attribute builders. From Control.fsi the suite spans display (TextBlock, Label, Image, Icon, Badge, Separator), input (Button, CheckBox, Switch, Slider, NumericInput, TextBox, TextArea, RadioGroup), layout containers (Stack, Grid, Dock, Wrap, Border, Panel), feedback (ProgressBar, Spinner, ValidationMessage), navigation (Tabs, Menu, Toolbar), and overlays (Tooltip, Dialog, Toast, Overlay). Charts (LineChart, BarChart, PieChart, ScatterPlot, GraphView) and a virtualized DataGrid round out the data controls. The Controls report records the governed catalog at 47 supported rows across these categories.

Authoring follows one uniform shape — read persistent state from the model, emit messages from events:

let view model =
    Stack.create [
        Stack.children [
            TextBlock.create [ TextBlock.text model.Title ]
            TextBox.create [
                TextBox.value model.Name
                TextBox.validation model.NameValidation
                TextBox.onChanged NameChanged ]
            Button.create [
                Button.text "Save"
                Button.enabled model.CanSave
                Button.onClick SaveRequested ] ] ]

Under all the typed modules sit the lower-level builders in Control: create, standard (over the typed StandardControlKind), customControl, withKey, and the lowering helpers lowerStandard / lowerCustom. These are the structural seam the typed front door and the catalog build on.

Rendering: from tree to scene

Control.render takes a Theme and a control and returns a ControlRenderResult<'msg>:

type ControlRenderResult<'msg> =
    { Scene: Scene
      Layout: LayoutNode
      Diagnostics: ControlDiagnostic list
      EventBindings: ControlEventBinding<'msg> list
      NodeCount: int }

This is the composition point over the lower layers. The render produces:

The Theme record carries foreground/background/accent/danger/muted colors, font, density, corner radius, and the contrast ratio required for the ContrastFailure check. The Theme module supplies built-in light and dark palettes plus withDensity, withAccent, and resolve for overrides. The deeper design-token pipeline that generates the theme primitives is covered in the typed front door & Penpot deep dive.

Beyond render, Control exposes dispatch: ControlEvent -> Control<'msg> -> 'msg list (compute the messages an event would produce), diagnostics (inspect without rendering), and count.

Interaction state and runtime effects

Persistent values — text, selected items, validation, committed values — live in your model. Transient interaction state (focus, hover, pressed, caret, selection, composition, drag) lives in a product-owned ControlRuntimeModel, an ordinary Elmish sub-model from ControlRuntime.fsi:

module ControlRuntime =
    val init: unit -> ControlRuntimeModel * ControlRuntimeEffect list
    val update: msg: ControlRuntimeMsg -> model: ControlRuntimeModel -> ControlRuntimeModel * ControlRuntimeEffect list
    val diagnostics: model: ControlRuntimeModel -> ControlDiagnostic list

ControlRuntimeMsg covers FocusControl, HoverControl, PressControl/ReleaseControl, SetCaret, SetSelection, composition start/commit, drag start/move/end, RemoveControl, RecoverStaleTarget, CancelInteraction, and Reset. Each turn emits ControlRuntimeEffect values such as FocusChanged, HoverChanged, DragChanged, StaleTarget, and ReportControlRuntimeDiagnostic — descriptions, not actions. Virtualized collections have a parallel pure sub-model in Collections (CollectionModel/CollectionMsg/CollectionEffect), which computes a VisibleRange from row height, viewport height, and scroll offset and emits VisibleRangeChanged.

The Elmish adapter

FS.Skia.UI.Controls.Elmish turns those runtime effects into a standard Elmish program. Its effect envelope and program record come from ControlsElmish.fsi:

type AdapterEffect<'msg> =
    | DispatchProductMessage of 'msg
    | DispatchControlRuntimeMessage of ControlRuntimeMsg
    | DispatchKeyboardMessage of KeyboardMsg
    | DispatchHostCommand of string
    | ReportAdapterDiagnostic of AdapterDiagnostic

type AdapterCommand<'msg> = AdapterEffect<'msg> list

The adapter is a set of total, pure lowering functions on ControlsElmish:

The bridge to Elmish proper is the AdapterCmd module (feature 068): toCmd converts an AdapterCommand<'msg> to an Elmish Cmd<'msg> by routing every effect case — product and non-product — through a caller-supplied function, preserving order, with [] mapping to Cmd.none. Its laws (toCmd route [] = none, productMessages (ofMessage m) = [ m ]) make the round trip checkable. So the full flow is: a pointer/keyboard/control event produces runtime effects → an interpret* function lowers them to an AdapterCommandAdapterCmd.toCmd lifts that to an Elmish Cmd → the message flows through your updateview re-projects the model to a Control<'msg>Control.render produces the next scene.

The typed front door (pointer to the deep dive)

There is a typed authoring surface, Widget<'msg>, that wraps the lowered Control<'msg> IR behind a sealed type with Widget.ofControl / Widget.toControl / Widget.render. The adapter integrates it directly: widgetView adapts a 'model -> Widget<'msg> view to the Control<'msg> the program expects (via Widget.toControl), and programOfWidget builds a program whose view is authored with the typed front door. The typed Props/MVU per-control surface, its parity guarantees, and the design-token / Penpot integration are deliberately out of scope here — see Typed control front door & Penpot flow.

Related pages

Analysis

Implementation strengths

Implementation weaknesses

Design pros

Design cons

Multiple items
namespace Microsoft.FSharp.Control

--------------------
type Control<'msg> = { Kind: obj Key: obj Attributes: Attr<'msg> list Children: Control<'msg> list Content: string option Accessibility: obj }
type 'T option = Option<'T>
type Attr<'msg> = { Name: string Category: obj Value: obj }
'msg
type 'T list = List<'T>
Multiple items
val string: value: 'T -> string

--------------------
type string = System.String
val view: model: 'a -> 'b
val model: 'a
type ControlRenderResult<'msg> = { Scene: obj Layout: obj Diagnostics: obj EventBindings: obj NodeCount: int }
Multiple items
val int: value: 'T -> int (requires member op_Explicit)

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

--------------------
type int<'Measure> = int
type unit = Unit
type AdapterEffect<'msg> = | DispatchProductMessage of 'msg | DispatchControlRuntimeMessage of obj | DispatchKeyboardMessage of obj | DispatchHostCommand of string | ReportAdapterDiagnostic of obj
type AdapterCommand<'msg> = AdapterEffect<'msg> list

Type something to start searching.