XenoAtom.Terminal.UI uses a binding model designed for terminal UIs:
The goal is to let you build “live” UIs without manual invalidation calls (RequestRender, MarkMeasureDirty, …).
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)
);
State<T> is itself a bindable model: it is just a class with a single [Bindable] property named Value.
Use State<T> for small, UI-only state. Use a custom bindable model when you want to group multiple related
values together.
Conceptually, State<T> is similar to:
public sealed partial class MyState<T>
{
[Bindable] public partial T Value { get; set; }
}
Bindable values are tracked when they are read during a “tracking context”, including:
PrepareChildrenMeasure / Arrange)RenderWhen a tracked value changes, the framework re-runs only the relevant passes for the affected visuals.
For tracking to work, always read bindable state through the property, not a private backing field.
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:
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.Suppression affects all bindable reads/writes within the scope. Keep the scope small and always use using so tracking is restored.
ComputedVisual with tracked and untracked stateIn 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.Most public control properties are [Bindable] and participate in dependency tracking.
The source generator emits:
T, Func<T>, and Binding<T> overloadsYou 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):
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()
);
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 };
[Bindable] requires source generation. In most projects this is enabled automatically by referencing
XenoAtom.Terminal.UI (which brings the generator as an analyzer). If you use custom build settings, ensure the
source generator package is included so your partial properties are generated.
Func<T>Use Func<T> to compute a value on demand, while still being dependency-tracked:
new TextBlock(() => $"Tick: {tick.Value}")
The same pattern applies to styles:
new Button("Save")
.Style(() => isDanger.Value
? (ButtonStyle.Default with { ShowBorder = true })
: ButtonStyle.Default);
For style-specific guidance, see Styling.
Some controls (TextBox/TextArea) can bind their value to a State<string> by providing a document wrapper that reads/writes the bound value.
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.SomePropertywithin a same tracking context
it usually means a method both:
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.
If you need a derived/computed value for layout, prefer an internal bindable property like MeasuredContentWidth
and make the dependency explicit (_ = MeasuredContentWidth;) in the pass that consumes it.
When building your own control:
[Bindable] for any state that affects layout or rendering.Render.See also: