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); + } +}