From 225da0d7e91982b5e2c036c767c21820a66f817c Mon Sep 17 00:00:00 2001 From: Markus Ewald Date: Wed, 12 Feb 2025 19:16:24 +0100 Subject: [PATCH] Initial porting attempt of my WinForms MVVM library to Avalonia; added IWindowScope as a new reature because this library should support Microsoft's dependency injector --- Nuclex.Avalonia (netstandard-2.0).csproj | 34 ++ ...ex.Avalonia.Tests (netstandard-2.0).csproj | 33 ++ Properties/AssemblyInfo.cs | 50 ++ Source/AutoBinding/ConventionBinder.cs | 62 +++ Source/AutoBinding/IAutoBinder.cs | 46 ++ Source/IActiveWindowTracker.cs | 35 ++ Source/IWindowManager.cs | 114 ++++ Source/IWindowScope.cs | 64 +++ Source/Messages/AvaloniaMessagePresenter.cs | 188 +++++++ Source/Messages/IMessageService.cs | 58 ++ Source/Messages/MessageEventArgs.cs | 54 ++ Source/Messages/MessageServiceHelper.cs | 117 ++++ Source/Messages/MessageText.cs | 57 ++ Source/NullActiveWindowTracker.cs | 37 ++ Source/ViewModels/IDialogViewModel.cs | 39 ++ Source/WindowManager.cs | 524 ++++++++++++++++++ 16 files changed, 1512 insertions(+) create mode 100644 Nuclex.Avalonia (netstandard-2.0).csproj create mode 100644 Nuclex.Avalonia.Tests (netstandard-2.0).csproj create mode 100644 Properties/AssemblyInfo.cs create mode 100644 Source/AutoBinding/ConventionBinder.cs create mode 100644 Source/AutoBinding/IAutoBinder.cs create mode 100644 Source/IActiveWindowTracker.cs create mode 100644 Source/IWindowManager.cs create mode 100644 Source/IWindowScope.cs create mode 100644 Source/Messages/AvaloniaMessagePresenter.cs create mode 100644 Source/Messages/IMessageService.cs create mode 100644 Source/Messages/MessageEventArgs.cs create mode 100644 Source/Messages/MessageServiceHelper.cs create mode 100644 Source/Messages/MessageText.cs create mode 100644 Source/NullActiveWindowTracker.cs create mode 100644 Source/ViewModels/IDialogViewModel.cs create mode 100644 Source/WindowManager.cs diff --git a/Nuclex.Avalonia (netstandard-2.0).csproj b/Nuclex.Avalonia (netstandard-2.0).csproj new file mode 100644 index 0000000..54ab9d9 --- /dev/null +++ b/Nuclex.Avalonia (netstandard-2.0).csproj @@ -0,0 +1,34 @@ + + + + netstandard2.0 + Apache-2.0 + false + True + Nuclex.Avalonia + Nuclex.Avalonia + obj\source + enable + 8.0 + + + + + + + + + + + + + + + + + + + + + + diff --git a/Nuclex.Avalonia.Tests (netstandard-2.0).csproj b/Nuclex.Avalonia.Tests (netstandard-2.0).csproj new file mode 100644 index 0000000..b8579f9 --- /dev/null +++ b/Nuclex.Avalonia.Tests (netstandard-2.0).csproj @@ -0,0 +1,33 @@ + + + + netstandard2.0 + Apache-2.0 + false + True + Nuclex.Support.Tests + Nuclex.Support.Tests + obj\tests + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Properties/AssemblyInfo.cs b/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..54b637c --- /dev/null +++ b/Properties/AssemblyInfo.cs @@ -0,0 +1,50 @@ +#region Apache License 2.0 +/* +Nuclex .NET Framework +Copyright (C) 2002-2024 Markus Ewald / Nuclex Development Labs + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +#endregion // Apache License 2.0 + +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("Nuclex.Avalonia")] +[assembly: AssemblyProduct("Nuclex.Avalonia")] +[assembly: AssemblyDescription("Lean and elegant MVVM library with extras for Avalonia")] +[assembly: AssemblyCompany("Nuclex Development Labs")] +[assembly: AssemblyCopyright("Copyright © Markus Ewald / Nuclex Development Labs 2002-2025")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("08254ad9-394e-4638-8412-098c8c4a4c39")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +[assembly: AssemblyVersion("1.3.0.0")] diff --git a/Source/AutoBinding/ConventionBinder.cs b/Source/AutoBinding/ConventionBinder.cs new file mode 100644 index 0000000..ff3840f --- /dev/null +++ b/Source/AutoBinding/ConventionBinder.cs @@ -0,0 +1,62 @@ +#region Apache License 2.0 +/* +Nuclex Foundation libraries for .NET +Copyright (C) 2002-2025 Markus Ewald / Nuclex Development Labs + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +#endregion // Apache License 2.0 + +using System; + +using Avalonia.Controls; + +namespace Nuclex.Avalonia.AutoBinding { + + /// + /// Binds a view to its model using a convention-over-configuration approach + /// + public class ConventionBinder : IAutoBinder { + + /// Binds the specified view to an explicitly selected view model + /// + /// Type of view model the view will be bound to + /// + /// View that will be bound to a view model + /// View model the view will be bound to + public void Bind(Control view, TViewModel? viewModel) + where TViewModel : class { + if(viewModel != null) { + bind(view, viewModel); + } + } + + /// + /// Binds the specified view to the view model specified in its DataContext + /// + /// View that will be bound + public void Bind(Control viewControl) { + if(viewControl.DataContext != null) { + bind(viewControl, viewControl.DataContext); + } + } + + /// Binds a view to a view model + /// View that will be bound + /// View model the view will be bound to + private void bind(Control view, object viewModel) { + } + + } + +} // namespace Nuclex.Avalonia.AutoBinding diff --git a/Source/AutoBinding/IAutoBinder.cs b/Source/AutoBinding/IAutoBinder.cs new file mode 100644 index 0000000..d9bd6b4 --- /dev/null +++ b/Source/AutoBinding/IAutoBinder.cs @@ -0,0 +1,46 @@ +#region Apache License 2.0 +/* +Nuclex Foundation libraries for .NET +Copyright (C) 2002-2025 Markus Ewald / Nuclex Development Labs + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +#endregion // Apache License 2.0 + +using System; + +using Avalonia.Controls; + +namespace Nuclex.Avalonia.AutoBinding { + + /// Binds views to their view models + public interface IAutoBinder { + + /// Binds the specified view to an explicitly selected view model + /// + /// Type of view model the view will be bound to + /// + /// View that will be bound to a view model + /// View model the view will be bound to + void Bind(Control view, TViewModel? viewModel) + where TViewModel : class; + + /// + /// Binds the specified view to the view model specified in its DataContext + /// + /// View that will be bound + void Bind(Control view); + + } + +} // namespace Nuclex.Avalonia.AutoBinding diff --git a/Source/IActiveWindowTracker.cs b/Source/IActiveWindowTracker.cs new file mode 100644 index 0000000..82d341c --- /dev/null +++ b/Source/IActiveWindowTracker.cs @@ -0,0 +1,35 @@ +#region Apache License 2.0 +/* +Nuclex Foundation libraries for .NET +Copyright (C) 2002-2025 Markus Ewald / Nuclex Development Labs + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +#endregion // Apache License 2.0 + +using System; + +using Avalonia.Controls; + +namespace Nuclex.Avalonia { + + /// Allows the currently active top level window to be looked up + public interface IActiveWindowTracker { + + /// The currently active top-level or modal window + Window? ActiveWindow { get; } + + } + +} // namespace Nuclex.Avalonia + diff --git a/Source/IWindowManager.cs b/Source/IWindowManager.cs new file mode 100644 index 0000000..e050f52 --- /dev/null +++ b/Source/IWindowManager.cs @@ -0,0 +1,114 @@ +#region Apache License 2.0 +/* +Nuclex Foundation libraries for .NET +Copyright (C) 2002-2025 Markus Ewald / Nuclex Development Labs + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +#endregion // Apache License 2.0 + +using System; +using System.Threading.Tasks; + +using Avalonia.Controls; + +namespace Nuclex.Avalonia { + + /// Manages open windows and connecting them to view models + public interface IWindowManager : IActiveWindowTracker { + + /// Opens a view as a new root window of the application + /// + /// Type of view model a root window will be opened for + /// + /// + /// View model a window will be opened for. If null, the view model will be + /// created as well (unless the dialog already specifies one as a resource) + /// + /// + /// Whether the view model should be disposed when the view is closed + /// + /// The window that has been opened by the window manager + Window OpenRoot( + TViewModel? viewModel = null, bool disposeOnClose = true + ) where TViewModel : class; + + /// Displays a view as a modal window + /// + /// Type of the view model for which a view will be displayed + /// + /// + /// View model a modal window will be displayed for. If null, the view model will + /// be created as well (unless the dialog already specifies one as a resource) + /// + /// + /// Whether the view model should be disposed when the view is closed + /// + /// The return value of the modal window + Task ShowModalAsync( + TViewModel? viewModel = null, bool disposeOnClose = false + ) where TViewModel : class; + + /// Displays a view as a modal window + /// + /// Type of result the modal dialog will return to the caller + /// + /// + /// Type of the view model for which a view will be displayed + /// + /// + /// View model a modal window will be displayed for. If null, the view model will + /// be created as well (unless the dialog already specifies one as a resource) + /// + /// + /// Whether the view model should be disposed when the view is closed + /// + /// The return value of the modal window + Task ShowModalAsync( + TViewModel? viewModel = null, bool disposeOnClose = true + ) where TViewModel : class; + + /// Creates the view for the specified view model + /// + /// Type of view model for which a view will be created + /// + /// + /// View model a view will be created for. If null, the view model will be + /// created as well (unless the dialog already specifies one as a resource) + /// + /// The view for the specified view model + Control CreateView(TViewModel? viewModel = null) + where TViewModel : class; + + /// Creates a view model without a matching view + /// Type of view model that will be created + /// The new view model + /// + /// + /// This is useful if a view model needs to create child view models (i.e. paged container + /// and wants to ensure the same dependency injector (if any) if used as the window + /// manager uses for other view models it creates. + /// + /// + /// This way, view models can set up their child view models without having to immediately + /// bind a view to them. Later on, views can use the window manager to create a matching + /// child view and store it in a container. + /// + /// + TViewModel CreateViewModel() + where TViewModel : class; + + } + +} // namespace Nuclex.Avalonia + diff --git a/Source/IWindowScope.cs b/Source/IWindowScope.cs new file mode 100644 index 0000000..59decd9 --- /dev/null +++ b/Source/IWindowScope.cs @@ -0,0 +1,64 @@ +#region Apache License 2.0 +/* +Nuclex Foundation libraries for .NET +Copyright (C) 2002-2025 Markus Ewald / Nuclex Development Labs + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +#endregion // Apache License 2.0 + +using System; + +namespace Nuclex.Avalonia { + + /// Constructs views and view model in a scope + /// + /// + /// By default the uses its own + /// method to construct views + /// and view models via , + /// which is enough to create forms (which the Windows Forms designer already + /// requires to have parameterless constructors) and view models, so long as + /// they also have parameterless constructors. + /// + /// + /// To support dependency injection via constructor parameters, you can + /// inherit from the and provide your own override + /// of that constructs the required + /// instance via your dependency injector. This is decent until you have multiple + /// view models all accessing the same resource (i.e. a database) via threads. + /// + /// + /// In this final case, "scopes" have become a common solution. Each + /// scope has access to singleton services (these exist for the lifetime of + /// the entire application), but there are also scoped services which will have + /// new instances constructed within each scope. By implementing the + /// method, you can make + /// the window manager set up an implicit scope per window or dialog. + /// + /// + public interface IWindowScope : IDisposable { + + /// Creates an instance of the specified type in the scope + /// Type an instance will be created of + /// The created instance + /// + /// Use this to wire up your dependency injection container. By default, + /// the Activator class will be used to create instances which only works + /// if all of your view models are concrete classes. + /// + object CreateInstance(Type type); + + } + +} // namespace Nuclex.Avalonia diff --git a/Source/Messages/AvaloniaMessagePresenter.cs b/Source/Messages/AvaloniaMessagePresenter.cs new file mode 100644 index 0000000..e7c181b --- /dev/null +++ b/Source/Messages/AvaloniaMessagePresenter.cs @@ -0,0 +1,188 @@ +#region Apache License 2.0 +/* +Nuclex Foundation libraries for .NET +Copyright (C) 2002-2025 Markus Ewald / Nuclex Development Labs + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +#endregion // Apache License 2.0 + +using System; +using System.Threading.Tasks; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +#if NET6_0_OR_GREATER +using System.Runtime.Versioning; +#endif + +using Avalonia.Controls; + +using MessageBoxIcon = MsBox.Avalonia.Enums.Icon; +using MessageBoxButtons = MsBox.Avalonia.Enums.ButtonEnum; +using MessageDialogResult = MsBox.Avalonia.Enums.ButtonResult; + +namespace Nuclex.Avalonia.Messages { + + /// Uses Avalonia to display message boxes + public class AvaloniaMessagePresenter : IMessageService { + + #region class MessageScope + + /// Triggers the message displayed and acknowledged events + private class MessageScope : IDisposable { + + /// + /// Initializes a new message scope, triggering the message displayed event + /// + /// Message service the scope belongs to + /// Image of the message being displayed + /// Text contained in the message being displayed + public MessageScope( + AvaloniaMessagePresenter self, MessageBoxIcon image, MessageText text + ) { + EventHandler? messageDisplayed = self.MessageDisplaying; + if(messageDisplayed != null) { + messageDisplayed(this, new MessageEventArgs(image, text)); + } + + this.self = self; + } + + /// Triggers the message acknowledged event + public void Dispose() { + EventHandler? messageAcknowledged = self.MessageAcknowledged; + if(messageAcknowledged != null) { + messageAcknowledged(this, EventArgs.Empty); + } + } + + /// Message service the scope belongs to + private AvaloniaMessagePresenter self; + + } + + #endregion // class MessageScope + + /// Triggered when a message is displayed to the user + public event EventHandler? MessageDisplaying; + + /// Triggered when the user has acknowledged the current message + public event EventHandler? MessageAcknowledged; + + /// Initializes a new Avalonia message service + /// Used to determine the current top-level window + public AvaloniaMessagePresenter(IActiveWindowTracker tracker) { + this.tracker = tracker; + } + + /// Asks the user a question that can be answered via several buttons + /// Image that will be shown on the message box + /// Text that will be shown to the user + /// Buttons available for the user to click on + /// The button the user has clicked on + public Task ShowQuestionAsync( + MessageBoxIcon image, MessageText text, MessageBoxButtons buttons + ) { + using(var scope = new MessageScope(this, image, text)) { + MsBox.Avalonia.Base.IMsBox messageBox = ( + MsBox.Avalonia.MessageBoxManager.GetMessageBoxStandard( + new MsBox.Avalonia.Dto.MessageBoxStandardParams() { + ContentTitle = text.Caption, + ContentHeader = text.Message, + ContentMessage = text.Details ?? string.Empty, + ButtonDefinitions = buttons, + Icon = image, + WindowStartupLocation = WindowStartupLocation.CenterOwner + } + ) + ); + return messageBox.ShowAsync(); // TODO: Make modal to current or main window + } + } + + /// Displays a notification to the user + /// Image that will be shown on the message bx + /// Text that will be shown to the user + public Task ShowNotificationAsync(MessageBoxIcon image, MessageText text) { + using(var scope = new MessageScope(this, image, text)) { + MsBox.Avalonia.Base.IMsBox messageBox = ( + MsBox.Avalonia.MessageBoxManager.GetMessageBoxStandard( + new MsBox.Avalonia.Dto.MessageBoxStandardParams() { + ContentTitle = text.Caption, + ContentHeader = text.Message, + ContentMessage = text.Details ?? string.Empty, + ButtonDefinitions = MessageBoxButtons.Ok, + Icon = image, + WindowStartupLocation = WindowStartupLocation.CenterOwner + } + ) + ); + + Window? activeWindow = this.tracker.ActiveWindow; + if(activeWindow == null) { + return messageBox.ShowAsync(); + } else { + //return messageBox.ShowAsPopupAsync(activeWindow); + return messageBox.ShowWindowDialogAsync(activeWindow); + } + } + } + + /// Reports an error using the system's message box functions + /// Title of the message box + /// Message text that will be displayed + public static void FallbackReportError(string title, string message) { + // TODO: Escape quotes for the command-line tools + // TODO: Wait for the child process to exit so display is certain + + if(RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { + MessageBoxW(IntPtr.Zero, message, title, MB_OK | MB_ICONEXCLAMATION); + } else if(RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { + Process.Start("zenity", $"--error --title=\"{title}\" --text=\"{message}\""); + } else if(RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { + Process.Start("osascript", $"-e 'display dialog \"{message}\" with title \"{title}\" with icon stop'"); + } + } + + /// Windows only: display a message box with an OK button +#if NET6_0_OR_GREATER + [SupportedOSPlatform("windows")] +#endif + private const uint MB_OK = 0x00000000; + + /// Windows only: display a message box with an Exclamation icon +#if NET6_0_OR_GREATER + [SupportedOSPlatform("windows")] +#endif + private const uint MB_ICONEXCLAMATION = 0x00000030; + + /// Windows only: displays a native Windows message box + /// Handle of the window that owns the message box + /// Text that should be in the message box + /// Caption or window title of the message box + /// Which icons and buttons that message box should have + /// How the user closed the message box and which button they clicked + [DllImport("user32.dll", CharSet = CharSet.Unicode)] +#if NET6_0_OR_GREATER + [SupportedOSPlatform("windows")] +#endif + private static extern int MessageBoxW(IntPtr parentWindowHandle, string text, string caption, uint type); + + // Provides the currently active top-level window + private IActiveWindowTracker tracker; + + } + +} // namespace Nuclex.Avalonia.Messages + diff --git a/Source/Messages/IMessageService.cs b/Source/Messages/IMessageService.cs new file mode 100644 index 0000000..0e6d7e6 --- /dev/null +++ b/Source/Messages/IMessageService.cs @@ -0,0 +1,58 @@ +#region Apache License 2.0 +/* +Nuclex Foundation libraries for .NET +Copyright (C) 2002-2025 Markus Ewald / Nuclex Development Labs + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +#endregion // Apache License 2.0 + +using System; +using System.Threading.Tasks; + +using MessageBoxIcon = MsBox.Avalonia.Enums.Icon; +using MessageBoxButtons = MsBox.Avalonia.Enums.ButtonEnum; +using MessageDialogResult = MsBox.Avalonia.Enums.ButtonResult; + +namespace Nuclex.Avalonia.Messages { + + /// Performs simple user interaction + /// + /// Methods provided by this service can be covered using plain old message boxes + /// and do not require special dialogs or calls to the task dialog API. + /// + public interface IMessageService { + + /// Triggered when a message is about to be displayed to the user + event EventHandler MessageDisplaying; + + /// Triggered when the user has acknowledged the current message + event EventHandler MessageAcknowledged; + + /// Asks the user a question that can be answered via several buttons + /// Image that will be shown on the message box + /// Text that will be shown to the user + /// Buttons available for the user to click on + /// The button the user has clicked on + Task ShowQuestionAsync( + MessageBoxIcon image, MessageText text, MessageBoxButtons buttons + ); + + /// Displays a notification to the user + /// Image that will be shown on the message bx + /// Text that will be shown to the user + Task ShowNotificationAsync(MessageBoxIcon image, MessageText text); + + } + +} // namespace Nuclex.Avalonia.Messages diff --git a/Source/Messages/MessageEventArgs.cs b/Source/Messages/MessageEventArgs.cs new file mode 100644 index 0000000..e20fd08 --- /dev/null +++ b/Source/Messages/MessageEventArgs.cs @@ -0,0 +1,54 @@ +#region Apache License 2.0 +/* +Nuclex Foundation libraries for .NET +Copyright (C) 2002-2025 Markus Ewald / Nuclex Development Labs + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +#endregion // Apache License 2.0 + +using System; + +using MessageBoxIcon = MsBox.Avalonia.Enums.Icon; + +namespace Nuclex.Avalonia.Messages { + + /// Provides a displayed message and its severity to event subscribers + public class MessageEventArgs : EventArgs { + + /// Initializes a new message box event argument container + /// Image the message box will be displaying + /// Text that will be displayed in the message box + public MessageEventArgs(MessageBoxIcon image, MessageText text) { + this.image = image; + this.text = text; + } + + /// Image that indicates the severity of the message being displayed + public MessageBoxIcon Image { + get { return this.image; } + } + + /// Text that is being displayed in the message box + public MessageText Text { + get { return this.text; } + } + + /// Image that indicates the severity of the message being displayed + private MessageBoxIcon image; + /// Text that is being displayed in the message box + private MessageText text; + + } + +} // namespace Nuclex.Avalonia.Messages diff --git a/Source/Messages/MessageServiceHelper.cs b/Source/Messages/MessageServiceHelper.cs new file mode 100644 index 0000000..ad7f85f --- /dev/null +++ b/Source/Messages/MessageServiceHelper.cs @@ -0,0 +1,117 @@ +#region Apache License 2.0 +/* +Nuclex Foundation libraries for .NET +Copyright (C) 2002-2025 Markus Ewald / Nuclex Development Labs + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +#endregion // Apache License 2.0 + +using System; +using System.Threading.Tasks; +#if NET6_0_OR_GREATER +using System.Runtime.Versioning; +#endif + +using MessageBoxIcon = MsBox.Avalonia.Enums.Icon; +using MessageBoxButtons = MsBox.Avalonia.Enums.ButtonEnum; +using MessageDialogResult = MsBox.Avalonia.Enums.ButtonResult; + +namespace Nuclex.Avalonia.Messages { + + /// Contains helper methods for the message service +#if NET6_0_OR_GREATER + [SupportedOSPlatform("windows")] +#endif + public static class MessageServiceHelper { + + /// Asks the user a question that can be answered with yes or no + /// + /// Message service that will be used to display the question + /// + /// Text that will be shown on the message box + /// The button the user has clicked on + public static Task AskYesNoAsync( + this IMessageService messageService, MessageText text + ) { + return messageService.ShowQuestionAsync( + MessageBoxIcon.Question, text, MessageBoxButtons.YesNo + ); + } + + /// Asks the user a question that can be answered with ok or cancel + /// + /// Message service that will be used to display the question + /// + /// Text that will be shown on the message box + /// The button the user has clicked on + public static Task AskOkCancelAsync( + this IMessageService messageService, MessageText text + ) { + return messageService.ShowQuestionAsync( + MessageBoxIcon.Question, text, MessageBoxButtons.OkCancel + ); + } + + /// + /// Asks the user a question that can be answered with yes, no or cancel + /// + /// + /// Message service that will be used to display the question + /// + /// Text that will be shown on the message box + /// The button the user has clicked on + public static Task AskYesNoCancelAsync( + this IMessageService messageService, MessageText text + ) { + return messageService.ShowQuestionAsync( + MessageBoxIcon.Question, text, MessageBoxButtons.YesNoCancel + ); + } + + /// Displays an informative message + /// + /// Message service that will be used to display the warning + /// + /// Text to be displayed on the warning message + public static Task InformAsync( + this IMessageService messageService, MessageText text + ) { + return messageService.ShowNotificationAsync(MessageBoxIcon.Info, text); + } + + /// Displays a warning + /// + /// Message service that will be used to display the warning + /// + /// Text to be displayed on the warning message + public static Task WarnAsync( + this IMessageService messageService, MessageText text + ) { + return messageService.ShowNotificationAsync(MessageBoxIcon.Warning, text); + } + + /// Reports an error + /// + /// Message service that will be used to display the warning + /// + /// Text to be displayed on the warning message + public static Task ReportErrorAsync( + this IMessageService messageService, MessageText text + ) { + return messageService.ShowNotificationAsync(MessageBoxIcon.Error, text); + } + + } + +} // namespace Nuclex.Avalonia.Messages diff --git a/Source/Messages/MessageText.cs b/Source/Messages/MessageText.cs new file mode 100644 index 0000000..b707434 --- /dev/null +++ b/Source/Messages/MessageText.cs @@ -0,0 +1,57 @@ +#region Apache License 2.0 +/* +Nuclex Foundation libraries for .NET +Copyright (C) 2002-2025 Markus Ewald / Nuclex Development Labs + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +#endregion // Apache License 2.0 + +using System; + +namespace Nuclex.Avalonia.Messages { + + /// Text that will be displayed in a message box + public class MessageText { + + /// Initializs a new message text + public MessageText() { + Caption = "Message"; + Message = "No message provided"; + } + + /// Initializes a new message text by copying another instance + /// Instance that will be copied + public MessageText(MessageText other) { + Caption = other.Caption; + Message = other.Message; + Details = other.Details; + ExpandedDetails = other.ExpandedDetails; + } + + /// The caption used when the is displayed in a message box + public string Caption { get; set; } + /// Main message being displayed to the user + public string Message { get; set; } + /// Message details shown below the main message + public string? Details { get; set; } + /// + /// Additional informations the user can display by expanding + /// the message dialog. Can be null, in which case the message dialog + /// will not be expandable. + /// + public string? ExpandedDetails { get; set; } + + } + +} // namespace Nuclex.Avalonia.Messages diff --git a/Source/NullActiveWindowTracker.cs b/Source/NullActiveWindowTracker.cs new file mode 100644 index 0000000..69efc80 --- /dev/null +++ b/Source/NullActiveWindowTracker.cs @@ -0,0 +1,37 @@ +#region Apache License 2.0 +/* +Nuclex Foundation libraries for .NET +Copyright (C) 2002-2025 Markus Ewald / Nuclex Development Labs + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +#endregion // Apache License 2.0 + +using System; + +using Avalonia.Controls; + +namespace Nuclex.Avalonia { + + /// Dummy implementation of the active window tracker service + internal class NullActiveWindowTracker : IActiveWindowTracker { + + /// The default instance of the dummy window tracker + public static readonly NullActiveWindowTracker Default = new NullActiveWindowTracker(); + + /// The currently active top-level or modal window + public Window? ActiveWindow { get { return null; } } + + } + +} // namespace Nuclex.Avalonia diff --git a/Source/ViewModels/IDialogViewModel.cs b/Source/ViewModels/IDialogViewModel.cs new file mode 100644 index 0000000..90f7714 --- /dev/null +++ b/Source/ViewModels/IDialogViewModel.cs @@ -0,0 +1,39 @@ +#region Apache License 2.0 +/* +Nuclex Foundation libraries for .NET +Copyright (C) 2002-2025 Markus Ewald / Nuclex Development Labs + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +#endregion // Apache License 2.0 + +using System; +using System.Threading.Tasks; + +using Nuclex.Support; + +namespace Nuclex.Windows.Forms.ViewModels { + + /// Interface for dialog view models (typically modal ones) + public interface IDialogViewModel { + + /// Indicates that the view should close + event EventHandler Submitted; + + /// Indicates that the dialog should be closed + /// A task that finishes when the submit notification has been sent + Task SubmitAsync(); + + } + +} // namespace Nuclex.Windows.Forms.ViewModels diff --git a/Source/WindowManager.cs b/Source/WindowManager.cs new file mode 100644 index 0000000..d1f9823 --- /dev/null +++ b/Source/WindowManager.cs @@ -0,0 +1,524 @@ +#region Apache License 2.0 +/* +Nuclex Foundation libraries for .NET +Copyright (C) 2002-2025 Markus Ewald / Nuclex Development Labs + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +#endregion // Apache License 2.0 + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading.Tasks; + +using Avalonia.Controls; +using Avalonia.Interactivity; + +using Nuclex.Support; +using Nuclex.Avalonia.AutoBinding; + +namespace Nuclex.Avalonia { + + /// Manages an application's windows and views + public class WindowManager : Observable, IWindowManager { + + #region class WindowManagerScope + + /// Global scope that uses the WindowManager's CreateInstance() + public class WindowManagerScope : IWindowScope { + + /// Initializes a new global window scope + /// + /// Window manager whose CreateInstance() method will be used + /// + public WindowManagerScope(WindowManager windowManager) { + this.windowManager = windowManager; + } + + /// Creates an instance of the specified type in the scope + /// Type an instance will be created of + /// The created instance + object IWindowScope.CreateInstance(Type type) { + return this.windowManager.CreateInstance(type); + } + + /// Does nothing because this is the global fallback scope + void IDisposable.Dispose() {} + + /// WindowManager whose CreateInstance() method will be wrapped + private WindowManager windowManager; + + } + + #endregion // class WindwoManagerScope + + #region class CancellableDisposer + + /// Disposes an object that potentially implements IDisposable + private struct CancellableDisposer : IDisposable { + + /// Initializes a new cancellable disposer + /// + /// Object that potentially implements IDisposable + /// + public CancellableDisposer(object? potentiallyDisposable = null) { + this.potentiallyDisposable = potentiallyDisposable; + } + + /// + /// Disposes the assigned object if the disposer has not been cancelled + /// + public void Dispose() { + IDisposable? disposable = this.potentiallyDisposable as IDisposable; + if(disposable != null) { + disposable.Dispose(); + } + } + + /// Cancels the call to Dispose(), keeping the object alive + public void Dismiss() { + this.potentiallyDisposable = null; + } + + /// Assigns a new potentially disposable object + /// + /// Potentially disposable object that the disposer will dispose + /// + public void Set(object? potentiallyDisposable) { + this.potentiallyDisposable = potentiallyDisposable; + } + + /// Object that will be disposed unless the disposer is cancelled + private object? potentiallyDisposable; + + } + + #endregion // class CancellableDisposer + + /// Initializes a new window manager + /// + /// View model binder that will be used to bind all created views to their models + /// + public WindowManager(IAutoBinder? autoBinder = null) { + this.autoBinder = autoBinder; + + this.rootWindowActivatedDelegate = rootWindowActivated; + this.rootWindowClosedDelegate = rootWindowClosed; + this.windowManagerAsScope = new WindowManagerScope(this); + this.viewTypesForViewModels = new ConcurrentDictionary(); + } + + /// The currently active top-level or modal window + public Window? ActiveWindow { + get { return this.activeWindow; } + private set { + if(value != this.activeWindow) { + this.activeWindow = value; + OnPropertyChanged(nameof(ActiveWindow)); + } + } + } + + /// Opens a view as a new root window of the application + /// + /// Type of view model a root window will be opened for + /// + /// + /// View model a window will be opened for. If null, the view model will be + /// created as well (unless the dialog already specifies one as a resource) + /// + /// + /// Whether the view model should be disposed when the view is closed + /// + /// The window that has been opened by the window manager + public Window OpenRoot( + TViewModel? viewModel = null, bool disposeOnClose = false + ) where TViewModel : class { + Window window = (Window)CreateView(viewModel); + window.Activated += this.rootWindowActivatedDelegate; + window.Closed += this.rootWindowClosedDelegate; + + // If we either created the view model or the user explicitly asked us to + // dispose their view model, prepare the window so that it will dispose its + // view model when the window is done. + if((viewModel == null) || disposeOnClose) { + setupViewModelDisposal(window); + } + + window.Show(); + + return window; + } + + /// Displays a view as a modal window + /// + /// Type of the view model for which a view will be displayed + /// + /// + /// View model a modal window will be displayed for. If null, the view model will + /// be created as well (unless the dialog already specifies one as a resource) + /// + /// + /// Whether the view model should be disposed when the view is closed + /// + /// The return value of the modal window + public Task ShowModalAsync( + TViewModel? viewModel = null, bool disposeOnClose = false + ) where TViewModel : class { + return ShowModalAsync(viewModel, disposeOnClose); + } + + /// Displays a view as a modal window + /// + /// Type of result the modal dialog will return to the caller + /// + /// + /// Type of the view model for which a view will be displayed + /// + /// + /// View model a modal window will be displayed for. If null, the view model will + /// be created as well (unless the dialog already specifies one as a resource) + /// + /// + /// Whether the view model should be disposed when the view is closed + /// + /// The return value of the modal window + public Task ShowModalAsync( + TViewModel? viewModel = null, bool disposeOnClose = false + ) where TViewModel : class { + if(this.activeWindow == null) { + throw new InvalidOperationException("Showing a modal dialog requires an active window"); + } + + Window window = (Window)CreateView(viewModel); + Window? parentWindow = this.activeWindow; + window.Activated += this.rootWindowActivatedDelegate; + + try { + // If we either created the view model or the user explicitly asked us to + // dispose his view model, prepare the window so that it will dispose its + // view model when the window is done. + if((viewModel == null) || disposeOnClose) { + setupViewModelDisposal(window); + } + + return window.ShowDialog(this.activeWindow); + } + finally { + window.Activated -= this.rootWindowActivatedDelegate; + ActiveWindow = parentWindow; + + if(disposeOnClose) { + disposeIfDisposable(window); + } + } + } + + /// Creates the view for the specified view model + /// + /// Type of view model for which a view will be created + /// + /// + /// View model a view will be created for. If null, the view model will be + /// created as well (unless the dialog already specifies one as a resource) + /// + /// The view for the specified view model + public virtual Control CreateView( + TViewModel? viewModel = null + ) where TViewModel : class { + Control viewControl; + { + Type viewType = LocateViewForViewModel(typeof(TViewModel)); + + IWindowScope scope = CreateWindowScope(); + using(var scopeDisposer = new CancellableDisposer(scope)) { + viewControl = (Control)scope.CreateInstance(viewType); + using(var viewDisposer = new CancellableDisposer(viewControl)) { + + // Create a view model if none was provided, and in either case assign + // the view model to the view (provided it implements IView). + using(var viewModelDisposer = new CancellableDisposer()) { + if(viewModel == null) { // No view model provided, create one + if(viewControl.DataContext == null) { // View has no view model + viewModel = (TViewModel)scope.CreateInstance(typeof(TViewModel)); + viewModelDisposer.Set(viewModel); + viewControl.DataContext = viewModel; + } else { // There's an existing view model + viewModel = viewControl.DataContext as TViewModel; + if(viewModel == null) { // The existing view model is another type + viewModel = (TViewModel)scope.CreateInstance(typeof(TViewModel)); + viewModelDisposer.Set(viewModel); + viewControl.DataContext = viewModel; + } + } + } else { // Caller has provided a view model + viewControl.DataContext = viewModel; + } + + // If an auto binder was provided, automatically bind the view to the view model + if(this.autoBinder != null) { + this.autoBinder.Bind(viewControl, viewModel); + } + + viewModelDisposer.Dismiss(); // Everything went well, we keep the view model + } + + viewDisposer.Dismiss(); // Everything went well, we keep the view + } + + scopeDisposer.Dismiss(); // Everything went well, we keep the scope + } + + setupScopeDisposal(viewControl, scope); + } // beauty scope + + return viewControl; + } + + /// Creates a view model without a matching view + /// Type of view model that will be created + /// The new view model + /// + /// + /// This is useful if a view model needs to create child view models (i.e. paged container + /// and wants to ensure the same dependency injector (if any) if used as the window + /// manager uses for other view models it creates. + /// + /// + /// This way, view models can set up their child view models without having to immediately + /// bind a view to them. Later on, views can use the window manager to create a matching + /// child view and store it in a container. + /// + /// + public TViewModel CreateViewModel() where TViewModel : class { + return (TViewModel)CreateInstance(typeof(TViewModel)); + } + + /// Locates the view that will be used to a view model + /// + /// Type of view model for which the view will be located + /// + /// The type of view that should be used for the specified view model + protected virtual Type LocateViewForViewModel(Type viewModelType) { + Type? viewType; + if(!this.viewTypesForViewModels.TryGetValue(viewModelType, out viewType)) { + string viewName = viewModelType.Name; + if(viewName.EndsWith("ViewModel")) { + viewName = viewName.Substring(0, viewName.Length - 9); + } + + Type[] exportedTypes = viewModelType.Assembly.GetExportedTypes(); + Type[] namespaceTypes = filterTypesByNamespace(exportedTypes, viewModelType.Namespace); + + // First, search the own namespace (because if two identical view models exist in + // different namespaces, the one in the same namespace is most likely the desired one) + viewType = findBestMatch( + namespaceTypes, + viewName + "View", + viewName + "Page", + viewName + "Form", + viewName + "Window", + viewName + "Dialog", + viewName + "Control" + ); + + // If the view model doesn't exist in the same namespace, expand the search to + // the entire assembly the view is in. + if(viewType == null) { + viewType = findBestMatch( + exportedTypes, + viewName + "View", + viewName + "Page", + viewName + "Form", + viewName + "Window", + viewName + "Dialog", + viewName + "Control" + ); + } + + // Still no view found? We give up! + if(viewType == null) { + throw new InvalidOperationException( + string.Format("Could not locate view for view model '{0}'", viewModelType.Name) + ); + } + + this.viewTypesForViewModels.TryAdd(viewModelType, viewType); + } + + return viewType; + } + + /// Creates an instance of the specified type + /// Type an instance will be created of + /// The created instance + /// + /// Use this to wire up your dependency injection container. By default, + /// the Activator class will be used to create instances which only works + /// if all of your view models are concrete classes. + /// + protected virtual object CreateInstance(Type type) { + return Activator.CreateInstance(type); + } + + /// Creates a new scope in which window-specific instances live + /// + /// A new scope in which scoped services requested by the window's view model + /// will live + /// + /// + /// If you do not override this method, services will be constructed through + /// the normal method (which actually may not + /// work without managing your own service scope in case your dependency + /// injector supports scopes and some of your services are scoped). By + /// overriding this method, you can automatically cause a new scope to be + /// created for each window or dialog. That way, an implicit service scope + /// will cover the lifetime of each window and its view model and any + /// non-singleton services will use new instances, avoiding, for example, + /// that multiple dialogs access the same database connection simultaneously. + /// + protected virtual IWindowScope CreateWindowScope() { + return this.windowManagerAsScope; + } + + /// Called when one of the application's root windows is closed + /// Window that has been closed + /// Not used + private void rootWindowClosed(object sender, EventArgs arguments) { + Window closedWindow = (Window)sender; + closedWindow.Closed -= this.rootWindowClosedDelegate; + closedWindow.Activated -= this.rootWindowActivatedDelegate; + + lock(this) { + ActiveWindow = null; + } + + // The IoC container already does this and it's the user's responsibility anyways + //disposeIfDisposable(closedWindow); + } + + /// Called when one of the application's root windows is activated + /// Window that has been put in the foreground + /// Not used + private void rootWindowActivated(object? sender, EventArgs arguments) { + lock(this) { + ActiveWindow = (Window?)sender; + } + } + + /// Tries to find the best match for a named type in a list of types + /// List of types the search will take place in + /// + /// The candidates the method will look for, starting with the best match + /// + /// The best match in the list of types, if any match was found + private static Type? findBestMatch(Type[] types, params string[] typeNames) { + int bestMatchFound = typeNames.Length; + + Type? type = null; + for(int index = 0; index < types.Length; ++index) { + for(int nameIndex = 0; nameIndex < bestMatchFound; ++nameIndex) { + if(types[index].Name == typeNames[nameIndex]) { + bestMatchFound = nameIndex; + type = types[index]; + + if(bestMatchFound == 0) { // There can be no better match + return type; + } + + break; + } + } + } + + return type; + } + + /// Disposes the specified object if it implements IDisposable + /// Type of object that will disposed if possible + /// Object that the method will attempt to dispose + private static void disposeIfDisposable(T instance) where T : class { + var disposable = instance as IDisposable; + if(disposable != null) { + disposable.Dispose(); + } + } + + /// Attaches a view model disposer to a control + /// + /// Control whose view model will be disposed when it is itself disposed + /// + private static void setupViewModelDisposal(Control control) { + IDisposable? disposableViewModel = control.DataContext as IDisposable; + if(disposableViewModel != null) { + control.Unloaded += delegate(object? sender, RoutedEventArgs arguments) { + disposableViewModel.Dispose(); + control.DataContext = null; + }; + } + + //window.Tag = "DisposeViewModelOnClose"; // TODO: Wrap SetProp() instead? + //window.SetValue(DisposeViewModelOnCloseProperty, true); + } + + /// Attaches a scope disposer to a control + /// + /// Control that will dispose a scope when it is itself disposed + /// + /// + /// Scope that will be disposed together with the control + /// + private void setupScopeDisposal(Control control, IWindowScope scope) { + IDisposable disposableScope = (IDisposable)scope; + if(disposableScope != null) { + control.Unloaded += delegate(object? sender, RoutedEventArgs arguments) { + disposableScope.Dispose(); + }; + } + } + + /// Filters a list of types to contain only those in a specific namespace + /// List of exported types that will be filtered + /// + /// Namespace the types in the filtered list will be in + /// + /// A subset of the specified types that are in the provided namespace + private static Type[] filterTypesByNamespace(Type[] exportedTypes, string? filteredNamespace) { + var filteredTypes = new List(exportedTypes.Length / 2); + for(int index = 0; index < exportedTypes.Length; ++index) { + Type exportedType = exportedTypes[index]; + if(exportedType.Namespace == filteredNamespace) { + filteredTypes.Add(exportedType); + } + } + + return filteredTypes.ToArray(); + } + + /// The application's currently active root window + private Window? activeWindow; + /// Invoked when a root window is put in the foreground + private EventHandler rootWindowActivatedDelegate; + /// Invoked when a root window has been closed + private EventHandler rootWindowClosedDelegate; + /// Scope that uses the WindowManager's global CreateInstance() method + private WindowManagerScope windowManagerAsScope; + /// View model binder that will be used on all created views + private IAutoBinder? autoBinder; + /// Caches the view types to use for a view model + private ConcurrentDictionary viewTypesForViewModels; + + } + +} // namespace Nuclex.Avalonia