Skip to content

Commit 3f6dbcb

Browse files
committed
refactor(tui): pair navigator activation hooks, cover AutoBind, drop dead code
- TuiNavigator: deactivate the covered screen before pushing a new one so OnActivatedAsync/OnDeactivatedAsync are paired (no double-activate on forward-then-back navigation) - add ViewBinderAutoBindTests covering the reflection binding paths (label one-way, textfield two-way apply, button command discovery, unrecognised/wrong-type skip) - delete unused ViewBinder.Track method - add missing XML summaries on ViewBinder ctor/Dispose, TuiViewRegistry Map/ViewTypeFor and TerminalGuiViewHost Show/Remove
1 parent 9955ede commit 3f6dbcb

6 files changed

Lines changed: 242 additions & 5 deletions

File tree

src/SquidStd.Tui/Binding/ViewBinder.cs

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ public sealed partial class ViewBinder : IDisposable
1313
private readonly List<IDisposable> _subscriptions = new();
1414
private readonly Action<Action> _marshal;
1515

16+
/// <summary>Initialises the binder with an optional marshal action for UI-thread dispatch.</summary>
1617
/// <param name="marshal">
1718
/// Runs an update on the UI thread. Defaults to running inline; the host passes a delegate over
1819
/// <c>Application.Invoke</c> so updates raised from background threads reach the Terminal.Gui loop.
@@ -119,11 +120,7 @@ void CanHandler(object? sender, EventArgs e)
119120
});
120121
}
121122

122-
private void Track(IDisposable subscription)
123-
{
124-
_subscriptions.Add(subscription);
125-
}
126-
123+
/// <summary>Disposes all active subscriptions created by this binder.</summary>
127124
public void Dispose()
128125
{
129126
for (var i = 0; i < _subscriptions.Count; i++)

src/SquidStd.Tui/Hosting/TerminalGuiViewHost.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ public sealed class TerminalGuiViewHost : ITuiViewHost
1313
/// <summary>The shell the navigator's screens are added to. Set by the application host before running.</summary>
1414
public View? Container { get; set; }
1515

16+
/// <summary>Adds <paramref name="view"/> as a full-size child of <see cref="Container"/> and gives it focus.</summary>
1617
public void Show(object view)
1718
{
1819
if (Container is not null && view is View concrete)
@@ -24,6 +25,7 @@ public void Show(object view)
2425
}
2526
}
2627

