Skip to content

Commit 1ef7736

Browse files
authored
Merge pull request #14 from tgiachi/feature/tui-mvvm
feat(tui): add SquidStd.Tui — MVVM layer over Terminal.Gui v2
2 parents 3c3cd09 + 3f6dbcb commit 1ef7736

37 files changed

Lines changed: 1689 additions & 0 deletions

SquidStd.slnx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
<Project Path="src/SquidStd.Vfs.Abstractions/SquidStd.Vfs.Abstractions.csproj" />
4242
<Project Path="src/SquidStd.Vfs/SquidStd.Vfs.csproj" />
4343
<Project Path="src/SquidStd.Secrets.Aws/SquidStd.Secrets.Aws.csproj" />
44+
<Project Path="src/SquidStd.Tui/SquidStd.Tui.csproj" />
4445
</Folder>
4546
<Folder Name="/samples/">
4647
<Project Path="samples/SquidStd.Samples.WorkerSystem/SquidStd.Samples.WorkerSystem.csproj" />
@@ -63,6 +64,7 @@
6364
<Project Path="samples/SquidStd.Samples.Vfs/SquidStd.Samples.Vfs.csproj" />
6465
<Project Path="samples/SquidStd.Samples.Secrets/SquidStd.Samples.Secrets.csproj" />
6566
<Project Path="samples/SquidStd.Samples.Actors/SquidStd.Samples.Actors.csproj" />
67+
<Project Path="samples/SquidStd.Samples.Tui/SquidStd.Samples.Tui.csproj" />
6668
</Folder>
6769
<Folder Name="/tests/">
6870
<Project Path="tests/SquidStd.Tests/SquidStd.Tests.csproj" />
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using SquidStd.Tui;
2+
using SquidStd.Tui.Binding;
3+
using Terminal.Gui.Views;
4+
5+
namespace SquidStd.Samples.Tui;
6+
7+
public sealed class CounterView : TuiView<CounterViewModel>
8+
{
9+
private Label _value = null!;
10+
private Button _increment = null!;
11+
12+
protected override void BuildLayout()
13+
{
14+
_value = new Label { X = 1, Y = 1 };
15+
_increment = new Button { X = 1, Y = 3, Text = "_Increment" };
16+
Add(_value, _increment);
17+
}
18+
19+
protected override void Bind(ViewBinder binder)
20+
{
21+
binder.OneWayTitle(ViewModel, x => x.Title, this);
22+
binder.OneWayText(ViewModel, x => x.Value, _value);
23+
binder.Command(_increment, ViewModel.IncrementCommand);
24+
}
25+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
using CommunityToolkit.Mvvm.ComponentModel;
2+
using CommunityToolkit.Mvvm.Input;
3+
using SquidStd.Tui;
4+
5+
namespace SquidStd.Samples.Tui;
6+
7+
public sealed partial class CounterViewModel : TuiViewModel
8+
{
9+
[ObservableProperty]
10+
private string _title = "SquidStd.Tui — Counter";
11+
12+
[ObservableProperty]
13+
private string _value = "0";
14+
15+
[RelayCommand]
16+
private void Increment()
17+
{
18+
Value = (int.Parse(Value) + 1).ToString();
19+
}
20+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
using DryIoc;
2+
using SquidStd.Samples.Tui;
3+
using SquidStd.Tui.Extensions;
4+
using SquidStd.Tui.Hosting;
5+
6+
var container = new Container();
7+
container.RegisterTui();
8+
container.RegisterView<CounterView, CounterViewModel>();
9+
10+
await container.Resolve<TuiApplicationHost>().RunAsync<CounterViewModel>();
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<OutputType>Exe</OutputType>
5+
<TargetFramework>net10.0</TargetFramework>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
<Nullable>enable</Nullable>
8+
</PropertyGroup>
9+
10+
<ItemGroup>
11+
<ProjectReference Include="..\..\src\SquidStd.Tui\SquidStd.Tui.csproj"/>
12+
</ItemGroup>
13+
14+
</Project>
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
using System.ComponentModel;
2+
using System.Reflection;
3+
using System.Windows.Input;
4+
using SquidStd.Tui.Internal;
5+
using Terminal.Gui.ViewBase;
6+
using Terminal.Gui.Views;
7+
8+
namespace SquidStd.Tui.Binding;
9+
10+
public sealed partial class ViewBinder
11+
{
12+
/// <summary>
13+
/// Convention binding: for each named subview, binds it to a matching ViewModel member by name
14+
/// (e.g. <c>NameField</c> ↔ <c>Name</c>, <c>SaveButton</c> ↔ <c>SaveCommand</c>). Explicit bindings
15+
/// declared before AutoBind win; AutoBind only fills the gaps it recognises (Label, TextField, Button).
16+
/// </summary>
17+
public void AutoBind(View view, INotifyPropertyChanged viewModel)
18+
{
19+
var vmType = viewModel.GetType();
20+
21+
foreach (var subview in view.SubViews)
22+
{
23+
var id = subview.Id;
24+
25+
if (string.IsNullOrEmpty(id))
26+
{
27+
continue;
28+
}
29+
30+
switch (subview)
31+
{
32+
case Button button:
33+
BindCommandByName(vmType, viewModel, ConventionNames.CommandName(id), button);
34+
break;
35+
case TextField field:
36+
BindStringByName(vmType, viewModel, ConventionNames.MemberName(id), field, twoWay: true, null);
37+
break;
38+
case Label label:
39+
BindStringByName(vmType, viewModel, ConventionNames.MemberName(id), null, twoWay: false, label);
40+
break;
41+
}
42+
}
43+
}
44+
45+
private void BindCommandByName(Type vmType, INotifyPropertyChanged vm, string memberName, Button button)
46+
{
47+
var property = vmType.GetProperty(memberName, BindingFlags.Public | BindingFlags.Instance);
48+
49+
if (property is not null && property.GetValue(vm) is ICommand command)
50+
{
51+
Command(button, command);
52+
}
53+
}
54+
55+
private void BindStringByName(
56+
Type vmType, INotifyPropertyChanged vm, string memberName, TextField? field, bool twoWay, Label? label
57+
)
58+
{
59+
var property = vmType.GetProperty(memberName, BindingFlags.Public | BindingFlags.Instance);
60+
61+
if (property is null || property.PropertyType != typeof(string))
62+
{
63+
return;
64+
}
65+
66+
if (twoWay && field is not null)
67+
{
68+
TwoWay(
69+
vm,
70+
memberName,
71+
() => field.Text = (string)(property.GetValue(vm) ?? string.Empty),
72+
callback => field.ValueChanged += (_, _) => callback(),
73+
() => property.SetValue(vm, field.Text)
74+
);
75+
}
76+
else if (label is not null)
77+
{
78+
OneWay(vm, memberName, () => label.Text = (string)(property.GetValue(vm) ?? string.Empty));
79+
}
80+
}
81+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
using System.ComponentModel;
2+
using System.Linq.Expressions;
3+
using SquidStd.Tui.Internal;
4+
5+
namespace SquidStd.Tui.Binding;
6+
7+
public sealed partial class ViewBinder
8+
{
9+
/// <summary>One-way bind a source property to a target member, by expression.</summary>
10+
public void OneWay<TSource, TTarget, TValue>(
11+
TSource source,
12+
Expression<Func<TSource, TValue>> sourceProperty,
13+
TTarget target,
14+
Expression<Func<TTarget, TValue>> targetProperty
15+
)
16+
where TSource : INotifyPropertyChanged
17+
{
18+
var name = PropertyPath.NameOf(sourceProperty);
19+
var read = sourceProperty.Compile();
20+
var write = PropertyPath.Setter(targetProperty);
21+
22+
OneWay(source, name, () => write(target, read(source)));
23+
}
24+
25+
/// <summary>Two-way bind a source property to a target member, by expression, given the target's change event.</summary>
26+
public void TwoWay<TSource, TTarget, TValue>(
27+
TSource source,
28+
Expression<Func<TSource, TValue>> sourceProperty,
29+
TTarget target,
30+
Expression<Func<TTarget, TValue>> targetProperty,
31+
Action<Action> subscribeTargetChanged
32+
)
33+
where TSource : INotifyPropertyChanged
34+
{
35+
var name = PropertyPath.NameOf(sourceProperty);
36+
var read = sourceProperty.Compile();
37+
var readTarget = targetProperty.Compile();
38+
var write = PropertyPath.Setter(targetProperty);
39+
var writeSource = PropertyPath.Setter(sourceProperty);
40+
41+
TwoWay(
42+
source,
43+
name,
44+
() => write(target, read(source)),
45+
subscribeTargetChanged,
46+
() => writeSource(source, readTarget(target))
47+
);
48+
}
49+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
using System.ComponentModel;
2+
using System.Linq.Expressions;
3+
using System.Windows.Input;
4+
using Terminal.Gui.Views;
5+
6+
namespace SquidStd.Tui.Binding;
7+
8+
/// <summary>Convenience overloads that wire the core binder to concrete Terminal.Gui widgets.</summary>
9+
public sealed partial class ViewBinder
10+
{
11+
/// <summary>One-way bind a source string property to a <see cref="Label" />'s text.</summary>
12+
public void OneWayText<TSource>(TSource source, Expression<Func<TSource, string>> property, Label label)
13+
where TSource : INotifyPropertyChanged
14+
{
15+
OneWay(source, property, label, l => l.Text);
16+
}
17+
18+
/// <summary>One-way bind a source string property to a <see cref="Window" />'s title.</summary>
19+
public void OneWayTitle<TSource>(TSource source, Expression<Func<TSource, string>> property, Window window)
20+
where TSource : INotifyPropertyChanged
21+
{
22+
OneWay(source, property, window, w => w.Title);
23+
}
24+
25+
/// <summary>Two-way bind a source string property to a <see cref="TextField" />.</summary>
26+
public void TwoWay<TSource>(TSource source, Expression<Func<TSource, string>> property, TextField field)
27+
where TSource : INotifyPropertyChanged
28+
{
29+
// Terminal.Gui 2.4.16 exposes ValueChanged (EventHandler<ValueChangedEventArgs<string?>>)
30+
// rather than a TextChanged event.
31+
TwoWay(source, property, field, f => f.Text, callback => field.ValueChanged += (_, _) => callback());
32+
}
33+
34+
/// <summary>Bind a command to a <see cref="Button" />: Accepted triggers it, CanExecute drives Enabled.</summary>
35+
public void Command(Button button, ICommand command)
36+
{
37+
// Use Accepted (post-accept, non-cancellable) rather than Accepting for this side-effect-only
38+
// handler; Terminal.Gui 2.4.16 docs mark subscribing side effects to the cancellable Accepting
39+
// phase as incorrect.
40+
Command(command, enabled => button.Enabled = enabled, callback => button.Accepted += (_, _) => callback());
41+
}
42+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
using System.ComponentModel;
2+
using System.Windows.Input;
3+
using SquidStd.Tui.Internal;
4+
5+
namespace SquidStd.Tui.Binding;
6+
7+
/// <summary>
8+
/// Wires ViewModel (<see cref="INotifyPropertyChanged" />) properties and commands to view targets, and
9+
/// owns the lifetime of every subscription it creates. Dispose to release them all.
10+
/// </summary>
11+
public sealed partial class ViewBinder : IDisposable
12+
{
13+
private readonly List<IDisposable> _subscriptions = new();
14+
private readonly Action<Action> _marshal;
15+
16+
/// <summary>Initialises the binder with an optional marshal action for UI-thread dispatch.</summary>
17+
/// <param name="marshal">
18+
/// Runs an update on the UI thread. Defaults to running inline; the host passes a delegate over
19+
/// <c>Application.Invoke</c> so updates raised from background threads reach the Terminal.Gui loop.
20+
/// </param>
21+
public ViewBinder(Action<Action>? marshal = null)
22+
{
23+
_marshal = marshal ?? (action => action());
24+
}
25+
26+
/// <summary>Applies <paramref name="apply" /> now and whenever <paramref name="propertyName" /> changes.</summary>
27+
public void OneWay(INotifyPropertyChanged source, string propertyName, Action apply)
28+
{
29+
apply();
30+
31+
void Handler(object? sender, PropertyChangedEventArgs e)
32+
{
33+
if (e.PropertyName is null || string.Equals(e.PropertyName, propertyName, StringComparison.Ordinal))
34+
{
35+
_marshal(apply);
36+
}
37+
}
38+
39+
source.PropertyChanged += Handler;
40+
_subscriptions.Add(new Unsubscriber(() => source.PropertyChanged -= Handler));
41+
}
42+
43+
/// <summary>
44+
/// Binds a source property both ways. <paramref name="applyToTarget" /> runs on source changes;
45+
/// <paramref name="writeToSource" /> runs when the target raises a change. A reentrancy guard stops
46+
/// the source→target→source feedback loop.
47+
/// </summary>
48+
public void TwoWay(
49+
INotifyPropertyChanged source,
50+
string propertyName,
51+
Action applyToTarget,
52+
Action<Action> subscribeTargetChanged,
53+
Action writeToSource
54+
)
55+
{
56+
var guard = new ReentryGuard();
57+
58+
applyToTarget();
59+
60+
void SourceHandler(object? sender, PropertyChangedEventArgs e)
61+
{
62+
if (e.PropertyName is not null && !string.Equals(e.PropertyName, propertyName, StringComparison.Ordinal))
63+
{
64+
return;
65+
}
66+
67+
if (guard.IsBusy)
68+
{
69+
return;
70+
}
71+
72+
_marshal(() =>
73+
{
74+
using (guard.Enter())
75+
{
76+
applyToTarget();
77+
}
78+
});
79+
}
80+
81+
source.PropertyChanged += SourceHandler;
82+
_subscriptions.Add(new Unsubscriber(() => source.PropertyChanged -= SourceHandler));
83+
84+
subscribeTargetChanged(() =>
85+
{
86+
if (guard.IsBusy)
87+
{
88+
return;
89+
}
90+
91+
using (guard.Enter())
92+
{
93+
writeToSource();
94+
}
95+
});
96+
}
97+
98+
/// <summary>
99+
/// Binds a command to a control: <paramref name="subscribeTrigger" /> activates the command (when it
100+
/// can execute), and <paramref name="setEnabled" /> tracks <see cref="ICommand.CanExecute" />.
101+
/// </summary>
102+
public void Command(ICommand command, Action<bool> setEnabled, Action<Action> subscribeTrigger)
103+
{
104+
setEnabled(command.CanExecute(null));
105+
106+
void CanHandler(object? sender, EventArgs e)
107+
{
108+
_marshal(() => setEnabled(command.CanExecute(null)));
109+
}
110+
111+
command.CanExecuteChanged += CanHandler;
112+
_subscriptions.Add(new Unsubscriber(() => command.CanExecuteChanged -= CanHandler));
113+
114+
subscribeTrigger(() =>
115+
{
116+
if (command.CanExecute(null))
117+
{
118+
command.Execute(null);
119+
}
120+
});
121+
}
122+
123+
/// <summary>Disposes all active subscriptions created by this binder.</summary>
124+
public void Dispose()
125+
{
126+
for (var i = 0; i < _subscriptions.Count; i++)
127+
{
128+
_subscriptions[i].Dispose();
129+
}
130+
131+
_subscriptions.Clear();
132+
}
133+
}

0 commit comments

Comments
 (0)