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 | [![NuGet](https://img.shields.io/nuget/v/Dock.Avalonia.Themes.Fluent.svg)](https://www.nuget.org/packages/Dock.Avalonia.Themes.Fluent) | [`Dock.Avalonia.Themes.Fluent`](https://www.nuget.org/packages/Dock.Avalonia.Themes.Fluent) | [![Downloads](https://img.shields.io/nuget/dt/Dock.Avalonia.Themes.Fluent.svg)](https://www.nuget.org/packages/Dock.Avalonia.Themes.Fluent) | | [![NuGet](https://img.shields.io/nuget/v/Dock.Avalonia.Themes.Browser.svg)](https://www.nuget.org/packages/Dock.Avalonia.Themes.Browser) | [`Dock.Avalonia.Themes.Browser`](https://www.nuget.org/packages/Dock.Avalonia.Themes.Browser) | [![Downloads](https://img.shields.io/nuget/dt/Dock.Avalonia.Themes.Browser.svg)](https://www.nuget.org/packages/Dock.Avalonia.Themes.Browser) | | [![NuGet](https://img.shields.io/nuget/v/Dock.Avalonia.Themes.Simple.svg)](https://www.nuget.org/packages/Dock.Avalonia.Themes.Simple) | [`Dock.Avalonia.Themes.Simple`](https://www.nuget.org/packages/Dock.Avalonia.Themes.Simple) | [![Downloads](https://img.shields.io/nuget/dt/Dock.Avalonia.Themes.Simple.svg)](https://www.nuget.org/packages/Dock.Avalonia.Themes.Simple) | +| [![NuGet](https://img.shields.io/nuget/v/Dock.Controls.DeferredContentControl.svg)](https://www.nuget.org/packages/Dock.Controls.DeferredContentControl) | [`Dock.Controls.DeferredContentControl`](https://www.nuget.org/packages/Dock.Controls.DeferredContentControl) | [![Downloads](https://img.shields.io/nuget/dt/Dock.Controls.DeferredContentControl.svg)](https://www.nuget.org/packages/Dock.Controls.DeferredContentControl) | | [![NuGet](https://img.shields.io/nuget/v/Dock.Controls.ProportionalStackPanel.svg)](https://www.nuget.org/packages/Dock.Controls.ProportionalStackPanel) | [`Dock.Controls.ProportionalStackPanel`](https://www.nuget.org/packages/Dock.Controls.ProportionalStackPanel) | [![Downloads](https://img.shields.io/nuget/dt/Dock.Controls.ProportionalStackPanel.svg)](https://www.nuget.org/packages/Dock.Controls.ProportionalStackPanel) | | [![NuGet](https://img.shields.io/nuget/v/Dock.Controls.Recycling.svg)](https://www.nuget.org/packages/Dock.Controls.Recycling) | [`Dock.Controls.Recycling`](https://www.nuget.org/packages/Dock.Controls.Recycling) | [![Downloads](https://img.shields.io/nuget/dt/Dock.Controls.Recycling.svg)](https://www.nuget.org/packages/Dock.Controls.Recycling) | | [![NuGet](https://img.shields.io/nuget/v/Dock.Controls.Recycling.Model.svg)](https://www.nuget.org/packages/Dock.Controls.Recycling.Model) | [`Dock.Controls.Recycling.Model`](https://www.nuget.org/packages/Dock.Controls.Recycling.Model) | [![Downloads](https://img.shields.io/nuget/dt/Dock.Controls.Recycling.Model.svg)](https://www.nuget.org/packages/Dock.Controls.Recycling.Model) | @@ -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: | [![NuGet](https://img.shields.io/nuget/v/Dock.Avalonia.Themes.Fluent.svg)](https://www.nuget.org/packages/Dock.Avalonia.Themes.Fluent) | [`Dock.Avalonia.Themes.Fluent`](https://www.nuget.org/packages/Dock.Avalonia.Themes.Fluent) | [![Downloads](https://img.shields.io/nuget/dt/Dock.Avalonia.Themes.Fluent.svg)](https://www.nuget.org/packages/Dock.Avalonia.Themes.Fluent) | | [![NuGet](https://img.shields.io/nuget/v/Dock.Avalonia.Themes.Browser.svg)](https://www.nuget.org/packages/Dock.Avalonia.Themes.Browser) | [`Dock.Avalonia.Themes.Browser`](https://www.nuget.org/packages/Dock.Avalonia.Themes.Browser) | [![Downloads](https://img.shields.io/nuget/dt/Dock.Avalonia.Themes.Browser.svg)](https://www.nuget.org/packages/Dock.Avalonia.Themes.Browser) | | [![NuGet](https://img.shields.io/nuget/v/Dock.Avalonia.Themes.Simple.svg)](https://www.nuget.org/packages/Dock.Avalonia.Themes.Simple) | [`Dock.Avalonia.Themes.Simple`](https://www.nuget.org/packages/Dock.Avalonia.Themes.Simple) | [![Downloads](https://img.shields.io/nuget/dt/Dock.Avalonia.Themes.Simple.svg)](https://www.nuget.org/packages/Dock.Avalonia.Themes.Simple) | +| [![NuGet](https://img.shields.io/nuget/v/Dock.Controls.DeferredContentControl.svg)](https://www.nuget.org/packages/Dock.Controls.DeferredContentControl) | [`Dock.Controls.DeferredContentControl`](https://www.nuget.org/packages/Dock.Controls.DeferredContentControl) | [![Downloads](https://img.shields.io/nuget/dt/Dock.Controls.DeferredContentControl.svg)](https://www.nuget.org/packages/Dock.Controls.DeferredContentControl) | | [![NuGet](https://img.shields.io/nuget/v/Dock.Controls.ProportionalStackPanel.svg)](https://www.nuget.org/packages/Dock.Controls.ProportionalStackPanel) | [`Dock.Controls.ProportionalStackPanel`](https://www.nuget.org/packages/Dock.Controls.ProportionalStackPanel) | [![Downloads](https://img.shields.io/nuget/dt/Dock.Controls.ProportionalStackPanel.svg)](https://www.nuget.org/packages/Dock.Controls.ProportionalStackPanel) | | [![NuGet](https://img.shields.io/nuget/v/Dock.Controls.Recycling.svg)](https://www.nuget.org/packages/Dock.Controls.Recycling) | [`Dock.Controls.Recycling`](https://www.nuget.org/packages/Dock.Controls.Recycling) | [![Downloads](https://img.shields.io/nuget/dt/Dock.Controls.Recycling.svg)](https://www.nuget.org/packages/Dock.Controls.Recycling) | | [![NuGet](https://img.shields.io/nuget/v/Dock.Controls.Recycling.Model.svg)](https://www.nuget.org/packages/Dock.Controls.Recycling.Model) | [`Dock.Controls.Recycling.Model`](https://www.nuget.org/packages/Dock.Controls.Recycling.Model) | [![Downloads](https://img.shields.io/nuget/dt/Dock.Controls.Recycling.Model.svg)](https://www.nuget.org/packages/Dock.Controls.Recycling.Model) | @@ -72,6 +74,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()