Browse Source

Merge pull request #3539 from johnmbaughman/v2_communitytoolkit

Fixes #3538. Add CommunityToolkit.MVVM example.
Tig 1 year ago
parent
commit
69ccdbb9d7

+ 19 - 0
CommunityToolkitExample/CommunityToolkitExample.csproj

@@ -0,0 +1,19 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <OutputType>Exe</OutputType>
+    <TargetFramework>net8.0</TargetFramework>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <Nullable>enable</Nullable>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" />
+    <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="..\Terminal.Gui\Terminal.Gui.csproj" />
+  </ItemGroup>
+
+</Project>

+ 8 - 0
CommunityToolkitExample/LoginActions.cs

@@ -0,0 +1,8 @@
+namespace CommunityToolkitExample;
+
+internal enum LoginActions
+{
+    Clear,
+    Validation,
+    LoginProgress
+}

+ 62 - 0
CommunityToolkitExample/LoginView.Designer.cs

@@ -0,0 +1,62 @@
+using Terminal.Gui;
+
+namespace CommunityToolkitExample;
+
+internal partial class LoginView : Window
+{
+    private Label titleLabel;
+    private Label usernameLengthLabel;
+    private TextField usernameInput;
+    private Label passwordLengthLabel;
+    private TextField passwordInput;
+    private Label validationLabel;
+    private Button loginButton;
+    private Button clearButton;
+    private Label loginProgressLabel;
+
+    private void InitializeComponent ()
+    {
+        titleLabel = new Label ();
+        titleLabel.Text = "Login Form";
+        Add (titleLabel);
+        usernameLengthLabel = new Label ();
+        usernameLengthLabel.X = Pos.Left (titleLabel);
+        usernameLengthLabel.Y = Pos.Top (titleLabel) + 1;
+        Add (usernameLengthLabel);
+        usernameInput = new TextField ();
+        usernameInput.X = Pos.Right (usernameLengthLabel) + 1;
+        usernameInput.Y = Pos.Top (usernameLengthLabel);
+        usernameInput.Width = 40;
+        Add (usernameInput);
+        passwordLengthLabel = new Label ();
+        passwordLengthLabel.X = Pos.Left (usernameLengthLabel);
+        passwordLengthLabel.Y = Pos.Top (usernameLengthLabel) + 1;
+        Add (passwordLengthLabel);
+        passwordInput = new TextField ();
+        passwordInput.X = Pos.Right (passwordLengthLabel) + 1;
+        passwordInput.Y = Pos.Top (passwordLengthLabel);
+        passwordInput.Width = 40;
+        passwordInput.Secret = true;
+        Add (passwordInput);
+        validationLabel = new Label ();
+        validationLabel.X = Pos.Left (passwordInput);
+        validationLabel.Y = Pos.Top (passwordInput) + 1;
+        Add (validationLabel);
+        loginButton = new Button ();
+        loginButton.X = Pos.Left (validationLabel);
+        loginButton.Y = Pos.Top (validationLabel) + 1;
+        loginButton.Text = "_Login";
+        Add (loginButton);
+        clearButton = new Button ();
+        clearButton.X = Pos.Left (loginButton);
+        clearButton.Y = Pos.Top (loginButton) + 1;
+        clearButton.Text = "_Clear";
+        Add (clearButton);
+        loginProgressLabel = new Label ();
+        loginProgressLabel.X = Pos.Left (clearButton);
+        loginProgressLabel.Y = Pos.Top (clearButton) + 1;
+        loginProgressLabel.Width = 40;
+        loginProgressLabel.Height = 1;
+        Add (loginProgressLabel);
+    }
+}

+ 72 - 0
CommunityToolkitExample/LoginView.cs

