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.
Most public control properties are [Bindable] and participate in dependency tracking.
The source generator emits:
T, Func<T>, and State<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}")
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: