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. NameFieldName, SaveButtonSaveCommand). 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 Shown { get; } = new(); + public List Removed { get; } = new(); + + public void Show(object view) + { + Shown.Add(view); + } + + public void Remove(object view) + { + Removed.Add(view); + } + } + + private static (TuiNavigator Navigator, FakeViewHost Host) Build() + { + var container = new Container(); + container.Register(Reuse.Transient); + container.Register(Reuse.Transient); + container.Register(Reuse.Transient); + + var registry = new TuiViewRegistry(); + registry.Map(typeof(HomeViewModel), typeof(FakeView)); + registry.Map(typeof(DetailViewModel), typeof(FakeView)); + + var host = new FakeViewHost(); + var navigator = new TuiNavigator(container, registry, host); + + return (navigator, host); + } + + [Fact] + public async Task NavigateTo_PushesViewModelBoundView_AndShowsIt() + { + var (navigator, host) = Build(); + + await navigator.NavigateToAsync(); + + Assert.Equal(1, navigator.Depth); + Assert.Single(host.Shown); + var view = Assert.IsType(host.Shown[0]); + Assert.True(view.Initialized); + Assert.IsType(view.BoundViewModel); + Assert.Same(navigator, ((HomeViewModel)view.BoundViewModel!).Navigator); + } + + [Fact] + public async Task Back_PopsCurrentAndRemovesItsView() + { + var (navigator, host) = Build(); + await navigator.NavigateToAsync(); + await navigator.NavigateToAsync(); + + Assert.Equal(2, navigator.Depth); + + await navigator.BackAsync(); + + Assert.Equal(1, navigator.Depth); + Assert.Single(host.Removed); + } + + [Fact] + public async Task Back_OnLastScreen_DoesNothing() + { + var (navigator, _) = Build(); + await navigator.NavigateToAsync(); + + await navigator.BackAsync(); + + Assert.Equal(1, navigator.Depth); // refuses to pop the root + } + + private sealed class TrackingHomeViewModel : TuiViewModel + { + public int Activated { get; private set; } + public int Deactivated { get; private set; } + + public override ValueTask OnActivatedAsync() + { + Activated++; + return ValueTask.CompletedTask; + } + + public override ValueTask OnDeactivatedAsync() + { + Deactivated++; + return ValueTask.CompletedTask; + } + } + + private sealed class TrackingDetailViewModel : TuiViewModel + { + public int Activated { get; private set; } + public int Deactivated { get; private set; } + + public override ValueTask OnActivatedAsync() + { + Activated++; + return ValueTask.CompletedTask; + } + + public override ValueTask OnDeactivatedAsync() + { + Deactivated++; + return ValueTask.CompletedTask; + } + } + + [Fact] + public async Task ForwardThenBack_PairsActivationHooks_NoDoubleActivateWithoutDeactivate() + { + var container = new Container(); + container.Register(Reuse.Singleton); + container.Register(Reuse.Singleton); + container.Register(Reuse.Transient); + + var registry = new TuiViewRegistry(); + registry.Map(typeof(TrackingHomeViewModel), typeof(FakeView)); + registry.Map(typeof(TrackingDetailViewModel), typeof(FakeView)); + + var navigator = new TuiNavigator(container, registry, new FakeViewHost()); + var home = container.Resolve(); + var detail = container.Resolve(); + + await navigator.NavigateToAsync(); // home: A1 D0 + Assert.Equal(1, home.Activated); + Assert.Equal(0, home.Deactivated); + + await navigator.NavigateToAsync(); // home deactivated; detail A1 + Assert.Equal(1, home.Activated); + Assert.Equal(1, home.Deactivated); + Assert.Equal(1, detail.Activated); + + await navigator.BackAsync(); // detail D1; home reactivated + Assert.Equal(1, detail.Deactivated); + Assert.Equal(2, home.Activated); + Assert.Equal(1, home.Deactivated); // still 1 — every activate is now paired with a prior deactivate + } +} diff --git a/tests/SquidStd.Tests/Tui/TuiViewModelTests.cs b/tests/SquidStd.Tests/Tui/TuiViewModelTests.cs new file mode 100644 index 00000000..6cd048e3 --- /dev/null +++ b/tests/SquidStd.Tests/Tui/TuiViewModelTests.cs @@ -0,0 +1,52 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using SquidStd.Tui; + +namespace SquidStd.Tests.Tui; + +public partial class TuiViewModelTests +{ + private sealed partial class SampleViewModel : TuiViewModel + { + [ObservableProperty] + private string _title = string.Empty; + + public int Activated { get; private set; } + + public override ValueTask OnActivatedAsync() + { + Activated++; + + return ValueTask.CompletedTask; + } + } + + [Fact] + public void ObservableProperty_RaisesPropertyChanged() + { + var vm = new SampleViewModel(); + var raised = new List(); + vm.PropertyChanged += (_, e) => raised.Add(e.PropertyName); + + vm.Title = "hello"; + + Assert.Contains(nameof(SampleViewModel.Title), raised); + } + + [Fact] + public async Task OnActivatedAsync_IsOverridable() + { + var vm = new SampleViewModel(); + + await vm.OnActivatedAsync(); + + Assert.Equal(1, vm.Activated); + } + + [Fact] + public async Task OnDeactivatedAsync_DefaultsToNoOp() + { + var vm = new SampleViewModel(); + + await vm.OnDeactivatedAsync(); // does not throw + } +}