Data Binding

SharpConsoleUI has a small, declarative data-binding layer for MVVM-style apps: you put your state in a view model that implements INotifyPropertyChanged, and bind control properties to it with .Bind() / .BindTwoWay(). When a view-model property changes, the bound control updates itself — you never reach into a control and call SetText(...).

This is one of two valid styles the framework supports. The other is a Coordinator/Controller pattern with imperative updates (control.SetContent(...) from an event handler). Binding is the more declarative, more testable path; the imperative one is also fine and is what some of the production apps built on SharpConsoleUI use. This page documents the binding path.


The shape of it

// 1. A view model — standard .NET INotifyPropertyChanged, nothing framework-specific.
public sealed class MonitorVm : ViewModelBase   // ViewModelBase = INPC + a SetProperty helper
{
    private double _cpu;
    public double Cpu { get => _cpu; set => SetProperty(ref _cpu, value); }

    private string _status = "";
    public string Status { get => _status; set => SetProperty(ref _status, value); }
}

// 2. Bind controls to it.
var bar    = Controls.BarGraph().Build();
var status = Controls.Label("");

bar.Bind(vm, v => v.Cpu, c => c.Value);        // one-way:  vm.Cpu    → bar.Value
status.Bind(vm, v => v.Status, c => c.Text);   // one-way:  vm.Status → status.Text

// 3. Mutate the view model — the controls follow.
vm.Cpu = 73;        // the bar redraws itself
vm.Status = "OK";   // the label updates

You only need INotifyPropertyChanged on the source (the view model). Every control already implements it (see Why controls are INPC), so the target side is handled for you.

A minimal ViewModelBase (the tutorials use exactly this):

public abstract class ViewModelBase : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler? PropertyChanged;

    protected void OnPropertyChanged([CallerMemberName] string? name = null)
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));

    protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string? name = null)
    {
        if (EqualityComparer<T>.Default.Equals(field, value)) return false;
        field = value;
        OnPropertyChanged(name);
        return true;
    }
}

SetProperty only raises the change event when the value actually changes, and [CallerMemberName] means you never type a property name as a string.


One-way binding

Bind pushes source → target. The control updates whenever the bound view-model property raises PropertyChanged; the initial value is applied immediately when you call Bind.

bar.Bind(vm, v => v.Cpu, c => c.Value);

The two lambdas are member-access expressions: v => v.Cpu names the source property, c => c.Value names the control property. Both sides must be the same type.

With a converter

When the source and target types differ — or you want to format a value — pass a converter:

// Message? -> display string
header.Bind(vm, v => v.SelectedMessage, c => c.Text,
            msg => msg is null ? "[grey50]Select a message[/]" : msg.HeaderText);

// double -> formatted markup
networkLabel.Bind(vm, v => v.NetworkKBps, c => c.Text,
                  v => $"[bold yellow]Network:[/] {v:F1} KB/s");

Two-way binding

BindTwoWay keeps source and target in lockstep in both directions: set the view-model property and the control updates; the user edits the control and the view model updates.

// PromptControl exposes its text as .Input (not .Text).
prompt.BindTwoWay(vm, v => v.Name, c => c.Input);

Two-way binding listens to PropertyChanged on both sides, with a re-entrancy guard so the two updates don't loop. A converter pair is available when the types differ:

edit.BindTwoWay(vm, v => v.Count, c => c.Text,
                toTarget: n => n.ToString(),
                toSource: s => int.TryParse(s, out var n) ? n : 0);

Which control properties are two-way-bindable?

Two-way binding requires the control property to raise PropertyChanged. These do (a representative list — all backed by the control's change notification):

Control Two-way property
CheckboxControl Checked
SliderControl Value
RangeSliderControl LowValue, HighValue
DropdownControl SelectedIndex
DatePickerControl SelectedDate
TimePickerControl SelectedTime
ListControl SelectedIndex
TabControl ActiveTabIndex
NavigationView SelectedIndex
PromptControl Input
MultilineEditControl Content
TableControl SelectedRowIndex
CollapsiblePanel IsExpanded
TreeNode IsExpanded, Text, TextColor, Tag

These all fire PropertyChanged on both the property setter and interactive (keyboard/mouse) mutation, so typing in a prompt, dragging a slider, or selecting a row flows back to the view model. (Display-only values like MarkupControl.Text, ProgressBarControl.Value, and BarGraphControl.Value are one-way targets.)


Binding on builders and menu items

Bind / BindTwoWay are also available fluently on control builders (the binding is applied when Build() runs) and on MenuItem:

// Builder — deferred until Build().
var bar = Controls.BarGraph()
    .Bind(vm, v => v.Cpu, c => c.Value)
    .Build();

// MenuItem — e.g. enable/disable from a CanExecute flag.
menuItem.Bind(vm, v => v.CanSave, m => m.IsEnabled);

Lifetime and disposal

Each binding is an IDisposable subscription stored in the control's Bindings collection. You don't manage it manually: BaseControl.Dispose() disposes the collection, and a window disposes all of its controls when it closes. So bindings are torn down automatically when the control (and its window) goes away — a binding never keeps a closed window alive.

If you bind to a long-lived view model from a short-lived control, this is exactly what you want: the binding dies with the control, not with the view model.


Why controls are INPC

Every control derives from BaseControl, which implements INotifyPropertyChanged and exposes a SetProperty helper used throughout the control library. That is why:

  • the target side of a binding needs no work from you, and
  • two-way binding works against control properties out of the box.

When you write your own custom control and want a property to be two-way-bindable, raise OnPropertyChanged(nameof(Prop)) from its setter (and from any interactive mutation path), the same way the built-in controls do.


NativeAOT and trimming

The binding layer is AOT- and trim-safe. Bind / BindTwoWay compile member-access Expression<Func<>> trees, but under NativeAOT (IsDynamicCodeSupported=false) System.Linq.Expressions falls back to its interpreter instead of Reflection.Emit, so the bindings run correctly in a native binary. The library's AOT smoke gate exercises this path. See AOT.md.


See also