Logo FS-Skia-UI

Layout

FS.Skia.UI.Layout turns a declarative tree of layout intents into concrete on-screen rectangles. You describe what you want — a column of children with some padding, gaps, flex-grow weights and min/max sizes — and the engine computes where everything lands. Internally it delegates the hard flexbox math to Facebook's Yoga (via the Yoga.Net package) but wraps it in a pure, immutable F# contract, carries a hand-written pure fallback for when the native engine fails, and exposes an Elmish/MVU workflow so layout can participate in the same message loop as the rest of an FS.Skia.UI app. This page explains the data model, the evaluation pipeline, how results feed the scene and Elmish runtime, and the engine's honest trade-offs.

The package depends only on FS.Skia.UI.Scene and Yoga.Net; it creates no windows and performs no rendering of its own. See the API reference for every public member.

The data model

Everything starts from a LayoutNode tree. Each node carries an id, a LayoutIntent, a LayoutVisibility, an optional content Scene, an optional ContentMeasure function for leaf-content measurement, and a list of child nodes:

type LayoutNode =
    { Id: LayoutNodeId            // string id, used for hit-testing and diagnostics
      Intent: LayoutIntent        // the flexbox styling for this node
      Visibility: LayoutVisibility // Visible | Hidden | Collapsed
      Measure: ContentMeasure option // leaf content sizing callback
      Content: Scene option       // what to draw at this node's bounds
      Children: LayoutNode list }

The LayoutIntent record is the framework's flexbox vocabulary: Direction (Row/Column), Wrap, AlignItems/AlignSelf/JustifyContent (the LayoutAlign cases include SpaceBetween/SpaceAround/SpaceEvenly), Padding, Margin, Gap, explicit Size/MinSize/MaxSize, and FlexGrow/FlexShrink/FlexBasis. These map almost one-to-one onto Yoga style properties.

Evaluation against an AvailableSpace (a width and height with MeasureModes) produces a LayoutResult:

type LayoutResult =
    { Bounds: ComputedBounds list      // one ComputedBounds per node (id + LayoutBounds + visibility)
      Diagnostics: LayoutDiagnostic list
      Invalidated: LayoutNodeId list
      Revision: int64 }

The LayoutDiagnostic list is a first-class output, not an exception channel. Bad inputs — a negative width, a min larger than a max, an unmeasurable leaf, a duplicate node id, an empty id — are normalized to safe values and reported as a diagnostic with a DiagnosticSeverity and a LayoutDiagnosticCode, rather than throwing. The Defaults module supplies constructors (layoutNode, availableSpace, layoutIntent, pixelSnapPolicy, stackConfig, …) so callers rarely write these records by hand.

The evaluation pipeline

Layout.evaluate is the core entry point. A single call does several things in sequence:

  1. Normalize the available space. Non-finite or negative width/height are clamped to 0 and recorded as an InvalidAvailableSpace error diagnostic.
  2. Run the pure validation pass. layoutNode — a hand-written recursive flexbox approximation — runs purely for its diagnostics here, collecting padding/margin/gap/size normalization warnings and constraint conflicts.
  3. Try the Yoga layout. tryYogaLayout builds a parallel tree of native Yoga nodes (YGNodeNew), applies each LayoutIntent via applyYogaStyle, wires a YGMeasureFunc for any leaf with a Measure callback, calls YGNodeCalculateLayout, then reads back absolute bounds and frees the native tree (YGNodeFreeRecursive).
  4. Fall back if Yoga throws. If the native call fails for any reason the engine catches the exception, re-runs the pure layoutNode computation to produce real bounds, and appends a FallbackBoundsApplied warning naming the exception — the call still returns a usable LayoutResult.
  5. Validate the tree for duplicate / empty ids and emit a summary FallbackBoundsApplied info diagnostic if any input needed bounded fallback geometry.

Layout.evaluateIncremental is the incremental door: it currently delegates to a full evaluate, bumps Revision, and records the caller-supplied changedNodeIds in Invalidated. The interface is incremental; the implementation re-evaluates the whole tree.

LayoutNode tree + AvailableSpace
        │
        ▼
 normalizeAvailable ──► pure layoutNode (diagnostics only)
        │
        ▼
   tryYogaLayout ──(Ok)──► native bounds + measurement diagnostics
        │
      (Error ex)
        │
        ▼
 pure layoutNode (real bounds) + FallbackBoundsApplied
        │
        ▼
   LayoutResult { Bounds; Diagnostics; Invalidated; Revision }

From bounds to pixels and scenes

A LayoutResult is geometry, not pixels. Three helpers connect it to rendering and interaction:

The Elmish workflow loop

For apps that want layout to react to host resizes and node changes, the package ships a small MVU loop mirroring the framework's Elmish/MVU conventions:

The split keeps updateWorkflow pure and testable while the actual layout computation (which calls into native Yoga) lives behind the effect interpreter.

Convenience builders and graph layout

Beyond the node-tree engine the module exposes simpler, self-contained builders: Layout.horizontalStack, verticalStack, and dock take a StackConfig/DockConfig and a list of LayoutChild values and return a grouped Scene; measureHorizontal/measureVertical return the per-child LayoutBounds for the even-split arithmetic they perform. (Note that the current horizontalStack/verticalStack/dock bodies ignore their config and simply group the children's content — the measurement functions, not the builders, carry the geometry.)

The Graph and GraphValidation modules add node/edge graph layout on top: Graph.layout/directed/undirected return Result<_, GraphValidationIssue list> (validating duplicate ids, missing endpoints, self-loops, and cycles before placing), and Graph.hitTest maps a point to a GraphTarget of Node or Edge.

How it fits the framework

In a generated FS.Skia.UI app, layout sits between product state and the renderer: the Elmish view produces a LayoutNode tree or stack/dock builders, Layout computes ComputedBounds, renderComputed turns them into a Scene, and the host presents it. The same hitTestComputed geometry is what pointer input (see Input) uses to address an interaction to the correct node. The whole package is acyclic and host-independent — it knows nothing about Vulkan, windows, or the event loop.

Analysis

Implementation strengths

Implementation weaknesses

Design pros

Design cons

type LayoutNode = { Id: obj Intent: obj Visibility: obj Measure: obj Content: obj Children: LayoutNode list }
Multiple items
type MeasureAttribute = inherit Attribute new: unit -> MeasureAttribute

--------------------
new: unit -> MeasureAttribute
type 'T option = Option<'T>
type 'T list = List<'T>
type LayoutResult = { Bounds: obj Diagnostics: obj Invalidated: obj Revision: int64 }
Multiple items
val int64: value: 'T -> int64 (requires member op_Explicit)

--------------------
type int64 = System.Int64

--------------------
type int64<'Measure> = int64

Type something to start searching.