@@ -0,0 +1,72 @@
+using CommunityToolkit.Mvvm.Messaging;
+using Terminal.Gui;
+
+namespace CommunityToolkitExample;
+
+internal partial class LoginView : IRecipient<Message<LoginActions>>
+{
+    public LoginView (LoginViewModel viewModel)
+    {
+        WeakReferenceMessenger.Default.Register (this);
+        Title = $"Community Toolkit MVVM Example - {Application.QuitKey} to Exit";
+        ViewModel = viewModel;
+        InitializeComponent ();
+        usernameInput.TextChanged += (_, _) =>
+                                     {
+                                         ViewModel.Username = usernameInput.Text;
+                                     };
+        passwordInput.TextChanged += (_, _) =>
+                                     {
+                                         ViewModel.Password = passwordInput.Text;
+                                     };
+        loginButton.Accept += (_, _) =>
+                              {
+                                  if (!ViewModel.CanLogin) { return; }
+                                  ViewModel.LoginCommand.Execute (null);
+                              };
+
+        clearButton.Accept += (_, _) =>
+                              {
+                                  ViewModel.ClearCommand.Execute (null);
+                              };
+
+        Initialized += (_, _) => { ViewModel.Initialized (); };
+    }
+
+    public LoginViewModel ViewModel { get; set; }
+
+    public void Receive (Message<LoginActions> message)
+    {
+        switch (message.Value)
+        {
+            case LoginActions.Clear:
+                {
+                    loginProgressLabel.Text = ViewModel.LoginProgressMessage;
+                    validationLabel.Text = ViewModel.ValidationMessage;
+                    validationLabel.ColorScheme = ViewModel.ValidationColorScheme;
+                    break;
+                }
+            case LoginActions.LoginProgress:
+                {
+                    loginProgressLabel.Text = ViewModel.LoginProgressMessage;
+                    break;
+                }
+            case LoginActions.Validation:
+                {
+                    validationLabel.Text = ViewModel.ValidationMessage;
+                    validationLabel.ColorScheme = ViewModel.ValidationColorScheme;
+                    break;
+                }
+        }
+        SetText();
+        Application.Refresh ();
+    }
+
+    private void SetText ()
+    {
+        usernameInput.Text = ViewModel.Username;
+        usernameLengthLabel.Text = ViewModel.UsernameLengthMessage;
+        passwordInput.Text = ViewModel.Password;
+        passwordLengthLabel.Text = ViewModel.PasswordLengthMessage;
+    }
+}

+ 128 - 0
CommunityToolkitExample/LoginViewModel.cs

@@ -0,0 +1,128 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using CommunityToolkit.Mvvm.Messaging;
+using Terminal.Gui;
+
+namespace CommunityToolkitExample;
+
+internal partial class LoginViewModel : ObservableObject
+{
+    private const string DEFAULT_LOGIN_PROGRESS_MESSAGE = "Press 'Login' to log in.";
+    private const string INVALID_LOGIN_MESSAGE = "Please enter a valid user name and password.";
+    private const string LOGGING_IN_PROGRESS_MESSAGE = "Logging in...";
+    private const string VALID_LOGIN_MESSAGE = "The input is valid!";
+    
+    [ObservableProperty]
+    private bool _canLogin;
+
+    [ObservableProperty]
+    private string _loginProgressMessage;
+
+    private string _password;
+
+    [ObservableProperty]
+    private string _passwordLengthMessage;
+
+    private string _username;
+
+    [ObservableProperty]
+    private string _usernameLengthMessage;
+    
+    [ObservableProperty]
+    private ColorScheme? _validationColorScheme;
+
+    [ObservableProperty]
+    private string _validationMessage;
+    public LoginViewModel ()
+    {
+        _loginProgressMessage = string.Empty;
+        _password = string.Empty;
+        _passwordLengthMessage = string.Empty;
+        _username = string.Empty;
+        _usernameLengthMessage = string.Empty;
+        _validationMessage = string.Empty;
+
+        Username = string.Empty;
+        Password = string.Empty;
+
+        ClearCommand = new (Clear);
+        LoginCommand = new (Execute);
+
+        Clear ();
+
+        return;
+
+        async void Execute () { await Login (); }
+    }
+
+    public RelayCommand ClearCommand { get; }
+
+    public RelayCommand LoginCommand { get; }
+
+    public string Password
+    {
+        get => _password;
+        set
+        {
+            SetProperty (ref _password, value);
+            PasswordLengthMessage = $"_Password ({_password.Length} characters):";
+            ValidateLogin ();
+        }
+    }
+
+    public string Username
+    {
+        get => _username;
+        set
+        {
+            SetProperty (ref _username, value);
+            UsernameLengthMessage = $"_Username ({_username.Length} characters):";
+            ValidateLogin ();
+        }
+    }
+
+    public void Initialized ()
+    {
+        Clear ();
+    }
+
+    private void Clear ()
+    {
+        Username = string.Empty;
+        Password = string.Empty;
+        SendMessage (LoginActions.Clear, DEFAULT_LOGIN_PROGRESS_MESSAGE);
+    }
+
+    private async Task Login ()
+    {
+        SendMessage (LoginActions.LoginProgress, LOGGING_IN_PROGRESS_MESSAGE);
+        await Task.Delay (TimeSpan.FromSeconds (1));
+        Clear ();
+    }
+
+    private void SendMessage (LoginActions loginAction, string message = "")
+    {
+        switch (loginAction)
+        {
+             case LoginActions.Clear:
+                LoginProgressMessage = message;
+                ValidationMessage = INVALID_LOGIN_MESSAGE;
+                ValidationColorScheme = Colors.ColorSchemes ["Error"];
+                break;
+            case LoginActions.LoginProgress:
+                LoginProgressMessage = message;
+                break;
+            case LoginActions.Validation:
+                ValidationMessage = CanLogin ? VALID_LOGIN_MESSAGE : INVALID_LOGIN_MESSAGE;
+                ValidationColorScheme = CanLogin ? Colors.ColorSchemes ["Base"] : Colors.ColorSchemes ["Error"];
+                break;
+        }
+        WeakReferenceMessenger.Default.Send (new Message<LoginActions> { Value = loginAction });
+    }
+
+    private void ValidateLogin ()
+    {
+        CanLogin = !string.IsNullOrEmpty (Username) && !string.IsNullOrEmpty (Password);
+        SendMessage (LoginActions.Validation);
+    }
+}

