diff --git a/docfx/articles/dock-controls-reference.md b/docfx/articles/dock-controls-reference.md index 9283c56c4..a5d8421f8 100644 --- a/docfx/articles/dock-controls-reference.md +++ b/docfx/articles/dock-controls-reference.md @@ -63,9 +63,11 @@ Unless noted otherwise, the properties listed are Avalonia styled properties and | `HeaderTemplate` | `IDataTemplate` | Tab header template. | | `ModifiedTemplate` | `IDataTemplate` | Modified indicator template. | | `CloseTemplate` | `IDataTemplate` | Close button template. | +| `EmptyContentTemplate` | `IDataTemplate?` | Template used to render model `EmptyContent` when no tabbed documents are visible. | | `CloseButtonTheme` | `ControlTheme?` | Theme for the close button. | | `IsActive` | `bool` | Active document state (drives `:active`). | | `TabsLayout` | `DocumentTabLayout` | Tab placement for the document dock. | +| `HasVisibleDockables` | `bool` | `true` when one or more tabbed dockables are visible (read-only). | ### ToolControl @@ -156,9 +158,11 @@ Unless noted otherwise, the properties listed are Avalonia styled properties and | `HeaderTemplate` | `IDataTemplate` | Window header template. | | `ModifiedTemplate` | `IDataTemplate` | Modified indicator template. | | `CloseTemplate` | `IDataTemplate` | Close button template. | +| `EmptyContentTemplate` | `IDataTemplate?` | Template used to render the model `EmptyContent` placeholder when no MDI documents are visible. | | `CloseButtonTheme` | `ControlTheme?` | Theme for the close button. | | `LayoutManager` | `IMdiLayoutManager?` | Layout manager forwarded to the MDI panel. | | `IsActive` | `bool` | Active state (drives `:active`). | +| `HasVisibleDocuments` | `bool` | `true` when one or more MDI documents are visible (read-only). | ### MdiDocumentWindow diff --git a/docfx/articles/dock-mdi.md b/docfx/articles/dock-mdi.md index 8938b30db..b13dec0e5 100644 --- a/docfx/articles/dock-mdi.md +++ b/docfx/articles/dock-mdi.md @@ -50,6 +50,26 @@ The Avalonia controls expose additional properties for theming: - `MdiDocumentControl` forwards `IconTemplate`, `HeaderTemplate`, `ModifiedTemplate`, `CloseTemplate`, and `CloseButtonTheme` to each window. - `MdiDocumentWindow` exposes `DocumentContextMenu`, `MdiState`, and `IsActive` for styling or interaction. +`IDocumentDock.EmptyContent` defines placeholder content shown when a document host has no visible dockables. In MDI mode, `MdiDocumentControl.EmptyContentTemplate` can optionally control how that content is rendered: + +```csharp +using Avalonia.Controls; +using Avalonia.Controls.Templates; + +var documents = new DocumentDock +{ + LayoutMode = DocumentLayoutMode.Mdi, + EmptyContent = "No documents are open" +}; + +var mdiDocumentControl = new MdiDocumentControl +{ + DataContext = documents, + EmptyContentTemplate = new FuncDataTemplate((text, _) => + new TextBlock { Text = text }, true) +}; +``` + To customize the layout algorithm, provide an `IMdiLayoutManager` implementation. It can be assigned on `MdiDocumentControl.LayoutManager` (which the default template forwards to `MdiLayoutPanel`): ```csharp diff --git a/docfx/articles/dock-model-controls.md b/docfx/articles/dock-model-controls.md index e45c5504f..3ecfba057 100644 --- a/docfx/articles/dock-model-controls.md +++ b/docfx/articles/dock-model-controls.md @@ -70,6 +70,10 @@ are placed. `IDocumentDock.LayoutMode` switches between tabbed documents and classic MDI windows. When MDI mode is enabled the dock exposes commands for cascade and tile operations, and documents implement `IMdiDocument` to store window state. +`IDocumentDock.EmptyContent` can provide placeholder content when either +document host is empty (tabbed or MDI). Template rendering for that content is +configured in the Avalonia layer via `DocumentControl.EmptyContentTemplate` +and `MdiDocumentControl.EmptyContentTemplate`. `IDocumentDockFactory` exposes a `DocumentFactory` delegate that is used by the `CreateDocument` command. When assigned, this factory is invoked to diff --git a/docfx/articles/dock-reference.md b/docfx/articles/dock-reference.md index 164f51221..b387a162d 100644 --- a/docfx/articles/dock-reference.md +++ b/docfx/articles/dock-reference.md @@ -52,6 +52,7 @@ from a saved state. - `TabsLayout` chooses where the tabs appear using the `DocumentTabLayout` enum - `LayoutMode` switches between `Tabbed` and `Mdi` layouts - `CloseButtonShowMode` controls when document close buttons appear +- `EmptyContent` defines placeholder content shown when a tabbed or MDI document host has no visible dockables - `CanCreateDocument` and `CreateDocument` control the new-document command - `CascadeDocuments`, `TileDocumentsHorizontal`, `TileDocumentsVertical`, and `RestoreDocuments` are MDI helpers @@ -124,9 +125,9 @@ These properties allow you to customize the context menus, flyouts, and button t Dock exposes template properties for tab headers and MDI windows: -- `DocumentControl`: `IconTemplate`, `HeaderTemplate`, `ModifiedTemplate`, `CloseTemplate`, `CloseButtonTheme`, `IsActive`, `TabsLayout`. +- `DocumentControl`: `IconTemplate`, `HeaderTemplate`, `ModifiedTemplate`, `CloseTemplate`, `EmptyContentTemplate`, `CloseButtonTheme`, `IsActive`, `TabsLayout`, `HasVisibleDockables`. - `ToolControl`: `IconTemplate`, `HeaderTemplate`, `ModifiedTemplate`. -- `MdiDocumentControl`: `IconTemplate`, `HeaderTemplate`, `ModifiedTemplate`, `CloseTemplate`, `CloseButtonTheme`, `LayoutManager`, `IsActive`. +- `MdiDocumentControl`: `IconTemplate`, `HeaderTemplate`, `ModifiedTemplate`, `CloseTemplate`, `EmptyContentTemplate`, `CloseButtonTheme`, `LayoutManager`, `IsActive`, `HasVisibleDocuments`. - `MdiDocumentWindow`: `IconTemplate`, `HeaderTemplate`, `ModifiedTemplate`, `CloseTemplate`, `CloseButtonTheme`, `DocumentContextMenu`, `IsActive`, `MdiState`. Use these to customize headers, icons, and modified indicators in styles or templates. See [Styling and theming](dock-styling.md) and [MDI document layout](dock-mdi.md) for details. diff --git a/src/Dock.Avalonia.Themes.Fluent/Controls/DocumentControl.axaml b/src/Dock.Avalonia.Themes.Fluent/Controls/DocumentControl.axaml index 142bccf0e..61a3190d4 100644 --- a/src/Dock.Avalonia.Themes.Fluent/Controls/DocumentControl.axaml +++ b/src/Dock.Avalonia.Themes.Fluent/Controls/DocumentControl.axaml @@ -118,17 +118,27 @@ - - - - - - - + + + + + + + + + + + diff --git a/src/Dock.Avalonia.Themes.Fluent/Controls/MdiDocumentControl.axaml b/src/Dock.Avalonia.Themes.Fluent/Controls/MdiDocumentControl.axaml index 49f1b83c1..ae3ffccbb 100644 --- a/src/Dock.Avalonia.Themes.Fluent/Controls/MdiDocumentControl.axaml +++ b/src/Dock.Avalonia.Themes.Fluent/Controls/MdiDocumentControl.axaml @@ -68,38 +68,48 @@ DockProperties.IsDropArea="True" DockProperties.IsDockTarget="True" DockProperties.DockAdornerHost="{Binding RelativeSource={RelativeSource Self}}"> - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + diff --git a/src/Dock.Avalonia/Controls/DocumentControl.axaml.cs b/src/Dock.Avalonia/Controls/DocumentControl.axaml.cs index 06dbcdb61..99da719f6 100644 --- a/src/Dock.Avalonia/Controls/DocumentControl.axaml.cs +++ b/src/Dock.Avalonia/Controls/DocumentControl.axaml.cs @@ -1,5 +1,8 @@ // Copyright (c) Wiesław Šoltés. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for details. +using System; +using System.Collections.Specialized; +using System.ComponentModel; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Metadata; @@ -7,6 +10,7 @@ using Avalonia.Controls.Templates; using Avalonia.Input; using Avalonia.Interactivity; +using Avalonia.Reactive; using Avalonia.Styling; using Dock.Model.Core; @@ -42,6 +46,12 @@ public class DocumentControl : TemplatedControl public static readonly StyledProperty CloseTemplateProperty = AvaloniaProperty.Register(nameof(CloseTemplate)); + /// + /// Defines the property. + /// + public static readonly StyledProperty EmptyContentTemplateProperty = + AvaloniaProperty.Register(nameof(EmptyContentTemplate)); + /// /// Define the property. /// @@ -69,6 +79,20 @@ public ControlTheme? CloseButtonTheme public static readonly StyledProperty TabsLayoutProperty = AvaloniaProperty.Register(nameof(TabsLayout)); + /// + /// Defines the property. + /// + public static readonly DirectProperty HasVisibleDockablesProperty = + AvaloniaProperty.RegisterDirect( + nameof(HasVisibleDockables), + o => o.HasVisibleDockables); + + private INotifyPropertyChanged? _dockSubscription; + private INotifyCollectionChanged? _dockablesSubscription; + private IDock? _currentDock; + private IDisposable? _dataContextSubscription; + private bool _hasVisibleDockables; + /// /// Gets or sets tab icon template. /// @@ -105,6 +129,15 @@ public IDataTemplate CloseTemplate set => SetValue(CloseTemplateProperty, value); } + /// + /// Gets or sets template used to render empty host content. + /// + public IDataTemplate? EmptyContentTemplate + { + get => GetValue(EmptyContentTemplateProperty); + set => SetValue(EmptyContentTemplateProperty, value); + } + /// /// Gets or sets if this is the currently active dockable. /// @@ -123,6 +156,15 @@ public DocumentTabLayout TabsLayout set => SetValue(TabsLayoutProperty, value); } + /// + /// Gets a value indicating whether the current tabbed host contains visible dockables. + /// + public bool HasVisibleDockables + { + get => _hasVisibleDockables; + private set => SetAndRaise(HasVisibleDockablesProperty, ref _hasVisibleDockables, value); + } + /// /// Initializes new instance of the class. /// @@ -137,6 +179,21 @@ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) base.OnAttachedToVisualTree(e); AddHandler(PointerPressedEvent, PressedHandler, RoutingStrategies.Tunnel); + + _dataContextSubscription = this.GetObservable(DataContextProperty) + .Subscribe(new AnonymousObserver(OnDockChanged)); + } + + /// + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + + RemoveHandler(PointerPressedEvent, PressedHandler); + + _dataContextSubscription?.Dispose(); + _dataContextSubscription = null; + DetachDockSubscriptions(); } private void PressedHandler(object? sender, PointerPressedEventArgs e) @@ -165,4 +222,86 @@ private void UpdatePseudoClasses(bool isActive) { PseudoClasses.Set(":active", isActive); } + + private void OnDockChanged(object? dataContext) + { + DetachDockSubscriptions(); + + _currentDock = dataContext as IDock; + if (_currentDock is null) + { + HasVisibleDockables = false; + return; + } + + if (_currentDock is INotifyPropertyChanged propertyChanged) + { + _dockSubscription = propertyChanged; + _dockSubscription.PropertyChanged += DockPropertyChanged; + } + + AttachDockablesCollection(_currentDock.VisibleDockables as INotifyCollectionChanged); + UpdateHasVisibleDockables(); + } + + private void DockPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (_currentDock is null) + { + return; + } + + if (e.PropertyName != nameof(IDock.VisibleDockables)) + { + return; + } + + AttachDockablesCollection(_currentDock.VisibleDockables as INotifyCollectionChanged); + UpdateHasVisibleDockables(); + } + + private void AttachDockablesCollection(INotifyCollectionChanged? collection) + { + if (_dockablesSubscription != null) + { + _dockablesSubscription.CollectionChanged -= DockablesCollectionChanged; + _dockablesSubscription = null; + } + + if (collection is null) + { + return; + } + + _dockablesSubscription = collection; + _dockablesSubscription.CollectionChanged += DockablesCollectionChanged; + } + + private void DockablesCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + UpdateHasVisibleDockables(); + } + + private void UpdateHasVisibleDockables() + { + HasVisibleDockables = _currentDock?.VisibleDockables?.Count > 0; + } + + private void DetachDockSubscriptions() + { + if (_dockSubscription != null) + { + _dockSubscription.PropertyChanged -= DockPropertyChanged; + _dockSubscription = null; + } + + if (_dockablesSubscription != null) + { + _dockablesSubscription.CollectionChanged -= DockablesCollectionChanged; + _dockablesSubscription = null; + } + + _currentDock = null; + HasVisibleDockables = false; + } } diff --git a/src/Dock.Avalonia/Controls/MdiDocumentControl.axaml.cs b/src/Dock.Avalonia/Controls/MdiDocumentControl.axaml.cs index 2c9825475..e6192c4bb 100644 --- a/src/Dock.Avalonia/Controls/MdiDocumentControl.axaml.cs +++ b/src/Dock.Avalonia/Controls/MdiDocumentControl.axaml.cs @@ -47,6 +47,12 @@ public class MdiDocumentControl : TemplatedControl public static readonly StyledProperty CloseTemplateProperty = AvaloniaProperty.Register(nameof(CloseTemplate)); + /// + /// Defines the property. + /// + public static readonly StyledProperty EmptyContentTemplateProperty = + AvaloniaProperty.Register(nameof(EmptyContentTemplate)); + /// /// Define the property. /// @@ -65,10 +71,19 @@ public class MdiDocumentControl : TemplatedControl public static readonly StyledProperty IsActiveProperty = AvaloniaProperty.Register(nameof(IsActive)); + /// + /// Defines the property. + /// + public static readonly DirectProperty HasVisibleDocumentsProperty = + AvaloniaProperty.RegisterDirect( + nameof(HasVisibleDocuments), + o => o.HasVisibleDocuments); + private INotifyPropertyChanged? _dockSubscription; private INotifyCollectionChanged? _dockablesSubscription; private IDock? _currentDock; private IDisposable? _dataContextSubscription; + private bool _hasVisibleDocuments; /// /// Gets or sets tab icon template. @@ -106,6 +121,15 @@ public IDataTemplate CloseTemplate set => SetValue(CloseTemplateProperty, value); } + /// + /// Gets or sets template used to render empty host content. + /// + public IDataTemplate? EmptyContentTemplate + { + get => GetValue(EmptyContentTemplateProperty); + set => SetValue(EmptyContentTemplateProperty, value); + } + /// /// Gets or sets the close button theme. /// @@ -133,6 +157,15 @@ public bool IsActive set => SetValue(IsActiveProperty, value); } + /// + /// Gets a value indicating whether the current MDI host contains visible MDI documents. + /// + public bool HasVisibleDocuments + { + get => _hasVisibleDocuments; + private set => SetAndRaise(HasVisibleDocumentsProperty, ref _hasVisibleDocuments, value); + } + /// /// Initializes new instance of the class. /// @@ -183,6 +216,7 @@ private void OnDockChanged(object? dataContext) _currentDock = dataContext as IDock; if (_currentDock is null) { + HasVisibleDocuments = false; return; } @@ -193,6 +227,7 @@ private void OnDockChanged(object? dataContext) } AttachDockablesCollection(_currentDock.VisibleDockables as INotifyCollectionChanged); + UpdateHasVisibleDocuments(); UpdateZOrder(); } @@ -206,6 +241,7 @@ private void DockPropertyChanged(object? sender, PropertyChangedEventArgs e) if (e.PropertyName == nameof(IDock.VisibleDockables)) { AttachDockablesCollection(_currentDock.VisibleDockables as INotifyCollectionChanged); + UpdateHasVisibleDocuments(); UpdateZOrder(); return; } @@ -235,9 +271,21 @@ private void AttachDockablesCollection(INotifyCollectionChanged? collection) private void DockablesCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { + UpdateHasVisibleDocuments(); UpdateZOrder(); } + private void UpdateHasVisibleDocuments() + { + if (_currentDock?.VisibleDockables is null) + { + HasVisibleDocuments = false; + return; + } + + HasVisibleDocuments = _currentDock.VisibleDockables.OfType().Any(); + } + private void UpdateZOrder() { if (_currentDock?.VisibleDockables is null) @@ -265,5 +313,6 @@ private void DetachDockSubscriptions() } _currentDock = null; + HasVisibleDocuments = false; } } diff --git a/src/Dock.Model.Avalonia/Controls/DocumentDock.cs b/src/Dock.Model.Avalonia/Controls/DocumentDock.cs index d1dc57adc..598f876b8 100644 --- a/src/Dock.Model.Avalonia/Controls/DocumentDock.cs +++ b/src/Dock.Model.Avalonia/Controls/DocumentDock.cs @@ -60,6 +60,12 @@ public class DocumentDock : DockBase, IDocumentDock, IDocumentDockContent, IItem public static readonly StyledProperty CloseButtonShowModeProperty = AvaloniaProperty.Register(nameof(CloseButtonShowMode), DocumentCloseButtonShowMode.Always); + /// + /// Defines the property. + /// + public static readonly StyledProperty EmptyContentProperty = + AvaloniaProperty.Register(nameof(EmptyContent), "No documents open"); + /// /// Defines the property. /// @@ -178,6 +184,15 @@ public DocumentCloseButtonShowMode CloseButtonShowMode set => SetValue(CloseButtonShowModeProperty, value); } + /// + [IgnoreDataMember] + [JsonIgnore] + public object? EmptyContent + { + get => GetValue(EmptyContentProperty); + set => SetValue(EmptyContentProperty, value); + } + /// [DataMember(IsRequired = false, EmitDefaultValue = true)] [JsonPropertyName("LayoutMode")] diff --git a/src/Dock.Model.CaliburMicro/Controls/DocumentDock.cs b/src/Dock.Model.CaliburMicro/Controls/DocumentDock.cs index c0bcceb83..36c25e617 100644 --- a/src/Dock.Model.CaliburMicro/Controls/DocumentDock.cs +++ b/src/Dock.Model.CaliburMicro/Controls/DocumentDock.cs @@ -14,6 +14,7 @@ namespace Dock.Model.CaliburMicro.Controls; /// public class DocumentDock : DockBase, IDocumentDock, IDocumentDockFactory { + private object? _emptyContent = "No documents open"; private bool _canCreateDocument = true; private ICommand? _createDocument; private bool _enableWindowDrag = true; @@ -79,6 +80,14 @@ public DocumentCloseButtonShowMode CloseButtonShowMode set => Set(ref _closeButtonShowMode, value); } + /// + [IgnoreDataMember] + public object? EmptyContent + { + get => _emptyContent; + set => Set(ref _emptyContent, value); + } + /// [DataMember(IsRequired = false, EmitDefaultValue = true)] public ICommand? CascadeDocuments diff --git a/src/Dock.Model.Inpc/Controls/DocumentDock.cs b/src/Dock.Model.Inpc/Controls/DocumentDock.cs index d76e21919..e6eaaf8f6 100644 --- a/src/Dock.Model.Inpc/Controls/DocumentDock.cs +++ b/src/Dock.Model.Inpc/Controls/DocumentDock.cs @@ -15,6 +15,7 @@ namespace Dock.Model.Inpc.Controls; [DataContract(IsReference = true)] public class DocumentDock : DockBase, IDocumentDock, IDocumentDockFactory { + private object? _emptyContent = "No documents open"; private bool _canCreateDocument; private bool _enableWindowDrag; private DocumentLayoutMode _layoutMode = DocumentLayoutMode.Tabbed; @@ -100,6 +101,14 @@ public DocumentCloseButtonShowMode CloseButtonShowMode set => SetProperty(ref _closeButtonShowMode, value); } + /// + [IgnoreDataMember] + public object? EmptyContent + { + get => _emptyContent; + set => SetProperty(ref _emptyContent, value); + } + private void CreateNewDocument() { if (DocumentFactory is { } factory) diff --git a/src/Dock.Model.Mvvm/Controls/DocumentDock.cs b/src/Dock.Model.Mvvm/Controls/DocumentDock.cs index 0c0cbb077..6b3d706f4 100644 --- a/src/Dock.Model.Mvvm/Controls/DocumentDock.cs +++ b/src/Dock.Model.Mvvm/Controls/DocumentDock.cs @@ -15,6 +15,7 @@ namespace Dock.Model.Mvvm.Controls; /// public class DocumentDock : DockBase, IDocumentDock, IDocumentDockFactory { + private object? _emptyContent = "No documents open"; private bool _canCreateDocument; private bool _enableWindowDrag; private DocumentLayoutMode _layoutMode = DocumentLayoutMode.Tabbed; @@ -100,6 +101,14 @@ public DocumentCloseButtonShowMode CloseButtonShowMode set => SetProperty(ref _closeButtonShowMode, value); } + /// + [IgnoreDataMember] + public object? EmptyContent + { + get => _emptyContent; + set => SetProperty(ref _emptyContent, value); + } + private void CreateNewDocument() { if (DocumentFactory is { } factory) diff --git a/src/Dock.Model.Prism/Controls/DocumentDock.cs b/src/Dock.Model.Prism/Controls/DocumentDock.cs index 9fd0a9631..aaad959c1 100644 --- a/src/Dock.Model.Prism/Controls/DocumentDock.cs +++ b/src/Dock.Model.Prism/Controls/DocumentDock.cs @@ -15,6 +15,7 @@ namespace Dock.Model.Prism.Controls; /// public class DocumentDock : DockBase, IDocumentDock, IDocumentDockFactory { + private object? _emptyContent = "No documents open"; private bool _canCreateDocument; private bool _enableWindowDrag; private DocumentLayoutMode _layoutMode = DocumentLayoutMode.Tabbed; @@ -100,6 +101,14 @@ public DocumentCloseButtonShowMode CloseButtonShowMode set => SetProperty(ref _closeButtonShowMode, value); } + /// + [IgnoreDataMember] + public object? EmptyContent + { + get => _emptyContent; + set => SetProperty(ref _emptyContent, value); + } + private void CreateNewDocument() { if (DocumentFactory is { } factory) diff --git a/src/Dock.Model.ReactiveProperty/Controls/DocumentDock.cs b/src/Dock.Model.ReactiveProperty/Controls/DocumentDock.cs index 78662b77a..6e245c2ff 100644 --- a/src/Dock.Model.ReactiveProperty/Controls/DocumentDock.cs +++ b/src/Dock.Model.ReactiveProperty/Controls/DocumentDock.cs @@ -16,6 +16,7 @@ namespace Dock.Model.ReactiveProperty.Controls; [DataContract(IsReference = true)] public class DocumentDock : DockBase, IDocumentDock, IDocumentDockFactory { + private object? _emptyContent = "No documents open"; private bool _canCreateDocument; private bool _enableWindowDrag; private DocumentLayoutMode _layoutMode = DocumentLayoutMode.Tabbed; @@ -101,6 +102,14 @@ public DocumentCloseButtonShowMode CloseButtonShowMode set => SetProperty(ref _closeButtonShowMode, value); } + /// + [IgnoreDataMember] + public object? EmptyContent + { + get => _emptyContent; + set => SetProperty(ref _emptyContent, value); + } + private void CreateNewDocument() { if (DocumentFactory is { } factory) diff --git a/src/Dock.Model.ReactiveUI/Controls/DocumentDock.cs b/src/Dock.Model.ReactiveUI/Controls/DocumentDock.cs index 35f2c64d6..c9f8e624e 100644 --- a/src/Dock.Model.ReactiveUI/Controls/DocumentDock.cs +++ b/src/Dock.Model.ReactiveUI/Controls/DocumentDock.cs @@ -16,6 +16,7 @@ namespace Dock.Model.ReactiveUI.Controls; /// public partial class DocumentDock : DockBase, IDocumentDock, IDocumentDockFactory { + private object? _emptyContent = "No documents open"; private DocumentTabLayout _tabsLayout = DocumentTabLayout.Top; private DocumentLayoutMode _layoutMode = DocumentLayoutMode.Tabbed; private DocumentCloseButtonShowMode _closeButtonShowMode = DocumentCloseButtonShowMode.Always; @@ -92,6 +93,14 @@ public DocumentCloseButtonShowMode CloseButtonShowMode set => this.RaiseAndSetIfChanged(ref _closeButtonShowMode, value); } + /// + [IgnoreDataMember] + public object? EmptyContent + { + get => _emptyContent; + set => this.RaiseAndSetIfChanged(ref _emptyContent, value); + } + private void CreateNewDocument() { if (DocumentFactory is { } factory) diff --git a/src/Dock.Model/Controls/IDocumentDock.cs b/src/Dock.Model/Controls/IDocumentDock.cs index 22ef0eee2..c0dbddca7 100644 --- a/src/Dock.Model/Controls/IDocumentDock.cs +++ b/src/Dock.Model/Controls/IDocumentDock.cs @@ -41,6 +41,11 @@ public interface IDocumentDock : IDock, ILocalTarget /// DocumentCloseButtonShowMode CloseButtonShowMode { get; set; } + /// + /// Gets or sets placeholder content shown when the document host has no visible dockables. + /// + object? EmptyContent { get; set; } + /// /// Gets or sets command to cascade MDI documents. /// diff --git a/src/Dock.Model/FluentExtensions.cs b/src/Dock.Model/FluentExtensions.cs index ee34cccf6..1cda3ddb3 100644 --- a/src/Dock.Model/FluentExtensions.cs +++ b/src/Dock.Model/FluentExtensions.cs @@ -969,6 +969,13 @@ public static IRootDock WithWindows(this IRootDock dock, params IDockWindow[] wi /// Close button display mode. /// The same document dock. public static IDocumentDock WithCloseButtonShowMode(this IDocumentDock dock, DocumentCloseButtonShowMode mode) { dock.CloseButtonShowMode = mode; return dock; } + /// + /// Sets placeholder content displayed when the document host has no visible dockables. + /// + /// The document dock. + /// Placeholder content. + /// The same document dock. + public static IDocumentDock WithEmptyContent(this IDocumentDock dock, object? content) { dock.EmptyContent = content; return dock; } // Avoid shadowing instance methods; provide chainable variants with distinct names /// /// Appends a document to the document dock in a chainable way. diff --git a/tests/Dock.Avalonia.HeadlessTests/ControlSubscriptionLeakTests.cs b/tests/Dock.Avalonia.HeadlessTests/ControlSubscriptionLeakTests.cs new file mode 100644 index 000000000..9560d8030 --- /dev/null +++ b/tests/Dock.Avalonia.HeadlessTests/ControlSubscriptionLeakTests.cs @@ -0,0 +1,246 @@ +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Dock.Avalonia.Controls; +using Dock.Model.Avalonia; +using Dock.Model.Avalonia.Controls; +using Dock.Model.Core; +using Xunit; + +namespace Dock.Avalonia.HeadlessTests; + +public class ControlSubscriptionLeakTests +{ + [AvaloniaFact] + public void DocumentControl_Subscriptions_Are_Released_On_Switch_Collection_Replace_And_Detach() + { + var firstCollection = new TrackingDockableCollection(); + var secondCollection = new TrackingDockableCollection(); + var replacementCollection = new TrackingDockableCollection(); + var firstDock = CreateDocumentDock(DocumentLayoutMode.Tabbed, firstCollection); + var secondDock = CreateDocumentDock(DocumentLayoutMode.Tabbed, secondCollection); + var control = new DocumentControl { DataContext = firstDock }; + var window = new Window + { + Width = 640, + Height = 480, + Content = control + }; + + window.Show(); + control.ApplyTemplate(); + window.UpdateLayout(); + control.UpdateLayout(); + + try + { + Assert.Equal(1, firstCollection.CollectionChangedSubscriberCount); + Assert.Equal(0, secondCollection.CollectionChangedSubscriberCount); + + control.DataContext = secondDock; + window.UpdateLayout(); + control.UpdateLayout(); + + Assert.Equal(0, firstCollection.CollectionChangedSubscriberCount); + Assert.Equal(1, secondCollection.CollectionChangedSubscriberCount); + + secondDock.VisibleDockables = replacementCollection; + window.UpdateLayout(); + control.UpdateLayout(); + + Assert.Equal(0, secondCollection.CollectionChangedSubscriberCount); + Assert.Equal(1, replacementCollection.CollectionChangedSubscriberCount); + + window.Content = null; + window.UpdateLayout(); + + Assert.Equal(0, replacementCollection.CollectionChangedSubscriberCount); + Assert.False(control.HasVisibleDockables); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void MdiDocumentControl_Subscriptions_Are_Released_On_Switch_Collection_Replace_And_Detach() + { + var firstCollection = new TrackingDockableCollection(); + var secondCollection = new TrackingDockableCollection(); + var replacementCollection = new TrackingDockableCollection(); + var firstDock = CreateDocumentDock(DocumentLayoutMode.Mdi, firstCollection); + var secondDock = CreateDocumentDock(DocumentLayoutMode.Mdi, secondCollection); + var control = new MdiDocumentControl { DataContext = firstDock }; + var window = new Window + { + Width = 640, + Height = 480, + Content = control + }; + + window.Show(); + control.ApplyTemplate(); + window.UpdateLayout(); + control.UpdateLayout(); + + try + { + Assert.Equal(1, firstCollection.CollectionChangedSubscriberCount); + Assert.Equal(0, secondCollection.CollectionChangedSubscriberCount); + + control.DataContext = secondDock; + window.UpdateLayout(); + control.UpdateLayout(); + + Assert.Equal(0, firstCollection.CollectionChangedSubscriberCount); + Assert.Equal(1, secondCollection.CollectionChangedSubscriberCount); + + secondDock.VisibleDockables = replacementCollection; + window.UpdateLayout(); + control.UpdateLayout(); + + Assert.Equal(0, secondCollection.CollectionChangedSubscriberCount); + Assert.Equal(1, replacementCollection.CollectionChangedSubscriberCount); + + window.Content = null; + window.UpdateLayout(); + + Assert.Equal(0, replacementCollection.CollectionChangedSubscriberCount); + Assert.False(control.HasVisibleDocuments); + } + finally + { + window.Close(); + } + } + + private static DocumentDock CreateDocumentDock(DocumentLayoutMode layoutMode, TrackingDockableCollection visibleDockables) + { + var dock = new DocumentDock + { + Factory = new Factory(), + LayoutMode = layoutMode, + VisibleDockables = visibleDockables, + EmptyContent = "No documents are open." + }; + return dock; + } + + private sealed class TrackingDockableCollection : IList, INotifyCollectionChanged + { + private readonly List _items = new(); + private NotifyCollectionChangedEventHandler? _collectionChanged; + + public int CollectionChangedSubscriberCount { get; private set; } + + public event NotifyCollectionChangedEventHandler? CollectionChanged + { + add + { + _collectionChanged += value; + CollectionChangedSubscriberCount++; + } + remove + { + _collectionChanged -= value; + CollectionChangedSubscriberCount--; + } + } + + public int Count => _items.Count; + + public bool IsReadOnly => false; + + public IDockable this[int index] + { + get => _items[index]; + set + { + var oldItem = _items[index]; + _items[index] = value; + RaiseCollectionChanged(value, oldItem, index); + } + } + + public void Add(IDockable item) + { + _items.Add(item); + RaiseCollectionChanged(NotifyCollectionChangedAction.Add, item, _items.Count - 1); + } + + public void Clear() + { + _items.Clear(); + RaiseCollectionChanged(NotifyCollectionChangedAction.Reset); + } + + public bool Contains(IDockable item) + { + return _items.Contains(item); + } + + public void CopyTo(IDockable[] array, int arrayIndex) + { + _items.CopyTo(array, arrayIndex); + } + + public IEnumerator GetEnumerator() + { + return _items.GetEnumerator(); + } + + public int IndexOf(IDockable item) + { + return _items.IndexOf(item); + } + + public void Insert(int index, IDockable item) + { + _items.Insert(index, item); + RaiseCollectionChanged(NotifyCollectionChangedAction.Add, item, index); + } + + public bool Remove(IDockable item) + { + var index = _items.IndexOf(item); + if (index < 0) + { + return false; + } + + _items.RemoveAt(index); + RaiseCollectionChanged(NotifyCollectionChangedAction.Remove, item, index); + return true; + } + + public void RemoveAt(int index) + { + var oldItem = _items[index]; + _items.RemoveAt(index); + RaiseCollectionChanged(NotifyCollectionChangedAction.Remove, oldItem, index); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return _items.GetEnumerator(); + } + + private void RaiseCollectionChanged(NotifyCollectionChangedAction action) + { + _collectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(action)); + } + + private void RaiseCollectionChanged(NotifyCollectionChangedAction action, IDockable item, int index) + { + _collectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(action, item, index)); + } + + private void RaiseCollectionChanged(IDockable newItem, IDockable oldItem, int index) + { + _collectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Replace, newItem, oldItem, index)); + } + } +} diff --git a/tests/Dock.Avalonia.HeadlessTests/DocumentDockPropertiesTests.cs b/tests/Dock.Avalonia.HeadlessTests/DocumentDockPropertiesTests.cs index 651e92f26..4824d22e5 100644 --- a/tests/Dock.Avalonia.HeadlessTests/DocumentDockPropertiesTests.cs +++ b/tests/Dock.Avalonia.HeadlessTests/DocumentDockPropertiesTests.cs @@ -29,6 +29,13 @@ public void DocumentDock_Default_LayoutMode_Tabbed() Assert.Equal(DocumentLayoutMode.Tabbed, dock.LayoutMode); } + [AvaloniaFact] + public void DocumentDock_Default_EmptyContent_Value() + { + var dock = new DocumentDock(); + Assert.Equal("No documents open", dock.EmptyContent); + } + [AvaloniaFact] public void SetDocumentDockTabsLayoutLeft_Changes_TabsLayout() { diff --git a/tests/Dock.Avalonia.HeadlessTests/DocumentEmptyContentTests.cs b/tests/Dock.Avalonia.HeadlessTests/DocumentEmptyContentTests.cs new file mode 100644 index 000000000..9234ed685 --- /dev/null +++ b/tests/Dock.Avalonia.HeadlessTests/DocumentEmptyContentTests.cs @@ -0,0 +1,219 @@ +using System.Linq; +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using Avalonia.Headless.XUnit; +using Avalonia.VisualTree; +using Dock.Avalonia.Controls; +using Dock.Model.Avalonia; +using Dock.Model.Avalonia.Controls; +using Dock.Model.Core; +using Xunit; + +namespace Dock.Avalonia.HeadlessTests; + +public class DocumentEmptyContentTests +{ + [AvaloniaFact] + public void DocumentControl_Shows_EmptyContent_When_NoDocuments() + { + var factory = new Factory(); + var dock = new DocumentDock + { + Factory = factory, + LayoutMode = DocumentLayoutMode.Tabbed, + VisibleDockables = factory.CreateList(), + EmptyContent = "No documents are open." + }; + + var control = new DocumentControl { DataContext = dock }; + var window = new Window + { + Width = 800, + Height = 600, + Content = control + }; + + window.Show(); + control.ApplyTemplate(); + window.UpdateLayout(); + control.UpdateLayout(); + + try + { + var emptyHost = control.GetVisualDescendants() + .OfType() + .FirstOrDefault(candidate => candidate.Name == "PART_EmptyContentHost"); + Assert.NotNull(emptyHost); + Assert.True(emptyHost!.IsVisible); + Assert.Equal("No documents are open.", emptyHost.Content); + Assert.False(control.HasVisibleDockables); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void DocumentControl_Hides_EmptyContent_When_Documents_Exist() + { + var factory = new Factory(); + var dock = new DocumentDock + { + Factory = factory, + LayoutMode = DocumentLayoutMode.Tabbed, + VisibleDockables = factory.CreateList(), + EmptyContent = "No documents are open." + }; + + var document = new Document + { + Id = "Document1", + Title = "Document 1" + }; + dock.VisibleDockables!.Add(document); + dock.ActiveDockable = document; + + var control = new DocumentControl { DataContext = dock }; + var window = new Window + { + Width = 800, + Height = 600, + Content = control + }; + + window.Show(); + control.ApplyTemplate(); + window.UpdateLayout(); + control.UpdateLayout(); + + try + { + var emptyHost = control.GetVisualDescendants() + .OfType() + .FirstOrDefault(candidate => candidate.Name == "PART_EmptyContentHost"); + Assert.NotNull(emptyHost); + Assert.False(emptyHost!.IsVisible); + Assert.True(control.HasVisibleDockables); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void DocumentControl_Updates_EmptyContent_Visibility_On_Document_Add_Remove() + { + var factory = new Factory(); + var dock = new DocumentDock + { + Factory = factory, + LayoutMode = DocumentLayoutMode.Tabbed, + VisibleDockables = factory.CreateList(), + EmptyContent = "No documents are open." + }; + + var control = new DocumentControl { DataContext = dock }; + var window = new Window + { + Width = 800, + Height = 600, + Content = control + }; + + window.Show(); + control.ApplyTemplate(); + window.UpdateLayout(); + control.UpdateLayout(); + + try + { + var emptyHost = control.GetVisualDescendants() + .OfType() + .FirstOrDefault(candidate => candidate.Name == "PART_EmptyContentHost"); + Assert.NotNull(emptyHost); + + Assert.True(emptyHost!.IsVisible); + Assert.False(control.HasVisibleDockables); + + var document = new Document + { + Id = "Document1", + Title = "Document 1" + }; + dock.VisibleDockables!.Add(document); + dock.ActiveDockable = document; + window.UpdateLayout(); + control.UpdateLayout(); + + Assert.False(emptyHost.IsVisible); + Assert.True(control.HasVisibleDockables); + + dock.VisibleDockables.Remove(document); + dock.ActiveDockable = null; + window.UpdateLayout(); + control.UpdateLayout(); + + Assert.True(emptyHost.IsVisible); + Assert.False(control.HasVisibleDockables); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void DocumentControl_Applies_EmptyContentTemplate() + { + var factory = new Factory(); + var template = new FuncDataTemplate( + (content, _) => new TextBlock { Text = $"Template: {content}" }, + true); + + var dock = new DocumentDock + { + Factory = factory, + LayoutMode = DocumentLayoutMode.Tabbed, + VisibleDockables = factory.CreateList(), + EmptyContent = "No documents are open." + }; + + var control = new DocumentControl + { + DataContext = dock, + EmptyContentTemplate = template + }; + + var window = new Window + { + Width = 800, + Height = 600, + Content = control + }; + + window.Show(); + control.ApplyTemplate(); + window.UpdateLayout(); + control.UpdateLayout(); + + try + { + var emptyHost = control.GetVisualDescendants() + .OfType() + .FirstOrDefault(candidate => candidate.Name == "PART_EmptyContentHost"); + Assert.NotNull(emptyHost); + Assert.Same(template, emptyHost!.ContentTemplate); + + var templateText = emptyHost.GetVisualDescendants() + .OfType() + .FirstOrDefault(candidate => candidate.Text == "Template: No documents are open."); + Assert.NotNull(templateText); + } + finally + { + window.Close(); + } + } +} diff --git a/tests/Dock.Avalonia.HeadlessTests/MdiEmptyContentTests.cs b/tests/Dock.Avalonia.HeadlessTests/MdiEmptyContentTests.cs new file mode 100644 index 000000000..bb12b8c47 --- /dev/null +++ b/tests/Dock.Avalonia.HeadlessTests/MdiEmptyContentTests.cs @@ -0,0 +1,211 @@ +using System.Linq; +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using Avalonia.Headless.XUnit; +using Avalonia.VisualTree; +using Dock.Avalonia.Controls; +using Dock.Model.Avalonia; +using Dock.Model.Avalonia.Controls; +using Dock.Model.Core; +using Xunit; + +namespace Dock.Avalonia.HeadlessTests; + +public class MdiEmptyContentTests +{ + [AvaloniaFact] + public void MdiDocumentControl_Shows_EmptyContent_When_NoDocuments() + { + var factory = new Factory(); + var dock = new DocumentDock + { + Factory = factory, + LayoutMode = DocumentLayoutMode.Mdi, + VisibleDockables = factory.CreateList(), + EmptyContent = "No documents are open." + }; + + var control = new MdiDocumentControl { DataContext = dock }; + var window = new Window + { + Width = 800, + Height = 600, + Content = control + }; + + window.Show(); + control.ApplyTemplate(); + window.UpdateLayout(); + control.UpdateLayout(); + + try + { + var emptyHost = control.GetVisualDescendants() + .OfType() + .FirstOrDefault(candidate => candidate.Name == "PART_EmptyContentHost"); + Assert.NotNull(emptyHost); + Assert.True(emptyHost!.IsVisible); + Assert.Equal("No documents are open.", emptyHost.Content); + Assert.False(control.HasVisibleDocuments); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void MdiDocumentControl_Hides_EmptyContent_When_Documents_Exist() + { + var factory = new Factory(); + var dock = new DocumentDock + { + Factory = factory, + LayoutMode = DocumentLayoutMode.Mdi, + VisibleDockables = factory.CreateList(), + EmptyContent = "No documents are open." + }; + + var document = new Document + { + Id = "Document1", + Title = "Document 1" + }; + dock.VisibleDockables!.Add(document); + + var control = new MdiDocumentControl { DataContext = dock }; + var window = new Window + { + Width = 800, + Height = 600, + Content = control + }; + + window.Show(); + control.ApplyTemplate(); + window.UpdateLayout(); + control.UpdateLayout(); + + try + { + var emptyHost = control.GetVisualDescendants() + .OfType() + .FirstOrDefault(candidate => candidate.Name == "PART_EmptyContentHost"); + Assert.NotNull(emptyHost); + Assert.False(emptyHost!.IsVisible); + Assert.True(control.HasVisibleDocuments); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void MdiDocumentControl_Updates_EmptyContent_Visibility_On_Document_Add_Remove() + { + var factory = new Factory(); + var dock = new DocumentDock + { + Factory = factory, + LayoutMode = DocumentLayoutMode.Mdi, + VisibleDockables = factory.CreateList(), + EmptyContent = "No documents are open." + }; + + var control = new MdiDocumentControl { DataContext = dock }; + var window = new Window + { + Width = 800, + Height = 600, + Content = control + }; + + window.Show(); + control.ApplyTemplate(); + window.UpdateLayout(); + control.UpdateLayout(); + + try + { + var emptyHost = control.GetVisualDescendants() + .OfType() + .FirstOrDefault(candidate => candidate.Name == "PART_EmptyContentHost"); + Assert.NotNull(emptyHost); + + Assert.True(emptyHost!.IsVisible); + Assert.False(control.HasVisibleDocuments); + + var document = new Document { Id = "Document1", Title = "Document 1" }; + dock.VisibleDockables!.Add(document); + window.UpdateLayout(); + control.UpdateLayout(); + + Assert.False(emptyHost.IsVisible); + Assert.True(control.HasVisibleDocuments); + + dock.VisibleDockables.Remove(document); + window.UpdateLayout(); + control.UpdateLayout(); + + Assert.True(emptyHost.IsVisible); + Assert.False(control.HasVisibleDocuments); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void MdiDocumentControl_Applies_EmptyContentTemplate() + { + var factory = new Factory(); + var template = new FuncDataTemplate( + (content, _) => new TextBlock { Text = $"Template: {content}" }, + true); + + var dock = new DocumentDock + { + Factory = factory, + LayoutMode = DocumentLayoutMode.Mdi, + VisibleDockables = factory.CreateList(), + EmptyContent = "No documents are open." + }; + + var control = new MdiDocumentControl + { + DataContext = dock, + EmptyContentTemplate = template + }; + var window = new Window + { + Width = 800, + Height = 600, + Content = control + }; + + window.Show(); + control.ApplyTemplate(); + window.UpdateLayout(); + control.UpdateLayout(); + + try + { + var emptyHost = control.GetVisualDescendants() + .OfType() + .FirstOrDefault(candidate => candidate.Name == "PART_EmptyContentHost"); + Assert.NotNull(emptyHost); + Assert.Same(template, emptyHost!.ContentTemplate); + + var templateText = emptyHost.GetVisualDescendants() + .OfType() + .FirstOrDefault(candidate => candidate.Text == "Template: No documents are open."); + Assert.NotNull(templateText); + } + finally + { + window.Close(); + } + } +} diff --git a/tests/Dock.Avalonia.LeakTests/EmptyContentSubscriptionLeakTests.cs b/tests/Dock.Avalonia.LeakTests/EmptyContentSubscriptionLeakTests.cs new file mode 100644 index 000000000..7e97a9f9d --- /dev/null +++ b/tests/Dock.Avalonia.LeakTests/EmptyContentSubscriptionLeakTests.cs @@ -0,0 +1,295 @@ +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using Avalonia.Controls; +using Avalonia.Themes.Fluent; +using Dock.Avalonia.Controls; +using Dock.Avalonia.Themes.Fluent; +using Dock.Model.Avalonia; +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 EmptyContentSubscriptionLeakTests +{ + [ReleaseFact] + public void DocumentControl_Subscriptions_Are_Released_On_Swap_Replace_And_Detach() + { + var result = RunInSession(() => + { + var firstCollection = new TrackingDockableCollection(); + var secondCollection = new TrackingDockableCollection(); + var replacementCollection = new TrackingDockableCollection(); + var firstDock = CreateDocumentDock(DocumentLayoutMode.Tabbed, firstCollection); + var secondDock = CreateDocumentDock(DocumentLayoutMode.Tabbed, secondCollection); + + var control = new DocumentControl { DataContext = firstDock }; + var window = new Window + { + Width = 640, + Height = 480, + Content = control + }; + window.Styles.Add(new FluentTheme()); + window.Styles.Add(new DockFluentTheme()); + + ShowWindow(window); + control.ApplyTemplate(); + control.UpdateLayout(); + DrainDispatcher(); + + var initialFirst = firstCollection.CollectionChangedSubscriberCount; + var initialSecond = secondCollection.CollectionChangedSubscriberCount; + + control.DataContext = secondDock; + DrainDispatcher(); + + var afterSwapFirst = firstCollection.CollectionChangedSubscriberCount; + var afterSwapSecond = secondCollection.CollectionChangedSubscriberCount; + + secondDock.VisibleDockables = replacementCollection; + DrainDispatcher(); + + var afterReplaceSecond = secondCollection.CollectionChangedSubscriberCount; + var afterReplaceNew = replacementCollection.CollectionChangedSubscriberCount; + + window.Content = null; + DrainDispatcher(); + + var afterDetach = replacementCollection.CollectionChangedSubscriberCount; + var hasVisibleDockables = control.HasVisibleDockables; + + CleanupWindow(window); + + return new SubscriptionResult( + initialFirst, + initialSecond, + afterSwapFirst, + afterSwapSecond, + afterReplaceSecond, + afterReplaceNew, + afterDetach, + hasVisibleDockables); + }); + + Assert.Equal(1, result.InitialFirstSubscribers); + Assert.Equal(0, result.InitialSecondSubscribers); + Assert.Equal(0, result.AfterSwapFirstSubscribers); + Assert.Equal(1, result.AfterSwapSecondSubscribers); + Assert.Equal(0, result.AfterReplaceOldSubscribers); + Assert.Equal(1, result.AfterReplaceNewSubscribers); + Assert.Equal(0, result.AfterDetachSubscribers); + Assert.False(result.HasVisibleItemsAfterDetach); + } + + [ReleaseFact] + public void MdiDocumentControl_Subscriptions_Are_Released_On_Swap_Replace_And_Detach() + { + var result = RunInSession(() => + { + var firstCollection = new TrackingDockableCollection(); + var secondCollection = new TrackingDockableCollection(); + var replacementCollection = new TrackingDockableCollection(); + var firstDock = CreateDocumentDock(DocumentLayoutMode.Mdi, firstCollection); + var secondDock = CreateDocumentDock(DocumentLayoutMode.Mdi, secondCollection); + + var control = new MdiDocumentControl { DataContext = firstDock }; + var window = new Window + { + Width = 640, + Height = 480, + Content = control + }; + window.Styles.Add(new FluentTheme()); + window.Styles.Add(new DockFluentTheme()); + + ShowWindow(window); + control.ApplyTemplate(); + control.UpdateLayout(); + DrainDispatcher(); + + var initialFirst = firstCollection.CollectionChangedSubscriberCount; + var initialSecond = secondCollection.CollectionChangedSubscriberCount; + + control.DataContext = secondDock; + DrainDispatcher(); + + var afterSwapFirst = firstCollection.CollectionChangedSubscriberCount; + var afterSwapSecond = secondCollection.CollectionChangedSubscriberCount; + + secondDock.VisibleDockables = replacementCollection; + DrainDispatcher(); + + var afterReplaceSecond = secondCollection.CollectionChangedSubscriberCount; + var afterReplaceNew = replacementCollection.CollectionChangedSubscriberCount; + + window.Content = null; + DrainDispatcher(); + + var afterDetach = replacementCollection.CollectionChangedSubscriberCount; + var hasVisibleDocuments = control.HasVisibleDocuments; + + CleanupWindow(window); + + return new SubscriptionResult( + initialFirst, + initialSecond, + afterSwapFirst, + afterSwapSecond, + afterReplaceSecond, + afterReplaceNew, + afterDetach, + hasVisibleDocuments); + }); + + Assert.Equal(1, result.InitialFirstSubscribers); + Assert.Equal(0, result.InitialSecondSubscribers); + Assert.Equal(0, result.AfterSwapFirstSubscribers); + Assert.Equal(1, result.AfterSwapSecondSubscribers); + Assert.Equal(0, result.AfterReplaceOldSubscribers); + Assert.Equal(1, result.AfterReplaceNewSubscribers); + Assert.Equal(0, result.AfterDetachSubscribers); + Assert.False(result.HasVisibleItemsAfterDetach); + } + + private static DocumentDock CreateDocumentDock(DocumentLayoutMode layoutMode, TrackingDockableCollection visibleDockables) + { + return new DocumentDock + { + Factory = new Factory(), + LayoutMode = layoutMode, + VisibleDockables = visibleDockables, + EmptyContent = "No documents are open." + }; + } + + private sealed record SubscriptionResult( + int InitialFirstSubscribers, + int InitialSecondSubscribers, + int AfterSwapFirstSubscribers, + int AfterSwapSecondSubscribers, + int AfterReplaceOldSubscribers, + int AfterReplaceNewSubscribers, + int AfterDetachSubscribers, + bool HasVisibleItemsAfterDetach); + + private sealed class TrackingDockableCollection : IList, INotifyCollectionChanged + { + private readonly List _items = new(); + private NotifyCollectionChangedEventHandler? _collectionChanged; + + public int CollectionChangedSubscriberCount { get; private set; } + + public event NotifyCollectionChangedEventHandler? CollectionChanged + { + add + { + _collectionChanged += value; + CollectionChangedSubscriberCount++; + } + remove + { + _collectionChanged -= value; + CollectionChangedSubscriberCount--; + } + } + + public int Count => _items.Count; + + public bool IsReadOnly => false; + + public IDockable this[int index] + { + get => _items[index]; + set + { + var oldItem = _items[index]; + _items[index] = value; + RaiseCollectionChanged(value, oldItem, index); + } + } + + public void Add(IDockable item) + { + _items.Add(item); + RaiseCollectionChanged(NotifyCollectionChangedAction.Add, item, _items.Count - 1); + } + + public void Clear() + { + _items.Clear(); + RaiseCollectionChanged(NotifyCollectionChangedAction.Reset); + } + + public bool Contains(IDockable item) + { + return _items.Contains(item); + } + + public void CopyTo(IDockable[] array, int arrayIndex) + { + _items.CopyTo(array, arrayIndex); + } + + public IEnumerator GetEnumerator() + { + return _items.GetEnumerator(); + } + + public int IndexOf(IDockable item) + { + return _items.IndexOf(item); + } + + public void Insert(int index, IDockable item) + { + _items.Insert(index, item); + RaiseCollectionChanged(NotifyCollectionChangedAction.Add, item, index); + } + + public bool Remove(IDockable item) + { + var index = _items.IndexOf(item); + if (index < 0) + { + return false; + } + + _items.RemoveAt(index); + RaiseCollectionChanged(NotifyCollectionChangedAction.Remove, item, index); + return true; + } + + public void RemoveAt(int index) + { + var oldItem = _items[index]; + _items.RemoveAt(index); + RaiseCollectionChanged(NotifyCollectionChangedAction.Remove, oldItem, index); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return _items.GetEnumerator(); + } + + private void RaiseCollectionChanged(NotifyCollectionChangedAction action) + { + _collectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(action)); + } + + private void RaiseCollectionChanged(NotifyCollectionChangedAction action, IDockable item, int index) + { + _collectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(action, item, index)); + } + + private void RaiseCollectionChanged(IDockable newItem, IDockable oldItem, int index) + { + _collectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Replace, newItem, oldItem, index)); + } + } +} diff --git a/tests/Dock.Model.CaliburMicro.UnitTests/DocumentDockTests.cs b/tests/Dock.Model.CaliburMicro.UnitTests/DocumentDockTests.cs index 629a620d5..814dd101b 100644 --- a/tests/Dock.Model.CaliburMicro.UnitTests/DocumentDockTests.cs +++ b/tests/Dock.Model.CaliburMicro.UnitTests/DocumentDockTests.cs @@ -68,4 +68,12 @@ public void AddTool_UsesFactory() Assert.Same(dock, factory.Focused[0].Dock); Assert.Same(tool, factory.Focused[0].Dockable); } + + [Fact] + public void EmptyContent_Default_Is_Set() + { + var dock = new DocumentDock(); + Assert.Equal("No documents open", dock.EmptyContent); + } + } diff --git a/tests/Dock.Model.Mvvm.UnitTests/FactoryTests.cs b/tests/Dock.Model.Mvvm.UnitTests/FactoryTests.cs index 00c8f2546..036acba9a 100644 --- a/tests/Dock.Model.Mvvm.UnitTests/FactoryTests.cs +++ b/tests/Dock.Model.Mvvm.UnitTests/FactoryTests.cs @@ -93,6 +93,13 @@ public void CreateDocumentDock_Creates_DocumentDock() Assert.IsType(actual); } + [Fact] + public void DocumentDock_Default_EmptyContent_Is_Set() + { + var dock = new DocumentDock(); + Assert.Equal("No documents open", dock.EmptyContent); + } + [Fact] public void CreateDockWindow_Creates_DockWindow() { diff --git a/tests/Dock.Model.Prism.UnitTests/DocumentDockTests.cs b/tests/Dock.Model.Prism.UnitTests/DocumentDockTests.cs index 99182f23e..2bf3224d5 100644 --- a/tests/Dock.Model.Prism.UnitTests/DocumentDockTests.cs +++ b/tests/Dock.Model.Prism.UnitTests/DocumentDockTests.cs @@ -83,4 +83,12 @@ public void CreateDocument_Command_UsesFactoryFunction() Assert.Single(factory.Added); Assert.Same(created, factory.Added[0].Dockable); } + + [Fact] + public void EmptyContent_Default_Is_Set() + { + var dock = new DocumentDock(); + Assert.Equal("No documents open", dock.EmptyContent); + } + } diff --git a/tests/Dock.Model.ReactiveProperty.UnitTests/FactoryTests.cs b/tests/Dock.Model.ReactiveProperty.UnitTests/FactoryTests.cs index 4fbdc4c03..7377fb2fc 100644 --- a/tests/Dock.Model.ReactiveProperty.UnitTests/FactoryTests.cs +++ b/tests/Dock.Model.ReactiveProperty.UnitTests/FactoryTests.cs @@ -93,6 +93,13 @@ public void CreateDocumentDock_Creates_DocumentDock() Assert.IsType(actual); } + [Fact] + public void DocumentDock_Default_EmptyContent_Is_Set() + { + var dock = new DocumentDock(); + Assert.Equal("No documents open", dock.EmptyContent); + } + [Fact] public void CreateDockWindow_Creates_DockWindow() { diff --git a/tests/Dock.Model.ReactiveUI.UnitTests/Controls/DockControlsTests.cs b/tests/Dock.Model.ReactiveUI.UnitTests/Controls/DockControlsTests.cs index ca38d2cf1..91a69d0e3 100644 --- a/tests/Dock.Model.ReactiveUI.UnitTests/Controls/DockControlsTests.cs +++ b/tests/Dock.Model.ReactiveUI.UnitTests/Controls/DockControlsTests.cs @@ -86,5 +86,6 @@ public void DocumentDock_Defaults() Assert.NotNull(dock.CreateDocument); Assert.False(dock.EnableWindowDrag); Assert.Equal(DocumentTabLayout.Top, dock.TabsLayout); + Assert.Equal("No documents open", dock.EmptyContent); } } diff --git a/tests/Dock.Model.UnitTests/DocumentDockFluentExtensionsTests.cs b/tests/Dock.Model.UnitTests/DocumentDockFluentExtensionsTests.cs new file mode 100644 index 000000000..0c86d7113 --- /dev/null +++ b/tests/Dock.Model.UnitTests/DocumentDockFluentExtensionsTests.cs @@ -0,0 +1,21 @@ +using Dock.Model; +using Dock.Model.Avalonia; +using Dock.Model.Controls; +using Xunit; + +namespace Dock.Model.UnitTests; + +public class DocumentDockFluentExtensionsTests +{ + [Fact] + public void WithEmptyContent_Sets_Value_And_Returns_Dock() + { + var factory = new Factory(); + var dock = factory.CreateDocumentDock(); + + var returned = dock.WithEmptyContent("Nothing to show"); + + Assert.Same(dock, returned); + Assert.Equal("Nothing to show", dock.EmptyContent); + } +} diff --git a/tests/Dock.Serializer.UnitTests/DocumentDockInpcTests.cs b/tests/Dock.Serializer.UnitTests/DocumentDockInpcTests.cs new file mode 100644 index 000000000..decce7d1a --- /dev/null +++ b/tests/Dock.Serializer.UnitTests/DocumentDockInpcTests.cs @@ -0,0 +1,15 @@ +using Dock.Model.Inpc.Controls; +using Xunit; + +namespace Dock.Serializer.UnitTests; + +public class DocumentDockInpcTests +{ + [Fact] + public void DocumentDock_Default_EmptyContent_Is_Set() + { + var dock = new DocumentDock(); + + Assert.Equal("No documents open", dock.EmptyContent); + } +}