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.
// 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 updatesYou 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.
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.
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");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);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.)
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);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.
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.
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.
- Tutorial 4 — Terminal Mail Client: a full MVVM app (master-detail
binding, a two-way compose dialog, a
TableControldata source). - Tutorial 5 — Terminal Music Player: one-way bindings with converters driving a "Now Playing" view.
- AOT.md: the AOT/trim story, including the binding interpreter fallback.