Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions SquidStd.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
<Project Path="src/SquidStd.Vfs.Abstractions/SquidStd.Vfs.Abstractions.csproj" />
<Project Path="src/SquidStd.Vfs/SquidStd.Vfs.csproj" />
<Project Path="src/SquidStd.Secrets.Aws/SquidStd.Secrets.Aws.csproj" />
<Project Path="src/SquidStd.Tui/SquidStd.Tui.csproj" />
</Folder>
<Folder Name="/samples/">
<Project Path="samples/SquidStd.Samples.WorkerSystem/SquidStd.Samples.WorkerSystem.csproj" />
Expand All @@ -63,6 +64,7 @@
<Project Path="samples/SquidStd.Samples.Vfs/SquidStd.Samples.Vfs.csproj" />
<Project Path="samples/SquidStd.Samples.Secrets/SquidStd.Samples.Secrets.csproj" />
<Project Path="samples/SquidStd.Samples.Actors/SquidStd.Samples.Actors.csproj" />
<Project Path="samples/SquidStd.Samples.Tui/SquidStd.Samples.Tui.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/SquidStd.Tests/SquidStd.Tests.csproj" />
Expand Down
25 changes: 25 additions & 0 deletions samples/SquidStd.Samples.Tui/CounterView.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using SquidStd.Tui;
using SquidStd.Tui.Binding;
using Terminal.Gui.Views;

namespace SquidStd.Samples.Tui;

public sealed class CounterView : TuiView<CounterViewModel>
{
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);
}
}
20 changes: 20 additions & 0 deletions samples/SquidStd.Samples.Tui/CounterViewModel.cs
Original file line number Diff line number Diff line change
@@ -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";

Check notice

Code scanning / CodeQL

Missed 'readonly' opportunity Note

Field '_title' can be 'readonly'.

[ObservableProperty]
private string _value = "0";

Check notice

Code scanning / CodeQL

Missed 'readonly' opportunity Note

Field '_value' can be 'readonly'.

[RelayCommand]
private void Increment()
{
Value = (int.Parse(Value) + 1).ToString();
}
}
10 changes: 10 additions & 0 deletions samples/SquidStd.Samples.Tui/Program.cs
Original file line number Diff line number Diff line change
@@ -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<CounterView, CounterViewModel>();

await container.Resolve<TuiApplicationHost>().RunAsync<CounterViewModel>();
14 changes: 14 additions & 0 deletions samples/SquidStd.Samples.Tui/SquidStd.Samples.Tui.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\SquidStd.Tui\SquidStd.Tui.csproj"/>
</ItemGroup>

</Project>
81 changes: 81 additions & 0 deletions src/SquidStd.Tui/Binding/ViewBinder.AutoBind.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Convention binding: for each named subview, binds it to a matching ViewModel member by name
/// (e.g. <c>NameField</c> ↔ <c>Name</c>, <c>SaveButton</c> ↔ <c>SaveCommand</c>). Explicit bindings
/// declared before AutoBind win; AutoBind only fills the gaps it recognises (Label, TextField, Button).
/// </summary>
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));
}
}
}
49 changes: 49 additions & 0 deletions src/SquidStd.Tui/Binding/ViewBinder.Expressions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using System.ComponentModel;
using System.Linq.Expressions;
using SquidStd.Tui.Internal;

namespace SquidStd.Tui.Binding;

public sealed partial class ViewBinder
{
/// <summary>One-way bind a source property to a target member, by expression.</summary>
public void OneWay<TSource, TTarget, TValue>(
TSource source,
Expression<Func<TSource, TValue>> sourceProperty,
TTarget target,
Expression<Func<TTarget, TValue>> 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)));
}

/// <summary>Two-way bind a source property to a target member, by expression, given the target's change event.</summary>
public void TwoWay<TSource, TTarget, TValue>(
TSource source,
Expression<Func<TSource, TValue>> sourceProperty,
TTarget target,
Expression<Func<TTarget, TValue>> targetProperty,
Action<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))
);
}
}
42 changes: 42 additions & 0 deletions src/SquidStd.Tui/Binding/ViewBinder.Widgets.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using System.ComponentModel;
using System.Linq.Expressions;
using System.Windows.Input;
using Terminal.Gui.Views;

namespace SquidStd.Tui.Binding;

/// <summary>Convenience overloads that wire the core binder to concrete Terminal.Gui widgets.</summary>
public sealed partial class ViewBinder
{
/// <summary>One-way bind a source string property to a <see cref="Label" />'s text.</summary>
public void OneWayText<TSource>(TSource source, Expression<Func<TSource, string>> property, Label label)
where TSource : INotifyPropertyChanged
{
OneWay(source, property, label, l => l.Text);
}

/// <summary>One-way bind a source string property to a <see cref="Window" />'s title.</summary>
public void OneWayTitle<TSource>(TSource source, Expression<Func<TSource, string>> property, Window window)
where TSource : INotifyPropertyChanged
{
OneWay(source, property, window, w => w.Title);
}

/// <summary>Two-way bind a source string property to a <see cref="TextField" />.</summary>
public void TwoWay<TSource>(TSource source, Expression<Func<TSource, string>> property, TextField field)
where TSource : INotifyPropertyChanged
{
// Terminal.Gui 2.4.16 exposes ValueChanged (EventHandler<ValueChangedEventArgs<string?>>)
// rather than a TextChanged event.
TwoWay(source, property, field, f => f.Text, callback => field.ValueChanged += (_, _) => callback());
}

/// <summary>Bind a command to a <see cref="Button" />: Accepted triggers it, CanExecute drives Enabled.</summary>
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());
}
}
133 changes: 133 additions & 0 deletions src/SquidStd.Tui/Binding/ViewBinder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
using System.ComponentModel;
using System.Windows.Input;
using SquidStd.Tui.Internal;

namespace SquidStd.Tui.Binding;

/// <summary>
/// Wires ViewModel (<see cref="INotifyPropertyChanged" />) properties and commands to view targets, and
/// owns the lifetime of every subscription it creates. Dispose to release them all.
/// </summary>
public sealed partial class ViewBinder : IDisposable
{
private readonly List<IDisposable> _subscriptions = new();
private readonly Action<Action> _marshal;

/// <summary>Initialises the binder with an optional marshal action for UI-thread dispatch.</summary>
/// <param name="marshal">
/// Runs an update on the UI thread. Defaults to running inline; the host passes a delegate over
/// <c>Application.Invoke</c> so updates raised from background threads reach the Terminal.Gui loop.
/// </param>
public ViewBinder(Action<Action>? marshal = null)
{
_marshal = marshal ?? (action => action());
}

/// <summary>Applies <paramref name="apply" /> now and whenever <paramref name="propertyName" /> changes.</summary>
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));
}

/// <summary>
/// Binds a source property both ways. <paramref name="applyToTarget" /> runs on source changes;
/// <paramref name="writeToSource" /> runs when the target raises a change. A reentrancy guard stops
/// the source→target→source feedback loop.
/// </summary>
public void TwoWay(
INotifyPropertyChanged source,
string propertyName,
Action applyToTarget,
Action<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();
}
});
}

/// <summary>
/// Binds a command to a control: <paramref name="subscribeTrigger" /> activates the command (when it
/// can execute), and <paramref name="setEnabled" /> tracks <see cref="ICommand.CanExecute" />.
/// </summary>
public void Command(ICommand command, Action<bool> setEnabled, Action<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);
}
});
}

/// <summary>Disposes all active subscriptions created by this binder.</summary>
public void Dispose()
{
for (var i = 0; i < _subscriptions.Count; i++)
{
_subscriptions[i].Dispose();
}

_subscriptions.Clear();
}
}
Loading
Loading