Skip to content

UI Data Binding

Mike Strobel edited this page Jun 26, 2026 · 1 revision

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 first binding

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.

The Binding descriptor

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 or Path init) — 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), or Default. Default resolves at install to TwoWay for properties whose metadata opts into two-way-by-default (editable controls), else OneWay.
  • 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), or Explicit (only when you call UpdateSource() on the expression). Default resolves to PropertyChanged — a deliberate divergence from WPF's per-property LostFocus default, 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 produces null.
var binding = new Binding("Temperature")
{
    Mode = BindingMode.OneWay,
    StringFormat = "{0:F1} °C",
    FallbackValue = "—",
};
readout.SetBinding(TextBlock.TextProperty, binding);

Choosing the source: anchors

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), or RelativeSource.Ancestor<T>(level) for FindAncestor (the nth logical ancestor assignable to T).
// 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.

TemplateBinding

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.

Value converters

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 boolVisibility. 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.

Names and name scopes

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).

Managing bindings in code: BindingOperations

BindingOperations is the static engine entry point (UI-thread only). It mirrors WPF's System.Windows.Data.BindingOperations:

  • Install(target, property, binding) / the target.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 for Install.
  • 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 (or UIProperty.UnsetValue when unresolved) is delivered to your callback, and it re-delivers automatically on DataContext changes. This is the seam the styling engine's data conditions use; the returned IBindingWatch is 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.

Compiled bindings (typed, zero-box)

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.Compiled re-analyzes the expression tree on every call, store the resulting descriptor in a static readonly field rather than constructing it inline per element.

Notes and gotchas

  • Threading — all binding operations are UI-thread only.
  • The notification ladder — change tracking prefers INotifyPropertyChanged, then a convention [PropertyName]Changed event, then a one-time read that refreshes only on DataContext change. Implement INotifyPropertyChanged for the most reliable live updates.
  • Default mode resolution depends on the target property's metadata, not the binding — editable controls (e.g. a TextBox's Text) default to TwoWay; most others to OneWay.

See also

  • 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.
  • OverviewDataContext, the property system, and where binding fits in the framework.

Clone this wiki locally