diff --git a/SquidStd.slnx b/SquidStd.slnx
index bbcd9f9d..b6e06793 100644
--- a/SquidStd.slnx
+++ b/SquidStd.slnx
@@ -41,6 +41,7 @@
+
@@ -63,6 +64,7 @@
+
diff --git a/samples/SquidStd.Samples.Tui/CounterView.cs b/samples/SquidStd.Samples.Tui/CounterView.cs
new file mode 100644
index 00000000..ec702153
--- /dev/null
+++ b/samples/SquidStd.Samples.Tui/CounterView.cs
@@ -0,0 +1,25 @@
+using SquidStd.Tui;
+using SquidStd.Tui.Binding;
+using Terminal.Gui.Views;
+
+namespace SquidStd.Samples.Tui;
+
+public sealed class CounterView : TuiView
+{
+ private Label _value = null!;
+ private Button _increment = null!;
+
+ protected override void BuildLayout()
+ {
+ _value = new Label { X = 1, Y = 1 };
+ _increment = new Button { X = 1, Y = 3, Text = "_Increment" };
+ Add(_value, _increment);
+ }
+
+ protected override void Bind(ViewBinder binder)
+ {
+ binder.OneWayTitle(ViewModel, x => x.Title, this);
+ binder.OneWayText(ViewModel, x => x.Value, _value);
+ binder.Command(_increment, ViewModel.IncrementCommand);
+ }
+}
diff --git a/samples/SquidStd.Samples.Tui/CounterViewModel.cs b/samples/SquidStd.Samples.Tui/CounterViewModel.cs
new file mode 100644
index 00000000..7f41c638
--- /dev/null
+++ b/samples/SquidStd.Samples.Tui/CounterViewModel.cs
@@ -0,0 +1,20 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using SquidStd.Tui;
+
+namespace SquidStd.Samples.Tui;
+
+public sealed partial class CounterViewModel : TuiViewModel
+{
+ [ObservableProperty]
+ private string _title = "SquidStd.Tui — Counter";
+
+ [ObservableProperty]
+ private string _value = "0";
+
+ [RelayCommand]
+ private void Increment()
+ {
+ Value = (int.Parse(Value) + 1).ToString();
+ }
+}
diff --git a/samples/SquidStd.Samples.Tui/Program.cs b/samples/SquidStd.Samples.Tui/Program.cs
new file mode 100644
index 00000000..06339c6f
--- /dev/null
+++ b/samples/SquidStd.Samples.Tui/Program.cs
@@ -0,0 +1,10 @@
+using DryIoc;
+using SquidStd.Samples.Tui;
+using SquidStd.Tui.Extensions;
+using SquidStd.Tui.Hosting;
+
+var container = new Container();
+container.RegisterTui();
+container.RegisterView();
+
+await container.Resolve().RunAsync();
diff --git a/samples/SquidStd.Samples.Tui/SquidStd.Samples.Tui.csproj b/samples/SquidStd.Samples.Tui/SquidStd.Samples.Tui.csproj
new file mode 100644
index 00000000..40bc8813
--- /dev/null
+++ b/samples/SquidStd.Samples.Tui/SquidStd.Samples.Tui.csproj
@@ -0,0 +1,14 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/src/SquidStd.Tui/Binding/ViewBinder.AutoBind.cs b/src/SquidStd.Tui/Binding/ViewBinder.AutoBind.cs
new file mode 100644
index 00000000..55c69aaa
--- /dev/null
+++ b/src/SquidStd.Tui/Binding/ViewBinder.AutoBind.cs
@@ -0,0 +1,81 @@
+using System.ComponentModel;
+using System.Reflection;
+using System.Windows.Input;
+using SquidStd.Tui.Internal;
+using Terminal.Gui.ViewBase;
+using Terminal.Gui.Views;
+
+namespace SquidStd.Tui.Binding;
+
+public sealed partial class ViewBinder
+{
+ ///
+ /// Convention binding: for each named subview, binds it to a matching ViewModel member by name
+ /// (e.g. NameField ↔ Name, SaveButton ↔ SaveCommand). Explicit bindings
+ /// declared before AutoBind win; AutoBind only fills the gaps it recognises (Label, TextField, Button).
+ ///
+ public void AutoBind(View view, INotifyPropertyChanged viewModel)
+ {
+ var vmType = viewModel.GetType();
+
+ foreach (var subview in view.SubViews)
+ {
+ var id = subview.Id;
+
+ if (string.IsNullOrEmpty(id))
+ {
+ continue;
+ }
+
+ switch (subview)
+ {
+ case Button button:
+ BindCommandByName(vmType, viewModel, ConventionNames.CommandName(id), button);
+ break;
+ case TextField field:
+ BindStringByName(vmType, viewModel, ConventionNames.MemberName(id), field, twoWay: true, null);
+ break;
+ case Label label:
+ BindStringByName(vmType, viewModel, ConventionNames.MemberName(id), null, twoWay: false, label);
+ break;
+ }
+ }
+ }
+
+ private void BindCommandByName(Type vmType, INotifyPropertyChanged vm, string memberName, Button button)
+ {
+ var property = vmType.GetProperty(memberName, BindingFlags.Public | BindingFlags.Instance);
+
+ if (property is not null && property.GetValue(vm) is ICommand command)
+ {
+ Command(button, command);
+ }
+ }
+
+ private void BindStringByName(
+ Type vmType, INotifyPropertyChanged vm, string memberName, TextField? field, bool twoWay, Label? label
+ )
+ {
+ var property = vmType.GetProperty(memberName, BindingFlags.Public | BindingFlags.Instance);
+
+ if (property is null || property.PropertyType != typeof(string))
+ {
+ return;
+ }
+
+ if (twoWay && field is not null)
+ {
+ TwoWay(
+ vm,
+ memberName,
+ () => field.Text = (string)(property.GetValue(vm) ?? string.Empty),
+ callback => field.ValueChanged += (_, _) => callback(),
+ () => property.SetValue(vm, field.Text)
+ );
+ }
+ else if (label is not null)
+ {
+ OneWay(vm, memberName, () => label.Text = (string)(property.GetValue(vm) ?? string.Empty));
+ }
+ }
+}
diff --git a/src/SquidStd.Tui/Binding/ViewBinder.Expressions.cs b/src/SquidStd.Tui/Binding/ViewBinder.Expressions.cs
new file mode 100644
index 00000000..c62354f9
--- /dev/null
+++ b/src/SquidStd.Tui/Binding/ViewBinder.Expressions.cs
@@ -0,0 +1,49 @@
+using System.ComponentModel;
+using System.Linq.Expressions;
+using SquidStd.Tui.Internal;
+
+namespace SquidStd.Tui.Binding;
+
+public sealed partial class ViewBinder
+{
+ /// One-way bind a source property to a target member, by expression.
+ public void OneWay(
+ TSource source,
+ Expression> sourceProperty,
+ TTarget target,
+ Expression> targetProperty
+ )
+ where TSource : INotifyPropertyChanged
+ {
+ var name = PropertyPath.NameOf(sourceProperty);
+ var read = sourceProperty.Compile();
+ var write = PropertyPath.Setter(targetProperty);
+
+ OneWay(source, name, () => write(target, read(source)));
+ }
+
+ /// Two-way bind a source property to a target member, by expression, given the target's change event.
+ public void TwoWay(
+ TSource source,
+ Expression> sourceProperty,
+ TTarget target,
+ Expression> targetProperty,
+ Action subscribeTargetChanged
+ )
+ where TSource : INotifyPropertyChanged
+ {
+ var name = PropertyPath.NameOf(sourceProperty);
+ var read = sourceProperty.Compile();
+ var readTarget = targetProperty.Compile();
+ var write = PropertyPath.Setter(targetProperty);
+ var writeSource = PropertyPath.Setter(sourceProperty);
+
+ TwoWay(
+ source,
+ name,
+ () => write(target, read(source)),
+ subscribeTargetChanged,
+ () => writeSource(source, readTarget(target))
+ );
+ }
+}
diff --git a/src/SquidStd.Tui/Binding/ViewBinder.Widgets.cs b/src/SquidStd.Tui/Binding/ViewBinder.Widgets.cs
new file mode 100644
index 00000000..7755e43e
--- /dev/null
+++ b/src/SquidStd.Tui/Binding/ViewBinder.Widgets.cs
@@ -0,0 +1,42 @@
+using System.ComponentModel;
+using System.Linq.Expressions;
+using System.Windows.Input;
+using Terminal.Gui.Views;
+
+namespace SquidStd.Tui.Binding;
+
+/// Convenience overloads that wire the core binder to concrete Terminal.Gui widgets.
+public sealed partial class ViewBinder
+{
+ /// One-way bind a source string property to a 's text.
+ public void OneWayText(TSource source, Expression> property, Label label)
+ where TSource : INotifyPropertyChanged
+ {
+ OneWay(source, property, label, l => l.Text);
+ }
+
+ /// One-way bind a source string property to a 's title.
+ public void OneWayTitle(TSource source, Expression> property, Window window)
+ where TSource : INotifyPropertyChanged
+ {
+ OneWay(source, property, window, w => w.Title);
+ }
+
+ /// Two-way bind a source string property to a .
+ public void TwoWay(TSource source, Expression> property, TextField field)
+ where TSource : INotifyPropertyChanged
+ {
+ // Terminal.Gui 2.4.16 exposes ValueChanged (EventHandler>)
+ // rather than a TextChanged event.
+ TwoWay(source, property, field, f => f.Text, callback => field.ValueChanged += (_, _) => callback());
+ }
+
+ /// Bind a command to a : Accepted triggers it, CanExecute drives Enabled.
+ public void Command(Button button, ICommand command)
+ {
+ // Use Accepted (post-accept, non-cancellable) rather than Accepting for this side-effect-only
+ // handler; Terminal.Gui 2.4.16 docs mark subscribing side effects to the cancellable Accepting
+ // phase as incorrect.
+ Command(command, enabled => button.Enabled = enabled, callback => button.Accepted += (_, _) => callback());
+ }
+}
diff --git a/src/SquidStd.Tui/Binding/ViewBinder.cs b/src/SquidStd.Tui/Binding/ViewBinder.cs
new file mode 100644
index 00000000..82a9385a
--- /dev/null
+++ b/src/SquidStd.Tui/Binding/ViewBinder.cs
@@ -0,0 +1,133 @@
+using System.ComponentModel;
+using System.Windows.Input;
+using SquidStd.Tui.Internal;
+
+namespace SquidStd.Tui.Binding;
+
+///
+/// Wires ViewModel () properties and commands to view targets, and
+/// owns the lifetime of every subscription it creates. Dispose to release them all.
+///
+public sealed partial class ViewBinder : IDisposable
+{
+ private readonly List _subscriptions = new();
+ private readonly Action _marshal;
+
+ /// Initialises the binder with an optional marshal action for UI-thread dispatch.
+ ///
+ /// Runs an update on the UI thread. Defaults to running inline; the host passes a delegate over
+ /// Application.Invoke so updates raised from background threads reach the Terminal.Gui loop.
+ ///
+ public ViewBinder(Action? marshal = null)
+ {
+ _marshal = marshal ?? (action => action());
+ }
+
+ /// Applies now and whenever changes.
+ public void OneWay(INotifyPropertyChanged source, string propertyName, Action apply)
+ {
+ apply();
+
+ void Handler(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName is null || string.Equals(e.PropertyName, propertyName, StringComparison.Ordinal))
+ {
+ _marshal(apply);
+ }
+ }
+
+ source.PropertyChanged += Handler;
+ _subscriptions.Add(new Unsubscriber(() => source.PropertyChanged -= Handler));
+ }
+
+ ///
+ /// Binds a source property both ways. runs on source changes;
+ /// runs when the target raises a change. A reentrancy guard stops
+ /// the source→target→source feedback loop.
+ ///
+ public void TwoWay(
+ INotifyPropertyChanged source,
+ string propertyName,
+ Action applyToTarget,
+ Action subscribeTargetChanged,
+ Action writeToSource
+ )
+ {
+ var guard = new ReentryGuard();
+
+ applyToTarget();
+
+ void SourceHandler(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName is not null && !string.Equals(e.PropertyName, propertyName, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ if (guard.IsBusy)
+ {
+ return;
+ }
+
+ _marshal(() =>
+ {
+ using (guard.Enter())
+ {
+ applyToTarget();
+ }
+ });
+ }
+
+ source.PropertyChanged += SourceHandler;
+ _subscriptions.Add(new Unsubscriber(() => source.PropertyChanged -= SourceHandler));
+
+ subscribeTargetChanged(() =>
+ {
+ if (guard.IsBusy)
+ {
+ return;
+ }
+
+ using (guard.Enter())
+ {
+ writeToSource();
+ }
+ });
+ }
+
+ ///
+ /// Binds a command to a control: activates the command (when it
+ /// can execute), and tracks .
+ ///
+ public void Command(ICommand command, Action setEnabled, Action subscribeTrigger)
+ {
+ setEnabled(command.CanExecute(null));
+
+ void CanHandler(object? sender, EventArgs e)
+ {
+ _marshal(() => setEnabled(command.CanExecute(null)));
+ }
+
+ command.CanExecuteChanged += CanHandler;
+ _subscriptions.Add(new Unsubscriber(() => command.CanExecuteChanged -= CanHandler));
+
+ subscribeTrigger(() =>
+ {
+ if (command.CanExecute(null))
+ {
+ command.Execute(null);
+ }
+ });
+ }
+
+ /// Disposes all active subscriptions created by this binder.
+ public void Dispose()
+ {
+ for (var i = 0; i < _subscriptions.Count; i++)
+ {
+ _subscriptions[i].Dispose();
+ }
+
+ _subscriptions.Clear();
+ }
+}
diff --git a/src/SquidStd.Tui/Extensions/RegisterTuiExtensions.cs b/src/SquidStd.Tui/Extensions/RegisterTuiExtensions.cs
new file mode 100644
index 00000000..f7797450
--- /dev/null
+++ b/src/SquidStd.Tui/Extensions/RegisterTuiExtensions.cs
@@ -0,0 +1,38 @@
+using DryIoc;
+using SquidStd.Tui.Hosting;
+using SquidStd.Tui.Interfaces;
+using SquidStd.Tui.Internal;
+using SquidStd.Tui.Navigation;
+
+namespace SquidStd.Tui.Extensions;
+
+/// DryIoc registration helpers for the TUI module.
+public static class RegisterTuiExtensions
+{
+ extension(IContainer container)
+ {
+ /// Registers the navigator, view registry, view host and application host.
+ public IContainer RegisterTui()
+ {
+ container.Register(Reuse.Singleton);
+ container.Register(Reuse.Singleton);
+ container.RegisterMapping();
+ container.Register(Reuse.Singleton);
+ container.Register(Reuse.Singleton);
+
+ return container;
+ }
+
+ /// Registers a View/ViewModel pair and records the mapping for navigation.
+ public IContainer RegisterView()
+ where TView : class, ITuiView
+ where TViewModel : TuiViewModel
+ {
+ container.Register(Reuse.Transient);
+ container.Register(Reuse.Transient);
+ container.Resolve().Map(typeof(TViewModel), typeof(TView));
+
+ return container;
+ }
+ }
+}
diff --git a/src/SquidStd.Tui/Hosting/TerminalGuiViewHost.cs b/src/SquidStd.Tui/Hosting/TerminalGuiViewHost.cs
new file mode 100644
index 00000000..86dcd060
--- /dev/null
+++ b/src/SquidStd.Tui/Hosting/TerminalGuiViewHost.cs
@@ -0,0 +1,37 @@
+using SquidStd.Tui.Interfaces;
+using Terminal.Gui.ViewBase;
+
+namespace SquidStd.Tui.Hosting;
+
+///
+/// Shows views as full-size children of a shell container supplied by .
+/// The container is set before the first navigation, so never no-ops on a null top view
+/// (Terminal.Gui 2.4.16 has no Application.Top; the running top view is null until the loop starts).
+///
+public sealed class TerminalGuiViewHost : ITuiViewHost
+{
+ /// The shell the navigator's screens are added to. Set by the application host before running.
+ public View? Container { get; set; }
+
+ /// Adds as a full-size child of and gives it focus.
+ public void Show(object view)
+ {
+ if (Container is not null && view is View concrete)
+ {
+ concrete.Width = Dim.Fill();
+ concrete.Height = Dim.Fill();
+ Container.Add(concrete);
+ concrete.SetFocus();
+ }
+ }
+
+ /// Removes from and disposes it.
+ public void Remove(object view)
+ {
+ if (Container is not null && view is View concrete)
+ {
+ Container.Remove(concrete);
+ concrete.Dispose();
+ }
+ }
+}
diff --git a/src/SquidStd.Tui/Hosting/TuiApplicationHost.cs b/src/SquidStd.Tui/Hosting/TuiApplicationHost.cs
new file mode 100644
index 00000000..265ae398
--- /dev/null
+++ b/src/SquidStd.Tui/Hosting/TuiApplicationHost.cs
@@ -0,0 +1,51 @@
+using SquidStd.Tui.Interfaces;
+using Terminal.Gui.App;
+using Terminal.Gui.Drawing;
+using Terminal.Gui.ViewBase;
+using Terminal.Gui.Views;
+
+namespace SquidStd.Tui.Hosting;
+
+///
+/// Boots a Terminal.Gui application: initialises the driver, creates a full-screen shell, navigates to the
+/// root ViewModel (whose view is added to the shell), runs the event loop, then shuts the driver down.
+///
+public sealed class TuiApplicationHost
+{
+ private readonly ITuiNavigator _navigator;
+ private readonly TerminalGuiViewHost _viewHost;
+
+ public TuiApplicationHost(ITuiNavigator navigator, TerminalGuiViewHost viewHost)
+ {
+ _navigator = navigator;
+ _viewHost = viewHost;
+ }
+
+ /// Runs the application with as the first screen.
+ public async Task RunAsync()
+ where TRootViewModel : TuiViewModel
+ {
+ Application.Init();
+
+ try
+ {
+ var shell = new Window
+ {
+ Width = Dim.Fill(),
+ Height = Dim.Fill(),
+ BorderStyle = LineStyle.None
+ };
+
+ _viewHost.Container = shell;
+
+ await _navigator.NavigateToAsync();
+
+ Application.Run(shell);
+ }
+ finally
+ {
+ _viewHost.Container = null;
+ Application.Shutdown();
+ }
+ }
+}
diff --git a/src/SquidStd.Tui/Interfaces/ITuiNavigator.cs b/src/SquidStd.Tui/Interfaces/ITuiNavigator.cs
new file mode 100644
index 00000000..1452ed8f
--- /dev/null
+++ b/src/SquidStd.Tui/Interfaces/ITuiNavigator.cs
@@ -0,0 +1,17 @@
+using SquidStd.Tui;
+
+namespace SquidStd.Tui.Interfaces;
+
+/// ViewModel-first navigation over a stack of screens.
+public interface ITuiNavigator
+{
+ /// Number of screens currently on the navigation stack.
+ int Depth { get; }
+
+ /// Resolves and its view and pushes it onto the stack.
+ Task NavigateToAsync(CancellationToken cancellationToken = default)
+ where TViewModel : TuiViewModel;
+
+ /// Pops the current screen and reactivates the one beneath it.
+ Task BackAsync(CancellationToken cancellationToken = default);
+}
diff --git a/src/SquidStd.Tui/Interfaces/ITuiView.cs b/src/SquidStd.Tui/Interfaces/ITuiView.cs
new file mode 100644
index 00000000..928f2944
--- /dev/null
+++ b/src/SquidStd.Tui/Interfaces/ITuiView.cs
@@ -0,0 +1,11 @@
+namespace SquidStd.Tui.Interfaces;
+
+/// Non-generic handle the navigator uses to bind a ViewModel and initialise a view.
+public interface ITuiView
+{
+ /// Assigns the ViewModel instance to the view.
+ void Bind(object viewModel);
+
+ /// Builds the layout and declares bindings. Called once after .
+ void Initialize();
+}
diff --git a/src/SquidStd.Tui/Interfaces/ITuiViewHost.cs b/src/SquidStd.Tui/Interfaces/ITuiViewHost.cs
new file mode 100644
index 00000000..838d2b43
--- /dev/null
+++ b/src/SquidStd.Tui/Interfaces/ITuiViewHost.cs
@@ -0,0 +1,11 @@
+namespace SquidStd.Tui.Interfaces;
+
+/// Displays and removes views. Abstracts the Terminal.Gui application from the navigator.
+public interface ITuiViewHost
+{
+ /// Shows a view as the current screen.
+ void Show(object view);
+
+ /// Removes a previously shown view.
+ void Remove(object view);
+}
diff --git a/src/SquidStd.Tui/Internal/ConventionNames.cs b/src/SquidStd.Tui/Internal/ConventionNames.cs
new file mode 100644
index 00000000..828200f2
--- /dev/null
+++ b/src/SquidStd.Tui/Internal/ConventionNames.cs
@@ -0,0 +1,25 @@
+namespace SquidStd.Tui.Internal;
+
+/// Maps a widget id to candidate ViewModel member names for convention binding.
+internal static class ConventionNames
+{
+ private static readonly string[] _suffixes = ["Field", "Label", "Button", "Text", "View", "Box", "List"];
+
+ public static string MemberName(string widgetId)
+ {
+ foreach (var suffix in _suffixes)
+ {
+ if (widgetId.Length > suffix.Length && widgetId.EndsWith(suffix, StringComparison.Ordinal))
+ {
+ return widgetId[..^suffix.Length];
+ }
+ }
+
+ return widgetId;
+ }
+
+ public static string CommandName(string widgetId)
+ {
+ return MemberName(widgetId) + "Command";
+ }
+}
diff --git a/src/SquidStd.Tui/Internal/PropertyPath.cs b/src/SquidStd.Tui/Internal/PropertyPath.cs
new file mode 100644
index 00000000..8510a725
--- /dev/null
+++ b/src/SquidStd.Tui/Internal/PropertyPath.cs
@@ -0,0 +1,32 @@
+using System.Linq.Expressions;
+
+namespace SquidStd.Tui.Internal;
+
+/// Extracts member name, getter and setter from a member-access expression.
+internal static class PropertyPath
+{
+ public static string NameOf(Expression> expression)
+ {
+ if (expression.Body is MemberExpression member)
+ {
+ return member.Member.Name;
+ }
+
+ throw new ArgumentException("Expression must be a direct property or field access.", nameof(expression));
+ }
+
+ public static Action Setter(Expression> expression)
+ {
+ if (expression.Body is not MemberExpression member)
+ {
+ throw new ArgumentException("Expression must be a direct property or field access.", nameof(expression));
+ }
+
+ var targetParam = Expression.Parameter(typeof(TSource), "target");
+ var valueParam = Expression.Parameter(typeof(TValue), "value");
+ var memberAccess = Expression.MakeMemberAccess(targetParam, member.Member);
+ var assign = Expression.Assign(memberAccess, valueParam);
+
+ return Expression.Lambda>(assign, targetParam, valueParam).Compile();
+ }
+}
diff --git a/src/SquidStd.Tui/Internal/ReentryGuard.cs b/src/SquidStd.Tui/Internal/ReentryGuard.cs
new file mode 100644
index 00000000..1bf5f195
--- /dev/null
+++ b/src/SquidStd.Tui/Internal/ReentryGuard.cs
@@ -0,0 +1,34 @@
+namespace SquidStd.Tui.Internal;
+
+/// Single-threaded reentrancy flag used to stop two-way binding write-back loops.
+internal sealed class ReentryGuard
+{
+ private bool _busy;
+
+ public bool IsBusy
+ {
+ get { return _busy; }
+ }
+
+ public IDisposable Enter()
+ {
+ _busy = true;
+
+ return new Scope(this);
+ }
+
+ private sealed class Scope : IDisposable
+ {
+ private readonly ReentryGuard _owner;
+
+ public Scope(ReentryGuard owner)
+ {
+ _owner = owner;
+ }
+
+ public void Dispose()
+ {
+ _owner._busy = false;
+ }
+ }
+}
diff --git a/src/SquidStd.Tui/Internal/TuiViewRegistry.cs b/src/SquidStd.Tui/Internal/TuiViewRegistry.cs
new file mode 100644
index 00000000..e1e0b5c6
--- /dev/null
+++ b/src/SquidStd.Tui/Internal/TuiViewRegistry.cs
@@ -0,0 +1,24 @@
+namespace SquidStd.Tui.Internal;
+
+/// Maps a ViewModel type to the View type that renders it.
+public sealed class TuiViewRegistry
+{
+ private readonly Dictionary _map = new();
+
+ /// Registers a mapping from to the that renders it.
+ public void Map(Type viewModelType, Type viewType)
+ {
+ _map[viewModelType] = viewType;
+ }
+
+ /// Returns the view type registered for , or throws if none is registered.
+ public Type ViewTypeFor(Type viewModelType)
+ {
+ if (_map.TryGetValue(viewModelType, out var viewType))
+ {
+ return viewType;
+ }
+
+ throw new InvalidOperationException($"No view registered for ViewModel '{viewModelType.Name}'.");
+ }
+}
diff --git a/src/SquidStd.Tui/Internal/Unsubscriber.cs b/src/SquidStd.Tui/Internal/Unsubscriber.cs
new file mode 100644
index 00000000..9c71e2f7
--- /dev/null
+++ b/src/SquidStd.Tui/Internal/Unsubscriber.cs
@@ -0,0 +1,19 @@
+namespace SquidStd.Tui.Internal;
+
+/// An that runs a single unsubscribe action once.
+internal sealed class Unsubscriber : IDisposable
+{
+ private Action? _unsubscribe;
+
+ public Unsubscriber(Action unsubscribe)
+ {
+ _unsubscribe = unsubscribe;
+ }
+
+ public void Dispose()
+ {
+ var action = _unsubscribe;
+ _unsubscribe = null;
+ action?.Invoke();
+ }
+}
diff --git a/src/SquidStd.Tui/Navigation/TuiNavigator.cs b/src/SquidStd.Tui/Navigation/TuiNavigator.cs
new file mode 100644
index 00000000..3c7a3d93
--- /dev/null
+++ b/src/SquidStd.Tui/Navigation/TuiNavigator.cs
@@ -0,0 +1,75 @@
+using DryIoc;
+using SquidStd.Tui.Interfaces;
+using SquidStd.Tui.Internal;
+
+namespace SquidStd.Tui.Navigation;
+
+/// Stack-based ViewModel-first navigator: resolves the ViewModel and its view from the container.
+public sealed class TuiNavigator : ITuiNavigator
+{
+ private readonly IResolver _resolver;
+ private readonly TuiViewRegistry _registry;
+ private readonly ITuiViewHost _viewHost;
+ private readonly Stack _stack = new();
+
+ public int Depth
+ {
+ get { return _stack.Count; }
+ }
+
+ public TuiNavigator(IResolver resolver, TuiViewRegistry registry, ITuiViewHost viewHost)
+ {
+ _resolver = resolver;
+ _registry = registry;
+ _viewHost = viewHost;
+ }
+
+ public async Task NavigateToAsync(CancellationToken cancellationToken = default)
+ where TViewModel : TuiViewModel
+ {
+ var viewModel = (TViewModel)_resolver.Resolve(typeof(TViewModel));
+ viewModel.Navigator = this;
+
+ var viewType = _registry.ViewTypeFor(typeof(TViewModel));
+ var view = (ITuiView)_resolver.Resolve(viewType);
+ view.Bind(viewModel);
+ view.Initialize();
+
+ if (_stack.Count > 0)
+ {
+ await _stack.Peek().ViewModel.OnDeactivatedAsync();
+ }
+
+ _stack.Push(new Screen(viewModel, view));
+ _viewHost.Show(view);
+
+ await viewModel.OnActivatedAsync();
+ }
+
+ public async Task BackAsync(CancellationToken cancellationToken = default)
+ {
+ if (_stack.Count <= 1)
+ {
+ return;
+ }
+
+ var current = _stack.Pop();
+ _viewHost.Remove(current.View);
+ await current.ViewModel.OnDeactivatedAsync();
+
+ var beneath = _stack.Peek();
+ await beneath.ViewModel.OnActivatedAsync();
+ }
+
+ private sealed class Screen
+ {
+ public TuiViewModel ViewModel { get; }
+ public ITuiView View { get; }
+
+ public Screen(TuiViewModel viewModel, ITuiView view)
+ {
+ ViewModel = viewModel;
+ View = view;
+ }
+ }
+}
diff --git a/src/SquidStd.Tui/README.md b/src/SquidStd.Tui/README.md
new file mode 100644
index 00000000..cda531ff
--- /dev/null
+++ b/src/SquidStd.Tui/README.md
@@ -0,0 +1,64 @@
+SquidStd.Tui
+
+MVVM for terminal apps, built on **Terminal.Gui v2** and **CommunityToolkit.Mvvm**. Observable
+ViewModels, a View↔ViewModel binder (fluent + opt-in convention), ViewModel-first navigation, and DryIoc
+wiring.
+
+## Install
+
+```bash
+dotnet add package SquidStd.Tui
+```
+
+## Usage
+
+```csharp
+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();
+}
+
+public sealed class CounterView : TuiView
+{
+ 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);
+ }
+}
+
+// boot
+var container = new Container().RegisterTui();
+container.RegisterView();
+await container.Resolve().RunAsync();
+```
+
+## Key types
+
+| Type | Purpose |
+|------|---------|
+| `TuiViewModel` | ViewModel base (ObservableObject + activation hooks + `Navigator`). |
+| `TuiView` | View base: a Terminal.Gui `Window` with a typed ViewModel and a binder. |
+| `ViewBinder` | One-way / two-way / command binding; fluent typed + `AutoBind` by convention. |
+| `ITuiNavigator` | ViewModel-first stack navigation (`NavigateToAsync`, `BackAsync`). |
+| `TuiApplicationHost` | Boots the Terminal.Gui loop with a root ViewModel. |
+| `RegisterTui()` / `RegisterView<,>()` | DryIoc registration. |
+
+## License
+
+MIT — part of [SquidStd](https://github.com/tgiachi/squid-std).
diff --git a/src/SquidStd.Tui/SquidStd.Tui.csproj b/src/SquidStd.Tui/SquidStd.Tui.csproj
new file mode 100644
index 00000000..8e30afb1
--- /dev/null
+++ b/src/SquidStd.Tui/SquidStd.Tui.csproj
@@ -0,0 +1,20 @@
+
+
+
+ net10.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/SquidStd.Tui/TuiView.cs b/src/SquidStd.Tui/TuiView.cs
new file mode 100644
index 00000000..02926395
--- /dev/null
+++ b/src/SquidStd.Tui/TuiView.cs
@@ -0,0 +1,53 @@
+using SquidStd.Tui.Binding;
+using SquidStd.Tui.Interfaces;
+using Terminal.Gui.App;
+using Terminal.Gui.Views;
+
+namespace SquidStd.Tui;
+
+///
+/// Base class for views: a Terminal.Gui that owns a typed ViewModel and a
+/// . Implement to create widgets and
+/// to declare bindings; both run once during .
+///
+/// The ViewModel type for this view.
+public abstract class TuiView : Window, ITuiView
+ where TViewModel : TuiViewModel
+{
+ private readonly ViewBinder _binder;
+
+ /// The bound ViewModel. Set by the navigator before .
+ protected TViewModel ViewModel { get; private set; } = null!;
+
+ protected TuiView()
+ {
+ _binder = new ViewBinder(Application.Invoke);
+ }
+
+ void ITuiView.Bind(object viewModel)
+ {
+ ViewModel = (TViewModel)viewModel;
+ }
+
+ void ITuiView.Initialize()
+ {
+ BuildLayout();
+ Bind(_binder);
+ }
+
+ /// Creates the Terminal.Gui widgets for this view.
+ protected abstract void BuildLayout();
+
+ /// Declares the bindings between and the widgets.
+ protected abstract void Bind(ViewBinder binder);
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _binder.Dispose();
+ }
+
+ base.Dispose(disposing);
+ }
+}
diff --git a/src/SquidStd.Tui/TuiViewModel.cs b/src/SquidStd.Tui/TuiViewModel.cs
new file mode 100644
index 00000000..6566658b
--- /dev/null
+++ b/src/SquidStd.Tui/TuiViewModel.cs
@@ -0,0 +1,27 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using SquidStd.Tui.Interfaces;
+
+namespace SquidStd.Tui;
+
+///
+/// Base class for TUI ViewModels. Extends (so CommunityToolkit's
+/// [ObservableProperty]/[RelayCommand] generators apply) and adds navigation access plus
+/// activation lifecycle hooks the navigator invokes.
+///
+public abstract class TuiViewModel : ObservableObject
+{
+ /// The navigator, assigned by the navigation system when the screen is pushed.
+ public ITuiNavigator Navigator { get; internal set; } = null!;
+
+ /// Invoked after the screen becomes active. Default is a no-op.
+ public virtual ValueTask OnActivatedAsync()
+ {
+ return ValueTask.CompletedTask;
+ }
+
+ /// Invoked after the screen is removed or hidden. Default is a no-op.
+ public virtual ValueTask OnDeactivatedAsync()
+ {
+ return ValueTask.CompletedTask;
+ }
+}
diff --git a/tests/SquidStd.Tests/SquidStd.Tests.csproj b/tests/SquidStd.Tests/SquidStd.Tests.csproj
index a2127844..70fb3401 100644
--- a/tests/SquidStd.Tests/SquidStd.Tests.csproj
+++ b/tests/SquidStd.Tests/SquidStd.Tests.csproj
@@ -74,6 +74,7 @@
+
diff --git a/tests/SquidStd.Tests/Tui/Binding/ViewBinderAutoBindTests.cs b/tests/SquidStd.Tests/Tui/Binding/ViewBinderAutoBindTests.cs
new file mode 100644
index 00000000..220936cf
--- /dev/null
+++ b/tests/SquidStd.Tests/Tui/Binding/ViewBinderAutoBindTests.cs
@@ -0,0 +1,164 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using SquidStd.Tui.Binding;
+using Terminal.Gui.ViewBase;
+using Terminal.Gui.Views;
+
+namespace SquidStd.Tests.Tui.Binding;
+
+public partial class ViewBinderAutoBindTests
+{
+ private sealed partial class AutoBindViewModel : ObservableObject
+ {
+ [ObservableProperty]
+ private string _title = string.Empty;
+
+ [ObservableProperty]
+ private string _name = string.Empty;
+
+ // Wrong type on purpose — used by the "wrong type" skip-path test.
+ public int Count { get; } = 42;
+
+ private bool _canSave;
+
+ [RelayCommand(CanExecute = nameof(CanSave))]
+ private void Save() { }
+
+ private bool CanSave() => _canSave;
+
+ public void SetCanSave(bool value)
+ {
+ _canSave = value;
+ SaveCommand.NotifyCanExecuteChanged();
+ }
+ }
+
+ // -------------------------------------------------------------------
+ // Label one-way
+ // -------------------------------------------------------------------
+
+ [Fact]
+ public void AutoBind_Label_AppliesInitialValue()
+ {
+ var vm = new AutoBindViewModel { Title = "Hello" };
+ var parent = new View();
+ var label = new Label { Id = "TitleLabel" };
+ parent.Add(label);
+
+ var binder = new ViewBinder();
+ binder.AutoBind(parent, vm);
+
+ Assert.Equal("Hello", label.Text);
+ }
+
+ [Fact]
+ public void AutoBind_Label_UpdatesWhenVmPropertyChanges()
+ {
+ var vm = new AutoBindViewModel { Title = "initial" };
+ var parent = new View();
+ var label = new Label { Id = "TitleLabel" };
+ parent.Add(label);
+
+ var binder = new ViewBinder();
+ binder.AutoBind(parent, vm);
+
+ vm.Title = "updated";
+
+ Assert.Equal("updated", label.Text);
+ }
+
+ // -------------------------------------------------------------------
+ // TextField two-way (vm→field direction; writeback omitted headless)
+ // -------------------------------------------------------------------
+
+ [Fact]
+ public void AutoBind_TextField_AppliesInitialValue()
+ {
+ var vm = new AutoBindViewModel { Name = "squid" };
+ var parent = new View();
+ var field = new TextField { Id = "NameField" };
+ parent.Add(field);
+
+ var binder = new ViewBinder();
+ binder.AutoBind(parent, vm);
+
+ Assert.Equal("squid", field.Text);
+ }
+
+ [Fact]
+ public void AutoBind_TextField_UpdatesFieldWhenVmPropertyChanges()
+ {
+ var vm = new AutoBindViewModel { Name = "before" };
+ var parent = new View();
+ var field = new TextField { Id = "NameField" };
+ parent.Add(field);
+
+ var binder = new ViewBinder();
+ binder.AutoBind(parent, vm);
+
+ vm.Name = "after";
+
+ Assert.Equal("after", field.Text);
+ }
+
+ // -------------------------------------------------------------------
+ // Button command — CanExecute drives Enabled
+ // -------------------------------------------------------------------
+
+ [Fact]
+ public void AutoBind_Button_TracksCanExecute()
+ {
+ var vm = new AutoBindViewModel(); // CanSave == false initially
+ var parent = new View();
+ var button = new Button { Id = "SaveButton" };
+ parent.Add(button);
+
+ var binder = new ViewBinder();
+ binder.AutoBind(parent, vm);
+
+ Assert.False(button.Enabled); // initially disabled because CanSave == false
+
+ vm.SetCanSave(true);
+
+ Assert.True(button.Enabled);
+
+ vm.SetCanSave(false);
+
+ Assert.False(button.Enabled);
+ }
+
+ // -------------------------------------------------------------------
+ // Skip paths — no throw, no unintended side-effects
+ // -------------------------------------------------------------------
+
+ [Fact]
+ public void AutoBind_UnknownId_DoesNotThrow()
+ {
+ // "Unknown" maps to no VM member — should silently skip.
+ var vm = new AutoBindViewModel();
+ var parent = new View();
+ var label = new Label { Id = "UnknownLabel" };
+ parent.Add(label);
+
+ var binder = new ViewBinder();
+ var ex = Record.Exception(() => binder.AutoBind(parent, vm));
+
+ Assert.Null(ex);
+ }
+
+ [Fact]
+ public void AutoBind_WrongPropertyType_DoesNotThrow()
+ {
+ // "Count" is int, not string — BindStringByName returns early without throwing.
+ var vm = new AutoBindViewModel();
+ var parent = new View();
+ var label = new Label { Id = "CountLabel" };
+ parent.Add(label);
+
+ var binder = new ViewBinder();
+ var ex = Record.Exception(() => binder.AutoBind(parent, vm));
+
+ Assert.Null(ex);
+ Assert.Equal(string.Empty, label.Text); // left untouched
+ }
+}
diff --git a/tests/SquidStd.Tests/Tui/Binding/ViewBinderCommandTests.cs b/tests/SquidStd.Tests/Tui/Binding/ViewBinderCommandTests.cs
new file mode 100644
index 00000000..14101e56
--- /dev/null
+++ b/tests/SquidStd.Tests/Tui/Binding/ViewBinderCommandTests.cs
@@ -0,0 +1,44 @@
+using CommunityToolkit.Mvvm.Input;
+using SquidStd.Tui.Binding;
+
+namespace SquidStd.Tests.Tui.Binding;
+
+public class ViewBinderCommandTests
+{
+ [Fact]
+ public void Command_ExecutesTrigger_AndReflectsCanExecute()
+ {
+ var executed = 0;
+ var canRun = false;
+ // ReSharper disable once AccessToModifiedClosure
+ var command = new RelayCommand(() => executed++, () => canRun);
+ var enabled = true;
+ Action? trigger = null;
+ var binder = new ViewBinder();
+
+ binder.Command(command, isEnabled => enabled = isEnabled, cb => trigger = cb);
+
+ Assert.False(enabled); // initial CanExecute == false -> disabled
+
+ canRun = true;
+ command.NotifyCanExecuteChanged();
+ Assert.True(enabled); // CanExecuteChanged -> enabled
+
+ trigger!(); // user activates the control
+ Assert.Equal(1, executed);
+ }
+
+ [Fact]
+ public void Command_DoesNotExecuteWhenCanExecuteFalse()
+ {
+ var executed = 0;
+ var command = new RelayCommand(() => executed++, () => false);
+ Action? trigger = null;
+ var binder = new ViewBinder();
+ binder.Command(command, _ => { }, cb => trigger = cb);
+
+ trigger!();
+
+ Assert.Equal(0, executed);
+ }
+}
diff --git a/tests/SquidStd.Tests/Tui/Binding/ViewBinderOneWayTests.cs b/tests/SquidStd.Tests/Tui/Binding/ViewBinderOneWayTests.cs
new file mode 100644
index 00000000..9a831f62
--- /dev/null
+++ b/tests/SquidStd.Tests/Tui/Binding/ViewBinderOneWayTests.cs
@@ -0,0 +1,66 @@
+using System.ComponentModel;
+using SquidStd.Tui.Binding;
+
+namespace SquidStd.Tests.Tui.Binding;
+
+public class ViewBinderOneWayTests
+{
+ private sealed class FakeViewModel : INotifyPropertyChanged
+ {
+ private string _title = string.Empty;
+
+ public string Title
+ {
+ get { return _title; }
+ set
+ {
+ _title = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Title)));
+ }
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+ }
+
+ [Fact]
+ public void OneWay_AppliesInitialValueAndUpdates()
+ {
+ var vm = new FakeViewModel { Title = "first" };
+ var applied = string.Empty;
+ var binder = new ViewBinder();
+
+ binder.OneWay(vm, nameof(FakeViewModel.Title), () => applied = vm.Title);
+
+ Assert.Equal("first", applied);
+
+ vm.Title = "second";
+ Assert.Equal("second", applied);
+ }
+
+ [Fact]
+ public void Dispose_StopsUpdates()
+ {
+ var vm = new FakeViewModel();
+ var applied = string.Empty;
+ var binder = new ViewBinder();
+ binder.OneWay(vm, nameof(FakeViewModel.Title), () => applied = vm.Title);
+
+ binder.Dispose();
+ vm.Title = "after-dispose";
+
+ Assert.Equal(string.Empty, applied);
+ }
+
+ [Fact]
+ public void OneWay_UsesMarshal()
+ {
+ var vm = new FakeViewModel();
+ var marshalled = 0;
+ var binder = new ViewBinder(action => { marshalled++; action(); });
+ binder.OneWay(vm, nameof(FakeViewModel.Title), () => { });
+
+ vm.Title = "x";
+
+ Assert.Equal(1, marshalled); // change marshalled once; the initial apply is NOT marshalled
+ }
+}
diff --git a/tests/SquidStd.Tests/Tui/Binding/ViewBinderTwoWayTests.cs b/tests/SquidStd.Tests/Tui/Binding/ViewBinderTwoWayTests.cs
new file mode 100644
index 00000000..586cff1d
--- /dev/null
+++ b/tests/SquidStd.Tests/Tui/Binding/ViewBinderTwoWayTests.cs
@@ -0,0 +1,87 @@
+using System.ComponentModel;
+using SquidStd.Tui.Binding;
+
+namespace SquidStd.Tests.Tui.Binding;
+
+public class ViewBinderTwoWayTests
+{
+ private sealed class FakeViewModel : INotifyPropertyChanged
+ {
+ private string _name = string.Empty;
+
+ public string Name
+ {
+ get { return _name; }
+ set
+ {
+ if (string.Equals(_name, value, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ _name = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Name)));
+ }
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+ }
+
+ private sealed class FakeField
+ {
+ private string _text = string.Empty;
+
+ public string Text
+ {
+ get { return _text; }
+ set { _text = value; }
+ }
+
+ public event Action? Changed;
+
+ public void UserTypes(string value)
+ {
+ _text = value;
+ Changed?.Invoke();
+ }
+ }
+
+ [Fact]
+ public void TwoWay_PropagatesBothDirections_WithoutLooping()
+ {
+ var vm = new FakeViewModel { Name = "init" };
+ var field = new FakeField();
+ var binder = new ViewBinder();
+
+ binder.TwoWay(
+ vm, nameof(FakeViewModel.Name),
+ applyToTarget: () => field.Text = vm.Name,
+ subscribeTargetChanged: cb => field.Changed += () => cb(),
+ writeToSource: () => vm.Name = field.Text
+ );
+
+ Assert.Equal("init", field.Text);
+
+ vm.Name = "from-vm";
+ Assert.Equal("from-vm", field.Text);
+
+ field.UserTypes("from-ui");
+ Assert.Equal("from-ui", vm.Name);
+ }
+
+ [Fact]
+ public void TwoWay_Typed_BindsViaExpressions()
+ {
+ var vm = new FakeViewModel { Name = "a" };
+ var field = new FakeField();
+ var binder = new ViewBinder();
+
+ binder.TwoWay(vm, x => x.Name, field, f => f.Text, cb => field.Changed += () => cb());
+
+ vm.Name = "b";
+ Assert.Equal("b", field.Text);
+
+ field.UserTypes("c");
+ Assert.Equal("c", vm.Name);
+ }
+}
diff --git a/tests/SquidStd.Tests/Tui/Extensions/RegisterTuiExtensionsTests.cs b/tests/SquidStd.Tests/Tui/Extensions/RegisterTuiExtensionsTests.cs
new file mode 100644
index 00000000..bc3e3cf5
--- /dev/null
+++ b/tests/SquidStd.Tests/Tui/Extensions/RegisterTuiExtensionsTests.cs
@@ -0,0 +1,53 @@
+using DryIoc;
+using SquidStd.Tui;
+using SquidStd.Tui.Extensions;
+using SquidStd.Tui.Hosting;
+using SquidStd.Tui.Interfaces;
+using SquidStd.Tui.Internal;
+
+namespace SquidStd.Tests.Tui.Extensions;
+
+public class RegisterTuiExtensionsTests
+{
+ private sealed class HomeViewModel : TuiViewModel
+ {
+ }
+
+ private sealed class HomeView : ITuiView
+ {
+ public void Bind(object viewModel)
+ {
+ }
+
+ public void Initialize()
+ {
+ }
+ }
+
+ [Fact]
+ public void RegisterTui_RegistersNavigatorAndRegistry()
+ {
+ var container = new Container();
+
+ container.RegisterTui();
+
+ Assert.NotNull(container.Resolve());
+ Assert.NotNull(container.Resolve());
+ Assert.NotNull(container.Resolve());
+ Assert.Same(container.Resolve(), container.Resolve());
+ }
+
+ [Fact]
+ public void RegisterView_MapsAndResolvesBothTypes()
+ {
+ var container = new Container();
+ container.RegisterTui();
+
+ container.RegisterView();
+
+ var registry = container.Resolve();
+ Assert.Equal(typeof(HomeView), registry.ViewTypeFor(typeof(HomeViewModel)));
+ Assert.NotNull(container.Resolve());
+ Assert.NotNull(container.Resolve());
+ }
+}
diff --git a/tests/SquidStd.Tests/Tui/Hosting/TerminalGuiViewHostTests.cs b/tests/SquidStd.Tests/Tui/Hosting/TerminalGuiViewHostTests.cs
new file mode 100644
index 00000000..3b8a9952
--- /dev/null
+++ b/tests/SquidStd.Tests/Tui/Hosting/TerminalGuiViewHostTests.cs
@@ -0,0 +1,32 @@
+using SquidStd.Tui.Hosting;
+using Terminal.Gui.ViewBase;
+
+namespace SquidStd.Tests.Tui.Hosting;
+
+public class TerminalGuiViewHostTests
+{
+ [Fact]
+ public void Show_AddsViewToContainer_AndRemoveDetachesIt()
+ {
+ var host = new TerminalGuiViewHost();
+ var shell = new View();
+ host.Container = shell;
+
+ var screen = new View();
+ host.Show(screen);
+
+ Assert.Contains(screen, shell.SubViews);
+
+ host.Remove(screen);
+
+ Assert.DoesNotContain(screen, shell.SubViews);
+ }
+
+ [Fact]
+ public void Show_WithNullContainer_DoesNotThrow()
+ {
+ var host = new TerminalGuiViewHost();
+
+ host.Show(new View()); // no container set -> no-op, must not throw
+ }
+}
diff --git a/tests/SquidStd.Tests/Tui/Internal/ConventionNamesTests.cs b/tests/SquidStd.Tests/Tui/Internal/ConventionNamesTests.cs
new file mode 100644
index 00000000..3d23f76d
--- /dev/null
+++ b/tests/SquidStd.Tests/Tui/Internal/ConventionNamesTests.cs
@@ -0,0 +1,22 @@
+using SquidStd.Tui.Internal;
+
+namespace SquidStd.Tests.Tui.Internal;
+
+public class ConventionNamesTests
+{
+ [Theory]
+ [InlineData("NameField", "Name")]
+ [InlineData("TitleLabel", "Title")]
+ [InlineData("SaveButton", "Save")]
+ [InlineData("Name", "Name")]
+ public void MemberName_StripsKnownWidgetSuffixes(string widgetId, string expected)
+ {
+ Assert.Equal(expected, ConventionNames.MemberName(widgetId));
+ }
+
+ [Fact]
+ public void CommandName_AppendsCommandSuffix()
+ {
+ Assert.Equal("SaveCommand", ConventionNames.CommandName("SaveButton"));
+ }
+}
diff --git a/tests/SquidStd.Tests/Tui/Internal/PropertyPathTests.cs b/tests/SquidStd.Tests/Tui/Internal/PropertyPathTests.cs
new file mode 100644
index 00000000..d1411c99
--- /dev/null
+++ b/tests/SquidStd.Tests/Tui/Internal/PropertyPathTests.cs
@@ -0,0 +1,41 @@
+using SquidStd.Tui.Internal;
+
+namespace SquidStd.Tests.Tui.Internal;
+
+public class PropertyPathTests
+{
+ private sealed class Sample
+ {
+ public string Name { get; set; } = string.Empty;
+ public int Count;
+ }
+
+ [Fact]
+ public void NameOf_Property_ReturnsName()
+ {
+ Assert.Equal("Name", PropertyPath.NameOf(s => s.Name));
+ }
+
+ [Fact]
+ public void NameOf_Field_ReturnsName()
+ {
+ Assert.Equal("Count", PropertyPath.NameOf(s => s.Count));
+ }
+
+ [Fact]
+ public void Setter_Property_WritesValue()
+ {
+ var setter = PropertyPath.Setter(s => s.Name);
+ var target = new Sample();
+
+ setter(target, "abc");
+
+ Assert.Equal("abc", target.Name);
+ }
+
+ [Fact]
+ public void NameOf_NonMemberExpression_Throws()
+ {
+ Assert.Throws(() => PropertyPath.NameOf(s => s.Count + 1));
+ }
+}
diff --git a/tests/SquidStd.Tests/Tui/Internal/ReentryGuardTests.cs b/tests/SquidStd.Tests/Tui/Internal/ReentryGuardTests.cs
new file mode 100644
index 00000000..4b5b0259
--- /dev/null
+++ b/tests/SquidStd.Tests/Tui/Internal/ReentryGuardTests.cs
@@ -0,0 +1,37 @@
+using SquidStd.Tui.Internal;
+
+namespace SquidStd.Tests.Tui.Internal;
+
+public class ReentryGuardTests
+{
+ [Fact]
+ public void IsBusy_FalseByDefault_TrueInsideScope_FalseAfter()
+ {
+ var guard = new ReentryGuard();
+ Assert.False(guard.IsBusy);
+
+ using (guard.Enter())
+ {
+ Assert.True(guard.IsBusy);
+ }
+
+ Assert.False(guard.IsBusy);
+ }
+
+ [Fact]
+ public void TryEnter_BlocksNestedEntry()
+ {
+ var guard = new ReentryGuard();
+ var inner = 0;
+
+ using (guard.Enter())
+ {
+ if (!guard.IsBusy)
+ {
+ inner++;
+ }
+ }
+
+ Assert.Equal(0, inner);
+ }
+}
diff --git a/tests/SquidStd.Tests/Tui/Navigation/TuiNavigatorTests.cs b/tests/SquidStd.Tests/Tui/Navigation/TuiNavigatorTests.cs
new file mode 100644
index 00000000..aeb072f9
--- /dev/null
+++ b/tests/SquidStd.Tests/Tui/Navigation/TuiNavigatorTests.cs
@@ -0,0 +1,176 @@
+using DryIoc;
+using SquidStd.Tui;
+using SquidStd.Tui.Interfaces;
+using SquidStd.Tui.Internal;
+using SquidStd.Tui.Navigation;
+
+namespace SquidStd.Tests.Tui.Navigation;
+
+public class TuiNavigatorTests
+{
+ private sealed class HomeViewModel : TuiViewModel
+ {
+ }
+
+ private sealed class DetailViewModel : TuiViewModel
+ {
+ }
+
+ // Non-generic fake view that records the ViewModel it was given and that it was initialised.
+ private sealed class FakeView : ITuiView
+ {
+ public object? BoundViewModel { get; private set; }
+ public bool Initialized { get; private set; }
+
+ public void Bind(object viewModel)
+ {
+ BoundViewModel = viewModel;
+ }
+
+ public void Initialize()
+ {
+ Initialized = true;
+ }
+ }
+
+ private sealed class FakeViewHost : ITuiViewHost
+ {
+ public List