diff --git a/src/Avalonia.Controls/ComboBox.cs b/src/Avalonia.Controls/ComboBox.cs index 57c07916db..274696d501 100644 --- a/src/Avalonia.Controls/ComboBox.cs +++ b/src/Avalonia.Controls/ComboBox.cs @@ -1,6 +1,8 @@ using System; using System.Linq; +using System.Reactive.Disposables; using Avalonia.Controls.Generators; +using Avalonia.Controls.Mixins; using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; using Avalonia.Controls.Shapes; @@ -80,7 +82,7 @@ namespace Avalonia.Controls private bool _isDropDownOpen; private Popup _popup; private object _selectionBoxItem; - private IDisposable _subscriptionsOnOpen; + private readonly CompositeDisposable _subscriptionsOnOpen = new CompositeDisposable(); /// /// Initializes static members of the class. @@ -291,6 +293,7 @@ namespace Avalonia.Controls _popup = e.NameScope.Get("PART_Popup"); _popup.Opened += PopupOpened; + _popup.Closed += PopupClosed; } internal void ItemFocused(ComboBoxItem dropDownItem) @@ -303,8 +306,7 @@ namespace Avalonia.Controls private void PopupClosed(object sender, EventArgs e) { - _subscriptionsOnOpen?.Dispose(); - _subscriptionsOnOpen = null; + _subscriptionsOnOpen.Clear(); if (CanFocus(this)) { @@ -316,20 +318,34 @@ namespace Avalonia.Controls { TryFocusSelectedItem(); - _subscriptionsOnOpen?.Dispose(); - _subscriptionsOnOpen = null; + _subscriptionsOnOpen.Clear(); var toplevel = this.GetVisualRoot() as TopLevel; if (toplevel != null) { - _subscriptionsOnOpen = toplevel.AddDisposableHandler(PointerWheelChangedEvent, (s, ev) => + toplevel.AddDisposableHandler(PointerWheelChangedEvent, (s, ev) => { //eat wheel scroll event outside dropdown popup while it's open if (IsDropDownOpen && (ev.Source as IVisual).GetVisualRoot() == toplevel) { ev.Handled = true; } - }, Interactivity.RoutingStrategies.Tunnel); + }, Interactivity.RoutingStrategies.Tunnel).DisposeWith(_subscriptionsOnOpen); + } + + this.GetObservable(IsVisibleProperty).Subscribe(IsVisibleChanged).DisposeWith(_subscriptionsOnOpen); + + foreach (var parent in this.GetVisualAncestors().OfType()) + { + parent.GetObservable(IsVisibleProperty).Subscribe(IsVisibleChanged).DisposeWith(_subscriptionsOnOpen); + } + } + + private void IsVisibleChanged(bool isVisible) + { + if (!isVisible && IsDropDownOpen) + { + IsDropDownOpen = false; } } diff --git a/src/Avalonia.Controls/Mixins/DisposableMixin.cs b/src/Avalonia.Controls/Mixins/DisposableMixin.cs new file mode 100644 index 0000000000..9b30b4ba4c --- /dev/null +++ b/src/Avalonia.Controls/Mixins/DisposableMixin.cs @@ -0,0 +1,38 @@ +using System; +using System.Reactive.Disposables; + +namespace Avalonia.Controls.Mixins +{ + /// + /// Extension methods associated with the IDisposable interface. + /// + public static class DisposableMixin + { + /// + /// Ensures the provided disposable is disposed with the specified . + /// + /// + /// The type of the disposable. + /// + /// + /// The disposable we are going to want to be disposed by the CompositeDisposable. + /// + /// + /// The to which will be added. + /// + /// + /// The disposable. + /// + public static T DisposeWith(this T item, CompositeDisposable compositeDisposable) + where T : IDisposable + { + if (compositeDisposable is null) + { + throw new ArgumentNullException(nameof(compositeDisposable)); + } + + compositeDisposable.Add(item); + return item; + } + } +} diff --git a/src/Avalonia.Controls/Primitives/Popup.cs b/src/Avalonia.Controls/Primitives/Popup.cs index d5fb69a672..e804c4b4a9 100644 --- a/src/Avalonia.Controls/Primitives/Popup.cs +++ b/src/Avalonia.Controls/Primitives/Popup.cs @@ -2,6 +2,7 @@ using System; using System.ComponentModel; using System.Linq; using System.Reactive.Disposables; +using Avalonia.Controls.Mixins; using Avalonia.Controls.Diagnostics; using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives.PopupPositioning; @@ -393,18 +394,8 @@ namespace Avalonia.Controls.Primitives var handlerCleanup = new CompositeDisposable(5); - void DeferCleanup(IDisposable? disposable) - { - if (disposable is null) - { - return; - } - - handlerCleanup.Add(disposable); - } - - DeferCleanup(popupHost.BindConstraints(this, WidthProperty, MinWidthProperty, MaxWidthProperty, - HeightProperty, MinHeightProperty, MaxHeightProperty, TopmostProperty)); + popupHost.BindConstraints(this, WidthProperty, MinWidthProperty, MaxWidthProperty, + HeightProperty, MinHeightProperty, MaxHeightProperty, TopmostProperty).DisposeWith(handlerCleanup); popupHost.SetChild(Child); ((ISetLogicalParent)popupHost).SetParent(this); @@ -418,19 +409,19 @@ namespace Avalonia.Controls.Primitives PlacementConstraintAdjustment, PlacementRect); - DeferCleanup(SubscribeToEventHandler>(popupHost, RootTemplateApplied, + SubscribeToEventHandler>(popupHost, RootTemplateApplied, (x, handler) => x.TemplateApplied += handler, - (x, handler) => x.TemplateApplied -= handler)); + (x, handler) => x.TemplateApplied -= handler).DisposeWith(handlerCleanup); if (topLevel is Window window) { - DeferCleanup(SubscribeToEventHandler(window, WindowDeactivated, + SubscribeToEventHandler(window, WindowDeactivated, (x, handler) => x.Deactivated += handler, - (x, handler) => x.Deactivated -= handler)); + (x, handler) => x.Deactivated -= handler).DisposeWith(handlerCleanup); - DeferCleanup(SubscribeToEventHandler(window.PlatformImpl, WindowLostFocus, + SubscribeToEventHandler(window.PlatformImpl, WindowLostFocus, (x, handler) => x.LostFocus += handler, - (x, handler) => x.LostFocus -= handler)); + (x, handler) => x.LostFocus -= handler).DisposeWith(handlerCleanup); } else { @@ -438,13 +429,13 @@ namespace Avalonia.Controls.Primitives if (parentPopupRoot?.Parent is Popup popup) { - DeferCleanup(SubscribeToEventHandler>(popup, ParentClosed, + SubscribeToEventHandler>(popup, ParentClosed, (x, handler) => x.Closed += handler, - (x, handler) => x.Closed -= handler)); + (x, handler) => x.Closed -= handler).DisposeWith(handlerCleanup); } } - DeferCleanup(InputManager.Instance?.Process.Subscribe(ListenForNonClientClick)); + InputManager.Instance?.Process.Subscribe(ListenForNonClientClick).DisposeWith(handlerCleanup); var cleanupPopup = Disposable.Create((popupHost, handlerCleanup), state => { @@ -466,17 +457,17 @@ namespace Avalonia.Controls.Primitives dismissLayer.IsVisible = true; dismissLayer.InputPassThroughElement = _overlayInputPassThroughElement; - DeferCleanup(Disposable.Create(() => + Disposable.Create(() => { dismissLayer.IsVisible = false; dismissLayer.InputPassThroughElement = null; - })); + }).DisposeWith(handlerCleanup); - DeferCleanup(SubscribeToEventHandler>( + SubscribeToEventHandler>( dismissLayer, PointerPressedDismissOverlay, (x, handler) => x.PointerPressed += handler, - (x, handler) => x.PointerPressed -= handler)); + (x, handler) => x.PointerPressed -= handler).DisposeWith(handlerCleanup); } } diff --git a/src/Avalonia.Controls/SystemDialog.cs b/src/Avalonia.Controls/SystemDialog.cs index e74b950f23..d81926ecc7 100644 --- a/src/Avalonia.Controls/SystemDialog.cs +++ b/src/Avalonia.Controls/SystemDialog.cs @@ -4,30 +4,65 @@ using System.Linq; using System.Threading.Tasks; using Avalonia.Controls.Platform; +#nullable enable + namespace Avalonia.Controls { + /// + /// Base class for system file dialogs. + /// public abstract class FileDialog : FileSystemDialog { + /// + /// Gets or sets a collection of filters which determine the types of files displayed in an + /// or an . + /// public List Filters { get; set; } = new List(); - public string InitialFileName { get; set; } + + /// + /// Gets or sets initial file name that is displayed when the dialog is opened. + /// + public string? InitialFileName { get; set; } } + /// + /// Base class for system file and directory dialogs. + /// public abstract class FileSystemDialog : SystemDialog { [Obsolete("Use Directory")] - public string InitialDirectory + public string? InitialDirectory { get => Directory; set => Directory = value; } - public string Directory { get; set; } + + /// + /// Gets or sets the initial directory that will be displayed when the file system dialog + /// is opened. + /// + public string? Directory { get; set; } } + /// + /// Represents a system dialog that prompts the user to select a location for saving a file. + /// public class SaveFileDialog : FileDialog { - public string DefaultExtension { get; set; } + /// + /// Gets or sets the default extension to be used to save the file (including the period "."). + /// + public string? DefaultExtension { get; set; } - public async Task ShowAsync(Window parent) + /// + /// Shows the save file dialog. + /// + /// The parent window. + /// + /// A task that on completion contains the full path of the save location, or null if the + /// dialog was canceled. + /// + public async Task ShowAsync(Window parent) { if(parent == null) throw new ArgumentNullException(nameof(parent)); @@ -37,11 +72,25 @@ namespace Avalonia.Controls } } + /// + /// Represents a system dialog that allows the user to select one or more files to open. + /// public class OpenFileDialog : FileDialog { + /// + /// Gets or sets a value indicating whether the user can select multiple files. + /// public bool AllowMultiple { get; set; } - public Task ShowAsync(Window parent) + /// + /// Shows the open file dialog. + /// + /// The parent window. + /// + /// A task that on completion returns an array containing the full path to the selected + /// files, or null if the dialog was canceled. + /// + public Task ShowAsync(Window parent) { if(parent == null) throw new ArgumentNullException(nameof(parent)); @@ -49,15 +98,27 @@ namespace Avalonia.Controls } } + /// + /// Represents a system dialog that allows the user to select a directory. + /// public class OpenFolderDialog : FileSystemDialog { [Obsolete("Use Directory")] - public string DefaultDirectory + public string? DefaultDirectory { get => Directory; set => Directory = value; } - public Task ShowAsync(Window parent) + + /// + /// Shows the open folder dialog. + /// + /// The parent window. + /// + /// A task that on completion returns the full path of the selected directory, or null if the + /// dialog was canceled. + /// + public Task ShowAsync(Window parent) { if(parent == null) throw new ArgumentNullException(nameof(parent)); @@ -65,14 +126,32 @@ namespace Avalonia.Controls } } + + /// + /// Base class for system dialogs. + /// public abstract class SystemDialog { - public string Title { get; set; } + /// + /// Gets or sets the dialog title. + /// + public string? Title { get; set; } } + /// + /// Represents a filter in an or an . + /// public class FileDialogFilter { - public string Name { get; set; } + /// + /// Gets or sets the name of the filter, e.g. ("Text files (.txt)"). + /// + public string? Name { get; set; } + + /// + /// Gets or sets a list of file extensions matched by the filter (e.g. "txt" or "*" for all + /// files). + /// public List Extensions { get; set; } = new List(); } } diff --git a/src/Avalonia.Input/Navigation/TabNavigation.cs b/src/Avalonia.Input/Navigation/TabNavigation.cs index ed7df67bf2..c8290cb3b7 100644 --- a/src/Avalonia.Input/Navigation/TabNavigation.cs +++ b/src/Avalonia.Input/Navigation/TabNavigation.cs @@ -234,7 +234,7 @@ namespace Avalonia.Input.Navigation // Return the first visible element. var uiElement = e as InputElement; - if (uiElement is null || uiElement.IsVisible) + if (uiElement is null || IsVisibleAndEnabled(uiElement)) { if (e is IVisual elementAsVisual) { @@ -245,7 +245,7 @@ namespace Avalonia.Input.Navigation { if (children[i] is InputElement ie) { - if (ie.IsVisible) + if (IsVisibleAndEnabled(ie)) return ie; else { @@ -270,7 +270,7 @@ namespace Avalonia.Input.Navigation // Return the last visible element. var uiElement = e as InputElement; - if (uiElement == null || uiElement.IsVisible) + if (uiElement == null || IsVisibleAndEnabled(uiElement)) { var elementAsVisual = e as IVisual; @@ -283,7 +283,7 @@ namespace Avalonia.Input.Navigation { if (children[i] is InputElement ie) { - if (ie.IsVisible) + if (IsVisibleAndEnabled(ie)) return ie; else { @@ -600,7 +600,7 @@ namespace Avalonia.Input.Navigation var vchild = children[i]; if (vchild == elementAsVisual) break; - if (vchild.IsVisible == true && vchild is IInputElement ie) + if (vchild is IInputElement ie && IsVisibleAndEnabled(ie)) prev = ie; } return prev; @@ -668,5 +668,6 @@ namespace Avalonia.Input.Navigation } private static bool IsTabStopOrGroup(IInputElement e) => IsTabStop(e) || IsGroup(e); + private static bool IsVisibleAndEnabled(IInputElement e) => e.IsVisible && e.IsEnabled; } } diff --git a/src/Windows/Avalonia.Direct2D1/Avalonia.Direct2D1.csproj b/src/Windows/Avalonia.Direct2D1/Avalonia.Direct2D1.csproj index cda95d2ebb..8533e1e176 100644 --- a/src/Windows/Avalonia.Direct2D1/Avalonia.Direct2D1.csproj +++ b/src/Windows/Avalonia.Direct2D1/Avalonia.Direct2D1.csproj @@ -12,6 +12,7 @@ + diff --git a/src/Windows/Avalonia.Direct2D1/HwndRenderTarget.cs b/src/Windows/Avalonia.Direct2D1/HwndRenderTarget.cs index 589f85f208..0597e88ed6 100644 --- a/src/Windows/Avalonia.Direct2D1/HwndRenderTarget.cs +++ b/src/Windows/Avalonia.Direct2D1/HwndRenderTarget.cs @@ -1,4 +1,5 @@ using Avalonia.Platform; +using Avalonia.Win32; using Avalonia.Win32.Interop; using SharpDX; using SharpDX.DXGI; @@ -21,7 +22,7 @@ namespace Avalonia.Direct2D1 protected override Size2F GetWindowDpi() { - if (UnmanagedMethods.ShCoreAvailable) + if (UnmanagedMethods.ShCoreAvailable && Win32Platform.WindowsVersion > PlatformConstants.Windows8) { uint dpix, dpiy; diff --git a/src/Windows/Avalonia.Win32.Interop/Wpf/Direct2DImageSurface.cs b/src/Windows/Avalonia.Win32.Interop/Wpf/Direct2DImageSurface.cs index 5b04c5d7ff..bc0a399d7f 100644 --- a/src/Windows/Avalonia.Win32.Interop/Wpf/Direct2DImageSurface.cs +++ b/src/Windows/Avalonia.Win32.Interop/Wpf/Direct2DImageSurface.cs @@ -150,6 +150,7 @@ namespace Avalonia.Win32.Interop.Wpf if (_image == null || _oldDpi.X != dpi.X || _oldDpi.Y != dpi.Y) { _image = new D3DImage(dpi.X, dpi.Y); + _oldDpi = dpi; } _impl.ImageSource = _image; diff --git a/src/Windows/Avalonia.Win32/FramebufferManager.cs b/src/Windows/Avalonia.Win32/FramebufferManager.cs index 699dc7c25d..0240ac9701 100644 --- a/src/Windows/Avalonia.Win32/FramebufferManager.cs +++ b/src/Windows/Avalonia.Win32/FramebufferManager.cs @@ -87,7 +87,7 @@ namespace Avalonia.Win32 private Vector GetCurrentDpi() { - if (UnmanagedMethods.ShCoreAvailable) + if (UnmanagedMethods.ShCoreAvailable && Win32Platform.WindowsVersion > PlatformConstants.Windows8) { var monitor = UnmanagedMethods.MonitorFromWindow(_hwnd, UnmanagedMethods.MONITOR.MONITOR_DEFAULTTONEAREST); diff --git a/src/Windows/Avalonia.Win32/PlatformConstants.cs b/src/Windows/Avalonia.Win32/PlatformConstants.cs index af1eca29be..9dd4780637 100644 --- a/src/Windows/Avalonia.Win32/PlatformConstants.cs +++ b/src/Windows/Avalonia.Win32/PlatformConstants.cs @@ -1,8 +1,13 @@ +using System; + namespace Avalonia.Win32 { - static class PlatformConstants + public static class PlatformConstants { public const string WindowHandleType = "HWND"; public const string CursorHandleType = "HCURSOR"; + + public static readonly Version Windows8 = new Version(6, 2); + public static readonly Version Windows7 = new Version(6, 1); } } diff --git a/src/Windows/Avalonia.Win32/SystemDialogImpl.cs b/src/Windows/Avalonia.Win32/SystemDialogImpl.cs index ad81cc1778..f595a58c91 100644 --- a/src/Windows/Avalonia.Win32/SystemDialogImpl.cs +++ b/src/Windows/Avalonia.Win32/SystemDialogImpl.cs @@ -19,7 +19,7 @@ namespace Avalonia.Win32 var hWnd = parent?.PlatformImpl?.Handle?.Handle ?? IntPtr.Zero; return Task.Factory.StartNew(() => { - var result = Array.Empty(); + string[] result = default; Guid clsid = dialog is OpenFileDialog ? UnmanagedMethods.ShellIds.OpenFileDialog : UnmanagedMethods.ShellIds.SaveFileDialog; Guid iid = UnmanagedMethods.ShellIds.IFileDialog; @@ -100,7 +100,7 @@ namespace Avalonia.Win32 { return Task.Factory.StartNew(() => { - string result = string.Empty; + string result = default; var hWnd = parent?.PlatformImpl?.Handle?.Handle ?? IntPtr.Zero; Guid clsid = UnmanagedMethods.ShellIds.OpenFileDialog; @@ -164,7 +164,7 @@ namespace Avalonia.Win32 } } } - return ""; + return default; } } } diff --git a/src/Windows/Avalonia.Win32/Win32GlManager.cs b/src/Windows/Avalonia.Win32/Win32GlManager.cs index e159ba3b78..e70ea52106 100644 --- a/src/Windows/Avalonia.Win32/Win32GlManager.cs +++ b/src/Windows/Avalonia.Win32/Win32GlManager.cs @@ -1,4 +1,3 @@ -using System; using Avalonia.OpenGL; using Avalonia.OpenGL.Angle; using Avalonia.OpenGL.Egl; @@ -9,7 +8,6 @@ namespace Avalonia.Win32 { static class Win32GlManager { - private static readonly Version Windows7 = new Version(6, 1); public static void Initialize() { @@ -22,7 +20,7 @@ namespace Avalonia.Win32 return wgl; } - if (opts?.AllowEglInitialization ?? Win32Platform.WindowsVersion > Windows7) + if (opts?.AllowEglInitialization ?? Win32Platform.WindowsVersion > PlatformConstants.Windows7) { var egl = EglPlatformOpenGlInterface.TryCreate(() => new AngleWin32EglDisplay()); diff --git a/src/Windows/Avalonia.Win32/Win32Platform.cs b/src/Windows/Avalonia.Win32/Win32Platform.cs index a881c45cd0..c011a458c3 100644 --- a/src/Windows/Avalonia.Win32/Win32Platform.cs +++ b/src/Windows/Avalonia.Win32/Win32Platform.cs @@ -94,7 +94,7 @@ namespace Avalonia namespace Avalonia.Win32 { - class Win32Platform : IPlatformThreadingInterface, IPlatformSettings, IWindowingPlatform, IPlatformIconLoader, IPlatformLifetimeEventsImpl + public class Win32Platform : IPlatformThreadingInterface, IPlatformSettings, IWindowingPlatform, IPlatformIconLoader, IPlatformLifetimeEventsImpl { private static readonly Win32Platform s_instance = new Win32Platform(); private static Thread _uiThread; diff --git a/src/Windows/Avalonia.Win32/WindowImpl.cs b/src/Windows/Avalonia.Win32/WindowImpl.cs index 0b7bd13082..3b7d3efa2f 100644 --- a/src/Windows/Avalonia.Win32/WindowImpl.cs +++ b/src/Windows/Avalonia.Win32/WindowImpl.cs @@ -764,8 +764,8 @@ namespace Avalonia.Win32 RegisterTouchWindow(_hwnd, 0); } - if (ShCoreAvailable) - { + if (ShCoreAvailable && Win32Platform.WindowsVersion > PlatformConstants.Windows8) + { var monitor = MonitorFromWindow( _hwnd, MONITOR.MONITOR_DEFAULTTONEAREST); diff --git a/tests/Avalonia.Controls.UnitTests/Utils/HotKeyManagerTests.cs b/tests/Avalonia.Controls.UnitTests/Utils/HotKeyManagerTests.cs index 2a3db9992c..b0eb694944 100644 --- a/tests/Avalonia.Controls.UnitTests/Utils/HotKeyManagerTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Utils/HotKeyManagerTests.cs @@ -10,7 +10,6 @@ using Avalonia.Styling; using Avalonia.UnitTests; using Moq; using Xunit; -using System; using Avalonia.Input.Raw; using Factory = System.Func, Avalonia.Controls.Window, Avalonia.AvaloniaObject>; diff --git a/tests/Avalonia.Input.UnitTests/KeyboardDeviceTests.cs b/tests/Avalonia.Input.UnitTests/KeyboardDeviceTests.cs index 7730cee78c..c354dbe72e 100644 --- a/tests/Avalonia.Input.UnitTests/KeyboardDeviceTests.cs +++ b/tests/Avalonia.Input.UnitTests/KeyboardDeviceTests.cs @@ -2,7 +2,6 @@ using System.Windows.Input; using Avalonia.Controls; using Avalonia.Input.Raw; -using Avalonia.Interactivity; using Avalonia.UnitTests; using Moq; using Xunit; @@ -126,7 +125,7 @@ namespace Avalonia.Input.UnitTests { private readonly Action _action; public DelegateCommand(Action action) => _action = action; - public event EventHandler CanExecuteChanged; + public event EventHandler CanExecuteChanged { add { } remove { } } public bool CanExecute(object parameter) => true; public void Execute(object parameter) => _action(); } diff --git a/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Tab.cs b/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Tab.cs index edcbf75a1d..9a117bb71d 100644 --- a/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Tab.cs +++ b/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Tab.cs @@ -1225,5 +1225,32 @@ namespace Avalonia.Input.UnitTests "Button2", "Button3", "Button5", "Button1", "Button6", "Button4" }, result); } + + [Fact] + public void Cannot_Focus_Child_Of_Disabled_Control() + { + Button start; + Button expected; + + var top = new StackPanel + { + [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle, + Children = + { + (start = new Button { Name = "Button1" }), + new Border + { + IsEnabled = false, + Child = new Button { Name = "Button2" }, + }, + (expected = new Button { Name = "Button3" }), + } + }; + + var current = (IInputElement)start; + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Next); + + Assert.Same(expected, result); + } } } diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/MarkupExtensions/DynamicResourceExtensionTests.cs b/tests/Avalonia.Markup.Xaml.UnitTests/MarkupExtensions/DynamicResourceExtensionTests.cs index e7f0230254..592dbfc0d1 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/MarkupExtensions/DynamicResourceExtensionTests.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/MarkupExtensions/DynamicResourceExtensionTests.cs @@ -923,7 +923,7 @@ namespace Avalonia.Markup.Xaml.UnitTests.MarkupExtensions public bool HasResources => true; public List RequestedResources { get; } = new List(); - public event EventHandler OwnerChanged; + public event EventHandler OwnerChanged { add { } remove { } } public void AddOwner(IResourceHost owner) => Owner = owner; public void RemoveOwner(IResourceHost owner) => Owner = null;