diff --git a/docfx/articles/dock-itemssource.md b/docfx/articles/dock-itemssource.md
index 4a3f65c4c..ba4218d72 100644
--- a/docfx/articles/dock-itemssource.md
+++ b/docfx/articles/dock-itemssource.md
@@ -8,19 +8,21 @@ The `DocumentDock.ItemsSource` and `ToolDock.ItemsSource` properties enable auto
`ItemsSource` is implemented by `Dock.Model.Avalonia.Controls.DocumentDock` and `Dock.Model.Avalonia.Controls.ToolDock` in the Avalonia model layer:
-- `DocumentDock.ItemsSource` requires `DocumentTemplate`.
-- `ToolDock.ItemsSource` requires `ToolTemplate`.
+- By default, `DocumentDock.ItemsSource` requires `DocumentTemplate`.
+- By default, `ToolDock.ItemsSource` requires `ToolTemplate`.
+- `DocumentDock.ItemContainerGenerator` and `ToolDock.ItemContainerGenerator` let you override container creation and preparation.
-If no template is supplied, no generated dockables are created.
+With the default generator, if no template is supplied, no generated dockables are created. A custom `ItemContainerGenerator` can override this behavior.
## Behavior details
-- `DocumentDock` creates a `Document` for each item and stores the item in `Document.Context`.
-- `ToolDock` creates a `Tool` for each item and stores the item in `Tool.Context`.
+- By default, `DocumentDock` creates a `Document` for each item and stores the item in `Document.Context`.
+- By default, `ToolDock` creates a `Tool` for each item and stores the item in `Tool.Context`.
- The tab title is derived from `Title`, `Name`, or `DisplayName` properties on the item (in that order), falling back to `ToString()`.
- `CanClose` is copied from the item if present; otherwise it defaults to `true`.
- When a generated document or tool is closed, the factory attempts to remove the source item from `ItemsSource` if it implements `IList`.
- Source-generated document/tool closes are treated as remove operations, even when `Factory.HideDocumentsOnClose` or `Factory.HideToolsOnClose` is enabled.
+- You can replace the default generation pipeline with `IDockItemContainerGenerator` for custom container types, metadata mapping, and cleanup.
## Key Benefits
@@ -317,6 +319,56 @@ public enum DocumentType
```
+### Custom Container Generator
+
+Use `IDockItemContainerGenerator` when you need custom container types or custom preparation/cleanup logic for source-generated dockables.
+
+```csharp
+public sealed class MyGenerator : DockItemContainerGenerator
+{
+ public override IDockable? CreateDocumentContainer(IItemsSourceDock dock, object item, int index)
+ {
+ return new MyDocumentContainer { Id = $"Doc-{index}" };
+ }
+
+ public override void PrepareDocumentContainer(IItemsSourceDock dock, IDockable container, object item, int index)
+ {
+ base.PrepareDocumentContainer(dock, container, item, index);
+ container.Title = $"Document {container.Title}";
+ }
+
+ public override IDockable? CreateToolContainer(IToolItemsSourceDock dock, object item, int index)
+ {
+ return new MyToolContainer { Id = $"Tool-{index}" };
+ }
+}
+```
+
+Assign the generator per dock:
+
+```xaml
+
+
+
+
+
+
+
+```
+
+`Dock.Model.Avalonia.Controls.DockItemContainerGenerator` provides the default behavior and can be subclassed, or you can implement `IDockItemContainerGenerator` from scratch.
+
+Container compatibility contract:
+
+- `CreateDocumentContainer` should return an `IDocument` implementation (for example `Document` or a derived type).
+- `CreateToolContainer` should return an `ITool` implementation (for example `Tool` or a derived type).
+
+Incompatible container types are skipped by the pipeline and immediately cleared.
+
### Custom Commands Integration
```csharp
diff --git a/docfx/articles/dock-model-controls.md b/docfx/articles/dock-model-controls.md
index a2dc08ebc..3963c406c 100644
--- a/docfx/articles/dock-model-controls.md
+++ b/docfx/articles/dock-model-controls.md
@@ -140,6 +140,17 @@ concept with a `ToolTemplate` and `CreateToolFromTemplate`. This mirrors
`IDocumentDockContent` for tool panes. In Avalonia, `ToolDock.ItemsSource`
uses this template to create generated tool content from source collections.
+## IItemsSourceDock and IToolItemsSourceDock
+
+These interfaces define source-backed dock generation contracts:
+
+- `ItemsSource` exposes the source collection used to generate dockables.
+- `ItemContainerGenerator` provides custom container creation/preparation/cleanup through `IDockItemContainerGenerator`.
+- `IsDocumentFromItemsSource` / `IsToolFromItemsSource` detect source-generated dockables.
+- `RemoveItemFromSource` synchronizes close operations back to mutable source collections.
+
+The Avalonia model's `DocumentDock` and `ToolDock` implementations use `DockItemContainerGenerator` by default, while still allowing per-dock generator overrides.
+
## IToolTemplate
`IToolTemplate` represents template content used to build tool views. In
diff --git a/docfx/articles/dock-reference.md b/docfx/articles/dock-reference.md
index 92f93458c..c8ea915a9 100644
--- a/docfx/articles/dock-reference.md
+++ b/docfx/articles/dock-reference.md
@@ -76,10 +76,13 @@ The factory provides helper methods `SetDocumentDockTabsLayoutLeft`, `SetDocumen
- `DocumentDock.ItemsSource` + `DocumentTemplate` generate `Document` dockables.
- `ToolDock.ItemsSource` + `ToolTemplate` generate `Tool` dockables.
+- `DocumentDock.ItemContainerGenerator` and `ToolDock.ItemContainerGenerator` accept `IDockItemContainerGenerator` for custom create/prepare/clear pipelines.
- `IsDocumentFromItemsSource(IDockable)` / `IsToolFromItemsSource(IDockable)` report whether the dockable was generated from the bound source.
- `RemoveItemFromSource(object)` removes source items from supported list collections.
-When `ItemsSource` is set (and the required template is provided), Dock automatically creates dockables for each source item. The generated `Title` is derived from `Title`, `Name`, or `DisplayName` on the source object. The generated dockable `Context` stores the source object for template bindings.
+When `ItemsSource` is set, Dock automatically creates dockables for each source item through the configured `IDockItemContainerGenerator`. With the default generator, generation requires the corresponding template (`DocumentTemplate` or `ToolTemplate`). The generated `Title` is derived from `Title`, `Name`, or `DisplayName` on the source object and `Context` stores the source object for template bindings.
+
+`DockItemContainerGenerator` is the built-in default implementation. Subclass it or implement `IDockItemContainerGenerator` directly to customize container type, source-to-container mapping, or container cleanup behavior.
Changes to `INotifyCollectionChanged` collections (for example, `ObservableCollection`) automatically add or remove corresponding dockables. When a generated document or tool is closed, the factory attempts to remove the source item from the collection if it implements `IList`.
diff --git a/samples/DockReactiveUIItemsSourceSample/Infrastructure/SampleDockItemContainerGenerator.cs b/samples/DockReactiveUIItemsSourceSample/Infrastructure/SampleDockItemContainerGenerator.cs
new file mode 100644
index 000000000..2f3f99fb3
--- /dev/null
+++ b/samples/DockReactiveUIItemsSourceSample/Infrastructure/SampleDockItemContainerGenerator.cs
@@ -0,0 +1,55 @@
+using Dock.Model.Avalonia.Controls;
+using Dock.Model.Core;
+
+namespace DockReactiveUIItemsSourceSample.Infrastructure;
+
+public sealed class SampleDockItemContainerGenerator : DockItemContainerGenerator
+{
+ public override IDockable? CreateDocumentContainer(IItemsSourceDock dock, object item, int index)
+ {
+ return new SampleGeneratedDocument
+ {
+ Id = $"SampleDocument-{index}"
+ };
+ }
+
+ public override void PrepareDocumentContainer(IItemsSourceDock dock, IDockable container, object item, int index)
+ {
+ base.PrepareDocumentContainer(dock, container, item, index);
+ container.Title = $"Doc {container.Title}";
+
+ if (container is SampleGeneratedDocument generatedDocument)
+ {
+ generatedDocument.SourceIndex = index;
+ }
+ }
+
+ public override IDockable? CreateToolContainer(IToolItemsSourceDock dock, object item, int index)
+ {
+ return new SampleGeneratedTool
+ {
+ Id = $"SampleTool-{index}"
+ };
+ }
+
+ public override void PrepareToolContainer(IToolItemsSourceDock dock, IDockable container, object item, int index)
+ {
+ base.PrepareToolContainer(dock, container, item, index);
+ container.Title = $"Tool {container.Title}";
+
+ if (container is SampleGeneratedTool generatedTool)
+ {
+ generatedTool.SourceIndex = index;
+ }
+ }
+}
+
+public sealed class SampleGeneratedDocument : Document
+{
+ public int SourceIndex { get; set; } = -1;
+}
+
+public sealed class SampleGeneratedTool : Tool
+{
+ public int SourceIndex { get; set; } = -1;
+}
diff --git a/samples/DockReactiveUIItemsSourceSample/ViewModels/MainWindowViewModel.cs b/samples/DockReactiveUIItemsSourceSample/ViewModels/MainWindowViewModel.cs
index ba4cfd7dd..2ab19e7f7 100644
--- a/samples/DockReactiveUIItemsSourceSample/ViewModels/MainWindowViewModel.cs
+++ b/samples/DockReactiveUIItemsSourceSample/ViewModels/MainWindowViewModel.cs
@@ -26,10 +26,10 @@ public MainWindowViewModel()
Documents.CollectionChanged += (_, _) => UpdateSummary();
Tools.CollectionChanged += (_, _) => UpdateSummary();
- AddDocument("Welcome", "Welcome to the ReactiveUI ItemsSource sample.", "You can edit this text.");
+ AddDocument("Welcome", "Welcome to the ReactiveUI ItemsSource sample with custom container generation.", "You can edit this text.");
AddDocument("Notes", "Closing a generated document removes it from the source collection.", "Try the close button on tabs.");
- AddTool("Explorer", "Source-backed tool generated via ToolDock.ItemsSource.");
+ AddTool("Explorer", "Source-backed tool generated via ToolDock.ItemsSource and custom container hooks.");
AddTool("Properties", "Another generated tool. Closing it updates the source collection.");
UpdateSummary();
diff --git a/samples/DockReactiveUIItemsSourceSample/Views/MainWindow.axaml b/samples/DockReactiveUIItemsSourceSample/Views/MainWindow.axaml
index bc8c9865b..ee6e80f74 100644
--- a/samples/DockReactiveUIItemsSourceSample/Views/MainWindow.axaml
+++ b/samples/DockReactiveUIItemsSourceSample/Views/MainWindow.axaml
@@ -4,6 +4,7 @@
xmlns:dockModel="using:Dock.Model.Avalonia"
xmlns:dock="using:Dock.Avalonia.Controls"
xmlns:vm="using:DockReactiveUIItemsSourceSample.ViewModels"
+ xmlns:infra="using:DockReactiveUIItemsSourceSample.Infrastructure"
xmlns:sampleModels="using:DockReactiveUIItemsSourceSample.Models"
x:Class="DockReactiveUIItemsSourceSample.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
@@ -44,11 +45,15 @@
Alignment="Left"
Proportion="0.3"
ItemsSource="{Binding DataContext.Tools, RelativeSource={RelativeSource AncestorType=Window}}">
+
+
+
-
+
+
@@ -66,11 +71,15 @@
Proportion="0.7"
CanCreateDocument="False"
ItemsSource="{Binding DataContext.Documents, RelativeSource={RelativeSource AncestorType=Window}}">
+
+
+
-
+
+
diff --git a/src/Dock.Model.Avalonia/Controls/DockItemContainerGenerator.cs b/src/Dock.Model.Avalonia/Controls/DockItemContainerGenerator.cs
new file mode 100644
index 000000000..114e3ba29
--- /dev/null
+++ b/src/Dock.Model.Avalonia/Controls/DockItemContainerGenerator.cs
@@ -0,0 +1,233 @@
+// 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 Avalonia;
+using Avalonia.Controls;
+using Avalonia.Media;
+using Dock.Model.Controls;
+using Dock.Model.Core;
+
+namespace Dock.Model.Avalonia.Controls;
+
+///
+/// Default ItemsSource container generator for document and tool docks.
+///
+public class DockItemContainerGenerator : IDockItemContainerGenerator
+{
+ ///
+ /// Gets the shared default container generator instance.
+ ///
+ public static DockItemContainerGenerator Default { get; } = new();
+
+ ///
+ public virtual IDockable? CreateDocumentContainer(IItemsSourceDock dock, object item, int index)
+ {
+ if (dock is not IDocumentDockContent { DocumentTemplate: not null })
+ {
+ return null;
+ }
+
+ return new Document
+ {
+ Id = Guid.NewGuid().ToString()
+ };
+ }
+
+ ///
+ public virtual void PrepareDocumentContainer(IItemsSourceDock dock, IDockable container, object item, int index)
+ {
+ container.Title = GetItemTitle(item);
+ container.Context = item;
+ container.CanClose = GetItemCanClose(item);
+
+ if (container is not IDocumentContent content)
+ {
+ return;
+ }
+
+ if (dock is not IDocumentDockContent { DocumentTemplate: var template } || template is null)
+ {
+ content.Content = null;
+ return;
+ }
+
+ if (template.Content != null)
+ {
+ content.Content = template.Content;
+ return;
+ }
+
+ content.Content = new Func(_ => CreateDocumentFallbackContent(container, item));
+ }
+
+ ///
+ public virtual void ClearDocumentContainer(IItemsSourceDock dock, IDockable container, object? item)
+ {
+ container.Context = null;
+
+ if (container is IDocumentContent content)
+ {
+ content.Content = null;
+ }
+ }
+
+ ///
+ public virtual IDockable? CreateToolContainer(IToolItemsSourceDock dock, object item, int index)
+ {
+ if (dock is not IToolDockContent { ToolTemplate: not null })
+ {
+ return null;
+ }
+
+ return new Tool
+ {
+ Id = Guid.NewGuid().ToString()
+ };
+ }
+
+ ///
+ public virtual void PrepareToolContainer(IToolItemsSourceDock dock, IDockable container, object item, int index)
+ {
+ container.Title = GetItemTitle(item);
+ container.Context = item;
+ container.CanClose = GetItemCanClose(item);
+
+ if (container is not IToolContent content)
+ {
+ return;
+ }
+
+ if (dock is not IToolDockContent { ToolTemplate: var template } || template is null)
+ {
+ content.Content = null;
+ return;
+ }
+
+ if (template.Content != null)
+ {
+ content.Content = template.Content;
+ return;
+ }
+
+ content.Content = new Func(_ => CreateToolFallbackContent(container, item));
+ }
+
+ ///
+ public virtual void ClearToolContainer(IToolItemsSourceDock dock, IDockable container, object? item)
+ {
+ container.Context = null;
+
+ if (container is IToolContent content)
+ {
+ content.Content = null;
+ }
+ }
+
+ ///
+ /// Resolves a source item title.
+ ///
+ /// The source item.
+ /// The resolved title text.
+ protected virtual string GetItemTitle(object item)
+ {
+ var type = item.GetType();
+
+ var titleProperty = type.GetProperty("Title");
+ if (titleProperty?.GetValue(item) is string title)
+ {
+ return title;
+ }
+
+ var nameProperty = type.GetProperty("Name");
+ if (nameProperty?.GetValue(item) is string name)
+ {
+ return name;
+ }
+
+ var displayNameProperty = type.GetProperty("DisplayName");
+ if (displayNameProperty?.GetValue(item) is string displayName)
+ {
+ return displayName;
+ }
+
+ return item.ToString() ?? type.Name;
+ }
+
+ ///
+ /// Resolves whether a source item can be closed.
+ ///
+ /// The source item.
+ /// true when the generated container should be closable.
+ protected virtual bool GetItemCanClose(object item)
+ {
+ var canCloseProperty = item.GetType().GetProperty("CanClose");
+ if (canCloseProperty?.GetValue(item) is bool canClose)
+ {
+ return canClose;
+ }
+
+ return true;
+ }
+
+ private static Control CreateDocumentFallbackContent(IDockable document, object item)
+ {
+ var contentPanel = new StackPanel
+ {
+ Margin = new Thickness(10)
+ };
+
+ var titleBlock = new TextBlock
+ {
+ Text = document.Title ?? "Document",
+ FontWeight = FontWeight.Bold,
+ FontSize = 16,
+ Background = Brushes.LightBlue,
+ Padding = new Thickness(5),
+ Margin = new Thickness(0, 0, 0, 10)
+ };
+ contentPanel.Children.Add(titleBlock);
+
+ var contentBlock = new TextBlock
+ {
+ Text = item.ToString() ?? "No content",
+ Background = Brushes.LightGray,
+ Padding = new Thickness(5),
+ TextWrapping = TextWrapping.Wrap
+ };
+ contentPanel.Children.Add(contentBlock);
+
+ contentPanel.DataContext = item;
+ return contentPanel;
+ }
+
+ private static Control CreateToolFallbackContent(IDockable tool, object item)
+ {
+ var contentPanel = new StackPanel
+ {
+ Margin = new Thickness(10)
+ };
+
+ var titleBlock = new TextBlock
+ {
+ Text = tool.Title ?? "Tool",
+ FontWeight = FontWeight.Bold,
+ FontSize = 16,
+ Background = Brushes.LightSteelBlue,
+ Padding = new Thickness(5),
+ Margin = new Thickness(0, 0, 0, 10)
+ };
+ contentPanel.Children.Add(titleBlock);
+
+ var contentBlock = new TextBlock
+ {
+ Text = item.ToString() ?? "No content",
+ Background = Brushes.LightGray,
+ Padding = new Thickness(5),
+ TextWrapping = TextWrapping.Wrap
+ };
+ contentPanel.Children.Add(contentBlock);
+
+ contentPanel.DataContext = item;
+ return contentPanel;
+ }
+}
diff --git a/src/Dock.Model.Avalonia/Controls/DocumentDock.cs b/src/Dock.Model.Avalonia/Controls/DocumentDock.cs
index 45deead21..c1b4b6fd1 100644
--- a/src/Dock.Model.Avalonia/Controls/DocumentDock.cs
+++ b/src/Dock.Model.Avalonia/Controls/DocumentDock.cs
@@ -9,8 +9,6 @@
using System.Text.Json.Serialization;
using System.Windows.Input;
using Avalonia;
-using Avalonia.Controls;
-using Avalonia.Media;
using Avalonia.Reactive;
using Dock.Model.Avalonia.Core;
using Dock.Model.Avalonia.Internal;
@@ -72,9 +70,17 @@ public class DocumentDock : DockBase, IDocumentDock, IDocumentDockContent, IItem
public static readonly StyledProperty ItemsSourceProperty =
AvaloniaProperty.Register(nameof(ItemsSource));
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty ItemContainerGeneratorProperty =
+ AvaloniaProperty.Register(nameof(ItemContainerGenerator));
+
private bool _canCreateDocument;
private readonly HashSet _generatedDocuments = new();
+ private readonly Dictionary _generatedDocumentGenerators = new();
private IDisposable? _itemsSourceSubscription;
+ private IDisposable? _itemContainerGeneratorSubscription;
///
/// Initializes new instance of the class.
@@ -89,6 +95,8 @@ public DocumentDock()
// Subscribe to ItemsSource property changes
_itemsSourceSubscription = this.GetObservable(ItemsSourceProperty).Subscribe(new AnonymousObserver(OnItemsSourceChanged));
+ _itemContainerGeneratorSubscription = this.GetObservable(ItemContainerGeneratorProperty)
+ .Subscribe(new AnonymousObserver(_ => OnItemContainerGeneratorChanged()));
}
///
@@ -101,6 +109,8 @@ protected virtual void Dispose(bool disposing)
// Unsubscribe from ItemsSource changes
_itemsSourceSubscription?.Dispose();
_itemsSourceSubscription = null;
+ _itemContainerGeneratorSubscription?.Dispose();
+ _itemContainerGeneratorSubscription = null;
// Unsubscribe from collection changes
if (_currentCollectionChanged != null)
@@ -225,6 +235,17 @@ public IEnumerable? ItemsSource
set => SetValue(ItemsSourceProperty, value);
}
+ ///
+ /// Gets or sets the generator used to create and prepare containers for ItemsSource items.
+ ///
+ [IgnoreDataMember]
+ [JsonIgnore]
+ public IDockItemContainerGenerator? ItemContainerGenerator
+ {
+ get => GetValue(ItemContainerGeneratorProperty);
+ set => SetValue(ItemContainerGeneratorProperty, value);
+ }
+
///
/// Creates new document from template.
///
@@ -325,6 +346,33 @@ public virtual void AddTool(IDockable tool)
private INotifyCollectionChanged? _currentCollectionChanged;
+ private IDockItemContainerGenerator ResolveItemContainerGenerator()
+ {
+ return ItemContainerGenerator ?? DockItemContainerGenerator.Default;
+ }
+
+ private void OnItemContainerGeneratorChanged()
+ {
+ if (ItemsSource is null)
+ {
+ return;
+ }
+
+ RegenerateGeneratedDocuments(ItemsSource);
+ }
+
+ private void RegenerateGeneratedDocuments(IEnumerable itemsSource)
+ {
+ ClearGeneratedDocuments();
+
+ var index = 0;
+ foreach (var item in itemsSource)
+ {
+ AddDocumentFromItem(item, index);
+ index++;
+ }
+ }
+
private void OnItemsSourceChanged(IEnumerable? newItemsSource)
{
if (_currentCollectionChanged != null)
@@ -333,21 +381,19 @@ private void OnItemsSourceChanged(IEnumerable? newItemsSource)
_currentCollectionChanged = null;
}
- ClearGeneratedDocuments();
-
if (newItemsSource is INotifyCollectionChanged notifyCollection)
{
_currentCollectionChanged = notifyCollection;
_currentCollectionChanged.CollectionChanged += OnCollectionChanged;
}
- if (newItemsSource != null)
+ if (newItemsSource is null)
{
- foreach (var item in newItemsSource)
- {
- AddDocumentFromItem(item);
- }
+ ClearGeneratedDocuments();
+ return;
}
+
+ RegenerateGeneratedDocuments(newItemsSource);
}
private void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
@@ -357,9 +403,12 @@ private void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArg
case NotifyCollectionChangedAction.Add:
if (e.NewItems != null)
{
+ var addIndex = e.NewStartingIndex;
+ var offset = 0;
foreach (var item in e.NewItems)
{
- AddDocumentFromItem(item);
+ AddDocumentFromItem(item, addIndex >= 0 ? addIndex + offset : -1);
+ offset++;
}
}
break;
@@ -384,115 +433,71 @@ private void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArg
}
if (e.NewItems != null)
{
+ var replaceIndex = e.NewStartingIndex;
+ var offset = 0;
foreach (var item in e.NewItems)
{
- AddDocumentFromItem(item);
+ AddDocumentFromItem(item, replaceIndex >= 0 ? replaceIndex + offset : -1);
+ offset++;
}
}
break;
case NotifyCollectionChangedAction.Reset:
- ClearGeneratedDocuments();
if (ItemsSource != null)
{
- foreach (var item in ItemsSource)
- {
- AddDocumentFromItem(item);
- }
+ RegenerateGeneratedDocuments(ItemsSource);
+ }
+ else
+ {
+ ClearGeneratedDocuments();
}
break;
}
}
- private void AddDocumentFromItem(object? item)
+ private void AddDocumentFromItem(object? item, int index)
{
if (item == null)
- return;
-
- // If there's no DocumentTemplate at all, don't create documents
- if (DocumentTemplate == null)
- return;
-
- // Create a new Document using the DocumentTemplate
- var document = new Document
{
- Id = Guid.NewGuid().ToString(),
- Title = GetDocumentTitle(item),
- Context = item, // Set the data context to the item
- CanClose = GetDocumentCanClose(item)
- };
+ return;
+ }
- // Use the DocumentTemplate to create content by setting the Content to a function
- // that builds the template with the item as data context
- // Set up document content using DocumentTemplate if available
- if (DocumentTemplate is DocumentTemplate template && template.Content != null)
+ var generator = ResolveItemContainerGenerator();
+ var document = generator.CreateDocumentContainer(this, item, index);
+ if (document is null)
{
- // Use the DocumentTemplate.Content directly - let the Document handle the template building
- document.Content = template.Content;
+ return;
}
- else
+
+ if (document is not IDocument)
{
- // Template exists but has no content, create fallback content
- document.Content = new Func(_ => CreateFallbackContent(document, item));
+ generator.ClearDocumentContainer(this, document, item);
+ return;
}
- // Add to our tracking collection
+ generator.PrepareDocumentContainer(this, document, item, index);
+
_generatedDocuments.Add(document);
+ _generatedDocumentGenerators[document] = generator;
TrackItemsSourceDocument(document);
- // Use the proper AddDocument API if Factory is available, otherwise add manually
if (Factory != null)
{
- // Use the proper AddDocument API which handles adding, making active, and focused
AddDocument(document);
+ return;
}
- else
- {
- // Fallback for unit tests or when no Factory is set
- if (VisibleDockables == null)
- {
- VisibleDockables = new global::Avalonia.Collections.AvaloniaList();
- }
-
- VisibleDockables.Add(document);
- // Set as active if it's the first document
- if (VisibleDockables.Count == 1)
- {
- ActiveDockable = document;
- }
+ if (VisibleDockables == null)
+ {
+ VisibleDockables = new global::Avalonia.Collections.AvaloniaList();
}
- }
- private Control CreateFallbackContent(Document document, object? item)
- {
- var contentPanel = new StackPanel { Margin = new Thickness(10) };
-
- // First TextBlock: Document title
- var titleBlock = new TextBlock
- {
- Text = document.Title ?? "Document",
- FontWeight = FontWeight.Bold,
- FontSize = 16,
- Background = Brushes.LightBlue,
- Padding = new Thickness(5),
- Margin = new Thickness(0, 0, 0, 10)
- };
- contentPanel.Children.Add(titleBlock);
-
- // Second TextBlock: Item's ToString() representation
- var contentBlock = new TextBlock
- {
- Text = item?.ToString() ?? "No content",
- Background = Brushes.LightGray,
- Padding = new Thickness(5),
- TextWrapping = TextWrapping.Wrap
- };
- contentPanel.Children.Add(contentBlock);
-
- // Set DataContext to the item (as expected by tests)
- contentPanel.DataContext = item;
- return contentPanel;
+ VisibleDockables.Add(document);
+ if (VisibleDockables.Count == 1)
+ {
+ ActiveDockable = document;
+ }
}
private void RemoveDocumentFromItem(object? item)
@@ -508,6 +513,7 @@ private void RemoveDocumentFromItem(object? item)
{
_generatedDocuments.Remove(documentToRemove);
UntrackItemsSourceDocument(documentToRemove);
+ ClearGeneratedDocumentContainer(documentToRemove, item);
RemoveGeneratedDocumentFromVisibleDockables(documentToRemove);
}
}
@@ -517,46 +523,12 @@ private void ClearGeneratedDocuments()
foreach (var document in _generatedDocuments.ToList())
{
UntrackItemsSourceDocument(document);
+ ClearGeneratedDocumentContainer(document, document.Context);
RemoveGeneratedDocumentFromVisibleDockables(document);
}
_generatedDocuments.Clear();
- }
-
- private string GetDocumentTitle(object item)
- {
- // Try to get title from common properties
- var type = item.GetType();
-
- // Check for Title property
- var titleProperty = type.GetProperty("Title");
- if (titleProperty?.GetValue(item) is string title)
- return title;
-
- // Check for Name property
- var nameProperty = type.GetProperty("Name");
- if (nameProperty?.GetValue(item) is string name)
- return name;
-
- // Check for DisplayName property
- var displayNameProperty = type.GetProperty("DisplayName");
- if (displayNameProperty?.GetValue(item) is string displayName)
- return displayName;
-
- // Fallback to ToString or type name
- return item.ToString() ?? type.Name;
- }
-
- private bool GetDocumentCanClose(object item)
- {
- // Try to get CanClose from the item
- var type = item.GetType();
- var canCloseProperty = type.GetProperty("CanClose");
- if (canCloseProperty?.GetValue(item) is bool canClose)
- return canClose;
-
- // Default to true
- return true;
+ _generatedDocumentGenerators.Clear();
}
///
@@ -621,12 +593,13 @@ private void UntrackGeneratedDocument(object item)
{
_generatedDocuments.Remove(generatedDocument);
UntrackItemsSourceDocument(generatedDocument);
+ ClearGeneratedDocumentContainer(generatedDocument, item);
}
}
- private Document? FindGeneratedDocument(object item)
+ private IDockable? FindGeneratedDocument(object item)
{
- foreach (var generatedDocument in _generatedDocuments.OfType())
+ foreach (var generatedDocument in _generatedDocuments)
{
if (IsMatchingContext(generatedDocument.Context, item))
{
@@ -647,6 +620,18 @@ private static bool IsMatchingContext(object? context, object? item)
return Equals(context, item);
}
+ private void ClearGeneratedDocumentContainer(IDockable document, object? item)
+ {
+ if (_generatedDocumentGenerators.TryGetValue(document, out var generator))
+ {
+ _generatedDocumentGenerators.Remove(document);
+ generator.ClearDocumentContainer(this, document, item);
+ return;
+ }
+
+ ResolveItemContainerGenerator().ClearDocumentContainer(this, document, item);
+ }
+
private void RemoveGeneratedDocumentFromVisibleDockables(IDockable document)
{
if (document.Owner is IDock owner)
diff --git a/src/Dock.Model.Avalonia/Controls/ToolDock.cs b/src/Dock.Model.Avalonia/Controls/ToolDock.cs
index 244ee924c..6071b1198 100644
--- a/src/Dock.Model.Avalonia/Controls/ToolDock.cs
+++ b/src/Dock.Model.Avalonia/Controls/ToolDock.cs
@@ -8,8 +8,6 @@
using System.Runtime.Serialization;
using System.Text.Json.Serialization;
using Avalonia;
-using Avalonia.Controls;
-using Avalonia.Media;
using Avalonia.Reactive;
using Dock.Model.Avalonia.Core;
using Dock.Model.Controls;
@@ -58,13 +56,21 @@ public class ToolDock : DockBase, IToolDock, IToolDockContent, IToolItemsSourceD
public static readonly StyledProperty ItemsSourceProperty =
AvaloniaProperty.Register(nameof(ItemsSource));
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty ItemContainerGeneratorProperty =
+ AvaloniaProperty.Register(nameof(ItemContainerGenerator));
+
private Alignment _alignment = Alignment.Unset;
private bool _isExpanded;
private bool _autoHide = true;
private GripMode _gripMode = GripMode.Visible;
private readonly HashSet _generatedTools = new();
+ private readonly Dictionary _generatedToolGenerators = new();
private IDisposable? _itemsSourceSubscription;
private IDisposable? _toolTemplateSubscription;
+ private IDisposable? _itemContainerGeneratorSubscription;
private INotifyCollectionChanged? _currentCollectionChanged;
///
@@ -77,6 +83,9 @@ public ToolDock()
_toolTemplateSubscription = this.GetObservable(ToolTemplateProperty)
.Subscribe(new AnonymousObserver(_ => OnToolTemplateChanged()));
+
+ _itemContainerGeneratorSubscription = this.GetObservable(ItemContainerGeneratorProperty)
+ .Subscribe(new AnonymousObserver(_ => OnItemContainerGeneratorChanged()));
}
///
@@ -96,6 +105,9 @@ protected virtual void Dispose(bool disposing)
_toolTemplateSubscription?.Dispose();
_toolTemplateSubscription = null;
+ _itemContainerGeneratorSubscription?.Dispose();
+ _itemContainerGeneratorSubscription = null;
+
if (_currentCollectionChanged != null)
{
_currentCollectionChanged.CollectionChanged -= OnCollectionChanged;
@@ -159,6 +171,17 @@ public IEnumerable? ItemsSource
set => SetValue(ItemsSourceProperty, value);
}
+ ///
+ /// Gets or sets the generator used to create and prepare containers for ItemsSource items.
+ ///
+ [IgnoreDataMember]
+ [JsonIgnore]
+ public IDockItemContainerGenerator? ItemContainerGenerator
+ {
+ get => GetValue(ItemContainerGeneratorProperty);
+ set => SetValue(ItemContainerGeneratorProperty, value);
+ }
+
///
/// Creates new tool from template.
///
@@ -246,6 +269,33 @@ public virtual bool IsToolFromItemsSource(IDockable tool)
return _generatedTools.Contains(tool);
}
+ private IDockItemContainerGenerator ResolveItemContainerGenerator()
+ {
+ return ItemContainerGenerator ?? DockItemContainerGenerator.Default;
+ }
+
+ private void OnItemContainerGeneratorChanged()
+ {
+ if (ItemsSource is null)
+ {
+ return;
+ }
+
+ RegenerateGeneratedTools(ItemsSource);
+ }
+
+ private void RegenerateGeneratedTools(IEnumerable itemsSource)
+ {
+ ClearGeneratedTools();
+
+ var index = 0;
+ foreach (var item in itemsSource)
+ {
+ AddToolFromItem(item, index);
+ index++;
+ }
+ }
+
private void OnItemsSourceChanged(IEnumerable? newItemsSource)
{
if (_currentCollectionChanged != null)
@@ -254,21 +304,19 @@ private void OnItemsSourceChanged(IEnumerable? newItemsSource)
_currentCollectionChanged = null;
}
- ClearGeneratedTools();
-
if (newItemsSource is INotifyCollectionChanged notifyCollection)
{
_currentCollectionChanged = notifyCollection;
_currentCollectionChanged.CollectionChanged += OnCollectionChanged;
}
- if (newItemsSource != null)
+ if (newItemsSource is null)
{
- foreach (var item in newItemsSource)
- {
- AddToolFromItem(item);
- }
+ ClearGeneratedTools();
+ return;
}
+
+ RegenerateGeneratedTools(newItemsSource);
}
private void OnToolTemplateChanged()
@@ -278,12 +326,7 @@ private void OnToolTemplateChanged()
return;
}
- ClearGeneratedTools();
-
- foreach (var item in ItemsSource)
- {
- AddToolFromItem(item);
- }
+ RegenerateGeneratedTools(ItemsSource);
}
private void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
@@ -293,9 +336,12 @@ private void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArg
case NotifyCollectionChangedAction.Add:
if (e.NewItems != null)
{
+ var addIndex = e.NewStartingIndex;
+ var offset = 0;
foreach (var item in e.NewItems)
{
- AddToolFromItem(item);
+ AddToolFromItem(item, addIndex >= 0 ? addIndex + offset : -1);
+ offset++;
}
}
break;
@@ -321,51 +367,53 @@ private void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArg
if (e.NewItems != null)
{
+ var replaceIndex = e.NewStartingIndex;
+ var offset = 0;
foreach (var item in e.NewItems)
{
- AddToolFromItem(item);
+ AddToolFromItem(item, replaceIndex >= 0 ? replaceIndex + offset : -1);
+ offset++;
}
}
break;
case NotifyCollectionChangedAction.Reset:
- ClearGeneratedTools();
if (ItemsSource != null)
{
- foreach (var item in ItemsSource)
- {
- AddToolFromItem(item);
- }
+ RegenerateGeneratedTools(ItemsSource);
+ }
+ else
+ {
+ ClearGeneratedTools();
}
break;
}
}
- private void AddToolFromItem(object? item)
+ private void AddToolFromItem(object? item, int index)
{
- if (item == null || ToolTemplate == null)
+ if (item == null)
{
return;
}
- var tool = new Tool
- {
- Id = Guid.NewGuid().ToString(),
- Title = GetToolTitle(item),
- Context = item,
- CanClose = GetToolCanClose(item)
- };
-
- if (ToolTemplate.Content != null)
+ var generator = ResolveItemContainerGenerator();
+ var tool = generator.CreateToolContainer(this, item, index);
+ if (tool is null)
{
- tool.Content = ToolTemplate.Content;
+ return;
}
- else
+
+ if (tool is not ITool)
{
- tool.Content = new Func(_ => CreateFallbackContent(tool, item));
+ generator.ClearToolContainer(this, tool, item);
+ return;
}
+ generator.PrepareToolContainer(this, tool, item, index);
+
_generatedTools.Add(tool);
+ _generatedToolGenerators[tool] = generator;
TrackItemsSourceTool(tool);
if (Factory != null)
@@ -387,34 +435,6 @@ private void AddToolFromItem(object? item)
}
}
- private static Control CreateFallbackContent(Tool tool, object item)
- {
- var contentPanel = new StackPanel { Margin = new Thickness(10) };
-
- var titleBlock = new TextBlock
- {
- Text = tool.Title ?? "Tool",
- FontWeight = FontWeight.Bold,
- FontSize = 16,
- Background = Brushes.LightSteelBlue,
- Padding = new Thickness(5),
- Margin = new Thickness(0, 0, 0, 10)
- };
- contentPanel.Children.Add(titleBlock);
-
- var contentBlock = new TextBlock
- {
- Text = item.ToString() ?? "No content",
- Background = Brushes.LightGray,
- Padding = new Thickness(5),
- TextWrapping = TextWrapping.Wrap
- };
- contentPanel.Children.Add(contentBlock);
-
- contentPanel.DataContext = item;
- return contentPanel;
- }
-
private void RemoveToolFromItem(object? item)
{
if (item == null)
@@ -428,6 +448,7 @@ private void RemoveToolFromItem(object? item)
{
_generatedTools.Remove(toolToRemove);
UntrackItemsSourceTool(toolToRemove);
+ ClearGeneratedToolContainer(toolToRemove, item);
RemoveGeneratedToolFromVisibleDockables(toolToRemove);
}
}
@@ -438,10 +459,12 @@ private void ClearGeneratedTools()
foreach (var tool in toolsToRemove)
{
UntrackItemsSourceTool(tool);
+ ClearGeneratedToolContainer(tool, tool.Context);
RemoveGeneratedToolFromVisibleDockables(tool);
}
_generatedTools.Clear();
+ _generatedToolGenerators.Clear();
}
private void UntrackGeneratedTool(object item)
@@ -452,12 +475,13 @@ private void UntrackGeneratedTool(object item)
{
_generatedTools.Remove(generatedTool);
UntrackItemsSourceTool(generatedTool);
+ ClearGeneratedToolContainer(generatedTool, item);
}
}
- private Tool? FindGeneratedTool(object item)
+ private IDockable? FindGeneratedTool(object item)
{
- foreach (var generatedTool in _generatedTools.OfType())
+ foreach (var generatedTool in _generatedTools)
{
if (IsMatchingContext(generatedTool.Context, item))
{
@@ -478,6 +502,18 @@ private static bool IsMatchingContext(object? context, object? item)
return Equals(context, item);
}
+ private void ClearGeneratedToolContainer(IDockable tool, object? item)
+ {
+ if (_generatedToolGenerators.TryGetValue(tool, out var generator))
+ {
+ _generatedToolGenerators.Remove(tool);
+ generator.ClearToolContainer(this, tool, item);
+ return;
+ }
+
+ ResolveItemContainerGenerator().ClearToolContainer(this, tool, item);
+ }
+
private void RemoveGeneratedToolFromVisibleDockables(IDockable tool)
{
if (tool.Owner is IDock owner)
@@ -510,41 +546,4 @@ private void UntrackItemsSourceTool(IDockable tool)
factoryBase.UntrackItemsSourceDockable(tool);
}
}
-
- private static string GetToolTitle(object item)
- {
- var type = item.GetType();
-
- var titleProperty = type.GetProperty("Title");
- if (titleProperty?.GetValue(item) is string title)
- {
- return title;
- }
-
- var nameProperty = type.GetProperty("Name");
- if (nameProperty?.GetValue(item) is string name)
- {
- return name;
- }
-
- var displayNameProperty = type.GetProperty("DisplayName");
- if (displayNameProperty?.GetValue(item) is string displayName)
- {
- return displayName;
- }
-
- return item.ToString() ?? type.Name;
- }
-
- private static bool GetToolCanClose(object item)
- {
- var type = item.GetType();
- var canCloseProperty = type.GetProperty("CanClose");
- if (canCloseProperty?.GetValue(item) is bool canClose)
- {
- return canClose;
- }
-
- return true;
- }
}
diff --git a/src/Dock.Model/Core/IDockItemContainerGenerator.cs b/src/Dock.Model/Core/IDockItemContainerGenerator.cs
new file mode 100644
index 000000000..f12048e1e
--- /dev/null
+++ b/src/Dock.Model/Core/IDockItemContainerGenerator.cs
@@ -0,0 +1,63 @@
+// Copyright (c) Wiesław Šoltés. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for details.
+
+namespace Dock.Model.Core;
+
+///
+/// Provides hooks to create, prepare, and clear ItemsSource-generated
+/// document and tool containers.
+///
+public interface IDockItemContainerGenerator
+{
+ ///
+ /// Creates a document container for the specified source item.
+ ///
+ /// The document items-source dock.
+ /// The source item.
+ /// The item index when known; otherwise -1.
+ /// The generated container, or null to skip generation.
+ IDockable? CreateDocumentContainer(IItemsSourceDock dock, object item, int index);
+
+ ///
+ /// Prepares a document container after creation.
+ ///
+ /// The document items-source dock.
+ /// The generated container.
+ /// The source item.
+ /// The item index when known; otherwise -1.
+ void PrepareDocumentContainer(IItemsSourceDock dock, IDockable container, object item, int index);
+
+ ///
+ /// Clears a previously generated document container.
+ ///
+ /// The document items-source dock.
+ /// The generated container.
+ /// The source item that produced the container.
+ void ClearDocumentContainer(IItemsSourceDock dock, IDockable container, object? item);
+
+ ///
+ /// Creates a tool container for the specified source item.
+ ///
+ /// The tool items-source dock.
+ /// The source item.
+ /// The item index when known; otherwise -1.
+ /// The generated container, or null to skip generation.
+ IDockable? CreateToolContainer(IToolItemsSourceDock dock, object item, int index);
+
+ ///
+ /// Prepares a tool container after creation.
+ ///
+ /// The tool items-source dock.
+ /// The generated container.
+ /// The source item.
+ /// The item index when known; otherwise -1.
+ void PrepareToolContainer(IToolItemsSourceDock dock, IDockable container, object item, int index);
+
+ ///
+ /// Clears a previously generated tool container.
+ ///
+ /// The tool items-source dock.
+ /// The generated container.
+ /// The source item that produced the container.
+ void ClearToolContainer(IToolItemsSourceDock dock, IDockable container, object? item);
+}
diff --git a/src/Dock.Model/Core/IItemsSourceDock.cs b/src/Dock.Model/Core/IItemsSourceDock.cs
index df1219d64..f35f9b53a 100644
--- a/src/Dock.Model/Core/IItemsSourceDock.cs
+++ b/src/Dock.Model/Core/IItemsSourceDock.cs
@@ -14,6 +14,11 @@ public interface IItemsSourceDock
///
IEnumerable? ItemsSource { get; }
+ ///
+ /// Gets the item container generator used for source-backed documents.
+ ///
+ IDockItemContainerGenerator? ItemContainerGenerator { get; }
+
///
/// Checks if a document was generated from ItemsSource.
///
@@ -27,4 +32,4 @@ public interface IItemsSourceDock
/// The item to remove.
/// True if the item was successfully removed, false otherwise.
bool RemoveItemFromSource(object? item);
-}
\ No newline at end of file
+}
diff --git a/src/Dock.Model/Core/IToolItemsSourceDock.cs b/src/Dock.Model/Core/IToolItemsSourceDock.cs
index f4547c5c4..aba0683a7 100644
--- a/src/Dock.Model/Core/IToolItemsSourceDock.cs
+++ b/src/Dock.Model/Core/IToolItemsSourceDock.cs
@@ -14,6 +14,11 @@ public interface IToolItemsSourceDock
///
IEnumerable? ItemsSource { get; }
+ ///
+ /// Gets the item container generator used for source-backed tools.
+ ///
+ IDockItemContainerGenerator? ItemContainerGenerator { get; }
+
///
/// Checks if a tool was generated from ItemsSource.
///
diff --git a/tests/Dock.Avalonia.LeakTests/DockItemContainerGeneratorLeakTests.cs b/tests/Dock.Avalonia.LeakTests/DockItemContainerGeneratorLeakTests.cs
new file mode 100644
index 000000000..6b924603f
--- /dev/null
+++ b/tests/Dock.Avalonia.LeakTests/DockItemContainerGeneratorLeakTests.cs
@@ -0,0 +1,74 @@
+using System;
+using System.Collections.ObjectModel;
+using Avalonia.Collections;
+using Dock.Model.Avalonia.Controls;
+using Dock.Model.Core;
+using Xunit;
+using static Dock.Avalonia.LeakTests.LeakTestHelpers;
+using static Dock.Avalonia.LeakTests.LeakTestSession;
+
+namespace Dock.Avalonia.LeakTests;
+
+[Collection("LeakTests")]
+public class DockItemContainerGeneratorLeakTests
+{
+ [ReleaseFact]
+ public void DocumentDock_ItemContainerGeneratorSwap_DoesNotLeak_PreviousGenerator()
+ {
+ var result = RunInSession(() =>
+ {
+ var dock = new DocumentDock
+ {
+ VisibleDockables = new AvaloniaList(),
+ DocumentTemplate = new DocumentTemplate()
+ };
+
+ var firstGenerator = new DockItemContainerGenerator();
+ dock.ItemContainerGenerator = firstGenerator;
+ dock.ItemsSource = new ObservableCollection { "DocA" };
+
+ var generatorRef = new WeakReference(firstGenerator);
+ firstGenerator = null;
+
+ dock.ItemContainerGenerator = new DockItemContainerGenerator();
+ dock.ItemsSource = null;
+
+ return new GeneratorSwapLeakResult(generatorRef, dock);
+ });
+
+ AssertCollected(result.GeneratorRef);
+ GC.KeepAlive(result.DockKeepAlive);
+ }
+
+ [ReleaseFact]
+ public void ToolDock_ItemContainerGeneratorSwap_DoesNotLeak_PreviousGenerator()
+ {
+ var result = RunInSession(() =>
+ {
+ var dock = new ToolDock
+ {
+ VisibleDockables = new AvaloniaList(),
+ ToolTemplate = new ToolTemplate()
+ };
+
+ var firstGenerator = new DockItemContainerGenerator();
+ dock.ItemContainerGenerator = firstGenerator;
+ dock.ItemsSource = new ObservableCollection { "ToolA" };
+
+ var generatorRef = new WeakReference(firstGenerator);
+ firstGenerator = null;
+
+ dock.ItemContainerGenerator = new DockItemContainerGenerator();
+ dock.ItemsSource = null;
+
+ return new GeneratorSwapLeakResult(generatorRef, dock);
+ });
+
+ AssertCollected(result.GeneratorRef);
+ GC.KeepAlive(result.DockKeepAlive);
+ }
+
+ private sealed record GeneratorSwapLeakResult(
+ WeakReference GeneratorRef,
+ object DockKeepAlive);
+}
diff --git a/tests/Dock.Model.Avalonia.UnitTests/Controls/DockItemContainerGeneratorTests.cs b/tests/Dock.Model.Avalonia.UnitTests/Controls/DockItemContainerGeneratorTests.cs
new file mode 100644
index 000000000..14644ce4d
--- /dev/null
+++ b/tests/Dock.Model.Avalonia.UnitTests/Controls/DockItemContainerGeneratorTests.cs
@@ -0,0 +1,467 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using Avalonia.Controls;
+using Avalonia.Headless.XUnit;
+using Dock.Model.Avalonia;
+using Dock.Model.Avalonia.Controls;
+using Dock.Model.Controls;
+using Dock.Model.Core;
+using Xunit;
+
+namespace Dock.Model.Avalonia.UnitTests.Controls;
+
+public class DockItemContainerGeneratorTests
+{
+ private sealed class TestItem
+ {
+ public string Title { get; set; } = string.Empty;
+
+ public bool CanClose { get; set; } = true;
+ }
+
+ private sealed class GeneratedDocument : Document
+ {
+ public bool IsPrepared { get; set; }
+ }
+
+ private sealed class GeneratedTool : Tool
+ {
+ public bool IsPrepared { get; set; }
+ }
+
+ private sealed class TrackingContainerGenerator : IDockItemContainerGenerator
+ {
+ public int PreparedDocuments { get; private set; }
+ public int ClearedDocuments { get; private set; }
+ public int PreparedTools { get; private set; }
+ public int ClearedTools { get; private set; }
+
+ public IDockable? CreateDocumentContainer(IItemsSourceDock dock, object item, int index)
+ {
+ return new GeneratedDocument
+ {
+ Id = $"GeneratedDocument-{index}"
+ };
+ }
+
+ public void PrepareDocumentContainer(IItemsSourceDock dock, IDockable container, object item, int index)
+ {
+ PreparedDocuments++;
+
+ var source = Assert.IsType(item);
+ var document = Assert.IsType(container);
+ document.Title = $"Generated {source.Title}";
+ document.Context = source;
+ document.CanClose = source.CanClose;
+ document.IsPrepared = true;
+
+ if (document is IDocumentContent content)
+ {
+ content.Content = new Func(_ => new TextBlock
+ {
+ Text = $"Doc:{document.Title}",
+ DataContext = source
+ });
+ }
+ }
+
+ public void ClearDocumentContainer(IItemsSourceDock dock, IDockable container, object? item)
+ {
+ ClearedDocuments++;
+ container.Context = null;
+
+ if (container is IDocumentContent content)
+ {
+ content.Content = null;
+ }
+ }
+
+ public IDockable? CreateToolContainer(IToolItemsSourceDock dock, object item, int index)
+ {
+ return new GeneratedTool
+ {
+ Id = $"GeneratedTool-{index}"
+ };
+ }
+
+ public void PrepareToolContainer(IToolItemsSourceDock dock, IDockable container, object item, int index)
+ {
+ PreparedTools++;
+
+ var source = Assert.IsType(item);
+ var tool = Assert.IsType(container);
+ tool.Title = $"Generated {source.Title}";
+ tool.Context = source;
+ tool.CanClose = source.CanClose;
+ tool.IsPrepared = true;
+
+ if (tool is IToolContent content)
+ {
+ content.Content = new Func(_ => new TextBlock
+ {
+ Text = $"Tool:{tool.Title}",
+ DataContext = source
+ });
+ }
+ }
+
+ public void ClearToolContainer(IToolItemsSourceDock dock, IDockable container, object? item)
+ {
+ ClearedTools++;
+ container.Context = null;
+
+ if (container is IToolContent content)
+ {
+ content.Content = null;
+ }
+ }
+ }
+
+ private sealed class InvalidContainerGenerator : IDockItemContainerGenerator
+ {
+ public int ClearedDocuments { get; private set; }
+ public int ClearedTools { get; private set; }
+
+ public IDockable? CreateDocumentContainer(IItemsSourceDock dock, object item, int index)
+ {
+ return new DockDock();
+ }
+
+ public void PrepareDocumentContainer(IItemsSourceDock dock, IDockable container, object item, int index)
+ {
+ }
+
+ public void ClearDocumentContainer(IItemsSourceDock dock, IDockable container, object? item)
+ {
+ ClearedDocuments++;
+ }
+
+ public IDockable? CreateToolContainer(IToolItemsSourceDock dock, object item, int index)
+ {
+ return new Document();
+ }
+
+ public void PrepareToolContainer(IToolItemsSourceDock dock, IDockable container, object item, int index)
+ {
+ }
+
+ public void ClearToolContainer(IToolItemsSourceDock dock, IDockable container, object? item)
+ {
+ ClearedTools++;
+ }
+ }
+
+ private static IList RequireVisibleDockables(DocumentDock dock)
+ {
+ return dock.VisibleDockables ?? throw new InvalidOperationException("VisibleDockables should not be null.");
+ }
+
+ private static IList RequireVisibleDockables(ToolDock dock)
+ {
+ return dock.VisibleDockables ?? throw new InvalidOperationException("VisibleDockables should not be null.");
+ }
+
+ [AvaloniaFact]
+ public void DocumentDock_CustomGenerator_CreatesCustomContainerAndContentBinding()
+ {
+ var generator = new TrackingContainerGenerator();
+ var sourceItem = new TestItem { Title = "Doc A", CanClose = false };
+
+ var dock = new DocumentDock
+ {
+ ItemContainerGenerator = generator,
+ ItemsSource = new ObservableCollection { sourceItem }
+ };
+
+ var generated = Assert.IsType(RequireVisibleDockables(dock)[0]);
+ Assert.True(generated.IsPrepared);
+ Assert.Equal("Generated Doc A", generated.Title);
+ Assert.False(generated.CanClose);
+ Assert.Same(sourceItem, generated.Context);
+ Assert.Equal(1, generator.PreparedDocuments);
+
+ var contentFactory = Assert.IsType>(generated.Content);
+ var content = Assert.IsType(contentFactory(null!));
+ Assert.Equal("Doc:Generated Doc A", content.Text);
+ Assert.Same(sourceItem, content.DataContext);
+ }
+
+ [AvaloniaFact]
+ public void ToolDock_CustomGenerator_CreatesCustomContainerAndContentBinding()
+ {
+ var generator = new TrackingContainerGenerator();
+ var sourceItem = new TestItem { Title = "Tool A", CanClose = false };
+
+ var dock = new ToolDock
+ {
+ ItemContainerGenerator = generator,
+ ItemsSource = new ObservableCollection { sourceItem }
+ };
+
+ var generated = Assert.IsType(RequireVisibleDockables(dock)[0]);
+ Assert.True(generated.IsPrepared);
+ Assert.Equal("Generated Tool A", generated.Title);
+ Assert.False(generated.CanClose);
+ Assert.Same(sourceItem, generated.Context);
+ Assert.Equal(1, generator.PreparedTools);
+
+ var contentFactory = Assert.IsType>(generated.Content);
+ var content = Assert.IsType(contentFactory(null!));
+ Assert.Equal("Tool:Generated Tool A", content.Text);
+ Assert.Same(sourceItem, content.DataContext);
+ }
+
+ [AvaloniaFact]
+ public void DocumentDock_CustomGenerator_Close_RemovesSourceAndClearsContainer()
+ {
+ var factory = new Factory();
+ var generator = new TrackingContainerGenerator();
+ var items = new ObservableCollection { new() { Title = "Doc A" } };
+
+ var dock = new DocumentDock
+ {
+ Factory = factory,
+ ItemContainerGenerator = generator,
+ ItemsSource = items
+ };
+
+ var generated = Assert.IsType(RequireVisibleDockables(dock)[0]);
+
+ factory.CloseDockable(generated);
+
+ Assert.Empty(items);
+ Assert.Empty(RequireVisibleDockables(dock));
+ Assert.Equal(1, generator.ClearedDocuments);
+ Assert.Null(generated.Context);
+ }
+
+ [AvaloniaFact]
+ public void ToolDock_CustomGenerator_Close_RemovesSourceAndClearsContainer()
+ {
+ var factory = new Factory();
+ var generator = new TrackingContainerGenerator();
+ var items = new ObservableCollection { new() { Title = "Tool A" } };
+
+ var dock = new ToolDock
+ {
+ Factory = factory,
+ ItemContainerGenerator = generator,
+ ItemsSource = items
+ };
+
+ var generated = Assert.IsType(RequireVisibleDockables(dock)[0]);
+
+ factory.CloseDockable(generated);
+
+ Assert.Empty(items);
+ Assert.Empty(RequireVisibleDockables(dock));
+ Assert.Equal(1, generator.ClearedTools);
+ Assert.Null(generated.Context);
+ }
+
+ [AvaloniaFact]
+ public void ChangingItemContainerGenerator_RegeneratesDocumentContainers()
+ {
+ var firstGenerator = new TrackingContainerGenerator();
+ var secondGenerator = new TrackingContainerGenerator();
+ var items = new ObservableCollection { new() { Title = "Doc A" } };
+
+ var dock = new DocumentDock
+ {
+ ItemContainerGenerator = firstGenerator,
+ ItemsSource = items
+ };
+
+ var firstDocument = Assert.IsType(RequireVisibleDockables(dock)[0]);
+ dock.ItemContainerGenerator = secondGenerator;
+
+ var secondDocument = Assert.IsType(RequireVisibleDockables(dock)[0]);
+ Assert.NotSame(firstDocument, secondDocument);
+ Assert.Equal(1, firstGenerator.ClearedDocuments);
+ Assert.Equal(1, secondGenerator.PreparedDocuments);
+ }
+
+ [AvaloniaFact]
+ public void ChangingItemContainerGenerator_RegeneratesToolContainers()
+ {
+ var firstGenerator = new TrackingContainerGenerator();
+ var secondGenerator = new TrackingContainerGenerator();
+ var items = new ObservableCollection { new() { Title = "Tool A" } };
+
+ var dock = new ToolDock
+ {
+ ItemContainerGenerator = firstGenerator,
+ ItemsSource = items
+ };
+
+ var firstTool = Assert.IsType(RequireVisibleDockables(dock)[0]);
+ dock.ItemContainerGenerator = secondGenerator;
+
+ var secondTool = Assert.IsType(RequireVisibleDockables(dock)[0]);
+ Assert.NotSame(firstTool, secondTool);
+ Assert.Equal(1, firstGenerator.ClearedTools);
+ Assert.Equal(1, secondGenerator.PreparedTools);
+ }
+
+ [AvaloniaFact]
+ public void DocumentDock_CustomGenerator_TracksAddRemoveReplaceReset()
+ {
+ var generator = new TrackingContainerGenerator();
+ var items = new ObservableCollection
+ {
+ new() { Title = "Doc A" }
+ };
+
+ var dock = new DocumentDock
+ {
+ ItemContainerGenerator = generator,
+ ItemsSource = items
+ };
+
+ Assert.Single(RequireVisibleDockables(dock));
+ Assert.Equal(1, generator.PreparedDocuments);
+ Assert.Equal(0, generator.ClearedDocuments);
+
+ items.Add(new TestItem { Title = "Doc B" });
+ Assert.Equal(2, RequireVisibleDockables(dock).Count);
+ Assert.Equal(2, generator.PreparedDocuments);
+
+ items[0] = new TestItem { Title = "Doc C" };
+ Assert.Equal(2, RequireVisibleDockables(dock).Count);
+ Assert.Equal(3, generator.PreparedDocuments);
+ Assert.Equal(1, generator.ClearedDocuments);
+
+ items.RemoveAt(1);
+ Assert.Single(RequireVisibleDockables(dock));
+ Assert.Equal(2, generator.ClearedDocuments);
+
+ items.Clear();
+ Assert.Empty(RequireVisibleDockables(dock));
+ Assert.Equal(3, generator.ClearedDocuments);
+ }
+
+ [AvaloniaFact]
+ public void ToolDock_CustomGenerator_TracksAddRemoveReplaceReset()
+ {
+ var generator = new TrackingContainerGenerator();
+ var items = new ObservableCollection
+ {
+ new() { Title = "Tool A" }
+ };
+
+ var dock = new ToolDock
+ {
+ ItemContainerGenerator = generator,
+ ItemsSource = items
+ };
+
+ Assert.Single(RequireVisibleDockables(dock));
+ Assert.Equal(1, generator.PreparedTools);
+ Assert.Equal(0, generator.ClearedTools);
+
+ items.Add(new TestItem { Title = "Tool B" });
+ Assert.Equal(2, RequireVisibleDockables(dock).Count);
+ Assert.Equal(2, generator.PreparedTools);
+
+ items[0] = new TestItem { Title = "Tool C" };
+ Assert.Equal(2, RequireVisibleDockables(dock).Count);
+ Assert.Equal(3, generator.PreparedTools);
+ Assert.Equal(1, generator.ClearedTools);
+
+ items.RemoveAt(1);
+ Assert.Single(RequireVisibleDockables(dock));
+ Assert.Equal(2, generator.ClearedTools);
+
+ items.Clear();
+ Assert.Empty(RequireVisibleDockables(dock));
+ Assert.Equal(3, generator.ClearedTools);
+ }
+
+ [AvaloniaFact]
+ public void DocumentDock_CustomGenerator_RespectsManualDockablesOnItemsSourceSwap()
+ {
+ var generator = new TrackingContainerGenerator();
+ var dock = new DocumentDock
+ {
+ ItemContainerGenerator = generator
+ };
+
+ var manual = new Document { Title = "Manual" };
+ RequireVisibleDockables(dock).Add(manual);
+ dock.ActiveDockable = manual;
+
+ dock.ItemsSource = new ObservableCollection
+ {
+ new() { Title = "Doc A" }
+ };
+
+ Assert.Equal(2, RequireVisibleDockables(dock).Count);
+ Assert.Contains(manual, RequireVisibleDockables(dock));
+
+ dock.ItemsSource = null;
+ Assert.Single(RequireVisibleDockables(dock));
+ Assert.Same(manual, RequireVisibleDockables(dock)[0]);
+ Assert.Same(manual, dock.ActiveDockable);
+ }
+
+ [AvaloniaFact]
+ public void ToolDock_CustomGenerator_RespectsManualDockablesOnItemsSourceSwap()
+ {
+ var generator = new TrackingContainerGenerator();
+ var dock = new ToolDock
+ {
+ ItemContainerGenerator = generator
+ };
+
+ var manual = new Tool { Title = "Manual" };
+ RequireVisibleDockables(dock).Add(manual);
+ dock.ActiveDockable = manual;
+
+ dock.ItemsSource = new ObservableCollection
+ {
+ new() { Title = "Tool A" }
+ };
+
+ Assert.Equal(2, RequireVisibleDockables(dock).Count);
+ Assert.Contains(manual, RequireVisibleDockables(dock));
+
+ dock.ItemsSource = null;
+ Assert.Single(RequireVisibleDockables(dock));
+ Assert.Same(manual, RequireVisibleDockables(dock)[0]);
+ Assert.Same(manual, dock.ActiveDockable);
+ }
+ [AvaloniaFact]
+ public void DocumentDock_InvalidContainerType_IsSkippedAndCleared()
+ {
+ var generator = new InvalidContainerGenerator();
+ var dock = new DocumentDock
+ {
+ ItemContainerGenerator = generator,
+ ItemsSource = new ObservableCollection
+ {
+ new() { Title = "Invalid" }
+ }
+ };
+
+ Assert.Empty(RequireVisibleDockables(dock));
+ Assert.Equal(1, generator.ClearedDocuments);
+ }
+
+ [AvaloniaFact]
+ public void ToolDock_InvalidContainerType_IsSkippedAndCleared()
+ {
+ var generator = new InvalidContainerGenerator();
+ var dock = new ToolDock
+ {
+ ItemContainerGenerator = generator,
+ ItemsSource = new ObservableCollection
+ {
+ new() { Title = "Invalid" }
+ }
+ };
+
+ Assert.Empty(RequireVisibleDockables(dock));
+ Assert.Equal(1, generator.ClearedTools);
+ }
+}
diff --git a/tests/Dock.Model.Avalonia.UnitTests/Controls/DocumentDockItemsSourceUITests.cs b/tests/Dock.Model.Avalonia.UnitTests/Controls/DocumentDockItemsSourceUITests.cs
index 290fd8734..98ef9c160 100644
--- a/tests/Dock.Model.Avalonia.UnitTests/Controls/DocumentDockItemsSourceUITests.cs
+++ b/tests/Dock.Model.Avalonia.UnitTests/Controls/DocumentDockItemsSourceUITests.cs
@@ -6,6 +6,7 @@
using Dock.Avalonia.Controls;
using Dock.Model.Avalonia;
using Dock.Model.Avalonia.Controls;
+using Dock.Model.Core;
using Xunit;
using Xunit.Abstractions;
@@ -75,6 +76,43 @@ protected bool SetProperty(ref T field, T value, [CallerMemberName] string? p
}
}
+ private sealed class CustomGeneratedDocument : Document
+ {
+ public string? Marker { get; set; }
+ }
+
+ private sealed class CustomGenerator : IDockItemContainerGenerator
+ {
+ public IDockable? CreateDocumentContainer(IItemsSourceDock dock, object item, int index)
+ {
+ return new CustomGeneratedDocument { Id = $"Doc-{index}" };
+ }
+
+ public void PrepareDocumentContainer(IItemsSourceDock dock, IDockable container, object item, int index)
+ {
+ container.Title = $"Generated {index}";
+ container.Context = item;
+ if (container is CustomGeneratedDocument doc)
+ {
+ doc.Marker = "prepared";
+ }
+ }
+
+ public void ClearDocumentContainer(IItemsSourceDock dock, IDockable container, object? item)
+ {
+ }
+
+ public IDockable? CreateToolContainer(IToolItemsSourceDock dock, object item, int index) => null;
+
+ public void PrepareToolContainer(IToolItemsSourceDock dock, IDockable container, object item, int index)
+ {
+ }
+
+ public void ClearToolContainer(IToolItemsSourceDock dock, IDockable container, object? item)
+ {
+ }
+ }
+
[AvaloniaFact]
public void ItemsSource_ExactSampleScenario_DebugTest()
{
@@ -187,4 +225,29 @@ public void ItemsSource_ExactSampleScenario_DebugTest()
Assert.IsType(control1);
}
}
-}
\ No newline at end of file
+
+ [AvaloniaFact]
+ public void ItemsSource_WithCustomGenerator_UsesCustomContainer()
+ {
+ var documentDock = new DocumentDock
+ {
+ Id = "Documents",
+ ItemContainerGenerator = new CustomGenerator(),
+ DocumentTemplate = new DocumentTemplate()
+ };
+
+ var documents = new ObservableCollection
+ {
+ new() { Title = "One", Content = "Content" }
+ };
+
+ documentDock.ItemsSource = documents;
+
+ Assert.NotNull(documentDock.VisibleDockables);
+ Assert.Single(documentDock.VisibleDockables);
+ var generated = Assert.IsType(documentDock.VisibleDockables[0]);
+ Assert.Equal("prepared", generated.Marker);
+ Assert.Equal("Generated 0", generated.Title);
+ Assert.Same(documents[0], generated.Context);
+ }
+}