diff --git a/samples/DockReactiveUICanonicalSample/App.axaml.cs b/samples/DockReactiveUICanonicalSample/App.axaml.cs index 1d02581c3..71672fc74 100644 --- a/samples/DockReactiveUICanonicalSample/App.axaml.cs +++ b/samples/DockReactiveUICanonicalSample/App.axaml.cs @@ -39,7 +39,10 @@ private void RegisterDockableTemplate() { if (existing is ReactiveUI.Avalonia.ViewModelViewHost existingHost) { - existingHost.ViewModel = item; + if (!ReferenceEquals(existingHost.ViewModel, item)) + { + existingHost.ViewModel = item; + } return existingHost; } diff --git a/src/Dock.Avalonia/Controls/DockControl.axaml.cs b/src/Dock.Avalonia/Controls/DockControl.axaml.cs index 76a230b37..b6c06f265 100644 --- a/src/Dock.Avalonia/Controls/DockControl.axaml.cs +++ b/src/Dock.Avalonia/Controls/DockControl.axaml.cs @@ -3,9 +3,11 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Runtime.CompilerServices; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Recycling; +using Avalonia.Controls.Recycling.Model; using Avalonia.Controls.Metadata; using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; @@ -36,6 +38,7 @@ namespace Dock.Avalonia.Controls; [TemplatePart("PART_ManagedWindowLayer", typeof(ManagedWindowLayer))] public class DockControl : TemplatedControl, IDockControl, IDockSelectorService { + private static readonly ConditionalWeakTable s_controlRecycling = new(); private readonly DockManagerOptions _dockManagerOptions; private readonly DockManager _dockManager; private readonly DockControlState _dockControlState; @@ -269,16 +272,53 @@ protected override void OnApplyTemplate(TemplateAppliedEventArgs e) private void InitializeControlRecycling() { - var recycling = ControlRecyclingDataTemplate.GetControlRecycling(this); - if (recycling is ControlRecycling shared) + if (Layout?.Factory is not { } factory) { - var local = new ControlRecycling + return; + } + + var controlRecycling = ControlRecyclingDataTemplate.GetControlRecycling(this); + if (controlRecycling is null) + { + return; + } + + if (s_controlRecycling.TryGetValue(factory, out var shared)) + { + if (ReferenceEquals(shared, controlRecycling)) { - TryToUseIdAsKey = shared.TryToUseIdAsKey - }; + return; + } + + if (shared is ControlRecycling sharedRecycling && controlRecycling is ControlRecycling localRecycling) + { + if (sharedRecycling.TryToUseIdAsKey != localRecycling.TryToUseIdAsKey) + { + sharedRecycling.TryToUseIdAsKey = localRecycling.TryToUseIdAsKey; + } - ControlRecyclingDataTemplate.SetControlRecycling(this, local); + ControlRecyclingDataTemplate.SetControlRecycling(this, sharedRecycling); + return; + } + + if (controlRecycling is ControlRecycling) + { + ControlRecyclingDataTemplate.SetControlRecycling(this, shared); + } + + return; } + + if (controlRecycling is ControlRecycling defaultRecycling) + { + controlRecycling = new ControlRecycling + { + TryToUseIdAsKey = defaultRecycling.TryToUseIdAsKey + }; + ControlRecyclingDataTemplate.SetControlRecycling(this, controlRecycling); + } + + s_controlRecycling.Add(factory, controlRecycling); } private void InitializeDefaultDataTemplates() @@ -346,6 +386,7 @@ private void Initialize(IDock? layout) layout.Factory.DockControls.Add(this); + InitializeControlRecycling(); UpdateManagedWindowLayer(layout); if (InitializeFactory) diff --git a/src/Dock.Avalonia/Controls/ManagedDockWindowDocument.cs b/src/Dock.Avalonia/Controls/ManagedDockWindowDocument.cs index eb15990c5..f67bea0a0 100644 --- a/src/Dock.Avalonia/Controls/ManagedDockWindowDocument.cs +++ b/src/Dock.Avalonia/Controls/ManagedDockWindowDocument.cs @@ -2,10 +2,13 @@ // Licensed under the MIT license. See LICENSE file in the project root for details. using System; using System.ComponentModel; +using Avalonia; using Avalonia.Controls; +using Avalonia.Controls.Presenters; using Avalonia.Controls.Templates; using Avalonia.Markup.Xaml.Templates; using Avalonia.Media; +using Avalonia.VisualTree; using Dock.Avalonia.Internal; using Dock.Model.Controls; using Dock.Model.Core; @@ -130,7 +133,7 @@ public bool Match(object? data) public Control? Build(object? data, Control? existing) { - return BuildContent(Content, this); + return BuildContent(Content, this, existing); } public void Dispose() @@ -316,7 +319,7 @@ private void SyncBounds() } } - private static Control? BuildContent(object? content, IDockable dockable) + private static Control? BuildContent(object? content, IDockable dockable, Control? existing) { if (DragPreviewContext.IsPreviewing(dockable)) { @@ -330,15 +333,85 @@ private void SyncBounds() if (content is Control directControl) { - return directControl; + return DetachOrFallback(directControl, existing, null); } if (content is Func direct) { - return direct(null!) as Control; + return DetachOrFallback(direct(null!) as Control, existing, () => direct(null!) as Control); } - return TemplateContent.Load(content)?.Result; + return DetachOrFallback(TemplateContent.Load(content)?.Result, existing, () => TemplateContent.Load(content)?.Result); + } + + private static Control? DetachOrFallback(Control? control, Control? existing, Func? fallbackFactory) + { + if (control is null || ReferenceEquals(control, existing)) + { + return control; + } + + if (TryDetachFromParent(control)) + { + return control; + } + + if (fallbackFactory is null) + { + return existing; + } + + var fallback = fallbackFactory(); + if (fallback is null) + { + return existing; + } + + if (ReferenceEquals(fallback, existing)) + { + return fallback; + } + + return TryDetachFromParent(fallback) ? fallback : existing; + } + + private static bool TryDetachFromParent(Control control) + { + var parent = control.Parent ?? control.GetVisualParent(); + + if (parent is null) + { + return true; + } + + switch (parent) + { + case Panel panel: + return panel.Children.Remove(control); + case ContentPresenter presenter: + return TryDetachFromContentPresenter(presenter, control); + case ContentControl contentControl when ReferenceEquals(contentControl.Content, control): + contentControl.SetCurrentValue(ContentControl.ContentProperty, null); + return true; + case Decorator decorator when ReferenceEquals(decorator.Child, control): + decorator.Child = null; + return true; + default: + return false; + } + } + + private static bool TryDetachFromContentPresenter(ContentPresenter presenter, Control control) + { + if (!ReferenceEquals(presenter.Child, control)) + { + return false; + } + + presenter.SetCurrentValue(ContentPresenter.ContentProperty, null); + presenter.UpdateChild(); + + return control.GetVisualParent() is null; } private static Control BuildPreviewContent(object? content) diff --git a/src/Dock.Avalonia/Controls/Overlays/OverlayHost.cs b/src/Dock.Avalonia/Controls/Overlays/OverlayHost.cs index fdebb7276..3c20a2895 100644 --- a/src/Dock.Avalonia/Controls/Overlays/OverlayHost.cs +++ b/src/Dock.Avalonia/Controls/Overlays/OverlayHost.cs @@ -9,6 +9,7 @@ using Avalonia.Controls.Templates; using Avalonia.Metadata; using Avalonia.Styling; +using Avalonia.VisualTree; namespace Dock.Avalonia.Controls.Overlays; @@ -250,9 +251,10 @@ private void OnOverlayLayerPropertyChanged(object? sender, AvaloniaPropertyChang private void RebuildPipeline() { - if (Content is Control hostedContent && hostedContent.Parent is not null) + if (Content is Control hostedContent) { - if (!TryDetachControl(hostedContent)) + var parent = hostedContent.Parent ?? hostedContent.GetVisualParent(); + if (parent is not null && !TryDetachControl(hostedContent)) { return; } @@ -325,12 +327,13 @@ private void RebuildPipeline() private static bool TryDetachControl(Control control) { - if (control.Parent is null) + var parent = control.Parent ?? control.GetVisualParent(); + if (parent is null) { return true; } - switch (control.Parent) + switch (parent) { case Panel panel: panel.Children.Remove(control); @@ -338,9 +341,22 @@ private static bool TryDetachControl(Control control) case Decorator decorator when ReferenceEquals(decorator.Child, control): decorator.Child = null; return true; - case ContentPresenter presenter when ReferenceEquals(presenter.Content, control): - presenter.Content = null; - return true; + case ContentPresenter presenter: + if (ReferenceEquals(presenter.Child, control)) + { + presenter.SetCurrentValue(ContentPresenter.ContentProperty, null); + presenter.UpdateChild(); + return control.GetVisualParent() is null; + } + + if (ReferenceEquals(presenter.Content, control)) + { + presenter.SetCurrentValue(ContentPresenter.ContentProperty, null); + presenter.UpdateChild(); + return true; + } + + return false; case ContentControl contentControl when ReferenceEquals(contentControl.Content, control): contentControl.Content = null; return true; diff --git a/src/Dock.Controls.Recycling/ControlRecycling.cs b/src/Dock.Controls.Recycling/ControlRecycling.cs index 34effd872..5cb698f44 100644 --- a/src/Dock.Controls.Recycling/ControlRecycling.cs +++ b/src/Dock.Controls.Recycling/ControlRecycling.cs @@ -82,25 +82,80 @@ public void Add(object data, object control) } } + var parentControl = parent as Control; + if (TryGetValue(key, out var control)) { - // If the cached control is currently in the visual tree, remove it from its parent - if (control is Visual visual && !ReferenceEquals(existing, control)) + if (control is Control cachedControl) { - RemoveFromVisualParent(visual); + var updatedControl = cachedControl; + + if (parentControl is not null) + { + var template = parentControl.FindDataTemplate(data); + if (template is IRecyclingDataTemplate recyclingTemplate) + { + var recycled = recyclingTemplate.Build(data, cachedControl); + if (recycled is not null) + { + updatedControl = recycled; + } + } + } + + if (!ReferenceEquals(updatedControl, cachedControl)) + { + Add(key!, updatedControl); + } + + if (!ReferenceEquals(existing, updatedControl)) + { + if (!TryDetachFromParent(updatedControl)) + { + var fallback = BuildFallback(parentControl, data, existing); + if (fallback is not null) + { + Add(key!, fallback); + } + + return fallback; + } + } + + return updatedControl; } return control; } - var dataTemplate = (parent as Control)?.FindDataTemplate(data); - - control = dataTemplate?.Build(data); + var dataTemplate = parentControl?.FindDataTemplate(data); + if (dataTemplate is IRecyclingDataTemplate recyclingDataTemplate) + { + control = recyclingDataTemplate.Build(data, null); + } + else + { + control = dataTemplate?.Build(data); + } if (control is null) { return null; } + if (control is Control createdControl && !ReferenceEquals(existing, createdControl)) + { + if (!TryDetachFromParent(createdControl)) + { + var fallback = BuildFallback(parentControl, data, existing); + if (fallback is not null) + { + Add(key!, fallback); + } + + return fallback; + } + } + Add(key!, control); return control; @@ -118,24 +173,103 @@ public void Clear() /// Removes a visual control from its current parent in the visual tree. /// /// The visual to remove from its parent. - private static void RemoveFromVisualParent(Visual visual) + private static bool TryDetachFromParent(Visual visual) { - var parent = visual.GetVisualParent(); + var parent = (visual as Control)?.Parent ?? visual.GetVisualParent(); + + if (parent is null) + { + return true; + } switch (parent) { - case Panel panel when visual is Control control: - panel.Children.Remove(control); - break; + case Panel panel when visual is Control child: + return panel.Children.Remove(child); case ContentPresenter contentPresenter: - contentPresenter.Content = null; - break; - case ContentControl contentControl: - contentControl.Content = null; - break; - case Decorator decorator: + return TryDetachFromContentPresenter(contentPresenter, visual); + case ContentControl contentControl when ReferenceEquals(contentControl.Content, visual): + contentControl.SetCurrentValue(ContentControl.ContentProperty, null); + return true; + case Decorator decorator when ReferenceEquals(decorator.Child, visual): decorator.Child = null; - break; + return true; + default: + return false; + } + } + + private static bool TryDetachFromContentPresenter(ContentPresenter presenter, Visual visual) + { + if (!ReferenceEquals(presenter.Child, visual)) + { + return false; + } + + presenter.SetCurrentValue(ContentPresenter.ContentProperty, null); + presenter.UpdateChild(); + + return visual.GetVisualParent() is null; + } + + private static object? BuildFallback(Control? parentControl, object? data, object? existing) + { + if (parentControl is null) + { + return null; } + + var dataTemplate = parentControl.FindDataTemplate(data); + if (dataTemplate is IRecyclingDataTemplate recyclingDataTemplate) + { + var existingControl = existing as Control; + var control = recyclingDataTemplate.Build(data, existingControl); + if (control is null) + { + control = recyclingDataTemplate.Build(data, null); + } + + if (control is Control fallbackControl) + { + if (ReferenceEquals(fallbackControl, existingControl)) + { + return fallbackControl; + } + + if (TryDetachFromParent(fallbackControl)) + { + return fallbackControl; + } + + var rebuilt = recyclingDataTemplate.Build(data, null); + if (rebuilt is Control rebuiltControl && TryDetachFromParent(rebuiltControl)) + { + return rebuiltControl; + } + + return null; + } + + return control; + } + + var built = dataTemplate?.Build(data); + if (built is Control builtControl) + { + if (TryDetachFromParent(builtControl)) + { + return builtControl; + } + + var rebuilt = dataTemplate?.Build(data); + if (rebuilt is Control rebuiltControl && TryDetachFromParent(rebuiltControl)) + { + return rebuiltControl; + } + + return null; + } + + return built; } } diff --git a/src/Dock.Controls.Recycling/RecylingDataTemplate.cs b/src/Dock.Controls.Recycling/RecylingDataTemplate.cs index ffb0504dd..05df0596b 100644 --- a/src/Dock.Controls.Recycling/RecylingDataTemplate.cs +++ b/src/Dock.Controls.Recycling/RecylingDataTemplate.cs @@ -1,8 +1,10 @@ // Copyright (c) Wiesław Šoltés. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for details. +using Avalonia.Controls.Presenters; using Avalonia.Controls.Recycling.Model; using Avalonia.Controls.Templates; using Avalonia.Data; +using Avalonia.VisualTree; namespace Avalonia.Controls.Recycling; @@ -63,7 +65,7 @@ public static void SetControlRecycling(AvaloniaObject control, IControlRecycling /// public Control? Build(object? param) { - return null; + return Build(param, null); } /// @@ -86,12 +88,17 @@ public bool Match(object? data) { if (data is IRecyclingDataTemplate recyclingTemplate && !ReferenceEquals(recyclingTemplate, this)) { - return recyclingTemplate.Build(data, existing); + return BuildFromRecyclingTemplate(recyclingTemplate, data, existing); } if (data is Control control) { - return control; + if (ReferenceEquals(control, existing)) + { + return control; + } + + return TryDetachFromParent(control) ? control : null; } if (Parent is not { } parent) @@ -105,6 +112,115 @@ public bool Match(object? data) return controlRecycling.Build(data, existing, parent) as Control; } - return parent.FindDataTemplate(data)?.Build(data); + var dataTemplate = parent.FindDataTemplate(data); + if (dataTemplate is IRecyclingDataTemplate recyclingDataTemplate) + { + return BuildFromRecyclingTemplate(recyclingDataTemplate, data, existing); + } + + return BuildFromDataTemplate(dataTemplate, data, existing); + } + + private static Control? BuildFromRecyclingTemplate(IRecyclingDataTemplate template, object? data, Control? existing) + { + var control = template.Build(data, existing); + if (control is null) + { + return null; + } + + if (ReferenceEquals(control, existing)) + { + return control; + } + + if (TryDetachFromParent(control)) + { + return control; + } + + var rebuilt = template.Build(data, null); + if (rebuilt is null) + { + return null; + } + + if (ReferenceEquals(rebuilt, existing)) + { + return rebuilt; + } + + return TryDetachFromParent(rebuilt) ? rebuilt : null; + } + + private static Control? BuildFromDataTemplate(IDataTemplate? template, object? data, Control? existing) + { + var control = template?.Build(data) as Control; + if (control is null) + { + return null; + } + + if (ReferenceEquals(control, existing)) + { + return control; + } + + if (TryDetachFromParent(control)) + { + return control; + } + + var rebuilt = template?.Build(data) as Control; + if (rebuilt is null) + { + return null; + } + + if (ReferenceEquals(rebuilt, existing)) + { + return rebuilt; + } + + return TryDetachFromParent(rebuilt) ? rebuilt : null; + } + + private static bool TryDetachFromParent(Control control) + { + var parent = control.Parent ?? control.GetVisualParent(); + + if (parent is null) + { + return true; + } + + switch (parent) + { + case Panel panel: + return panel.Children.Remove(control); + case ContentPresenter presenter: + return TryDetachFromContentPresenter(presenter, control); + case ContentControl contentControl when ReferenceEquals(contentControl.Content, control): + contentControl.SetCurrentValue(ContentControl.ContentProperty, null); + return true; + case Decorator decorator when ReferenceEquals(decorator.Child, control): + decorator.Child = null; + return true; + default: + return false; + } + } + + private static bool TryDetachFromContentPresenter(ContentPresenter presenter, Control control) + { + if (!ReferenceEquals(presenter.Child, control)) + { + return false; + } + + presenter.SetCurrentValue(ContentPresenter.ContentProperty, null); + presenter.UpdateChild(); + + return control.GetVisualParent() is null; } } diff --git a/src/Dock.Model.Avalonia/Controls/TemplateHelper.cs b/src/Dock.Model.Avalonia/Controls/TemplateHelper.cs index 5d0b7bbaa..588d1ec68 100644 --- a/src/Dock.Model.Avalonia/Controls/TemplateHelper.cs +++ b/src/Dock.Model.Avalonia/Controls/TemplateHelper.cs @@ -23,6 +23,13 @@ internal static class TemplateHelper if (content is Control directControl) { + if (!ReferenceEquals(existing, directControl)) + { + if (!TryDetachFromParent(directControl)) + { + return existing; + } + } return directControl; } @@ -36,7 +43,16 @@ internal static class TemplateHelper { if (!ReferenceEquals(existing, cachedControl)) { - RemoveFromVisualParent(cachedControl); + if (!TryDetachFromParent(cachedControl)) + { + var fallback = BuildFallback(content, existing); + if (fallback is not null) + { + controlRecycling.Add(key, fallback); + } + + return fallback; + } } return cachedControl; @@ -48,6 +64,20 @@ internal static class TemplateHelper control = TemplateContent.Load(content)?.Result; if (control is not null) { + if (control is Control builtControl && !ReferenceEquals(existing, builtControl)) + { + if (!TryDetachFromParent(builtControl)) + { + var fallback = BuildFallback(content, existing); + if (fallback is not null) + { + controlRecycling.Add(key, fallback); + } + + return fallback; + } + } + controlRecycling.Add(key, control); } @@ -71,25 +101,75 @@ private static object GetCacheKey(IControlRecycling controlRecycling, AvaloniaOb return content; } - private static void RemoveFromVisualParent(Visual visual) + private static bool TryDetachFromParent(Visual visual) { - var parent = visual.GetVisualParent(); + var parent = (visual as Control)?.Parent ?? visual.GetVisualParent(); + + if (parent is null) + { + return true; + } switch (parent) { - case Panel panel when visual is Control control: - panel.Children.Remove(control); - break; + case Panel panel when visual is Control child: + return panel.Children.Remove(child); case ContentPresenter contentPresenter: - contentPresenter.Content = null; - break; - case ContentControl contentControl: - contentControl.Content = null; - break; - case Decorator decorator: + return TryDetachFromContentPresenter(contentPresenter, visual); + case ContentControl contentControl when ReferenceEquals(contentControl.Content, visual): + contentControl.SetCurrentValue(ContentControl.ContentProperty, null); + return true; + case Decorator decorator when ReferenceEquals(decorator.Child, visual): decorator.Child = null; - break; + return true; + default: + return false; + } + } + + private static bool TryDetachFromContentPresenter(ContentPresenter presenter, Visual visual) + { + if (!ReferenceEquals(presenter.Child, visual)) + { + return false; + } + + presenter.SetCurrentValue(ContentPresenter.ContentProperty, null); + presenter.UpdateChild(); + + return visual.GetVisualParent() is null; + } + + private static Control? BuildFallback(object? content, Control? existing) + { + var built = TemplateContent.Load(content)?.Result; + if (built is null) + { + return existing; } + + if (ReferenceEquals(built, existing)) + { + return built; + } + + if (TryDetachFromParent(built)) + { + return built; + } + + var rebuilt = TemplateContent.Load(content)?.Result; + if (rebuilt is null) + { + return existing; + } + + if (ReferenceEquals(rebuilt, existing)) + { + return rebuilt; + } + + return TryDetachFromParent(rebuilt) ? rebuilt : existing; } internal static TemplateResult? Load(object? templateContent) diff --git a/src/Dock.Model.ReactiveUI.Services/Navigation/Services/DockNavigationService.cs b/src/Dock.Model.ReactiveUI.Services/Navigation/Services/DockNavigationService.cs index 8cbe57dac..c5cf30ffa 100644 --- a/src/Dock.Model.ReactiveUI.Services/Navigation/Services/DockNavigationService.cs +++ b/src/Dock.Model.ReactiveUI.Services/Navigation/Services/DockNavigationService.cs @@ -39,6 +39,21 @@ public void OpenDocument(IScreen hostScreen, IDockable document, bool floatWindo return; } + if (document.Owner is IDock ownerDock + && ownerDock.VisibleDockables?.Contains(document) == true) + { + factory.SetActiveDockable(document); + factory.SetFocusedDockable(ownerDock, document); + factory.ActivateWindow(document); + + if (floatWindow) + { + factory.FloatDockable(document); + } + + return; + } + factory.AddDockable(documentDock, document); factory.SetActiveDockable(document); factory.SetFocusedDockable(documentDock, document); diff --git a/tests/Dock.Avalonia.HeadlessTests/OverlayHostRebuildPipelineTests.cs b/tests/Dock.Avalonia.HeadlessTests/OverlayHostRebuildPipelineTests.cs new file mode 100644 index 000000000..38af59298 --- /dev/null +++ b/tests/Dock.Avalonia.HeadlessTests/OverlayHostRebuildPipelineTests.cs @@ -0,0 +1,51 @@ +using Avalonia.Controls; +using Avalonia.Controls.Presenters; +using Avalonia.Headless.XUnit; +using Avalonia.VisualTree; +using Dock.Avalonia.Controls.Overlays; +using Xunit; + +namespace Dock.Avalonia.HeadlessTests; + +public class OverlayHostRebuildPipelineTests +{ + [AvaloniaFact] + public void RebuildPipeline_Detaches_Content_From_VisualParent() + { + var content = new Border(); + var existingPresenter = new ContentPresenter { Content = content }; + var panel = new StackPanel + { + Children = + { + existingPresenter + } + }; + var window = new Window { Content = panel }; + + window.Show(); + window.UpdateLayout(); + + try + { + Assert.Same(existingPresenter, content.GetVisualParent()); + + if (content.Parent is not null) + { + // Simulate a visual parent without a logical parent. + ((ISetLogicalParent)content).SetParent(null); + } + + var host = new OverlayHost { Content = content }; + panel.Children.Add(host); + window.UpdateLayout(); + + Assert.NotNull(content.GetVisualParent()); + Assert.NotSame(existingPresenter, content.GetVisualParent()); + } + finally + { + window.Close(); + } + } +} diff --git a/tests/Dock.Controls.Recycling.UnitTests/Controls/ControlRecyclingDataTemplateTests.cs b/tests/Dock.Controls.Recycling.UnitTests/Controls/ControlRecyclingDataTemplateTests.cs index a22980ce3..769ce162e 100644 --- a/tests/Dock.Controls.Recycling.UnitTests/Controls/ControlRecyclingDataTemplateTests.cs +++ b/tests/Dock.Controls.Recycling.UnitTests/Controls/ControlRecyclingDataTemplateTests.cs @@ -1,4 +1,5 @@ using Avalonia.Controls; +using Avalonia.Controls.Presenters; using Avalonia.Controls.Recycling; using Avalonia.Controls.Templates; using Avalonia.Headless.XUnit; @@ -15,6 +16,30 @@ public void Build_Returns_Null_When_No_Parent() Assert.Null(template.Build(new object(), null)); } + [AvaloniaFact] + public void Build_Detaches_Control_From_ContentPresenter() + { + var control = new TextBlock(); + var presenter = new ContentPresenter { Content = control }; + var window = new Window { Content = presenter }; + var template = new ControlRecyclingDataTemplate(); + + try + { + window.Show(); + window.UpdateLayout(); + + var result = template.Build(control, null); + + Assert.Same(control, result); + Assert.Null(presenter.Content); + } + finally + { + window.Close(); + } + } + [AvaloniaFact] public void Build_Uses_Parent_ControlRecycling() { diff --git a/tests/Dock.Controls.Recycling.UnitTests/Controls/ControlRecyclingTests.cs b/tests/Dock.Controls.Recycling.UnitTests/Controls/ControlRecyclingTests.cs index 6f7d8facd..fdad7ddc5 100644 --- a/tests/Dock.Controls.Recycling.UnitTests/Controls/ControlRecyclingTests.cs +++ b/tests/Dock.Controls.Recycling.UnitTests/Controls/ControlRecyclingTests.cs @@ -1,12 +1,13 @@ +using System.ComponentModel; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Presenters; using Avalonia.Controls.Recycling; using Avalonia.Controls.Recycling.Model; using Avalonia.Controls.Templates; +using Avalonia.Data; using Avalonia.Headless.XUnit; using Avalonia.Media; -using Avalonia.VisualTree; using Xunit; namespace Dock.Controls.Recycling.UnitTests.Controls; @@ -19,6 +20,52 @@ private class IdData(string id) : AvaloniaObject, IControlRecyclingIdProvider public string? GetControlRecyclingId() => Id; } + private class RecyclingIdData(string id, string value) : AvaloniaObject, IControlRecyclingIdProvider + { + public string Id { get; } = id; + public string Value { get; } = value; + public string? GetControlRecyclingId() => Id; + } + + private class TrackingRecyclingTemplate : IRecyclingDataTemplate + { + public int BuildCalls { get; private set; } + + public Control? Build(object? data) => Build(data, null); + + public bool Match(object? data) => data is RecyclingIdData; + + public Control? Build(object? data, Control? existing) + { + BuildCalls++; + var control = existing ?? new TextBlock(); + control.Tag = data is RecyclingIdData recyclingData ? recyclingData.Value : null; + return control; + } + } + + private sealed class TestViewModel : INotifyPropertyChanged + { + private object? _item; + + public object? Item + { + get => _item; + set + { + if (ReferenceEquals(_item, value)) + { + return; + } + + _item = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Item))); + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + } + [AvaloniaFact] public void Add_And_TryGetValue_Work() { @@ -73,6 +120,50 @@ public void Build_Uses_DataTemplate_And_Caches_Result() Assert.Same(result1, result2); } + [AvaloniaFact] + public void Build_Updates_Cached_Control_When_Recycling_Template_Is_Used() + { + var recycling = new ControlRecycling { TryToUseIdAsKey = true }; + var parent = new Control(); + var template = new TrackingRecyclingTemplate(); + parent.DataTemplates.Add(template); + + var data1 = new RecyclingIdData("a", "first"); + var data2 = new RecyclingIdData("a", "second"); + + var result1 = recycling.Build(data1, null, parent) as Control; + var result2 = recycling.Build(data2, null, parent) as Control; + + Assert.Same(result1, result2); + Assert.Equal("second", result1?.Tag); + Assert.Equal(2, template.BuildCalls); + } + + [AvaloniaFact] + public void Build_Does_Not_Reuse_Control_For_Different_Recycling_Key() + { + var recycling = new ControlRecycling { TryToUseIdAsKey = true }; + var parent = new Control(); + var template = new TrackingRecyclingTemplate(); + parent.DataTemplates.Add(template); + + var data1 = new RecyclingIdData("a", "first"); + var data2 = new RecyclingIdData("b", "second"); + + var result1 = recycling.Build(data1, null, parent) as Control; + var result2 = recycling.Build(data2, result1, parent) as Control; + + Assert.NotNull(result1); + Assert.NotNull(result2); + Assert.NotSame(result1, result2); + Assert.Equal("first", result1?.Tag); + Assert.Equal("second", result2?.Tag); + Assert.True(recycling.TryGetValue("a", out var cached1)); + Assert.True(recycling.TryGetValue("b", out var cached2)); + Assert.Same(result1, cached1); + Assert.Same(result2, cached2); + } + [AvaloniaFact] public void Build_Uses_Id_When_Enabled() { @@ -126,6 +217,33 @@ public void Build_Removes_Cached_Control_From_Visual_Parent() Assert.Empty(parentPanel.Children); // Parent should no longer contain the control } + [AvaloniaFact] + public void Build_Removes_Cached_Control_From_ContentPresenter() + { + var recycling = new ControlRecycling(); + var data = new object(); + var control = new TextBlock { Background = Brushes.Red }; + var presenter = new ContentPresenter { Content = control }; + var window = new Window { Content = presenter }; + + try + { + window.Show(); + window.UpdateLayout(); + + recycling.Add(data, control); + + var result = recycling.Build(data, null, null); + + Assert.Same(control, result); + Assert.Null(presenter.Content); + } + finally + { + window.Close(); + } + } + [AvaloniaFact] public void Build_Handles_Cached_Control_Without_Visual_Parent() { @@ -142,6 +260,65 @@ public void Build_Handles_Cached_Control_Without_Visual_Parent() Assert.Same(control, result); } + [AvaloniaFact] + public void Build_Reparents_Control_From_Recycling_ContentPresenter_And_Preserves_Binding() + { + var data = new object(); + var updated = new object(); + var viewModel = new TestViewModel { Item = data }; + var recycling = new ControlRecycling(); + var template = new FuncDataTemplate((value, _) => new Border { Tag = value }, true); + + var presenterA = new ContentPresenter(); + presenterA.DataTemplates.Add(template); + presenterA.Bind(ContentPresenter.ContentProperty, new Binding(nameof(TestViewModel.Item)) { Source = viewModel }); + ControlRecyclingDataTemplate.SetControlRecycling(presenterA, recycling); + presenterA.ContentTemplate = new ControlRecyclingDataTemplate { Parent = presenterA }; + + var presenterB = new ContentPresenter(); + presenterB.DataTemplates.Add(template); + ControlRecyclingDataTemplate.SetControlRecycling(presenterB, recycling); + presenterB.ContentTemplate = new ControlRecyclingDataTemplate { Parent = presenterB }; + + var window = new Window + { + Content = new StackPanel + { + Children = + { + presenterA, + presenterB + } + } + }; + + try + { + window.Show(); + window.UpdateLayout(); + + var cached = presenterA.Child; + Assert.NotNull(cached); + + presenterB.Content = data; + window.UpdateLayout(); + + Assert.Same(cached, presenterB.Child); + Assert.Null(presenterA.Child); + Assert.NotNull(BindingOperations.GetBindingExpressionBase(presenterA, ContentPresenter.ContentProperty)); + + viewModel.Item = updated; + window.UpdateLayout(); + + Assert.Same(updated, presenterA.Content); + Assert.NotNull(presenterA.Child); + } + finally + { + window.Close(); + } + } + [AvaloniaFact] public void Build_Reuses_Cached_Control_Successfully() { diff --git a/tests/Dock.Model.Avalonia.UnitTests/Controls/TemplateHelperTests.cs b/tests/Dock.Model.Avalonia.UnitTests/Controls/TemplateHelperTests.cs new file mode 100644 index 000000000..805ffda8a --- /dev/null +++ b/tests/Dock.Model.Avalonia.UnitTests/Controls/TemplateHelperTests.cs @@ -0,0 +1,107 @@ +using Avalonia.Controls; +using Avalonia.Controls.Presenters; +using Avalonia.Headless.XUnit; +using Dock.Avalonia.Controls; +using Dock.Model.Avalonia.Controls; +using Dock.Model.Avalonia.Core; +using Xunit; + +namespace Dock.Model.Avalonia.UnitTests.Controls; + +public class TemplateHelperTests +{ + [AvaloniaFact] + public void Document_Build_Detaches_Direct_Control_From_Parent() + { + var control = new Border(); + var host = new ContentControl { Content = control }; + var document = new Document { Content = control }; + + var result = document.Build(null, null); + + Assert.Same(control, result); + Assert.Null(host.Content); + } + + [AvaloniaFact] + public void Tool_Build_Detaches_Direct_Control_From_Parent() + { + var control = new Border(); + var host = new ContentControl { Content = control }; + var tool = new Tool { Content = control }; + + var result = tool.Build(null, null); + + Assert.Same(control, result); + Assert.Null(host.Content); + } + + [AvaloniaFact] + public void Document_Build_Detaches_Direct_Control_From_ContentPresenter() + { + var control = new Border(); + var presenter = new ContentPresenter { Content = control }; + var window = new Window { Content = presenter }; + var document = new Document { Content = control }; + + try + { + window.Show(); + window.UpdateLayout(); + + var result = document.Build(null, null); + + Assert.Same(control, result); + Assert.Null(presenter.Content); + Assert.Null(presenter.Child); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void ManagedDockWindowDocument_Build_Detaches_Direct_Control_From_Parent() + { + var window = new DockWindow(); + var document = new ManagedDockWindowDocument(window); + var control = new Border(); + var host = new ContentControl { Content = control }; + + document.Content = control; + + var result = document.Build(null, null); + + Assert.Same(control, result); + Assert.Null(host.Content); + } + + [AvaloniaFact] + public void ManagedDockWindowDocument_Build_Detaches_Direct_Control_From_ContentPresenter() + { + var window = new DockWindow(); + var document = new ManagedDockWindowDocument(window); + var control = new Border(); + var presenter = new ContentPresenter { Content = control }; + var host = new Window { Content = presenter }; + + document.Content = control; + + try + { + host.Show(); + host.UpdateLayout(); + + var result = document.Build(null, null); + + Assert.Same(control, result); + Assert.Null(presenter.Content); + Assert.Null(presenter.Child); + } + finally + { + host.Close(); + } + } +} diff --git a/tests/Dock.Model.ReactiveUI.UnitTests/DockNavigationHelpersTests.cs b/tests/Dock.Model.ReactiveUI.UnitTests/DockNavigationHelpersTests.cs index 4a78f7851..71f483d3d 100644 --- a/tests/Dock.Model.ReactiveUI.UnitTests/DockNavigationHelpersTests.cs +++ b/tests/Dock.Model.ReactiveUI.UnitTests/DockNavigationHelpersTests.cs @@ -71,6 +71,7 @@ private sealed class TestFactory : Factory public IDockable? ActiveDockable { get; private set; } public IDock? FocusedDock { get; private set; } public IDockable? FocusedDockable { get; private set; } + public IDockable? FloatedDockable { get; private set; } public int FloatDockableCalls { get; private set; } public override void AddDockable(IDock dock, IDockable dockable) @@ -96,6 +97,7 @@ public override void SetFocusedDockable(IDock dock, IDockable? dockable) public override void FloatDockable(IDockable dockable) { FloatDockableCalls++; + FloatedDockable = dockable; base.FloatDockable(dockable); } } @@ -136,6 +138,69 @@ public void OpenDocument_Floats_WhenRequested() Assert.Equal(1, factory.FloatDockableCalls); } + [Fact] + public void OpenDocument_Uses_Document_Already_In_Dock() + { + var factory = CreateFactory(out var hostScreen, out var documentDock); + var document = new Document { Id = "Doc1" }; + factory.AddDockable(documentDock, document); + var service = new DockNavigationService(); + + service.AttachFactory(factory, hostScreen); + service.OpenDocument(hostScreen, document, floatWindow: false); + + var dockables = documentDock.VisibleDockables; + Assert.NotNull(dockables); + Assert.Single(dockables!); + Assert.Same(document, factory.ActiveDockable); + Assert.Same(documentDock, factory.FocusedDock); + Assert.Same(document, factory.FocusedDockable); + } + + [Fact] + public void OpenDocument_Readds_Document_When_Removed_From_Owner() + { + var factory = CreateFactory(out var hostScreen, out var documentDock); + var document = new Document { Id = "Doc1" }; + factory.AddDockable(documentDock, document); + factory.RemoveDockable(document, true); + + Assert.NotNull(documentDock.VisibleDockables); + Assert.Empty(documentDock.VisibleDockables!); + + var service = new DockNavigationService(); + + service.AttachFactory(factory, hostScreen); + service.OpenDocument(hostScreen, document, floatWindow: false); + + var dockables = documentDock.VisibleDockables; + Assert.NotNull(dockables); + Assert.Single(dockables!); + Assert.Same(document, dockables![0]); + } + + [Fact] + public void OpenDocument_Adds_New_Document_When_Id_Matches_Existing() + { + var factory = CreateFactory(out var hostScreen, out var documentDock); + var existing = new Document { Id = "Doc1" }; + factory.AddDockable(documentDock, existing); + var newDocument = new Document { Id = "Doc1" }; + var service = new DockNavigationService(); + + service.AttachFactory(factory, hostScreen); + service.OpenDocument(hostScreen, newDocument, floatWindow: false); + + var dockables = documentDock.VisibleDockables; + Assert.NotNull(dockables); + Assert.Equal(2, dockables!.Count); + Assert.Contains(existing, dockables); + Assert.Contains(newDocument, dockables); + Assert.Same(newDocument, factory.ActiveDockable); + Assert.Same(documentDock, factory.FocusedDock); + Assert.Same(newDocument, factory.FocusedDockable); + } + private static TestFactory CreateFactory(out TestScreenDockable hostScreen, out DocumentDock documentDock) { var factory = new TestFactory();