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
diff --git a/Dock.slnx b/Dock.slnx
index 67be320d5..03e37ed82 100644
--- a/Dock.slnx
+++ b/Dock.slnx
@@ -49,6 +49,7 @@
+
@@ -81,6 +82,7 @@
+
diff --git a/README.md b/README.md
index e11555b66..a63be4ff2 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 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
@@ -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
| [](https://www.nuget.org/packages/Dock.Avalonia.Themes.Fluent) | [`Dock.Avalonia.Themes.Fluent`](https://www.nuget.org/packages/Dock.Avalonia.Themes.Fluent) | [](https://www.nuget.org/packages/Dock.Avalonia.Themes.Fluent) |
| [](https://www.nuget.org/packages/Dock.Avalonia.Themes.Browser) | [`Dock.Avalonia.Themes.Browser`](https://www.nuget.org/packages/Dock.Avalonia.Themes.Browser) | [](https://www.nuget.org/packages/Dock.Avalonia.Themes.Browser) |
| [](https://www.nuget.org/packages/Dock.Avalonia.Themes.Simple) | [`Dock.Avalonia.Themes.Simple`](https://www.nuget.org/packages/Dock.Avalonia.Themes.Simple) | [](https://www.nuget.org/packages/Dock.Avalonia.Themes.Simple) |
+| [](https://www.nuget.org/packages/Dock.Controls.DeferredContentControl) | [`Dock.Controls.DeferredContentControl`](https://www.nuget.org/packages/Dock.Controls.DeferredContentControl) | [](https://www.nuget.org/packages/Dock.Controls.DeferredContentControl) |
| [](https://www.nuget.org/packages/Dock.Controls.ProportionalStackPanel) | [`Dock.Controls.ProportionalStackPanel`](https://www.nuget.org/packages/Dock.Controls.ProportionalStackPanel) | [](https://www.nuget.org/packages/Dock.Controls.ProportionalStackPanel) |
| [](https://www.nuget.org/packages/Dock.Controls.Recycling) | [`Dock.Controls.Recycling`](https://www.nuget.org/packages/Dock.Controls.Recycling) | [](https://www.nuget.org/packages/Dock.Controls.Recycling) |
| [](https://www.nuget.org/packages/Dock.Controls.Recycling.Model) | [`Dock.Controls.Recycling.Model`](https://www.nuget.org/packages/Dock.Controls.Recycling.Model) | [](https://www.nuget.org/packages/Dock.Controls.Recycling.Model) |
@@ -126,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/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-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
new file mode 100644
index 000000000..360e5f93f
--- /dev/null
+++ b/docfx/articles/dock-deferred-content.md
@@ -0,0 +1,229 @@
+# Deferred Content Presentation
+
+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:
+
+- `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.
+
+## Add the package
+
+```bash
+dotnet add package Dock.Controls.DeferredContentControl
+```
+
+## 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`,
+- a short opacity reveal to smooth later deferred content swaps without hiding first paint.
+
+That keeps existing Dock theme behavior unchanged.
+
+## Use DeferredContentControl
+
+Use `DeferredContentControl` in theme templates that do not require a `ContentPresenter`-typed part:
+
+```xaml
+
+
+
+
+
+
+
+
+
+```
+
+The host keeps the latest `Content` and `ContentTemplate`, then forwards them to its inner presenter when the deferred queue grants that target a turn.
+
+## Use DeferredContentPresenter
+
+Some templates must keep a `ContentPresenter` contract. In that case, use `DeferredContentPresenter` directly:
+
+```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);
+DeferredContentPresentationSettings.RevealDuration = TimeSpan.FromMilliseconds(90);
+```
+
+For a realization-time budget:
+
+```csharp
+DeferredContentPresentationSettings.BudgetMode = DeferredContentPresentationBudgetMode.RealizationTime;
+DeferredContentPresentationSettings.MaxRealizationTimePerPass = TimeSpan.FromMilliseconds(10);
+DeferredContentPresentationSettings.FollowUpDelay = TimeSpan.FromMilliseconds(33);
+DeferredContentPresentationSettings.RevealDuration = TimeSpan.FromMilliseconds(90);
+```
+
+Properties:
+
+- `BudgetMode`
+- `MaxPresentationsPerPass`
+- `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`.
+
+```xaml
+
+
+
+
+
+
+
+
+
+
+```
+
+Every deferred host in that subtree shares the same scoped queue. A different subtree can attach a different timeline and realize independently.
+
+## 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.
+
+## 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`.
+
+```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 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 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 ad5a05e30..af21f51d4 100644
--- a/docfx/articles/toc.yml
+++ b/docfx/articles/toc.yml
@@ -144,6 +144,10 @@
href: dock-overlay-customization.md
- name: Selector overlay
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 d824cc61a..aa43157b3 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:
| [](https://www.nuget.org/packages/Dock.Avalonia.Themes.Fluent) | [`Dock.Avalonia.Themes.Fluent`](https://www.nuget.org/packages/Dock.Avalonia.Themes.Fluent) | [](https://www.nuget.org/packages/Dock.Avalonia.Themes.Fluent) |
| [](https://www.nuget.org/packages/Dock.Avalonia.Themes.Browser) | [`Dock.Avalonia.Themes.Browser`](https://www.nuget.org/packages/Dock.Avalonia.Themes.Browser) | [](https://www.nuget.org/packages/Dock.Avalonia.Themes.Browser) |
| [](https://www.nuget.org/packages/Dock.Avalonia.Themes.Simple) | [`Dock.Avalonia.Themes.Simple`](https://www.nuget.org/packages/Dock.Avalonia.Themes.Simple) | [](https://www.nuget.org/packages/Dock.Avalonia.Themes.Simple) |
+| [](https://www.nuget.org/packages/Dock.Controls.DeferredContentControl) | [`Dock.Controls.DeferredContentControl`](https://www.nuget.org/packages/Dock.Controls.DeferredContentControl) | [](https://www.nuget.org/packages/Dock.Controls.DeferredContentControl) |
| [](https://www.nuget.org/packages/Dock.Controls.ProportionalStackPanel) | [`Dock.Controls.ProportionalStackPanel`](https://www.nuget.org/packages/Dock.Controls.ProportionalStackPanel) | [](https://www.nuget.org/packages/Dock.Controls.ProportionalStackPanel) |
| [](https://www.nuget.org/packages/Dock.Controls.Recycling) | [`Dock.Controls.Recycling`](https://www.nuget.org/packages/Dock.Controls.Recycling) | [](https://www.nuget.org/packages/Dock.Controls.Recycling) |
| [](https://www.nuget.org/packages/Dock.Controls.Recycling.Model) | [`Dock.Controls.Recycling.Model`](https://www.nuget.org/packages/Dock.Controls.Recycling.Model) | [](https://www.nuget.org/packages/Dock.Controls.Recycling.Model) |
@@ -72,6 +74,8 @@ 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)
+- [Deferred content sample](articles/dock-deferred-content-sample.md)
- [Document and tool ItemsSource guide](articles/dock-itemssource.md)
- [API documentation](api/index.md)
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);
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/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/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.Themes.Fluent/Controls/ToolControl.axaml b/src/Dock.Avalonia.Themes.Fluent/Controls/ToolControl.axaml
index f1933e8ea..084d5f642 100644
--- a/src/Dock.Avalonia.Themes.Fluent/Controls/ToolControl.axaml
+++ b/src/Dock.Avalonia.Themes.Fluent/Controls/ToolControl.axaml
@@ -55,14 +55,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/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
new file mode 100644
index 000000000..c06c73ca1
--- /dev/null
+++ b/src/Dock.Controls.DeferredContentControl/DeferredContentControl.cs
@@ -0,0 +1,1157 @@
+// 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 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;
+using Avalonia.Controls.Primitives;
+using Avalonia.Controls.Templates;
+using Avalonia.Data;
+using Avalonia.LogicalTree;
+using Avalonia.Threading;
+using Avalonia.Visuals;
+
+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; }
+}
+
+///
+/// Defines how a deferred timeline 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
+}
+
+///
+/// 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);
+ private TimeSpan _revealDuration = TimeSpan.FromMilliseconds(90);
+
+ ///
+ /// 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;
+ }
+
+ ///
+ /// 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;
+ 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 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 => s_defaultTimeline.BudgetMode;
+ set => s_defaultTimeline.BudgetMode = value;
+ }
+
+ ///
+ /// Gets or sets the maximum number of successful realizations allowed in one dispatcher pass when is .
+ ///
+ public static int MaxPresentationsPerPass
+ {
+ get => s_defaultTimeline.MaxPresentationsPerPass;
+ set => s_defaultTimeline.MaxPresentationsPerPass = value;
+ }
+
+ ///
+ /// Gets or sets the maximum elapsed realization time allowed in one dispatcher pass when is .
+ ///
+ public static TimeSpan MaxRealizationTimePerPass
+ {
+ 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;
+ }
+
+ ///
+ /// 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;
+ }
+}
+
+///
+/// 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 a deferred timeline.
+///
+[TemplatePart("PART_ContentPresenter", typeof(ContentPresenter))]
+public class DeferredContentControl : ContentControl, IDeferredContentPresentationTarget
+{
+ private ContentPresenter? _presenter;
+ private object? _appliedContent;
+ private IDataTemplate? _appliedContentTemplate;
+ private long _requestedVersion;
+ private long _appliedVersion = -1;
+ private DeferredContentPresentationTimeline? _enqueuedTimeline;
+
+ 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)));
+ }
+
+ DeferredContentPresentationTimeline? IDeferredContentPresentationTarget.EnqueuedTimeline
+ {
+ get => _enqueuedTimeline;
+ set => _enqueuedTimeline = value;
+ }
+
+ bool IDeferredContentPresentationTarget.RetainPendingPresentationOnFailure => false;
+
+ ///
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+ QueueDeferredPresentation();
+ }
+
+ ///
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnDetachedFromVisualTree(e);
+ 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 DeferredContentPresenter deferredPresenter)
+ {
+ deferredPresenter.RemoveQueuedPresentation();
+ }
+
+ _appliedVersion = -1;
+ QueueDeferredPresentation();
+ }
+
+ ///
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ base.OnPropertyChanged(change);
+
+ if (change.Property == ContentProperty || change.Property == ContentTemplateProperty)
+ {
+ _requestedVersion++;
+ QueueDeferredPresentation();
+ return;
+ }
+
+ if (change.Property == DeferredContentScheduling.TimelineProperty
+ || change.Property == DeferredContentScheduling.DelayProperty
+ || change.Property == DeferredContentScheduling.OrderProperty)
+ {
+ QueueDeferredPresentation();
+ }
+ }
+
+ internal bool ApplyDeferredPresentation()
+ {
+ return ApplyDeferredPresentation(animateReveal: true);
+ }
+
+ internal bool ApplyDeferredPresentation(bool animateReveal)
+ {
+ if (!IsReadyForPresentation())
+ {
+ return false;
+ }
+
+ object? content = Content;
+ IDataTemplate? contentTemplate = ContentTemplate;
+
+ if (_appliedVersion == _requestedVersion
+ && ReferenceEquals(_appliedContent, content)
+ && ReferenceEquals(_appliedContentTemplate, contentTemplate))
+ {
+ return true;
+ }
+
+ var revealDuration = animateReveal
+ ? DeferredContentPresentationTargetHelpers.ResolveRevealDuration(_enqueuedTimeline, this)
+ : TimeSpan.Zero;
+ ApplyDeferredState(_presenter!, content, contentTemplate, revealDuration);
+ _appliedContent = content;
+ _appliedContentTemplate = contentTemplate;
+ _appliedVersion = _requestedVersion;
+ return true;
+ }
+
+ bool IDeferredContentPresentationTarget.ApplyDeferredPresentation()
+ {
+ return ApplyDeferredPresentation();
+ }
+
+ private void QueueDeferredPresentation()
+ {
+ if (!IsReadyForPresentation())
+ {
+ RemoveQueuedPresentation();
+ return;
+ }
+
+ if (Content is IDeferredContentPresentation { DeferContentPresentation: false })
+ {
+ RemoveQueuedPresentation();
+ ApplyDeferredPresentation(animateReveal: false);
+ return;
+ }
+
+ var timeline = DeferredContentPresentationTargetHelpers.ResolveTimeline(this);
+ if (!ReferenceEquals(_enqueuedTimeline, timeline))
+ {
+ RemoveQueuedPresentation();
+ }
+
+ timeline.Enqueue(this, DeferredContentPresentationTargetHelpers.ResolveDelay(this), DeferredContentPresentationTargetHelpers.ResolveOrder(this));
+ }
+
+ private bool IsReadyForPresentation()
+ {
+ return _presenter is not null
+ && VisualRoot is not null
+ && ((ILogical)this).IsAttachedToLogicalTree;
+ }
+
+ private void RemoveQueuedPresentation()
+ {
+ if (_enqueuedTimeline is { } timeline)
+ {
+ timeline.Remove(this);
+ }
+ }
+
+ private static void ApplyDeferredState(ContentPresenter presenter, object? content, IDataTemplate? contentTemplate, TimeSpan revealDuration)
+ {
+ if (presenter is DeferredContentPresenter deferredPresenter)
+ {
+ 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);
+ }
+}
+
+internal interface IDeferredContentPresentationTarget
+{
+ DeferredContentPresentationTimeline? EnqueuedTimeline { get; set; }
+
+ bool RetainPendingPresentationOnFailure { get; }
+
+ bool ApplyDeferredPresentation();
+}
+
+///
+/// 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;
+ }
+
+ bool IDeferredContentPresentationTarget.RetainPendingPresentationOnFailure => false;
+
+ ///
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+ QueueDeferredPresentation();
+ }
+
+ ///
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnDetachedFromVisualTree(e);
+ RemoveQueuedPresentation();
+ }
+
+ ///
+ protected override Size MeasureOverride(Size availableSize)
+ {
+ if (ShouldDeferMeasure())
+ {
+ return _lastDesiredSize;
+ }
+
+ var desiredSize = base.MeasureOverride(availableSize);
+ _lastDesiredSize = desiredSize;
+ return desiredSize;
+ }
+
+ ///
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ if (change.Property == ContentProperty || change.Property == ContentTemplateProperty)
+ {
+ if (_suppressDeferredUpdates)
+ {
+ 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)
+ {
+ ApplyDeferredState(content, contentTemplate, TimeSpan.Zero);
+ }
+
+ internal void ApplyDeferredState(object? content, IDataTemplate? contentTemplate, TimeSpan revealDuration)
+ {
+ var hadPresentedChild = Child is not null;
+ _suppressDeferredUpdates = true;
+
+ try
+ {
+ SetCurrentValue(ContentTemplateProperty, contentTemplate);
+ SetCurrentValue(ContentProperty, content);
+ }
+ finally
+ {
+ _suppressDeferredUpdates = false;
+ }
+
+ UpdatePresentedChild();
+ DeferredContentPresentationTargetHelpers.ApplyRevealAnimation(
+ this,
+ revealDuration,
+ hadPresentedChild && content is not null && Child is not null);
+ }
+
+ bool IDeferredContentPresentationTarget.ApplyDeferredPresentation()
+ {
+ return ApplyDeferredPresentation();
+ }
+
+ internal bool ApplyDeferredPresentation()
+ {
+ return ApplyDeferredPresentation(animateReveal: true);
+ }
+
+ internal bool ApplyDeferredPresentation(bool animateReveal)
+ {
+ if (!IsReadyForPresentation())
+ {
+ return false;
+ }
+
+ object? content = _requestedContent;
+ IDataTemplate? contentTemplate = _requestedContentTemplate;
+
+ if (_appliedVersion == _requestedVersion
+ && ReferenceEquals(_appliedContent, content)
+ && ReferenceEquals(_appliedContentTemplate, contentTemplate))
+ {
+ return true;
+ }
+
+ var revealDuration = animateReveal
+ ? DeferredContentPresentationTargetHelpers.ResolveRevealDuration(_enqueuedTimeline, this)
+ : TimeSpan.Zero;
+ ApplyDeferredState(content, contentTemplate, revealDuration);
+ _appliedContent = content;
+ _appliedContentTemplate = contentTemplate;
+ _appliedVersion = _requestedVersion;
+ return true;
+ }
+
+ internal void RemoveQueuedPresentation()
+ {
+ if (_enqueuedTimeline is { } timeline)
+ {
+ timeline.Remove(this);
+ }
+ }
+
+ private void QueueDeferredPresentation()
+ {
+ if (TemplatedParent is DeferredContentControl)
+ {
+ RemoveQueuedPresentation();
+ return;
+ }
+
+ if (!IsReadyForPresentation())
+ {
+ RemoveQueuedPresentation();
+ return;
+ }
+
+ if (_requestedContent is IDeferredContentPresentation { DeferContentPresentation: false })
+ {
+ RemoveQueuedPresentation();
+ ApplyDeferredPresentation(animateReveal: false);
+ return;
+ }
+
+ var timeline = DeferredContentPresentationTargetHelpers.ResolveTimeline(this);
+ if (!ReferenceEquals(_enqueuedTimeline, timeline))
+ {
+ RemoveQueuedPresentation();
+ }
+
+ timeline.Enqueue(this, DeferredContentPresentationTargetHelpers.ResolveDelay(this), DeferredContentPresentationTargetHelpers.ResolveOrder(this));
+ }
+
+ private bool IsReadyForPresentation()
+ {
+ return VisualRoot is not null
+ && ((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();
+ PseudoClasses.Set(":empty", Content is null);
+ InvalidateMeasure();
+ }
+}
+
+internal sealed class DeferredContentPresentationTimelineQueue
+{
+ 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;
+
+ internal int PendingCount => _pending.Count;
+
+ internal void Enqueue(IDeferredContentPresentationTarget target, TimeSpan delay, int order)
+ {
+ if (!Dispatcher.UIThread.CheckAccess())
+ {
+ Dispatcher.UIThread.Post(() => Enqueue(target, delay, order), s_flushPriority);
+ return;
+ }
+
+ var dueAt = DateTimeOffset.UtcNow + NormalizeDelay(_timeline.InitialDelay + delay);
+ var sequence = ++_nextSequence;
+
+ if (_pending.TryGetValue(target, out var entry))
+ {
+ entry.DueAt = dueAt;
+ entry.Order = order;
+ entry.Sequence = sequence;
+ }
+ else
+ {
+ _pending.Add(target, new PendingEntry(target, dueAt, order, sequence));
+ }
+
+ target.EnqueuedTimeline = _timeline;
+
+ if (AutoSchedule)
+ {
+ ScheduleNextPending(DateTimeOffset.UtcNow);
+ }
+ }
+
+ internal void Remove(IDeferredContentPresentationTarget target)
+ {
+ if (!Dispatcher.UIThread.CheckAccess())
+ {
+ Dispatcher.UIThread.Post(() => Remove(target), s_flushPriority);
+ return;
+ }
+
+ RemovePending(target);
+
+ if (_pending.Count == 0)
+ {
+ CancelScheduling();
+ }
+ }
+
+ internal void FlushPendingBatchForTesting()
+ {
+ if (!Dispatcher.UIThread.CheckAccess())
+ {
+ Dispatcher.UIThread.Post(FlushPendingBatchForTesting, s_flushPriority);
+ return;
+ }
+
+ FlushScheduledBatch(_scheduleVersion);
+ }
+
+ private void FlushScheduledBatch(long scheduledVersion)
+ {
+ if (!Dispatcher.UIThread.CheckAccess())
+ {
+ Dispatcher.UIThread.Post(() => FlushScheduledBatch(scheduledVersion), s_flushPriority);
+ return;
+ }
+
+ if (scheduledVersion != _scheduleVersion)
+ {
+ return;
+ }
+
+ _isScheduled = false;
+ _scheduledDueAt = null;
+
+ if (_pending.Count == 0)
+ {
+ CancelScheduling();
+ return;
+ }
+
+ var now = DateTimeOffset.UtcNow;
+ _dueEntries.Clear();
+
+ foreach (PendingEntry entry in _pending.Values)
+ {
+ if (entry.DueAt <= now)
+ {
+ _dueEntries.Add(entry);
+ }
+ }
+
+ if (_dueEntries.Count == 0)
+ {
+ if (AutoSchedule)
+ {
+ ScheduleNextPending(now);
+ }
+
+ return;
+ }
+
+ _dueEntries.Sort(s_pendingEntryComparison);
+
+ var batchSize = Math.Min(Math.Max(1, _timeline.MaxPresentationsPerPass), _dueEntries.Count);
+ var stopwatch = _timeline.BudgetMode == DeferredContentPresentationBudgetMode.RealizationTime
+ ? Stopwatch.StartNew()
+ : null;
+ var completedCount = 0;
+
+ foreach (PendingEntry entry in _dueEntries)
+ {
+ if (!_pending.TryGetValue(entry.Target, out var current) || !ReferenceEquals(current, entry))
+ {
+ continue;
+ }
+
+ if (entry.Target.ApplyDeferredPresentation())
+ {
+ 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))
+ {
+ break;
+ }
+ }
+
+ _dueEntries.Clear();
+
+ if (_pending.Count == 0)
+ {
+ CancelScheduling();
+ return;
+ }
+
+ if (!AutoSchedule)
+ {
+ return;
+ }
+
+ now = DateTimeOffset.UtcNow;
+ ScheduleFlush(HasDuePending(now) ? _timeline.FollowUpDelay : GetEarliestDueDelay(now));
+ }
+
+ private void ScheduleNextPending(DateTimeOffset now)
+ {
+ if (_pending.Count == 0)
+ {
+ CancelScheduling();
+ return;
+ }
+
+ ScheduleFlush(GetEarliestDueDelay(now));
+ }
+
+ private void ScheduleFlush(TimeSpan delay)
+ {
+ 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;
+ }
+
+ _ = PostDelayedFlushAsync(normalizedDelay, scheduledVersion);
+ }
+
+ private void CancelScheduling()
+ {
+ _isScheduled = false;
+ _scheduledDueAt = null;
+ _scheduleVersion++;
+ }
+
+ private void RemovePending(IDeferredContentPresentationTarget target)
+ {
+ if (!_pending.Remove(target))
+ {
+ return;
+ }
+
+ 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 TimeSpan GetEarliestDueDelay(DateTimeOffset now)
+ {
+ using var enumerator = _pending.Values.GetEnumerator();
+ enumerator.MoveNext();
+
+ var earliestDueAt = enumerator.Current.DueAt;
+
+ while (enumerator.MoveNext())
+ {
+ 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
+{
+ 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;
+ }
+
+ 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);
+ }
+
+ 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/src/Dock.Controls.DeferredContentControl/Dock.Controls.DeferredContentControl.csproj b/src/Dock.Controls.DeferredContentControl/Dock.Controls.DeferredContentControl.csproj
new file mode 100644
index 000000000..b85136b2b
--- /dev/null
+++ b/src/Dock.Controls.DeferredContentControl/Dock.Controls.DeferredContentControl.csproj
@@ -0,0 +1,30 @@
+
+
+
+ net6.0;net8.0;net10.0
+ Library
+ bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml
+ enable
+ Dock.Controls.DeferredContentControl
+
+
+
+ Dock.Controls.DeferredContentControl
+
+
+
+
+
+
+
+
+
+ <_Parameter1>Dock.Avalonia.HeadlessTests, PublicKey=002400000480000014010000060200000024000052534131000800000100010063995F354132525DE60CDE9A446E1594E09A106005D58B53010BFCC683490D005DBDF1A8CF59318338D6962D9367B6BE376046C978C2D08A101E4162F0297AD792C4B9F439B70FB83607387832C12A1BF1778A53BE1BF455BA8575819E37D4052FAD3CB1F1CD22B19545FDD06D39C0B9FFF39BAC69340B9AD3311ACD4F5E539D7BE179B86F9BF076F18F8126BB92EF2CDF2428069345F094DC703C346A4365A8956EBA6901A1CC6C4EDB41349BFDE51D40915B4A1DFAED473ADA2EB3B1F179B48DD75C06803C49538025B8404FA4AB30EFF36D6D98701A045E15B881E156BEEEC1BB786F53910C0B6065A16DF9AF276ECE4F9B7E5231C1DACBCBA9D7A32FA1D0
+
+
+
+
+
+
+
+
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/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/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
new file mode 100644
index 000000000..30cd3920b
--- /dev/null
+++ b/tests/Dock.Avalonia.HeadlessTests/DeferredContentControlTests.cs
@@ -0,0 +1,1920 @@
+using System;
+using System.Linq;
+using System.Threading;
+using Avalonia;
+using Avalonia.Animation;
+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.Visuals;
+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;
+
+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;
+ }
+ }
+
+ private sealed class TestDeferredTarget : IDeferredContentPresentationTarget
+ {
+ public TimeSpan ApplyDelay { get; set; }
+
+ public bool CanApply { get; set; }
+
+ public bool RetainPendingPresentationOnFailure { get; set; } = true;
+
+ public int MaxApplyCountBeforeThrow { get; set; } = int.MaxValue;
+
+ public int ApplyCount { get; private set; }
+
+ public int SuccessfulApplyCount { get; private set; }
+
+ public DeferredContentPresentationTimeline? EnqueuedTimeline { get; set; }
+
+ public bool ApplyDeferredPresentation()
+ {
+ if (ApplyDelay > TimeSpan.Zero)
+ {
+ Thread.Sleep(ApplyDelay);
+ }
+
+ ApplyCount++;
+ if (ApplyCount > MaxApplyCountBeforeThrow)
+ {
+ throw new InvalidOperationException("Target was revisited within the same deferred flush.");
+ }
+
+ if (CanApply)
+ {
+ SuccessfulApplyCount++;
+ return true;
+ }
+
+ return false;
+ }
+ }
+
+ 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()
+ {
+ using var _ = new DeferredBatchLimitScope(autoSchedule: false);
+ 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);
+
+ DrainDeferredQueueBatch(window);
+
+ Assert.NotNull(control.Presenter.Child);
+ Assert.Equal(1, template.BuildCount);
+ }
+ finally
+ {
+ window.Close();
+ }
+ }
+
+ [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
+ {
+ 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);
+
+ DrainDeferredQueueBatch(window);
+
+ 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()
+ {
+ using var _ = new DeferredBatchLimitScope(autoSchedule: false);
+ 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();
+ DrainDeferredQueueBatch(firstWindow);
+
+ 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
+ {
+ DrainDeferredQueueBatch(secondWindow);
+
+ Assert.Equal(2, template.BuildCount);
+ var textBlock = Assert.IsType(control.Presenter!.Child);
+ Assert.Equal("Second", textBlock.DataContext);
+ }
+ finally
+ {
+ secondWindow.Close();
+ }
+ }
+
+ [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" }
+ });
+ 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);
+
+ DrainDeferredQueueBatch(window);
+
+ Assert.NotNull(control.Presenter.Child);
+ Assert.Equal(1, template.BuildCount);
+ }
+ finally
+ {
+ window.Close();
+ }
+ }
+
+ [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 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()
+ {
+ 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.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(4, targets.Sum(target => target.SuccessfulApplyCount));
+ Assert.Equal(1, DeferredContentPresentationQueue.PendingCount);
+
+ DeferredContentPresentationQueue.FlushPendingBatchForTesting();
+
+ 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_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_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()
+ {
+ 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
+ {
+ foreach (var target in targets)
+ {
+ DeferredContentPresentationQueue.Remove(target);
+ }
+ }
+ }
+
+ [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()
+ {
+ using var _ = new DeferredBatchLimitScope(autoSchedule: false);
+ 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");
+
+ DrainDeferredQueueBatch(window);
+
+ Assert.InRange(buildCount, 1, 2);
+ Assert.Contains(control.GetVisualDescendants(), visual => visual is TextBlock textBlock && textBlock.Text == "Document");
+ }
+ finally
+ {
+ window.Close();
+ }
+ }
+
+ [AvaloniaFact]
+ public void ToolContentControl_Defers_Tool_Template_Materialization()
+ {
+ using var _ = new DeferredBatchLimitScope(autoSchedule: false);
+ 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");
+
+ DrainDeferredQueueBatch(window);
+
+ Assert.InRange(buildCount, 1, 2);
+ Assert.Contains(control.GetVisualDescendants(), visual => visual is TextBlock textBlock && textBlock.Text == "Tool");
+ }
+ finally
+ {
+ window.Close();
+ }
+ }
+
+ [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
+ {
+ 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);
+
+ DrainDeferredQueueBatch(window);
+
+ Assert.InRange(buildCount, 1, 2);
+ var textBlock = Assert.IsType(presenterHost.Presenter!.Child);
+ Assert.Equal("DocumentControl", textBlock.Text);
+ }
+ finally
+ {
+ window.Close();
+ }
+ }
+
+ [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
+ {
+ 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);
+
+ DrainDeferredQueueBatch(window);
+
+ Assert.InRange(buildCount, 1, 2);
+ var textBlock = Assert.IsType(presenterHost.Presenter!.Child);
+ Assert.Equal("ToolControl", textBlock.Text);
+ }
+ finally
+ {
+ window.Close();
+ }
+ }
+
+ [AvaloniaFact]
+ public void MdiDocumentWindow_Defers_Window_Content_Materialization()
+ {
+ using var _ = new DeferredBatchLimitScope(autoSchedule: false);
+ 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);
+
+ DrainDeferredQueueBatch(window);
+
+ 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()
+ {
+ using var _ = new DeferredBatchLimitScope(autoSchedule: false);
+ 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");
+
+ DrainDeferredQueueBatch(window);
+
+ 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()
+ {
+ using var _ = new DeferredBatchLimitScope(autoSchedule: false);
+ 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");
+
+ DrainDeferredQueueBatch(window);
+
+ 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()
+ {
+ using var _ = new DeferredBatchLimitScope(autoSchedule: false);
+ 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);
+
+ DrainDeferredQueueBatch(window);
+
+ 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()
+ {
+ using var _ = new DeferredBatchLimitScope(autoSchedule: false);
+ 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);
+
+ DrainDeferredQueueBatch(window);
+
+ 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()
+ {
+ using var _ = new DeferredBatchLimitScope(autoSchedule: false);
+ 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(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);
+
+ control.Content = "Second";
+
+ var staleTextBlock = Assert.IsType(presenterHost.Child);
+ Assert.Equal("First", staleTextBlock.DataContext);
+
+ DrainDeferredQueueBatch(window);
+
+ 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()
+ {
+ using var _ = new DeferredBatchLimitScope(autoSchedule: false);
+ 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(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);
+
+ window.Content = "Second";
+
+ var staleTextBlock = Assert.IsType(presenterHost.Child);
+ Assert.Equal("First", staleTextBlock.DataContext);
+
+ DrainDeferredQueueBatch(window);
+
+ 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;
+ }
+
+ private static void DrainDeferredQueueBatch(Window window)
+ {
+ Dispatcher.UIThread.RunJobs();
+ window.UpdateLayout();
+ DeferredContentPresentationQueue.FlushPendingBatchForTesting();
+ Dispatcher.UIThread.RunJobs();
+ 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 TimeSpan _previousRevealDuration = DeferredContentPresentationSettings.RevealDuration;
+ 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,
+ TimeSpan? revealDuration = 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);
+ DeferredContentPresentationSettings.RevealDuration = revealDuration ?? TimeSpan.FromMilliseconds(90);
+ DeferredContentPresentationQueue.AutoSchedule = autoSchedule;
+ }
+
+ public void Dispose()
+ {
+ DeferredContentPresentationSettings.BudgetMode = _previousBudgetMode;
+ DeferredContentPresentationSettings.MaxPresentationsPerPass = _previousLimit;
+ DeferredContentPresentationSettings.MaxRealizationTimePerPass = _previousMaxRealizationTime;
+ DeferredContentPresentationSettings.InitialDelay = _previousInitialDelay;
+ DeferredContentPresentationSettings.FollowUpDelay = _previousFollowUpDelay;
+ DeferredContentPresentationSettings.RevealDuration = _previousRevealDuration;
+ DeferredContentPresentationQueue.AutoSchedule = _previousAutoSchedule;
+ }
+ }
+}
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);
+ }
}
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
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()