Binding & State

XenoAtom.Terminal.UI uses a binding model designed for terminal UIs:

  • property access is tracked during dynamic updates, layout, and rendering
  • when state changes, only the affected visuals are invalidated

The goal is to let you build “live” UIs without manual invalidation calls (RequestRender, MarkMeasureDirty, …).

State

State<T> is a small observable container used to drive UI:

var name = new State<string>("Alex");

var ui = new VStack(
    new TextBlock(() => $"Hello {name.Value}"),
    new TextBox().Text(name)
);

Conceptually, State<T> is similar to:

public sealed partial class MyState<T>
{
    [Bindable] public partial T Value { get; set; }
}

Tracking contexts (what gets invalidated)

Bindable values are tracked when they are read during a “tracking context”, including:

  • dynamic updates / composition (building children, reacting to changes)
  • PrepareChildren
  • layout (Measure / Arrange)
  • Render
  • input handlers that read bindables to decide behavior

When a tracked value changes, the framework re-runs only the relevant passes for the affected visuals.

Selective dependency tracking (advanced)

Sometimes you want to read or write bindable state without recording it as a dependency of the current tracking context. This is most common inside a ComputedVisual factory:

  • A tracked state should rebuild the computed subtree.
  • A non-tracked state is only used for side effects or snapshots and should not cause rebuilds.

This matters because ComputedVisual runs its factory during PrepareChildren, which is a tracking context. Any bindable state you read while constructing the subtree is recorded as a dependency of that ComputedVisual. If you later write to that same state as part of normal UI operations, it will trigger the ComputedVisual to rebuild, re-running the factory and potentially reinitializing the subtree (often resulting in surprising “reset” behavior).

To support this, BindingManager exposes two scoped suppression helpers:

  • BindingManager.Current.SuppressReadTracking() - disables dependency recording for bindable reads in the scope.
  • BindingManager.Current.SuppressWriteTracking() - suppresses write notifications (ValueChanged) in the scope.
  • BindingManager.Current.RunAfterTracking(action) - runs action immediately when no tracking scope is active, or defers it until the outermost tracking scope completes.

Example: a ComputedVisual with tracked and untracked state

In this example, mode is tracked (changes should rebuild), while telemetry is read and updated without becoming a dependency:

var mode = new State<int>(0);
var telemetry = new State<int>(0);

var view = new ComputedVisual(() =>
{
    // Tracked dependency: changing mode rebuilds the subtree.
    var currentMode = mode.Value;

    // Untracked: reading/updating telemetry should not rebuild this ComputedVisual.
    int snapshot;
    using (BindingManager.Current.SuppressReadTracking())
    using (BindingManager.Current.SuppressWriteTracking())
    {
        snapshot = telemetry.Value;
        telemetry.Value = snapshot + 1;
    }

    return new TextBlock($"Mode: {currentMode} (telemetry={snapshot})");
});

Notes:

  • SuppressReadTracking is useful for reading state that you intentionally do not want to become a dependency.
  • SuppressWriteTracking is for internal bookkeeping updates where you do not want to trigger invalidation. Avoid suppressing writes for user-visible state that other visuals should react to.
  • RunAfterTracking is useful when an operation must not happen during layout/render/input tracking itself (for example, dispatching a routed event or synchronizing derived state after clamping with local variables).

Deferred work after tracking

Some operations should never run inside a tracking context, even if they are logically caused by work happening there. Common examples are:

  • raising routed events while layout/render/input dependency tracking is active
  • mirroring a clamped/effective layout value back into a public bindable property
  • firing change notifications that would otherwise run arbitrary user code during tracking

In those cases, keep the tracking-time computation local, then schedule the follow-up work with:

BindingManager.Current.RunAfterTracking(() =>
{
    // Safe: runs after the outermost tracking context completes.
});

This keeps layout/render/input passes pure from follow-up side effects while still allowing state and notifications to be synchronized immediately after tracking finishes.

Bindable properties

Most public control properties are [Bindable] and participate in dependency tracking. The source generator emits:

  • property accessors wired into the binding hub
  • fluent extension methods for T, Func<T>, and Binding<T> overloads

Generated bindable getters stay pure reads: they register the read and return the current backing field value. They do not “catch up” by synchronizing from another source during a later PrepareChildren / Measure / Arrange / Render read.

The three fluent forms map to two runtime mechanisms:

  • Property(value) applies a direct value
  • Property(binding) attaches an explicit push-driven Binding<T>
  • Property(() => expr) on a Visual installs tracked computed configuration that re-runs during dynamic update

