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.BindingManager.Current.RunAfterTracking(action) - runs action immediately when no tracking scope is active, or defers it until the outermost tracking scope completes.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.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).Some operations should never run inside a tracking context, even if they are logically caused by work happening there. Common examples are:
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.
Most public control properties are [Bindable] and participate in dependency tracking.
The source generator emits:
T, Func<T>, and Binding<T> overloadsGenerated 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 valueProperty(binding) attaches an explicit push-driven Binding<T>Property(() => expr) on a Visual installs tracked computed configuration that re-runs during dynamic updateWhen a generated bindable property is attached to a Binding<T>, the framework synchronizes the local backing value as
soon as the source binding changes.
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):
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>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.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.
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.
Avoid clamping or “fixing up” a public bindable value from PrepareChildren, Measure, Arrange, or Render.
Read the public value, compute a safe local/clamped value, and use that local value for layout/rendering instead.
If you must publish the derived result or notify listeners, schedule that work with RunAfterTracking(...) so it
happens after the tracking context completes.
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: