Terminal UI (MVVM)
Build a Counter terminal app with an observable ViewModel, then display it two ways - the
imperative TuiView<T> and the declarative TuiComposedView<T> DSL.
What you'll build
A small Counter app where a CounterViewModel holds a Value property and an IncrementCommand.
You will see both ways to build the view:
- Imperative -
TuiView<T>: overrideBuildLayoutto add Terminal.Gui widgets, then overrideBindto wire them throughViewBinder. - Declarative -
TuiComposedView<T>: overrideComposeand return aTuiNodetree built with theUi.*DSL; bindings and layout are inferred from the node types.
Prerequisites
- .NET 10 SDK
dotnet add package SquidStd.Tui
Steps
1. Define the ViewModel
Derive from TuiViewModel (which extends ObservableObject from CommunityToolkit.Mvvm) and mark
observable properties and relay commands with the standard source-generator attributes.
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using SquidStd.Tui.ViewModels;
public sealed partial class CounterViewModel : TuiViewModel
{
[ObservableProperty]
private string _title = "Counter";
[ObservableProperty]
private string _value = "0";
[RelayCommand]
private void Increment() => Value = (int.Parse(Value) + 1).ToString();
}
2. Imperative view - TuiView<T>
Override BuildLayout to create and add Terminal.Gui widgets, then override Bind to wire them
through ViewBinder. The binder tracks property-changed notifications and updates widgets without
polling.
using Terminal.Gui;
using SquidStd.Tui.Views;
using SquidStd.Tui.Binders;
public sealed class CounterView : TuiView<CounterViewModel>
{
private Label _value = null!;
private Button _inc = null!;
protected override void BuildLayout()
{
_value = new Label { X = 1, Y = 1 };
_inc = new Button { X = 1, Y = 3, Text = "+1" };
Add(_value, _inc);
}
protected override void Bind(ViewBinder b)
{
b.OneWayTitle(ViewModel, x => x.Title, this);
b.OneWayText(ViewModel, x => x.Value, _value);
b.Command(_inc, ViewModel.IncrementCommand);
}
}
3. Declarative view - TuiComposedView<T>
Instead of BuildLayout + Bind, derive from TuiComposedView<T> and return a node tree from
Compose. The framework infers binding direction from the node type (Label → one-way, TextField →
two-way, Button → command) and applies layout automatically.
using SquidStd.Tui.Views;
using SquidStd.Tui.Dsl;
public sealed class CounterView : TuiComposedView<CounterViewModel>
{
protected override TuiNode<CounterViewModel> Compose() =>
Ui.VStack(
Ui.Label(x => x.Title),
Ui.Label(x => x.Value),
Ui.Button("+1", x => x.IncrementCommand));
}
Ui.VStack / Ui.HStack stack children vertically or horizontally and auto-assign Y/X
offsets. Ui.TextField(x => x.Name) is two-way by default; pass BindMode.OneWay to override.
4. Register and run
RegisterTui() adds the core TUI services and TuiApplicationHost to the DryIoc container.
RegisterView<TView, TViewModel>() registers the view for ViewModel-first resolution.
TuiApplicationHost.RunAsync<TViewModel>() resolves the root ViewModel, shows its view, and starts
the Terminal.Gui event loop.
using DryIoc;
using SquidStd.Tui.Extensions;
using SquidStd.Tui.Hosts;
var container = new Container().RegisterTui();
container.RegisterView<CounterView, CounterViewModel>();
await container.Resolve<TuiApplicationHost>().RunAsync<CounterViewModel>();
5. ViewModel-first navigation
Push a new ViewModel onto the navigation stack from inside an existing ViewModel via the injected
ITuiNavigator:
// inside CounterViewModel
await Navigator.NavigateToAsync<DetailViewModel>();
await Navigator.BackAsync();
The navigator resolves the view that was registered for DetailViewModel and shows it; BackAsync
pops the stack and restores the previous view.