When a generated bindable property is attached to a Binding<T>, the framework synchronizes the local backing value as soon as the source binding changes.

Bindable models (your own data types)

You can also use [Bindable] on your own model classes so controls can bind to them directly. This is especially useful for data-driven controls like DataGridControl.

Example:

using XenoAtom.Terminal.UI;

public enum WorkItemKind { Feature, Bug, Chore }
public enum WorkItemPriority { Low, Medium, High, Critical }

public sealed partial class WorkItem
{
    [Bindable] public partial bool Done { get; set; }
    [Bindable] public partial WorkItemKind Kind { get; set; }
    [Bindable] public partial WorkItemPriority Priority { get; set; }
    [Bindable] public partial int EstimateHours { get; set; }
    [Bindable] public partial string Title { get; set; } = string.Empty;
    [Bindable] public partial string Notes { get; set; } = string.Empty;
}

The generator produces a Bind helper (for bindings) and strongly-typed BindingAccessor<T> instances (for reuse):

Binding a model to controls (forms)

var model = new WorkItem();

var ui = new VStack(
    new HStack(new Switch().IsOn(model.Bind.Done), "Done").Spacing(1),
    new EnumSelect<WorkItemKind>().Value(model.Bind.Kind),
    new EnumSelect<WorkItemPriority>().Value(model.Bind.Priority),
    new NumberBox<int>().Value(model.Bind.EstimateHours),
    new TextBox().Text(model.Bind.Title),
    new TextArea().Text(model.Bind.Notes).MinHeight(5).MaxHeight(5).Scrollable()
);

Reusing accessors (DataGrid, documents, schema)

var done = WorkItem.Accessor.Done;
var title = WorkItem.Accessor.Title;

You can plug these accessors into the data grid document model without writing manual getters/setters:

using XenoAtom.Terminal.UI.Controls;
using XenoAtom.Terminal.UI.DataGrid;

var doc = new DataGridListDocument<WorkItem>()
    .AddColumn(WorkItem.Accessor.Done)
    .AddColumn(WorkItem.Accessor.Title);

doc.AddRow(new WorkItem { Done = false, Title = "Write docs" });
doc.AddRow(new WorkItem { Done = true, Title = "Ship v1" });

using var view = new DataGridDocumentView(doc);
var grid = new DataGridControl { View = view };

When to use Func<T>

For bindable property fluent APIs on Visual receivers, Func<T> means "recompute this property during dynamic update when the values read by the lambda change":

new Button("Save")
    .IsVisible(() => filter.Value == "show");

The lambda reads the real upstream bindables directly. There is no intermediate synthetic binding source.

Semantics:

  • Property(value) clears a prior computed function for that property.
  • Property(binding) clears a prior computed function for that property.
  • Direct setter assignment after computed configuration does not clear the recipe; the next dynamic update reapplies the computed value.

For non-visual bindable models, generated Func<T> overloads are one-shot: the function is evaluated immediately and the resulting value is assigned once.

If you need a live custom binding source whose value changes over time, that source must notify changes explicitly. Read-only does not imply pull-on-read behavior.

Two-way binding

Some controls (TextBox/TextArea) can bind their value to a State<string> by providing a document wrapper that reads/writes the bound value.

The “read then write” rule (and how to work with it)

To prevent accidental dependency loops, the binding system disallows reading and then writing the same bindable property within a single tracking context (e.g. within one Arrange pass).

If you hit an exception like:

Cannot read and then write SomeControl.SomeProperty within a same tracking context

it usually means a method both:

  • read a bindable property (to react to it), then
  • wrote to that same bindable property (to “fix up” state) in the same pass.

Workaround pattern: split read/write across phases

The most common pattern is to mirror external state into an internal bindable version in PrepareChildren, then read that mirrored value in Arrange / Render.

Example (scrollable controls):

[Bindable] private partial int ScrollVersion { get; set; }

protected override void PrepareChildren()
{
    // Write in PrepareChildren…
    ScrollVersion = Scroll.Version;
}

protected override void ArrangeCore(in Rectangle rect)
{
    // …read in ArrangeCore (different tracking context).
    _ = ScrollVersion;

    // Now it is safe to update the scroll model without creating a read/write loop.
    Scroll.SetViewport(rect.Width, rect.Height);
}

This pattern is also useful for “measured values” that are computed in Measure but consumed in Arrange.

Custom controls (user code)

When building your own control:

  1. Use [Bindable] for any state that affects layout or rendering.
  2. Avoid mutating bindable properties during Render.
  3. If you must derive state during layout, prefer the “split read/write” pattern above.

See also: