diff --git a/ReactiveExample/Extensions.cs b/ReactiveExample/Extensions.cs new file mode 100644 index 000000000..1a653f090 --- /dev/null +++ b/ReactiveExample/Extensions.cs @@ -0,0 +1,41 @@ +using Terminal.Gui; + +namespace ReactiveExample { + public static class Extensions + { + public static MemoizedElement StackPanel( + this TOwner owner, + TNew control) + where TOwner : View + where TNew : View => + new MemoizedElement(owner, control); + + public static MemoizedElement Append( + this MemoizedElement owner, + TNew control, + int height = 1) + where TOwner : View + where TOld : View + where TNew : View + { + control.X = Pos.Left(owner.Control); + control.Y = Pos.Top(owner.Control) + height; + return new MemoizedElement(owner.View, control); + } + + public class MemoizedElement + where TOwner : View + where TControl : View + { + public TOwner View { get; } + public TControl Control { get; } + + public MemoizedElement(TOwner owner, TControl control) + { + View = owner; + Control = control; + View.Add(control); + } + } + } +} \ No newline at end of file diff --git a/ReactiveExample/FodyWeavers.xml b/ReactiveExample/FodyWeavers.xml new file mode 100644 index 000000000..63fc14848 --- /dev/null +++ b/ReactiveExample/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/ReactiveExample/LoginView.cs b/ReactiveExample/LoginView.cs new file mode 100644 index 000000000..abd776ba1 --- /dev/null +++ b/ReactiveExample/LoginView.cs @@ -0,0 +1,126 @@ +using System.Reactive.Disposables; +using System.Reactive.Linq; +using NStack; +using ReactiveUI; +using Terminal.Gui; + +namespace ReactiveExample { + public class LoginView : Window, IViewFor { + readonly CompositeDisposable _disposable = new CompositeDisposable(); + + public LoginView (LoginViewModel viewModel) : base("Reactive Extensions Example") { + ViewModel = viewModel; + this.StackPanel (new Label ("Login Form")) + .Append (UsernameLengthLabel ()) + .Append (UsernameInput ()) + .Append (PasswordLengthLabel ()) + .Append (PasswordInput ()) + .Append (ValidationLabel ()) + .Append (LoginButton ()) + .Append (LoginProgressLabel ()); + } + + public LoginViewModel ViewModel { get; set; } + + protected override void Dispose (bool disposing) { + _disposable.Dispose (); + base.Dispose (disposing); + } + + TextField UsernameInput () { + var usernameInput = new TextField (ViewModel.Username) { Width = 40 }; + ViewModel + .WhenAnyValue (x => x.Username) + .BindTo (usernameInput, x => x.Text) + .DisposeWith (_disposable); + usernameInput + .Events () + .TextChanged + .Select (old => usernameInput.Text.ToString ()) + .DistinctUntilChanged () + .BindTo (ViewModel, x => x.Username) + .DisposeWith (_disposable); + return usernameInput; + } + + Label UsernameLengthLabel () { + var usernameLengthLabel = new Label { Width = 40 }; + ViewModel + .WhenAnyValue (x => x.UsernameLength) + .Select (length => ustring.Make ($"Username ({length} characters)")) + .BindTo (usernameLengthLabel, x => x.Text) + .DisposeWith (_disposable); + return usernameLengthLabel; + } + + TextField PasswordInput () { + var passwordInput = new TextField (ViewModel.Password) { Width = 40 }; + ViewModel + .WhenAnyValue (x => x.Password) + .BindTo (passwordInput, x => x.Text) + .DisposeWith (_disposable); + passwordInput + .Events () + .TextChanged + .Select (old => passwordInput.Text.ToString ()) + .DistinctUntilChanged () + .BindTo (ViewModel, x => x.Password) + .DisposeWith (_disposable); + return passwordInput; + } + + Label PasswordLengthLabel () { + var passwordLengthLabel = new Label { Width = 40 }; + ViewModel + .WhenAnyValue (x => x.PasswordLength) + .Select (length => ustring.Make ($"Password ({length} characters)")) + .BindTo (passwordLengthLabel, x => x.Text) + .DisposeWith (_disposable); + return passwordLengthLabel; + } + + Label ValidationLabel () { + var error = ustring.Make("Please, enter user name and password."); + var success = ustring.Make("The input is valid!"); + var validationLabel = new Label(error) { Width = 40 }; + ViewModel + .WhenAnyValue (x => x.IsValid) + .Select (valid => valid ? success : error) + .BindTo (validationLabel, x => x.Text) + .DisposeWith (_disposable); + ViewModel + .WhenAnyValue (x => x.IsValid) + .Select (valid => valid ? Colors.Base : Colors.Error) + .BindTo (validationLabel, x => x.ColorScheme) + .DisposeWith (_disposable); + return validationLabel; + } + + Label LoginProgressLabel () { + var progress = ustring.Make ("Logging in..."); + var idle = ustring.Make ("Press 'Login' to log in."); + var loginProgressLabel = new Label(idle) { Width = 40 }; + ViewModel + .WhenAnyObservable (x => x.Login.IsExecuting) + .Select (executing => executing ? progress : idle) + .BindTo (loginProgressLabel, x => x.Text) + .DisposeWith (_disposable); + return loginProgressLabel; + } + + Button LoginButton () { + var loginButton = new Button ("Login") { Width = 40 }; + loginButton + .Events () + .Clicked + .InvokeCommand (ViewModel, x => x.Login) + .DisposeWith (_disposable); + return loginButton; + } + + object IViewFor.ViewModel { + get => ViewModel; + set => ViewModel = (LoginViewModel) value; + } + } +} \ No newline at end of file diff --git a/ReactiveExample/LoginViewModel.cs b/ReactiveExample/LoginViewModel.cs new file mode 100644 index 000000000..0b630eda1 --- /dev/null +++ b/ReactiveExample/LoginViewModel.cs @@ -0,0 +1,57 @@ +using System; +using System.Reactive; +using System.Reactive.Linq; +using System.Runtime.Serialization; +using System.Threading.Tasks; +using ReactiveUI; +using ReactiveUI.Fody.Helpers; + +namespace ReactiveExample { + [DataContract] + public class LoginViewModel : ReactiveObject { + readonly ObservableAsPropertyHelper _usernameLength; + readonly ObservableAsPropertyHelper _passwordLength; + readonly ObservableAsPropertyHelper _isValid; + + public LoginViewModel () { + var canLogin = this.WhenAnyValue ( + x => x.Username, + x => x.Password, + (username, password) => + !string.IsNullOrWhiteSpace (username) && + !string.IsNullOrWhiteSpace (password)); + + _isValid = canLogin.ToProperty (this, x => x.IsValid); + Login = ReactiveCommand.CreateFromTask ( + () => Task.Delay (TimeSpan.FromSeconds (1)), + canLogin); + + _usernameLength = this + .WhenAnyValue (x => x.Username) + .Select (name => name.Length) + .ToProperty (this, x => x.UsernameLength); + _passwordLength = this + .WhenAnyValue (x => x.Password) + .Select (password => password.Length) + .ToProperty (this, x => x.PasswordLength); + } + + [Reactive, DataMember] + public string Username { get; set; } = string.Empty; + + [Reactive, DataMember] + public string Password { get; set; } = string.Empty; + + [IgnoreDataMember] + public int UsernameLength => _usernameLength.Value; + + [IgnoreDataMember] + public int PasswordLength => _passwordLength.Value; + + [IgnoreDataMember] + public ReactiveCommand Login { get; } + + [IgnoreDataMember] + public bool IsValid => _isValid.Value; + } +} \ No newline at end of file diff --git a/ReactiveExample/Program.cs b/ReactiveExample/Program.cs index 71689ef3e..4f20602ce 100644 --- a/ReactiveExample/Program.cs +++ b/ReactiveExample/Program.cs @@ -1,10 +1,12 @@ using System; +using Terminal.Gui; namespace ReactiveExample { - class Program { - static void Main (string [] args) - { - Console.WriteLine ("Hello World!"); + public static class Program { + static void Main (string [] args) { + Application.Init (); // A hacky way to enable instant UI updates. + Application.MainLoop.AddTimeout(TimeSpan.FromMilliseconds(600), a => true); + Application.Run (new LoginView (new LoginViewModel ())); } } } \ No newline at end of file diff --git a/ReactiveExample/ReactiveExample.csproj b/ReactiveExample/ReactiveExample.csproj index 2c0dacc01..f833d5fa1 100644 --- a/ReactiveExample/ReactiveExample.csproj +++ b/ReactiveExample/ReactiveExample.csproj @@ -1,8 +1,13 @@ - Exe netcoreapp3.1 - + + + + + + +