+ 6 - 0
CommunityToolkitExample/Message.cs

@@ -0,0 +1,6 @@
+namespace CommunityToolkitExample;
+
+internal class Message<T>
+{
+    public T? Value { get; set; }
+}

+ 26 - 0
CommunityToolkitExample/Program.cs

@@ -0,0 +1,26 @@
+using Microsoft.Extensions.DependencyInjection;
+using Terminal.Gui;
+
+namespace CommunityToolkitExample;
+
+public static class Program
+{
+    public static IServiceProvider? Services { get; private set; }
+
+    private static void Main (string [] args)
+    {
+        Services = ConfigureServices ();
+        Application.Init ();
+        Application.Run (Services.GetRequiredService<LoginView> ());
+        Application.Top.Dispose();
+        Application.Shutdown ();
+    }
+
+    private static IServiceProvider ConfigureServices ()
+    {
+        var services = new ServiceCollection ();
+        services.AddTransient<LoginView> ();
+        services.AddTransient<LoginViewModel> ();
+        return services.BuildServiceProvider ();
+    }
+}

+ 154 - 0
CommunityToolkitExample/README.md

@@ -0,0 +1,154 @@
+# CommunityToolkit.MVVM Example
+
+This small demo gives an example of using the `CommunityToolkit.MVVM` framework's `ObservableObject`, `ObservableProperty`, and `IRecipient<T>` in conjunction with `Microsoft.Extensions.DependencyInjection`. 
+
+Right away we use IoC to load our views and view models.
+
+``` csharp
+// As a public property for access further in the application if needed. 
+public static IServiceProvider Services { get; private set; }
+...
+// In Main
+Services = ConfigureServices ();
+...
+private static IServiceProvider ConfigureServices ()
+{
+    var services = new ServiceCollection ();
+    services.AddTransient<LoginView> ();
+    services.AddTransient<LoginViewModel> ();
+    return services.BuildServiceProvider ();
+}
+```
+
+Now, we start the app and get our main view.
+
+``` csharp
+Application.Run (Services.GetRequiredService<LoginView> ());
+```
+
+Our view implements `IRecipient<T>` to demonstrate the use of the `WeakReferenceMessenger`. The binding of the view events is then created.
+
+``` csharp
+internal partial class LoginView : IRecipient<Message<LoginAction>>
+{
+    public LoginView (LoginViewModel viewModel)
+    {
+        // Initialize our Receive method
+        WeakReferenceMessenger.Default.Register (this);
+        ...
+        ViewModel = viewModel;
+        ...
+        passwordInput.TextChanged += (_, _) =>
+                                     {
+                                         ViewModel.Password = passwordInput.Text;
+                                         SetText ();
+                                     };
+        loginButton.Accept += (_, _) =>
+                              {
+                                  if (!ViewModel.CanLogin) { return; }
+                                  ViewModel.LoginCommand.Execute (null);
+                              };
+        ...
+        // Let the view model know the view is intialized.
+        Initialized += (_, _) => { ViewModel.Initialized (); };
+    }
+    ...
+}
+```
+
+Momentarily slipping over to the view model, all bindable properties use some form of `ObservableProperty` with the class deriving from `ObservableObject`. Commands are of the `RelayCommand` type. The use of `ObservableProperty` generates the code for handling `INotifyPropertyChanged` and `INotifyPropertyChanging`.
+
+``` csharp
+internal partial class LoginViewModel : ObservableObject
+{
+    ...
+    [ObservableProperty]
+    private bool _canLogin;
+
+    private string _password;
+    ...
+    public LoginViewModel ()
+    {
+        ...
+        Password = string.Empty;
+        ...   
+        LoginCommand = new (Execute);
+
+        Clear ();
+
+        return;
+
+        async void Execute () { await Login (); }
+    }
+    ...
+    public RelayCommand LoginCommand { get; }
+
+    public string Password
+    {
+        get => _password;
+        set
+        {
+            SetProperty (ref _password, value);
+            PasswordLengthMessage = $"_Password ({_password.Length} characters):";
+            ValidateLogin ();
+        }
+    }
+```
+
+The use of `WeakReferenceMessenger` provides one method of signaling the view from the view model. It's just one way to handle cross-thread messaging in this framework.
+
+``` csharp
+...
+private async Task Login ()
+{
+    SendMessage (LoginAction.LoginProgress, LOGGING_IN_PROGRESS_MESSAGE);
+    await Task.Delay (TimeSpan.FromSeconds (1));
+    Clear ();
+}
+
+private void SendMessage (LoginAction loginAction, string message = "")
+{
+    switch (loginAction)
+    {
+        case LoginAction.LoginProgress:
+            LoginProgressMessage = message;
+            break;
+        case LoginAction.Validation:
+            ValidationMessage = CanLogin ? VALID_LOGIN_MESSAGE : INVALID_LOGIN_MESSAGE;
+            ValidationColorScheme = CanLogin ? Colors.ColorSchemes ["Base"] : Colors.ColorSchemes ["Error"];
+            break;
+    }
+    WeakReferenceMessenger.Default.Send (new Message<LoginAction> { Value = loginAction });
+}
+
+private void ValidateLogin ()
+{
+    CanLogin = !string.IsNullOrEmpty (Username) && !string.IsNullOrEmpty (Password);
+    SendMessage (LoginAction.Validation);
+}
+...
+```
+
+And the view's `Receive` function which provides an `Application.Refresh()` call to update the UI immediately.
+
+``` csharp
+public void Receive (Message<LoginAction> message)
+{
+    switch (message.Value)
+    {
+        case LoginAction.LoginProgress:
+            {
+                loginProgressLabel.Text = ViewModel.LoginProgressMessage;
+                break;
+            }
+        case LoginAction.Validation:
+            {
+                validationLabel.Text = ViewModel.ValidationMessage;
+                validationLabel.ColorScheme = ViewModel.ValidationColorScheme;
+                break;
+            }
+    }
+    SetText();
+    Application.Refresh ();
+}
+```

