2
0
Эх сурвалжийг харах

Merge pull request #937 from worldbeater/reactive-demo

Add Reactive Extensions & ReactiveUI Sample App
Charlie Kindel 4 жил өмнө
parent
commit
0c966e661c

+ 1 - 0
README.md

@@ -52,6 +52,7 @@ In addition, a complete Xterm/Vt100 terminal emulator that you can embed is now
 * **[Arbitrary Views](https://migueldeicaza.github.io/gui.cs/api/Terminal.Gui/Terminal.Gui.View.html)** - All visible UI elements are subclasses of the `View` class, and these in turn can contain an arbitrary number of sub-views.
 * **Advanced App Features** - The [Mainloop](https://migueldeicaza.github.io/gui.cs/api/Terminal.Gui/Mono.Terminal.MainLoop.html) supports processing events, idle handlers, timers, and monitoring file
 descriptors.
+* **Reactive Extensions Support** - Use [reactive extensions](https://github.com/dotnet/reactive) and benefit from increased code readability, and the ability to apply the MVVM pattern and [ReactiveUI](https://www.reactiveui.net/) data bindings. See the [source code](https://github.com/migueldeicaza/gui.cs/tree/master/ReactiveExample) of a sample app in order to learn how to achieve this.
 
 ### Keyboard Input Handling
 

+ 3 - 0
ReactiveExample/FodyWeavers.xml

@@ -0,0 +1,3 @@
+<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
+  <ReactiveUI />
+</Weavers>

+ 184 - 0
ReactiveExample/LoginView.cs

@@ -0,0 +1,184 @@
+using System.Reactive.Disposables;
+using System.Reactive.Linq;
+using NStack;
+using ReactiveUI;
+using Terminal.Gui;
+
+namespace ReactiveExample {
+	public class LoginView : Window, IViewFor<LoginViewModel> {
+		readonly CompositeDisposable _disposable = new CompositeDisposable();
+		
+		public LoginView (LoginViewModel viewModel) : base("Reactive Extensions Example") {
+			ViewModel = viewModel;
+			var title = TitleLabel ();
+			var usernameLengthLabel = UsernameLengthLabel (title);
+			var usernameInput = UsernameInput (usernameLengthLabel);
+			var passwordLengthLabel = PasswordLengthLabel (usernameInput);
+			var passwordInput = PasswordInput (passwordLengthLabel);
+			var validationLabel = ValidationLabel (passwordInput);
+			var loginButton = LoginButton (validationLabel);
+			var clearButton = ClearButton (loginButton);
+			LoginProgressLabel (clearButton);
+		}
+		
+		public LoginViewModel ViewModel { get; set; }
+
+		protected override void Dispose (bool disposing) {
+			_disposable.Dispose ();
+			base.Dispose (disposing);
+		}
+
+		Label TitleLabel () {
+			var label = new Label("Login Form");
+			Add (label);
+			return label;
+		}
+
+		TextField UsernameInput (View previous) {
+			var usernameInput = new TextField (ViewModel.Username) {
+				X = Pos.Left(previous),
+				Y = Pos.Top(previous) + 1,
+				Width = 40
+			};
+			ViewModel
+				.WhenAnyValue (x => x.Username)
+				.BindTo (usernameInput, x => x.Text)
+				.DisposeWith (_disposable);
+			usernameInput
+				.Events ()
+				.TextChanged
+				.Select (old => usernameInput.Text)
+				.DistinctUntilChanged ()
+				.BindTo (ViewModel, x => x.Username)
+				.DisposeWith (_disposable);
+			Add (usernameInput);
+			return usernameInput;
+		}
+
+		Label UsernameLengthLabel (View previous) {
+			var usernameLengthLabel = new Label {
+				X = Pos.Left(previous),
+				Y = Pos.Top(previous) + 1,
+				Width = 40
+			};
+			ViewModel
+				.WhenAnyValue (x => x.UsernameLength)
+				.Select (length => ustring.Make ($"Username ({length} characters)"))
+				.BindTo (usernameLengthLabel, x => x.Text)
+				.DisposeWith (_disposable);
+			Add (usernameLengthLabel);
+			return usernameLengthLabel;
+		}
+
+		TextField PasswordInput (View previous) {
+			var passwordInput = new TextField (ViewModel.Password) {
+				X = Pos.Left(previous),
+				Y = Pos.Top(previous) + 1,
+				Width = 40
+			};
+			ViewModel
+				.WhenAnyValue (x => x.Password)
+				.BindTo (passwordInput, x => x.Text)
+				.DisposeWith (_disposable);
+			passwordInput
+				.Events ()
+				.TextChanged
+				.Select (old => passwordInput.Text)
+				.DistinctUntilChanged ()
+				.BindTo (ViewModel, x => x.Password)
+				.DisposeWith (_disposable);
+			Add (passwordInput);
+			return passwordInput;
+		}
+
+		Label PasswordLengthLabel (View previous) {
+			var passwordLengthLabel = new Label {
+				X = Pos.Left(previous),
+				Y = Pos.Top(previous) + 1,
+				Width = 40
+			};
+			ViewModel
+				.WhenAnyValue (x => x.PasswordLength)
+				.Select (length => ustring.Make ($"Password ({length} characters)"))
+				.BindTo (passwordLengthLabel, x => x.Text)
+				.DisposeWith (_disposable);
+			Add (passwordLengthLabel);
+			return passwordLengthLabel;
+		}
+
+		Label ValidationLabel (View previous) {
+			var error = ustring.Make("Please, enter user name and password.");
+			var success = ustring.Make("The input is valid!");
+			var validationLabel = new Label(error) {
+				X = Pos.Left(previous),
+				Y = Pos.Top(previous) + 1,
+				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);
+			Add (validationLabel);
+			return validationLabel;
+		}
+
+		Label LoginProgressLabel (View previous) {
+			var progress = ustring.Make ("Logging in...");
+			var idle = ustring.Make ("Press 'Login' to log in.");
+			var loginProgressLabel = new Label(idle) {
+				X = Pos.Left(previous),
+				Y = Pos.Top(previous) + 1,
+				Width = 40
+			};
+			ViewModel
+				.WhenAnyObservable (x => x.Login.IsExecuting)
+				.Select (executing => executing ? progress : idle)
+				.ObserveOn (RxApp.MainThreadScheduler)
+				.BindTo (loginProgressLabel, x => x.Text)
+				.DisposeWith (_disposable);
+			Add (loginProgressLabel);
+			return loginProgressLabel;
+		}
+
+		Button LoginButton (View previous) {
+			var loginButton = new Button ("Login") {
+				X = Pos.Left(previous),
+				Y = Pos.Top(previous) + 1,
+				Width = 40
+			};
+			loginButton
+				.Events ()
+				.Clicked
+				.InvokeCommand (ViewModel, x => x.Login)
+				.DisposeWith (_disposable);
+			Add (loginButton);
+			return loginButton;
+		}
+
+		Button ClearButton (View previous) {
+			var clearButton = new Button("Clear") {
+				X = Pos.Left(previous),
+				Y = Pos.Top(previous) + 1,
+				Width = 40
+			};
+			clearButton
+				.Events ()
+				.Clicked
+				.InvokeCommand (ViewModel, x => x.Clear)
+				.DisposeWith (_disposable);
+			Add (clearButton);
+			return clearButton;
+		}
+		
+		object IViewFor.ViewModel {
+			get => ViewModel;
+			set => ViewModel = (LoginViewModel) value;
+		}
+	}
+}

+ 78 - 0
ReactiveExample/LoginViewModel.cs

@@ -0,0 +1,78 @@
+using System;
+using System.Reactive;
+using System.Reactive.Linq;
+using System.Runtime.Serialization;
+using System.Threading.Tasks;
+using NStack;
+using ReactiveUI;
+using ReactiveUI.Fody.Helpers;
+
+namespace ReactiveExample {
+	//
+	// This view model can be easily shared across different UI frameworks.
+	// For example, if you have a WPF or XF app with view models written
+	// this way, you can easily port your app to Terminal.Gui by implementing
+	// the views with Terminal.Gui classes and ReactiveUI bindings.
+	//
+	// We mark the view model with the [DataContract] attributes and this
+	// allows you to save the view model class to the disk, and then to read
+	// the view model from the disk, making your app state persistent.
+	// See also: https://www.reactiveui.net/docs/handbook/data-persistence/
+	//
+	[DataContract]
+	public class LoginViewModel : ReactiveObject {
+		readonly ObservableAsPropertyHelper<int> _usernameLength;
+		readonly ObservableAsPropertyHelper<int> _passwordLength;
+		readonly ObservableAsPropertyHelper<bool> _isValid;
+		
+		public LoginViewModel () {
+			var canLogin = this.WhenAnyValue (
+				x => x.Username, 
+				x => x.Password,
+				(username, password) =>
+					!ustring.IsNullOrEmpty (username) &&
+					!ustring.IsNullOrEmpty (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);
+			
+			Clear = ReactiveCommand.Create (() => { });
+			Clear.Subscribe (unit => {
+				Username = ustring.Empty;
+				Password = ustring.Empty;
+			});
+		}
+		
+		[Reactive, DataMember]
+		public ustring Username { get; set; } = ustring.Empty;
+		
+		[Reactive, DataMember]
+		public ustring Password { get; set; } = ustring.Empty;
+		
+		[IgnoreDataMember]
+		public int UsernameLength => _usernameLength.Value;
+		
+		[IgnoreDataMember]
+		public int PasswordLength => _passwordLength.Value;
+
+		[IgnoreDataMember]
+		public ReactiveCommand<Unit, Unit> Login { get; }
+		
+		[IgnoreDataMember]
+		public ReactiveCommand<Unit, Unit> Clear { get; }
+		
+		[IgnoreDataMember]
+		public bool IsValid => _isValid.Value;
+	}
+}

+ 14 - 0
ReactiveExample/Program.cs

@@ -0,0 +1,14 @@
+using System.Reactive.Concurrency;
+using ReactiveUI;
+using Terminal.Gui;
+
+namespace ReactiveExample {
+	public static class Program {
+		static void Main (string [] args) {
+			Application.Init ();
+			RxApp.MainThreadScheduler = TerminalScheduler.Default;
+			RxApp.TaskpoolScheduler = TaskPoolScheduler.Default;
+			Application.Run (new LoginView (new LoginViewModel ()));
+		}
+	}
+}

+ 48 - 0
ReactiveExample/README.md

@@ -0,0 +1,48 @@
+This is a sample app that shows how to use `System.Reactive` and `ReactiveUI` with `Terminal.Gui`. The app uses the MVVM architecture that may seem familiar to folks coming from WPF, Xamarin Forms, UWP, Avalonia, or Windows Forms. In this app, we implement the data bindings using ReactiveUI `WhenAnyValue` syntax and [Pharmacist](https://github.com/reactiveui/pharmacist) — a tool that converts all events in a NuGet package into observable wrappers.
+
+<img src="https://user-images.githubusercontent.com/6759207/94748621-646a7280-038a-11eb-8ea0-34629dc799b3.gif" width="450">
+
+### Scheduling
+
+In order to use reactive extensions scheduling, copy-paste the `TerminalScheduler.cs` file into your project, and add the following lines to the composition root of your `Terminal.Gui` application:
+
+```cs
+Application.Init ();
+RxApp.MainThreadScheduler = TerminalScheduler.Default;
+RxApp.TaskpoolScheduler = TaskPoolScheduler.Default;
+Application.Run (new RootView (new RootViewModel ()));
+```
+
+From now on, you can use `.ObserveOn(RxApp.MainThreadScheduler)` to return to the main loop from a background thread. This is useful when you have a `IObservable<TValue>` updated from a background thread, and you wish to update the UI with `TValue`s received from that observable.
+
+### Data Bindings
+
+If you wish to implement `OneWay` data binding, then use the `WhenAnyValue` [ReactiveUI extension method](https://www.reactiveui.net/docs/handbook/when-any/) that listens to `INotifyPropertyChanged` events of the specified property, and converts that events into `IObservable<TProperty>`:
+
+```cs
+// 'usernameInput' is 'TextField' 
+ViewModel
+	.WhenAnyValue (x => x.Username)
+	.BindTo (usernameInput, x => x.Text);
+```
+
+Note that your view model should implement `INotifyPropertyChanged` or inherit from a `ReactiveObject`. If you wish to implement `OneWayToSource` data binding, then install [Pharmacist.MSBuild](https://github.com/reactiveui/pharmacist) into your project and listen to e.g. `TextChanged` event of a `TextField`:
+
+```cs
+// 'usernameInput' is 'TextField'
+usernameInput
+	.Events () // The Events() extension is generated by Pharmacist.
+	.TextChanged
+	.Select (old => usernameInput.Text)
+	.DistinctUntilChanged ()
+	.BindTo (ViewModel, x => x.Username);
+```
+
+If you combine `OneWay` and `OneWayToSource` data bindings, you get `TwoWay` data binding. Also be sure to use the `ustring` type instead of the `string` type. Invoking commands should be as simple as this:
+```cs
+// 'clearButton' is 'Button'
+clearButton
+	.Events ()
+	.Clicked
+	.InvokeCommand (ViewModel, x => x.Clear);
+```

+ 13 - 0
ReactiveExample/ReactiveExample.csproj

@@ -0,0 +1,13 @@
+<Project Sdk="Microsoft.NET.Sdk">
+    <PropertyGroup>
+        <OutputType>Exe</OutputType>
+        <TargetFramework>netcoreapp3.1</TargetFramework>
+    </PropertyGroup>
+    <ItemGroup>
+        <PackageReference Include="Pharmacist.MsBuild" Version="1.8.1" PrivateAssets="all" />
+        <PackageReference Include="Pharmacist.Common" Version="1.8.1" />
+        <PackageReference Include="Terminal.Gui" Version="1.0.0-pre.1" />
+        <PackageReference Include="ReactiveUI.Fody" Version="11.5.35" />
+        <PackageReference Include="ReactiveUI" Version="11.5.35" />
+    </ItemGroup>
+</Project>

+ 41 - 0
ReactiveExample/TerminalScheduler.cs

@@ -0,0 +1,41 @@
+using System;
+using System.Reactive.Concurrency;
+using System.Reactive.Disposables;
+using Terminal.Gui;
+
+namespace ReactiveExample {
+	public class TerminalScheduler : LocalScheduler {
+		public static readonly TerminalScheduler Default = new TerminalScheduler();
+		TerminalScheduler () { }
+
+		public override IDisposable Schedule<TState> (
+			TState state, TimeSpan dueTime,
+			Func<IScheduler, TState, IDisposable> action) {
+			
+			IDisposable PostOnMainLoop() {
+				var composite = new CompositeDisposable(2);
+				var cancellation = new CancellationDisposable();
+				Application.MainLoop.Invoke (() => {
+					if (!cancellation.Token.IsCancellationRequested)
+						composite.Add(action(this, state));
+				});
+				composite.Add(cancellation);
+				return composite;
+			}
+
+			IDisposable PostOnMainLoopAsTimeout () {
+				var composite = new CompositeDisposable (2);
+				var timeout = Application.MainLoop.AddTimeout (dueTime, args => {
+					composite.Add(action (this, state));
+					return false;
+				});
+				composite.Add (Disposable.Create (() => Application.MainLoop.RemoveTimeout (timeout)));
+				return composite;
+			}
+
+			return dueTime == TimeSpan.Zero 
+				? PostOnMainLoop ()
+				: PostOnMainLoopAsTimeout ();
+		}
+	}
+}

+ 1 - 0
Terminal.Gui/Terminal.Gui.csproj

@@ -24,6 +24,7 @@
     <PackageReleaseNotes>
       v1.00 -
       * If StatusBar.Visible is set to false, TopLevel resizes correctly enabling hiding/showing of a StatusBar. UICatalog demonstrates.
+      * New sample/demo app - ReactiveExample - Shows how to use reactive extensions and ReactiveUI with gui.cs.
       
       v0.90 - "Feature Complete" pre-release of Terminal.Gui (aka gui.cs) 1.0. This release is a signficant upgrade from the previous published release (0.81). Most of the major changes and bug fixes are listed below. NOTE: This release includes breaking changes to the API; we will strive to avoid any more breaking changes before 1.0.
 

+ 10 - 0
Terminal.sln

@@ -12,6 +12,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UICatalog", "UICatalog\UICa
 EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UnitTests", "UnitTests\UnitTests.csproj", "{8B901EDE-8974-4820-B100-5226917E2990}"
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReactiveExample", "ReactiveExample\ReactiveExample.csproj", "{44E15B48-0DB2-4560-82BD-D3B7989811C3}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -60,6 +62,14 @@ Global
 		{8B901EDE-8974-4820-B100-5226917E2990}.Release|Any CPU.Build.0 = Release|Any CPU
 		{8B901EDE-8974-4820-B100-5226917E2990}.Release|x86.ActiveCfg = Release|Any CPU
 		{8B901EDE-8974-4820-B100-5226917E2990}.Release|x86.Build.0 = Release|Any CPU
+		{44E15B48-0DB2-4560-82BD-D3B7989811C3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{44E15B48-0DB2-4560-82BD-D3B7989811C3}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{44E15B48-0DB2-4560-82BD-D3B7989811C3}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{44E15B48-0DB2-4560-82BD-D3B7989811C3}.Debug|x86.Build.0 = Debug|Any CPU
+		{44E15B48-0DB2-4560-82BD-D3B7989811C3}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{44E15B48-0DB2-4560-82BD-D3B7989811C3}.Release|Any CPU.Build.0 = Release|Any CPU
+		{44E15B48-0DB2-4560-82BD-D3B7989811C3}.Release|x86.ActiveCfg = Release|Any CPU
+		{44E15B48-0DB2-4560-82BD-D3B7989811C3}.Release|x86.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE