From c54c8c01c3afe0c5deb811d3f52e06886bde97c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Mon, 30 Mar 2026 18:45:12 +0200 Subject: [PATCH 01/18] Add deferred dock content host --- .../Controls/DocumentContentControl.axaml | 4 +- .../Controls/ToolContentControl.axaml | 4 +- .../Controls/DeferredContentControl.cs | 222 ++++++++++++++++++ 3 files changed, 226 insertions(+), 4 deletions(-) create mode 100644 src/Dock.Avalonia/Controls/DeferredContentControl.cs diff --git a/src/Dock.Avalonia.Themes.Fluent/Controls/DocumentContentControl.axaml b/src/Dock.Avalonia.Themes.Fluent/Controls/DocumentContentControl.axaml index ac5d4bcc5..b9d155cc8 100644 --- a/src/Dock.Avalonia.Themes.Fluent/Controls/DocumentContentControl.axaml +++ b/src/Dock.Avalonia.Themes.Fluent/Controls/DocumentContentControl.axaml @@ -15,8 +15,8 @@ - + diff --git a/src/Dock.Avalonia.Themes.Fluent/Controls/ToolContentControl.axaml b/src/Dock.Avalonia.Themes.Fluent/Controls/ToolContentControl.axaml index 21c877b35..3cd627d1d 100644 --- a/src/Dock.Avalonia.Themes.Fluent/Controls/ToolContentControl.axaml +++ b/src/Dock.Avalonia.Themes.Fluent/Controls/ToolContentControl.axaml @@ -19,8 +19,8 @@ - + diff --git a/src/Dock.Avalonia/Controls/DeferredContentControl.cs b/src/Dock.Avalonia/Controls/DeferredContentControl.cs new file mode 100644 index 000000000..85071af2b --- /dev/null +++ b/src/Dock.Avalonia/Controls/DeferredContentControl.cs @@ -0,0 +1,222 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. +using System.Collections.Generic; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Metadata; +using Avalonia.Controls.Presenters; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; +using Avalonia.Data; +using Avalonia.LogicalTree; +using Avalonia.Threading; + +namespace Dock.Avalonia.Controls; + +/// +/// A that batches content materialization onto the next dispatcher pass. +/// +[TemplatePart("PART_ContentPresenter", typeof(ContentPresenter))] +public class DeferredContentControl : ContentControl +{ + private DeferredContentPresenter? _presenter; + private object? _appliedContent; + private IDataTemplate? _appliedContentTemplate; + private long _requestedVersion; + private long _appliedVersion = -1; + + static DeferredContentControl() + { + TemplateProperty.OverrideDefaultValue( + new FuncControlTemplate((_, nameScope) => new DeferredContentPresenter + { + Name = "PART_ContentPresenter", + [~ContentPresenter.BackgroundProperty] = new TemplateBinding(BackgroundProperty), + [~ContentPresenter.BackgroundSizingProperty] = new TemplateBinding(BackgroundSizingProperty), + [~ContentPresenter.BorderBrushProperty] = new TemplateBinding(BorderBrushProperty), + [~ContentPresenter.BorderThicknessProperty] = new TemplateBinding(BorderThicknessProperty), + [~ContentPresenter.CornerRadiusProperty] = new TemplateBinding(CornerRadiusProperty), + [~ContentPresenter.PaddingProperty] = new TemplateBinding(PaddingProperty), + [~ContentPresenter.VerticalContentAlignmentProperty] = new TemplateBinding(VerticalContentAlignmentProperty), + [~ContentPresenter.HorizontalContentAlignmentProperty] = new TemplateBinding(HorizontalContentAlignmentProperty) + }.RegisterInNameScope(nameScope))); + } + + /// + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + QueueDeferredPresentation(); + } + + /// + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + DeferredContentPresentationQueue.Remove(this); + } + + /// + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + _presenter = e.NameScope.Find("PART_ContentPresenter"); + _appliedVersion = -1; + QueueDeferredPresentation(); + } + + /// + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == ContentProperty || change.Property == ContentTemplateProperty) + { + _requestedVersion++; + QueueDeferredPresentation(); + } + } + + internal void ApplyDeferredPresentation() + { + if (!IsReadyForPresentation()) + { + return; + } + + var content = Content; + var contentTemplate = ContentTemplate; + + if (_appliedVersion == _requestedVersion + && ReferenceEquals(_appliedContent, content) + && ReferenceEquals(_appliedContentTemplate, contentTemplate)) + { + return; + } + + _presenter!.ApplyDeferredState(content, contentTemplate); + _appliedContent = content; + _appliedContentTemplate = contentTemplate; + _appliedVersion = _requestedVersion; + } + + private void QueueDeferredPresentation() + { + if (!IsReadyForPresentation()) + { + return; + } + + DeferredContentPresentationQueue.Enqueue(this); + } + + private bool IsReadyForPresentation() + { + return _presenter is not null + && VisualRoot is not null + && ((ILogical)this).IsAttachedToLogicalTree; + } +} + +internal sealed class DeferredContentPresenter : ContentPresenter +{ + private bool _suppressDeferredUpdates; + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + if (_suppressDeferredUpdates + && (change.Property == ContentProperty || change.Property == ContentTemplateProperty)) + { + return; + } + + base.OnPropertyChanged(change); + } + + internal void ApplyDeferredState(object? content, IDataTemplate? contentTemplate) + { + _suppressDeferredUpdates = true; + + try + { + SetCurrentValue(ContentTemplateProperty, contentTemplate); + SetCurrentValue(ContentProperty, content); + } + finally + { + _suppressDeferredUpdates = false; + } + + UpdateChild(); + PseudoClasses.Set(":empty", content is null); + InvalidateMeasure(); + } +} + +internal static class DeferredContentPresentationQueue +{ + private static readonly HashSet s_pending = new(); + private static bool s_isScheduled; + + internal static void Enqueue(DeferredContentControl control) + { + if (!Dispatcher.UIThread.CheckAccess()) + { + Dispatcher.UIThread.Post(() => Enqueue(control), DispatcherPriority.Render); + return; + } + + if (!s_pending.Add(control)) + { + return; + } + + ScheduleFlush(); + } + + internal static void Remove(DeferredContentControl control) + { + if (!Dispatcher.UIThread.CheckAccess()) + { + Dispatcher.UIThread.Post(() => Remove(control), DispatcherPriority.Render); + return; + } + + s_pending.Remove(control); + } + + private static void ScheduleFlush() + { + if (s_isScheduled) + { + return; + } + + s_isScheduled = true; + Dispatcher.UIThread.Post(Flush, DispatcherPriority.Render); + } + + private static void Flush() + { + s_isScheduled = false; + + if (s_pending.Count == 0) + { + return; + } + + var batch = new DeferredContentControl[s_pending.Count]; + s_pending.CopyTo(batch); + s_pending.Clear(); + + foreach (var control in batch) + { + control.ApplyDeferredPresentation(); + } + + if (s_pending.Count > 0) + { + ScheduleFlush(); + } + } +} From ba153af47a075deadfe284d6c6317767241fa6e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Mon, 30 Mar 2026 18:45:20 +0200 Subject: [PATCH 02/18] Add deferred content headless tests --- .../DeferredContentControlTests.cs | 269 ++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs diff --git a/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs b/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs new file mode 100644 index 000000000..92bddf275 --- /dev/null +++ b/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs @@ -0,0 +1,269 @@ +using System; +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using Avalonia.Headless.XUnit; +using Avalonia.Threading; +using Avalonia.VisualTree; +using Dock.Avalonia.Controls; +using Dock.Model.Avalonia.Controls; +using Xunit; + +namespace Dock.Avalonia.HeadlessTests; + +public class DeferredContentControlTests +{ + private sealed class CountingTemplate : IRecyclingDataTemplate + { + private readonly Func _factory; + + public CountingTemplate(Func factory) + { + _factory = factory; + } + + public int BuildCount { get; private set; } + + public Control? Build(object? param) + { + return Build(param, null); + } + + public Control? Build(object? data, Control? existing) + { + if (existing is not null) + { + return existing; + } + + BuildCount++; + return _factory(); + } + + public bool Match(object? data) + { + return true; + } + } + + [AvaloniaFact] + public void DeferredContentControl_Defers_Content_Materialization_Until_Dispatcher_Run() + { + var template = new CountingTemplate(() => new Border + { + Child = new TextBlock { Text = "Deferred" } + }); + var control = new DeferredContentControl + { + Content = new object(), + ContentTemplate = template + }; + var window = new Window + { + Width = 800, + Height = 600, + Content = control + }; + + window.Show(); + control.ApplyTemplate(); + window.UpdateLayout(); + + try + { + Assert.NotNull(control.Presenter); + Assert.Null(control.Presenter!.Child); + Assert.Equal(0, template.BuildCount); + + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + + Assert.NotNull(control.Presenter.Child); + Assert.Equal(1, template.BuildCount); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void DeferredContentControl_Batches_Content_Changes_Into_A_Single_Build() + { + var template = new CountingTemplate(() => new TextBlock()); + var control = new DeferredContentControl + { + ContentTemplate = template + }; + var window = new Window + { + Width = 800, + Height = 600, + Content = control + }; + + window.Show(); + control.ApplyTemplate(); + + try + { + control.Content = "First"; + control.Content = "Second"; + window.UpdateLayout(); + + Assert.Equal(0, template.BuildCount); + Assert.Null(control.Presenter?.Child); + + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + + Assert.Equal(1, template.BuildCount); + var textBlock = Assert.IsType(control.Presenter!.Child); + Assert.Equal("Second", textBlock.DataContext); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void DeferredContentControl_Reattaches_And_Applies_Deferred_Content_Changes() + { + var template = new CountingTemplate(() => new TextBlock()); + var control = new DeferredContentControl + { + Content = "First", + ContentTemplate = template + }; + var firstWindow = new Window + { + Width = 800, + Height = 600, + Content = control + }; + + firstWindow.Show(); + control.ApplyTemplate(); + Dispatcher.UIThread.RunJobs(); + firstWindow.UpdateLayout(); + + Assert.Equal(1, template.BuildCount); + + firstWindow.Content = null; + firstWindow.Close(); + control.Content = "Second"; + + Assert.Equal(1, template.BuildCount); + + var secondWindow = new Window + { + Width = 800, + Height = 600, + Content = control + }; + + secondWindow.Show(); + + try + { + Dispatcher.UIThread.RunJobs(); + secondWindow.UpdateLayout(); + + Assert.Equal(2, template.BuildCount); + var textBlock = Assert.IsType(control.Presenter!.Child); + Assert.Equal("Second", textBlock.DataContext); + } + finally + { + secondWindow.Close(); + } + } + + [AvaloniaFact] + public void DocumentContentControl_Defers_Document_Template_Materialization() + { + var buildCount = 0; + var document = new Document + { + Content = (Func)(_ => + { + buildCount++; + return new TextBlock { Text = "Document" }; + }) + }; + var control = new DocumentContentControl + { + DataContext = document + }; + var window = new Window + { + Width = 800, + Height = 600, + Content = control + }; + + window.Show(); + control.ApplyTemplate(); + window.UpdateLayout(); + + try + { + Assert.Equal(0, buildCount); + Assert.DoesNotContain(control.GetVisualDescendants(), visual => visual is TextBlock textBlock && textBlock.Text == "Document"); + + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + + Assert.Equal(1, buildCount); + Assert.Contains(control.GetVisualDescendants(), visual => visual is TextBlock textBlock && textBlock.Text == "Document"); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void ToolContentControl_Defers_Tool_Template_Materialization() + { + var buildCount = 0; + var tool = new Tool + { + Content = (Func)(_ => + { + buildCount++; + return new TextBlock { Text = "Tool" }; + }) + }; + var control = new ToolContentControl + { + DataContext = tool + }; + var window = new Window + { + Width = 800, + Height = 600, + Content = control + }; + + window.Show(); + control.ApplyTemplate(); + window.UpdateLayout(); + + try + { + Assert.Equal(0, buildCount); + Assert.DoesNotContain(control.GetVisualDescendants(), visual => visual is TextBlock textBlock && textBlock.Text == "Tool"); + + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + + Assert.Equal(1, buildCount); + Assert.Contains(control.GetVisualDescendants(), visual => visual is TextBlock textBlock && textBlock.Text == "Tool"); + } + finally + { + window.Close(); + } + } +} From f83a4a0bbc18330d3ca1d4cfec19c57b9ff50d1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Mon, 30 Mar 2026 18:45:27 +0200 Subject: [PATCH 03/18] Ignore local report artifacts --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 868cef041..9b344aa88 100644 --- a/.gitignore +++ b/.gitignore @@ -298,3 +298,4 @@ docfx/api/*.yml docfx/api/*.manifest /plan +/report From 8e80a6707e262e762132517dd28e46c4ad7db075 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Mon, 30 Mar 2026 19:49:41 +0200 Subject: [PATCH 04/18] Extract deferred content hosts into standalone package --- Dock.slnx | 1 + .../Controls/DockControl.axaml | 6 +- .../Controls/DocumentControl.axaml | 14 +- .../Controls/HostWindow.axaml | 24 +- .../Controls/MdiDocumentWindow.axaml | 14 +- .../Controls/PinnedDockControl.axaml | 8 +- .../Controls/RootDockControl.axaml | 2 +- .../Controls/SplitViewDockControl.axaml | 20 +- .../Controls/ToolChromeControl.axaml | 15 +- .../Controls/ToolControl.axaml | 14 +- .../Controls/ManagedDockWindowDocument.cs | 5 +- src/Dock.Avalonia/Dock.Avalonia.csproj | 1 + .../DeferredContentControl.cs | 138 ++++- ...ock.Controls.DeferredContentControl.csproj | 24 + .../Properties/AssemblyInfo.cs | 3 + .../DeferredContentControlTests.cs | 480 ++++++++++++++++++ 16 files changed, 697 insertions(+), 72 deletions(-) rename src/{Dock.Avalonia/Controls => Dock.Controls.DeferredContentControl}/DeferredContentControl.cs (59%) create mode 100644 src/Dock.Controls.DeferredContentControl/Dock.Controls.DeferredContentControl.csproj create mode 100644 src/Dock.Controls.DeferredContentControl/Properties/AssemblyInfo.cs diff --git a/Dock.slnx b/Dock.slnx index 67be320d5..4ae558fed 100644 --- a/Dock.slnx +++ b/Dock.slnx @@ -81,6 +81,7 @@ + diff --git a/src/Dock.Avalonia.Themes.Fluent/Controls/DockControl.axaml b/src/Dock.Avalonia.Themes.Fluent/Controls/DockControl.axaml index f19b97393..9725ef2b4 100644 --- a/src/Dock.Avalonia.Themes.Fluent/Controls/DockControl.axaml +++ b/src/Dock.Avalonia.Themes.Fluent/Controls/DockControl.axaml @@ -22,9 +22,9 @@ - + diff --git a/src/Dock.Avalonia.Themes.Fluent/Controls/DocumentControl.axaml b/src/Dock.Avalonia.Themes.Fluent/Controls/DocumentControl.axaml index 59fcb4861..1b5ea1587 100644 --- a/src/Dock.Avalonia.Themes.Fluent/Controls/DocumentControl.axaml +++ b/src/Dock.Avalonia.Themes.Fluent/Controls/DocumentControl.axaml @@ -47,14 +47,14 @@ - - + + - - + + - + @@ -94,12 +94,12 @@ - + diff --git a/src/Dock.Avalonia.Themes.Fluent/Controls/MdiDocumentWindow.axaml b/src/Dock.Avalonia.Themes.Fluent/Controls/MdiDocumentWindow.axaml index 272b1ac9c..4c5dce8bb 100644 --- a/src/Dock.Avalonia.Themes.Fluent/Controls/MdiDocumentWindow.axaml +++ b/src/Dock.Avalonia.Themes.Fluent/Controls/MdiDocumentWindow.axaml @@ -416,14 +416,14 @@ - - + + - - + + diff --git a/src/Dock.Avalonia.Themes.Fluent/Controls/PinnedDockControl.axaml b/src/Dock.Avalonia.Themes.Fluent/Controls/PinnedDockControl.axaml index ebf177bad..a7f9f11ca 100644 --- a/src/Dock.Avalonia.Themes.Fluent/Controls/PinnedDockControl.axaml +++ b/src/Dock.Avalonia.Themes.Fluent/Controls/PinnedDockControl.axaml @@ -26,8 +26,8 @@ - - + + - - + + diff --git a/src/Dock.Avalonia.Themes.Fluent/Controls/RootDockControl.axaml b/src/Dock.Avalonia.Themes.Fluent/Controls/RootDockControl.axaml index d16835c5a..40f2613a5 100644 --- a/src/Dock.Avalonia.Themes.Fluent/Controls/RootDockControl.axaml +++ b/src/Dock.Avalonia.Themes.Fluent/Controls/RootDockControl.axaml @@ -41,7 +41,7 @@ IsVisible="{Binding !!BottomPinnedDockables?.Count, TargetNullValue={x:False}, FallbackValue={x:False}}" /> - + diff --git a/src/Dock.Avalonia.Themes.Fluent/Controls/SplitViewDockControl.axaml b/src/Dock.Avalonia.Themes.Fluent/Controls/SplitViewDockControl.axaml index 3fe5176e5..d41a89a33 100644 --- a/src/Dock.Avalonia.Themes.Fluent/Controls/SplitViewDockControl.axaml +++ b/src/Dock.Avalonia.Themes.Fluent/Controls/SplitViewDockControl.axaml @@ -27,17 +27,17 @@ PanePlacement="{Binding PanePlacement, Converter={x:Static SplitViewPanePlacementConverter.Instance}}" UseLightDismissOverlayMode="{Binding UseLightDismissOverlayMode}"> - + - + diff --git a/src/Dock.Avalonia.Themes.Fluent/Controls/ToolChromeControl.axaml b/src/Dock.Avalonia.Themes.Fluent/Controls/ToolChromeControl.axaml index cb1c3505d..563be9068 100644 --- a/src/Dock.Avalonia.Themes.Fluent/Controls/ToolChromeControl.axaml +++ b/src/Dock.Avalonia.Themes.Fluent/Controls/ToolChromeControl.axaml @@ -150,13 +150,14 @@ - + - - + + - - + + diff --git a/src/Dock.Avalonia/Controls/ManagedDockWindowDocument.cs b/src/Dock.Avalonia/Controls/ManagedDockWindowDocument.cs index daa1d77fc..e3e4e785a 100644 --- a/src/Dock.Avalonia/Controls/ManagedDockWindowDocument.cs +++ b/src/Dock.Avalonia/Controls/ManagedDockWindowDocument.cs @@ -10,6 +10,7 @@ using Avalonia.Media; using Avalonia.VisualTree; using Dock.Avalonia.Internal; +using Dock.Controls.DeferredContentControl; using Dock.Model.Controls; using Dock.Model.Core; @@ -18,7 +19,7 @@ namespace Dock.Avalonia.Controls; /// /// Document model used for managed floating windows. /// -public sealed class ManagedDockWindowDocument : ManagedDockableBase, IMdiDocument, IDocumentContent, IRecyclingDataTemplate, IDisposable +public sealed class ManagedDockWindowDocument : ManagedDockableBase, IMdiDocument, IDocumentContent, IRecyclingDataTemplate, IDeferredContentPresentation, IDisposable { private DockRect _mdiBounds; private MdiWindowState _mdiState = MdiWindowState.Normal; @@ -51,6 +52,8 @@ public ManagedDockWindowDocument(IDockWindow window) /// public IDockWindow? Window => _window; + bool IDeferredContentPresentation.DeferContentPresentation => false; + /// /// Gets whether the managed window hosts a tool dock. /// diff --git a/src/Dock.Avalonia/Dock.Avalonia.csproj b/src/Dock.Avalonia/Dock.Avalonia.csproj index 9be368135..18a933a5a 100644 --- a/src/Dock.Avalonia/Dock.Avalonia.csproj +++ b/src/Dock.Avalonia/Dock.Avalonia.csproj @@ -30,6 +30,7 @@ + diff --git a/src/Dock.Avalonia/Controls/DeferredContentControl.cs b/src/Dock.Controls.DeferredContentControl/DeferredContentControl.cs similarity index 59% rename from src/Dock.Avalonia/Controls/DeferredContentControl.cs rename to src/Dock.Controls.DeferredContentControl/DeferredContentControl.cs index 85071af2b..d098fae31 100644 --- a/src/Dock.Avalonia/Controls/DeferredContentControl.cs +++ b/src/Dock.Controls.DeferredContentControl/DeferredContentControl.cs @@ -11,13 +11,24 @@ using Avalonia.LogicalTree; using Avalonia.Threading; -namespace Dock.Avalonia.Controls; +namespace Dock.Controls.DeferredContentControl; + +/// +/// Defines whether a content instance should bypass deferred presentation. +/// +public interface IDeferredContentPresentation +{ + /// + /// Gets a value indicating whether content presentation should be deferred. + /// + bool DeferContentPresentation { get; } +} /// /// A that batches content materialization onto the next dispatcher pass. /// [TemplatePart("PART_ContentPresenter", typeof(ContentPresenter))] -public class DeferredContentControl : ContentControl +public class DeferredContentControl : ContentControl, IDeferredContentPresentationTarget { private DeferredContentPresenter? _presenter; private object? _appliedContent; @@ -84,8 +95,8 @@ internal void ApplyDeferredPresentation() return; } - var content = Content; - var contentTemplate = ContentTemplate; + object? content = Content; + IDataTemplate? contentTemplate = ContentTemplate; if (_appliedVersion == _requestedVersion && ReferenceEquals(_appliedContent, content) @@ -100,6 +111,11 @@ internal void ApplyDeferredPresentation() _appliedVersion = _requestedVersion; } + void IDeferredContentPresentationTarget.ApplyDeferredPresentation() + { + ApplyDeferredPresentation(); + } + private void QueueDeferredPresentation() { if (!IsReadyForPresentation()) @@ -107,6 +123,13 @@ private void QueueDeferredPresentation() return; } + if (Content is IDeferredContentPresentation { DeferContentPresentation: false }) + { + DeferredContentPresentationQueue.Remove(this); + ApplyDeferredPresentation(); + return; + } + DeferredContentPresentationQueue.Enqueue(this); } @@ -118,15 +141,48 @@ private bool IsReadyForPresentation() } } -internal sealed class DeferredContentPresenter : ContentPresenter +internal interface IDeferredContentPresentationTarget +{ + void ApplyDeferredPresentation(); +} + +/// +/// A that batches content materialization onto the next dispatcher pass. +/// +public class DeferredContentPresenter : ContentPresenter, IDeferredContentPresentationTarget { private bool _suppressDeferredUpdates; + private object? _appliedContent; + private IDataTemplate? _appliedContentTemplate; + private long _requestedVersion; + private long _appliedVersion = -1; + /// + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + QueueDeferredPresentation(); + } + + /// + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + DeferredContentPresentationQueue.Remove(this); + } + + /// protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { - if (_suppressDeferredUpdates - && (change.Property == ContentProperty || change.Property == ContentTemplateProperty)) + if (change.Property == ContentProperty || change.Property == ContentTemplateProperty) { + if (_suppressDeferredUpdates) + { + return; + } + + _requestedVersion++; + QueueDeferredPresentation(); return; } @@ -147,18 +203,74 @@ internal void ApplyDeferredState(object? content, IDataTemplate? contentTemplate _suppressDeferredUpdates = false; } + UpdatePresentedChild(); + } + + void IDeferredContentPresentationTarget.ApplyDeferredPresentation() + { + ApplyDeferredPresentation(); + } + + internal void ApplyDeferredPresentation() + { + if (!IsReadyForPresentation()) + { + return; + } + + object? content = Content; + IDataTemplate? contentTemplate = ContentTemplate; + + if (_appliedVersion == _requestedVersion + && ReferenceEquals(_appliedContent, content) + && ReferenceEquals(_appliedContentTemplate, contentTemplate)) + { + return; + } + + ApplyDeferredState(content, contentTemplate); + _appliedContent = content; + _appliedContentTemplate = contentTemplate; + _appliedVersion = _requestedVersion; + } + + private void QueueDeferredPresentation() + { + if (!IsReadyForPresentation()) + { + return; + } + + if (Content is IDeferredContentPresentation { DeferContentPresentation: false }) + { + DeferredContentPresentationQueue.Remove(this); + ApplyDeferredPresentation(); + return; + } + + DeferredContentPresentationQueue.Enqueue(this); + } + + private bool IsReadyForPresentation() + { + return VisualRoot is not null + && ((ILogical)this).IsAttachedToLogicalTree; + } + + private void UpdatePresentedChild() + { UpdateChild(); - PseudoClasses.Set(":empty", content is null); + PseudoClasses.Set(":empty", Content is null); InvalidateMeasure(); } } internal static class DeferredContentPresentationQueue { - private static readonly HashSet s_pending = new(); + private static readonly HashSet s_pending = new(); private static bool s_isScheduled; - internal static void Enqueue(DeferredContentControl control) + internal static void Enqueue(IDeferredContentPresentationTarget control) { if (!Dispatcher.UIThread.CheckAccess()) { @@ -174,7 +286,7 @@ internal static void Enqueue(DeferredContentControl control) ScheduleFlush(); } - internal static void Remove(DeferredContentControl control) + internal static void Remove(IDeferredContentPresentationTarget control) { if (!Dispatcher.UIThread.CheckAccess()) { @@ -205,11 +317,11 @@ private static void Flush() return; } - var batch = new DeferredContentControl[s_pending.Count]; + var batch = new IDeferredContentPresentationTarget[s_pending.Count]; s_pending.CopyTo(batch); s_pending.Clear(); - foreach (var control in batch) + foreach (IDeferredContentPresentationTarget control in batch) { control.ApplyDeferredPresentation(); } diff --git a/src/Dock.Controls.DeferredContentControl/Dock.Controls.DeferredContentControl.csproj b/src/Dock.Controls.DeferredContentControl/Dock.Controls.DeferredContentControl.csproj new file mode 100644 index 000000000..1a63a1607 --- /dev/null +++ b/src/Dock.Controls.DeferredContentControl/Dock.Controls.DeferredContentControl.csproj @@ -0,0 +1,24 @@ + + + + net6.0;net8.0;net10.0 + Library + bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml + enable + Dock.Controls.DeferredContentControl + + + + Dock.Controls.DeferredContentControl + + + + + + + + + + + + diff --git a/src/Dock.Controls.DeferredContentControl/Properties/AssemblyInfo.cs b/src/Dock.Controls.DeferredContentControl/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..edd7be8a3 --- /dev/null +++ b/src/Dock.Controls.DeferredContentControl/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using Avalonia.Metadata; + +[assembly: XmlnsDefinition("https://github.com/avaloniaui", "Dock.Controls.DeferredContentControl")] diff --git a/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs b/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs index 92bddf275..1159a0537 100644 --- a/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs +++ b/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs @@ -1,11 +1,18 @@ using System; +using System.Linq; +using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Templates; using Avalonia.Headless.XUnit; +using Avalonia.Styling; using Avalonia.Threading; using Avalonia.VisualTree; using Dock.Avalonia.Controls; +using Dock.Avalonia.Themes.Fluent; +using Dock.Controls.DeferredContentControl; +using Dock.Model.Avalonia; using Dock.Model.Avalonia.Controls; +using Dock.Model.Core; using Xunit; namespace Dock.Avalonia.HeadlessTests; @@ -266,4 +273,477 @@ public void ToolContentControl_Defers_Tool_Template_Materialization() window.Close(); } } + + [AvaloniaFact] + public void DocumentControl_Defers_Active_Document_Template_Materialization() + { + var factory = new Factory(); + var buildCount = 0; + var dock = new DocumentDock + { + Factory = factory, + LayoutMode = DocumentLayoutMode.Tabbed, + VisibleDockables = factory.CreateList() + }; + + var document = new Document + { + Id = "doc-1", + Title = "Doc 1", + Content = (Func)(_ => + { + buildCount++; + return new TextBlock { Text = "DocumentControl" }; + }) + }; + dock.VisibleDockables!.Add(document); + dock.ActiveDockable = document; + + var control = new DocumentControl + { + DataContext = dock + }; + + var window = ShowInWindow(control, new DockFluentTheme()); + var presenterHost = GetDeferredPresenter(control); + + try + { + Assert.Equal(0, buildCount); + Assert.Null(presenterHost.Presenter?.Child); + + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + + Assert.Equal(1, buildCount); + var textBlock = Assert.IsType(presenterHost.Presenter!.Child); + Assert.Equal("DocumentControl", textBlock.Text); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void ToolControl_Defers_Active_Tool_Template_Materialization() + { + var factory = new Factory(); + var buildCount = 0; + var dock = new ToolDock + { + Factory = factory, + VisibleDockables = factory.CreateList() + }; + + var tool = new Tool + { + Id = "tool-1", + Title = "Tool 1", + Content = (Func)(_ => + { + buildCount++; + return new TextBlock { Text = "ToolControl" }; + }) + }; + dock.VisibleDockables!.Add(tool); + dock.ActiveDockable = tool; + + var control = new ToolControl + { + DataContext = dock + }; + + var window = ShowInWindow(control, new DockFluentTheme()); + var presenterHost = GetDeferredPresenter(control); + + try + { + Assert.Equal(0, buildCount); + Assert.Null(presenterHost.Presenter?.Child); + + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + + Assert.Equal(1, buildCount); + var textBlock = Assert.IsType(presenterHost.Presenter!.Child); + Assert.Equal("ToolControl", textBlock.Text); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void MdiDocumentWindow_Defers_Window_Content_Materialization() + { + var buildCount = 0; + var document = new Document + { + Id = "doc-1", + Title = "Doc 1", + Content = (Func)(_ => + { + buildCount++; + return new TextBlock { Text = "MdiDocumentWindow" }; + }) + }; + + var control = new MdiDocumentWindow + { + DataContext = document + }; + + var window = ShowInWindow(control, new DockFluentTheme()); + var presenterHost = GetDeferredPresenter(control); + + try + { + Assert.Equal(0, buildCount); + Assert.Null(presenterHost.Presenter?.Child); + + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + + Assert.Equal(1, buildCount); + var textBlock = Assert.IsType(presenterHost.Presenter!.Child); + Assert.Equal("MdiDocumentWindow", textBlock.Text); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void SplitViewDockControl_Defers_Pane_And_Content_Materialization() + { + var factory = new Factory(); + var paneDockable = new Tool + { + Id = "tool-1", + Title = "Pane" + }; + var contentDockable = new DocumentDock + { + Factory = factory, + Id = "doc-dock-1", + Title = "Content", + VisibleDockables = factory.CreateList() + }; + var splitViewDock = new SplitViewDock + { + Factory = factory, + PaneDockable = paneDockable, + ContentDockable = contentDockable, + IsPaneOpen = true + }; + + var paneBuildCount = 0; + var contentBuildCount = 0; + var control = new SplitViewDockControl + { + DataContext = splitViewDock + }; + control.DataTemplates.Add(CreateCountingTemplate("SplitPane", () => paneBuildCount++)); + control.DataTemplates.Add(CreateCountingTemplate("SplitContent", () => contentBuildCount++)); + + var window = ShowInWindow(control); + + try + { + Assert.Equal(0, paneBuildCount); + Assert.Equal(0, contentBuildCount); + Assert.DoesNotContain(control.GetVisualDescendants(), visual => visual is TextBlock textBlock && textBlock.Text == "SplitPane"); + Assert.DoesNotContain(control.GetVisualDescendants(), visual => visual is TextBlock textBlock && textBlock.Text == "SplitContent"); + + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + + Assert.Equal(1, paneBuildCount); + Assert.Equal(1, contentBuildCount); + Assert.Contains(control.GetVisualDescendants(), visual => visual is TextBlock textBlock && textBlock.Text == "SplitPane"); + Assert.Contains(control.GetVisualDescendants(), visual => visual is TextBlock textBlock && textBlock.Text == "SplitContent"); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void PinnedDockControl_Defers_Pinned_Dock_Materialization() + { + var factory = new Factory(); + var root = new RootDock + { + Factory = factory, + VisibleDockables = factory.CreateList() + }; + var pinnedDock = new ToolDock + { + Alignment = Alignment.Left, + Factory = factory, + VisibleDockables = factory.CreateList() + }; + pinnedDock.VisibleDockables!.Add(new Tool { Id = "tool-1", Title = "Tool" }); + root.PinnedDock = pinnedDock; + + var buildCount = 0; + var control = new PinnedDockControl + { + DataContext = root + }; + control.DataTemplates.Add(CreateCountingTemplate("PinnedDock", () => buildCount++)); + + var window = ShowInWindow(control); + + try + { + Assert.Equal(0, buildCount); + Assert.DoesNotContain(control.GetVisualDescendants(), visual => visual is TextBlock textBlock && textBlock.Text == "PinnedDock"); + + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + + Assert.Equal(1, buildCount); + Assert.Contains(control.GetVisualDescendants(), visual => visual is TextBlock textBlock && textBlock.Text == "PinnedDock"); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void DockControl_Defers_Layout_Materialization() + { + var buildCount = 0; + var root = new RootDock + { + Id = "root-1" + }; + + var control = new DockControl + { + AutoCreateDataTemplates = false, + Layout = root + }; + + var window = ShowInWindow(control, new DockFluentTheme()); + var presenterHost = GetDeferredHost(control, "PART_ContentControl"); + presenterHost.DataTemplates.Add(CreateCountingTemplate("DockControl", () => buildCount++)); + + try + { + Assert.Equal(0, buildCount); + Assert.Null(presenterHost.Presenter?.Child); + + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + + Assert.Equal(1, buildCount); + var textBlock = Assert.IsType(presenterHost.Presenter!.Child); + Assert.Equal("DockControl", textBlock.Text); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void RootDockControl_Defers_Active_Dock_Materialization() + { + var factory = new Factory(); + var buildCount = 0; + var activeDock = new DocumentDock + { + Factory = factory, + Id = "doc-dock-1" + }; + var root = new RootDock + { + Factory = factory, + ActiveDockable = activeDock + }; + + var control = new RootDockControl + { + DataContext = root + }; + + var window = ShowInWindow(control, new DockFluentTheme()); + var presenterHost = GetDeferredHost(control, "PART_MainContent"); + presenterHost.DataTemplates.Add(CreateCountingTemplate("RootDockControl", () => buildCount++)); + + try + { + Assert.Equal(0, buildCount); + Assert.Null(presenterHost.Presenter?.Child); + + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + + Assert.Equal(1, buildCount); + var textBlock = Assert.IsType(presenterHost.Presenter!.Child); + Assert.Equal("RootDockControl", textBlock.Text); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void ToolChromeControl_Defers_Content_Materialization() + { + var factory = new Factory(); + var toolDock = new ToolDock + { + Factory = factory, + ActiveDockable = new Tool { Id = "tool-1", Title = "Tool 1" } + }; + var template = new CountingTemplate(() => new TextBlock { Text = "ToolChromeControl" }); + var control = new ToolChromeControl + { + DataContext = toolDock, + Content = "First", + ContentTemplate = template + }; + var window = new Window + { + Width = 800, + Height = 600, + Content = control + }; + window.Styles.Add(new DockFluentTheme()); + window.Show(); + control.ApplyTemplate(); + + var presenterHost = GetDeferredPresenterHost(control, "PART_ContentPresenter"); + + try + { + window.UpdateLayout(); + Assert.Equal(1, template.BuildCount); + var initialTextBlock = Assert.IsType(presenterHost.Child); + Assert.Equal("First", initialTextBlock.DataContext); + + control.Content = "Second"; + + var staleTextBlock = Assert.IsType(presenterHost.Child); + Assert.Equal("First", staleTextBlock.DataContext); + + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + + Assert.Equal(1, template.BuildCount); + var textBlock = Assert.IsType(presenterHost.Child); + Assert.Equal("Second", textBlock.DataContext); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void HostWindow_Defers_Content_Materialization() + { + var template = new CountingTemplate(() => new TextBlock { Text = "HostWindow" }); + var window = new HostWindow + { + Width = 800, + Height = 600, + Content = "First", + ContentTemplate = template + }; + window.Styles.Add(new DockFluentTheme()); + + window.Show(); + window.ApplyTemplate(); + + var presenterHost = GetDeferredPresenterHost(window, "PART_ContentPresenter"); + + try + { + window.UpdateLayout(); + Assert.Equal(1, template.BuildCount); + var initialTextBlock = Assert.IsType(presenterHost.Child); + Assert.Equal("First", initialTextBlock.DataContext); + + window.Content = "Second"; + + var staleTextBlock = Assert.IsType(presenterHost.Child); + Assert.Equal("First", staleTextBlock.DataContext); + + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + + Assert.Equal(1, template.BuildCount); + var textBlock = Assert.IsType(presenterHost.Child); + Assert.Equal("Second", textBlock.DataContext); + } + finally + { + window.Close(); + } + } + + private static FuncDataTemplate CreateCountingTemplate(string text, Action onBuild) + { + return new FuncDataTemplate( + (_, _) => + { + onBuild(); + return new TextBlock { Text = text }; + }, + true); + } + + private static DeferredContentControl GetDeferredPresenter(Control control) + { + return GetDeferredHost(control, "PART_ContentPresenter"); + } + + private static DeferredContentControl GetDeferredHost(Visual visual, string name) + { + var presenter = visual.GetVisualDescendants() + .OfType() + .FirstOrDefault(candidate => candidate.Name == name); + Assert.NotNull(presenter); + return presenter!; + } + + private static DeferredContentPresenter GetDeferredPresenterHost(Visual visual, string name) + { + var presenter = visual.GetVisualDescendants() + .OfType() + .FirstOrDefault(candidate => candidate.Name == name); + Assert.NotNull(presenter); + return presenter!; + } + + private static Window ShowInWindow(Control control, params IStyle[] styles) + { + var window = new Window + { + Width = 800, + Height = 600, + Content = control + }; + + foreach (var style in styles) + { + window.Styles.Add(style); + } + + window.Show(); + control.ApplyTemplate(); + window.UpdateLayout(); + control.UpdateLayout(); + return window; + } } From 6ecdbc49963410588cc0b63ecd9e8437135d52d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Mon, 30 Mar 2026 19:49:58 +0200 Subject: [PATCH 05/18] Document deferred content control package --- README.md | 3 + docfx/articles/README.md | 3 +- docfx/articles/dock-custom-theme.md | 6 ++ docfx/articles/dock-deferred-content.md | 77 +++++++++++++++++++++++++ docfx/articles/toc.yml | 2 + docfx/index.md | 3 + 6 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 docfx/articles/dock-deferred-content.md diff --git a/README.md b/README.md index e11555b66..f9cb977f9 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ A docking layout system. - **ItemsSource Support**: Bind document collections directly to DocumentDock for automatic document management - **Flexible Content Templates**: Use DocumentTemplate for customizable document content rendering - **Optional Document Content Caching**: Keep document views alive across tab switches via theme option (`CacheDocumentTabContent`) +- **Deferred Content Materialization**: Defer expensive content presenter work to the next dispatcher frame in Dock themes or custom templates - **Multiple MVVM Frameworks**: Support for ReactiveUI, Prism, ReactiveProperty, and standard MVVM patterns - **Comprehensive Serialization**: Save and restore layouts with multiple format options (JSON, XML, YAML, Protobuf) - **Rich Theming**: Fluent and Simple themes with full customization support @@ -63,6 +64,7 @@ Install-Package Dock.Model.Mvvm Install-Package Dock.Serializer.Newtonsoft Install-Package Dock.Avalonia.Themes.Fluent Install-Package Dock.Avalonia.Themes.Browser +Install-Package Dock.Controls.DeferredContentControl ``` **Available NuGet packages:** @@ -74,6 +76,7 @@ Install-Package Dock.Avalonia.Themes.Browser | [![NuGet](https://img.shields.io/nuget/v/Dock.Avalonia.Themes.Fluent.svg)](https://www.nuget.org/packages/Dock.Avalonia.Themes.Fluent) | [`Dock.Avalonia.Themes.Fluent`](https://www.nuget.org/packages/Dock.Avalonia.Themes.Fluent) | [![Downloads](https://img.shields.io/nuget/dt/Dock.Avalonia.Themes.Fluent.svg)](https://www.nuget.org/packages/Dock.Avalonia.Themes.Fluent) | | [![NuGet](https://img.shields.io/nuget/v/Dock.Avalonia.Themes.Browser.svg)](https://www.nuget.org/packages/Dock.Avalonia.Themes.Browser) | [`Dock.Avalonia.Themes.Browser`](https://www.nuget.org/packages/Dock.Avalonia.Themes.Browser) | [![Downloads](https://img.shields.io/nuget/dt/Dock.Avalonia.Themes.Browser.svg)](https://www.nuget.org/packages/Dock.Avalonia.Themes.Browser) | | [![NuGet](https://img.shields.io/nuget/v/Dock.Avalonia.Themes.Simple.svg)](https://www.nuget.org/packages/Dock.Avalonia.Themes.Simple) | [`Dock.Avalonia.Themes.Simple`](https://www.nuget.org/packages/Dock.Avalonia.Themes.Simple) | [![Downloads](https://img.shields.io/nuget/dt/Dock.Avalonia.Themes.Simple.svg)](https://www.nuget.org/packages/Dock.Avalonia.Themes.Simple) | +| [![NuGet](https://img.shields.io/nuget/v/Dock.Controls.DeferredContentControl.svg)](https://www.nuget.org/packages/Dock.Controls.DeferredContentControl) | [`Dock.Controls.DeferredContentControl`](https://www.nuget.org/packages/Dock.Controls.DeferredContentControl) | [![Downloads](https://img.shields.io/nuget/dt/Dock.Controls.DeferredContentControl.svg)](https://www.nuget.org/packages/Dock.Controls.DeferredContentControl) | | [![NuGet](https://img.shields.io/nuget/v/Dock.Controls.ProportionalStackPanel.svg)](https://www.nuget.org/packages/Dock.Controls.ProportionalStackPanel) | [`Dock.Controls.ProportionalStackPanel`](https://www.nuget.org/packages/Dock.Controls.ProportionalStackPanel) | [![Downloads](https://img.shields.io/nuget/dt/Dock.Controls.ProportionalStackPanel.svg)](https://www.nuget.org/packages/Dock.Controls.ProportionalStackPanel) | | [![NuGet](https://img.shields.io/nuget/v/Dock.Controls.Recycling.svg)](https://www.nuget.org/packages/Dock.Controls.Recycling) | [`Dock.Controls.Recycling`](https://www.nuget.org/packages/Dock.Controls.Recycling) | [![Downloads](https://img.shields.io/nuget/dt/Dock.Controls.Recycling.svg)](https://www.nuget.org/packages/Dock.Controls.Recycling) | | [![NuGet](https://img.shields.io/nuget/v/Dock.Controls.Recycling.Model.svg)](https://www.nuget.org/packages/Dock.Controls.Recycling.Model) | [`Dock.Controls.Recycling.Model`](https://www.nuget.org/packages/Dock.Controls.Recycling.Model) | [![Downloads](https://img.shields.io/nuget/dt/Dock.Controls.Recycling.Model.svg)](https://www.nuget.org/packages/Dock.Controls.Recycling.Model) | diff --git a/docfx/articles/README.md b/docfx/articles/README.md index 92aa03fdc..6df13e59d 100644 --- a/docfx/articles/README.md +++ b/docfx/articles/README.md @@ -44,7 +44,7 @@ Dock supports a wide range of UI patterns: - Floating windows, docking targets, and drag gestures. - Layout persistence and restoration. - Custom themes and styling. -- Control recycling for performance. +- Control recycling and deferred content presentation for performance. - Overlay systems for busy states and dialogs. ## How to approach the documentation @@ -65,6 +65,7 @@ Sample apps demonstrate complete layouts and patterns: Dock is published on NuGet. Common packages include: - `Dock.Avalonia` +- `Dock.Controls.DeferredContentControl` - `Dock.Model` - `Dock.Avalonia.Themes.Fluent` - `Dock.Avalonia.Themes.Browser` diff --git a/docfx/articles/dock-custom-theme.md b/docfx/articles/dock-custom-theme.md index c354482c9..87a79a2e0 100644 --- a/docfx/articles/dock-custom-theme.md +++ b/docfx/articles/dock-custom-theme.md @@ -48,6 +48,12 @@ or copy them into your own assembly and include `/Controls/...` resources. ``` +If your copied templates host expensive document or tool content, add the +`Dock.Controls.DeferredContentControl` package and replace eager content hosts +with `DeferredContentControl` or `DeferredContentPresenter`. See the +[Deferred content presentation](dock-deferred-content.md) guide for the theme +patterns used by the built-in Dock themes. + ## 3. Apply the theme Reference the theme from `App.axaml`: diff --git a/docfx/articles/dock-deferred-content.md b/docfx/articles/dock-deferred-content.md new file mode 100644 index 000000000..998555534 --- /dev/null +++ b/docfx/articles/dock-deferred-content.md @@ -0,0 +1,77 @@ +# Deferred Content Presentation + +Dock can defer expensive content materialization to the next dispatcher render pass. This is useful when initial template resolution and later style resolution make document, tool, or window content noticeably heavy during first layout. + +The feature lives in the `Dock.Controls.DeferredContentControl` package and exposes two hosts: + +- `DeferredContentControl` for templates that can use a `ContentControl`. +- `DeferredContentPresenter` for templates that must keep a `ContentPresenter` contract. + +## Adding the package + +Include the package alongside your normal Dock references: + +```bash +dotnet add package Dock.Controls.DeferredContentControl +``` + +## Using DeferredContentControl in custom themes + +Use `DeferredContentControl` in places where a Dock theme would otherwise materialize heavy content immediately. + +```xaml + + + + + + + + + +``` + +This keeps the requested content and template, then applies them on the next dispatcher frame. Multiple content changes before the flush are batched into one materialization pass. + +## Using DeferredContentPresenter + +Some Dock templates require the named part to remain a `ContentPresenter`. In those cases use `DeferredContentPresenter` instead of `DeferredContentControl`. + +```xaml + +``` + +This is the right choice for hosts such as custom chrome windows or any template that relies on a `ContentPresenter`-typed part. + +## Opting out for specific content + +If a content object must stay synchronous, implement `IDeferredContentPresentation` and return `false`. + +```csharp +public sealed class ManagedDockWindowDocument : IDeferredContentPresentation +{ + bool IDeferredContentPresentation.DeferContentPresentation => false; +} +``` + +Dock uses this for managed floating-window content that should not be delayed. + +## Built-in theme behavior + +The Fluent and Simple themes use deferred content presentation for the main heavy content hosts, including document, tool, MDI, split-view, pinned, root, and host-window content paths. Cached document tab content stays eager by design because that path intentionally prebuilds hidden tabs. + +## Related guides + +- [Control recycling](dock-control-recycling.md) +- [Custom Dock themes](dock-custom-theme.md) +- [Styling and theming](dock-styling.md) diff --git a/docfx/articles/toc.yml b/docfx/articles/toc.yml index ad5a05e30..acb86fdb7 100644 --- a/docfx/articles/toc.yml +++ b/docfx/articles/toc.yml @@ -144,6 +144,8 @@ href: dock-overlay-customization.md - name: Selector overlay href: dock-selector-overlay.md + - name: Deferred content presentation + href: dock-deferred-content.md - name: Control recycling href: dock-control-recycling.md - name: Proportional StackPanel diff --git a/docfx/index.md b/docfx/index.md index d824cc61a..05dbcd4a7 100644 --- a/docfx/index.md +++ b/docfx/index.md @@ -24,6 +24,7 @@ This design keeps the layout engine reusable and lets you swap view models or fr ```bash dotnet add package Dock.Avalonia +dotnet add package Dock.Controls.DeferredContentControl dotnet add package Dock.Model.Mvvm dotnet add package Dock.Avalonia.Themes.Fluent dotnet add package Dock.Avalonia.Themes.Browser @@ -46,6 +47,7 @@ Recommended path: | [![NuGet](https://img.shields.io/nuget/v/Dock.Avalonia.Themes.Fluent.svg)](https://www.nuget.org/packages/Dock.Avalonia.Themes.Fluent) | [`Dock.Avalonia.Themes.Fluent`](https://www.nuget.org/packages/Dock.Avalonia.Themes.Fluent) | [![Downloads](https://img.shields.io/nuget/dt/Dock.Avalonia.Themes.Fluent.svg)](https://www.nuget.org/packages/Dock.Avalonia.Themes.Fluent) | | [![NuGet](https://img.shields.io/nuget/v/Dock.Avalonia.Themes.Browser.svg)](https://www.nuget.org/packages/Dock.Avalonia.Themes.Browser) | [`Dock.Avalonia.Themes.Browser`](https://www.nuget.org/packages/Dock.Avalonia.Themes.Browser) | [![Downloads](https://img.shields.io/nuget/dt/Dock.Avalonia.Themes.Browser.svg)](https://www.nuget.org/packages/Dock.Avalonia.Themes.Browser) | | [![NuGet](https://img.shields.io/nuget/v/Dock.Avalonia.Themes.Simple.svg)](https://www.nuget.org/packages/Dock.Avalonia.Themes.Simple) | [`Dock.Avalonia.Themes.Simple`](https://www.nuget.org/packages/Dock.Avalonia.Themes.Simple) | [![Downloads](https://img.shields.io/nuget/dt/Dock.Avalonia.Themes.Simple.svg)](https://www.nuget.org/packages/Dock.Avalonia.Themes.Simple) | +| [![NuGet](https://img.shields.io/nuget/v/Dock.Controls.DeferredContentControl.svg)](https://www.nuget.org/packages/Dock.Controls.DeferredContentControl) | [`Dock.Controls.DeferredContentControl`](https://www.nuget.org/packages/Dock.Controls.DeferredContentControl) | [![Downloads](https://img.shields.io/nuget/dt/Dock.Controls.DeferredContentControl.svg)](https://www.nuget.org/packages/Dock.Controls.DeferredContentControl) | | [![NuGet](https://img.shields.io/nuget/v/Dock.Controls.ProportionalStackPanel.svg)](https://www.nuget.org/packages/Dock.Controls.ProportionalStackPanel) | [`Dock.Controls.ProportionalStackPanel`](https://www.nuget.org/packages/Dock.Controls.ProportionalStackPanel) | [![Downloads](https://img.shields.io/nuget/dt/Dock.Controls.ProportionalStackPanel.svg)](https://www.nuget.org/packages/Dock.Controls.ProportionalStackPanel) | | [![NuGet](https://img.shields.io/nuget/v/Dock.Controls.Recycling.svg)](https://www.nuget.org/packages/Dock.Controls.Recycling) | [`Dock.Controls.Recycling`](https://www.nuget.org/packages/Dock.Controls.Recycling) | [![Downloads](https://img.shields.io/nuget/dt/Dock.Controls.Recycling.svg)](https://www.nuget.org/packages/Dock.Controls.Recycling) | | [![NuGet](https://img.shields.io/nuget/v/Dock.Controls.Recycling.Model.svg)](https://www.nuget.org/packages/Dock.Controls.Recycling.Model) | [`Dock.Controls.Recycling.Model`](https://www.nuget.org/packages/Dock.Controls.Recycling.Model) | [![Downloads](https://img.shields.io/nuget/dt/Dock.Controls.Recycling.Model.svg)](https://www.nuget.org/packages/Dock.Controls.Recycling.Model) | @@ -72,6 +74,7 @@ Recommended path: - [Documentation index](articles/README.md) - [Quick start](articles/quick-start.md) - [Document and tool content guide](articles/dock-content-guide.md) +- [Deferred content presentation](articles/dock-deferred-content.md) - [Document and tool ItemsSource guide](articles/dock-itemssource.md) - [API documentation](api/index.md) From 8944d52d5636f30350a473a6a8a210cbe6da04b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Mon, 30 Mar 2026 19:56:03 +0200 Subject: [PATCH 06/18] Support standard content presenter templates --- .../DeferredContentControl.cs | 18 +++++-- .../DeferredContentControlTests.cs | 47 +++++++++++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/Dock.Controls.DeferredContentControl/DeferredContentControl.cs b/src/Dock.Controls.DeferredContentControl/DeferredContentControl.cs index d098fae31..61468bbfd 100644 --- a/src/Dock.Controls.DeferredContentControl/DeferredContentControl.cs +++ b/src/Dock.Controls.DeferredContentControl/DeferredContentControl.cs @@ -30,7 +30,7 @@ public interface IDeferredContentPresentation [TemplatePart("PART_ContentPresenter", typeof(ContentPresenter))] public class DeferredContentControl : ContentControl, IDeferredContentPresentationTarget { - private DeferredContentPresenter? _presenter; + private ContentPresenter? _presenter; private object? _appliedContent; private IDataTemplate? _appliedContentTemplate; private long _requestedVersion; @@ -71,7 +71,7 @@ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { base.OnApplyTemplate(e); - _presenter = e.NameScope.Find("PART_ContentPresenter"); + _presenter = e.NameScope.Find("PART_ContentPresenter"); _appliedVersion = -1; QueueDeferredPresentation(); } @@ -105,7 +105,7 @@ internal void ApplyDeferredPresentation() return; } - _presenter!.ApplyDeferredState(content, contentTemplate); + ApplyDeferredState(_presenter!, content, contentTemplate); _appliedContent = content; _appliedContentTemplate = contentTemplate; _appliedVersion = _requestedVersion; @@ -139,6 +139,18 @@ private bool IsReadyForPresentation() && VisualRoot is not null && ((ILogical)this).IsAttachedToLogicalTree; } + + private static void ApplyDeferredState(ContentPresenter presenter, object? content, IDataTemplate? contentTemplate) + { + if (presenter is DeferredContentPresenter deferredPresenter) + { + deferredPresenter.ApplyDeferredState(content, contentTemplate); + return; + } + + presenter.SetCurrentValue(ContentPresenter.ContentTemplateProperty, contentTemplate); + presenter.SetCurrentValue(ContentPresenter.ContentProperty, content); + } } internal interface IDeferredContentPresentationTarget diff --git a/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs b/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs index 1159a0537..f640cc347 100644 --- a/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs +++ b/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs @@ -2,6 +2,7 @@ using System.Linq; using Avalonia; using Avalonia.Controls; +using Avalonia.Controls.Presenters; using Avalonia.Controls.Templates; using Avalonia.Headless.XUnit; using Avalonia.Styling; @@ -186,6 +187,52 @@ public void DeferredContentControl_Reattaches_And_Applies_Deferred_Content_Chang } } + [AvaloniaFact] + public void DeferredContentControl_Applies_Deferred_Content_With_Standard_ContentPresenter_Template() + { + var template = new CountingTemplate(() => new Border + { + Child = new TextBlock { Text = "FallbackPresenter" } + }); + var control = new DeferredContentControl + { + Template = new FuncControlTemplate((_, nameScope) => + new ContentPresenter + { + Name = "PART_ContentPresenter" + }.RegisterInNameScope(nameScope)), + Content = new object(), + ContentTemplate = template + }; + var window = new Window + { + Width = 800, + Height = 600, + Content = control + }; + + window.Show(); + control.ApplyTemplate(); + window.UpdateLayout(); + + try + { + Assert.NotNull(control.Presenter); + Assert.Null(control.Presenter!.Child); + Assert.Equal(0, template.BuildCount); + + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + + Assert.NotNull(control.Presenter.Child); + Assert.Equal(1, template.BuildCount); + } + finally + { + window.Close(); + } + } + [AvaloniaFact] public void DocumentContentControl_Defers_Document_Template_Materialization() { From 1ec4ac487bafb87261303188d260c8d964b8ca6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Mon, 30 Mar 2026 20:51:23 +0200 Subject: [PATCH 07/18] Batch deferred content realization per frame --- .../DeferredContentControl.cs | 134 ++++++++++++--- ...ock.Controls.DeferredContentControl.csproj | 6 + .../DeferredContentControlTests.cs | 154 ++++++++++++++---- 3 files changed, 239 insertions(+), 55 deletions(-) diff --git a/src/Dock.Controls.DeferredContentControl/DeferredContentControl.cs b/src/Dock.Controls.DeferredContentControl/DeferredContentControl.cs index 61468bbfd..c49b3ef9d 100644 --- a/src/Dock.Controls.DeferredContentControl/DeferredContentControl.cs +++ b/src/Dock.Controls.DeferredContentControl/DeferredContentControl.cs @@ -1,5 +1,6 @@ // Copyright (c) Wiesław Šoltés. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for details. +using System; using System.Collections.Generic; using Avalonia; using Avalonia.Controls; @@ -72,6 +73,10 @@ protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { base.OnApplyTemplate(e); _presenter = e.NameScope.Find("PART_ContentPresenter"); + if (_presenter is IDeferredContentPresentationTarget deferredPresenter) + { + DeferredContentPresentationQueue.Remove(deferredPresenter); + } _appliedVersion = -1; QueueDeferredPresentation(); } @@ -88,11 +93,11 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang } } - internal void ApplyDeferredPresentation() + internal bool ApplyDeferredPresentation() { if (!IsReadyForPresentation()) { - return; + return false; } object? content = Content; @@ -102,18 +107,19 @@ internal void ApplyDeferredPresentation() && ReferenceEquals(_appliedContent, content) && ReferenceEquals(_appliedContentTemplate, contentTemplate)) { - return; + return true; } ApplyDeferredState(_presenter!, content, contentTemplate); _appliedContent = content; _appliedContentTemplate = contentTemplate; _appliedVersion = _requestedVersion; + return true; } - void IDeferredContentPresentationTarget.ApplyDeferredPresentation() + bool IDeferredContentPresentationTarget.ApplyDeferredPresentation() { - ApplyDeferredPresentation(); + return ApplyDeferredPresentation(); } private void QueueDeferredPresentation() @@ -155,7 +161,7 @@ private static void ApplyDeferredState(ContentPresenter presenter, object? conte internal interface IDeferredContentPresentationTarget { - void ApplyDeferredPresentation(); + bool ApplyDeferredPresentation(); } /// @@ -218,16 +224,16 @@ internal void ApplyDeferredState(object? content, IDataTemplate? contentTemplate UpdatePresentedChild(); } - void IDeferredContentPresentationTarget.ApplyDeferredPresentation() + bool IDeferredContentPresentationTarget.ApplyDeferredPresentation() { - ApplyDeferredPresentation(); + return ApplyDeferredPresentation(); } - internal void ApplyDeferredPresentation() + internal bool ApplyDeferredPresentation() { if (!IsReadyForPresentation()) { - return; + return false; } object? content = Content; @@ -237,17 +243,24 @@ internal void ApplyDeferredPresentation() && ReferenceEquals(_appliedContent, content) && ReferenceEquals(_appliedContentTemplate, contentTemplate)) { - return; + return true; } ApplyDeferredState(content, contentTemplate); _appliedContent = content; _appliedContentTemplate = contentTemplate; _appliedVersion = _requestedVersion; + return true; } private void QueueDeferredPresentation() { + if (TemplatedParent is DeferredContentControl) + { + DeferredContentPresentationQueue.Remove(this); + return; + } + if (!IsReadyForPresentation()) { return; @@ -279,7 +292,12 @@ private void UpdatePresentedChild() internal static class DeferredContentPresentationQueue { + internal static bool AutoSchedule { get; set; } = true; + internal static int MaxPresentationsPerFrame { get; set; } = 8; + internal static int PendingCount => s_pending.Count; + private static readonly HashSet s_pending = new(); + private static readonly TimeSpan s_followUpFlushDelay = TimeSpan.FromMilliseconds(1); private static bool s_isScheduled; internal static void Enqueue(IDeferredContentPresentationTarget control) @@ -295,7 +313,10 @@ internal static void Enqueue(IDeferredContentPresentationTarget control) return; } - ScheduleFlush(); + if (AutoSchedule) + { + ScheduleFlush(delayed: false); + } } internal static void Remove(IDeferredContentPresentationTarget control) @@ -307,9 +328,14 @@ internal static void Remove(IDeferredContentPresentationTarget control) } s_pending.Remove(control); + + if (s_pending.Count == 0) + { + CancelScheduling(); + } } - private static void ScheduleFlush() + private static void ScheduleFlush(bool delayed) { if (s_isScheduled) { @@ -317,30 +343,92 @@ private static void ScheduleFlush() } s_isScheduled = true; - Dispatcher.UIThread.Post(Flush, DispatcherPriority.Render); + + if (delayed) + { + DispatcherTimer.RunOnce(FlushScheduledBatch, s_followUpFlushDelay, DispatcherPriority.Render); + return; + } + + Dispatcher.UIThread.Post(FlushScheduledBatch, DispatcherPriority.Render); } - private static void Flush() + internal static void FlushPendingBatchForTesting() { - s_isScheduled = false; + if (!Dispatcher.UIThread.CheckAccess()) + { + Dispatcher.UIThread.Post(FlushPendingBatchForTesting, DispatcherPriority.Render); + return; + } + + FlushScheduledBatch(); + } + + private static void FlushScheduledBatch() + { + if (!Dispatcher.UIThread.CheckAccess()) + { + Dispatcher.UIThread.Post(FlushScheduledBatch, DispatcherPriority.Render); + return; + } if (s_pending.Count == 0) { + CancelScheduling(); return; } - var batch = new IDeferredContentPresentationTarget[s_pending.Count]; - s_pending.CopyTo(batch); - s_pending.Clear(); + s_isScheduled = false; + + var batchSize = Math.Min(Math.Max(1, MaxPresentationsPerFrame), s_pending.Count); + var batch = new IDeferredContentPresentationTarget[batchSize]; + var index = 0; + + foreach (var control in s_pending) + { + batch[index++] = control; + + if (index == batchSize) + { + break; + } + } + + var completed = new IDeferredContentPresentationTarget[index]; + var completedCount = 0; - foreach (IDeferredContentPresentationTarget control in batch) + for (var i = 0; i < index; i++) { - control.ApplyDeferredPresentation(); + if (batch[i].ApplyDeferredPresentation()) + { + completed[completedCount++] = batch[i]; + } } - if (s_pending.Count > 0) + for (var i = 0; i < completedCount; i++) { - ScheduleFlush(); + s_pending.Remove(completed[i]); } + + if (s_pending.Count == 0) + { + CancelScheduling(); + return; + } + + if (AutoSchedule) + { + ScheduleFlush(delayed: true); + } + } + + private static void CancelScheduling() + { + if (!s_isScheduled) + { + return; + } + + s_isScheduled = false; } } diff --git a/src/Dock.Controls.DeferredContentControl/Dock.Controls.DeferredContentControl.csproj b/src/Dock.Controls.DeferredContentControl/Dock.Controls.DeferredContentControl.csproj index 1a63a1607..b85136b2b 100644 --- a/src/Dock.Controls.DeferredContentControl/Dock.Controls.DeferredContentControl.csproj +++ b/src/Dock.Controls.DeferredContentControl/Dock.Controls.DeferredContentControl.csproj @@ -17,6 +17,12 @@ + + + <_Parameter1>Dock.Avalonia.HeadlessTests, PublicKey=002400000480000014010000060200000024000052534131000800000100010063995F354132525DE60CDE9A446E1594E09A106005D58B53010BFCC683490D005DBDF1A8CF59318338D6962D9367B6BE376046C978C2D08A101E4162F0297AD792C4B9F439B70FB83607387832C12A1BF1778A53BE1BF455BA8575819E37D4052FAD3CB1F1CD22B19545FDD06D39C0B9FFF39BAC69340B9AD3311ACD4F5E539D7BE179B86F9BF076F18F8126BB92EF2CDF2428069345F094DC703C346A4365A8956EBA6901A1CC6C4EDB41349BFDE51D40915B4A1DFAED473ADA2EB3B1F179B48DD75C06803C49538025B8404FA4AB30EFF36D6D98701A045E15B881E156BEEEC1BB786F53910C0B6065A16DF9AF276ECE4F9B7E5231C1DACBCBA9D7A32FA1D0 + + + diff --git a/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs b/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs index f640cc347..40615d24e 100644 --- a/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs +++ b/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs @@ -56,6 +56,7 @@ public bool Match(object? data) [AvaloniaFact] public void DeferredContentControl_Defers_Content_Materialization_Until_Dispatcher_Run() { + using var _ = new DeferredBatchLimitScope(autoSchedule: false); var template = new CountingTemplate(() => new Border { Child = new TextBlock { Text = "Deferred" } @@ -82,8 +83,7 @@ public void DeferredContentControl_Defers_Content_Materialization_Until_Dispatch Assert.Null(control.Presenter!.Child); Assert.Equal(0, template.BuildCount); - Dispatcher.UIThread.RunJobs(); - window.UpdateLayout(); + DrainDeferredQueueBatch(window); Assert.NotNull(control.Presenter.Child); Assert.Equal(1, template.BuildCount); @@ -97,6 +97,7 @@ public void DeferredContentControl_Defers_Content_Materialization_Until_Dispatch [AvaloniaFact] public void DeferredContentControl_Batches_Content_Changes_Into_A_Single_Build() { + using var _ = new DeferredBatchLimitScope(autoSchedule: false); var template = new CountingTemplate(() => new TextBlock()); var control = new DeferredContentControl { @@ -121,8 +122,7 @@ public void DeferredContentControl_Batches_Content_Changes_Into_A_Single_Build() Assert.Equal(0, template.BuildCount); Assert.Null(control.Presenter?.Child); - Dispatcher.UIThread.RunJobs(); - window.UpdateLayout(); + DrainDeferredQueueBatch(window); Assert.Equal(1, template.BuildCount); var textBlock = Assert.IsType(control.Presenter!.Child); @@ -137,6 +137,7 @@ public void DeferredContentControl_Batches_Content_Changes_Into_A_Single_Build() [AvaloniaFact] public void DeferredContentControl_Reattaches_And_Applies_Deferred_Content_Changes() { + using var _ = new DeferredBatchLimitScope(autoSchedule: false); var template = new CountingTemplate(() => new TextBlock()); var control = new DeferredContentControl { @@ -152,8 +153,7 @@ public void DeferredContentControl_Reattaches_And_Applies_Deferred_Content_Chang firstWindow.Show(); control.ApplyTemplate(); - Dispatcher.UIThread.RunJobs(); - firstWindow.UpdateLayout(); + DrainDeferredQueueBatch(firstWindow); Assert.Equal(1, template.BuildCount); @@ -174,8 +174,7 @@ public void DeferredContentControl_Reattaches_And_Applies_Deferred_Content_Chang try { - Dispatcher.UIThread.RunJobs(); - secondWindow.UpdateLayout(); + DrainDeferredQueueBatch(secondWindow); Assert.Equal(2, template.BuildCount); var textBlock = Assert.IsType(control.Presenter!.Child); @@ -190,6 +189,7 @@ public void DeferredContentControl_Reattaches_And_Applies_Deferred_Content_Chang [AvaloniaFact] public void DeferredContentControl_Applies_Deferred_Content_With_Standard_ContentPresenter_Template() { + using var _ = new DeferredBatchLimitScope(autoSchedule: false); var template = new CountingTemplate(() => new Border { Child = new TextBlock { Text = "FallbackPresenter" } @@ -221,8 +221,7 @@ public void DeferredContentControl_Applies_Deferred_Content_With_Standard_Conten Assert.Null(control.Presenter!.Child); Assert.Equal(0, template.BuildCount); - Dispatcher.UIThread.RunJobs(); - window.UpdateLayout(); + DrainDeferredQueueBatch(window); Assert.NotNull(control.Presenter.Child); Assert.Equal(1, template.BuildCount); @@ -233,9 +232,74 @@ public void DeferredContentControl_Applies_Deferred_Content_With_Standard_Conten } } + [AvaloniaFact] + public void DeferredContentControl_Limits_Realization_Batch_Per_Render_Tick() + { + var templates = Enumerable.Range(0, 5) + .Select(_ => new CountingTemplate(() => new TextBlock())) + .ToArray(); + var panel = new StackPanel(); + var controls = templates + .Select(template => new DeferredContentControl + { + ContentTemplate = template + }) + .ToArray(); + + foreach (var control in controls) + { + panel.Children.Add(control); + } + + var window = new Window + { + Width = 800, + Height = 600, + Content = panel + }; + + using var _ = new DeferredBatchLimitScope(limit: 2, autoSchedule: false); + + window.Show(); + + foreach (var control in controls) + { + control.ApplyTemplate(); + } + + window.UpdateLayout(); + + try + { + Assert.Equal(0, templates.Sum(template => template.BuildCount)); + + foreach (var control in controls) + { + control.Content = new object(); + } + + Assert.Equal(5, DeferredContentPresentationQueue.PendingCount); + + DrainDeferredQueueBatch(window); + Assert.Equal(2, templates.Sum(template => template.BuildCount)); + + DrainDeferredQueueBatch(window); + Assert.Equal(4, templates.Sum(template => template.BuildCount)); + + DrainDeferredQueueBatch(window); + Assert.Equal(5, templates.Sum(template => template.BuildCount)); + Assert.All(controls, control => Assert.NotNull(control.Presenter?.Child)); + } + finally + { + window.Close(); + } + } + [AvaloniaFact] public void DocumentContentControl_Defers_Document_Template_Materialization() { + using var _ = new DeferredBatchLimitScope(autoSchedule: false); var buildCount = 0; var document = new Document { @@ -265,10 +329,9 @@ public void DocumentContentControl_Defers_Document_Template_Materialization() Assert.Equal(0, buildCount); Assert.DoesNotContain(control.GetVisualDescendants(), visual => visual is TextBlock textBlock && textBlock.Text == "Document"); - Dispatcher.UIThread.RunJobs(); - window.UpdateLayout(); + DrainDeferredQueueBatch(window); - Assert.Equal(1, buildCount); + Assert.InRange(buildCount, 1, 2); Assert.Contains(control.GetVisualDescendants(), visual => visual is TextBlock textBlock && textBlock.Text == "Document"); } finally @@ -280,6 +343,7 @@ public void DocumentContentControl_Defers_Document_Template_Materialization() [AvaloniaFact] public void ToolContentControl_Defers_Tool_Template_Materialization() { + using var _ = new DeferredBatchLimitScope(autoSchedule: false); var buildCount = 0; var tool = new Tool { @@ -309,10 +373,9 @@ public void ToolContentControl_Defers_Tool_Template_Materialization() Assert.Equal(0, buildCount); Assert.DoesNotContain(control.GetVisualDescendants(), visual => visual is TextBlock textBlock && textBlock.Text == "Tool"); - Dispatcher.UIThread.RunJobs(); - window.UpdateLayout(); + DrainDeferredQueueBatch(window); - Assert.Equal(1, buildCount); + Assert.InRange(buildCount, 1, 2); Assert.Contains(control.GetVisualDescendants(), visual => visual is TextBlock textBlock && textBlock.Text == "Tool"); } finally @@ -324,6 +387,7 @@ public void ToolContentControl_Defers_Tool_Template_Materialization() [AvaloniaFact] public void DocumentControl_Defers_Active_Document_Template_Materialization() { + using var _ = new DeferredBatchLimitScope(autoSchedule: false); var factory = new Factory(); var buildCount = 0; var dock = new DocumentDock @@ -359,10 +423,9 @@ public void DocumentControl_Defers_Active_Document_Template_Materialization() Assert.Equal(0, buildCount); Assert.Null(presenterHost.Presenter?.Child); - Dispatcher.UIThread.RunJobs(); - window.UpdateLayout(); + DrainDeferredQueueBatch(window); - Assert.Equal(1, buildCount); + Assert.InRange(buildCount, 1, 2); var textBlock = Assert.IsType(presenterHost.Presenter!.Child); Assert.Equal("DocumentControl", textBlock.Text); } @@ -375,6 +438,7 @@ public void DocumentControl_Defers_Active_Document_Template_Materialization() [AvaloniaFact] public void ToolControl_Defers_Active_Tool_Template_Materialization() { + using var _ = new DeferredBatchLimitScope(autoSchedule: false); var factory = new Factory(); var buildCount = 0; var dock = new ToolDock @@ -409,10 +473,9 @@ public void ToolControl_Defers_Active_Tool_Template_Materialization() Assert.Equal(0, buildCount); Assert.Null(presenterHost.Presenter?.Child); - Dispatcher.UIThread.RunJobs(); - window.UpdateLayout(); + DrainDeferredQueueBatch(window); - Assert.Equal(1, buildCount); + Assert.InRange(buildCount, 1, 2); var textBlock = Assert.IsType(presenterHost.Presenter!.Child); Assert.Equal("ToolControl", textBlock.Text); } @@ -425,6 +488,7 @@ public void ToolControl_Defers_Active_Tool_Template_Materialization() [AvaloniaFact] public void MdiDocumentWindow_Defers_Window_Content_Materialization() { + using var _ = new DeferredBatchLimitScope(autoSchedule: false); var buildCount = 0; var document = new Document { @@ -450,8 +514,7 @@ public void MdiDocumentWindow_Defers_Window_Content_Materialization() Assert.Equal(0, buildCount); Assert.Null(presenterHost.Presenter?.Child); - Dispatcher.UIThread.RunJobs(); - window.UpdateLayout(); + DrainDeferredQueueBatch(window); Assert.Equal(1, buildCount); var textBlock = Assert.IsType(presenterHost.Presenter!.Child); @@ -466,6 +529,7 @@ public void MdiDocumentWindow_Defers_Window_Content_Materialization() [AvaloniaFact] public void SplitViewDockControl_Defers_Pane_And_Content_Materialization() { + using var _ = new DeferredBatchLimitScope(autoSchedule: false); var factory = new Factory(); var paneDockable = new Tool { @@ -505,8 +569,7 @@ public void SplitViewDockControl_Defers_Pane_And_Content_Materialization() Assert.DoesNotContain(control.GetVisualDescendants(), visual => visual is TextBlock textBlock && textBlock.Text == "SplitPane"); Assert.DoesNotContain(control.GetVisualDescendants(), visual => visual is TextBlock textBlock && textBlock.Text == "SplitContent"); - Dispatcher.UIThread.RunJobs(); - window.UpdateLayout(); + DrainDeferredQueueBatch(window); Assert.Equal(1, paneBuildCount); Assert.Equal(1, contentBuildCount); @@ -522,6 +585,7 @@ public void SplitViewDockControl_Defers_Pane_And_Content_Materialization() [AvaloniaFact] public void PinnedDockControl_Defers_Pinned_Dock_Materialization() { + using var _ = new DeferredBatchLimitScope(autoSchedule: false); var factory = new Factory(); var root = new RootDock { @@ -551,8 +615,7 @@ public void PinnedDockControl_Defers_Pinned_Dock_Materialization() Assert.Equal(0, buildCount); Assert.DoesNotContain(control.GetVisualDescendants(), visual => visual is TextBlock textBlock && textBlock.Text == "PinnedDock"); - Dispatcher.UIThread.RunJobs(); - window.UpdateLayout(); + DrainDeferredQueueBatch(window); Assert.Equal(1, buildCount); Assert.Contains(control.GetVisualDescendants(), visual => visual is TextBlock textBlock && textBlock.Text == "PinnedDock"); @@ -566,6 +629,7 @@ public void PinnedDockControl_Defers_Pinned_Dock_Materialization() [AvaloniaFact] public void DockControl_Defers_Layout_Materialization() { + using var _ = new DeferredBatchLimitScope(autoSchedule: false); var buildCount = 0; var root = new RootDock { @@ -587,8 +651,7 @@ public void DockControl_Defers_Layout_Materialization() Assert.Equal(0, buildCount); Assert.Null(presenterHost.Presenter?.Child); - Dispatcher.UIThread.RunJobs(); - window.UpdateLayout(); + DrainDeferredQueueBatch(window); Assert.Equal(1, buildCount); var textBlock = Assert.IsType(presenterHost.Presenter!.Child); @@ -603,6 +666,7 @@ public void DockControl_Defers_Layout_Materialization() [AvaloniaFact] public void RootDockControl_Defers_Active_Dock_Materialization() { + using var _ = new DeferredBatchLimitScope(autoSchedule: false); var factory = new Factory(); var buildCount = 0; var activeDock = new DocumentDock @@ -630,8 +694,7 @@ public void RootDockControl_Defers_Active_Dock_Materialization() Assert.Equal(0, buildCount); Assert.Null(presenterHost.Presenter?.Child); - Dispatcher.UIThread.RunJobs(); - window.UpdateLayout(); + DrainDeferredQueueBatch(window); Assert.Equal(1, buildCount); var textBlock = Assert.IsType(presenterHost.Presenter!.Child); @@ -793,4 +856,31 @@ private static Window ShowInWindow(Control control, params IStyle[] styles) control.UpdateLayout(); return window; } + + private static void DrainDeferredQueueBatch(Window window) + { + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + DeferredContentPresentationQueue.FlushPendingBatchForTesting(); + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + } + + private sealed class DeferredBatchLimitScope : IDisposable + { + private readonly int _previousLimit = DeferredContentPresentationQueue.MaxPresentationsPerFrame; + private readonly bool _previousAutoSchedule = DeferredContentPresentationQueue.AutoSchedule; + + public DeferredBatchLimitScope(int limit = int.MaxValue, bool autoSchedule = true) + { + DeferredContentPresentationQueue.MaxPresentationsPerFrame = limit; + DeferredContentPresentationQueue.AutoSchedule = autoSchedule; + } + + public void Dispose() + { + DeferredContentPresentationQueue.MaxPresentationsPerFrame = _previousLimit; + DeferredContentPresentationQueue.AutoSchedule = _previousAutoSchedule; + } + } } From f580fabf3455b4cd793f717a052bd24a07e00436 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Mon, 30 Mar 2026 21:05:24 +0200 Subject: [PATCH 08/18] Prevent deferred queue starvation --- .../DeferredContentControl.cs | 60 +++++++-------- .../DeferredContentControlTests.cs | 74 +++++++++++++++++++ 2 files changed, 104 insertions(+), 30 deletions(-) diff --git a/src/Dock.Controls.DeferredContentControl/DeferredContentControl.cs b/src/Dock.Controls.DeferredContentControl/DeferredContentControl.cs index c49b3ef9d..1495ac1e3 100644 --- a/src/Dock.Controls.DeferredContentControl/DeferredContentControl.cs +++ b/src/Dock.Controls.DeferredContentControl/DeferredContentControl.cs @@ -294,9 +294,10 @@ internal static class DeferredContentPresentationQueue { internal static bool AutoSchedule { get; set; } = true; internal static int MaxPresentationsPerFrame { get; set; } = 8; - internal static int PendingCount => s_pending.Count; + internal static int PendingCount => s_pendingLookup.Count; - private static readonly HashSet s_pending = new(); + private static readonly LinkedList s_pending = new(); + private static readonly Dictionary> s_pendingLookup = new(); private static readonly TimeSpan s_followUpFlushDelay = TimeSpan.FromMilliseconds(1); private static bool s_isScheduled; @@ -308,11 +309,14 @@ internal static void Enqueue(IDeferredContentPresentationTarget control) return; } - if (!s_pending.Add(control)) + if (s_pendingLookup.ContainsKey(control)) { return; } + var node = s_pending.AddLast(control); + s_pendingLookup.Add(control, node); + if (AutoSchedule) { ScheduleFlush(delayed: false); @@ -327,9 +331,9 @@ internal static void Remove(IDeferredContentPresentationTarget control) return; } - s_pending.Remove(control); + RemovePending(control); - if (s_pending.Count == 0) + if (s_pendingLookup.Count == 0) { CancelScheduling(); } @@ -372,7 +376,7 @@ private static void FlushScheduledBatch() return; } - if (s_pending.Count == 0) + if (s_pendingLookup.Count == 0) { CancelScheduling(); return; @@ -380,37 +384,23 @@ private static void FlushScheduledBatch() s_isScheduled = false; - var batchSize = Math.Min(Math.Max(1, MaxPresentationsPerFrame), s_pending.Count); - var batch = new IDeferredContentPresentationTarget[batchSize]; - var index = 0; - - foreach (var control in s_pending) - { - batch[index++] = control; - - if (index == batchSize) - { - break; - } - } - - var completed = new IDeferredContentPresentationTarget[index]; + var batchSize = Math.Min(Math.Max(1, MaxPresentationsPerFrame), s_pendingLookup.Count); + var node = s_pending.First; var completedCount = 0; - for (var i = 0; i < index; i++) + while (node is not null && completedCount < batchSize) { - if (batch[i].ApplyDeferredPresentation()) + var current = node; + node = node.Next; + + if (current.Value.ApplyDeferredPresentation()) { - completed[completedCount++] = batch[i]; + RemovePending(current.Value); + completedCount++; } } - for (var i = 0; i < completedCount; i++) - { - s_pending.Remove(completed[i]); - } - - if (s_pending.Count == 0) + if (s_pendingLookup.Count == 0) { CancelScheduling(); return; @@ -431,4 +421,14 @@ private static void CancelScheduling() s_isScheduled = false; } + + private static void RemovePending(IDeferredContentPresentationTarget control) + { + if (!s_pendingLookup.Remove(control, out var node)) + { + return; + } + + s_pending.Remove(node); + } } diff --git a/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs b/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs index 40615d24e..3667b1058 100644 --- a/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs +++ b/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs @@ -53,6 +53,19 @@ public bool Match(object? data) } } + private sealed class TestDeferredTarget : IDeferredContentPresentationTarget + { + public bool CanApply { get; set; } + + public int ApplyCount { get; private set; } + + public bool ApplyDeferredPresentation() + { + ApplyCount++; + return CanApply; + } + } + [AvaloniaFact] public void DeferredContentControl_Defers_Content_Materialization_Until_Dispatcher_Run() { @@ -296,6 +309,67 @@ public void DeferredContentControl_Limits_Realization_Batch_Per_Render_Tick() } } + [AvaloniaFact] + public void DeferredContentQueue_Does_Not_Starve_Ready_Targets_Behind_NotReady_Ones() + { + using var _ = new DeferredBatchLimitScope(limit: 2, autoSchedule: false); + + var firstBlocked = new TestDeferredTarget(); + var secondBlocked = new TestDeferredTarget(); + var firstReady = new TestDeferredTarget { CanApply = true }; + var secondReady = new TestDeferredTarget { CanApply = true }; + var thirdReady = new TestDeferredTarget { CanApply = true }; + var targets = new[] + { + firstBlocked, + secondBlocked, + firstReady, + secondReady, + thirdReady + }; + + try + { + foreach (var target in targets) + { + DeferredContentPresentationQueue.Enqueue(target); + } + + Assert.Equal(5, DeferredContentPresentationQueue.PendingCount); + + DeferredContentPresentationQueue.FlushPendingBatchForTesting(); + + Assert.Equal(1, firstBlocked.ApplyCount); + Assert.Equal(1, secondBlocked.ApplyCount); + Assert.Equal(1, firstReady.ApplyCount); + Assert.Equal(1, secondReady.ApplyCount); + Assert.Equal(0, thirdReady.ApplyCount); + Assert.Equal(3, DeferredContentPresentationQueue.PendingCount); + + firstBlocked.CanApply = true; + secondBlocked.CanApply = true; + + DeferredContentPresentationQueue.FlushPendingBatchForTesting(); + + Assert.Equal(2, firstBlocked.ApplyCount); + Assert.Equal(2, secondBlocked.ApplyCount); + Assert.Equal(0, thirdReady.ApplyCount); + Assert.Equal(1, DeferredContentPresentationQueue.PendingCount); + + DeferredContentPresentationQueue.FlushPendingBatchForTesting(); + + Assert.Equal(1, thirdReady.ApplyCount); + Assert.Equal(0, DeferredContentPresentationQueue.PendingCount); + } + finally + { + foreach (var target in targets) + { + DeferredContentPresentationQueue.Remove(target); + } + } + } + [AvaloniaFact] public void DocumentContentControl_Defers_Document_Template_Materialization() { From 1fd6dc8bbde42aab1a03ba972694f7dedb911b38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Mon, 30 Mar 2026 22:10:39 +0200 Subject: [PATCH 09/18] Add configurable deferred presentation budgets --- .../DeferredContentControl.cs | 88 ++++++++++++++- .../DeferredContentControlTests.cs | 102 +++++++++++++++--- 2 files changed, 177 insertions(+), 13 deletions(-) diff --git a/src/Dock.Controls.DeferredContentControl/DeferredContentControl.cs b/src/Dock.Controls.DeferredContentControl/DeferredContentControl.cs index 1495ac1e3..8d2b5f786 100644 --- a/src/Dock.Controls.DeferredContentControl/DeferredContentControl.cs +++ b/src/Dock.Controls.DeferredContentControl/DeferredContentControl.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for details. using System; using System.Collections.Generic; +using System.Diagnostics; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Metadata; @@ -25,6 +26,55 @@ public interface IDeferredContentPresentation bool DeferContentPresentation { get; } } +/// +/// Defines how the shared deferred presentation queue limits work done in a single dispatcher pass. +/// +public enum DeferredContentPresentationBudgetMode +{ + /// + /// Limits work by the number of realized targets in a single dispatcher pass. + /// + ItemCount, + + /// + /// Limits work by elapsed realization time in a single dispatcher pass. + /// + RealizationTime +} + +/// +/// Configures the shared deferred presentation queue used by all deferred content hosts. +/// +public static class DeferredContentPresentationSettings +{ + /// + /// Gets or sets the active budget mode for the shared deferred presentation queue. + /// + public static DeferredContentPresentationBudgetMode BudgetMode + { + get => DeferredContentPresentationQueue.BudgetMode; + set => DeferredContentPresentationQueue.BudgetMode = value; + } + + /// + /// Gets or sets the maximum number of targets that can be realized in one dispatcher pass when is . + /// + public static int MaxPresentationsPerPass + { + get => DeferredContentPresentationQueue.MaxPresentationsPerFrame; + set => DeferredContentPresentationQueue.MaxPresentationsPerFrame = value > 0 ? value : 1; + } + + /// + /// Gets or sets the maximum elapsed realization time allowed in one dispatcher pass when is . + /// + public static TimeSpan MaxRealizationTimePerPass + { + get => DeferredContentPresentationQueue.MaxRealizationDuration; + set => DeferredContentPresentationQueue.MaxRealizationDuration = value >= TimeSpan.Zero ? value : TimeSpan.Zero; + } +} + /// /// A that batches content materialization onto the next dispatcher pass. /// @@ -292,8 +342,10 @@ private void UpdatePresentedChild() internal static class DeferredContentPresentationQueue { + internal static DeferredContentPresentationBudgetMode BudgetMode { get; set; } = DeferredContentPresentationBudgetMode.ItemCount; internal static bool AutoSchedule { get; set; } = true; internal static int MaxPresentationsPerFrame { get; set; } = 8; + internal static TimeSpan MaxRealizationDuration { get; set; } = TimeSpan.FromMilliseconds(10); internal static int PendingCount => s_pendingLookup.Count; private static readonly LinkedList s_pending = new(); @@ -385,10 +437,13 @@ private static void FlushScheduledBatch() s_isScheduled = false; var batchSize = Math.Min(Math.Max(1, MaxPresentationsPerFrame), s_pendingLookup.Count); + var stopwatch = BudgetMode == DeferredContentPresentationBudgetMode.RealizationTime + ? Stopwatch.StartNew() + : null; var node = s_pending.First; var completedCount = 0; - while (node is not null && completedCount < batchSize) + while (node is not null) { var current = node; node = node.Next; @@ -398,6 +453,15 @@ private static void FlushScheduledBatch() RemovePending(current.Value); completedCount++; } + else + { + MovePendingToBack(current); + } + + if (!ShouldContinueProcessing(completedCount, batchSize, stopwatch)) + { + break; + } } if (s_pendingLookup.Count == 0) @@ -431,4 +495,26 @@ private static void RemovePending(IDeferredContentPresentationTarget control) s_pending.Remove(node); } + + private static void MovePendingToBack(LinkedListNode node) + { + if (node.List != s_pending || node.Next is null) + { + return; + } + + s_pending.Remove(node); + var moved = s_pending.AddLast(node.Value); + s_pendingLookup[node.Value] = moved; + } + + private static bool ShouldContinueProcessing(int completedCount, int batchSize, Stopwatch? stopwatch) + { + return BudgetMode switch + { + DeferredContentPresentationBudgetMode.ItemCount => completedCount < batchSize, + DeferredContentPresentationBudgetMode.RealizationTime => stopwatch is not null && stopwatch.Elapsed < MaxRealizationDuration, + _ => false + }; + } } diff --git a/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs b/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs index 3667b1058..fbb5df057 100644 --- a/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs +++ b/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Threading; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Presenters; @@ -55,14 +56,29 @@ public bool Match(object? data) private sealed class TestDeferredTarget : IDeferredContentPresentationTarget { + public TimeSpan ApplyDelay { get; set; } + public bool CanApply { get; set; } public int ApplyCount { get; private set; } + public int SuccessfulApplyCount { get; private set; } + public bool ApplyDeferredPresentation() { + if (ApplyDelay > TimeSpan.Zero) + { + Thread.Sleep(ApplyDelay); + } + ApplyCount++; - return CanApply; + if (CanApply) + { + SuccessfulApplyCount++; + return true; + } + + return false; } } @@ -341,24 +357,76 @@ public void DeferredContentQueue_Does_Not_Starve_Ready_Targets_Behind_NotReady_O Assert.Equal(1, firstBlocked.ApplyCount); Assert.Equal(1, secondBlocked.ApplyCount); - Assert.Equal(1, firstReady.ApplyCount); - Assert.Equal(1, secondReady.ApplyCount); - Assert.Equal(0, thirdReady.ApplyCount); + Assert.Equal(1, firstReady.SuccessfulApplyCount); + Assert.Equal(1, secondReady.SuccessfulApplyCount); + Assert.Equal(0, thirdReady.SuccessfulApplyCount); Assert.Equal(3, DeferredContentPresentationQueue.PendingCount); + Assert.Equal(2, targets.Sum(target => target.SuccessfulApplyCount)); firstBlocked.CanApply = true; secondBlocked.CanApply = true; DeferredContentPresentationQueue.FlushPendingBatchForTesting(); - Assert.Equal(2, firstBlocked.ApplyCount); - Assert.Equal(2, secondBlocked.ApplyCount); - Assert.Equal(0, thirdReady.ApplyCount); + Assert.Equal(4, targets.Sum(target => target.SuccessfulApplyCount)); Assert.Equal(1, DeferredContentPresentationQueue.PendingCount); DeferredContentPresentationQueue.FlushPendingBatchForTesting(); - Assert.Equal(1, thirdReady.ApplyCount); + Assert.Equal(5, targets.Sum(target => target.SuccessfulApplyCount)); + Assert.Equal(0, DeferredContentPresentationQueue.PendingCount); + } + finally + { + foreach (var target in targets) + { + DeferredContentPresentationQueue.Remove(target); + } + } + } + + [AvaloniaFact] + public void DeferredContentQueue_Limits_Realization_Batch_By_Time_Budget() + { + using var _ = new DeferredBatchLimitScope( + budgetMode: DeferredContentPresentationBudgetMode.RealizationTime, + maxRealizationTimePerPass: TimeSpan.FromMilliseconds(10), + autoSchedule: false); + + var slowReady = new TestDeferredTarget + { + CanApply = true, + ApplyDelay = TimeSpan.FromMilliseconds(25) + }; + var firstReady = new TestDeferredTarget { CanApply = true }; + var secondReady = new TestDeferredTarget { CanApply = true }; + var targets = new[] + { + slowReady, + firstReady, + secondReady + }; + + try + { + foreach (var target in targets) + { + DeferredContentPresentationQueue.Enqueue(target); + } + + Assert.Equal(3, DeferredContentPresentationQueue.PendingCount); + + DeferredContentPresentationQueue.FlushPendingBatchForTesting(); + + Assert.Equal(1, slowReady.SuccessfulApplyCount); + Assert.Equal(0, firstReady.SuccessfulApplyCount); + Assert.Equal(0, secondReady.SuccessfulApplyCount); + Assert.Equal(2, DeferredContentPresentationQueue.PendingCount); + + DeferredContentPresentationQueue.FlushPendingBatchForTesting(); + + Assert.Equal(1, firstReady.SuccessfulApplyCount); + Assert.Equal(1, secondReady.SuccessfulApplyCount); Assert.Equal(0, DeferredContentPresentationQueue.PendingCount); } finally @@ -942,18 +1010,28 @@ private static void DrainDeferredQueueBatch(Window window) private sealed class DeferredBatchLimitScope : IDisposable { - private readonly int _previousLimit = DeferredContentPresentationQueue.MaxPresentationsPerFrame; + private readonly DeferredContentPresentationBudgetMode _previousBudgetMode = DeferredContentPresentationSettings.BudgetMode; + private readonly int _previousLimit = DeferredContentPresentationSettings.MaxPresentationsPerPass; + private readonly TimeSpan _previousMaxRealizationTime = DeferredContentPresentationSettings.MaxRealizationTimePerPass; private readonly bool _previousAutoSchedule = DeferredContentPresentationQueue.AutoSchedule; - public DeferredBatchLimitScope(int limit = int.MaxValue, bool autoSchedule = true) + public DeferredBatchLimitScope( + DeferredContentPresentationBudgetMode budgetMode = DeferredContentPresentationBudgetMode.ItemCount, + int limit = int.MaxValue, + TimeSpan? maxRealizationTimePerPass = null, + bool autoSchedule = true) { - DeferredContentPresentationQueue.MaxPresentationsPerFrame = limit; + DeferredContentPresentationSettings.BudgetMode = budgetMode; + DeferredContentPresentationSettings.MaxPresentationsPerPass = limit; + DeferredContentPresentationSettings.MaxRealizationTimePerPass = maxRealizationTimePerPass ?? TimeSpan.FromMilliseconds(10); DeferredContentPresentationQueue.AutoSchedule = autoSchedule; } public void Dispose() { - DeferredContentPresentationQueue.MaxPresentationsPerFrame = _previousLimit; + DeferredContentPresentationSettings.BudgetMode = _previousBudgetMode; + DeferredContentPresentationSettings.MaxPresentationsPerPass = _previousLimit; + DeferredContentPresentationSettings.MaxRealizationTimePerPass = _previousMaxRealizationTime; DeferredContentPresentationQueue.AutoSchedule = _previousAutoSchedule; } } From a719c5c71b7e27f29b0614ce5b0e5349797fc6bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Mon, 30 Mar 2026 22:10:45 +0200 Subject: [PATCH 10/18] Document deferred presentation budget settings --- docfx/articles/dock-deferred-content.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docfx/articles/dock-deferred-content.md b/docfx/articles/dock-deferred-content.md index 998555534..bf7be3560 100644 --- a/docfx/articles/dock-deferred-content.md +++ b/docfx/articles/dock-deferred-content.md @@ -38,6 +38,26 @@ Use `DeferredContentControl` in places where a Dock theme would otherwise materi This keeps the requested content and template, then applies them on the next dispatcher frame. Multiple content changes before the flush are batched into one materialization pass. +## Configuring the queue budget + +The deferred queue is shared by all deferred hosts. You can configure it globally through `DeferredContentPresentationSettings`. + +Count-based budget: + +```csharp +DeferredContentPresentationSettings.BudgetMode = DeferredContentPresentationBudgetMode.ItemCount; +DeferredContentPresentationSettings.MaxPresentationsPerPass = 3; +``` + +Time-based budget: + +```csharp +DeferredContentPresentationSettings.BudgetMode = DeferredContentPresentationBudgetMode.RealizationTime; +DeferredContentPresentationSettings.MaxRealizationTimePerPass = TimeSpan.FromMilliseconds(10); +``` + +Use item-count budgeting when you want predictable batching by host count. Use time-based budgeting when you want to cap the total realization time spent in one dispatcher pass. + ## Using DeferredContentPresenter Some Dock templates require the named part to remain a `ContentPresenter`. In those cases use `DeferredContentPresenter` instead of `DeferredContentControl`. From 16b5eca4ae8f3084eec64efd3d30ed551da768fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Mon, 30 Mar 2026 22:22:31 +0200 Subject: [PATCH 11/18] Bound deferred flushes to one queue scan --- .../DeferredContentControl.cs | 5 ++- .../DeferredContentControlTests.cs | 45 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/Dock.Controls.DeferredContentControl/DeferredContentControl.cs b/src/Dock.Controls.DeferredContentControl/DeferredContentControl.cs index 8d2b5f786..e554767b7 100644 --- a/src/Dock.Controls.DeferredContentControl/DeferredContentControl.cs +++ b/src/Dock.Controls.DeferredContentControl/DeferredContentControl.cs @@ -436,17 +436,20 @@ private static void FlushScheduledBatch() s_isScheduled = false; + var pendingCountAtStart = s_pendingLookup.Count; var batchSize = Math.Min(Math.Max(1, MaxPresentationsPerFrame), s_pendingLookup.Count); var stopwatch = BudgetMode == DeferredContentPresentationBudgetMode.RealizationTime ? Stopwatch.StartNew() : null; var node = s_pending.First; var completedCount = 0; + var processedCount = 0; - while (node is not null) + while (node is not null && processedCount < pendingCountAtStart) { var current = node; node = node.Next; + processedCount++; if (current.Value.ApplyDeferredPresentation()) { diff --git a/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs b/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs index fbb5df057..24ad9da73 100644 --- a/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs +++ b/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs @@ -60,6 +60,8 @@ private sealed class TestDeferredTarget : IDeferredContentPresentationTarget public bool CanApply { get; set; } + public int MaxApplyCountBeforeThrow { get; set; } = int.MaxValue; + public int ApplyCount { get; private set; } public int SuccessfulApplyCount { get; private set; } @@ -72,6 +74,11 @@ public bool ApplyDeferredPresentation() } ApplyCount++; + if (ApplyCount > MaxApplyCountBeforeThrow) + { + throw new InvalidOperationException("Target was revisited within the same deferred flush."); + } + if (CanApply) { SuccessfulApplyCount++; @@ -385,6 +392,44 @@ public void DeferredContentQueue_Does_Not_Starve_Ready_Targets_Behind_NotReady_O } } + [AvaloniaFact] + public void DeferredContentQueue_Does_Not_Revisit_NotReady_Targets_Within_Single_ItemCount_Pass() + { + using var _ = new DeferredBatchLimitScope(limit: 2, autoSchedule: false); + + var firstBlocked = new TestDeferredTarget { MaxApplyCountBeforeThrow = 1 }; + var secondBlocked = new TestDeferredTarget { MaxApplyCountBeforeThrow = 1 }; + var thirdBlocked = new TestDeferredTarget { MaxApplyCountBeforeThrow = 1 }; + var targets = new[] + { + firstBlocked, + secondBlocked, + thirdBlocked + }; + + try + { + foreach (var target in targets) + { + DeferredContentPresentationQueue.Enqueue(target); + } + + Assert.Equal(3, DeferredContentPresentationQueue.PendingCount); + + DeferredContentPresentationQueue.FlushPendingBatchForTesting(); + + Assert.All(targets, target => Assert.Equal(1, target.ApplyCount)); + Assert.Equal(3, DeferredContentPresentationQueue.PendingCount); + } + finally + { + foreach (var target in targets) + { + DeferredContentPresentationQueue.Remove(target); + } + } + } + [AvaloniaFact] public void DeferredContentQueue_Limits_Realization_Batch_By_Time_Budget() { From f9cbbd27b7bcc38e68d2b1cdba9a72b5a09a103c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 31 Mar 2026 21:48:13 +0200 Subject: [PATCH 12/18] Detach removed windows from owner graph --- src/Dock.Model/FactoryBase.Window.cs | 11 ++++++++ .../FactoryWindowManagementTests.cs | 28 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/Dock.Model/FactoryBase.Window.cs b/src/Dock.Model/FactoryBase.Window.cs index ee1e77832..5535442d5 100644 --- a/src/Dock.Model/FactoryBase.Window.cs +++ b/src/Dock.Model/FactoryBase.Window.cs @@ -61,9 +61,20 @@ public virtual void RemoveWindow(IDockWindow window) { if (window.Owner is IRootDock rootDock) { + var layout = window.Layout; window.Exit(); rootDock.Windows?.Remove(window); OnWindowRemoved(window); + + if (layout is not null && ReferenceEquals(layout.Window, window)) + { + layout.Window = null; + } + + window.ParentWindow = null; + window.Owner = null; + window.Factory = null; + window.Layout = null; } } diff --git a/tests/Dock.Avalonia.HeadlessTests/FactoryWindowManagementTests.cs b/tests/Dock.Avalonia.HeadlessTests/FactoryWindowManagementTests.cs index 46ec1c6e0..51adbbadd 100644 --- a/tests/Dock.Avalonia.HeadlessTests/FactoryWindowManagementTests.cs +++ b/tests/Dock.Avalonia.HeadlessTests/FactoryWindowManagementTests.cs @@ -55,4 +55,32 @@ public void RemoveWindow_Removes_From_Root() Assert.Empty(root.Windows!); } + + [AvaloniaFact] + public void RemoveWindow_Clears_Window_Owner_And_Layout_Graph() + { + var factory = new Factory(); + var workspaceRoot = new RootDock { Windows = factory.CreateList() }; + workspaceRoot.Factory = factory; + + var floatingRoot = new RootDock { Factory = factory }; + var parentWindow = new DockWindow(); + var window = new DockWindow + { + Layout = floatingRoot, + ParentWindow = parentWindow + }; + + floatingRoot.Window = window; + factory.AddWindow(workspaceRoot, window); + + factory.RemoveWindow(window); + + Assert.Empty(workspaceRoot.Windows!); + Assert.Null(window.Owner); + Assert.Null(window.Factory); + Assert.Null(window.Layout); + Assert.Null(window.ParentWindow); + Assert.Null(floatingRoot.Window); + } } From 9f4ef42a33af98c2f53f8aed19426069d0165baf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Wed, 1 Apr 2026 14:05:00 +0200 Subject: [PATCH 13/18] Add scoped deferred presentation timelines --- .../DeferredContentControl.cs | 705 +++++++++++++++--- .../DeferredContentControlTests.cs | 559 +++++++++++++- .../HostWindowThemeChangeTests.cs | 7 + 3 files changed, 1166 insertions(+), 105 deletions(-) diff --git a/src/Dock.Controls.DeferredContentControl/DeferredContentControl.cs b/src/Dock.Controls.DeferredContentControl/DeferredContentControl.cs index e554767b7..e295b174c 100644 --- a/src/Dock.Controls.DeferredContentControl/DeferredContentControl.cs +++ b/src/Dock.Controls.DeferredContentControl/DeferredContentControl.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Threading.Tasks; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Metadata; @@ -27,7 +28,7 @@ public interface IDeferredContentPresentation } /// -/// Defines how the shared deferred presentation queue limits work done in a single dispatcher pass. +/// Defines how a deferred timeline limits work done in a single dispatcher pass. /// public enum DeferredContentPresentationBudgetMode { @@ -43,26 +44,122 @@ public enum DeferredContentPresentationBudgetMode } /// -/// Configures the shared deferred presentation queue used by all deferred content hosts. +/// Defines a reusable deferred presentation timeline shared by one or more deferred hosts. +/// +public sealed class DeferredContentPresentationTimeline +{ + private readonly DeferredContentPresentationTimelineQueue _queue; + private DeferredContentPresentationBudgetMode _budgetMode = DeferredContentPresentationBudgetMode.ItemCount; + private int _maxPresentationsPerPass = 8; + private TimeSpan _maxRealizationTimePerPass = TimeSpan.FromMilliseconds(10); + private TimeSpan _initialDelay = TimeSpan.Zero; + private TimeSpan _followUpDelay = TimeSpan.FromMilliseconds(1); + + /// + /// Initializes a new instance of the class. + /// + public DeferredContentPresentationTimeline() + { + _queue = new DeferredContentPresentationTimelineQueue(this); + } + + /// + /// Gets or sets the active budget mode for the timeline queue. + /// + public DeferredContentPresentationBudgetMode BudgetMode + { + get => _budgetMode; + set => _budgetMode = value; + } + + /// + /// Gets or sets the maximum number of successful realizations allowed in one dispatcher pass when is . + /// + public int MaxPresentationsPerPass + { + get => _maxPresentationsPerPass; + set => _maxPresentationsPerPass = value > 0 ? value : 1; + } + + /// + /// Gets or sets the maximum elapsed realization time allowed in one dispatcher pass when is . + /// + public TimeSpan MaxRealizationTimePerPass + { + get => _maxRealizationTimePerPass; + set => _maxRealizationTimePerPass = value >= TimeSpan.Zero ? value : TimeSpan.Zero; + } + + /// + /// Gets or sets the delay applied when a target first joins the timeline. + /// + public TimeSpan InitialDelay + { + get => _initialDelay; + set => _initialDelay = value >= TimeSpan.Zero ? value : TimeSpan.Zero; + } + + /// + /// Gets or sets the delay used between follow-up dispatcher passes while the timeline still has pending due targets. + /// + public TimeSpan FollowUpDelay + { + get => _followUpDelay; + set => _followUpDelay = value >= TimeSpan.Zero ? value : TimeSpan.Zero; + } + + internal bool AutoSchedule + { + get => _queue.AutoSchedule; + set => _queue.AutoSchedule = value; + } + + internal int PendingCount => _queue.PendingCount; + + internal void Enqueue(IDeferredContentPresentationTarget target, TimeSpan delay, int order) + { + _queue.Enqueue(target, delay, order); + } + + internal void Remove(IDeferredContentPresentationTarget target) + { + _queue.Remove(target); + } + + internal void FlushPendingBatchForTesting() + { + _queue.FlushPendingBatchForTesting(); + } +} + +/// +/// Configures the shared default deferred presentation timeline used when no scoped timeline is supplied. /// public static class DeferredContentPresentationSettings { + private static readonly DeferredContentPresentationTimeline s_defaultTimeline = new(); + /// - /// Gets or sets the active budget mode for the shared deferred presentation queue. + /// Gets the shared default timeline used by deferred hosts that do not resolve a scoped timeline. + /// + public static DeferredContentPresentationTimeline DefaultTimeline => s_defaultTimeline; + + /// + /// Gets or sets the active budget mode for the shared default timeline. /// public static DeferredContentPresentationBudgetMode BudgetMode { - get => DeferredContentPresentationQueue.BudgetMode; - set => DeferredContentPresentationQueue.BudgetMode = value; + get => s_defaultTimeline.BudgetMode; + set => s_defaultTimeline.BudgetMode = value; } /// - /// Gets or sets the maximum number of targets that can be realized in one dispatcher pass when is . + /// Gets or sets the maximum number of successful realizations allowed in one dispatcher pass when is . /// public static int MaxPresentationsPerPass { - get => DeferredContentPresentationQueue.MaxPresentationsPerFrame; - set => DeferredContentPresentationQueue.MaxPresentationsPerFrame = value > 0 ? value : 1; + get => s_defaultTimeline.MaxPresentationsPerPass; + set => s_defaultTimeline.MaxPresentationsPerPass = value; } /// @@ -70,13 +167,116 @@ public static int MaxPresentationsPerPass /// public static TimeSpan MaxRealizationTimePerPass { - get => DeferredContentPresentationQueue.MaxRealizationDuration; - set => DeferredContentPresentationQueue.MaxRealizationDuration = value >= TimeSpan.Zero ? value : TimeSpan.Zero; + get => s_defaultTimeline.MaxRealizationTimePerPass; + set => s_defaultTimeline.MaxRealizationTimePerPass = value; + } + + /// + /// Gets or sets the initial delay applied when a target first joins the shared default timeline. + /// + public static TimeSpan InitialDelay + { + get => s_defaultTimeline.InitialDelay; + set => s_defaultTimeline.InitialDelay = value; + } + + /// + /// Gets or sets the delay used between follow-up dispatcher passes while the shared default timeline still has pending due targets. + /// + public static TimeSpan FollowUpDelay + { + get => s_defaultTimeline.FollowUpDelay; + set => s_defaultTimeline.FollowUpDelay = value; + } +} + +/// +/// Provides inheritable attached properties that scope deferred presentation timelines and per-host scheduling metadata. +/// +public sealed class DeferredContentScheduling +{ + /// + /// Defines the deferred presentation timeline attached property. + /// + public static readonly AttachedProperty TimelineProperty = + AvaloniaProperty.RegisterAttached( + "Timeline", + defaultValue: null, + inherits: true); + + /// + /// Defines the per-host deferred delay attached property. + /// + public static readonly AttachedProperty DelayProperty = + AvaloniaProperty.RegisterAttached( + "Delay", + defaultValue: TimeSpan.Zero, + inherits: true); + + /// + /// Defines the per-host deferred order attached property. + /// + public static readonly AttachedProperty OrderProperty = + AvaloniaProperty.RegisterAttached( + "Order", + defaultValue: 0, + inherits: true); + + private DeferredContentScheduling() + { + } + + /// + /// Gets the deferred presentation timeline for the specified object. + /// + public static DeferredContentPresentationTimeline? GetTimeline(AvaloniaObject target) + { + return target.GetValue(TimelineProperty); + } + + /// + /// Sets the deferred presentation timeline for the specified object. + /// + public static void SetTimeline(AvaloniaObject target, DeferredContentPresentationTimeline? value) + { + target.SetValue(TimelineProperty, value); + } + + /// + /// Gets the deferred delay for the specified object. + /// + public static TimeSpan GetDelay(AvaloniaObject target) + { + return target.GetValue(DelayProperty); + } + + /// + /// Sets the deferred delay for the specified object. + /// + public static void SetDelay(AvaloniaObject target, TimeSpan value) + { + target.SetValue(DelayProperty, value); + } + + /// + /// Gets the deferred order for the specified object. + /// + public static int GetOrder(AvaloniaObject target) + { + return target.GetValue(OrderProperty); + } + + /// + /// Sets the deferred order for the specified object. + /// + public static void SetOrder(AvaloniaObject target, int value) + { + target.SetValue(OrderProperty, value); } } /// -/// A that batches content materialization onto the next dispatcher pass. +/// A that batches content materialization onto a deferred timeline. /// [TemplatePart("PART_ContentPresenter", typeof(ContentPresenter))] public class DeferredContentControl : ContentControl, IDeferredContentPresentationTarget @@ -86,6 +286,7 @@ public class DeferredContentControl : ContentControl, IDeferredContentPresentati private IDataTemplate? _appliedContentTemplate; private long _requestedVersion; private long _appliedVersion = -1; + private DeferredContentPresentationTimeline? _enqueuedTimeline; static DeferredContentControl() { @@ -104,6 +305,12 @@ static DeferredContentControl() }.RegisterInNameScope(nameScope))); } + DeferredContentPresentationTimeline? IDeferredContentPresentationTarget.EnqueuedTimeline + { + get => _enqueuedTimeline; + set => _enqueuedTimeline = value; + } + /// protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { @@ -115,18 +322,25 @@ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) { base.OnDetachedFromVisualTree(e); - DeferredContentPresentationQueue.Remove(this); + RemoveQueuedPresentation(); } /// protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { base.OnApplyTemplate(e); + + if (_presenter is DeferredContentPresenter oldDeferredPresenter) + { + oldDeferredPresenter.RemoveQueuedPresentation(); + } + _presenter = e.NameScope.Find("PART_ContentPresenter"); - if (_presenter is IDeferredContentPresentationTarget deferredPresenter) + if (_presenter is DeferredContentPresenter deferredPresenter) { - DeferredContentPresentationQueue.Remove(deferredPresenter); + deferredPresenter.RemoveQueuedPresentation(); } + _appliedVersion = -1; QueueDeferredPresentation(); } @@ -140,6 +354,14 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang { _requestedVersion++; QueueDeferredPresentation(); + return; + } + + if (change.Property == DeferredContentScheduling.TimelineProperty + || change.Property == DeferredContentScheduling.DelayProperty + || change.Property == DeferredContentScheduling.OrderProperty) + { + QueueDeferredPresentation(); } } @@ -176,17 +398,24 @@ private void QueueDeferredPresentation() { if (!IsReadyForPresentation()) { + RemoveQueuedPresentation(); return; } if (Content is IDeferredContentPresentation { DeferContentPresentation: false }) { - DeferredContentPresentationQueue.Remove(this); + RemoveQueuedPresentation(); ApplyDeferredPresentation(); return; } - DeferredContentPresentationQueue.Enqueue(this); + var timeline = DeferredContentPresentationTargetHelpers.ResolveTimeline(this); + if (!ReferenceEquals(_enqueuedTimeline, timeline)) + { + RemoveQueuedPresentation(); + } + + timeline.Enqueue(this, DeferredContentPresentationTargetHelpers.ResolveDelay(this), DeferredContentPresentationTargetHelpers.ResolveOrder(this)); } private bool IsReadyForPresentation() @@ -196,6 +425,14 @@ private bool IsReadyForPresentation() && ((ILogical)this).IsAttachedToLogicalTree; } + private void RemoveQueuedPresentation() + { + if (_enqueuedTimeline is { } timeline) + { + timeline.Remove(this); + } + } + private static void ApplyDeferredState(ContentPresenter presenter, object? content, IDataTemplate? contentTemplate) { if (presenter is DeferredContentPresenter deferredPresenter) @@ -211,19 +448,31 @@ private static void ApplyDeferredState(ContentPresenter presenter, object? conte internal interface IDeferredContentPresentationTarget { + DeferredContentPresentationTimeline? EnqueuedTimeline { get; set; } + bool ApplyDeferredPresentation(); } /// -/// A that batches content materialization onto the next dispatcher pass. +/// A that batches content materialization onto a deferred timeline. /// public class DeferredContentPresenter : ContentPresenter, IDeferredContentPresentationTarget { private bool _suppressDeferredUpdates; + private object? _requestedContent; + private IDataTemplate? _requestedContentTemplate; private object? _appliedContent; private IDataTemplate? _appliedContentTemplate; private long _requestedVersion; private long _appliedVersion = -1; + private Size _lastDesiredSize; + private DeferredContentPresentationTimeline? _enqueuedTimeline; + + DeferredContentPresentationTimeline? IDeferredContentPresentationTarget.EnqueuedTimeline + { + get => _enqueuedTimeline; + set => _enqueuedTimeline = value; + } /// protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) @@ -236,7 +485,20 @@ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) { base.OnDetachedFromVisualTree(e); - DeferredContentPresentationQueue.Remove(this); + RemoveQueuedPresentation(); + } + + /// + protected override Size MeasureOverride(Size availableSize) + { + if (ShouldDeferMeasure()) + { + return _lastDesiredSize; + } + + var desiredSize = base.MeasureOverride(availableSize); + _lastDesiredSize = desiredSize; + return desiredSize; } /// @@ -249,12 +511,29 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang return; } + if (change.Property == ContentProperty) + { + _requestedContent = change.NewValue; + } + else + { + _requestedContentTemplate = change.NewValue as IDataTemplate; + } + _requestedVersion++; + RestoreAppliedState(); QueueDeferredPresentation(); return; } base.OnPropertyChanged(change); + + if (change.Property == DeferredContentScheduling.TimelineProperty + || change.Property == DeferredContentScheduling.DelayProperty + || change.Property == DeferredContentScheduling.OrderProperty) + { + QueueDeferredPresentation(); + } } internal void ApplyDeferredState(object? content, IDataTemplate? contentTemplate) @@ -286,8 +565,8 @@ internal bool ApplyDeferredPresentation() return false; } - object? content = Content; - IDataTemplate? contentTemplate = ContentTemplate; + object? content = _requestedContent; + IDataTemplate? contentTemplate = _requestedContentTemplate; if (_appliedVersion == _requestedVersion && ReferenceEquals(_appliedContent, content) @@ -303,27 +582,42 @@ internal bool ApplyDeferredPresentation() return true; } + internal void RemoveQueuedPresentation() + { + if (_enqueuedTimeline is { } timeline) + { + timeline.Remove(this); + } + } + private void QueueDeferredPresentation() { if (TemplatedParent is DeferredContentControl) { - DeferredContentPresentationQueue.Remove(this); + RemoveQueuedPresentation(); return; } if (!IsReadyForPresentation()) { + RemoveQueuedPresentation(); return; } - if (Content is IDeferredContentPresentation { DeferContentPresentation: false }) + if (_requestedContent is IDeferredContentPresentation { DeferContentPresentation: false }) { - DeferredContentPresentationQueue.Remove(this); + RemoveQueuedPresentation(); ApplyDeferredPresentation(); return; } - DeferredContentPresentationQueue.Enqueue(this); + var timeline = DeferredContentPresentationTargetHelpers.ResolveTimeline(this); + if (!ReferenceEquals(_enqueuedTimeline, timeline)) + { + RemoveQueuedPresentation(); + } + + timeline.Enqueue(this, DeferredContentPresentationTargetHelpers.ResolveDelay(this), DeferredContentPresentationTargetHelpers.ResolveOrder(this)); } private bool IsReadyForPresentation() @@ -332,6 +626,48 @@ private bool IsReadyForPresentation() && ((ILogical)this).IsAttachedToLogicalTree; } + private bool ShouldDeferMeasure() + { + if (_suppressDeferredUpdates) + { + return false; + } + + if (TemplatedParent is DeferredContentControl) + { + return false; + } + + if (!IsReadyForPresentation()) + { + return false; + } + + if (Content is IDeferredContentPresentation { DeferContentPresentation: false }) + { + return false; + } + + return _appliedVersion != _requestedVersion; + } + + private void RestoreAppliedState() + { + _suppressDeferredUpdates = true; + + try + { + SetCurrentValue(ContentTemplateProperty, _appliedContentTemplate); + SetCurrentValue(ContentProperty, _appliedContent); + } + finally + { + _suppressDeferredUpdates = false; + } + + UpdatePresentedChild(); + } + private void UpdatePresentedChild() { UpdateChild(); @@ -340,125 +676,166 @@ private void UpdatePresentedChild() } } -internal static class DeferredContentPresentationQueue +internal sealed class DeferredContentPresentationTimelineQueue { - internal static DeferredContentPresentationBudgetMode BudgetMode { get; set; } = DeferredContentPresentationBudgetMode.ItemCount; - internal static bool AutoSchedule { get; set; } = true; - internal static int MaxPresentationsPerFrame { get; set; } = 8; - internal static TimeSpan MaxRealizationDuration { get; set; } = TimeSpan.FromMilliseconds(10); - internal static int PendingCount => s_pendingLookup.Count; + private static readonly DispatcherPriority s_flushPriority = DispatcherPriority.Background; + private static readonly Comparison s_pendingEntryComparison = ComparePendingEntries; + + private sealed class PendingEntry + { + public PendingEntry(IDeferredContentPresentationTarget target, DateTimeOffset dueAt, int order, long sequence) + { + Target = target; + DueAt = dueAt; + Order = order; + Sequence = sequence; + } + + public IDeferredContentPresentationTarget Target { get; } + + public DateTimeOffset DueAt { get; set; } + + public int Order { get; set; } + + public long Sequence { get; set; } + } + + private readonly DeferredContentPresentationTimeline _timeline; + private readonly Dictionary _pending = new(); + private readonly List _dueEntries = new(); + private bool _isScheduled; + private DateTimeOffset? _scheduledDueAt; + private long _nextSequence; + private long _scheduleVersion; + + public DeferredContentPresentationTimelineQueue(DeferredContentPresentationTimeline timeline) + { + _timeline = timeline; + } + + internal bool AutoSchedule { get; set; } = true; - private static readonly LinkedList s_pending = new(); - private static readonly Dictionary> s_pendingLookup = new(); - private static readonly TimeSpan s_followUpFlushDelay = TimeSpan.FromMilliseconds(1); - private static bool s_isScheduled; + internal int PendingCount => _pending.Count; - internal static void Enqueue(IDeferredContentPresentationTarget control) + internal void Enqueue(IDeferredContentPresentationTarget target, TimeSpan delay, int order) { if (!Dispatcher.UIThread.CheckAccess()) { - Dispatcher.UIThread.Post(() => Enqueue(control), DispatcherPriority.Render); + Dispatcher.UIThread.Post(() => Enqueue(target, delay, order), s_flushPriority); return; } - if (s_pendingLookup.ContainsKey(control)) + var dueAt = DateTimeOffset.UtcNow + NormalizeDelay(_timeline.InitialDelay + delay); + var sequence = ++_nextSequence; + + if (_pending.TryGetValue(target, out var entry)) { - return; + entry.DueAt = dueAt; + entry.Order = order; + entry.Sequence = sequence; + } + else + { + _pending.Add(target, new PendingEntry(target, dueAt, order, sequence)); } - var node = s_pending.AddLast(control); - s_pendingLookup.Add(control, node); + target.EnqueuedTimeline = _timeline; if (AutoSchedule) { - ScheduleFlush(delayed: false); + ScheduleNextPending(DateTimeOffset.UtcNow); } } - internal static void Remove(IDeferredContentPresentationTarget control) + internal void Remove(IDeferredContentPresentationTarget target) { if (!Dispatcher.UIThread.CheckAccess()) { - Dispatcher.UIThread.Post(() => Remove(control), DispatcherPriority.Render); + Dispatcher.UIThread.Post(() => Remove(target), s_flushPriority); return; } - RemovePending(control); + RemovePending(target); - if (s_pendingLookup.Count == 0) + if (_pending.Count == 0) { CancelScheduling(); } } - private static void ScheduleFlush(bool delayed) + internal void FlushPendingBatchForTesting() { - if (s_isScheduled) + if (!Dispatcher.UIThread.CheckAccess()) { + Dispatcher.UIThread.Post(FlushPendingBatchForTesting, s_flushPriority); return; } - s_isScheduled = true; + FlushScheduledBatch(_scheduleVersion); + } + + private void FlushScheduledBatch(long scheduledVersion) + { + if (!Dispatcher.UIThread.CheckAccess()) + { + Dispatcher.UIThread.Post(() => FlushScheduledBatch(scheduledVersion), s_flushPriority); + return; + } - if (delayed) + if (scheduledVersion != _scheduleVersion) { - DispatcherTimer.RunOnce(FlushScheduledBatch, s_followUpFlushDelay, DispatcherPriority.Render); return; } - Dispatcher.UIThread.Post(FlushScheduledBatch, DispatcherPriority.Render); - } + _isScheduled = false; + _scheduledDueAt = null; - internal static void FlushPendingBatchForTesting() - { - if (!Dispatcher.UIThread.CheckAccess()) + if (_pending.Count == 0) { - Dispatcher.UIThread.Post(FlushPendingBatchForTesting, DispatcherPriority.Render); + CancelScheduling(); return; } - FlushScheduledBatch(); - } + var now = DateTimeOffset.UtcNow; + _dueEntries.Clear(); - private static void FlushScheduledBatch() - { - if (!Dispatcher.UIThread.CheckAccess()) + foreach (PendingEntry entry in _pending.Values) { - Dispatcher.UIThread.Post(FlushScheduledBatch, DispatcherPriority.Render); - return; + if (entry.DueAt <= now) + { + _dueEntries.Add(entry); + } } - if (s_pendingLookup.Count == 0) + if (_dueEntries.Count == 0) { - CancelScheduling(); + if (AutoSchedule) + { + ScheduleNextPending(now); + } + return; } - s_isScheduled = false; + _dueEntries.Sort(s_pendingEntryComparison); - var pendingCountAtStart = s_pendingLookup.Count; - var batchSize = Math.Min(Math.Max(1, MaxPresentationsPerFrame), s_pendingLookup.Count); - var stopwatch = BudgetMode == DeferredContentPresentationBudgetMode.RealizationTime + var batchSize = Math.Min(Math.Max(1, _timeline.MaxPresentationsPerPass), _dueEntries.Count); + var stopwatch = _timeline.BudgetMode == DeferredContentPresentationBudgetMode.RealizationTime ? Stopwatch.StartNew() : null; - var node = s_pending.First; var completedCount = 0; - var processedCount = 0; - while (node is not null && processedCount < pendingCountAtStart) + foreach (PendingEntry entry in _dueEntries) { - var current = node; - node = node.Next; - processedCount++; - - if (current.Value.ApplyDeferredPresentation()) + if (!_pending.TryGetValue(entry.Target, out var current) || !ReferenceEquals(current, entry)) { - RemovePending(current.Value); - completedCount++; + continue; } - else + + if (entry.Target.ApplyDeferredPresentation()) { - MovePendingToBack(current); + RemovePending(entry.Target); + completedCount++; } if (!ShouldContinueProcessing(completedCount, batchSize, stopwatch)) @@ -467,57 +844,183 @@ private static void FlushScheduledBatch() } } - if (s_pendingLookup.Count == 0) + _dueEntries.Clear(); + + if (_pending.Count == 0) { CancelScheduling(); return; } - if (AutoSchedule) + if (!AutoSchedule) { - ScheduleFlush(delayed: true); + return; } + + now = DateTimeOffset.UtcNow; + ScheduleFlush(HasDuePending(now) ? _timeline.FollowUpDelay : GetEarliestDueDelay(now)); } - private static void CancelScheduling() + private void ScheduleNextPending(DateTimeOffset now) { - if (!s_isScheduled) + if (_pending.Count == 0) { + CancelScheduling(); return; } - s_isScheduled = false; + ScheduleFlush(GetEarliestDueDelay(now)); } - private static void RemovePending(IDeferredContentPresentationTarget control) + private void ScheduleFlush(TimeSpan delay) { - if (!s_pendingLookup.Remove(control, out var node)) + var normalizedDelay = NormalizeDelay(delay); + var dueAt = DateTimeOffset.UtcNow + normalizedDelay; + + if (_isScheduled + && _scheduledDueAt is { } scheduledDueAt + && scheduledDueAt <= dueAt) + { + return; + } + + _isScheduled = true; + _scheduledDueAt = dueAt; + + var scheduledVersion = ++_scheduleVersion; + + if (normalizedDelay == TimeSpan.Zero) { + Dispatcher.UIThread.Post(() => FlushScheduledBatch(scheduledVersion), s_flushPriority); return; } - s_pending.Remove(node); + _ = PostDelayedFlushAsync(normalizedDelay, scheduledVersion); + } + + private void CancelScheduling() + { + _isScheduled = false; + _scheduledDueAt = null; + _scheduleVersion++; } - private static void MovePendingToBack(LinkedListNode node) + private void RemovePending(IDeferredContentPresentationTarget target) { - if (node.List != s_pending || node.Next is null) + if (!_pending.Remove(target)) { return; } - s_pending.Remove(node); - var moved = s_pending.AddLast(node.Value); - s_pendingLookup[node.Value] = moved; + if (ReferenceEquals(target.EnqueuedTimeline, _timeline)) + { + target.EnqueuedTimeline = null; + } + } + + private bool ShouldContinueProcessing(int completedCount, int batchSize, Stopwatch? stopwatch) + { + return stopwatch is null + ? completedCount < batchSize + : stopwatch.Elapsed < _timeline.MaxRealizationTimePerPass; + } + + private bool HasDuePending(DateTimeOffset now) + { + foreach (PendingEntry entry in _pending.Values) + { + if (entry.DueAt <= now) + { + return true; + } + } + + return false; } - private static bool ShouldContinueProcessing(int completedCount, int batchSize, Stopwatch? stopwatch) + private TimeSpan GetEarliestDueDelay(DateTimeOffset now) { - return BudgetMode switch + using var enumerator = _pending.Values.GetEnumerator(); + enumerator.MoveNext(); + + var earliestDueAt = enumerator.Current.DueAt; + + while (enumerator.MoveNext()) { - DeferredContentPresentationBudgetMode.ItemCount => completedCount < batchSize, - DeferredContentPresentationBudgetMode.RealizationTime => stopwatch is not null && stopwatch.Elapsed < MaxRealizationDuration, - _ => false - }; + if (enumerator.Current.DueAt < earliestDueAt) + { + earliestDueAt = enumerator.Current.DueAt; + } + } + + var delay = earliestDueAt - now; + return NormalizeDelay(delay); + } + + private static int ComparePendingEntries(PendingEntry left, PendingEntry right) + { + var orderComparison = left.Order.CompareTo(right.Order); + if (orderComparison != 0) + { + return orderComparison; + } + + return left.Sequence.CompareTo(right.Sequence); + } + + private static TimeSpan NormalizeDelay(TimeSpan delay) + { + return delay >= TimeSpan.Zero ? delay : TimeSpan.Zero; + } + + private async Task PostDelayedFlushAsync(TimeSpan delay, long scheduledVersion) + { + await Task.Delay(delay).ConfigureAwait(false); + Dispatcher.UIThread.Post(() => FlushScheduledBatch(scheduledVersion), s_flushPriority); + } +} + +internal static class DeferredContentPresentationQueue +{ + internal static bool AutoSchedule + { + get => DeferredContentPresentationSettings.DefaultTimeline.AutoSchedule; + set => DeferredContentPresentationSettings.DefaultTimeline.AutoSchedule = value; + } + + internal static int PendingCount => DeferredContentPresentationSettings.DefaultTimeline.PendingCount; + + internal static void Enqueue(IDeferredContentPresentationTarget target) + { + DeferredContentPresentationSettings.DefaultTimeline.Enqueue(target, TimeSpan.Zero, 0); + } + + internal static void Remove(IDeferredContentPresentationTarget target) + { + DeferredContentPresentationSettings.DefaultTimeline.Remove(target); + } + + internal static void FlushPendingBatchForTesting() + { + DeferredContentPresentationSettings.DefaultTimeline.FlushPendingBatchForTesting(); + } +} + +internal static class DeferredContentPresentationTargetHelpers +{ + internal static DeferredContentPresentationTimeline ResolveTimeline(AvaloniaObject target) + { + return DeferredContentScheduling.GetTimeline(target) ?? DeferredContentPresentationSettings.DefaultTimeline; + } + + internal static TimeSpan ResolveDelay(AvaloniaObject target) + { + var delay = DeferredContentScheduling.GetDelay(target); + return delay >= TimeSpan.Zero ? delay : TimeSpan.Zero; + } + + internal static int ResolveOrder(AvaloniaObject target) + { + return DeferredContentScheduling.GetOrder(target); } } diff --git a/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs b/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs index 24ad9da73..4d6a46f61 100644 --- a/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs +++ b/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs @@ -4,8 +4,10 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Presenters; +using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; using Avalonia.Headless.XUnit; +using Avalonia.Data; using Avalonia.Styling; using Avalonia.Threading; using Avalonia.VisualTree; @@ -66,6 +68,8 @@ private sealed class TestDeferredTarget : IDeferredContentPresentationTarget public int SuccessfulApplyCount { get; private set; } + public DeferredContentPresentationTimeline? EnqueuedTimeline { get; set; } + public bool ApplyDeferredPresentation() { if (ApplyDelay > TimeSpan.Zero) @@ -89,6 +93,46 @@ public bool ApplyDeferredPresentation() } } + private sealed class PresenterSlot + { + public PresenterSlot(object content, IDataTemplate template, int order, TimeSpan delay) + { + Content = content; + Template = template; + Order = order; + Delay = delay; + } + + public object Content { get; } + + public IDataTemplate Template { get; } + + public int Order { get; } + + public TimeSpan Delay { get; } + } + + private sealed class TestDeferredPresenterHost : TemplatedControl + { + public static readonly StyledProperty CardProperty = + AvaloniaProperty.Register(nameof(Card)); + + public static readonly StyledProperty CardTemplateProperty = + AvaloniaProperty.Register(nameof(CardTemplate)); + + public object? Card + { + get => GetValue(CardProperty); + set => SetValue(CardProperty, value); + } + + public IDataTemplate? CardTemplate + { + get => GetValue(CardTemplateProperty); + set => SetValue(CardTemplateProperty, value); + } + } + [AvaloniaFact] public void DeferredContentControl_Defers_Content_Materialization_Until_Dispatcher_Run() { @@ -483,6 +527,486 @@ public void DeferredContentQueue_Limits_Realization_Batch_By_Time_Budget() } } + [AvaloniaFact] + public void DeferredContentControl_Uses_Inherited_Scoped_Timeline() + { + var timeline = new DeferredContentPresentationTimeline + { + MaxPresentationsPerPass = 2 + }; + timeline.AutoSchedule = false; + + var firstTemplate = new CountingTemplate(() => new TextBlock { Text = "FirstScope" }); + var secondTemplate = new CountingTemplate(() => new TextBlock { Text = "SecondScope" }); + var first = new DeferredContentControl + { + Content = "First", + ContentTemplate = firstTemplate + }; + var second = new DeferredContentControl + { + Content = "Second", + ContentTemplate = secondTemplate + }; + var panel = new StackPanel(); + DeferredContentScheduling.SetTimeline(panel, timeline); + panel.Children.Add(first); + panel.Children.Add(second); + + var window = new Window + { + Width = 800, + Height = 600, + Content = panel + }; + + window.Show(); + first.ApplyTemplate(); + second.ApplyTemplate(); + window.UpdateLayout(); + + try + { + Assert.Equal(2, timeline.PendingCount); + Assert.Equal(0, DeferredContentPresentationQueue.PendingCount); + Assert.Null(first.Presenter?.Child); + Assert.Null(second.Presenter?.Child); + + DrainDeferredQueueBatch(window, timeline); + + Assert.Equal(1, firstTemplate.BuildCount); + Assert.Equal(1, secondTemplate.BuildCount); + Assert.NotNull(first.Presenter?.Child); + Assert.NotNull(second.Presenter?.Child); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void DeferredContentControl_Uses_Inherited_Order_Within_Scope() + { + var timeline = new DeferredContentPresentationTimeline + { + MaxPresentationsPerPass = 1 + }; + timeline.AutoSchedule = false; + + var firstTemplate = new CountingTemplate(() => new TextBlock { Text = "Order20" }); + var secondTemplate = new CountingTemplate(() => new TextBlock { Text = "OrderMinus10" }); + var first = new DeferredContentControl + { + Content = "First", + ContentTemplate = firstTemplate + }; + var second = new DeferredContentControl + { + Content = "Second", + ContentTemplate = secondTemplate + }; + DeferredContentScheduling.SetOrder(first, 20); + DeferredContentScheduling.SetOrder(second, -10); + + var panel = new StackPanel(); + DeferredContentScheduling.SetTimeline(panel, timeline); + panel.Children.Add(first); + panel.Children.Add(second); + + var window = new Window + { + Width = 800, + Height = 600, + Content = panel + }; + + window.Show(); + first.ApplyTemplate(); + second.ApplyTemplate(); + window.UpdateLayout(); + + try + { + DrainDeferredQueueBatch(window, timeline); + + Assert.Equal(0, firstTemplate.BuildCount); + Assert.Equal(1, secondTemplate.BuildCount); + Assert.Null(first.Presenter?.Child); + Assert.NotNull(second.Presenter?.Child); + + DrainDeferredQueueBatch(window, timeline); + + Assert.Equal(1, firstTemplate.BuildCount); + Assert.NotNull(first.Presenter?.Child); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void DeferredContentControl_Uses_Inherited_Delay_Within_Scope() + { + var timeline = new DeferredContentPresentationTimeline + { + MaxPresentationsPerPass = 1 + }; + timeline.AutoSchedule = false; + + var template = new CountingTemplate(() => new TextBlock { Text = "Delayed" }); + var control = new DeferredContentControl + { + Content = "Delayed", + ContentTemplate = template + }; + var panel = new StackPanel(); + DeferredContentScheduling.SetTimeline(panel, timeline); + DeferredContentScheduling.SetDelay(panel, TimeSpan.FromMilliseconds(20)); + panel.Children.Add(control); + + var window = new Window + { + Width = 800, + Height = 600, + Content = panel + }; + + window.Show(); + control.ApplyTemplate(); + window.UpdateLayout(); + + try + { + DrainDeferredQueueBatch(window, timeline); + + Assert.Equal(0, template.BuildCount); + Assert.Null(control.Presenter?.Child); + + Thread.Sleep(40); + DrainDeferredQueueBatch(window, timeline); + + Assert.Equal(1, template.BuildCount); + Assert.NotNull(control.Presenter?.Child); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void DeferredContentControl_Scoped_Timelines_Are_Independent() + { + var leftTimeline = new DeferredContentPresentationTimeline + { + MaxPresentationsPerPass = 1 + }; + var rightTimeline = new DeferredContentPresentationTimeline + { + MaxPresentationsPerPass = 1 + }; + leftTimeline.AutoSchedule = false; + rightTimeline.AutoSchedule = false; + + var leftTemplate = new CountingTemplate(() => new TextBlock { Text = "LeftScope" }); + var rightTemplate = new CountingTemplate(() => new TextBlock { Text = "RightScope" }); + var leftControl = new DeferredContentControl + { + Content = "Left", + ContentTemplate = leftTemplate + }; + var rightControl = new DeferredContentControl + { + Content = "Right", + ContentTemplate = rightTemplate + }; + + var leftHost = new StackPanel(); + DeferredContentScheduling.SetTimeline(leftHost, leftTimeline); + leftHost.Children.Add(leftControl); + + var rightHost = new StackPanel(); + DeferredContentScheduling.SetTimeline(rightHost, rightTimeline); + rightHost.Children.Add(rightControl); + + var root = new StackPanel(); + root.Children.Add(leftHost); + root.Children.Add(rightHost); + + var window = new Window + { + Width = 800, + Height = 600, + Content = root + }; + + window.Show(); + leftControl.ApplyTemplate(); + rightControl.ApplyTemplate(); + window.UpdateLayout(); + + try + { + Assert.Equal(1, leftTimeline.PendingCount); + Assert.Equal(1, rightTimeline.PendingCount); + + DrainDeferredQueueBatch(window, leftTimeline); + + Assert.Equal(1, leftTemplate.BuildCount); + Assert.Equal(0, rightTemplate.BuildCount); + Assert.NotNull(leftControl.Presenter?.Child); + Assert.Null(rightControl.Presenter?.Child); + + DrainDeferredQueueBatch(window, rightTimeline); + + Assert.Equal(1, rightTemplate.BuildCount); + Assert.NotNull(rightControl.Presenter?.Child); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void DeferredContentTimeline_Applies_Lower_Order_First_In_Time_Budget_Mode() + { + var timeline = new DeferredContentPresentationTimeline + { + BudgetMode = DeferredContentPresentationBudgetMode.RealizationTime, + MaxRealizationTimePerPass = TimeSpan.Zero + }; + timeline.AutoSchedule = false; + + var slowTarget = new TestDeferredTarget + { + CanApply = true, + ApplyDelay = TimeSpan.FromMilliseconds(20) + }; + var fastTarget = new TestDeferredTarget + { + CanApply = true + }; + + try + { + timeline.Enqueue(slowTarget, TimeSpan.Zero, order: 10); + timeline.Enqueue(fastTarget, TimeSpan.Zero, order: -10); + + Assert.Equal(2, timeline.PendingCount); + + timeline.FlushPendingBatchForTesting(); + + Assert.Equal(1, fastTarget.SuccessfulApplyCount); + Assert.Equal(0, slowTarget.SuccessfulApplyCount); + Assert.Equal(1, timeline.PendingCount); + + timeline.FlushPendingBatchForTesting(); + + Assert.Equal(1, slowTarget.SuccessfulApplyCount); + Assert.Equal(0, timeline.PendingCount); + } + finally + { + timeline.Remove(slowTarget); + timeline.Remove(fastTarget); + } + } + + [AvaloniaFact] + public void DeferredContentPresenter_Uses_Scoped_Time_Budget_Inside_ItemsControl() + { + var firstTemplate = new CountingTemplate(() => new Border + { + Child = new TextBlock { Text = "PresenterFirst" } + }); + var secondTemplate = new CountingTemplate(() => new Border + { + Child = new TextBlock { Text = "PresenterSecond" } + }); + var slots = new[] + { + new PresenterSlot("First", firstTemplate, order: 10, delay: TimeSpan.FromMilliseconds(20)), + new PresenterSlot("Second", secondTemplate, order: -10, delay: TimeSpan.Zero) + }; + var timeline = new DeferredContentPresentationTimeline + { + BudgetMode = DeferredContentPresentationBudgetMode.RealizationTime, + MaxRealizationTimePerPass = TimeSpan.Zero, + InitialDelay = TimeSpan.Zero, + FollowUpDelay = TimeSpan.FromMilliseconds(1) + }; + timeline.AutoSchedule = false; + + var itemsControl = new ItemsControl + { + ItemsSource = slots, + ItemTemplate = new FuncDataTemplate( + (slot, _) => + { + var presenter = new DeferredContentPresenter + { + Content = slot.Content, + ContentTemplate = slot.Template, + Name = $"Presenter_{slot.Order}" + }; + + DeferredContentScheduling.SetOrder(presenter, slot.Order); + DeferredContentScheduling.SetDelay(presenter, slot.Delay); + return presenter; + }, + supportsRecycling: true) + }; + var host = new Border + { + Child = itemsControl + }; + DeferredContentScheduling.SetTimeline(host, timeline); + + var window = new Window + { + Width = 800, + Height = 600, + Content = host + }; + + window.Show(); + window.UpdateLayout(); + + try + { + var presenters = window.GetVisualDescendants() + .OfType() + .ToArray(); + var fastPresenter = presenters.Single(presenter => presenter.Name == "Presenter_-10"); + var slowPresenter = presenters.Single(presenter => presenter.Name == "Presenter_10"); + + Assert.Equal(2, presenters.Length); + Assert.Equal(2, timeline.PendingCount); + Assert.All(presenters, presenter => Assert.Null(presenter.Child)); + + DrainDeferredQueueBatch(window, timeline); + + Assert.Equal(0, firstTemplate.BuildCount); + Assert.Equal(1, secondTemplate.BuildCount); + Assert.Null(slowPresenter.Child); + Assert.NotNull(fastPresenter.Child); + + Thread.Sleep(40); + DrainDeferredQueueBatch(window, timeline); + + Assert.Equal(1, firstTemplate.BuildCount); + Assert.Equal(1, secondTemplate.BuildCount); + Assert.NotNull(slowPresenter.Child); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void DeferredContentPresenter_AutoScheduled_Time_Budget_Materializes_In_ItemsControl() + { + var firstTemplate = new CountingTemplate(() => new Border + { + Child = new TextBlock { Text = "PresenterFirstAuto" } + }); + var secondTemplate = new CountingTemplate(() => new Border + { + Child = new TextBlock { Text = "PresenterSecondAuto" } + }); + var slots = new[] + { + new PresenterSlot("First", firstTemplate, order: -5, delay: TimeSpan.Zero), + new PresenterSlot("Second", secondTemplate, order: 10, delay: TimeSpan.FromMilliseconds(150)) + }; + var timeline = new DeferredContentPresentationTimeline + { + BudgetMode = DeferredContentPresentationBudgetMode.RealizationTime, + MaxRealizationTimePerPass = TimeSpan.FromMilliseconds(2), + InitialDelay = TimeSpan.Zero, + FollowUpDelay = TimeSpan.FromMilliseconds(10) + }; + + var itemsControl = new ItemsControl + { + ItemsSource = slots, + ItemTemplate = new FuncDataTemplate( + (slot, _) => + { + var hostControl = new TestDeferredPresenterHost + { + Card = slot.Content, + CardTemplate = slot.Template, + Name = $"AutoPresenterHost_{slot.Order}", + Template = new FuncControlTemplate((_, nameScope) => + new DeferredContentPresenter + { + Name = "PART_Presenter", + [~DeferredContentPresenter.ContentProperty] = new TemplateBinding(TestDeferredPresenterHost.CardProperty), + [~DeferredContentPresenter.ContentTemplateProperty] = new TemplateBinding(TestDeferredPresenterHost.CardTemplateProperty) + }.RegisterInNameScope(nameScope)) + }; + + DeferredContentScheduling.SetOrder(hostControl, slot.Order); + DeferredContentScheduling.SetDelay(hostControl, slot.Delay); + return hostControl; + }, + supportsRecycling: true) + }; + var host = new Border + { + Child = itemsControl + }; + DeferredContentScheduling.SetTimeline(host, timeline); + + var window = new Window + { + Width = 800, + Height = 600, + Content = host + }; + + window.Show(); + window.UpdateLayout(); + + try + { + var presenterHosts = window.GetVisualDescendants() + .OfType() + .ToArray(); + var firstHost = presenterHosts.Single(hostControl => hostControl.Name == "AutoPresenterHost_-5"); + var secondHost = presenterHosts.Single(hostControl => hostControl.Name == "AutoPresenterHost_10"); + var firstPresenter = firstHost.GetVisualDescendants().OfType().Single(); + var secondPresenter = secondHost.GetVisualDescendants().OfType().Single(); + + Assert.All(new[] { firstPresenter, secondPresenter }, presenter => Assert.Null(presenter.Child)); + + Thread.Sleep(60); + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + + Assert.Equal(1, firstTemplate.BuildCount); + Assert.Equal(0, secondTemplate.BuildCount); + Assert.NotNull(firstPresenter.Child); + Assert.Null(secondPresenter.Child); + + Thread.Sleep(180); + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + + Assert.Equal(1, secondTemplate.BuildCount); + Assert.NotNull(secondPresenter.Child); + } + finally + { + window.Close(); + } + } + [AvaloniaFact] public void DocumentContentControl_Defers_Document_Template_Materialization() { @@ -896,6 +1420,7 @@ public void RootDockControl_Defers_Active_Dock_Materialization() [AvaloniaFact] public void ToolChromeControl_Defers_Content_Materialization() { + using var _ = new DeferredBatchLimitScope(autoSchedule: false); var factory = new Factory(); var toolDock = new ToolDock { @@ -924,6 +1449,11 @@ public void ToolChromeControl_Defers_Content_Materialization() try { window.UpdateLayout(); + Assert.Equal(0, template.BuildCount); + Assert.Null(presenterHost.Child); + + DrainDeferredQueueBatch(window); + Assert.Equal(1, template.BuildCount); var initialTextBlock = Assert.IsType(presenterHost.Child); Assert.Equal("First", initialTextBlock.DataContext); @@ -933,8 +1463,7 @@ public void ToolChromeControl_Defers_Content_Materialization() var staleTextBlock = Assert.IsType(presenterHost.Child); Assert.Equal("First", staleTextBlock.DataContext); - Dispatcher.UIThread.RunJobs(); - window.UpdateLayout(); + DrainDeferredQueueBatch(window); Assert.Equal(1, template.BuildCount); var textBlock = Assert.IsType(presenterHost.Child); @@ -949,6 +1478,7 @@ public void ToolChromeControl_Defers_Content_Materialization() [AvaloniaFact] public void HostWindow_Defers_Content_Materialization() { + using var _ = new DeferredBatchLimitScope(autoSchedule: false); var template = new CountingTemplate(() => new TextBlock { Text = "HostWindow" }); var window = new HostWindow { @@ -967,6 +1497,11 @@ public void HostWindow_Defers_Content_Materialization() try { window.UpdateLayout(); + Assert.Equal(0, template.BuildCount); + Assert.Null(presenterHost.Child); + + DrainDeferredQueueBatch(window); + Assert.Equal(1, template.BuildCount); var initialTextBlock = Assert.IsType(presenterHost.Child); Assert.Equal("First", initialTextBlock.DataContext); @@ -976,8 +1511,7 @@ public void HostWindow_Defers_Content_Materialization() var staleTextBlock = Assert.IsType(presenterHost.Child); Assert.Equal("First", staleTextBlock.DataContext); - Dispatcher.UIThread.RunJobs(); - window.UpdateLayout(); + DrainDeferredQueueBatch(window); Assert.Equal(1, template.BuildCount); var textBlock = Assert.IsType(presenterHost.Child); @@ -1053,22 +1587,37 @@ private static void DrainDeferredQueueBatch(Window window) window.UpdateLayout(); } + private static void DrainDeferredQueueBatch(Window window, DeferredContentPresentationTimeline timeline) + { + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + timeline.FlushPendingBatchForTesting(); + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + } + private sealed class DeferredBatchLimitScope : IDisposable { private readonly DeferredContentPresentationBudgetMode _previousBudgetMode = DeferredContentPresentationSettings.BudgetMode; private readonly int _previousLimit = DeferredContentPresentationSettings.MaxPresentationsPerPass; private readonly TimeSpan _previousMaxRealizationTime = DeferredContentPresentationSettings.MaxRealizationTimePerPass; + private readonly TimeSpan _previousInitialDelay = DeferredContentPresentationSettings.InitialDelay; + private readonly TimeSpan _previousFollowUpDelay = DeferredContentPresentationSettings.FollowUpDelay; private readonly bool _previousAutoSchedule = DeferredContentPresentationQueue.AutoSchedule; public DeferredBatchLimitScope( DeferredContentPresentationBudgetMode budgetMode = DeferredContentPresentationBudgetMode.ItemCount, int limit = int.MaxValue, TimeSpan? maxRealizationTimePerPass = null, + TimeSpan? initialDelay = null, + TimeSpan? followUpDelay = null, bool autoSchedule = true) { DeferredContentPresentationSettings.BudgetMode = budgetMode; DeferredContentPresentationSettings.MaxPresentationsPerPass = limit; DeferredContentPresentationSettings.MaxRealizationTimePerPass = maxRealizationTimePerPass ?? TimeSpan.FromMilliseconds(10); + DeferredContentPresentationSettings.InitialDelay = initialDelay ?? TimeSpan.Zero; + DeferredContentPresentationSettings.FollowUpDelay = followUpDelay ?? TimeSpan.FromMilliseconds(1); DeferredContentPresentationQueue.AutoSchedule = autoSchedule; } @@ -1077,6 +1626,8 @@ public void Dispose() DeferredContentPresentationSettings.BudgetMode = _previousBudgetMode; DeferredContentPresentationSettings.MaxPresentationsPerPass = _previousLimit; DeferredContentPresentationSettings.MaxRealizationTimePerPass = _previousMaxRealizationTime; + DeferredContentPresentationSettings.InitialDelay = _previousInitialDelay; + DeferredContentPresentationSettings.FollowUpDelay = _previousFollowUpDelay; DeferredContentPresentationQueue.AutoSchedule = _previousAutoSchedule; } } diff --git a/tests/Dock.Avalonia.HeadlessTests/HostWindowThemeChangeTests.cs b/tests/Dock.Avalonia.HeadlessTests/HostWindowThemeChangeTests.cs index ae4a66dce..d3528405f 100644 --- a/tests/Dock.Avalonia.HeadlessTests/HostWindowThemeChangeTests.cs +++ b/tests/Dock.Avalonia.HeadlessTests/HostWindowThemeChangeTests.cs @@ -2,6 +2,7 @@ using Avalonia; using Avalonia.Headless.XUnit; using Avalonia.Styling; +using Avalonia.Threading; using Dock.Avalonia.Controls; using Dock.Model.Avalonia; using Dock.Model.Controls; @@ -34,16 +35,22 @@ public void HostWindow_ThemeChange_DoesNotDuplicate_DockControls() window.SetLayout(layout); window.Show(); window.UpdateLayout(); + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); Assert.Single(factory.DockControls); Assert.Same(layout, window.Content); app.RequestedThemeVariant = ThemeVariant.Dark; window.UpdateLayout(); + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); Assert.Single(factory.DockControls); app.RequestedThemeVariant = ThemeVariant.Light; window.UpdateLayout(); + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); Assert.Single(factory.DockControls); } finally From 8473c59855c7bb71d40caa7873e9fa5b25b51647 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Wed, 1 Apr 2026 14:05:10 +0200 Subject: [PATCH 14/18] Add deferred content timeline sample --- Dock.slnx | 1 + samples/DockDeferredContentSample/App.axaml | 8 + .../DockDeferredContentSample/App.axaml.cs | 38 +++ .../Controls/PresenterCardHost.cs | 27 ++ .../DockDeferredContentSample.csproj | 21 ++ .../MainWindow.axaml | 232 ++++++++++++++++++ .../MainWindow.axaml.cs | 17 ++ samples/DockDeferredContentSample/Program.cs | 20 ++ samples/DockDeferredContentSample/README.md | 20 ++ .../ViewModels/MainWindowViewModel.cs | 142 +++++++++++ 10 files changed, 526 insertions(+) create mode 100644 samples/DockDeferredContentSample/App.axaml create mode 100644 samples/DockDeferredContentSample/App.axaml.cs create mode 100644 samples/DockDeferredContentSample/Controls/PresenterCardHost.cs create mode 100644 samples/DockDeferredContentSample/DockDeferredContentSample.csproj create mode 100644 samples/DockDeferredContentSample/MainWindow.axaml create mode 100644 samples/DockDeferredContentSample/MainWindow.axaml.cs create mode 100644 samples/DockDeferredContentSample/Program.cs create mode 100644 samples/DockDeferredContentSample/README.md create mode 100644 samples/DockDeferredContentSample/ViewModels/MainWindowViewModel.cs diff --git a/Dock.slnx b/Dock.slnx index 4ae558fed..03e37ed82 100644 --- a/Dock.slnx +++ b/Dock.slnx @@ -49,6 +49,7 @@ + diff --git a/samples/DockDeferredContentSample/App.axaml b/samples/DockDeferredContentSample/App.axaml new file mode 100644 index 000000000..ac4d3fec9 --- /dev/null +++ b/samples/DockDeferredContentSample/App.axaml @@ -0,0 +1,8 @@ + + + + + diff --git a/samples/DockDeferredContentSample/App.axaml.cs b/samples/DockDeferredContentSample/App.axaml.cs new file mode 100644 index 000000000..78761e49b --- /dev/null +++ b/samples/DockDeferredContentSample/App.axaml.cs @@ -0,0 +1,38 @@ +using System; +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using Dock.Controls.DeferredContentControl; +using DockDeferredContentSample.ViewModels; + +namespace DockDeferredContentSample; + +public partial class App : Application +{ + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + DeferredContentPresentationSettings.BudgetMode = DeferredContentPresentationBudgetMode.ItemCount; + DeferredContentPresentationSettings.MaxPresentationsPerPass = 2; + DeferredContentPresentationSettings.InitialDelay = TimeSpan.Zero; + DeferredContentPresentationSettings.FollowUpDelay = TimeSpan.FromMilliseconds(40); + + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = new MainWindow + { + DataContext = new MainWindowViewModel() + }; + } + + base.OnFrameworkInitializationCompleted(); + +#if DEBUG + this.AttachDevTools(); +#endif + } +} diff --git a/samples/DockDeferredContentSample/Controls/PresenterCardHost.cs b/samples/DockDeferredContentSample/Controls/PresenterCardHost.cs new file mode 100644 index 000000000..6f385bc65 --- /dev/null +++ b/samples/DockDeferredContentSample/Controls/PresenterCardHost.cs @@ -0,0 +1,27 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; + +namespace DockDeferredContentSample.Controls; + +public class PresenterCardHost : TemplatedControl +{ + public static readonly StyledProperty CardProperty = + AvaloniaProperty.Register(nameof(Card)); + + public static readonly StyledProperty CardTemplateProperty = + AvaloniaProperty.Register(nameof(CardTemplate)); + + public object? Card + { + get => GetValue(CardProperty); + set => SetValue(CardProperty, value); + } + + public IDataTemplate? CardTemplate + { + get => GetValue(CardTemplateProperty); + set => SetValue(CardTemplateProperty, value); + } +} diff --git a/samples/DockDeferredContentSample/DockDeferredContentSample.csproj b/samples/DockDeferredContentSample/DockDeferredContentSample.csproj new file mode 100644 index 000000000..b50d17813 --- /dev/null +++ b/samples/DockDeferredContentSample/DockDeferredContentSample.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + WinExe + False + False + enable + + + + + + + + + + + + + diff --git a/samples/DockDeferredContentSample/MainWindow.axaml b/samples/DockDeferredContentSample/MainWindow.axaml new file mode 100644 index 000000000..13f108b76 --- /dev/null +++ b/samples/DockDeferredContentSample/MainWindow.axaml @@ -0,0 +1,232 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/DockDeferredContentSample/MainWindow.axaml.cs b/samples/DockDeferredContentSample/MainWindow.axaml.cs new file mode 100644 index 000000000..a0ef9fd37 --- /dev/null +++ b/samples/DockDeferredContentSample/MainWindow.axaml.cs @@ -0,0 +1,17 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace DockDeferredContentSample; + +public partial class MainWindow : Window +{ + public MainWindow() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } +} diff --git a/samples/DockDeferredContentSample/Program.cs b/samples/DockDeferredContentSample/Program.cs new file mode 100644 index 000000000..096143a3f --- /dev/null +++ b/samples/DockDeferredContentSample/Program.cs @@ -0,0 +1,20 @@ +using System; +using Avalonia; + +namespace DockDeferredContentSample; + +internal static class Program +{ + [STAThread] + public static void Main(string[] args) + { + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + } + + public static AppBuilder BuildAvaloniaApp() + { + return AppBuilder.Configure() + .UsePlatformDetect() + .LogToTrace(); + } +} diff --git a/samples/DockDeferredContentSample/README.md b/samples/DockDeferredContentSample/README.md new file mode 100644 index 000000000..cbe6915cd --- /dev/null +++ b/samples/DockDeferredContentSample/README.md @@ -0,0 +1,20 @@ +# DockDeferredContentSample + +This sample demonstrates the `Dock.Controls.DeferredContentControl` package directly. + +## What it shows + +- The shared default deferred timeline. +- A scoped timeline applied through the inheritable `DeferredContentScheduling.Timeline` attached property. +- Per-host `DeferredContentScheduling.Delay`. +- Per-host `DeferredContentScheduling.Order`. +- `DeferredContentControl`. +- `DeferredContentPresenter`. +- A templated presenter-contract host that uses `DeferredContentPresenter` internally. +- Count-based and time-based timeline budgets. + +## Run + +```bash +dotnet run --project samples/DockDeferredContentSample/DockDeferredContentSample.csproj +``` diff --git a/samples/DockDeferredContentSample/ViewModels/MainWindowViewModel.cs b/samples/DockDeferredContentSample/ViewModels/MainWindowViewModel.cs new file mode 100644 index 000000000..06417c707 --- /dev/null +++ b/samples/DockDeferredContentSample/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using Avalonia.Media; + +namespace DockDeferredContentSample.ViewModels; + +public sealed class MainWindowViewModel +{ + public MainWindowViewModel() + { + GlobalSlots = + [ + CreateSlot( + "Default A", + "Uses the shared default timeline from DeferredContentPresentationSettings.", + 0, + TimeSpan.Zero, + "Shared default queue with count budgeting.", + "#4E6AF3"), + CreateSlot( + "Default B", + "Keeps the default order so it follows the first host in FIFO order.", + 0, + TimeSpan.Zero, + "Same queue and no scoped overrides.", + "#1E88E5"), + CreateSlot( + "Default C", + "Still resolves through the global timeline and follows the same follow-up cadence.", + 0, + TimeSpan.Zero, + "No scoped timeline, no scoped delay.", + "#00897B") + ]; + + OrderedSlots = + [ + CreateSlot( + "Order 20", + "This host stays later in the shared scope because its order is larger.", + 20, + TimeSpan.FromMilliseconds(120), + "Scoped queue. Lower order wins.", + "#8E24AA"), + CreateSlot( + "Order -10", + "This host jumps ahead of later items inside the same scoped timeline.", + -10, + TimeSpan.Zero, + "Same scope, earlier order, no extra delay.", + "#D81B60"), + CreateSlot( + "Order 5", + "This host lands between the negative and high positive orders.", + 5, + TimeSpan.FromMilliseconds(40), + "Scoped queue with per-host delay override.", + "#FB8C00") + ]; + + PresenterSlots = + [ + CreateSlot( + "Presenter -5", + "Realizes first in the scoped presenter queue because it combines the lowest order with no extra delay.", + -5, + TimeSpan.Zero, + "Lower order first, then FIFO for ties.", + "#00838F"), + CreateSlot( + "Presenter 0", + "Uses DeferredContentPresenter for templates that must keep a ContentPresenter contract and adds a short per-host delay.", + 0, + TimeSpan.FromMilliseconds(30), + "Time-budget scope with a short per-host delay.", + "#3949AB"), + CreateSlot( + "Presenter 10", + "Runs later inside the same scoped presenter queue because the order is larger.", + 10, + TimeSpan.FromMilliseconds(90), + "Same time-budget scope, later order.", + "#6D4C41") + ]; + } + + public string Overview { get; } = + "This sample demonstrates the deferred content package without Dock theme integration noise. " + + "The first section uses the shared default timeline. The second section scopes a custom timeline " + + "to a subtree and overrides per-host order and delay. The last section keeps the ContentPresenter " + + "contract and uses a time-budgeted scoped timeline."; + + public string GlobalSummary { get; } = + "Default behavior stays unchanged: one shared queue, next-pass scheduling, and FIFO ordering when no scoped overrides are supplied."; + + public string OrderedSummary { get; } = + "The border hosts an inherited timeline. Each child keeps that queue but overrides Delay and Order so the same scope can realize in a controlled sequence."; + + public string PresenterSummary { get; } = + "This scope uses DeferredContentPresenter instead of DeferredContentControl and switches the queue to realization-time budgeting. The first card has no extra delay so the scope does not look blank on first paint, while later cards still stage through order and delay."; + + public IReadOnlyList GlobalSlots { get; } + + public IReadOnlyList OrderedSlots { get; } + + public IReadOnlyList PresenterSlots { get; } + + private static DeferredSampleSlot CreateSlot( + string name, + string hint, + int order, + TimeSpan delay, + string notes, + string accent) + { + return new DeferredSampleSlot( + name, + hint, + order, + delay, + $"order={order} delay={delay.TotalMilliseconds:0}ms", + new DeferredSampleCard( + $"{name} Realized", + $"Scope order {order}", + notes, + new SolidColorBrush(Color.Parse(accent)))); + } +} + +public sealed record DeferredSampleSlot( + string Name, + string Hint, + int Order, + TimeSpan Delay, + string Scheduling, + DeferredSampleCard Card); + +public sealed record DeferredSampleCard( + string Title, + string Scope, + string Notes, + IBrush AccentBrush); From 6d1a0d902f658526ab08d0ccf9cdd955a918cf92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Wed, 1 Apr 2026 14:05:19 +0200 Subject: [PATCH 15/18] Document deferred presentation timelines --- README.md | 3 +- .../articles/dock-deferred-content-sample.md | 33 ++++ docfx/articles/dock-deferred-content.md | 164 +++++++++++++++--- docfx/articles/toc.yml | 2 + docfx/index.md | 1 + 5 files changed, 177 insertions(+), 26 deletions(-) create mode 100644 docfx/articles/dock-deferred-content-sample.md diff --git a/README.md b/README.md index f9cb977f9..a63be4ff2 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ A docking layout system. - **ItemsSource Support**: Bind document collections directly to DocumentDock for automatic document management - **Flexible Content Templates**: Use DocumentTemplate for customizable document content rendering - **Optional Document Content Caching**: Keep document views alive across tab switches via theme option (`CacheDocumentTabContent`) -- **Deferred Content Materialization**: Defer expensive content presenter work to the next dispatcher frame in Dock themes or custom templates +- **Deferred Content Materialization**: Defer expensive content presenter work with shared or scoped timelines, per-host delay, and explicit ordering - **Multiple MVVM Frameworks**: Support for ReactiveUI, Prism, ReactiveProperty, and standard MVVM patterns - **Comprehensive Serialization**: Save and restore layouts with multiple format options (JSON, XML, YAML, Protobuf) - **Rich Theming**: Fluent and Simple themes with full customization support @@ -129,6 +129,7 @@ Install-Package Dock.Avalonia.Themes.Browser -Pre - **`DockXamlSample`** - XAML layouts with ItemsSource examples - **`DockMvvmSample`** - Full MVVM implementation - **`DockReactiveUISample`** - ReactiveUI patterns + - **`DockDeferredContentSample`** - Deferred timeline scopes, delay, order, and presenter-host behavior - **`DockOfficeSample`** - Office-style workspaces with ReactiveUI navigation - **`DockCodeOnlySample`** - Pure C# layouts - **`Notepad`** - Real-world text editor example diff --git a/docfx/articles/dock-deferred-content-sample.md b/docfx/articles/dock-deferred-content-sample.md new file mode 100644 index 000000000..faed88b30 --- /dev/null +++ b/docfx/articles/dock-deferred-content-sample.md @@ -0,0 +1,33 @@ +# Deferred Content Sample + +The deferred content sample is a focused app for `Dock.Controls.DeferredContentControl`. + +## Sample project + +- `samples/DockDeferredContentSample/DockDeferredContentSample.csproj` +- `samples/DockDeferredContentSample/MainWindow.axaml` + +## What it demonstrates + +- The shared default timeline configured through `DeferredContentPresentationSettings`. +- A scoped `DeferredContentPresentationTimeline` applied with `DeferredContentScheduling.Timeline`. +- Per-host `DeferredContentScheduling.Delay`. +- Per-host `DeferredContentScheduling.Order`. +- `DeferredContentControl`. +- `DeferredContentPresenter`. +- A templated presenter-contract host that feeds `DeferredContentPresenter` through template bindings. +- Count-based and realization-time budgets. + +## Run the sample + +```bash +dotnet run --project samples/DockDeferredContentSample/DockDeferredContentSample.csproj +``` + +## What to validate + +- The first group uses the shared default queue. +- The second group realizes in scoped order with per-host delays. +- The third group keeps a `ContentPresenter` contract while using a scoped time-budgeted timeline. +- The first presenter card in the third group appears immediately, while the later cards still stage through the scoped `Delay` and `Order` values. +- The three sections can behave differently because they do not share one queue. diff --git a/docfx/articles/dock-deferred-content.md b/docfx/articles/dock-deferred-content.md index bf7be3560..11df25a58 100644 --- a/docfx/articles/dock-deferred-content.md +++ b/docfx/articles/dock-deferred-content.md @@ -1,23 +1,34 @@ # Deferred Content Presentation -Dock can defer expensive content materialization to the next dispatcher render pass. This is useful when initial template resolution and later style resolution make document, tool, or window content noticeably heavy during first layout. +Dock can defer expensive content materialization so presenter-heavy layouts do not pay for template creation, logical attachment, and style activation inside the first measure burst. -The feature lives in the `Dock.Controls.DeferredContentControl` package and exposes two hosts: +The feature lives in the `Dock.Controls.DeferredContentControl` package and exposes: - `DeferredContentControl` for templates that can use a `ContentControl`. - `DeferredContentPresenter` for templates that must keep a `ContentPresenter` contract. +- `DeferredContentPresentationTimeline` for shared scoped queues. +- `DeferredContentScheduling` for inherited `Timeline`, `Delay`, and `Order` attached properties. -## Adding the package - -Include the package alongside your normal Dock references: +## Add the package ```bash dotnet add package Dock.Controls.DeferredContentControl ``` -## Using DeferredContentControl in custom themes +## Default behavior + +If you do nothing beyond replacing an eager content host with `DeferredContentControl` or `DeferredContentPresenter`, the package keeps the current default behavior: + +- a shared default queue, +- next-pass presentation, +- FIFO ordering for equal items, +- count-based batching through `DeferredContentPresentationSettings`. -Use `DeferredContentControl` in places where a Dock theme would otherwise materialize heavy content immediately. +That keeps existing Dock theme behavior unchanged. + +## Use DeferredContentControl + +Use `DeferredContentControl` in theme templates that do not require a `ContentPresenter`-typed part: ```xaml ``` -This keeps the requested content and template, then applies them on the next dispatcher frame. Multiple content changes before the flush are batched into one materialization pass. +The host keeps the latest `Content` and `ContentTemplate`, then forwards them to its inner presenter when the deferred queue grants that target a turn. -## Configuring the queue budget +## Use DeferredContentPresenter -The deferred queue is shared by all deferred hosts. You can configure it globally through `DeferredContentPresentationSettings`. +Some templates must keep a `ContentPresenter` contract. In that case, use `DeferredContentPresenter` directly: -Count-based budget: +```xaml + +``` + +This is the right choice for host windows, chrome templates, or any control contract that names the part as a `ContentPresenter`. + +## Configure the default timeline + +`DeferredContentPresentationSettings` now configures the shared default timeline: ```csharp DeferredContentPresentationSettings.BudgetMode = DeferredContentPresentationBudgetMode.ItemCount; DeferredContentPresentationSettings.MaxPresentationsPerPass = 3; +DeferredContentPresentationSettings.InitialDelay = TimeSpan.Zero; +DeferredContentPresentationSettings.FollowUpDelay = TimeSpan.FromMilliseconds(16); ``` -Time-based budget: +For a realization-time budget: ```csharp DeferredContentPresentationSettings.BudgetMode = DeferredContentPresentationBudgetMode.RealizationTime; DeferredContentPresentationSettings.MaxRealizationTimePerPass = TimeSpan.FromMilliseconds(10); +DeferredContentPresentationSettings.FollowUpDelay = TimeSpan.FromMilliseconds(33); ``` -Use item-count budgeting when you want predictable batching by host count. Use time-based budgeting when you want to cap the total realization time spent in one dispatcher pass. +Properties: + +- `BudgetMode` +- `MaxPresentationsPerPass` +- `MaxRealizationTimePerPass` +- `InitialDelay` +- `FollowUpDelay` +- `DefaultTimeline` -## Using DeferredContentPresenter +## Scope a timeline to a subtree -Some Dock templates require the named part to remain a `ContentPresenter`. In those cases use `DeferredContentPresenter` instead of `DeferredContentControl`. +Create a `DeferredContentPresentationTimeline` resource and attach it to a container with `DeferredContentScheduling.Timeline`. ```xaml - + + + + + + + + + + ``` -This is the right choice for hosts such as custom chrome windows or any template that relies on a `ContentPresenter`-typed part. +Every deferred host in that subtree shares the same scoped queue. A different subtree can attach a different timeline and realize independently. -## Opting out for specific content +## Set per-host delay and order + +`DeferredContentScheduling.Delay` and `DeferredContentScheduling.Order` are inheritable attached properties. You can set them on a scope root or on individual hosts. + +```xaml + + + + + + + +``` + +Rules: + +- lower `Order` values realize first, +- equal `Order` values preserve FIFO behavior, +- `Delay` adds per-host time on top of the timeline's `InitialDelay`, +- scoped order and delay work in both count-based and time-based budgets. + +If you configure a non-zero `InitialDelay` or large per-host `Delay` values, a host can legitimately remain blank until its scheduled turn. Use smaller delays, or stage visible placeholder UI outside the deferred host, when first-paint completeness matters. + +## How scopes and budgets interact + +Every `DeferredContentPresentationTimeline` owns a separate queue and scheduler. That means you can mix different policies in one app: + +- one subtree can use `ItemCount` batching, +- another subtree can use `RealizationTime` batching, +- each subtree can use different `InitialDelay` and `FollowUpDelay`, +- per-host `Delay` and `Order` still apply inside each scope. + +This makes deferred loading composable in the same way Dock scopes control recycling. + +## Opt out for specific content If a content object must stay synchronous, implement `IDeferredContentPresentation` and return `false`. @@ -86,12 +168,44 @@ public sealed class ManagedDockWindowDocument : IDeferredContentPresentation Dock uses this for managed floating-window content that should not be delayed. -## Built-in theme behavior +## Built-in Dock theme behavior + +The Fluent and Simple Dock themes already use deferred presentation for the heavy content hosts: + +- document content, +- tool content, +- document and tool active presenters, +- MDI document content, +- split-view content, +- pinned content, +- root and dock-level active hosts, +- host-window and chrome presenter paths. + +Cached document tab content stays eager by design because that path intentionally prebuilds hidden tabs. + +## Sample + +The repository includes a focused sample that demonstrates: -The Fluent and Simple themes use deferred content presentation for the main heavy content hosts, including document, tool, MDI, split-view, pinned, root, and host-window content paths. Cached document tab content stays eager by design because that path intentionally prebuilds hidden tabs. +- the shared default timeline, +- scoped timelines, +- inherited `Delay`, +- inherited `Order`, +- `DeferredContentControl`, +- `DeferredContentPresenter` through a presenter-contract templated host, +- count-based and time-based budget modes. + +See [Deferred content sample](dock-deferred-content-sample.md). + +Run it with: + +```bash +dotnet run --project samples/DockDeferredContentSample/DockDeferredContentSample.csproj +``` ## Related guides +- [Deferred content sample](dock-deferred-content-sample.md) - [Control recycling](dock-control-recycling.md) - [Custom Dock themes](dock-custom-theme.md) - [Styling and theming](dock-styling.md) diff --git a/docfx/articles/toc.yml b/docfx/articles/toc.yml index acb86fdb7..af21f51d4 100644 --- a/docfx/articles/toc.yml +++ b/docfx/articles/toc.yml @@ -146,6 +146,8 @@ href: dock-selector-overlay.md - name: Deferred content presentation href: dock-deferred-content.md + - name: Deferred content sample + href: dock-deferred-content-sample.md - name: Control recycling href: dock-control-recycling.md - name: Proportional StackPanel diff --git a/docfx/index.md b/docfx/index.md index 05dbcd4a7..aa43157b3 100644 --- a/docfx/index.md +++ b/docfx/index.md @@ -75,6 +75,7 @@ Recommended path: - [Quick start](articles/quick-start.md) - [Document and tool content guide](articles/dock-content-guide.md) - [Deferred content presentation](articles/dock-deferred-content.md) +- [Deferred content sample](articles/dock-deferred-content-sample.md) - [Document and tool ItemsSource guide](articles/dock-itemssource.md) - [API documentation](api/index.md) From 9e0680eb35e6c00524c2de18cdfdd4bc96804ada Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Wed, 1 Apr 2026 15:31:58 +0200 Subject: [PATCH 16/18] Refine deferred content reveal behavior --- .../DeferredContentControl.cs | 131 ++++++++- .../DeferredContentControlTests.cs | 256 ++++++++++++++++++ 2 files changed, 381 insertions(+), 6 deletions(-) diff --git a/src/Dock.Controls.DeferredContentControl/DeferredContentControl.cs b/src/Dock.Controls.DeferredContentControl/DeferredContentControl.cs index e295b174c..8cd5628e7 100644 --- a/src/Dock.Controls.DeferredContentControl/DeferredContentControl.cs +++ b/src/Dock.Controls.DeferredContentControl/DeferredContentControl.cs @@ -5,6 +5,8 @@ using System.Diagnostics; using System.Threading.Tasks; using Avalonia; +using Avalonia.Animation; +using Avalonia.Animation.Easings; using Avalonia.Controls; using Avalonia.Controls.Metadata; using Avalonia.Controls.Presenters; @@ -13,6 +15,7 @@ using Avalonia.Data; using Avalonia.LogicalTree; using Avalonia.Threading; +using Avalonia.Visuals; namespace Dock.Controls.DeferredContentControl; @@ -54,6 +57,7 @@ public sealed class DeferredContentPresentationTimeline private TimeSpan _maxRealizationTimePerPass = TimeSpan.FromMilliseconds(10); private TimeSpan _initialDelay = TimeSpan.Zero; private TimeSpan _followUpDelay = TimeSpan.FromMilliseconds(1); + private TimeSpan _revealDuration = TimeSpan.FromMilliseconds(90); /// /// Initializes a new instance of the class. @@ -108,6 +112,15 @@ public TimeSpan FollowUpDelay set => _followUpDelay = value >= TimeSpan.Zero ? value : TimeSpan.Zero; } + /// + /// Gets or sets the short opacity reveal duration applied when deferred content becomes visible. Set to zero to disable the reveal animation. + /// + public TimeSpan RevealDuration + { + get => _revealDuration; + set => _revealDuration = value >= TimeSpan.Zero ? value : TimeSpan.Zero; + } + internal bool AutoSchedule { get => _queue.AutoSchedule; @@ -188,6 +201,15 @@ public static TimeSpan FollowUpDelay get => s_defaultTimeline.FollowUpDelay; set => s_defaultTimeline.FollowUpDelay = value; } + + /// + /// Gets or sets the short opacity reveal duration applied when deferred content becomes visible through the shared default timeline. Set to zero to disable the reveal animation. + /// + public static TimeSpan RevealDuration + { + get => s_defaultTimeline.RevealDuration; + set => s_defaultTimeline.RevealDuration = value; + } } /// @@ -366,6 +388,11 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang } internal bool ApplyDeferredPresentation() + { + return ApplyDeferredPresentation(animateReveal: true); + } + + internal bool ApplyDeferredPresentation(bool animateReveal) { if (!IsReadyForPresentation()) { @@ -382,7 +409,10 @@ internal bool ApplyDeferredPresentation() return true; } - ApplyDeferredState(_presenter!, content, contentTemplate); + var revealDuration = animateReveal + ? DeferredContentPresentationTargetHelpers.ResolveRevealDuration(_enqueuedTimeline, this) + : TimeSpan.Zero; + ApplyDeferredState(_presenter!, content, contentTemplate, revealDuration); _appliedContent = content; _appliedContentTemplate = contentTemplate; _appliedVersion = _requestedVersion; @@ -405,7 +435,7 @@ private void QueueDeferredPresentation() if (Content is IDeferredContentPresentation { DeferContentPresentation: false }) { RemoveQueuedPresentation(); - ApplyDeferredPresentation(); + ApplyDeferredPresentation(animateReveal: false); return; } @@ -433,16 +463,21 @@ private void RemoveQueuedPresentation() } } - private static void ApplyDeferredState(ContentPresenter presenter, object? content, IDataTemplate? contentTemplate) + private static void ApplyDeferredState(ContentPresenter presenter, object? content, IDataTemplate? contentTemplate, TimeSpan revealDuration) { if (presenter is DeferredContentPresenter deferredPresenter) { - deferredPresenter.ApplyDeferredState(content, contentTemplate); + deferredPresenter.ApplyDeferredState(content, contentTemplate, revealDuration); return; } + var hadPresentedChild = presenter.Child is not null; presenter.SetCurrentValue(ContentPresenter.ContentTemplateProperty, contentTemplate); presenter.SetCurrentValue(ContentPresenter.ContentProperty, content); + DeferredContentPresentationTargetHelpers.ApplyRevealAnimation( + presenter, + revealDuration, + hadPresentedChild && content is not null && presenter.Child is not null); } } @@ -538,6 +573,12 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang internal void ApplyDeferredState(object? content, IDataTemplate? contentTemplate) { + ApplyDeferredState(content, contentTemplate, TimeSpan.Zero); + } + + internal void ApplyDeferredState(object? content, IDataTemplate? contentTemplate, TimeSpan revealDuration) + { + var hadPresentedChild = Child is not null; _suppressDeferredUpdates = true; try @@ -551,6 +592,10 @@ internal void ApplyDeferredState(object? content, IDataTemplate? contentTemplate } UpdatePresentedChild(); + DeferredContentPresentationTargetHelpers.ApplyRevealAnimation( + this, + revealDuration, + hadPresentedChild && content is not null && Child is not null); } bool IDeferredContentPresentationTarget.ApplyDeferredPresentation() @@ -559,6 +604,11 @@ bool IDeferredContentPresentationTarget.ApplyDeferredPresentation() } internal bool ApplyDeferredPresentation() + { + return ApplyDeferredPresentation(animateReveal: true); + } + + internal bool ApplyDeferredPresentation(bool animateReveal) { if (!IsReadyForPresentation()) { @@ -575,7 +625,10 @@ internal bool ApplyDeferredPresentation() return true; } - ApplyDeferredState(content, contentTemplate); + var revealDuration = animateReveal + ? DeferredContentPresentationTargetHelpers.ResolveRevealDuration(_enqueuedTimeline, this) + : TimeSpan.Zero; + ApplyDeferredState(content, contentTemplate, revealDuration); _appliedContent = content; _appliedContentTemplate = contentTemplate; _appliedVersion = _requestedVersion; @@ -607,7 +660,7 @@ private void QueueDeferredPresentation() if (_requestedContent is IDeferredContentPresentation { DeferContentPresentation: false }) { RemoveQueuedPresentation(); - ApplyDeferredPresentation(); + ApplyDeferredPresentation(animateReveal: false); return; } @@ -1008,6 +1061,13 @@ internal static void FlushPendingBatchForTesting() internal static class DeferredContentPresentationTargetHelpers { + private const double RevealStartingOpacity = 0.85D; + private static readonly CubicEaseOut s_revealEasing = new(); + + private sealed class DeferredRevealTransition : DoubleTransition + { + } + internal static DeferredContentPresentationTimeline ResolveTimeline(AvaloniaObject target) { return DeferredContentScheduling.GetTimeline(target) ?? DeferredContentPresentationSettings.DefaultTimeline; @@ -1023,4 +1083,63 @@ internal static int ResolveOrder(AvaloniaObject target) { return DeferredContentScheduling.GetOrder(target); } + + internal static TimeSpan ResolveRevealDuration(DeferredContentPresentationTimeline? activeTimeline, AvaloniaObject target) + { + return (activeTimeline ?? ResolveTimeline(target)).RevealDuration; + } + + internal static void ApplyRevealAnimation(Control control, TimeSpan revealDuration, bool shouldReveal) + { + if (!shouldReveal || revealDuration <= TimeSpan.Zero) + { + control.Opacity = 1D; + return; + } + + EnsureRevealTransition(control, revealDuration); + + control.Opacity = RevealStartingOpacity; + Dispatcher.UIThread.Post(() => control.SetCurrentValue(Visual.OpacityProperty, 1D), DispatcherPriority.Background); + } + + private static void EnsureRevealTransition(Control control, TimeSpan revealDuration) + { + if (control.Transitions is { } transitions) + { + foreach (var transition in transitions) + { + if (transition is DeferredRevealTransition revealTransition) + { + revealTransition.Duration = revealDuration; + revealTransition.Easing = s_revealEasing; + return; + } + + if (transition is DoubleTransition doubleTransition + && doubleTransition.Property == Visual.OpacityProperty) + { + return; + } + } + + transitions.Add(CreateRevealTransition(revealDuration)); + return; + } + + control.Transitions = + [ + CreateRevealTransition(revealDuration) + ]; + } + + private static DeferredRevealTransition CreateRevealTransition(TimeSpan revealDuration) + { + return new DeferredRevealTransition + { + Property = Visual.OpacityProperty, + Duration = revealDuration, + Easing = s_revealEasing + }; + } } diff --git a/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs b/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs index 4d6a46f61..377693cb5 100644 --- a/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs +++ b/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Threading; using Avalonia; +using Avalonia.Animation; using Avalonia.Controls; using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; @@ -10,6 +11,7 @@ using Avalonia.Data; using Avalonia.Styling; using Avalonia.Threading; +using Avalonia.Visuals; using Avalonia.VisualTree; using Dock.Avalonia.Controls; using Dock.Avalonia.Themes.Fluent; @@ -376,6 +378,256 @@ public void DeferredContentControl_Limits_Realization_Batch_Per_Render_Tick() } } + [AvaloniaFact] + public void DeferredContentControl_Does_Not_Reveal_From_Blank_On_First_Materialization() + { + using var _ = new DeferredBatchLimitScope(autoSchedule: false, revealDuration: TimeSpan.FromMilliseconds(50)); + var template = new CountingTemplate(() => new TextBlock { Text = "Reveal" }); + var control = new DeferredContentControl + { + Content = "Reveal", + ContentTemplate = template + }; + var window = new Window + { + Width = 800, + Height = 600, + Content = control + }; + + window.Show(); + control.ApplyTemplate(); + window.UpdateLayout(); + + try + { + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + + DeferredContentPresentationQueue.FlushPendingBatchForTesting(); + + Assert.NotNull(control.Presenter?.Child); + Assert.Equal(1D, control.Presenter.Opacity); + Assert.Null(control.Presenter.Transitions); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void DeferredContentControl_Reveals_When_Replacing_Already_Presented_Content() + { + using var _ = new DeferredBatchLimitScope(autoSchedule: false, revealDuration: TimeSpan.FromMilliseconds(50)); + var template = new CountingTemplate(() => new TextBlock()); + var control = new DeferredContentControl + { + Content = "First", + ContentTemplate = template + }; + var window = new Window + { + Width = 800, + Height = 600, + Content = control + }; + + window.Show(); + control.ApplyTemplate(); + window.UpdateLayout(); + + try + { + DrainDeferredQueueBatch(window); + + var firstTextBlock = Assert.IsType(control.Presenter!.Child); + Assert.Equal("First", firstTextBlock.DataContext); + + control.Content = "Second"; + window.UpdateLayout(); + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + + DeferredContentPresentationQueue.FlushPendingBatchForTesting(); + + var opacityTransition = Assert.Single(control.Presenter.Transitions!.OfType()); + Assert.Equal(TimeSpan.FromMilliseconds(50), opacityTransition.Duration); + Assert.True(control.Presenter.Opacity > 0.5D); + + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + + var secondTextBlock = Assert.IsType(control.Presenter.Child); + Assert.Equal("Second", secondTextBlock.DataContext); + Assert.Equal(1D, control.Presenter.Opacity); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void ToolControl_Switching_Active_Tool_Keeps_New_Content_Visible_During_Reveal() + { + using var _ = new DeferredBatchLimitScope(autoSchedule: false, revealDuration: TimeSpan.FromMilliseconds(50)); + var factory = new Factory(); + var dock = new ToolDock + { + Factory = factory, + VisibleDockables = factory.CreateList() + }; + + var firstTool = new Tool + { + Id = "tool-1", + Title = "Tool 1", + Content = (Func)(_ => new TextBlock { Text = "ToolOne" }) + }; + var secondTool = new Tool + { + Id = "tool-2", + Title = "Tool 2", + Content = (Func)(_ => new TextBlock { Text = "ToolTwo" }) + }; + + dock.VisibleDockables!.Add(firstTool); + dock.VisibleDockables.Add(secondTool); + dock.ActiveDockable = firstTool; + + var control = new ToolControl + { + DataContext = dock + }; + + var window = ShowInWindow(control, new DockFluentTheme()); + var presenterHost = GetDeferredPresenter(control); + + try + { + DrainDeferredQueueBatch(window); + + var firstTextBlock = Assert.IsType(presenterHost.Presenter!.Child); + Assert.Equal("ToolOne", firstTextBlock.Text); + + dock.ActiveDockable = secondTool; + window.UpdateLayout(); + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + + DeferredContentPresentationQueue.FlushPendingBatchForTesting(); + + var secondTextBlock = Assert.IsType(presenterHost.Presenter.Child); + Assert.Equal("ToolTwo", secondTextBlock.Text); + Assert.True(presenterHost.Presenter.Opacity > 0.5D); + + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + + Assert.Equal(1D, presenterHost.Presenter.Opacity); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void DeferredContentControl_Reveal_Does_Not_Replace_Existing_Opacity_Transition() + { + using var _ = new DeferredBatchLimitScope(autoSchedule: false, revealDuration: TimeSpan.FromMilliseconds(50)); + var template = new CountingTemplate(() => new TextBlock { Text = "ExistingTransition" }); + var control = new DeferredContentControl + { + Content = "ExistingTransition", + ContentTemplate = template + }; + var window = new Window + { + Width = 800, + Height = 600, + Content = control + }; + + window.Show(); + control.ApplyTemplate(); + window.UpdateLayout(); + + try + { + DrainDeferredQueueBatch(window); + + var transitions = new Transitions + { + new DoubleTransition + { + Property = Visual.OpacityProperty, + Duration = TimeSpan.FromMilliseconds(250) + } + }; + control.Presenter!.Transitions = transitions; + control.Content = "Updated"; + + window.UpdateLayout(); + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + + DeferredContentPresentationQueue.FlushPendingBatchForTesting(); + + Assert.Same(transitions, control.Presenter.Transitions); + var opacityTransition = Assert.Single(control.Presenter.Transitions!.OfType()); + Assert.Equal(TimeSpan.FromMilliseconds(250), opacityTransition.Duration); + + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + + Assert.Equal(1D, control.Presenter.Opacity); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void DeferredContentControl_RevealDuration_Zero_Skips_Opacity_Staging() + { + using var _ = new DeferredBatchLimitScope(autoSchedule: false, revealDuration: TimeSpan.Zero); + var template = new CountingTemplate(() => new TextBlock { Text = "Immediate" }); + var control = new DeferredContentControl + { + Content = "Immediate", + ContentTemplate = template + }; + var window = new Window + { + Width = 800, + Height = 600, + Content = control + }; + + window.Show(); + control.ApplyTemplate(); + window.UpdateLayout(); + + try + { + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + + DeferredContentPresentationQueue.FlushPendingBatchForTesting(); + + Assert.NotNull(control.Presenter?.Child); + Assert.Equal(1D, control.Presenter!.Opacity); + Assert.Null(control.Presenter.Transitions); + } + finally + { + window.Close(); + } + } + [AvaloniaFact] public void DeferredContentQueue_Does_Not_Starve_Ready_Targets_Behind_NotReady_Ones() { @@ -1603,6 +1855,7 @@ private sealed class DeferredBatchLimitScope : IDisposable private readonly TimeSpan _previousMaxRealizationTime = DeferredContentPresentationSettings.MaxRealizationTimePerPass; private readonly TimeSpan _previousInitialDelay = DeferredContentPresentationSettings.InitialDelay; private readonly TimeSpan _previousFollowUpDelay = DeferredContentPresentationSettings.FollowUpDelay; + private readonly TimeSpan _previousRevealDuration = DeferredContentPresentationSettings.RevealDuration; private readonly bool _previousAutoSchedule = DeferredContentPresentationQueue.AutoSchedule; public DeferredBatchLimitScope( @@ -1611,6 +1864,7 @@ public DeferredBatchLimitScope( TimeSpan? maxRealizationTimePerPass = null, TimeSpan? initialDelay = null, TimeSpan? followUpDelay = null, + TimeSpan? revealDuration = null, bool autoSchedule = true) { DeferredContentPresentationSettings.BudgetMode = budgetMode; @@ -1618,6 +1872,7 @@ public DeferredBatchLimitScope( DeferredContentPresentationSettings.MaxRealizationTimePerPass = maxRealizationTimePerPass ?? TimeSpan.FromMilliseconds(10); DeferredContentPresentationSettings.InitialDelay = initialDelay ?? TimeSpan.Zero; DeferredContentPresentationSettings.FollowUpDelay = followUpDelay ?? TimeSpan.FromMilliseconds(1); + DeferredContentPresentationSettings.RevealDuration = revealDuration ?? TimeSpan.FromMilliseconds(90); DeferredContentPresentationQueue.AutoSchedule = autoSchedule; } @@ -1628,6 +1883,7 @@ public void Dispose() DeferredContentPresentationSettings.MaxRealizationTimePerPass = _previousMaxRealizationTime; DeferredContentPresentationSettings.InitialDelay = _previousInitialDelay; DeferredContentPresentationSettings.FollowUpDelay = _previousFollowUpDelay; + DeferredContentPresentationSettings.RevealDuration = _previousRevealDuration; DeferredContentPresentationQueue.AutoSchedule = _previousAutoSchedule; } } From 99ed42cbff984ffe27c0f12c19f587b02fa36aac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Wed, 1 Apr 2026 15:32:17 +0200 Subject: [PATCH 17/18] Document deferred content reveal behavior --- docfx/articles/dock-deferred-content.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/docfx/articles/dock-deferred-content.md b/docfx/articles/dock-deferred-content.md index 11df25a58..360e5f93f 100644 --- a/docfx/articles/dock-deferred-content.md +++ b/docfx/articles/dock-deferred-content.md @@ -22,7 +22,8 @@ If you do nothing beyond replacing an eager content host with `DeferredContentCo - a shared default queue, - next-pass presentation, - FIFO ordering for equal items, -- count-based batching through `DeferredContentPresentationSettings`. +- count-based batching through `DeferredContentPresentationSettings`, +- a short opacity reveal to smooth later deferred content swaps without hiding first paint. That keeps existing Dock theme behavior unchanged. @@ -73,6 +74,7 @@ DeferredContentPresentationSettings.BudgetMode = DeferredContentPresentationBudg DeferredContentPresentationSettings.MaxPresentationsPerPass = 3; DeferredContentPresentationSettings.InitialDelay = TimeSpan.Zero; DeferredContentPresentationSettings.FollowUpDelay = TimeSpan.FromMilliseconds(16); +DeferredContentPresentationSettings.RevealDuration = TimeSpan.FromMilliseconds(90); ``` For a realization-time budget: @@ -81,6 +83,7 @@ For a realization-time budget: DeferredContentPresentationSettings.BudgetMode = DeferredContentPresentationBudgetMode.RealizationTime; DeferredContentPresentationSettings.MaxRealizationTimePerPass = TimeSpan.FromMilliseconds(10); DeferredContentPresentationSettings.FollowUpDelay = TimeSpan.FromMilliseconds(33); +DeferredContentPresentationSettings.RevealDuration = TimeSpan.FromMilliseconds(90); ``` Properties: @@ -90,8 +93,11 @@ Properties: - `MaxRealizationTimePerPass` - `InitialDelay` - `FollowUpDelay` +- `RevealDuration` - `DefaultTimeline` +Set `RevealDuration` to `TimeSpan.Zero` to disable the reveal transition. + ## Scope a timeline to a subtree Create a `DeferredContentPresentationTimeline` resource and attach it to a container with `DeferredContentScheduling.Timeline`. @@ -155,6 +161,18 @@ Every `DeferredContentPresentationTimeline` owns a separate queue and scheduler. This makes deferred loading composable in the same way Dock scopes control recycling. +## Smooth the deferred reveal + +Deferred loading can still cause a visible pop when content arrives on a later dispatcher turn. The package smooths later deferred content swaps by fading the presenter in over `RevealDuration`. + +The first realization from a blank host stays immediate so startup and structural dock hosts are not hidden behind an extra opacity handoff. + +That improves the perceived refresh, but it does not change measurement correctness. If a host is auto-sized and its true size is unknown until the content is materialized, layout can still change when the real content arrives. In those cases, use one or more of: + +- explicit width or height on the deferred host, +- placeholder chrome outside the deferred host, +- smaller timeline delays so the content lands closer to the initial layout burst. + ## Opt out for specific content If a content object must stay synchronous, implement `IDeferredContentPresentation` and return `false`. From ec927e874fe54ec4673266197ba5127e4a8a495b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Wed, 1 Apr 2026 21:43:56 +0200 Subject: [PATCH 18/18] Fix deferred-content CI hangs --- .../Internal/DragPreviewHelper.cs | 31 ++++++++++-- .../DeferredContentControl.cs | 12 +++++ .../AutomationReaderCompatibilityTests.cs | 6 +++ .../DeferredContentControlTests.cs | 30 ++++++++++++ .../ManagedWindowParityTests.cs | 48 +++++++++++++++++++ .../LeakTestHelpers.cs | 2 + 6 files changed, 125 insertions(+), 4 deletions(-) diff --git a/src/Dock.Avalonia/Internal/DragPreviewHelper.cs b/src/Dock.Avalonia/Internal/DragPreviewHelper.cs index aed6dcb89..d75a8c691 100644 --- a/src/Dock.Avalonia/Internal/DragPreviewHelper.cs +++ b/src/Dock.Avalonia/Internal/DragPreviewHelper.cs @@ -28,6 +28,8 @@ internal class DragPreviewHelper private static bool s_windowMoveFlushScheduled; private static bool s_windowSizeFrozen; private static bool s_windowSizeFreezeScheduled; + private static bool s_windowSizeFreezeAbandoned; + private static int s_windowSizeFreezeRetryCount; private static double s_frozenWindowWidthPixels; private static double s_frozenWindowHeightPixels; private static double s_frozenContentWidthPixels = double.NaN; @@ -35,6 +37,7 @@ internal class DragPreviewHelper private static double s_lastFrozenWindowScaling = 1.0; private static int s_windowMoveSessionId; private static long s_windowMovePostSequence; + private const int MaxWindowSizeFreezeRetries = 4; private static PixelPoint GetPositionWithinWindow(Window window, PixelPoint position, PixelPoint offset) { @@ -197,7 +200,10 @@ private static void ApplyWindowMove(DragPreviewWindow window, DragPreviewControl MaintainFrozenWindowSize(window, control); } - if (!s_windowSizeFrozen && !s_windowSizeFreezeScheduled && !string.IsNullOrEmpty(status)) + if (!s_windowSizeFrozen + && !s_windowSizeFreezeScheduled + && !s_windowSizeFreezeAbandoned + && !string.IsNullOrEmpty(status)) { s_windowSizeFreezeScheduled = true; Dispatcher.UIThread.Post(FreezeWindowSizeIfNeeded, DispatcherPriority.Render); @@ -265,13 +271,26 @@ private static void FreezeWindowSizeIfNeeded() var bounds = s_window.Bounds; if (bounds.Width <= 0 || bounds.Height <= 0) { - // Retry on the next render pass; on some platforms the preview window - // may not have a stable measured size on the first callback. + if (s_windowSizeFreezeRetryCount >= MaxWindowSizeFreezeRetries) + { + // Some headless/native combinations never produce a stable non-zero size. + // Give up for the current preview session so later move updates do not + // keep reposting freeze work indefinitely. + s_windowSizeFreezeAbandoned = true; + return; + } + + s_windowSizeFreezeRetryCount++; + + // Retry on a later dispatcher turn; some platforms do not have a stable + // preview size on the first callback, but retries must stay bounded. s_windowSizeFreezeScheduled = true; - Dispatcher.UIThread.Post(FreezeWindowSizeIfNeeded, DispatcherPriority.Render); + Dispatcher.UIThread.Post(FreezeWindowSizeIfNeeded, DispatcherPriority.Background); return; } + s_windowSizeFreezeRetryCount = 0; + s_windowSizeFreezeAbandoned = false; var scaling = GetWindowScaling(s_window); s_lastFrozenWindowScaling = scaling; s_frozenWindowWidthPixels = bounds.Width * scaling; @@ -354,6 +373,8 @@ public void Show(IDockable dockable, PixelPoint position, PixelPoint offset, Vis s_windowMovePostSequence = 0; s_windowSizeFrozen = false; s_windowSizeFreezeScheduled = false; + s_windowSizeFreezeAbandoned = false; + s_windowSizeFreezeRetryCount = 0; s_frozenWindowWidthPixels = 0; s_frozenWindowHeightPixels = 0; s_frozenContentWidthPixels = double.NaN; @@ -465,6 +486,8 @@ public void Hide() s_windowMovePostSequence = 0; s_windowSizeFrozen = false; s_windowSizeFreezeScheduled = false; + s_windowSizeFreezeAbandoned = false; + s_windowSizeFreezeRetryCount = 0; s_frozenWindowWidthPixels = 0; s_frozenWindowHeightPixels = 0; s_frozenContentWidthPixels = double.NaN; diff --git a/src/Dock.Controls.DeferredContentControl/DeferredContentControl.cs b/src/Dock.Controls.DeferredContentControl/DeferredContentControl.cs index 8cd5628e7..c06c73ca1 100644 --- a/src/Dock.Controls.DeferredContentControl/DeferredContentControl.cs +++ b/src/Dock.Controls.DeferredContentControl/DeferredContentControl.cs @@ -333,6 +333,8 @@ static DeferredContentControl() set => _enqueuedTimeline = value; } + bool IDeferredContentPresentationTarget.RetainPendingPresentationOnFailure => false; + /// protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { @@ -485,6 +487,8 @@ internal interface IDeferredContentPresentationTarget { DeferredContentPresentationTimeline? EnqueuedTimeline { get; set; } + bool RetainPendingPresentationOnFailure { get; } + bool ApplyDeferredPresentation(); } @@ -509,6 +513,8 @@ public class DeferredContentPresenter : ContentPresenter, IDeferredContentPresen set => _enqueuedTimeline = value; } + bool IDeferredContentPresentationTarget.RetainPendingPresentationOnFailure => false; + /// protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { @@ -890,6 +896,12 @@ private void FlushScheduledBatch(long scheduledVersion) RemovePending(entry.Target); completedCount++; } + else if (!entry.Target.RetainPendingPresentationOnFailure) + { + // Real control targets re-enqueue themselves from attach/template/content change hooks. + // Dropping not-ready items avoids hot polling loops when a dispatcher drain runs eagerly. + RemovePending(entry.Target); + } if (!ShouldContinueProcessing(completedCount, batchSize, stopwatch)) { diff --git a/tests/Dock.Avalonia.HeadlessTests/AutomationReaderCompatibilityTests.cs b/tests/Dock.Avalonia.HeadlessTests/AutomationReaderCompatibilityTests.cs index 26fae519b..8954a5811 100644 --- a/tests/Dock.Avalonia.HeadlessTests/AutomationReaderCompatibilityTests.cs +++ b/tests/Dock.Avalonia.HeadlessTests/AutomationReaderCompatibilityTests.cs @@ -5,6 +5,7 @@ using Avalonia.Automation.Provider; using Avalonia.Controls; using Avalonia.Headless.XUnit; +using Avalonia.Threading; using Dock.Avalonia.Controls; using Dock.Avalonia.Selectors; using Dock.Model.Avalonia; @@ -32,13 +33,18 @@ public void AutomationReader_Can_Traverse_DockControl_PeerTree_And_Read_Metadata dockControl.ApplyTemplate(); window.UpdateLayout(); dockControl.UpdateLayout(); + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + dockControl.UpdateLayout(); if (dockControl is IDockSelectorService selectorService) { selectorService.ShowSelector(DockSelectorMode.Documents); } + Dispatcher.UIThread.RunJobs(); window.UpdateLayout(); + dockControl.UpdateLayout(); try { diff --git a/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs b/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs index 377693cb5..30cd3920b 100644 --- a/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs +++ b/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs @@ -64,6 +64,8 @@ private sealed class TestDeferredTarget : IDeferredContentPresentationTarget public bool CanApply { get; set; } + public bool RetainPendingPresentationOnFailure { get; set; } = true; + public int MaxApplyCountBeforeThrow { get; set; } = int.MaxValue; public int ApplyCount { get; private set; } @@ -726,6 +728,34 @@ public void DeferredContentQueue_Does_Not_Revisit_NotReady_Targets_Within_Single } } + [AvaloniaFact] + public void DeferredContentQueue_Removes_NotReady_RealTargets_Instead_Of_Polling_Them() + { + using var _ = new DeferredBatchLimitScope(limit: 2, autoSchedule: false); + + var target = new TestDeferredTarget + { + RetainPendingPresentationOnFailure = false + }; + + DeferredContentPresentationQueue.Enqueue(target); + + try + { + Assert.Equal(1, DeferredContentPresentationQueue.PendingCount); + + DeferredContentPresentationQueue.FlushPendingBatchForTesting(); + + Assert.Equal(1, target.ApplyCount); + Assert.Equal(0, DeferredContentPresentationQueue.PendingCount); + Assert.Null(target.EnqueuedTimeline); + } + finally + { + DeferredContentPresentationQueue.Remove(target); + } + } + [AvaloniaFact] public void DeferredContentQueue_Limits_Realization_Batch_By_Time_Budget() { diff --git a/tests/Dock.Avalonia.HeadlessTests/ManagedWindowParityTests.cs b/tests/Dock.Avalonia.HeadlessTests/ManagedWindowParityTests.cs index 2ebc31e35..d88e8d379 100644 --- a/tests/Dock.Avalonia.HeadlessTests/ManagedWindowParityTests.cs +++ b/tests/Dock.Avalonia.HeadlessTests/ManagedWindowParityTests.cs @@ -644,6 +644,54 @@ public void DragPreviewHelper_Uses_Managed_Layer_From_Visual_Context() } } + [AvaloniaFact] + public void DragPreviewHelper_Does_Not_Reschedule_Size_Freeze_After_Abandoning_Current_Preview() + { + var helper = new DragPreviewHelper(); + var type = typeof(DragPreviewHelper); + var applyWindowMove = type.GetMethod("ApplyWindowMove", BindingFlags.Static | BindingFlags.NonPublic); + var windowField = type.GetField("s_window", BindingFlags.Static | BindingFlags.NonPublic); + var controlField = type.GetField("s_control", BindingFlags.Static | BindingFlags.NonPublic); + var frozenField = type.GetField("s_windowSizeFrozen", BindingFlags.Static | BindingFlags.NonPublic); + var scheduledField = type.GetField("s_windowSizeFreezeScheduled", BindingFlags.Static | BindingFlags.NonPublic); + var abandonedField = type.GetField("s_windowSizeFreezeAbandoned", BindingFlags.Static | BindingFlags.NonPublic); + var retryField = type.GetField("s_windowSizeFreezeRetryCount", BindingFlags.Static | BindingFlags.NonPublic); + + Assert.NotNull(applyWindowMove); + Assert.NotNull(windowField); + Assert.NotNull(controlField); + Assert.NotNull(frozenField); + Assert.NotNull(scheduledField); + Assert.NotNull(abandonedField); + Assert.NotNull(retryField); + + var window = new DragPreviewWindow(); + var control = new DragPreviewControl(); + + helper.Hide(); + + try + { + windowField!.SetValue(null, window); + controlField!.SetValue(null, control); + frozenField!.SetValue(null, false); + scheduledField!.SetValue(null, false); + abandonedField!.SetValue(null, true); + retryField!.SetValue(null, 4); + + applyWindowMove!.Invoke(null, new object[] { window, control, new PixelPoint(24, 42), "Float" }); + + Assert.Equal("Float", control.Status); + Assert.Equal(new PixelPoint(24, 42), window.Position); + Assert.False((bool)scheduledField.GetValue(null)!); + Assert.True((bool)abandonedField.GetValue(null)!); + } + finally + { + helper.Hide(); + } + } + [AvaloniaFact] public void DragPreviewControl_Uses_PreviewContent_When_Set() { diff --git a/tests/Dock.Avalonia.LeakTests/LeakTestHelpers.cs b/tests/Dock.Avalonia.LeakTests/LeakTestHelpers.cs index 5cb017ae1..3d79c2ad9 100644 --- a/tests/Dock.Avalonia.LeakTests/LeakTestHelpers.cs +++ b/tests/Dock.Avalonia.LeakTests/LeakTestHelpers.cs @@ -1626,6 +1626,8 @@ private static void ClearDragPreviewHelper() SetStaticField(type, "s_managedLayer", null); SetStaticField(type, "s_windowTemplatesInitialized", false); SetStaticField(type, "s_managedTemplatesInitialized", false); + SetStaticField(type, "s_windowSizeFreezeAbandoned", false); + SetStaticField(type, "s_windowSizeFreezeRetryCount", 0); } private static void ClearDragPreviewContext()