28+
/// <summary>Removes <paramref name="view"/> from <see cref="Container"/> and disposes it.</summary>
2729
public void Remove(object view)
2830
{
2931
if (Container is not null && view is View concrete)

src/SquidStd.Tui/Internal/TuiViewRegistry.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ public sealed class TuiViewRegistry
55
{
66
private readonly Dictionary<Type, Type> _map = new();
77

8+
/// <summary>Registers a mapping from <paramref name="viewModelType"/> to the <paramref name="viewType"/> that renders it.</summary>
89
public void Map(Type viewModelType, Type viewType)
910
{
1011
_map[viewModelType] = viewType;
1112
}
1213

14+
/// <summary>Returns the view type registered for <paramref name="viewModelType"/>, or throws if none is registered.</summary>
1315
public Type ViewTypeFor(Type viewModelType)
1416
{
1517
if (_map.TryGetValue(viewModelType, out var viewType))

src/SquidStd.Tui/Navigation/TuiNavigator.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ public async Task NavigateToAsync<TViewModel>(CancellationToken cancellationToke
3535
view.Bind(viewModel);
3636
view.Initialize();
3737

38+
if (_stack.Count > 0)
39+
{
40+
await _stack.Peek().ViewModel.OnDeactivatedAsync();
41+
}
42+
3843
_stack.Push(new Screen(viewModel, view));
3944
_viewHost.Show(view);
4045

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
using CommunityToolkit.Mvvm.ComponentModel;
2+
using CommunityToolkit.Mvvm.Input;
3+
using SquidStd.Tui.Binding;
4+
using Terminal.Gui.ViewBase;
5+
using Terminal.Gui.Views;
6+
7+
namespace SquidStd.Tests.Tui.Binding;
8+
9+
public partial class ViewBinderAutoBindTests
10+
{
11+
private sealed partial class AutoBindViewModel : ObservableObject
12+
{
13+
[ObservableProperty]
14+
private string _title = string.Empty;
15+
16+
[ObservableProperty]
17+
private string _name = string.Empty;
18+
19+
// Wrong type on purpose — used by the "wrong type" skip-path test.
20+
public int Count { get; } = 42;
21+
22+
private bool _canSave;
23+
24+
[RelayCommand(CanExecute = nameof(CanSave))]
25+
private void Save() { }
26+
27+
private bool CanSave() => _canSave;
28+
29+
public void SetCanSave(bool value)
30+
{
31+
_canSave = value;
32+
SaveCommand.NotifyCanExecuteChanged();
33+
}
34+
}
35+
36+
// -------------------------------------------------------------------
37+
// Label one-way
38+
// -------------------------------------------------------------------
39+
40+
[Fact]
41+
public void AutoBind_Label_AppliesInitialValue()
42+
{
43+
var vm = new AutoBindViewModel { Title = "Hello" };
44+
var parent = new View();
45+
var label = new Label { Id = "TitleLabel" };
46+
parent.Add(label);
47+
48+
var binder = new ViewBinder();
49+
binder.AutoBind(parent, vm);
50+
51+
Assert.Equal("Hello", label.Text);
52+
}
53+
54+
[Fact]
55+
public void AutoBind_Label_UpdatesWhenVmPropertyChanges()
56+
{
57+
var vm = new AutoBindViewModel { Title = "initial" };
58+
var parent = new View();
59+
var label = new Label { Id = "TitleLabel" };
60+
parent.Add(label);
61+
62+
var binder = new ViewBinder();
63+
binder.AutoBind(parent, vm);
64+
65+
vm.Title = "updated";
66+
67+
Assert.Equal("updated", label.Text);
68+
}
69+
70+
// -------------------------------------------------------------------
71+
// TextField two-way (vm→field direction; writeback omitted headless)
72+
// -------------------------------------------------------------------
73+
74+
[Fact]
75+
public void AutoBind_TextField_AppliesInitialValue()
76+
{
77+
var vm = new AutoBindViewModel { Name = "squid" };
78+
var parent = new View();
79+
var field = new TextField { Id = "NameField" };
80+
parent.Add(field);
81+
82+
var binder = new ViewBinder();
83+
binder.AutoBind(parent, vm);
84+
85+
Assert.Equal("squid", field.Text);
86+
}
87+
88+
[Fact]
89+
public void AutoBind_TextField_UpdatesFieldWhenVmPropertyChanges()
90+
{
91+
var vm = new AutoBindViewModel { Name = "before" };
92+
var parent = new View();
93+
var field = new TextField { Id = "NameField" };
94+
parent.Add(field);
95+
96+
var binder = new ViewBinder();
97+
binder.AutoBind(parent, vm);
98+
99+
vm.Name = "after";
100+
101+
Assert.Equal("after", field.Text);
102+
}
103+
104+
// -------------------------------------------------------------------
105+
// Button command — CanExecute drives Enabled
106+
// -------------------------------------------------------------------
107+
108+
[Fact]
109+
public void AutoBind_Button_TracksCanExecute()
110+
{
111+
var vm = new AutoBindViewModel(); // CanSave == false initially
112+
var parent = new View();
113+
var button = new Button { Id = "SaveButton" };
114+
parent.Add(button);
115+
116+
var binder = new ViewBinder();
117+
binder.AutoBind(parent, vm);
118+
119+
Assert.False(button.Enabled); // initially disabled because CanSave == false
120+
121+
vm.SetCanSave(true);
122+
123+
Assert.True(button.Enabled);
124+
125+
vm.SetCanSave(false);
126+
127+
Assert.False(button.Enabled);
128+
}
129+
130+
// -------------------------------------------------------------------
131+
// Skip paths — no throw, no unintended side-effects
132+
// -------------------------------------------------------------------
133+
134+
[Fact]
135+
public void AutoBind_UnknownId_DoesNotThrow()
136+
{
137+
// "Unknown" maps to no VM member — should silently skip.
138+
var vm = new AutoBindViewModel();
139+
var parent = new View();
140+
var label = new Label { Id = "UnknownLabel" };
141+
parent.Add(label);
142+
143+
var binder = new ViewBinder();
144+
var ex = Record.Exception(() => binder.AutoBind(parent, vm));
145+
146+
Assert.Null(ex);
147+
}
148+
149+
[Fact]
150+
public void AutoBind_WrongPropertyType_DoesNotThrow()
151+
{
152+
// "Count" is int, not string — BindStringByName returns early without throwing.
153+
var vm = new AutoBindViewModel();
154+
var parent = new View();
155+
var label = new Label { Id = "CountLabel" };
156+
parent.Add(label);
157+
158+
var binder = new ViewBinder();
159+
var ex = Record.Exception(() => binder.AutoBind(parent, vm));
160+
161+
Assert.Null(ex);
162+
Assert.Equal(string.Empty, label.Text); // left untouched
163+
}
164+
}

tests/SquidStd.Tests/Tui/Navigation/TuiNavigatorTests.cs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,4 +106,71 @@ public async Task Back_OnLastScreen_DoesNothing()
106106

107107
Assert.Equal(1, navigator.Depth); // refuses to pop the root
108108
}
109+
110+
private sealed class TrackingHomeViewModel : TuiViewModel
111+
{
112+
public int Activated { get; private set; }
113+
public int Deactivated { get; private set; }
114+
115+
public override ValueTask OnActivatedAsync()
116+
{
117+
Activated++;
118+
return ValueTask.CompletedTask;
119+
}
120+
121+
public override ValueTask OnDeactivatedAsync()
122+
{
123+
Deactivated++;
124+
return ValueTask.CompletedTask;
125+
}
126+
}
127+
128+
private sealed class TrackingDetailViewModel : TuiViewModel
129+
{
130+
public int Activated { get; private set; }
131+
public int Deactivated { get; private set; }
132+
133+
public override ValueTask OnActivatedAsync()
134+
{
135+
Activated++;
136+
return ValueTask.CompletedTask;
137+
}
138+
139+
public override ValueTask OnDeactivatedAsync()
140+
{
141+
Deactivated++;
142+
return ValueTask.CompletedTask;
143+
}
144+
}
145+
146+
[Fact]
147+
public async Task ForwardThenBack_PairsActivationHooks_NoDoubleActivateWithoutDeactivate()
148+
{
149+
var container = new Container();
150+
container.Register<TrackingHomeViewModel>(Reuse.Singleton);
151+
container.Register<TrackingDetailViewModel>(Reuse.Singleton);
152+
container.Register<FakeView>(Reuse.Transient);
153+
154+
var registry = new TuiViewRegistry();
155+
registry.Map(typeof(TrackingHomeViewModel), typeof(FakeView));
156+
registry.Map(typeof(TrackingDetailViewModel), typeof(FakeView));
157+
158+
var navigator = new TuiNavigator(container, registry, new FakeViewHost());
159+
var home = container.Resolve<TrackingHomeViewModel>();
160+
var detail = container.Resolve<TrackingDetailViewModel>();
161+
162+
await navigator.NavigateToAsync<TrackingHomeViewModel>(); // home: A1 D0
163+
Assert.Equal(1, home.Activated);
164+
Assert.Equal(0, home.Deactivated);
165+
166+
await navigator.NavigateToAsync<TrackingDetailViewModel>(); // home deactivated; detail A1
167+
Assert.Equal(1, home.Activated);
168+
Assert.Equal(1, home.Deactivated);
169+
Assert.Equal(1, detail.Activated);
170+
171+
await navigator.BackAsync(); // detail D1; home reactivated
172+
Assert.Equal(1, detail.Deactivated);
173+
Assert.Equal(2, home.Activated);
174+
Assert.Equal(1, home.Deactivated); // still 1 — every activate is now paired with a prior deactivate
175+
}
109176
}

0 commit comments

Comments
 (0)