-
Notifications
You must be signed in to change notification settings - Fork 0
UI Data Binding
Cursorial's data-binding engine wires element properties to a source object (typically a view model) and keeps
them in sync — the same {Binding} / DataContext model you know from WPF and Avalonia, ported to the terminal.
Reach for it whenever a property's value comes from data that changes over time, or when an editable control
should flow user input back to a model.
The descriptor types live in Cursorial.UI.Data (Binding, BindingBase, BindingMode, IValueConverter,
BindingOperations, …); DataContext and FindName live on UIElement in Cursorial.UI. You can author
bindings two ways: in XAML as a {Binding} markup extension, or in code through BindingOperations /
the SetBinding extension method.
A binding resolves its source from the target element's inherited DataContext, walks a property path, and
pushes the value into the target property. DataContext is an ordinary inherited property, so setting it on a
panel makes it the source for every descendant that doesn't override it.
var view = new StackPanel { DataContext = new GreetingViewModel { Name = "Ada" } };
var label = new TextBlock();
label.SetBinding(TextBlock.TextProperty, new Binding("Name")); // TextBlock.Text ← vm.Name
view.Children.Add(label);In XAML the same thing is a markup extension on the property:
<StackPanel>
<TextBlock Text="{Binding Name}" />
<TextBox Text="{Binding Name, Mode=TwoWay}" />
</StackPanel>For change notification to flow, the source should implement System.ComponentModel.INotifyPropertyChanged.
Cursorial also honors a convention fallback: a public [PropertyName]Changed event (no INotifyPropertyChanged
required) is subscribed automatically. If neither is present, the value is read once and re-read only when an
ancestor's DataContext changes.
Binding derives from BindingBase (the shared knobs) through AnchoredBinding (the source-anchor knobs). It is
construction-immutable and shareable — one descriptor instance can be installed on many elements. Key members:
-
Path(ctor arg orPathinit) — the property path.""or"."means the source object itself. Paths support property steps, integer/string indexers (Items[0],Map["key"]), and attached-property segments. -
Mode(BindingMode) —OneWay(source → target),TwoWay(source ↔ target),OneTime(read once at activation, no subscription),OneWayToSource(target → source only), orDefault.Defaultresolves at install toTwoWayfor properties whose metadata opts into two-way-by-default (editable controls), elseOneWay. -
UpdateSourceTrigger— when a two-way target edit flushes back:PropertyChanged(on every change),LostFocus(when focus leaves the element, plus the terminal-focus-out edit-commit pulse), orExplicit(only when you callUpdateSource()on the expression).Defaultresolves toPropertyChanged— a deliberate divergence from WPF's per-propertyLostFocusdefault, since a terminal keystroke source update is cheap. -
Converter(IValueConverter) +ConverterParameter+ConverterCulture— a forward/back value conversion stage (see below). -
StringFormat— a composite format applied to string/object targets on the way out (and reverse-parsed only when it is exactly"{0}"). -
FallbackValue— pushed when the path can't be resolved. -
TargetNullValue— substituted when the source producesnull.
var binding = new Binding("Temperature")
{
Mode = BindingMode.OneWay,
StringFormat = "{0:F1} °C",
FallbackValue = "—",
};
readout.SetBinding(TextBlock.TextProperty, binding);DataContext is the default source, but AnchoredBinding exposes three mutually-exclusive alternatives (setting
more than one throws):
-
Source— an explicit, fixed source object. Never re-resolved. -
ElementName— bind to another named element in scope (resolved through the enclosing name scope). In XAML,{Binding Path, ElementName=other};Source={x:Reference other}is equivalent. -
RelativeSource— a relative anchor:RelativeSource.Self(the target element),RelativeSource.TemplatedParent(the templated parent of a control-template part), orRelativeSource.Ancestor<T>(level)forFindAncestor(the nth logical ancestor assignable toT).
// Sync a label's text to a slider elsewhere in the tree.
label.SetBinding(TextBlock.TextProperty,
new Binding("Value") { ElementName = "volumeSlider", StringFormat = "{0}%" });
// In a control template, reach the templated parent.
border.SetBinding(Border.BackgroundProperty,
new Binding("Background") { RelativeSource = RelativeSource.TemplatedParent });
// Walk up to the nearest ancestor ListBox.
item.SetBinding(SomeProperty,
new Binding("SelectionMode") { RelativeSource = RelativeSource.Ancestor<ListBox>() });FindAncestor is logical-tree only in v1; level counts matching ancestors, not hops.
Inside a ControlTemplate, TemplateBinding is the lightweight one-way reach to the templated parent — the
common case of "this template part shows the control's property":
<ControlTemplate>
<Border Background="{TemplateBinding Background}"
Padding="{TemplateBinding Padding}">
<ContentPresenter />
</Border>
</ControlTemplate>TemplateBinding takes a UIProperty and supports Mode Default/OneWay only (a non-default mode or trigger
throws). It accepts a Converter/ConverterParameter. For a two-way reach-in, use a full
new Binding { RelativeSource = RelativeSource.TemplatedParent, Mode = BindingMode.TwoWay } instead.
Implement IValueConverter to adapt between source and target types. Convert runs source → target; ConvertBack
runs target → source for two-way bindings. The targetType is the destination CLR type; parameter is the
binding's ConverterParameter; culture is the binding's effective culture. Return UIProperty.UnsetValue to
mean "no value" (the pipeline then falls to FallbackValue); a one-way converter may throw
NotSupportedException from ConvertBack and the engine treats it as a binding error, not a crash.
public sealed class UpperCaseConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> (value as string)?.ToUpper(culture);
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException(); // one-way
}A ready-made BooleanToVisibilityConverter (with a .Instance singleton) maps bool ↔ Visibility. When no
converter is set, the engine runs a no-converter type-conversion ladder (assignable → IConvertible /
enum-parse → TypeConverter), so a "42" string binds cleanly to an int property.
ElementName (and FindName) resolve through name scopes. The XAML loader and templating engine populate scopes
automatically; in code, attach a NameScopeDictionary via NameScope.SetNameScope(root, scope) and register
elements with scope.Register("name", element). Look an element up with element.FindName("name") — it returns
the registered object or null on a miss (template parts see their template's names first, then document names).
BindingOperations is the static engine entry point (UI-thread only). It mirrors WPF's
System.Windows.Data.BindingOperations:
-
Install(target, property, binding)/ thetarget.SetBinding(property, binding)extension — installs at the local-value priority, returning the expression. Re-installing on the same property disposes the previous binding first. -
SetBinding(target, property, binding)— a WPF-shaped alias forInstall. -
ClearBinding(target, property)— disposes the installed binding (unsubscribing its sources) and clears the local value. A no-op when nothing is bound there. -
GetBindingExpression(target, property)/GetBinding(target, property)— retrieve the live expression or its source descriptor (for inspection or cloning). -
Watch(anchor, binding, onValueChanged)— arm a binding with no target property: the resolved value (orUIProperty.UnsetValuewhen unresolved) is delivered to your callback, and it re-delivers automatically onDataContextchanges. This is the seam the styling engine's data conditions use; the returnedIBindingWatchis disposable.
var expr = label.SetBinding(TextBlock.TextProperty, new Binding("Name"));
// …later…
BindingOperations.ClearBinding(label, TextBlock.TextProperty);Properties whose metadata is marked not-data-bindable throw ArgumentException on install. Bindings are torn down
automatically when an element is permanently detached, so you rarely need to clear them by hand.
For hot paths and AOT-friendly code, a compiled binding replaces reflection with a typed delegate. You build one from a strongly-typed lambda — the lambda is the sole path source, so the path, getter, and (for writable leaves) setter all come from one expression:
// Cache the descriptor in a static field — analysis happens once.
private static readonly CompiledBinding<CustomerViewModel, string> NameBinding =
Binding.Compiled((CustomerViewModel vm) => vm.Customer.Address.City);
field.SetBinding(TextBlock.TextProperty, NameBinding);Binding.Compiled<TSource, TValue>(...) (and the discoverability twin CompiledBinding.From<TSource, TValue>(...))
analyze the lambda into a CompiledBinding<TSource, TValue>. The chain may contain member-access and
constant-index hops only — a method call or operator in the path throws FormatException naming the offending
node. The result is a typed whole-chain getter for value reads plus per-hop wiring for change subscription; when the
target is a matching typed property with no converter/format/coercion in the way, the value is pushed without
boxing. A non-writable leaf makes the binding one-way; supply a Converter/ConverterParameter (which forfeits the
zero-box fast path but still works). Compiled bindings inherit the full AnchoredBinding surface, so they can be
Self/TemplatedParent/ElementName/FindAncestor-anchored just like a reflection Binding.
Because
Binding.Compiledre-analyzes the expression tree on every call, store the resulting descriptor in astatic readonlyfield rather than constructing it inline per element.
- Threading — all binding operations are UI-thread only.
-
The notification ladder — change tracking prefers
INotifyPropertyChanged, then a convention[PropertyName]Changedevent, then a one-time read that refreshes only onDataContextchange. ImplementINotifyPropertyChangedfor the most reliable live updates. -
Defaultmode resolution depends on the target property's metadata, not the binding — editable controls (e.g. aTextBox'sText) default toTwoWay; most others toOneWay.
-
XAML —
{Binding}/{TemplateBinding}markup,x:Name, and the compiled-binding generator path. - Controls — the controls you bind, and which properties default to two-way.
-
Styling and themes — data conditions (
When) that drive looks from bound values. -
Overview —
DataContext, the property system, and where binding fits in the framework.
Cursorial.Core
Cursorial.Rendering
Drawing & Animation
Cursorial.UI
- Overview
- Layout & panels
- Controls
- Styling & themes
- Data binding
- Input & focus
- Windowing
- Animation & transitions
Declarative