diff --git a/CommunityToolkitExample/CommunityToolkitExample.csproj b/CommunityToolkitExample/CommunityToolkitExample.csproj new file mode 100644 index 000000000..fdb41dfcd --- /dev/null +++ b/CommunityToolkitExample/CommunityToolkitExample.csproj @@ -0,0 +1,19 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + + + + + + diff --git a/CommunityToolkitExample/LoginActions.cs b/CommunityToolkitExample/LoginActions.cs new file mode 100644 index 000000000..3cbc6972c --- /dev/null +++ b/CommunityToolkitExample/LoginActions.cs @@ -0,0 +1,8 @@ +namespace CommunityToolkitExample; + +internal enum LoginActions +{ + Clear, + Validation, + LoginProgress +} diff --git a/CommunityToolkitExample/LoginView.Designer.cs b/CommunityToolkitExample/LoginView.Designer.cs new file mode 100644 index 000000000..e1bddff45 --- /dev/null +++ b/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); + } +} diff --git a/CommunityToolkitExample/LoginView.cs b/CommunityToolkitExample/LoginView.cs new file mode 100644 index 000000000..b0d891fa9 --- /dev/null +++ b/CommunityToolkitExample/LoginView.cs @@ -0,0 +1,72 @@ +using CommunityToolkit.Mvvm.Messaging; +using Terminal.Gui; + +namespace CommunityToolkitExample; + +internal partial class LoginView : IRecipient> +{ + 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 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; + } +} \ No newline at end of file diff --git a/CommunityToolkitExample/LoginViewModel.cs b/CommunityToolkitExample/LoginViewModel.cs new file mode 100644 index 000000000..8d48546bb --- /dev/null +++ b/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 { Value = loginAction }); + } + + private void ValidateLogin () + { + CanLogin = !string.IsNullOrEmpty (Username) && !string.IsNullOrEmpty (Password); + SendMessage (LoginActions.Validation); + } +} diff --git a/CommunityToolkitExample/Message.cs b/CommunityToolkitExample/Message.cs new file mode 100644 index 000000000..f0e8ad530 --- /dev/null +++ b/CommunityToolkitExample/Message.cs @@ -0,0 +1,6 @@ +namespace CommunityToolkitExample; + +internal class Message +{ + public T? Value { get; set; } +} diff --git a/CommunityToolkitExample/Program.cs b/CommunityToolkitExample/Program.cs new file mode 100644 index 000000000..0d4f21c30 --- /dev/null +++ b/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 ()); + Application.Top.Dispose(); + Application.Shutdown (); + } + + private static IServiceProvider ConfigureServices () + { + var services = new ServiceCollection (); + services.AddTransient (); + services.AddTransient (); + return services.BuildServiceProvider (); + } +} \ No newline at end of file diff --git a/CommunityToolkitExample/README.md b/CommunityToolkitExample/README.md new file mode 100644 index 000000000..c3a712868 --- /dev/null +++ b/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` 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 (); + services.AddTransient (); + return services.BuildServiceProvider (); +} +``` + +Now, we start the app and get our main view. + +``` csharp +Application.Run (Services.GetRequiredService ()); +``` + +Our view implements `IRecipient` to demonstrate the use of the `WeakReferenceMessenger`. The binding of the view events is then created. + +``` csharp +internal partial class LoginView : IRecipient> +{ + 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 { 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 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 (); +} +``` diff --git a/ReactiveExample/Program.cs b/ReactiveExample/Program.cs index 2b5105b8c..2bbc6667b 100644 --- a/ReactiveExample/Program.cs +++ b/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 (); } } diff --git a/Terminal.sln b/Terminal.sln index 98de83b41..ee838a41a 100644 --- a/Terminal.sln +++ b/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