+ 1 - 0
ReactiveExample/Program.cs

@@ -12,6 +12,7 @@ public static class Program
         RxApp.MainThreadScheduler = TerminalScheduler.Default;
         RxApp.TaskpoolScheduler = TaskPoolScheduler.Default;
         Application.Run (new LoginView (new LoginViewModel ()));
+        Application.Top.Dispose();
         Application.Shutdown ();
     }
 }

+ 6 - 0
Terminal.sln

@@ -38,6 +38,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Terminal.Gui.Analyzers.Inte
 EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Terminal.Gui.Analyzers.Internal.Debugging", "Analyzers\Terminal.Gui.Analyzers.Internal.Debugging\Terminal.Gui.Analyzers.Internal.Debugging.csproj", "{C2AD09BD-D579-45A7-ACA3-E4EF3BC027D2}"
 EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CommunityToolkitExample", "CommunityToolkitExample\CommunityToolkitExample.csproj", "{58FDCA8F-08F7-4D80-9DA3-6A9AED01E163}"
+EndProject
 Global
 	GlobalSection(NestedProjects) = preSolution
 		{5DE91722-8765-4E2B-97E4-2A18010B5CED} = {CCADA0BC-61CF-4B4B-96BA-A3B0C0A7F54D}
@@ -81,6 +83,10 @@ Global
 		{C2AD09BD-D579-45A7-ACA3-E4EF3BC027D2}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{C2AD09BD-D579-45A7-ACA3-E4EF3BC027D2}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{C2AD09BD-D579-45A7-ACA3-E4EF3BC027D2}.Release|Any CPU.Build.0 = Release|Any CPU
+		{58FDCA8F-08F7-4D80-9DA3-6A9AED01E163}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{58FDCA8F-08F7-4D80-9DA3-6A9AED01E163}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{58FDCA8F-08F7-4D80-9DA3-6A9AED01E163}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{58FDCA8F-08F7-4D80-9DA3-6A9AED01E163}.Release|Any CPU.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE