From 204a6ed0d1cd57a3af566e7dde1005f34baf45ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Mon, 29 Jun 2026 14:44:39 +0200 Subject: [PATCH 01/31] Update Avalonia and diagnostics tooling --- Directory.Packages.props | 5 +++-- build/Avalonia.Diagnostics.props | 2 +- samples/DockDeferredContentSample/App.axaml.cs | 1 + .../DockDeferredContentSample.csproj | 1 + 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 9f272a54a..e0c88a8b0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,12 +1,13 @@ true - 12.0.0 + 12.0.5 + 12.0.4 - + diff --git a/build/Avalonia.Diagnostics.props b/build/Avalonia.Diagnostics.props index cb73056df..a4398c715 100644 --- a/build/Avalonia.Diagnostics.props +++ b/build/Avalonia.Diagnostics.props @@ -1,6 +1,6 @@  - + diff --git a/samples/DockDeferredContentSample/App.axaml.cs b/samples/DockDeferredContentSample/App.axaml.cs index 78761e49b..db9542fec 100644 --- a/samples/DockDeferredContentSample/App.axaml.cs +++ b/samples/DockDeferredContentSample/App.axaml.cs @@ -1,6 +1,7 @@ using System; using Avalonia; using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Diagnostics; using Avalonia.Markup.Xaml; using Dock.Controls.DeferredContentControl; using DockDeferredContentSample.ViewModels; diff --git a/samples/DockDeferredContentSample/DockDeferredContentSample.csproj b/samples/DockDeferredContentSample/DockDeferredContentSample.csproj index 38ab241c6..b50d17813 100644 --- a/samples/DockDeferredContentSample/DockDeferredContentSample.csproj +++ b/samples/DockDeferredContentSample/DockDeferredContentSample.csproj @@ -12,6 +12,7 @@ + From 0b369cdcebae2f8b43a2f8b78da3bec79443317f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Mon, 29 Jun 2026 14:44:51 +0200 Subject: [PATCH 02/31] Add flat proportional dock presentation --- .../Controls/DockControl.axaml.cs | 44 +- .../Controls/DockPresentationMode.cs | 20 + .../FlatProportionalDockControl.axaml.cs | 15 + .../Controls/FlatProportionalDockPanel.cs | 981 ++++++++++++++++++ .../Controls/FlatProportionalDockSplitter.cs | 275 +++++ .../FlatProportionalSplitterPreviewAdorner.cs | 68 ++ .../Internal/DockDataTemplateHelper.cs | 35 +- 7 files changed, 1430 insertions(+), 8 deletions(-) create mode 100644 src/Dock.Avalonia/Controls/DockPresentationMode.cs create mode 100644 src/Dock.Avalonia/Controls/FlatProportionalDockControl.axaml.cs create mode 100644 src/Dock.Avalonia/Controls/FlatProportionalDockPanel.cs create mode 100644 src/Dock.Avalonia/Controls/FlatProportionalDockSplitter.cs create mode 100644 src/Dock.Avalonia/Controls/FlatProportionalSplitterPreviewAdorner.cs diff --git a/src/Dock.Avalonia/Controls/DockControl.axaml.cs b/src/Dock.Avalonia/Controls/DockControl.axaml.cs index 65efea3af..a16867b74 100644 --- a/src/Dock.Avalonia/Controls/DockControl.axaml.cs +++ b/src/Dock.Avalonia/Controls/DockControl.axaml.cs @@ -49,6 +49,7 @@ public class DockControl : TemplatedControl, IDockControl, IDockSelectorService private DockCommandBarHost? _commandBarHost; private DockCommandBarManager? _commandBarManager; private DockSelectorOverlay? _selectorOverlay; + private readonly List _defaultDataTemplates = new(); private DockSelectorMode _selectorMode; private KeyGesture? _selectorGesture; private readonly Dictionary _activationOrder = new(); @@ -118,6 +119,12 @@ public class DockControl : TemplatedControl, IDockControl, IDockSelectorService public static readonly StyledProperty AutoCreateDataTemplatesProperty = AvaloniaProperty.Register(nameof(AutoCreateDataTemplates), true); + /// + /// Defines the property. + /// + public static readonly StyledProperty PresentationModeProperty = + AvaloniaProperty.Register(nameof(PresentationMode), DockPresentationMode.Nested); + /// public IDockManager DockManager => _dockManager; @@ -212,6 +219,15 @@ public bool AutoCreateDataTemplates set => SetValue(AutoCreateDataTemplatesProperty, value); } + /// + /// Gets or sets how proportional docks are presented in the Avalonia visual layer. + /// + public DockPresentationMode PresentationMode + { + get => GetValue(PresentationModeProperty); + set => SetValue(PresentationModeProperty, value); + } + /// public bool IsOpen => _selectorOverlay?.IsOpen == true; @@ -456,17 +472,34 @@ private void InitializeDefaultDataTemplates() return; } - // Check if auto-creation of DataTemplates is enabled + RemoveDefaultDataTemplates(); + if (!AutoCreateDataTemplates) { return; } - // Create and add default DataTemplates using helper class - foreach (var template in DockDataTemplateHelper.CreateDefaultDataTemplates()) + foreach (var template in DockDataTemplateHelper.CreateDefaultDataTemplates(PresentationMode)) { _contentControl.DataTemplates.Add(template); + _defaultDataTemplates.Add(template); + } + } + + private void RemoveDefaultDataTemplates() + { + if (_contentControl?.DataTemplates is null || _defaultDataTemplates.Count == 0) + { + _defaultDataTemplates.Clear(); + return; } + + foreach (var template in _defaultDataTemplates) + { + _contentControl.DataTemplates.Remove(template); + } + + _defaultDataTemplates.Clear(); } /// @@ -491,6 +524,11 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang { _dockManagerOptions.IsDockingEnabled = change.GetNewValue(); } + else if (change.Property == AutoCreateDataTemplatesProperty + || change.Property == PresentationModeProperty) + { + InitializeDefaultDataTemplates(); + } } private void Initialize(IDock? layout) diff --git a/src/Dock.Avalonia/Controls/DockPresentationMode.cs b/src/Dock.Avalonia/Controls/DockPresentationMode.cs new file mode 100644 index 000000000..e24558707 --- /dev/null +++ b/src/Dock.Avalonia/Controls/DockPresentationMode.cs @@ -0,0 +1,20 @@ +// 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.Avalonia.Controls; + +/// +/// Defines how proportional docks are presented in the Avalonia visual layer. +/// +public enum DockPresentationMode +{ + /// + /// Presents the dock model with the existing nested proportional dock controls. + /// + Nested = 0, + + /// + /// Presents proportional dock descendants through a flattened panel surface. + /// + Flat = 1 +} diff --git a/src/Dock.Avalonia/Controls/FlatProportionalDockControl.axaml.cs b/src/Dock.Avalonia/Controls/FlatProportionalDockControl.axaml.cs new file mode 100644 index 000000000..31d0263c5 --- /dev/null +++ b/src/Dock.Avalonia/Controls/FlatProportionalDockControl.axaml.cs @@ -0,0 +1,15 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using Avalonia.Controls.Metadata; +using Avalonia.Controls.Primitives; + +namespace Dock.Avalonia.Controls; + +/// +/// Presents an through a flattened visual panel. +/// +[TemplatePart("PART_Panel", typeof(FlatProportionalDockPanel), IsRequired = true)] +public class FlatProportionalDockControl : TemplatedControl +{ +} diff --git a/src/Dock.Avalonia/Controls/FlatProportionalDockPanel.cs b/src/Dock.Avalonia/Controls/FlatProportionalDockPanel.cs new file mode 100644 index 000000000..b182672e8 --- /dev/null +++ b/src/Dock.Avalonia/Controls/FlatProportionalDockPanel.cs @@ -0,0 +1,981 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.ComponentModel; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Presenters; +using Avalonia.Data; +using Avalonia.Media; +using Avalonia.VisualTree; +using Dock.Model.Controls; +using Dock.Model.Core; +using Dock.Settings; +using AvaloniaOrientation = Avalonia.Layout.Orientation; +using DockOrientation = Dock.Model.Core.Orientation; + +namespace Dock.Avalonia.Controls; + +/// +/// Presents a proportional dock tree as a flat set of direct child visuals. +/// +public class FlatProportionalDockPanel : Panel +{ + private readonly Dictionary _dockSurfaces = new(ReferenceEqualityComparer.Instance); + private readonly Dictionary _presenters = new(ReferenceEqualityComparer.Instance); + private readonly Dictionary _splitters = new(ReferenceEqualityComparer.Instance); + private readonly Dictionary _dockBounds = new(ReferenceEqualityComparer.Instance); + private readonly HashSet _propertySubscriptions = new(ReferenceEqualityComparer.Instance); + private readonly HashSet _collectionSubscriptions = new(ReferenceEqualityComparer.Instance); + private bool _isRebuilding; + private bool _isAssigningProportions; + + /// + /// Defines the property. + /// + public static readonly StyledProperty DockProperty = + AvaloniaProperty.Register(nameof(Dock)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty SplitterThicknessProperty = + AvaloniaProperty.Register(nameof(SplitterThickness), 4.0); + + /// + /// Defines the property. + /// + public static readonly StyledProperty MinimumProportionSizeProperty = + AvaloniaProperty.Register(nameof(MinimumProportionSize), 75.0); + + /// + /// Gets or sets the root proportional dock to present. + /// + public IProportionalDock? Dock + { + get => GetValue(DockProperty); + set => SetValue(DockProperty, value); + } + + /// + /// Gets or sets the default thickness assigned to flat splitters. + /// + public double SplitterThickness + { + get => GetValue(SplitterThicknessProperty); + set => SetValue(SplitterThicknessProperty, value); + } + + /// + /// Gets or sets the minimum size a splitter keeps for each adjacent dockable. + /// + public double MinimumProportionSize + { + get => GetValue(MinimumProportionSizeProperty); + set => SetValue(MinimumProportionSizeProperty, value); + } + + /// + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == DockProperty) + { + RebuildVisualTree(); + return; + } + + if (change.Property == SplitterThicknessProperty) + { + UpdateSplitterThickness(); + InvalidateMeasure(); + InvalidateArrange(); + return; + } + + if (change.Property == MinimumProportionSizeProperty) + { + InvalidateMeasure(); + InvalidateArrange(); + } + } + + /// + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + + UnsubscribeLayout(); + } + + /// + protected override Size MeasureOverride(Size availableSize) + { + if (Dock is not { } dock) + { + return default; + } + + MeasureDock(dock, availableSize); + return NormalizeDesiredSize(availableSize); + } + + /// + protected override Size ArrangeOverride(Size finalSize) + { + foreach (var child in Children) + { + child.Arrange(default); + } + + _dockBounds.Clear(); + + if (Dock is { } dock) + { + ArrangeDock(dock, new Rect(finalSize)); + } + + return finalSize; + } + + internal void ResizeSplitter(FlatProportionalDockSplitter splitterControl, double dragDelta) + { + if (splitterControl.OwnerDock is not { } ownerDock + || splitterControl.Splitter is not { } splitter + || ownerDock.VisibleDockables is not { } visibleDockables) + { + return; + } + + var splitterIndex = visibleDockables.IndexOf(splitter); + if (splitterIndex < 0) + { + return; + } + + var target = FindResizeSibling(visibleDockables, splitterIndex, -1); + var neighbor = FindResizeSibling(visibleDockables, splitterIndex, 1); + if (target is null || neighbor is null) + { + return; + } + + if (!_dockBounds.TryGetValue(ownerDock, out var ownerBounds)) + { + ownerBounds = new Rect(Bounds.Size); + } + + var availableSize = ownerDock.Orientation == DockOrientation.Vertical + ? ownerBounds.Height + : ownerBounds.Width; + + if (availableSize <= 0 || double.IsNaN(availableSize) || double.IsInfinity(availableSize)) + { + return; + } + + var targetProportion = ResolveValidProportion(target.Proportion, 0.5); + var neighborProportion = ResolveValidProportion(neighbor.Proportion, 0.5); + var deltaProportion = dragDelta / availableSize; + + if (targetProportion + deltaProportion < 0) + { + deltaProportion = -targetProportion; + } + + if (neighborProportion - deltaProportion < 0) + { + deltaProportion = neighborProportion; + } + + var nextTargetProportion = targetProportion + deltaProportion; + var nextNeighborProportion = neighborProportion - deltaProportion; + + ApplyResizeConstraints( + ownerDock.Orientation, + availableSize, + target, + neighbor, + ref nextTargetProportion, + ref nextNeighborProportion); + + ApplyResizeConstraints( + ownerDock.Orientation, + availableSize, + neighbor, + target, + ref nextNeighborProportion, + ref nextTargetProportion); + + SetDockableProportion(target, Math.Max(0, nextTargetProportion), updateCollapsedProportion: true); + SetDockableProportion(neighbor, Math.Max(0, nextNeighborProportion), updateCollapsedProportion: true); + + InvalidateMeasure(); + InvalidateArrange(); + } + + private void RebuildVisualTree() + { + if (_isRebuilding) + { + return; + } + + _isRebuilding = true; + try + { + UnsubscribeLayout(); + _dockSurfaces.Clear(); + _presenters.Clear(); + _splitters.Clear(); + _dockBounds.Clear(); + Children.Clear(); + + if (Dock is { } dock) + { + AddDockSurfaces(dock); + AddDockVisuals(dock); + SubscribeLayout(dock); + } + } + finally + { + _isRebuilding = false; + } + + InvalidateMeasure(); + InvalidateArrange(); + } + + private void AddDockSurfaces(IProportionalDock dock) + { + var surface = CreateDockSurface(dock); + _dockSurfaces[dock] = surface; + Children.Add(surface); + + if (dock.VisibleDockables is null) + { + return; + } + + foreach (var dockable in dock.VisibleDockables) + { + if (dockable is IProportionalDock childDock) + { + AddDockSurfaces(childDock); + } + } + } + + private DockableControl CreateDockSurface(IProportionalDock dock) + { + var surface = new DockableControl + { + TrackingMode = TrackingMode.Visible, + Background = Brushes.Transparent, + DataContext = dock, + [DockProperties.IsDropAreaProperty] = true + }; + + surface.Bind(DockProperties.IsDropEnabledProperty, new Binding(nameof(IDockable.CanDrop))); + surface.Bind(DockProperties.DockGroupProperty, new Binding(nameof(IDockable.DockGroup))); + + return surface; + } + + private void AddDockVisuals(IProportionalDock dock) + { + if (dock.VisibleDockables is null) + { + return; + } + + foreach (var dockable in dock.VisibleDockables) + { + switch (dockable) + { + case IProportionalDockSplitter splitter: + AddSplitter(dock, splitter); + break; + case IProportionalDock childDock: + AddDockVisuals(childDock); + break; + default: + AddPresenter(dockable); + break; + } + } + } + + private void AddSplitter(IProportionalDock ownerDock, IProportionalDockSplitter splitter) + { + var control = new FlatProportionalDockSplitter + { + DataContext = splitter, + OwnerDock = ownerDock, + Splitter = splitter, + Orientation = ToAvaloniaOrientation(ownerDock.Orientation), + Thickness = SplitterThickness + }; + + control.Bind(FlatProportionalDockSplitter.IsResizingEnabledProperty, new Binding(nameof(IProportionalDockSplitter.CanResize))); + control.Bind(FlatProportionalDockSplitter.PreviewResizeProperty, new Binding(nameof(IProportionalDockSplitter.ResizePreview))); + + _splitters[splitter] = control; + Children.Add(control); + } + + private void AddPresenter(IDockable dockable) + { + var presenter = new ContentPresenter + { + Content = dockable, + DataContext = dockable + }; + + _presenters[dockable] = presenter; + Children.Add(presenter); + } + + private void SubscribeLayout(IProportionalDock dock) + { + SubscribeDockable(dock); + + if (dock.VisibleDockables is INotifyCollectionChanged collectionChanged + && _collectionSubscriptions.Add(collectionChanged)) + { + collectionChanged.CollectionChanged += VisibleDockablesCollectionChanged; + } + + if (dock.VisibleDockables is null) + { + return; + } + + foreach (var dockable in dock.VisibleDockables) + { + SubscribeDockable(dockable); + + if (dockable is IProportionalDock childDock) + { + SubscribeLayout(childDock); + } + } + } + + private void SubscribeDockable(IDockable dockable) + { + if (dockable is INotifyPropertyChanged propertyChanged + && _propertySubscriptions.Add(propertyChanged)) + { + propertyChanged.PropertyChanged += DockablePropertyChanged; + } + } + + private void UnsubscribeLayout() + { + foreach (var propertyChanged in _propertySubscriptions) + { + propertyChanged.PropertyChanged -= DockablePropertyChanged; + } + + foreach (var collectionChanged in _collectionSubscriptions) + { + collectionChanged.CollectionChanged -= VisibleDockablesCollectionChanged; + } + + _propertySubscriptions.Clear(); + _collectionSubscriptions.Clear(); + } + + private void VisibleDockablesCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (_isRebuilding || _isAssigningProportions) + { + return; + } + + RebuildVisualTree(); + } + + private void DockablePropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (_isRebuilding || _isAssigningProportions) + { + return; + } + + if (string.IsNullOrEmpty(e.PropertyName) + || e.PropertyName == nameof(IDock.VisibleDockables) + || e.PropertyName == nameof(IProportionalDock.Orientation)) + { + RebuildVisualTree(); + return; + } + + InvalidateMeasure(); + InvalidateArrange(); + } + + private void UpdateSplitterThickness() + { + foreach (var splitter in _splitters.Values) + { + splitter.Thickness = SplitterThickness; + } + } + + private void MeasureDock(IProportionalDock dock, Size availableSize) + { + if (_dockSurfaces.TryGetValue(dock, out var surface)) + { + surface.Measure(availableSize); + } + + if (dock.VisibleDockables is not { } visibleDockables || visibleDockables.Count == 0) + { + return; + } + + var splitterThickness = GetTotalSplitterThickness(visibleDockables); + AssignProportions(dock, availableSize, splitterThickness); + var availableLength = Math.Max(0, GetLength(availableSize, dock.Orientation) - splitterThickness); + var sumOfFractions = 0.0; + + for (var i = 0; i < visibleDockables.Count; i++) + { + var dockable = visibleDockables[i]; + if (dockable is IProportionalDockSplitter splitter) + { + MeasureSplitter(dock, visibleDockables, i, splitter, availableSize); + continue; + } + + if (IsCollapsed(dockable)) + { + MeasureCollapsed(dockable); + continue; + } + + var length = CalculateDimensionWithConstraints( + dockable, + dock.Orientation, + availableLength, + ResolveValidProportion(dockable.Proportion, 0), + ref sumOfFractions); + + var childSize = CreateChildSize(availableSize, dock.Orientation, length); + if (dockable is IProportionalDock childDock) + { + MeasureDock(childDock, childSize); + } + else if (_presenters.TryGetValue(dockable, out var presenter)) + { + presenter.Measure(childSize); + } + } + } + + private void MeasureSplitter( + IProportionalDock dock, + IList visibleDockables, + int index, + IProportionalDockSplitter splitter, + Size availableSize) + { + if (!_splitters.TryGetValue(splitter, out var splitterControl)) + { + return; + } + + splitterControl.Orientation = ToAvaloniaOrientation(dock.Orientation); + + if (!ShouldUseSplitter(visibleDockables, index)) + { + splitterControl.Measure(default); + return; + } + + var size = dock.Orientation == DockOrientation.Vertical + ? new Size(availableSize.Width, splitterControl.Thickness) + : new Size(splitterControl.Thickness, availableSize.Height); + + splitterControl.Measure(size); + } + + private void MeasureCollapsed(IDockable dockable) + { + switch (dockable) + { + case IProportionalDock dock: + MeasureDock(dock, default); + break; + default: + if (_presenters.TryGetValue(dockable, out var presenter)) + { + presenter.Measure(default); + } + break; + } + } + + private void ArrangeDock(IProportionalDock dock, Rect bounds) + { + _dockBounds[dock] = bounds; + + if (_dockSurfaces.TryGetValue(dock, out var surface)) + { + surface.Arrange(bounds); + } + + if (dock.VisibleDockables is not { } visibleDockables || visibleDockables.Count == 0) + { + return; + } + + var splitterThickness = GetTotalSplitterThickness(visibleDockables); + AssignProportions(dock, bounds.Size, splitterThickness); + var availableLength = Math.Max(0, GetLength(bounds.Size, dock.Orientation) - splitterThickness); + var offset = 0.0; + var sumOfFractions = 0.0; + + for (var i = 0; i < visibleDockables.Count; i++) + { + var dockable = visibleDockables[i]; + + if (dockable is IProportionalDockSplitter splitter) + { + ArrangeSplitter(dock, visibleDockables, i, splitter, bounds, ref offset); + continue; + } + + if (IsCollapsed(dockable)) + { + continue; + } + + var length = CalculateDimensionWithConstraints( + dockable, + dock.Orientation, + availableLength, + ResolveValidProportion(dockable.Proportion, 0), + ref sumOfFractions); + + var childBounds = CreateChildRect(bounds, dock.Orientation, offset, length); + offset += length; + + if (dockable is IProportionalDock childDock) + { + ArrangeDock(childDock, childBounds); + } + else if (_presenters.TryGetValue(dockable, out var presenter)) + { + presenter.Arrange(childBounds); + } + } + } + + private void ArrangeSplitter( + IProportionalDock dock, + IList visibleDockables, + int index, + IProportionalDockSplitter splitter, + Rect bounds, + ref double offset) + { + if (!_splitters.TryGetValue(splitter, out var splitterControl)) + { + return; + } + + splitterControl.Orientation = ToAvaloniaOrientation(dock.Orientation); + + if (!ShouldUseSplitter(visibleDockables, index)) + { + return; + } + + var thickness = splitterControl.Thickness; + var splitterBounds = CreateChildRect(bounds, dock.Orientation, offset, thickness); + offset += thickness; + splitterControl.Arrange(splitterBounds); + } + + private double GetTotalSplitterThickness(IList visibleDockables) + { + var total = 0.0; + + for (var i = 0; i < visibleDockables.Count; i++) + { + if (visibleDockables[i] is IProportionalDockSplitter splitter + && ShouldUseSplitter(visibleDockables, i) + && _splitters.TryGetValue(splitter, out var splitterControl)) + { + total += splitterControl.Thickness; + } + } + + return total; + } + + private void AssignProportions(IProportionalDock dock, Size size, double splitterThickness) + { + if (dock.VisibleDockables is not { } visibleDockables) + { + return; + } + + var dockables = new List(); + foreach (var dockable in visibleDockables) + { + if (dockable is not IProportionalDockSplitter) + { + dockables.Add(dockable); + } + } + + if (dockables.Count == 0) + { + return; + } + + _isAssigningProportions = true; + try + { + var availableLength = Math.Max(1.0, GetLength(size, dock.Orientation) - splitterThickness); + var hasCollapsed = false; + var assignedTotal = 0.0; + var unassignedCount = 0; + var targets = new Dictionary(ReferenceEqualityComparer.Instance); + + foreach (var dockable in dockables) + { + if (IsCollapsed(dockable)) + { + hasCollapsed = true; + if (IsValidProportion(dockable.Proportion) && dockable.Proportion > 0) + { + dockable.CollapsedProportion = dockable.Proportion; + } + + targets[dockable] = 0.0; + continue; + } + + var target = IsValidProportion(dockable.CollapsedProportion) + ? dockable.CollapsedProportion + : dockable.Proportion; + + if (IsValidProportion(target)) + { + assignedTotal += target; + targets[dockable] = target; + } + else + { + unassignedCount++; + targets[dockable] = double.NaN; + } + } + + if (unassignedCount > 0) + { + var remaining = Math.Max(0, 1.0 - assignedTotal); + var proportion = remaining / unassignedCount; + foreach (var dockable in dockables) + { + if (!IsCollapsed(dockable) && !IsValidProportion(targets[dockable])) + { + targets[dockable] = proportion; + } + } + } + + NormalizeActiveProportions(dockables, targets); + + foreach (var dockable in dockables) + { + var target = ClampProportion(dockable, dock.Orientation, availableLength, targets[dockable]); + SetDockableProportion(dockable, target, !IsCollapsed(dockable) && !hasCollapsed); + } + } + finally + { + _isAssigningProportions = false; + } + } + + private static void NormalizeActiveProportions(IList dockables, IDictionary targets) + { + var total = 0.0; + foreach (var dockable in dockables) + { + if (!IsCollapsed(dockable)) + { + total += targets[dockable]; + } + } + + if (total <= 0 || Math.Abs(total - 1.0) < 1e-10) + { + return; + } + + var scale = 1.0 / total; + foreach (var dockable in dockables) + { + if (!IsCollapsed(dockable)) + { + targets[dockable] *= scale; + } + } + } + + private double ClampProportion(IDockable dockable, DockOrientation orientation, double availableLength, double proportion) + { + if (!IsValidProportion(proportion)) + { + return proportion; + } + + var min = GetMinimumLength(dockable, orientation); + var max = GetMaximumLength(dockable, orientation); + var minProportion = MinimumProportionSize > 0 ? MinimumProportionSize / availableLength : 0.0; + var maxProportion = double.PositiveInfinity; + + if (!double.IsNaN(min) && min > 0) + { + minProportion = Math.Max(minProportion, min / availableLength); + } + + if (!double.IsNaN(max) && !double.IsPositiveInfinity(max) && max > 0) + { + maxProportion = max / availableLength; + } + + if (maxProportion < minProportion) + { + maxProportion = minProportion; + } + + return Math.Clamp(proportion, minProportion, maxProportion); + } + + private void ApplyResizeConstraints( + DockOrientation orientation, + double availableSize, + IDockable primary, + IDockable secondary, + ref double primaryProportion, + ref double secondaryProportion) + { + var primaryConstraints = GetProportionConstraints(primary, orientation, availableSize); + var secondaryConstraints = GetProportionConstraints(secondary, orientation, availableSize); + + if (primaryProportion < primaryConstraints.Min) + { + var deficit = primaryConstraints.Min - primaryProportion; + primaryProportion = primaryConstraints.Min; + secondaryProportion = Math.Max(secondaryConstraints.Min, secondaryProportion - deficit); + } + else if (primaryProportion > primaryConstraints.Max) + { + var excess = primaryProportion - primaryConstraints.Max; + primaryProportion = primaryConstraints.Max; + secondaryProportion = Math.Min(secondaryConstraints.Max, secondaryProportion + excess); + } + } + + private (double Min, double Max) GetProportionConstraints(IDockable dockable, DockOrientation orientation, double availableSize) + { + var min = GetMinimumLength(dockable, orientation); + var max = GetMaximumLength(dockable, orientation); + var minProportion = MinimumProportionSize > 0 ? MinimumProportionSize / availableSize : 0.0; + var maxProportion = double.PositiveInfinity; + + if (!double.IsNaN(min) && min > 0) + { + minProportion = Math.Max(minProportion, min / availableSize); + } + + if (!double.IsNaN(max) && !double.IsPositiveInfinity(max) && max > 0) + { + maxProportion = max / availableSize; + } + + if (maxProportion < minProportion) + { + maxProportion = minProportion; + } + + return (minProportion, maxProportion); + } + + private static IDockable? FindResizeSibling(IList dockables, int splitterIndex, int direction) + { + for (var index = splitterIndex + direction; index >= 0 && index < dockables.Count; index += direction) + { + var dockable = dockables[index]; + if (dockable is IProportionalDockSplitter || IsCollapsed(dockable)) + { + continue; + } + + return dockable; + } + + return null; + } + + private static bool ShouldUseSplitter(IList dockables, int splitterIndex) + { + if (dockables[splitterIndex] is not IProportionalDockSplitter) + { + return false; + } + + var previous = FindAdjacentDockable(dockables, splitterIndex, -1); + var next = FindAdjacentDockable(dockables, splitterIndex, 1); + + return previous is not null + && next is not null + && !IsCollapsed(previous) + && !IsCollapsed(next); + } + + private static IDockable? FindAdjacentDockable(IList dockables, int splitterIndex, int direction) + { + var index = splitterIndex + direction; + if (index < 0 || index >= dockables.Count) + { + return null; + } + + var dockable = dockables[index]; + return dockable is IProportionalDockSplitter ? null : dockable; + } + + private static Size NormalizeDesiredSize(Size availableSize) + { + var width = double.IsInfinity(availableSize.Width) ? 0 : availableSize.Width; + var height = double.IsInfinity(availableSize.Height) ? 0 : availableSize.Height; + return new Size(width, height); + } + + private static Size CreateChildSize(Size availableSize, DockOrientation orientation, double length) + { + return orientation == DockOrientation.Vertical + ? new Size(availableSize.Width, length) + : new Size(length, availableSize.Height); + } + + private static Rect CreateChildRect(Rect bounds, DockOrientation orientation, double offset, double length) + { + return orientation == DockOrientation.Vertical + ? new Rect(bounds.X, bounds.Y + offset, bounds.Width, length) + : new Rect(bounds.X + offset, bounds.Y, length, bounds.Height); + } + + private static double CalculateDimensionWithConstraints( + IDockable dockable, + DockOrientation orientation, + double dimension, + double proportion, + ref double sumOfFractions) + { + var calculated = CalculateDimension(dimension, proportion, ref sumOfFractions); + var min = GetMinimumLength(dockable, orientation); + var max = GetMaximumLength(dockable, orientation); + + if (!double.IsNaN(min) && calculated < min) + { + calculated = min; + } + + if (!double.IsNaN(max) && !double.IsPositiveInfinity(max) && calculated > max) + { + calculated = max; + } + + return calculated; + } + + private static double CalculateDimension(double dimension, double proportion, ref double sumOfFractions) + { + var childDimension = dimension * proportion; + var flooredChildDimension = Math.Floor(childDimension); + sumOfFractions += childDimension - flooredChildDimension; + + var round = Math.Round(sumOfFractions, 1); + var clamp = Math.Clamp(Math.Floor(sumOfFractions), 1, double.MaxValue); + if (round - clamp >= 0) + { + sumOfFractions -= Math.Round(sumOfFractions); + return Math.Max(0, flooredChildDimension + 1); + } + + return Math.Max(0, flooredChildDimension); + } + + private static double GetLength(Size size, DockOrientation orientation) + { + return orientation == DockOrientation.Vertical ? size.Height : size.Width; + } + + private static double GetMinimumLength(IDockable dockable, DockOrientation orientation) + { + return orientation == DockOrientation.Vertical ? dockable.MinHeight : dockable.MinWidth; + } + + private static double GetMaximumLength(IDockable dockable, DockOrientation orientation) + { + return orientation == DockOrientation.Vertical ? dockable.MaxHeight : dockable.MaxWidth; + } + + private static bool IsCollapsed(IDockable dockable) + { + return dockable.IsCollapsable && dockable.IsEmpty; + } + + private static bool IsValidProportion(double value) + { + return !double.IsNaN(value) && !double.IsInfinity(value) && value >= 0; + } + + private static double ResolveValidProportion(double value, double fallback) + { + return IsValidProportion(value) ? value : fallback; + } + + private static void SetDockableProportion(IDockable dockable, double value, bool updateCollapsedProportion) + { + if (!AreClose(dockable.Proportion, value)) + { + dockable.Proportion = value; + } + + if (updateCollapsedProportion && !AreClose(dockable.CollapsedProportion, value)) + { + dockable.CollapsedProportion = value; + } + } + + private static bool AreClose(double left, double right) + { + if (double.IsNaN(left) && double.IsNaN(right)) + { + return true; + } + + return Math.Abs(left - right) < 1e-10; + } + + private static AvaloniaOrientation ToAvaloniaOrientation(DockOrientation orientation) + { + return orientation == DockOrientation.Vertical ? AvaloniaOrientation.Vertical : AvaloniaOrientation.Horizontal; + } +} diff --git a/src/Dock.Avalonia/Controls/FlatProportionalDockSplitter.cs b/src/Dock.Avalonia/Controls/FlatProportionalDockSplitter.cs new file mode 100644 index 000000000..b739a5a5a --- /dev/null +++ b/src/Dock.Avalonia/Controls/FlatProportionalDockSplitter.cs @@ -0,0 +1,275 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Metadata; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.VisualTree; +using Dock.Model.Controls; +using AvaloniaOrientation = Avalonia.Layout.Orientation; + +namespace Dock.Avalonia.Controls; + +/// +/// Splitter used by to resize flattened proportional dock regions. +/// +[PseudoClasses(":horizontal", ":vertical", ":preview")] +public class FlatProportionalDockSplitter : Thumb +{ + private Point _startPoint; + private bool _isMoving; + private FlatProportionalSplitterPreviewAdorner? _previewAdorner; + private AdornerLayer? _adornerLayer; + private double _startOffset; + + /// + /// Defines the property. + /// + public static readonly StyledProperty ThicknessProperty = + AvaloniaProperty.Register(nameof(Thickness), 4.0); + + /// + /// Defines the property. + /// + public static readonly StyledProperty IsResizingEnabledProperty = + AvaloniaProperty.Register(nameof(IsResizingEnabled), true); + + /// + /// Defines the property. + /// + public static readonly StyledProperty PreviewResizeProperty = + AvaloniaProperty.Register(nameof(PreviewResize)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty OrientationProperty = + AvaloniaProperty.Register(nameof(Orientation)); + + /// + /// Gets or sets the splitter thickness. + /// + public double Thickness + { + get => GetValue(ThicknessProperty); + set => SetValue(ThicknessProperty, value); + } + + /// + /// Gets or sets a value indicating whether the splitter can resize neighboring dockables. + /// + public bool IsResizingEnabled + { + get => GetValue(IsResizingEnabledProperty); + set => SetValue(IsResizingEnabledProperty, value); + } + + /// + /// Gets or sets whether resize changes are previewed until pointer release. + /// + public bool PreviewResize + { + get => GetValue(PreviewResizeProperty); + set => SetValue(PreviewResizeProperty, value); + } + + /// + /// Gets or sets the orientation of the owning proportional dock. + /// + public AvaloniaOrientation Orientation + { + get => GetValue(OrientationProperty); + set => SetValue(OrientationProperty, value); + } + + /// + /// Gets the model splitter represented by this control. + /// + public IProportionalDockSplitter? Splitter { get; internal set; } + + /// + /// Gets the proportional dock that owns . + /// + public IProportionalDock? OwnerDock { get; internal set; } + + /// + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == OrientationProperty + || change.Property == ThicknessProperty + || change.Property == IsResizingEnabledProperty) + { + UpdateVisualState(); + } + + if (change.Property == PreviewResizeProperty) + { + UpdatePreviewPseudoClass(); + } + } + + /// + protected override Size MeasureOverride(Size availableSize) + { + return Orientation == AvaloniaOrientation.Vertical + ? new Size(0, Thickness) + : new Size(Thickness, 0); + } + + /// + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + + UpdateVisualState(); + UpdatePreviewPseudoClass(); + } + + /// + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + + if (!IsResizingEnabled || GetPanel() is not { } panel) + { + return; + } + + _startPoint = e.GetPosition(panel); + _isMoving = true; + UpdatePreviewPseudoClass(); + + if (!PreviewResize) + { + return; + } + + var position = this.TranslatePoint(new Point(), panel); + if (position is null) + { + return; + } + + _adornerLayer = AdornerLayer.GetAdornerLayer(panel); + if (_adornerLayer is null) + { + return; + } + + _startOffset = Orientation == AvaloniaOrientation.Vertical ? position.Value.Y : position.Value.X; + _previewAdorner = new FlatProportionalSplitterPreviewAdorner + { + Orientation = Orientation, + Thickness = Thickness, + Offset = _startOffset, + [AdornerLayer.AdornedElementProperty] = panel + }; + + ((ISetLogicalParent)_previewAdorner).SetParent(panel); + _adornerLayer.Children.Add(_previewAdorner); + } + + /// + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + + if (!_isMoving || !IsResizingEnabled || GetPanel() is not { } panel) + { + return; + } + + var point = e.GetPosition(panel); + var delta = point - _startPoint; + var axisDelta = Orientation == AvaloniaOrientation.Vertical ? delta.Y : delta.X; + + if (PreviewResize) + { + if (_previewAdorner is not null) + { + _previewAdorner.Offset = _startOffset + axisDelta; + _previewAdorner.InvalidateVisual(); + } + return; + } + + _startPoint = point; + panel.ResizeSplitter(this, axisDelta); + } + + /// + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + + if (_isMoving && IsResizingEnabled && GetPanel() is { } panel && PreviewResize) + { + var point = e.GetPosition(panel); + var delta = point - _startPoint; + panel.ResizeSplitter(this, Orientation == AvaloniaOrientation.Vertical ? delta.Y : delta.X); + } + + RemovePreviewAdorner(); + _isMoving = false; + UpdatePreviewPseudoClass(); + } + + /// + protected override void OnPointerCaptureLost(PointerCaptureLostEventArgs e) + { + base.OnPointerCaptureLost(e); + + RemovePreviewAdorner(); + _isMoving = false; + UpdatePreviewPseudoClass(); + } + + private FlatProportionalDockPanel? GetPanel() + { + return this.FindAncestorOfType(); + } + + private void UpdateVisualState() + { + if (Orientation == AvaloniaOrientation.Vertical) + { + Height = Thickness; + Width = double.NaN; + Cursor = IsResizingEnabled ? new Cursor(StandardCursorType.SizeNorthSouth) : new Cursor(StandardCursorType.Arrow); + PseudoClasses.Set(":vertical", true); + PseudoClasses.Set(":horizontal", false); + return; + } + + Width = Thickness; + Height = double.NaN; + Cursor = IsResizingEnabled ? new Cursor(StandardCursorType.SizeWestEast) : new Cursor(StandardCursorType.Arrow); + PseudoClasses.Set(":horizontal", true); + PseudoClasses.Set(":vertical", false); + } + + private void UpdatePreviewPseudoClass() + { + PseudoClasses.Set(":preview", PreviewResize && _isMoving); + } + + private void RemovePreviewAdorner() + { + if (_previewAdorner is null || _adornerLayer is null) + { + _previewAdorner = null; + _adornerLayer = null; + return; + } + + _adornerLayer.Children.Remove(_previewAdorner); + ((ISetLogicalParent)_previewAdorner).SetParent(null); + _previewAdorner = null; + _adornerLayer = null; + } +} diff --git a/src/Dock.Avalonia/Controls/FlatProportionalSplitterPreviewAdorner.cs b/src/Dock.Avalonia/Controls/FlatProportionalSplitterPreviewAdorner.cs new file mode 100644 index 000000000..71623a77b --- /dev/null +++ b/src/Dock.Avalonia/Controls/FlatProportionalSplitterPreviewAdorner.cs @@ -0,0 +1,68 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using System; +using AvaloniaOrientation = Avalonia.Layout.Orientation; + +namespace Dock.Avalonia.Controls; + +internal sealed class FlatProportionalSplitterPreviewAdorner : Control +{ + public static readonly StyledProperty OrientationProperty = + AvaloniaProperty.Register(nameof(Orientation)); + + public static readonly StyledProperty ThicknessProperty = + AvaloniaProperty.Register(nameof(Thickness), 4.0); + + public static readonly StyledProperty OffsetProperty = + AvaloniaProperty.Register(nameof(Offset)); + + public static readonly StyledProperty PreviewBrushProperty = + AvaloniaProperty.Register( + nameof(PreviewBrush), + new SolidColorBrush(Color.FromArgb(96, 0, 120, 212))); + + public AvaloniaOrientation Orientation + { + get => GetValue(OrientationProperty); + set => SetValue(OrientationProperty, value); + } + + public double Thickness + { + get => GetValue(ThicknessProperty); + set => SetValue(ThicknessProperty, value); + } + + public double Offset + { + get => GetValue(OffsetProperty); + set => SetValue(OffsetProperty, value); + } + + public IBrush? PreviewBrush + { + get => GetValue(PreviewBrushProperty); + set => SetValue(PreviewBrushProperty, value); + } + + public override void Render(DrawingContext context) + { + base.Render(context); + + if (PreviewBrush is not { } brush) + { + return; + } + + var thickness = Math.Max(1.0, Thickness); + var rect = Orientation == AvaloniaOrientation.Vertical + ? new Rect(0, Offset, Bounds.Width, thickness) + : new Rect(Offset, 0, thickness, Bounds.Height); + + context.FillRectangle(brush, rect); + } +} diff --git a/src/Dock.Avalonia/Internal/DockDataTemplateHelper.cs b/src/Dock.Avalonia/Internal/DockDataTemplateHelper.cs index 8a003183f..e207ca150 100644 --- a/src/Dock.Avalonia/Internal/DockDataTemplateHelper.cs +++ b/src/Dock.Avalonia/Internal/DockDataTemplateHelper.cs @@ -22,14 +22,37 @@ internal static class DockDataTemplateHelper /// /// A collection of DataTemplates for dock controls. public static IEnumerable CreateDefaultDataTemplates() + { + return CreateDefaultDataTemplates(DockPresentationMode.Nested); + } + + /// + /// Creates a collection of default DataTemplates for all dock control types. + /// + /// The proportional dock presentation mode. + /// A collection of DataTemplates for dock controls. + public static IEnumerable CreateDefaultDataTemplates(DockPresentationMode presentationMode) { yield return CreateDataTemplate(() => new DocumentContentControl()); yield return CreateDataTemplate(() => new ToolContentControl()); - yield return CreateDataTemplate(() => new ProportionalStackPanelSplitter + + if (presentationMode == DockPresentationMode.Flat) { - [!ProportionalStackPanelSplitter.IsResizingEnabledProperty] = new Binding(nameof(IProportionalDockSplitter.CanResize)), - [!ProportionalStackPanelSplitter.PreviewResizeProperty] = new Binding(nameof(IProportionalDockSplitter.ResizePreview)) - }); + yield return CreateDataTemplate(() => new FlatProportionalDockSplitter + { + [!FlatProportionalDockSplitter.IsResizingEnabledProperty] = new Binding(nameof(IProportionalDockSplitter.CanResize)), + [!FlatProportionalDockSplitter.PreviewResizeProperty] = new Binding(nameof(IProportionalDockSplitter.ResizePreview)) + }); + } + else + { + yield return CreateDataTemplate(() => new ProportionalStackPanelSplitter + { + [!ProportionalStackPanelSplitter.IsResizingEnabledProperty] = new Binding(nameof(IProportionalDockSplitter.CanResize)), + [!ProportionalStackPanelSplitter.PreviewResizeProperty] = new Binding(nameof(IProportionalDockSplitter.ResizePreview)) + }); + } + yield return CreateDataTemplate(() => new GridSplitter { [!GridSplitter.ResizeDirectionProperty] = new Binding(nameof(IGridDockSplitter.ResizeDirection)) @@ -37,7 +60,9 @@ public static IEnumerable CreateDefaultDataTemplates() yield return CreateDataTemplate(() => new DocumentDockControl()); yield return CreateDataTemplate(() => new ToolDockControl()); yield return CreateDataTemplate(() => new SplitViewDockControl()); - yield return CreateDataTemplate(() => new ProportionalDockControl()); + yield return CreateDataTemplate(() => presentationMode == DockPresentationMode.Flat + ? new FlatProportionalDockControl() + : new ProportionalDockControl()); yield return CreateDataTemplate(() => new StackDockControl()); yield return CreateDataTemplate(() => new GridDockControl()); yield return CreateDataTemplate(() => new WrapDockControl()); From f72cb1953d8c3af9001f49bf6ab895b4c7503056 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Mon, 29 Jun 2026 14:45:06 +0200 Subject: [PATCH 03/31] Add Fluent flat dock theme --- .../Accents/Fluent.axaml | 2 ++ .../FlatProportionalDockControl.axaml | 34 +++++++++++++++++++ .../FlatProportionalDockSplitter.axaml | 30 ++++++++++++++++ .../DensityStyles/Compact.axaml | 1 + .../DockFluentFlatTheme.axaml | 11 ++++++ .../DockFluentFlatTheme.axaml.cs | 23 +++++++++++++ .../DockFluentTheme.axaml | 2 ++ .../DockFluentThemeManager.cs | 18 +++++++--- 8 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 src/Dock.Avalonia.Themes.Fluent/Controls/FlatProportionalDockControl.axaml create mode 100644 src/Dock.Avalonia.Themes.Fluent/Controls/FlatProportionalDockSplitter.axaml create mode 100644 src/Dock.Avalonia.Themes.Fluent/DockFluentFlatTheme.axaml create mode 100644 src/Dock.Avalonia.Themes.Fluent/DockFluentFlatTheme.axaml.cs diff --git a/src/Dock.Avalonia.Themes.Fluent/Accents/Fluent.axaml b/src/Dock.Avalonia.Themes.Fluent/Accents/Fluent.axaml index b10bfccbc..0cc27ac77 100644 --- a/src/Dock.Avalonia.Themes.Fluent/Accents/Fluent.axaml +++ b/src/Dock.Avalonia.Themes.Fluent/Accents/Fluent.axaml @@ -30,6 +30,8 @@ + 4 + 75 diff --git a/src/Dock.Avalonia.Themes.Fluent/Controls/FlatProportionalDockControl.axaml b/src/Dock.Avalonia.Themes.Fluent/Controls/FlatProportionalDockControl.axaml new file mode 100644 index 000000000..4fdf463dd --- /dev/null +++ b/src/Dock.Avalonia.Themes.Fluent/Controls/FlatProportionalDockControl.axaml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Dock.Avalonia.Themes.Fluent/Controls/FlatProportionalDockSplitter.axaml b/src/Dock.Avalonia.Themes.Fluent/Controls/FlatProportionalDockSplitter.axaml new file mode 100644 index 000000000..c52bb36e4 --- /dev/null +++ b/src/Dock.Avalonia.Themes.Fluent/Controls/FlatProportionalDockSplitter.axaml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Dock.Avalonia.Themes.Fluent/DensityStyles/Compact.axaml b/src/Dock.Avalonia.Themes.Fluent/DensityStyles/Compact.axaml index 4b4a55fb5..66b22ba71 100644 --- a/src/Dock.Avalonia.Themes.Fluent/DensityStyles/Compact.axaml +++ b/src/Dock.Avalonia.Themes.Fluent/DensityStyles/Compact.axaml @@ -28,6 +28,7 @@ 0,0,1,0 1 1 + 3 4 6,0 0,3,6,3 diff --git a/src/Dock.Avalonia.Themes.Fluent/DockFluentFlatTheme.axaml b/src/Dock.Avalonia.Themes.Fluent/DockFluentFlatTheme.axaml new file mode 100644 index 000000000..88dc9ee81 --- /dev/null +++ b/src/Dock.Avalonia.Themes.Fluent/DockFluentFlatTheme.axaml @@ -0,0 +1,11 @@ + + + + + diff --git a/src/Dock.Avalonia.Themes.Fluent/DockFluentFlatTheme.axaml.cs b/src/Dock.Avalonia.Themes.Fluent/DockFluentFlatTheme.axaml.cs new file mode 100644 index 000000000..3ac02f774 --- /dev/null +++ b/src/Dock.Avalonia.Themes.Fluent/DockFluentFlatTheme.axaml.cs @@ -0,0 +1,23 @@ +// 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.Markup.Xaml; +using Avalonia.Styling; + +namespace Dock.Avalonia.Themes.Fluent; + +/// +/// Fluent Dock theme variant that enables the flattened proportional dock presentation by default. +/// +public class DockFluentFlatTheme : Styles +{ + /// + /// Initializes a new instance of the class. + /// + /// The optional service provider. + public DockFluentFlatTheme(IServiceProvider? serviceProvider = null) + { + AvaloniaXamlLoader.Load(serviceProvider, this); + } +} diff --git a/src/Dock.Avalonia.Themes.Fluent/DockFluentTheme.axaml b/src/Dock.Avalonia.Themes.Fluent/DockFluentTheme.axaml index 7f737b71e..2a356c5bc 100644 --- a/src/Dock.Avalonia.Themes.Fluent/DockFluentTheme.axaml +++ b/src/Dock.Avalonia.Themes.Fluent/DockFluentTheme.axaml @@ -31,6 +31,8 @@ + + diff --git a/src/Dock.Avalonia.Themes.Fluent/DockFluentThemeManager.cs b/src/Dock.Avalonia.Themes.Fluent/DockFluentThemeManager.cs index 2a54feb56..3a8d4d3bd 100644 --- a/src/Dock.Avalonia.Themes.Fluent/DockFluentThemeManager.cs +++ b/src/Dock.Avalonia.Themes.Fluent/DockFluentThemeManager.cs @@ -2,8 +2,10 @@ // Licensed under the MIT license. See LICENSE file in the project root for details. using System; +using System.Collections.Generic; using Avalonia; using Avalonia.Controls; +using Avalonia.Styling; using Dock.Avalonia.Themes; namespace Dock.Avalonia.Themes.Fluent; @@ -44,12 +46,20 @@ public DockFluentThemeManager() /// protected override bool TryGetDefaultPresetOwner(Application application, out IResourceDictionary? owner) { - foreach (var style in application.Styles) + return TryGetDefaultPresetOwner(application.Styles, out owner); + } + + private static bool TryGetDefaultPresetOwner(IEnumerable styles, out IResourceDictionary? owner) + { + foreach (var style in styles) { - if (style is DockFluentTheme dockTheme && dockTheme.Resources is { } resources) + switch (style) { - owner = resources; - return true; + case DockFluentTheme { Resources: { } resources }: + owner = resources; + return true; + case Styles nestedStyles when TryGetDefaultPresetOwner(nestedStyles, out owner): + return true; } } From 866c944ded65379734fc993f569d702f0e228875 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Mon, 29 Jun 2026 14:45:22 +0200 Subject: [PATCH 04/31] Cover flat dock presentation --- .../DockControlDataTemplateTests.cs | 44 +++++++++- .../FlatProportionalDockPanelTests.cs | 88 +++++++++++++++++++ .../DocumentTabStripItemAndThemeTests.cs | 7 ++ .../Helpers/DockDataTemplateHelperTests.cs | 36 ++++++++ 4 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 tests/Dock.Avalonia.HeadlessTests/FlatProportionalDockPanelTests.cs diff --git a/tests/Dock.Avalonia.HeadlessTests/DockControlDataTemplateTests.cs b/tests/Dock.Avalonia.HeadlessTests/DockControlDataTemplateTests.cs index cb34d5517..6d8722341 100644 --- a/tests/Dock.Avalonia.HeadlessTests/DockControlDataTemplateTests.cs +++ b/tests/Dock.Avalonia.HeadlessTests/DockControlDataTemplateTests.cs @@ -32,6 +32,20 @@ public void DockControl_AutoCreateDataTemplates_CanSet_False() Assert.False(control.AutoCreateDataTemplates); } + [AvaloniaFact] + public void DockControl_PresentationMode_Default_Nested() + { + var control = new DockControl(); + Assert.Equal(DockPresentationMode.Nested, control.PresentationMode); + } + + [AvaloniaFact] + public void DockControl_PresentationMode_CanSet_Flat() + { + var control = new DockControl { PresentationMode = DockPresentationMode.Flat }; + Assert.Equal(DockPresentationMode.Flat, control.PresentationMode); + } + [AvaloniaFact] public void DockDataTemplateHelper_CreateDefaultDataTemplates_ReturnsCorrectCount() { @@ -101,6 +115,20 @@ public void DockDataTemplateHelper_ProportionalSplitterTemplate_CanCreateControl Assert.IsType(control); } + [AvaloniaFact] + public void DockDataTemplateHelper_FlatProportionalSplitterTemplate_CanCreateControl() + { + var templates = DockDataTemplateHelper.CreateDefaultDataTemplates(DockPresentationMode.Flat).ToList(); + var template = FindTemplateForType(templates); + Assert.NotNull(template); + + var splitter = new ProportionalDockSplitter(); + var control = template.Build(splitter); + + Assert.NotNull(control); + Assert.IsType(control); + } + [AvaloniaFact] public void DockDataTemplateHelper_GridSplitterTemplate_CanCreateControl() { @@ -165,6 +193,20 @@ public void DockDataTemplateHelper_ProportionalDockTemplate_CanCreateControl() Assert.IsType(control); } + [AvaloniaFact] + public void DockDataTemplateHelper_FlatProportionalDockTemplate_CanCreateControl() + { + var templates = DockDataTemplateHelper.CreateDefaultDataTemplates(DockPresentationMode.Flat).ToList(); + var template = FindTemplateForType(templates); + Assert.NotNull(template); + + var dock = new ProportionalDock(); + var control = template.Build(dock); + + Assert.NotNull(control); + Assert.IsType(control); + } + [AvaloniaFact] public void DockDataTemplateHelper_StackDockTemplate_CanCreateControl() { @@ -351,4 +393,4 @@ private static bool HasRequiresDataTemplateAttribute(Type interfaceType) { return interfaceType.GetCustomAttribute() != null; } -} \ No newline at end of file +} diff --git a/tests/Dock.Avalonia.HeadlessTests/FlatProportionalDockPanelTests.cs b/tests/Dock.Avalonia.HeadlessTests/FlatProportionalDockPanelTests.cs new file mode 100644 index 000000000..8d585c1c2 --- /dev/null +++ b/tests/Dock.Avalonia.HeadlessTests/FlatProportionalDockPanelTests.cs @@ -0,0 +1,88 @@ +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Controls.Presenters; +using Avalonia.Headless.XUnit; +using Dock.Avalonia.Controls; +using Dock.Model.Avalonia.Controls; +using Dock.Model.Controls; +using Dock.Model.Core; +using Xunit; + +namespace Dock.Avalonia.HeadlessTests; + +public class FlatProportionalDockPanelTests +{ + [AvaloniaFact] + public void FlatProportionalDockPanel_Flattens_Nested_ProportionalDocks() + { + var left = new DocumentDock { Id = "Left", Proportion = 0.25, CollapsedProportion = 0.25 }; + var top = new DocumentDock { Id = "Top", Proportion = 0.6, CollapsedProportion = 0.6 }; + var bottom = new ToolDock { Id = "Bottom", Proportion = 0.4, CollapsedProportion = 0.4 }; + var rootSplitter = new ProportionalDockSplitter { Id = "RootSplitter" }; + var innerSplitter = new ProportionalDockSplitter { Id = "InnerSplitter" }; + var inner = new ProportionalDock + { + Id = "Inner", + Orientation = Orientation.Vertical, + Proportion = 0.75, + CollapsedProportion = 0.75, + VisibleDockables = new List { top, innerSplitter, bottom } + }; + var root = new ProportionalDock + { + Id = "Root", + Orientation = Orientation.Horizontal, + VisibleDockables = new List { left, rootSplitter, inner } + }; + var panel = new FlatProportionalDockPanel { Dock = root }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var presenters = panel.Children.OfType().ToList(); + var splitters = panel.Children.OfType().ToList(); + var surfaces = panel.Children.OfType().ToList(); + + Assert.Equal(3, presenters.Count); + Assert.Equal(2, splitters.Count); + Assert.Equal(2, surfaces.Count); + Assert.DoesNotContain(panel.Children, child => child is ProportionalDockControl); + Assert.Contains(surfaces, surface => ReferenceEquals(surface.DataContext, root)); + Assert.Contains(surfaces, surface => ReferenceEquals(surface.DataContext, inner)); + + var leftPresenter = presenters.Single(presenter => ReferenceEquals(presenter.Content, left)); + var innerSurface = surfaces.Single(surface => ReferenceEquals(surface.DataContext, inner)); + + Assert.Equal(249, leftPresenter.Bounds.Width, 0); + Assert.Equal(253, innerSurface.Bounds.X, 0); + Assert.Equal(747, innerSurface.Bounds.Width, 0); + } + + [AvaloniaFact] + public void FlatProportionalDockPanel_ResizeSplitter_Updates_ModelProportions() + { + var left = new DocumentDock { Id = "Left", Proportion = 0.25, CollapsedProportion = 0.25 }; + var right = new DocumentDock { Id = "Right", Proportion = 0.75, CollapsedProportion = 0.75 }; + var splitter = new ProportionalDockSplitter { Id = "Splitter" }; + var root = new ProportionalDock + { + Id = "Root", + Orientation = Orientation.Horizontal, + VisibleDockables = new List { left, splitter, right } + }; + var panel = new FlatProportionalDockPanel { Dock = root }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var splitterControl = panel.Children + .OfType() + .Single(control => ReferenceEquals(control.Splitter, splitter)); + + panel.ResizeSplitter(splitterControl, 100); + + Assert.Equal(0.35, left.Proportion, 2); + Assert.Equal(0.65, right.Proportion, 2); + } +} diff --git a/tests/Dock.Avalonia.Themes.UnitTests/DocumentTabStripItemAndThemeTests.cs b/tests/Dock.Avalonia.Themes.UnitTests/DocumentTabStripItemAndThemeTests.cs index a42d8224c..511463009 100644 --- a/tests/Dock.Avalonia.Themes.UnitTests/DocumentTabStripItemAndThemeTests.cs +++ b/tests/Dock.Avalonia.Themes.UnitTests/DocumentTabStripItemAndThemeTests.cs @@ -30,6 +30,13 @@ public void DockFluentTheme_Can_Instantiate() Assert.NotNull(theme); } + [AvaloniaFact] + public void DockFluentFlatTheme_Can_Instantiate() + { + Styles theme = new DockFluentFlatTheme(); + Assert.NotNull(theme); + } + [AvaloniaFact] public void DockSimpleTheme_Can_Instantiate() { diff --git a/tests/Dock.Avalonia.UnitTests/Helpers/DockDataTemplateHelperTests.cs b/tests/Dock.Avalonia.UnitTests/Helpers/DockDataTemplateHelperTests.cs index 44d972344..982059e6b 100644 --- a/tests/Dock.Avalonia.UnitTests/Helpers/DockDataTemplateHelperTests.cs +++ b/tests/Dock.Avalonia.UnitTests/Helpers/DockDataTemplateHelperTests.cs @@ -107,6 +107,42 @@ public void CreateDefaultDataTemplates_ProportionalSplitter_ShouldCreateCorrectC Assert.IsType(control); } + /// + /// Tests that FlatProportionalDockSplitter is created for flat presentation mode. + /// + [Fact] + public void CreateDefaultDataTemplates_FlatProportionalSplitter_ShouldCreateCorrectControl() + { + // Act + var templates = DockDataTemplateHelper.CreateDefaultDataTemplates(DockPresentationMode.Flat).ToList(); + var proportionalTemplate = templates + .FirstOrDefault(t => GetFuncDataTemplateType(t) == typeof(IProportionalDockSplitter)); + + // Assert + Assert.NotNull(proportionalTemplate); + + var control = proportionalTemplate.Build(null); + Assert.IsType(control); + } + + /// + /// Tests that FlatProportionalDockControl is created for flat presentation mode. + /// + [Fact] + public void CreateDefaultDataTemplates_FlatProportionalDock_ShouldCreateCorrectControl() + { + // Act + var templates = DockDataTemplateHelper.CreateDefaultDataTemplates(DockPresentationMode.Flat).ToList(); + var proportionalTemplate = templates + .FirstOrDefault(t => GetFuncDataTemplateType(t) == typeof(IProportionalDock)); + + // Assert + Assert.NotNull(proportionalTemplate); + + var control = proportionalTemplate.Build(null); + Assert.IsType(control); + } + /// /// Tests that GridSplitter DataTemplate exists. /// Note: Control creation is skipped due to platform dependencies in unit tests. From edf48d5cfd26416643add0219eca6d5a71f95fc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Mon, 29 Jun 2026 14:45:39 +0200 Subject: [PATCH 05/31] Add ReactiveUI flat dock sample --- Dock.slnx | 1 + samples/DockReactiveUIFlatSample/App.axaml | 82 +++++++ samples/DockReactiveUIFlatSample/App.axaml.cs | 85 +++++++ .../DockReactiveUIFlatSample.csproj | 42 ++++ .../Models/DemoData.cs | 6 + .../Models/Documents/DemoDocument.cs | 6 + .../Models/Tools/Tool1.cs | 6 + .../Models/Tools/Tool2.cs | 6 + .../Models/Tools/Tool3.cs | 6 + .../Models/Tools/Tool4.cs | 6 + .../Models/Tools/Tool5.cs | 6 + .../Models/Tools/Tool6.cs | 6 + .../Models/Tools/Tool7.cs | 6 + .../Models/Tools/Tool8.cs | 6 + samples/DockReactiveUIFlatSample/Program.cs | 30 +++ .../DockReactiveUIFlatSample/ViewLocator.cs | 41 ++++ .../ViewModels/DockFactory.cs | 202 ++++++++++++++++ .../ViewModels/Docks/CustomDocumentDock.cs | 31 +++ .../ViewModels/Documents/DocumentViewModel.cs | 7 + .../ViewModels/MainWindowViewModel.cs | 222 ++++++++++++++++++ .../ViewModels/Tools/Tool1ViewModel.cs | 7 + .../ViewModels/Tools/Tool2ViewModel.cs | 7 + .../ViewModels/Tools/Tool3ViewModel.cs | 7 + .../ViewModels/Tools/Tool4ViewModel.cs | 7 + .../ViewModels/Tools/Tool5ViewModel.cs | 7 + .../ViewModels/Tools/Tool6ViewModel.cs | 7 + .../ViewModels/Tools/Tool7ViewModel.cs | 7 + .../ViewModels/Tools/Tool8ViewModel.cs | 7 + .../ViewModels/Views/DashboardViewModel.cs | 7 + .../ViewModels/Views/HomeViewModel.cs | 7 + .../Views/DockableOptionsView.axaml | 34 +++ .../Views/DockableOptionsView.axaml.cs | 35 +++ .../Views/Documents/DocumentView.axaml | 20 ++ .../Views/Documents/DocumentView.axaml.cs | 12 + .../Views/MainView.axaml | 86 +++++++ .../Views/MainView.axaml.cs | 52 ++++ .../Views/MainWindow.axaml | 55 +++++ .../Views/MainWindow.axaml.cs | 12 + .../Views/ProportionalStackPanelView.axaml | 73 ++++++ .../Views/ProportionalStackPanelView.axaml.cs | 12 + .../Views/Tools/Tool1View.axaml | 20 ++ .../Views/Tools/Tool1View.axaml.cs | 12 + .../Views/Tools/Tool2View.axaml | 20 ++ .../Views/Tools/Tool2View.axaml.cs | 12 + .../Views/Tools/Tool3View.axaml | 20 ++ .../Views/Tools/Tool3View.axaml.cs | 12 + .../Views/Tools/Tool4View.axaml | 20 ++ .../Views/Tools/Tool4View.axaml.cs | 12 + .../Views/Tools/Tool5View.axaml | 20 ++ .../Views/Tools/Tool5View.axaml.cs | 12 + .../Views/Tools/Tool6View.axaml | 20 ++ .../Views/Tools/Tool6View.axaml.cs | 12 + .../Views/Tools/Tool7View.axaml | 20 ++ .../Views/Tools/Tool7View.axaml.cs | 12 + .../Views/Tools/Tool8View.axaml | 20 ++ .../Views/Tools/Tool8View.axaml.cs | 12 + .../Views/Views/DashboardView.axaml | 25 ++ .../Views/Views/DashboardView.axaml.cs | 12 + .../Views/Views/HomeView.axaml | 14 ++ .../Views/Views/HomeView.axaml.cs | 12 + 60 files changed, 1583 insertions(+) create mode 100644 samples/DockReactiveUIFlatSample/App.axaml create mode 100644 samples/DockReactiveUIFlatSample/App.axaml.cs create mode 100644 samples/DockReactiveUIFlatSample/DockReactiveUIFlatSample.csproj create mode 100644 samples/DockReactiveUIFlatSample/Models/DemoData.cs create mode 100644 samples/DockReactiveUIFlatSample/Models/Documents/DemoDocument.cs create mode 100644 samples/DockReactiveUIFlatSample/Models/Tools/Tool1.cs create mode 100644 samples/DockReactiveUIFlatSample/Models/Tools/Tool2.cs create mode 100644 samples/DockReactiveUIFlatSample/Models/Tools/Tool3.cs create mode 100644 samples/DockReactiveUIFlatSample/Models/Tools/Tool4.cs create mode 100644 samples/DockReactiveUIFlatSample/Models/Tools/Tool5.cs create mode 100644 samples/DockReactiveUIFlatSample/Models/Tools/Tool6.cs create mode 100644 samples/DockReactiveUIFlatSample/Models/Tools/Tool7.cs create mode 100644 samples/DockReactiveUIFlatSample/Models/Tools/Tool8.cs create mode 100644 samples/DockReactiveUIFlatSample/Program.cs create mode 100644 samples/DockReactiveUIFlatSample/ViewLocator.cs create mode 100644 samples/DockReactiveUIFlatSample/ViewModels/DockFactory.cs create mode 100644 samples/DockReactiveUIFlatSample/ViewModels/Docks/CustomDocumentDock.cs create mode 100644 samples/DockReactiveUIFlatSample/ViewModels/Documents/DocumentViewModel.cs create mode 100644 samples/DockReactiveUIFlatSample/ViewModels/MainWindowViewModel.cs create mode 100644 samples/DockReactiveUIFlatSample/ViewModels/Tools/Tool1ViewModel.cs create mode 100644 samples/DockReactiveUIFlatSample/ViewModels/Tools/Tool2ViewModel.cs create mode 100644 samples/DockReactiveUIFlatSample/ViewModels/Tools/Tool3ViewModel.cs create mode 100644 samples/DockReactiveUIFlatSample/ViewModels/Tools/Tool4ViewModel.cs create mode 100644 samples/DockReactiveUIFlatSample/ViewModels/Tools/Tool5ViewModel.cs create mode 100644 samples/DockReactiveUIFlatSample/ViewModels/Tools/Tool6ViewModel.cs create mode 100644 samples/DockReactiveUIFlatSample/ViewModels/Tools/Tool7ViewModel.cs create mode 100644 samples/DockReactiveUIFlatSample/ViewModels/Tools/Tool8ViewModel.cs create mode 100644 samples/DockReactiveUIFlatSample/ViewModels/Views/DashboardViewModel.cs create mode 100644 samples/DockReactiveUIFlatSample/ViewModels/Views/HomeViewModel.cs create mode 100644 samples/DockReactiveUIFlatSample/Views/DockableOptionsView.axaml create mode 100644 samples/DockReactiveUIFlatSample/Views/DockableOptionsView.axaml.cs create mode 100644 samples/DockReactiveUIFlatSample/Views/Documents/DocumentView.axaml create mode 100644 samples/DockReactiveUIFlatSample/Views/Documents/DocumentView.axaml.cs create mode 100644 samples/DockReactiveUIFlatSample/Views/MainView.axaml create mode 100644 samples/DockReactiveUIFlatSample/Views/MainView.axaml.cs create mode 100644 samples/DockReactiveUIFlatSample/Views/MainWindow.axaml create mode 100644 samples/DockReactiveUIFlatSample/Views/MainWindow.axaml.cs create mode 100644 samples/DockReactiveUIFlatSample/Views/ProportionalStackPanelView.axaml create mode 100644 samples/DockReactiveUIFlatSample/Views/ProportionalStackPanelView.axaml.cs create mode 100644 samples/DockReactiveUIFlatSample/Views/Tools/Tool1View.axaml create mode 100644 samples/DockReactiveUIFlatSample/Views/Tools/Tool1View.axaml.cs create mode 100644 samples/DockReactiveUIFlatSample/Views/Tools/Tool2View.axaml create mode 100644 samples/DockReactiveUIFlatSample/Views/Tools/Tool2View.axaml.cs create mode 100644 samples/DockReactiveUIFlatSample/Views/Tools/Tool3View.axaml create mode 100644 samples/DockReactiveUIFlatSample/Views/Tools/Tool3View.axaml.cs create mode 100644 samples/DockReactiveUIFlatSample/Views/Tools/Tool4View.axaml create mode 100644 samples/DockReactiveUIFlatSample/Views/Tools/Tool4View.axaml.cs create mode 100644 samples/DockReactiveUIFlatSample/Views/Tools/Tool5View.axaml create mode 100644 samples/DockReactiveUIFlatSample/Views/Tools/Tool5View.axaml.cs create mode 100644 samples/DockReactiveUIFlatSample/Views/Tools/Tool6View.axaml create mode 100644 samples/DockReactiveUIFlatSample/Views/Tools/Tool6View.axaml.cs create mode 100644 samples/DockReactiveUIFlatSample/Views/Tools/Tool7View.axaml create mode 100644 samples/DockReactiveUIFlatSample/Views/Tools/Tool7View.axaml.cs create mode 100644 samples/DockReactiveUIFlatSample/Views/Tools/Tool8View.axaml create mode 100644 samples/DockReactiveUIFlatSample/Views/Tools/Tool8View.axaml.cs create mode 100644 samples/DockReactiveUIFlatSample/Views/Views/DashboardView.axaml create mode 100644 samples/DockReactiveUIFlatSample/Views/Views/DashboardView.axaml.cs create mode 100644 samples/DockReactiveUIFlatSample/Views/Views/HomeView.axaml create mode 100644 samples/DockReactiveUIFlatSample/Views/Views/HomeView.axaml.cs diff --git a/Dock.slnx b/Dock.slnx index ce7e9f5d3..bd0330607 100644 --- a/Dock.slnx +++ b/Dock.slnx @@ -61,6 +61,7 @@ + diff --git a/samples/DockReactiveUIFlatSample/App.axaml b/samples/DockReactiveUIFlatSample/App.axaml new file mode 100644 index 000000000..150b84978 --- /dev/null +++ b/samples/DockReactiveUIFlatSample/App.axaml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + #FFFAFAFA + #E2E2E2 + + + #FF212121 + #1F1F1F + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/DockReactiveUIFlatSample/App.axaml.cs b/samples/DockReactiveUIFlatSample/App.axaml.cs new file mode 100644 index 000000000..fc57eb069 --- /dev/null +++ b/samples/DockReactiveUIFlatSample/App.axaml.cs @@ -0,0 +1,85 @@ +using System.Diagnostics.CodeAnalysis; +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Diagnostics; +using Avalonia.Input; +using Avalonia.Markup.Xaml; +using Dock.Avalonia.Diagnostics.Controls; +using Dock.Avalonia.Diagnostics; +using Dock.Avalonia.Themes; +using Dock.Avalonia.Themes.Fluent; +using DockReactiveUIFlatSample.ViewModels; +using DockReactiveUIFlatSample.Views; + +namespace DockReactiveUIFlatSample; + +[RequiresUnreferencedCode("Requires unreferenced code for MainWindowViewModel.")] +[RequiresDynamicCode("Requires unreferenced code for MainWindowViewModel.")] +public partial class App : Application +{ + public static IDockThemeManager? ThemeManager; + + public override void Initialize() + { + ThemeManager = new DockFluentThemeManager(); +#if DOCK_USE_GENERATED_APP_INITIALIZE_COMPONENT + InitializeComponent(); +#else + AvaloniaXamlLoader.Load(this); +#endif + } + + public override void OnFrameworkInitializationCompleted() + { + // DockManager.s_enableSplitToWindow = true; + + var mainWindowViewModel = new MainWindowViewModel(); + + switch (ApplicationLifetime) + { + case IClassicDesktopStyleApplicationLifetime desktopLifetime: + { + var mainWindow = new MainWindow + { + DataContext = mainWindowViewModel + }; +#if DEBUG + mainWindow.AttachDockDebug( + () => mainWindowViewModel.Layout!, + new KeyGesture(Key.F11)); + mainWindow.AttachDockDebugOverlay(new KeyGesture(Key.F9)); +#endif + mainWindow.Closing += (_, _) => + { + mainWindowViewModel.CloseLayout(); + }; + + desktopLifetime.MainWindow = mainWindow; + + desktopLifetime.Exit += (_, _) => + { + mainWindowViewModel.CloseLayout(); + }; + + break; + } + case ISingleViewApplicationLifetime singleViewLifetime: + { + var mainView = new MainView() + { + DataContext = mainWindowViewModel + }; + + singleViewLifetime.MainView = mainView; + + break; + } + } + + base.OnFrameworkInitializationCompleted(); + +#if DEBUG + this.AttachDevTools(); +#endif + } +} diff --git a/samples/DockReactiveUIFlatSample/DockReactiveUIFlatSample.csproj b/samples/DockReactiveUIFlatSample/DockReactiveUIFlatSample.csproj new file mode 100644 index 000000000..1dc097293 --- /dev/null +++ b/samples/DockReactiveUIFlatSample/DockReactiveUIFlatSample.csproj @@ -0,0 +1,42 @@ + + + + net10.0 + WinExe + False + False + enable + InitializeComponent + true + true + $(BaseIntermediateOutputPath)\GeneratedFiles + + + + true + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + + + + + + diff --git a/samples/DockReactiveUIFlatSample/Models/DemoData.cs b/samples/DockReactiveUIFlatSample/Models/DemoData.cs new file mode 100644 index 000000000..862c32669 --- /dev/null +++ b/samples/DockReactiveUIFlatSample/Models/DemoData.cs @@ -0,0 +1,6 @@ + +namespace DockReactiveUIFlatSample.Models; + +public class DemoData +{ +} diff --git a/samples/DockReactiveUIFlatSample/Models/Documents/DemoDocument.cs b/samples/DockReactiveUIFlatSample/Models/Documents/DemoDocument.cs new file mode 100644 index 000000000..7881564da --- /dev/null +++ b/samples/DockReactiveUIFlatSample/Models/Documents/DemoDocument.cs @@ -0,0 +1,6 @@ + +namespace DockReactiveUIFlatSample.Models.Documents; + +public class DemoDocument +{ +} diff --git a/samples/DockReactiveUIFlatSample/Models/Tools/Tool1.cs b/samples/DockReactiveUIFlatSample/Models/Tools/Tool1.cs new file mode 100644 index 000000000..5e6bc2289 --- /dev/null +++ b/samples/DockReactiveUIFlatSample/Models/Tools/Tool1.cs @@ -0,0 +1,6 @@ + +namespace DockReactiveUIFlatSample.Models.Tools; + +public class Tool1 +{ +} diff --git a/samples/DockReactiveUIFlatSample/Models/Tools/Tool2.cs b/samples/DockReactiveUIFlatSample/Models/Tools/Tool2.cs new file mode 100644 index 000000000..1c6faaa08 --- /dev/null +++ b/samples/DockReactiveUIFlatSample/Models/Tools/Tool2.cs @@ -0,0 +1,6 @@ + +namespace DockReactiveUIFlatSample.Models.Tools; + +public class Tool2 +{ +} diff --git a/samples/DockReactiveUIFlatSample/Models/Tools/Tool3.cs b/samples/DockReactiveUIFlatSample/Models/Tools/Tool3.cs new file mode 100644 index 000000000..492c40b24 --- /dev/null +++ b/samples/DockReactiveUIFlatSample/Models/Tools/Tool3.cs @@ -0,0 +1,6 @@ + +namespace DockReactiveUIFlatSample.Models.Tools; + +public class Tool3 +{ +} diff --git a/samples/DockReactiveUIFlatSample/Models/Tools/Tool4.cs b/samples/DockReactiveUIFlatSample/Models/Tools/Tool4.cs new file mode 100644 index 000000000..335379e0f --- /dev/null +++ b/samples/DockReactiveUIFlatSample/Models/Tools/Tool4.cs @@ -0,0 +1,6 @@ + +namespace DockReactiveUIFlatSample.Models.Tools; + +public class Tool4 +{ +} diff --git a/samples/DockReactiveUIFlatSample/Models/Tools/Tool5.cs b/samples/DockReactiveUIFlatSample/Models/Tools/Tool5.cs new file mode 100644 index 000000000..2e2ce7ff6 --- /dev/null +++ b/samples/DockReactiveUIFlatSample/Models/Tools/Tool5.cs @@ -0,0 +1,6 @@ + +namespace DockReactiveUIFlatSample.Models.Tools; + +public class Tool5 +{ +} diff --git a/samples/DockReactiveUIFlatSample/Models/Tools/Tool6.cs b/samples/DockReactiveUIFlatSample/Models/Tools/Tool6.cs new file mode 100644 index 000000000..28ffe4214 --- /dev/null +++ b/samples/DockReactiveUIFlatSample/Models/Tools/Tool6.cs @@ -0,0 +1,6 @@ + +namespace DockReactiveUIFlatSample.Models.Tools; + +public class Tool6 +{ +} diff --git a/samples/DockReactiveUIFlatSample/Models/Tools/Tool7.cs b/samples/DockReactiveUIFlatSample/Models/Tools/Tool7.cs new file mode 100644 index 000000000..db9585a1d --- /dev/null +++ b/samples/DockReactiveUIFlatSample/Models/Tools/Tool7.cs @@ -0,0 +1,6 @@ + +namespace DockReactiveUIFlatSample.Models.Tools; + +public class Tool7 +{ +} diff --git a/samples/DockReactiveUIFlatSample/Models/Tools/Tool8.cs b/samples/DockReactiveUIFlatSample/Models/Tools/Tool8.cs new file mode 100644 index 000000000..ecfcf1c3a --- /dev/null +++ b/samples/DockReactiveUIFlatSample/Models/Tools/Tool8.cs @@ -0,0 +1,6 @@ + +namespace DockReactiveUIFlatSample.Models.Tools; + +public class Tool8 +{ +} diff --git a/samples/DockReactiveUIFlatSample/Program.cs b/samples/DockReactiveUIFlatSample/Program.cs new file mode 100644 index 000000000..3bddbd4cc --- /dev/null +++ b/samples/DockReactiveUIFlatSample/Program.cs @@ -0,0 +1,30 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. +using Avalonia; +using ReactiveUI.Avalonia; +using System; +using System.Diagnostics.CodeAnalysis; +using Dock.Settings; + +namespace DockReactiveUIFlatSample; + +[RequiresUnreferencedCode("Requires unreferenced code for App.")] +[RequiresDynamicCode("Requires unreferenced code for App.")] +internal class Program +{ + [STAThread] + private static void Main(string[] args) + { + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + } + + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .UseReactiveUI() + .ShowDockablePreviewOnDrag() + .SetDragPreviewOpacity(0.6) + // .UseManagedWindows() + .LogToTrace(); +} diff --git a/samples/DockReactiveUIFlatSample/ViewLocator.cs b/samples/DockReactiveUIFlatSample/ViewLocator.cs new file mode 100644 index 000000000..28866dd11 --- /dev/null +++ b/samples/DockReactiveUIFlatSample/ViewLocator.cs @@ -0,0 +1,41 @@ +using System; +using Avalonia.Controls; +using Avalonia.Controls.Templates; +//using CommunityToolkit.Mvvm.ComponentModel; +using Dock.Model.Core; +using ReactiveUI; +using StaticViewLocator; + +namespace DockReactiveUIFlatSample; + +[StaticViewLocator] +public partial class ViewLocator : IDataTemplate +{ + public Control? Build(object? data) + { + if (data is null) + { + return null; + } + + var type = data.GetType(); + + if (s_views.TryGetValue(type, out var func)) + { + return func.Invoke(); + } + + throw new Exception($"Unable to create view for type: {type}"); + } + + public bool Match(object? data) + { + if (data is null) + { + return false; + } + + var type = data.GetType(); + return data is IDockable || s_views.ContainsKey(type); + } +} diff --git a/samples/DockReactiveUIFlatSample/ViewModels/DockFactory.cs b/samples/DockReactiveUIFlatSample/ViewModels/DockFactory.cs new file mode 100644 index 000000000..d851982e1 --- /dev/null +++ b/samples/DockReactiveUIFlatSample/ViewModels/DockFactory.cs @@ -0,0 +1,202 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using DockReactiveUIFlatSample.Models.Documents; +using DockReactiveUIFlatSample.Models.Tools; +using DockReactiveUIFlatSample.ViewModels.Docks; +using DockReactiveUIFlatSample.ViewModels.Documents; +using DockReactiveUIFlatSample.ViewModels.Tools; +using DockReactiveUIFlatSample.ViewModels.Views; +using Dock.Avalonia.Controls; +using Dock.Settings; +using Dock.Model.Controls; +using Dock.Model.Core; +using Dock.Model.ReactiveUI; +using Dock.Model.ReactiveUI.Controls; + +namespace DockReactiveUIFlatSample.ViewModels; + +[RequiresUnreferencedCode("Requires unreferenced code for CustomDocumentDock.")] +[RequiresDynamicCode("Requires unreferenced code for CustomDocumentDock.")] +public class DockFactory : Factory +{ + private readonly object _context; + private IRootDock? _rootDock; + private IDocumentDock? _documentDock; + + public DockFactory(object context) + { + _context = context; + } + + public override IDocumentDock CreateDocumentDock() => new CustomDocumentDock(); + + public override IRootDock CreateLayout() + { + var document1 = new DocumentViewModel {Id = "Document1", Title = "Document1"}; + var document2 = new DocumentViewModel {Id = "Document2", Title = "Document2"}; + var document3 = new DocumentViewModel {Id = "Document3", Title = "Document3", CanClose = true}; + var tool1 = new Tool1ViewModel {Id = "Tool1", Title = "Tool1", KeepPinnedDockableVisible = true}; + var tool2 = new Tool2ViewModel {Id = "Tool2", Title = "Tool2", KeepPinnedDockableVisible = true}; + var tool3 = new Tool3ViewModel {Id = "Tool3", Title = "Tool3", CanDrag = false }; + var tool4 = new Tool4ViewModel {Id = "Tool4", Title = "Tool4", CanDrag = false }; + var tool5 = new Tool5ViewModel {Id = "Tool5", Title = "Tool5" }; + var tool6 = new Tool6ViewModel {Id = "Tool6", Title = "Tool6", CanClose = true, CanPin = true}; + var tool7 = new Tool7ViewModel {Id = "Tool7", Title = "Tool7", CanClose = false, CanPin = false}; + var tool8 = new Tool8ViewModel {Id = "Tool8", Title = "Tool8", CanClose = false, CanPin = true}; + + var leftDock = new ProportionalDock + { + Proportion = 0.25, + Orientation = Orientation.Vertical, + ActiveDockable = null, + VisibleDockables = CreateList + ( + new ToolDock + { + ActiveDockable = tool1, + VisibleDockables = CreateList(tool1, tool2), + Alignment = Alignment.Left, + // CanDrop = false + }, + new ProportionalDockSplitter { CanResize = true }, + new ToolDock + { + ActiveDockable = tool3, + VisibleDockables = CreateList(tool3, tool4), + Alignment = Alignment.Bottom, + CanDrag = false, + CanDrop = false + } + ), + // CanDrop = false + }; + + var rightDock = new ProportionalDock + { + Proportion = 0.25, + Orientation = Orientation.Vertical, + ActiveDockable = null, + VisibleDockables = CreateList + ( + new ToolDock + { + ActiveDockable = tool5, + VisibleDockables = CreateList(tool5, tool6), + Alignment = Alignment.Top, + GripMode = GripMode.Hidden + }, + new ProportionalDockSplitter(), + new ToolDock + { + ActiveDockable = tool7, + VisibleDockables = CreateList(tool7, tool8), + Alignment = Alignment.Right, + GripMode = GripMode.AutoHide + } + ), + // CanDrop = false + }; + + var documentDock = new CustomDocumentDock + { + IsCollapsable = false, + ActiveDockable = document1, + VisibleDockables = CreateList(document1, document2, document3), + CanCreateDocument = true, + // CanDrop = false, + EnableWindowDrag = true, + // CanCloseLastDockable = false, + }; + + var mainLayout = new ProportionalDock + { + Orientation = Orientation.Horizontal, + VisibleDockables = CreateList + ( + leftDock, + new ProportionalDockSplitter(), + documentDock, + new ProportionalDockSplitter(), + rightDock + ) + }; + + var dashboardView = new DashboardViewModel + { + Id = "Dashboard", + Title = "Dashboard" + }; + + var homeView = new HomeViewModel + { + Id = "Home", + Title = "Home", + ActiveDockable = mainLayout, + VisibleDockables = CreateList(mainLayout) + }; + + var rootDock = CreateRootDock(); + + rootDock.IsCollapsable = false; + rootDock.ActiveDockable = dashboardView; + rootDock.DefaultDockable = homeView; + rootDock.VisibleDockables = CreateList(dashboardView, homeView); + + rootDock.LeftPinnedDockables = CreateList(); + rootDock.RightPinnedDockables = CreateList(); + rootDock.TopPinnedDockables = CreateList(); + rootDock.BottomPinnedDockables = CreateList(); + + rootDock.PinnedDock = null; + + _documentDock = documentDock; + _rootDock = rootDock; + + return rootDock; + } + + public override IDockWindow? CreateWindowFrom(IDockable dockable) + { + var window = base.CreateWindowFrom(dockable); + + if (window != null) + { + window.Title = "Dock Avalonia Demo"; + } + return window; + } + + public override void InitLayout(IDockable layout) + { + ContextLocator = new Dictionary> + { + ["Document1"] = () => new DemoDocument(), + ["Document2"] = () => new DemoDocument(), + ["Document3"] = () => new DemoDocument(), + ["Tool1"] = () => new Tool1(), + ["Tool2"] = () => new Tool2(), + ["Tool3"] = () => new Tool3(), + ["Tool4"] = () => new Tool4(), + ["Tool5"] = () => new Tool5(), + ["Tool6"] = () => new Tool6(), + ["Tool7"] = () => new Tool7(), + ["Tool8"] = () => new Tool8(), + ["Dashboard"] = () => layout, + ["Home"] = () => _context + }; + + DockableLocator = new Dictionary>() + { + ["Root"] = () => _rootDock, + ["Documents"] = () => _documentDock + }; + + HostWindowLocator = new Dictionary> + { + [nameof(IDockWindow)] = () => DockSettings.UseManagedWindows ? new ManagedHostWindow() : new HostWindow() + }; + + base.InitLayout(layout); + } +} diff --git a/samples/DockReactiveUIFlatSample/ViewModels/Docks/CustomDocumentDock.cs b/samples/DockReactiveUIFlatSample/ViewModels/Docks/CustomDocumentDock.cs new file mode 100644 index 000000000..6499cb862 --- /dev/null +++ b/samples/DockReactiveUIFlatSample/ViewModels/Docks/CustomDocumentDock.cs @@ -0,0 +1,31 @@ +using System.Diagnostics.CodeAnalysis; +using DockReactiveUIFlatSample.ViewModels.Documents; +using Dock.Model.ReactiveUI.Controls; +using ReactiveUI; + +namespace DockReactiveUIFlatSample.ViewModels.Docks; + +[RequiresUnreferencedCode("Requires unreferenced code for ReactiveCommand.Create.")] +[RequiresDynamicCode("Requires unreferenced code for ReactiveCommand.Create.")] +public class CustomDocumentDock : DocumentDock +{ + public CustomDocumentDock() + { + CreateDocument = ReactiveCommand.Create(CreateNewDocument); + } + + private void CreateNewDocument() + { + if (!CanCreateDocument) + { + return; + } + + var index = VisibleDockables?.Count + 1; + var document = new DocumentViewModel {Id = $"Document{index}", Title = $"Document{index}"}; + + Factory?.AddDockable(this, document); + Factory?.SetActiveDockable(document); + Factory?.SetFocusedDockable(this, document); + } +} diff --git a/samples/DockReactiveUIFlatSample/ViewModels/Documents/DocumentViewModel.cs b/samples/DockReactiveUIFlatSample/ViewModels/Documents/DocumentViewModel.cs new file mode 100644 index 000000000..a2ca4260e --- /dev/null +++ b/samples/DockReactiveUIFlatSample/ViewModels/Documents/DocumentViewModel.cs @@ -0,0 +1,7 @@ +using Dock.Model.ReactiveUI.Controls; + +namespace DockReactiveUIFlatSample.ViewModels.Documents; + +public class DocumentViewModel : Document +{ +} diff --git a/samples/DockReactiveUIFlatSample/ViewModels/MainWindowViewModel.cs b/samples/DockReactiveUIFlatSample/ViewModels/MainWindowViewModel.cs new file mode 100644 index 000000000..ed86e863e --- /dev/null +++ b/samples/DockReactiveUIFlatSample/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,222 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Windows.Input; +using DockReactiveUIFlatSample.Models; +using Dock.Model.Controls; +using Dock.Model.Core; +using ReactiveUI; + +namespace DockReactiveUIFlatSample.ViewModels; + +[RequiresUnreferencedCode("Requires unreferenced code for RaiseAndSetIfChanged.")] +[RequiresDynamicCode("Requires unreferenced code for RaiseAndSetIfChanged.")] +public class MainWindowViewModel : ReactiveObject +{ + private readonly IFactory? _factory; + private IRootDock? _layout; + private string _globalStatus = "Global: (none)"; + + public IRootDock? Layout + { + get => _layout; + set => this.RaiseAndSetIfChanged(ref _layout, value); + } + + public string GlobalStatus + { + get => _globalStatus; + set => this.RaiseAndSetIfChanged(ref _globalStatus, value); + } + + public ICommand NewLayout { get; } + + public MainWindowViewModel() + { + _factory = new DockFactory(new DemoData()); + + DebugFactoryEvents(_factory); + + var layout = _factory?.CreateLayout(); + if (layout is not null) + { + _factory?.InitLayout(layout); + layout.Navigate.Execute("Home"); + } + Layout = layout; + GlobalStatus = layout is null + ? "Global: (none)" + : FormatGlobalStatus(_factory?.GlobalDockTrackingState ?? GlobalDockTrackingState.Empty); + + NewLayout = ReactiveCommand.Create(ResetLayout); + } + + private void DebugFactoryEvents(IFactory factory) + { + factory.ActiveDockableChanged += (_, args) => + { + Debug.WriteLine($"[ActiveDockableChanged] Title='{args.Dockable?.Title}', Root='{args.RootDock?.Id}', Window='{args.Window?.Id}'"); + }; + + factory.FocusedDockableChanged += (_, args) => + { + Debug.WriteLine($"[FocusedDockableChanged] Title='{args.Dockable?.Title}', Root='{args.RootDock?.Id}', Window='{args.Window?.Id}'"); + }; + + factory.GlobalDockTrackingChanged += (_, args) => + { + GlobalStatus = FormatGlobalStatus(args.Current); + Debug.WriteLine($"[GlobalDockTrackingChanged] Reason='{args.Reason}', Dockable='{args.Current.Dockable?.Title}', Root='{args.Current.RootDock?.Id}', Window='{args.Current.Window?.Id}'"); + }; + + factory.DockableAdded += (_, args) => + { + Debug.WriteLine($"[DockableAdded] Title='{args.Dockable?.Title}'"); + }; + + factory.DockableRemoved += (_, args) => + { + Debug.WriteLine($"[DockableRemoved] Title='{args.Dockable?.Title}'"); + }; + + factory.DockableClosed += (_, args) => + { + Debug.WriteLine($"[DockableClosed] Title='{args.Dockable?.Title}'"); + }; + + factory.DockableMoved += (_, args) => + { + Debug.WriteLine($"[DockableMoved] Title='{args.Dockable?.Title}'"); + }; + + factory.DockableDocked += (_, args) => + { + Debug.WriteLine($"[DockableDocked] Title='{args.Dockable?.Title}', Operation='{args.Operation}'"); + }; + + factory.DockableUndocked += (_, args) => + { + Debug.WriteLine($"[DockableUndocked] Title='{args.Dockable?.Title}', Operation='{args.Operation}'"); + }; + + factory.DockableSwapped += (_, args) => + { + Debug.WriteLine($"[DockableSwapped] Title='{args.Dockable?.Title}'"); + }; + + factory.DockablePinned += (_, args) => + { + Debug.WriteLine($"[DockablePinned] Title='{args.Dockable?.Title}'"); + }; + + factory.DockableUnpinned += (_, args) => + { + Debug.WriteLine($"[DockableUnpinned] Title='{args.Dockable?.Title}'"); + }; + + factory.WindowOpened += (_, args) => + { + Debug.WriteLine($"[WindowOpened] Title='{args.Window?.Title}'"); + }; + + factory.WindowClosed += (_, args) => + { + Debug.WriteLine($"[WindowClosed] Title='{args.Window?.Title}'"); + }; + + factory.WindowClosing += (_, args) => + { + // NOTE: Set to True to cancel window closing. +#if false + args.Cancel = true; +#endif + Debug.WriteLine($"[WindowClosing] Title='{args.Window?.Title}', Cancel={args.Cancel}"); + }; + + factory.WindowAdded += (_, args) => + { + Debug.WriteLine($"[WindowAdded] Title='{args.Window?.Title}'"); + }; + + factory.WindowRemoved += (_, args) => + { + Debug.WriteLine($"[WindowRemoved] Title='{args.Window?.Title}'"); + }; + + factory.WindowMoveDragBegin += (_, args) => + { + // NOTE: Set to True to cancel window dragging. +#if false + args.Cancel = true; +#endif + Debug.WriteLine($"[WindowMoveDragBegin] Title='{args.Window?.Title}', Cancel={args.Cancel}, X='{args.Window?.X}', Y='{args.Window?.Y}'"); + }; + + factory.WindowMoveDrag += (_, args) => + { + Debug.WriteLine($"[WindowMoveDrag] Title='{args.Window?.Title}', X='{args.Window?.X}', Y='{args.Window?.Y}"); + }; + + factory.WindowMoveDragEnd += (_, args) => + { + Debug.WriteLine($"[WindowMoveDragEnd] Title='{args.Window?.Title}', X='{args.Window?.X}', Y='{args.Window?.Y}"); + }; + + factory.WindowActivated += (_, args) => + { + Debug.WriteLine($"[WindowActivated] Title='{args.Window?.Title}'"); + }; + + factory.DockableActivated += (_, args) => + { + Debug.WriteLine($"[DockableActivated] Title='{args.Dockable?.Title}'"); + }; + + factory.WindowDeactivated += (_, args) => + { + Debug.WriteLine($"[WindowDeactivated] Title='{args.Window?.Title}'"); + }; + + factory.DockableDeactivated += (_, args) => + { + Debug.WriteLine($"[DockableDeactivated] Title='{args.Dockable?.Title}'"); + }; + } + + public void CloseLayout() + { + if (Layout is IDock dock) + { + if (dock.Close.CanExecute(null)) + { + dock.Close.Execute(null); + } + } + } + + public void ResetLayout() + { + if (Layout is not null) + { + if (Layout.Close.CanExecute(null)) + { + Layout.Close.Execute(null); + } + } + + var layout = _factory?.CreateLayout(); + if (layout is not null) + { + _factory?.InitLayout(layout); + Layout = layout; + } + } + + private static string FormatGlobalStatus(GlobalDockTrackingState state) + { + var dockableTitle = state.Dockable?.Title ?? "(none)"; + var rootId = state.RootDock?.Id ?? "(none)"; + var windowTitle = state.Window?.Title ?? "(main)"; + var host = state.HostWindow?.GetType().Name ?? "(main)"; + return $"Dockable: {dockableTitle} | Root: {rootId} | Window: {windowTitle} | Host: {host}"; + } +} diff --git a/samples/DockReactiveUIFlatSample/ViewModels/Tools/Tool1ViewModel.cs b/samples/DockReactiveUIFlatSample/ViewModels/Tools/Tool1ViewModel.cs new file mode 100644 index 000000000..e7342f000 --- /dev/null +++ b/samples/DockReactiveUIFlatSample/ViewModels/Tools/Tool1ViewModel.cs @@ -0,0 +1,7 @@ +using Dock.Model.ReactiveUI.Controls; + +namespace DockReactiveUIFlatSample.ViewModels.Tools; + +public class Tool1ViewModel : Tool +{ +} diff --git a/samples/DockReactiveUIFlatSample/ViewModels/Tools/Tool2ViewModel.cs b/samples/DockReactiveUIFlatSample/ViewModels/Tools/Tool2ViewModel.cs new file mode 100644 index 000000000..669503140 --- /dev/null +++ b/samples/DockReactiveUIFlatSample/ViewModels/Tools/Tool2ViewModel.cs @@ -0,0 +1,7 @@ +using Dock.Model.ReactiveUI.Controls; + +namespace DockReactiveUIFlatSample.ViewModels.Tools; + +public class Tool2ViewModel : Tool +{ +} diff --git a/samples/DockReactiveUIFlatSample/ViewModels/Tools/Tool3ViewModel.cs b/samples/DockReactiveUIFlatSample/ViewModels/Tools/Tool3ViewModel.cs new file mode 100644 index 000000000..0698f6349 --- /dev/null +++ b/samples/DockReactiveUIFlatSample/ViewModels/Tools/Tool3ViewModel.cs @@ -0,0 +1,7 @@ +using Dock.Model.ReactiveUI.Controls; + +namespace DockReactiveUIFlatSample.ViewModels.Tools; + +public class Tool3ViewModel : Tool +{ +} diff --git a/samples/DockReactiveUIFlatSample/ViewModels/Tools/Tool4ViewModel.cs b/samples/DockReactiveUIFlatSample/ViewModels/Tools/Tool4ViewModel.cs new file mode 100644 index 000000000..7bd1f3503 --- /dev/null +++ b/samples/DockReactiveUIFlatSample/ViewModels/Tools/Tool4ViewModel.cs @@ -0,0 +1,7 @@ +using Dock.Model.ReactiveUI.Controls; + +namespace DockReactiveUIFlatSample.ViewModels.Tools; + +public class Tool4ViewModel : Tool +{ +} diff --git a/samples/DockReactiveUIFlatSample/ViewModels/Tools/Tool5ViewModel.cs b/samples/DockReactiveUIFlatSample/ViewModels/Tools/Tool5ViewModel.cs new file mode 100644 index 000000000..d3b27b1e1 --- /dev/null +++ b/samples/DockReactiveUIFlatSample/ViewModels/Tools/Tool5ViewModel.cs @@ -0,0 +1,7 @@ +using Dock.Model.ReactiveUI.Controls; + +namespace DockReactiveUIFlatSample.ViewModels.Tools; + +public class Tool5ViewModel : Tool +{ +} diff --git a/samples/DockReactiveUIFlatSample/ViewModels/Tools/Tool6ViewModel.cs b/samples/DockReactiveUIFlatSample/ViewModels/Tools/Tool6ViewModel.cs new file mode 100644 index 000000000..b695242cd --- /dev/null +++ b/samples/DockReactiveUIFlatSample/ViewModels/Tools/Tool6ViewModel.cs @@ -0,0 +1,7 @@ +using Dock.Model.ReactiveUI.Controls; + +namespace DockReactiveUIFlatSample.ViewModels.Tools; + +public class Tool6ViewModel : Tool +{ +} diff --git a/samples/DockReactiveUIFlatSample/ViewModels/Tools/Tool7ViewModel.cs b/samples/DockReactiveUIFlatSample/ViewModels/Tools/Tool7ViewModel.cs new file mode 100644 index 000000000..48ef5b812 --- /dev/null +++ b/samples/DockReactiveUIFlatSample/ViewModels/Tools/Tool7ViewModel.cs @@ -0,0 +1,7 @@ +using Dock.Model.ReactiveUI.Controls; + +namespace DockReactiveUIFlatSample.ViewModels.Tools; + +public class Tool7ViewModel : Tool +{ +} diff --git a/samples/DockReactiveUIFlatSample/ViewModels/Tools/Tool8ViewModel.cs b/samples/DockReactiveUIFlatSample/ViewModels/Tools/Tool8ViewModel.cs new file mode 100644 index 000000000..01a872edf --- /dev/null +++ b/samples/DockReactiveUIFlatSample/ViewModels/Tools/Tool8ViewModel.cs @@ -0,0 +1,7 @@ +using Dock.Model.ReactiveUI.Controls; + +namespace DockReactiveUIFlatSample.ViewModels.Tools; + +public class Tool8ViewModel : Tool +{ +} diff --git a/samples/DockReactiveUIFlatSample/ViewModels/Views/DashboardViewModel.cs b/samples/DockReactiveUIFlatSample/ViewModels/Views/DashboardViewModel.cs new file mode 100644 index 000000000..580826e61 --- /dev/null +++ b/samples/DockReactiveUIFlatSample/ViewModels/Views/DashboardViewModel.cs @@ -0,0 +1,7 @@ +using Dock.Model.ReactiveUI.Core; + +namespace DockReactiveUIFlatSample.ViewModels.Views; + +public class DashboardViewModel : DockBase +{ +} diff --git a/samples/DockReactiveUIFlatSample/ViewModels/Views/HomeViewModel.cs b/samples/DockReactiveUIFlatSample/ViewModels/Views/HomeViewModel.cs new file mode 100644 index 000000000..a2aa4d897 --- /dev/null +++ b/samples/DockReactiveUIFlatSample/ViewModels/Views/HomeViewModel.cs @@ -0,0 +1,7 @@ +using Dock.Model.ReactiveUI.Controls; + +namespace DockReactiveUIFlatSample.ViewModels.Views; + +public class HomeViewModel : RootDock +{ +} diff --git a/samples/DockReactiveUIFlatSample/Views/DockableOptionsView.axaml b/samples/DockReactiveUIFlatSample/Views/DockableOptionsView.axaml new file mode 100644 index 000000000..5cbbf8f89 --- /dev/null +++ b/samples/DockReactiveUIFlatSample/Views/DockableOptionsView.axaml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/DockReactiveUIFlatSample/Views/DockableOptionsView.axaml.cs b/samples/DockReactiveUIFlatSample/Views/DockableOptionsView.axaml.cs new file mode 100644 index 000000000..aa96156a1 --- /dev/null +++ b/samples/DockReactiveUIFlatSample/Views/DockableOptionsView.axaml.cs @@ -0,0 +1,35 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Dock.Model.Core; + +namespace DockReactiveUIFlatSample.Views; + +public partial class DockableOptionsView : UserControl +{ + public static object?[] PinnedDockDisplayModeOptions { get; } = + { + null, + PinnedDockDisplayMode.Overlay, + PinnedDockDisplayMode.Inline + }; + + public static DockingWindowState[] DockingWindowStateOptions { get; } = + { + DockingWindowState.Docked, + DockingWindowState.Pinned, + DockingWindowState.Document, + DockingWindowState.Docked | DockingWindowState.Floating, + DockingWindowState.Pinned | DockingWindowState.Floating, + DockingWindowState.Document | DockingWindowState.Floating, + DockingWindowState.Docked | DockingWindowState.Hidden, + DockingWindowState.Pinned | DockingWindowState.Hidden, + DockingWindowState.Document | DockingWindowState.Hidden, + DockingWindowState.Docked | DockingWindowState.Floating | DockingWindowState.Hidden, + DockingWindowState.Document | DockingWindowState.Floating | DockingWindowState.Hidden + }; + + public DockableOptionsView() + { + InitializeComponent(); + } +} diff --git a/samples/DockReactiveUIFlatSample/Views/Documents/DocumentView.axaml b/samples/DockReactiveUIFlatSample/Views/Documents/DocumentView.axaml new file mode 100644 index 000000000..b54f2e0ca --- /dev/null +++ b/samples/DockReactiveUIFlatSample/Views/Documents/DocumentView.axaml @@ -0,0 +1,20 @@ + + + + + + + + + + + diff --git a/samples/DockReactiveUIFlatSample/Views/Documents/DocumentView.axaml.cs b/samples/DockReactiveUIFlatSample/Views/Documents/DocumentView.axaml.cs new file mode 100644 index 000000000..810729078 --- /dev/null +++ b/samples/DockReactiveUIFlatSample/Views/Documents/DocumentView.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace DockReactiveUIFlatSample.Views.Documents; + +public partial class DocumentView : UserControl +{ + public DocumentView() + { + InitializeComponent(); + } +} diff --git a/samples/DockReactiveUIFlatSample/Views/MainView.axaml b/samples/DockReactiveUIFlatSample/Views/MainView.axaml new file mode 100644 index 000000000..2d86c526f --- /dev/null +++ b/samples/DockReactiveUIFlatSample/Views/MainView.axaml @@ -0,0 +1,86 @@ + + + + + + M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Zm0-2V4a8 8 0 1 1 0 16Z + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/DockReactiveUIFlatSample/Views/MainView.axaml.cs b/samples/DockReactiveUIFlatSample/Views/MainView.axaml.cs new file mode 100644 index 000000000..9d22a4b71 --- /dev/null +++ b/samples/DockReactiveUIFlatSample/Views/MainView.axaml.cs @@ -0,0 +1,52 @@ +using System.Diagnostics.CodeAnalysis; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.Styling; + +namespace DockReactiveUIFlatSample.Views; + +[RequiresUnreferencedCode("Requires unreferenced code for ThemeManager.")] +[RequiresDynamicCode("Requires unreferenced code for ThemeManager.")] +public partial class MainView : UserControl +{ + public MainView() + { + InitializeComponent(); + InitializeThemes(); + } +private void InitializeThemes() + { + var themeManager = App.ThemeManager; + + if (themeManager is null) + { + return; + } + + var dark = Application.Current?.RequestedThemeVariant == ThemeVariant.Dark; + var theme = this.Find public class FlatProportionalDockPanel : Panel { + private static readonly CubicEaseOut s_layoutTransitionEasing = new(); + private static readonly TimeSpan s_transitionCompletionSlack = TimeSpan.FromMilliseconds(16); + private readonly Dictionary _dockSurfaces = new(ReferenceEqualityComparer.Instance); private readonly Dictionary _presenters = new(ReferenceEqualityComparer.Instance); private readonly Dictionary _splitters = new(ReferenceEqualityComparer.Instance); private readonly Dictionary _dockBounds = new(ReferenceEqualityComparer.Instance); + private readonly Dictionary _connectedStartBounds = new(ReferenceEqualityComparer.Instance); + private readonly Dictionary _pendingConnectedAnimations = new(ReferenceEqualityComparer.Instance); + private readonly Dictionary _connectedAnimationVersions = new(ReferenceEqualityComparer.Instance); + private readonly HashSet _activeConnectedControls = new(ReferenceEqualityComparer.Instance); + private readonly HashSet _enteringPresenters = new(ReferenceEqualityComparer.Instance); + private readonly HashSet _pendingInsertAnimations = new(ReferenceEqualityComparer.Instance); + private readonly Dictionary _insertAnimationVersions = new(ReferenceEqualityComparer.Instance); + private readonly HashSet _activeInsertPresenters = new(ReferenceEqualityComparer.Instance); + private readonly Dictionary _pendingLayoutAnimations = new(ReferenceEqualityComparer.Instance); + private readonly Dictionary _layoutAnimationVersions = new(ReferenceEqualityComparer.Instance); + private readonly HashSet _activeLayoutPresenters = new(ReferenceEqualityComparer.Instance); + private readonly Dictionary _removalPresenterBounds = new(ReferenceEqualityComparer.Instance); + private readonly Dictionary _removalPresentersByDockable = new(ReferenceEqualityComparer.Instance); + private readonly HashSet _pendingRemovalPresenters = new(ReferenceEqualityComparer.Instance); + private readonly HashSet _activeRemovalPresenters = new(ReferenceEqualityComparer.Instance); + private readonly HashSet _layoutActionDockables = new(ReferenceEqualityComparer.Instance); + private readonly HashSet _arrangedChildren = new(ReferenceEqualityComparer.Instance); private readonly HashSet _propertySubscriptions = new(ReferenceEqualityComparer.Instance); private readonly HashSet _collectionSubscriptions = new(ReferenceEqualityComparer.Instance); + private Dictionary? _reusableDockSurfaces; + private Dictionary? _reusablePresenters; + private Dictionary? _reusableSplitters; private bool _isRebuilding; private bool _isAssigningProportions; + private bool _hasCompletedArrange; + private bool _hasPendingConnectedAnimationPost; + private bool _hasPendingRebuild; + private bool _suppressLayoutTransitions; + private bool _hasScopedLayoutAction; /// /// Defines the property. @@ -51,6 +84,20 @@ public class FlatProportionalDockPanel : Panel public static readonly StyledProperty MinimumProportionSizeProperty = AvaloniaProperty.Register(nameof(MinimumProportionSize), 75.0); + /// + /// Defines the property. + /// + public static readonly StyledProperty UseLayoutTransitionsProperty = + AvaloniaProperty.Register(nameof(UseLayoutTransitions), true); + + /// + /// Defines the property. + /// + public static readonly StyledProperty LayoutTransitionDurationProperty = + AvaloniaProperty.Register( + nameof(LayoutTransitionDuration), + TimeSpan.FromMilliseconds(240)); + /// /// Gets or sets the root proportional dock to present. /// @@ -78,6 +125,24 @@ public double MinimumProportionSize set => SetValue(MinimumProportionSizeProperty, value); } + /// + /// Gets or sets whether flat child bounds changes should animate on the compositor. + /// + public bool UseLayoutTransitions + { + get => GetValue(UseLayoutTransitionsProperty); + set => SetValue(UseLayoutTransitionsProperty, value); + } + + /// + /// Gets or sets the duration used for flat child bounds animations. + /// + public TimeSpan LayoutTransitionDuration + { + get => GetValue(LayoutTransitionDurationProperty); + set => SetValue(LayoutTransitionDurationProperty, value); + } + /// protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { @@ -85,7 +150,7 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang if (change.Property == DockProperty) { - RebuildVisualTree(); + RequestRebuildVisualTree(); return; } @@ -101,6 +166,23 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang { InvalidateMeasure(); InvalidateArrange(); + return; + } + + if (change.Property == UseLayoutTransitionsProperty) + { + if (change.NewValue is false) + { + CancelLayoutTransitions(removeRemovalPresenters: true); + } + + return; + } + + if (change.Property == LayoutTransitionDurationProperty + && LayoutTransitionDuration <= TimeSpan.Zero) + { + CancelLayoutTransitions(removeRemovalPresenters: true); } } @@ -110,28 +192,32 @@ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e base.OnDetachedFromVisualTree(e); UnsubscribeLayout(); + CancelLayoutTransitions(removeRemovalPresenters: true); + _hasPendingRebuild = false; } /// protected override Size MeasureOverride(Size availableSize) { + ExecutePendingRebuild(invalidateLayout: false); + if (Dock is not { } dock) { return default; } MeasureDock(dock, availableSize); + MeasureRemovalPresenters(); return NormalizeDesiredSize(availableSize); } /// protected override Size ArrangeOverride(Size finalSize) { - foreach (var child in Children) - { - child.Arrange(default); - } + ExecutePendingRebuild(invalidateLayout: false); + TrackStructuralLayoutActionDockables(); + _arrangedChildren.Clear(); _dockBounds.Clear(); if (Dock is { } dock) @@ -139,6 +225,22 @@ protected override Size ArrangeOverride(Size finalSize) ArrangeDock(dock, new Rect(finalSize)); } + ArrangeRemovalPresenters(); + + foreach (var child in Children) + { + if (!_arrangedChildren.Contains(child)) + { + child.Arrange(default); + } + } + + _arrangedChildren.Clear(); + _hasCompletedArrange = true; + _suppressLayoutTransitions = false; + RequestConnectedAnimations(); + ClearScopedLayoutAction(); + return finalSize; } @@ -214,11 +316,36 @@ internal void ResizeSplitter(FlatProportionalDockSplitter splitterControl, doubl SetDockableProportion(target, Math.Max(0, nextTargetProportion), updateCollapsedProportion: true); SetDockableProportion(neighbor, Math.Max(0, nextNeighborProportion), updateCollapsedProportion: true); + _suppressLayoutTransitions = true; + InvalidateMeasure(); + InvalidateArrange(); + } + + private void RequestRebuildVisualTree() + { + if (_isRebuilding) + { + return; + } + + _hasPendingRebuild = true; InvalidateMeasure(); InvalidateArrange(); + Dispatcher.UIThread.Post(() => ExecutePendingRebuild(invalidateLayout: true), DispatcherPriority.Render); + } + + private void ExecutePendingRebuild(bool invalidateLayout) + { + if (!_hasPendingRebuild || _isRebuilding) + { + return; + } + + _hasPendingRebuild = false; + RebuildVisualTree(invalidateLayout); } - private void RebuildVisualTree() + private void RebuildVisualTree(bool invalidateLayout = true) { if (_isRebuilding) { @@ -228,34 +355,52 @@ private void RebuildVisualTree() _isRebuilding = true; try { + CaptureConnectedStartBounds(); + TrackStructuralLayoutActionDockables(); UnsubscribeLayout(); + _reusableDockSurfaces = new Dictionary(_dockSurfaces, ReferenceEqualityComparer.Instance); + _reusablePresenters = new Dictionary(_presenters, ReferenceEqualityComparer.Instance); + _reusableSplitters = new Dictionary(_splitters, ReferenceEqualityComparer.Instance); _dockSurfaces.Clear(); _presenters.Clear(); _splitters.Clear(); _dockBounds.Clear(); - Children.Clear(); if (Dock is { } dock) { AddDockSurfaces(dock); AddDockVisuals(dock); + CreateRemovalPresenters(); + RemoveUnusedVisuals(); + AddRemovalPresenters(); SubscribeLayout(dock); } + else + { + RemoveUnusedVisuals(); + RemoveRemovalPresenters(); + } } finally { + _reusableDockSurfaces = null; + _reusablePresenters = null; + _reusableSplitters = null; _isRebuilding = false; } - InvalidateMeasure(); - InvalidateArrange(); + if (invalidateLayout) + { + InvalidateMeasure(); + InvalidateArrange(); + } } private void AddDockSurfaces(IProportionalDock dock) { var surface = CreateDockSurface(dock); _dockSurfaces[dock] = surface; - Children.Add(surface); + EnsureSurfaceChild(surface); if (dock.VisibleDockables is null) { @@ -273,14 +418,23 @@ private void AddDockSurfaces(IProportionalDock dock) private DockableControl CreateDockSurface(IProportionalDock dock) { + if (_reusableDockSurfaces?.Remove(dock, out var reusableSurface) == true) + { + reusableSurface.DataContext = dock; + return reusableSurface; + } + var surface = new DockableControl { TrackingMode = TrackingMode.Visible, Background = Brushes.Transparent, DataContext = dock, - [DockProperties.IsDropAreaProperty] = true + [DockProperties.IsDropAreaProperty] = true, + [DockProperties.IsDockTargetProperty] = true }; + DockProperties.SetDockAdornerHost(surface, surface); + surface.Bind(DockProperties.IsDropEnabledProperty, new Binding(nameof(IDockable.CanDrop))); surface.Bind(DockProperties.DockGroupProperty, new Binding(nameof(IDockable.DockGroup))); @@ -313,32 +467,235 @@ private void AddDockVisuals(IProportionalDock dock) private void AddSplitter(IProportionalDock ownerDock, IProportionalDockSplitter splitter) { - var control = new FlatProportionalDockSplitter + FlatProportionalDockSplitter? reusableSplitter = null; + var reused = _reusableSplitters is not null && _reusableSplitters.Remove(splitter, out reusableSplitter); + var control = reused + ? reusableSplitter! + : new FlatProportionalDockSplitter + { + DataContext = splitter + }; + + control.OwnerDock = ownerDock; + control.Splitter = splitter; + control.Orientation = ToAvaloniaOrientation(ownerDock.Orientation); + control.Thickness = SplitterThickness; + + if (control.DataContext is not IProportionalDockSplitter) { - DataContext = splitter, - OwnerDock = ownerDock, - Splitter = splitter, - Orientation = ToAvaloniaOrientation(ownerDock.Orientation), - Thickness = SplitterThickness - }; + control.DataContext = splitter; + } - control.Bind(FlatProportionalDockSplitter.IsResizingEnabledProperty, new Binding(nameof(IProportionalDockSplitter.CanResize))); - control.Bind(FlatProportionalDockSplitter.PreviewResizeProperty, new Binding(nameof(IProportionalDockSplitter.ResizePreview))); + if (!reused) + { + control.Bind(FlatProportionalDockSplitter.IsResizingEnabledProperty, new Binding(nameof(IProportionalDockSplitter.CanResize))); + control.Bind(FlatProportionalDockSplitter.PreviewResizeProperty, new Binding(nameof(IProportionalDockSplitter.ResizePreview))); + } _splitters[splitter] = control; - Children.Add(control); + EnsureDockVisualChild(control); } private void AddPresenter(IDockable dockable) { - var presenter = new ContentPresenter + ContentPresenter? reusablePresenter = null; + var reused = _reusablePresenters is not null && _reusablePresenters.Remove(dockable, out reusablePresenter); + if (!reused && _removalPresentersByDockable.Remove(dockable, out reusablePresenter)) { - Content = dockable, - DataContext = dockable - }; + reused = true; + _removalPresenterBounds.Remove(reusablePresenter); + _pendingRemovalPresenters.Remove(reusablePresenter); + _activeRemovalPresenters.Remove(reusablePresenter); + ResetPresenterComposition(reusablePresenter); + } + + var presenter = reused + ? reusablePresenter! + : new ContentPresenter(); + + presenter.Content = dockable; + presenter.DataContext = dockable; + + if (reused) + { + if (_activeInsertPresenters.Contains(presenter)) + { + _enteringPresenters.Remove(presenter); + presenter.Opacity = 1.0; + presenter.IsHitTestVisible = false; + } + else if (_pendingInsertAnimations.Contains(presenter)) + { + presenter.Opacity = 0.0; + presenter.IsHitTestVisible = false; + _enteringPresenters.Add(presenter); + } + else if (_activeLayoutPresenters.Contains(presenter) || _pendingLayoutAnimations.ContainsKey(presenter)) + { + _enteringPresenters.Remove(presenter); + presenter.Opacity = 1.0; + presenter.IsHitTestVisible = false; + } + else + { + _enteringPresenters.Remove(presenter); + presenter.Opacity = 1.0; + presenter.IsHitTestVisible = true; + } + } + else if (_hasCompletedArrange && UseLayoutTransitions) + { + presenter.Opacity = 0.0; + presenter.IsHitTestVisible = false; + _enteringPresenters.Add(presenter); + } + else + { + presenter.Opacity = 1.0; + presenter.IsHitTestVisible = true; + } _presenters[dockable] = presenter; - Children.Add(presenter); + EnsureDockVisualChild(presenter); + } + + private void CreateRemovalPresenters() + { + if (!UseLayoutTransitions + || !_hasCompletedArrange + || _reusablePresenters is null + || _reusablePresenters.Count == 0) + { + return; + } + + var removedPresenters = new List>(_reusablePresenters); + foreach (var kvp in removedPresenters) + { + var dockable = kvp.Key; + var presenter = kvp.Value; + if (!_connectedStartBounds.TryGetValue(presenter, out var bounds) || !HasVisibleSize(bounds)) + { + continue; + } + + _reusablePresenters.Remove(dockable); + _pendingInsertAnimations.Remove(presenter); + _activeInsertPresenters.Remove(presenter); + _insertAnimationVersions.Remove(presenter); + _pendingLayoutAnimations.Remove(presenter); + _layoutAnimationVersions.Remove(presenter); + _activeLayoutPresenters.Remove(presenter); + + _removalPresenterBounds[presenter] = bounds; + _removalPresentersByDockable[dockable] = presenter; + _pendingRemovalPresenters.Add(presenter); + presenter.Opacity = 1.0; + presenter.IsHitTestVisible = false; + } + } + + private void AddRemovalPresenters() + { + foreach (var presenter in _removalPresenterBounds.Keys) + { + if (!Children.Contains(presenter)) + { + Children.Add(presenter); + } + } + } + + private void RemoveUnusedVisuals() + { + if (_reusableDockSurfaces is not null) + { + foreach (var surface in _reusableDockSurfaces.Values) + { + Children.Remove(surface); + surface.DataContext = null; + } + } + + if (_reusableSplitters is not null) + { + foreach (var splitter in _reusableSplitters.Values) + { + Children.Remove(splitter); + ClearConnectedControlState(splitter); + ResetControlComposition(splitter); + splitter.OwnerDock = null; + splitter.Splitter = null; + splitter.DataContext = null; + } + } + + if (_reusablePresenters is null) + { + return; + } + + foreach (var presenter in _reusablePresenters.Values) + { + Children.Remove(presenter); + _enteringPresenters.Remove(presenter); + _pendingInsertAnimations.Remove(presenter); + _activeInsertPresenters.Remove(presenter); + _insertAnimationVersions.Remove(presenter); + _pendingLayoutAnimations.Remove(presenter); + _layoutAnimationVersions.Remove(presenter); + _activeLayoutPresenters.Remove(presenter); + presenter.Content = null; + presenter.DataContext = null; + presenter.Opacity = 1.0; + presenter.IsHitTestVisible = true; + } + } + + private void RemoveRemovalPresenters() + { + var presenters = new List(_removalPresenterBounds.Keys); + foreach (var presenter in presenters) + { + RemoveRemovalPresenter(presenter); + } + } + + private void EnsureSurfaceChild(DockableControl surface) + { + if (Children.Contains(surface)) + { + return; + } + + var index = 0; + while (index < Children.Count && Children[index] is DockableControl) + { + index++; + } + + Children.Insert(index, surface); + } + + private void EnsureDockVisualChild(Control control) + { + if (Children.Contains(control)) + { + return; + } + + var index = Children.Count; + while (index > 0 && IsRemovalPresenter(Children[index - 1])) + { + index--; + } + + Children.Insert(index, control); + } + + private bool IsRemovalPresenter(Control control) + { + return control is ContentPresenter presenter && _removalPresenterBounds.ContainsKey(presenter); } private void SubscribeLayout(IProportionalDock dock) @@ -399,7 +756,8 @@ private void VisibleDockablesCollectionChanged(object? sender, NotifyCollectionC return; } - RebuildVisualTree(); + TrackLayoutActionDockables(e); + RequestRebuildVisualTree(); } private void DockablePropertyChanged(object? sender, PropertyChangedEventArgs e) @@ -413,10 +771,11 @@ private void DockablePropertyChanged(object? sender, PropertyChangedEventArgs e) || e.PropertyName == nameof(IDock.VisibleDockables) || e.PropertyName == nameof(IProportionalDock.Orientation)) { - RebuildVisualTree(); + RequestRebuildVisualTree(); return; } + CaptureConnectedStartBounds(); InvalidateMeasure(); InvalidateArrange(); } @@ -529,7 +888,7 @@ private void ArrangeDock(IProportionalDock dock, Rect bounds) if (_dockSurfaces.TryGetValue(dock, out var surface)) { - surface.Arrange(bounds); + ArrangeChild(surface, bounds, useConnectedAnimation: false); } if (dock.VisibleDockables is not { } visibleDockables || visibleDockables.Count == 0) @@ -574,7 +933,7 @@ private void ArrangeDock(IProportionalDock dock, Rect bounds) } else if (_presenters.TryGetValue(dockable, out var presenter)) { - presenter.Arrange(childBounds); + ArrangeChild(presenter, childBounds, useConnectedAnimation: true); } } } @@ -602,112 +961,1281 @@ private void ArrangeSplitter( var thickness = splitterControl.Thickness; var splitterBounds = CreateChildRect(bounds, dock.Orientation, offset, thickness); offset += thickness; - splitterControl.Arrange(splitterBounds); + ArrangeChild(splitterControl, splitterBounds, useConnectedAnimation: true); } - private double GetTotalSplitterThickness(IList visibleDockables) + private void MeasureRemovalPresenters() { - var total = 0.0; - - for (var i = 0; i < visibleDockables.Count; i++) + foreach (var kvp in _removalPresenterBounds) { - if (visibleDockables[i] is IProportionalDockSplitter splitter - && ShouldUseSplitter(visibleDockables, i) - && _splitters.TryGetValue(splitter, out var splitterControl)) - { - total += splitterControl.Thickness; - } + kvp.Key.Measure(kvp.Value.Size); } - - return total; } - private void AssignProportions(IProportionalDock dock, Size size, double splitterThickness) + private void ArrangeRemovalPresenters() { - if (dock.VisibleDockables is not { } visibleDockables) + foreach (var kvp in _removalPresenterBounds) { - return; + var presenter = kvp.Key; + var bounds = kvp.Value; + presenter.Measure(bounds.Size); + presenter.Arrange(bounds); + _arrangedChildren.Add(presenter); } + } - var dockables = new List(); - foreach (var dockable in visibleDockables) + private void ArrangeChild(Control control, Rect bounds, bool useConnectedAnimation) + { + var previousBounds = _connectedStartBounds.TryGetValue(control, out var connectedStartBounds) + ? connectedStartBounds + : control.Bounds; + + control.Arrange(bounds); + _arrangedChildren.Add(control); + + if (useConnectedAnimation) { - if (dockable is not IProportionalDockSplitter) + if (control is ContentPresenter presenter && _enteringPresenters.Remove(presenter)) { - dockables.Add(dockable); + QueueInsertAnimation(presenter, bounds); + QueueConnectedAnimation(presenter, previousBounds, bounds); + return; } + + QueueConnectedAnimation(control, previousBounds, bounds); } + } - if (dockables.Count == 0) + private void QueueInsertAnimation(ContentPresenter presenter, Rect bounds) + { + if (!UseLayoutTransitions + || _suppressLayoutTransitions + || !_hasCompletedArrange + || LayoutTransitionDuration <= TimeSpan.Zero + || !HasVisibleSize(bounds)) { + CompleteInsertAnimation(presenter); return; } - _isAssigningProportions = true; - try + _pendingInsertAnimations.Add(presenter); + } + + private void QueueConnectedAnimation(Control control, Rect previousBounds, Rect nextBounds) + { + if (!UseLayoutTransitions + || _suppressLayoutTransitions + || !_hasCompletedArrange + || LayoutTransitionDuration <= TimeSpan.Zero + || !HasVisibleSize(nextBounds)) { - var availableLength = Math.Max(1.0, GetLength(size, dock.Orientation) - splitterThickness); - var hasCollapsed = false; - var assignedTotal = 0.0; - var unassignedCount = 0; - var targets = new Dictionary(ReferenceEqualityComparer.Instance); + return; + } - foreach (var dockable in dockables) + if (control is ContentPresenter presenter) + { + if (!ShouldAnimateLayoutChange(presenter)) { - if (IsCollapsed(dockable)) - { - hasCollapsed = true; - if (IsValidProportion(dockable.Proportion) && dockable.Proportion > 0) - { - dockable.CollapsedProportion = dockable.Proportion; - } + CompleteScopedExcludedPresenter(presenter); + return; + } - targets[dockable] = 0.0; - continue; - } + var fromBounds = _pendingLayoutAnimations.TryGetValue(presenter, out var pendingLayoutAnimation) + ? pendingLayoutAnimation.From + : previousBounds; - var target = IsValidProportion(dockable.CollapsedProportion) - ? dockable.CollapsedProportion - : dockable.Proportion; + if (!HasVisibleSize(fromBounds)) + { + return; + } - if (IsValidProportion(target)) - { - assignedTotal += target; - targets[dockable] = target; - } - else + if (AreClose(fromBounds, nextBounds)) + { + _pendingLayoutAnimations.Remove(presenter); + if (_activeLayoutPresenters.Contains(presenter)) { - unassignedCount++; - targets[dockable] = double.NaN; + CompleteLayoutAnimation(presenter); + return; } - } - if (unassignedCount > 0) - { - var remaining = Math.Max(0, 1.0 - assignedTotal); - var proportion = remaining / unassignedCount; - foreach (var dockable in dockables) + if (!_pendingInsertAnimations.Contains(presenter) + && !_activeInsertPresenters.Contains(presenter) + && !_activeLayoutPresenters.Contains(presenter)) { - if (!IsCollapsed(dockable) && !IsValidProportion(targets[dockable])) - { - targets[dockable] = proportion; - } + presenter.Opacity = 1.0; + presenter.IsHitTestVisible = true; } + + return; } - NormalizeActiveProportions(dockables, targets); + var isPendingInsert = _pendingInsertAnimations.Contains(presenter); + _pendingLayoutAnimations[presenter] = new ConnectedAnimation(fromBounds, nextBounds); + presenter.Opacity = isPendingInsert ? 0.0 : 1.0; + presenter.IsHitTestVisible = false; + return; + } - foreach (var dockable in dockables) - { - var target = ClampProportion(dockable, dock.Orientation, availableLength, targets[dockable]); - SetDockableProportion(dockable, target, !IsCollapsed(dockable) && !hasCollapsed); - } + if (IsScopedLayoutActionActive()) + { + CompleteScopedExcludedControl(control); + return; } - finally + + var connectedFromBounds = _pendingConnectedAnimations.TryGetValue(control, out var pendingConnectedAnimation) + ? pendingConnectedAnimation.From + : previousBounds; + + if (!HasVisibleSize(connectedFromBounds)) { - _isAssigningProportions = false; + return; } - } + + if (AreClose(connectedFromBounds, nextBounds)) + { + _pendingConnectedAnimations.Remove(control); + if (_activeConnectedControls.Contains(control)) + { + CompleteConnectedAnimation(control); + } + + return; + } + + _pendingConnectedAnimations[control] = new ConnectedAnimation(connectedFromBounds, nextBounds); + } + + private void RequestConnectedAnimations() + { + _connectedStartBounds.Clear(); + + if ((_pendingConnectedAnimations.Count == 0 + && _pendingInsertAnimations.Count == 0 + && _pendingLayoutAnimations.Count == 0 + && _pendingRemovalPresenters.Count == 0) + || _hasPendingConnectedAnimationPost) + { + if (_pendingConnectedAnimations.Count == 0 + && _pendingInsertAnimations.Count == 0 + && _pendingLayoutAnimations.Count == 0 + && _pendingRemovalPresenters.Count == 0) + { + ClearScopedLayoutAction(); + } + + return; + } + + _hasPendingConnectedAnimationPost = true; + + if (this.IsAttachedToVisualTree() + && ElementComposition.GetElementVisual(this)?.Compositor is { } compositor) + { + compositor.RequestCompositionUpdate(StartPendingConnectedAnimations); + return; + } + + Dispatcher.UIThread.Post(StartPendingConnectedAnimations, DispatcherPriority.Render); + } + + private bool ShouldAnimateLayoutChange(ContentPresenter presenter) + { + if (IsScopedLayoutActionActive()) + { + return presenter.Content is IDockable dockable + && _layoutActionDockables.Contains(dockable) + && _connectedStartBounds.ContainsKey(presenter); + } + + return true; + } + + private bool IsScopedLayoutActionActive() + { + return _hasScopedLayoutAction && _layoutActionDockables.Count > 0; + } + + private void CompleteScopedExcludedPresenter(ContentPresenter presenter) + { + if (!IsScopedLayoutActionActive()) + { + return; + } + + if (_pendingLayoutAnimations.ContainsKey(presenter) + || _activeLayoutPresenters.Contains(presenter)) + { + CompleteLayoutAnimation(presenter); + return; + } + + if (!_pendingInsertAnimations.Contains(presenter) + && !_activeInsertPresenters.Contains(presenter)) + { + presenter.Opacity = 1.0; + presenter.IsHitTestVisible = true; + ResetPresenterComposition(presenter); + } + } + + private void CompleteScopedExcludedControl(Control control) + { + if (!IsScopedLayoutActionActive()) + { + return; + } + + if (_pendingConnectedAnimations.ContainsKey(control) + || _activeConnectedControls.Contains(control)) + { + CompleteConnectedAnimation(control); + return; + } + + ResetControlComposition(control); + } + + private void StartPendingConnectedAnimations() + { + _hasPendingConnectedAnimationPost = false; + + if (!UseLayoutTransitions || LayoutTransitionDuration <= TimeSpan.Zero) + { + CancelLayoutTransitions(removeRemovalPresenters: true); + return; + } + + var animations = new List>(_pendingConnectedAnimations); + var insertions = new List(_pendingInsertAnimations); + var layoutAnimations = new List>(_pendingLayoutAnimations); + var removals = new List(_pendingRemovalPresenters); + _pendingConnectedAnimations.Clear(); + _pendingInsertAnimations.Clear(); + _pendingLayoutAnimations.Clear(); + _pendingRemovalPresenters.Clear(); + ClearScopedLayoutAction(); + + foreach (var kvp in animations) + { + StartConnectedControlAnimation(kvp.Key, kvp.Value); + } + + foreach (var presenter in insertions) + { + StartInsertAnimation(presenter); + } + + foreach (var kvp in layoutAnimations) + { + StartLayoutAnimation(kvp.Key, kvp.Value); + } + + foreach (var presenter in removals) + { + StartRemovalAnimation(presenter); + } + } + + private bool StartConnectedAnimation(Control control, ConnectedAnimation animation) + { + return StartConnectedAnimation(control, animation, useScaleForSize: false, useCurrentCompositionStart: false); + } + + private bool StartConnectedAnimation( + Control control, + ConnectedAnimation animation, + bool useScaleForSize, + bool useCurrentCompositionStart) + { + if (!control.IsAttachedToVisualTree() + || !HasVisibleSize(animation.From) + || !HasVisibleSize(animation.To) + || AreClose(animation.From, animation.To)) + { + return false; + } + + var visual = ElementComposition.GetElementVisual(control); + if (visual is null) + { + return false; + } + + var compositor = visual.Compositor; + if (compositor is null) + { + return false; + } + + var to = animation.To; + var from = animation.From; + var finalOffset = new Vector3D(to.X, to.Y, visual.Offset.Z); + var startOffset = new Vector3D( + finalOffset.X + from.X - to.X, + finalOffset.Y + from.Y - to.Y, + finalOffset.Z); + var finalSize = new Vector(to.Width, to.Height); + var startSize = new Vector(from.Width, from.Height); + var startScale = new Vector3D( + to.Width > 0 ? from.Width / to.Width : 1.0, + to.Height > 0 ? from.Height / to.Height : 1.0, + 1.0); + var duration = LayoutTransitionDuration; + var hasSizeDelta = !AreClose(from.Width, to.Width) || !AreClose(from.Height, to.Height); + + if (!useCurrentCompositionStart) + { + visual.StopAnimation(nameof(CompositionVisual.Offset)); + visual.StopAnimation(nameof(CompositionVisual.Scale)); + visual.StopAnimation(nameof(CompositionVisual.Size)); + } + + visual.CenterPoint = new Vector3D(0.0, 0.0, 0.0); + if (!useCurrentCompositionStart) + { + visual.Offset = finalOffset; + visual.Size = finalSize; + visual.Scale = new Vector3D(1.0, 1.0, 1.0); + } + + var offset = compositor.CreateVector3DKeyFrameAnimation(); + offset.Target = nameof(CompositionVisual.Offset); + offset.Duration = duration; + offset.StopBehavior = AnimationStopBehavior.SetToFinalValue; + if (useCurrentCompositionStart) + { + offset.InsertExpressionKeyFrame(0.0f, "this.StartingValue", s_layoutTransitionEasing); + } + else + { + offset.InsertKeyFrame(0.0f, startOffset, s_layoutTransitionEasing); + } + + offset.InsertKeyFrame(1.0f, finalOffset, s_layoutTransitionEasing); + + var group = compositor.CreateAnimationGroup(); + group.Add(offset); + + if (useScaleForSize) + { + if (hasSizeDelta || useCurrentCompositionStart) + { + var scale = compositor.CreateVector3DKeyFrameAnimation(); + scale.Target = nameof(CompositionVisual.Scale); + scale.Duration = duration; + scale.StopBehavior = AnimationStopBehavior.SetToFinalValue; + if (useCurrentCompositionStart) + { + scale.InsertExpressionKeyFrame(0.0f, "this.StartingValue", s_layoutTransitionEasing); + } + else + { + scale.InsertKeyFrame(0.0f, startScale, s_layoutTransitionEasing); + } + + scale.InsertKeyFrame(1.0f, new Vector3D(1.0, 1.0, 1.0), s_layoutTransitionEasing); + + group.Add(scale); + } + + visual.StartAnimationGroup(group); + return true; + } + + if (hasSizeDelta || useCurrentCompositionStart) + { + var size = compositor.CreateVectorKeyFrameAnimation(); + size.Target = nameof(CompositionVisual.Size); + size.Duration = duration; + size.StopBehavior = AnimationStopBehavior.SetToFinalValue; + if (useCurrentCompositionStart) + { + size.InsertExpressionKeyFrame(0.0f, "this.StartingValue", s_layoutTransitionEasing); + } + else + { + size.InsertKeyFrame(0.0f, startSize, s_layoutTransitionEasing); + } + + size.InsertKeyFrame(1.0f, finalSize, s_layoutTransitionEasing); + + group.Add(size); + } + + visual.StartAnimationGroup(group); + return true; + } + + private void StartConnectedControlAnimation(Control control, ConnectedAnimation animation) + { + var useCurrentCompositionStart = _activeConnectedControls.Contains(control); + var version = NextConnectedAnimationVersion(control); + _activeConnectedControls.Add(control); + var compositor = ElementComposition.GetElementVisual(control)?.Compositor; + + if (compositor is null + || !StartConnectedAnimation(control, animation, useScaleForSize: false, useCurrentCompositionStart)) + { + CompleteConnectedAnimation(control, version); + return; + } + + CompleteConnectedAnimationAfterCommit(compositor, control, version, GetTransitionCompletionDelay()); + } + + private async void CompleteConnectedAnimationAfterCommit( + Compositor compositor, + Control control, + int version, + TimeSpan delay) + { + await compositor.RequestCommitAsync(); + await Task.Delay(delay); + Dispatcher.UIThread.Post(() => CompleteConnectedAnimation(control, version), DispatcherPriority.Background); + } + + private int NextConnectedAnimationVersion(Control control) + { + var version = _connectedAnimationVersions.TryGetValue(control, out var currentVersion) + ? currentVersion + 1 + : 1; + + _connectedAnimationVersions[control] = version; + return version; + } + + private void CompleteConnectedAnimation(Control control, int? version = null) + { + if (version.HasValue + && _connectedAnimationVersions.TryGetValue(control, out var currentVersion) + && currentVersion != version.Value) + { + return; + } + + ClearConnectedControlState(control); + + if (Children.Contains(control)) + { + ResetControlComposition(control); + } + } + + private void StartInsertAnimation(ContentPresenter presenter) + { + var version = NextInsertAnimationVersion(presenter); + _activeInsertPresenters.Add(presenter); + + if (!presenter.IsAttachedToVisualTree()) + { + CompleteInsertAnimation(presenter, version); + return; + } + + var visual = ElementComposition.GetElementVisual(presenter); + if (visual is null) + { + CompleteInsertAnimation(presenter, version); + return; + } + + var compositor = visual.Compositor; + if (compositor is null) + { + CompleteInsertAnimation(presenter, version); + return; + } + + presenter.Opacity = 1.0; + presenter.IsHitTestVisible = false; + visual.Opacity = 1.0f; + visual.StopAnimation(nameof(CompositionVisual.Opacity)); + + var opacity = compositor.CreateScalarKeyFrameAnimation(); + opacity.Target = nameof(CompositionVisual.Opacity); + opacity.Duration = LayoutTransitionDuration; + opacity.StopBehavior = AnimationStopBehavior.SetToFinalValue; + opacity.InsertKeyFrame(0.0f, 0.0f, s_layoutTransitionEasing); + opacity.InsertKeyFrame(1.0f, 1.0f, s_layoutTransitionEasing); + + visual.StartAnimation(nameof(CompositionVisual.Opacity), opacity); + + CompleteInsertAnimationAfterCommit(compositor, presenter, version, GetTransitionCompletionDelay()); + } + + private void StartLayoutAnimation(ContentPresenter presenter, ConnectedAnimation animation) + { + var useCurrentCompositionStart = _activeLayoutPresenters.Contains(presenter); + var version = NextLayoutAnimationVersion(presenter); + _activeLayoutPresenters.Add(presenter); + + if (!presenter.IsAttachedToVisualTree()) + { + CompleteLayoutAnimation(presenter, version); + return; + } + + presenter.Opacity = 1.0; + presenter.IsHitTestVisible = false; + var compositor = ElementComposition.GetElementVisual(presenter)?.Compositor; + + if (compositor is null + || !StartConnectedAnimation(presenter, animation, useScaleForSize: false, useCurrentCompositionStart)) + { + CompleteLayoutAnimation(presenter, version); + return; + } + + CompleteLayoutAnimationAfterCommit(compositor, presenter, version, GetTransitionCompletionDelay()); + } + + private async void CompleteLayoutAnimationAfterCommit( + Compositor compositor, + ContentPresenter presenter, + int version, + TimeSpan delay) + { + await compositor.RequestCommitAsync(); + await Task.Delay(delay); + Dispatcher.UIThread.Post(() => CompleteLayoutAnimation(presenter, version), DispatcherPriority.Background); + } + + private int NextLayoutAnimationVersion(ContentPresenter presenter) + { + var version = _layoutAnimationVersions.TryGetValue(presenter, out var currentVersion) + ? currentVersion + 1 + : 1; + + _layoutAnimationVersions[presenter] = version; + return version; + } + + private void CompleteLayoutAnimation(ContentPresenter presenter, int? version = null) + { + if (version.HasValue + && _layoutAnimationVersions.TryGetValue(presenter, out var currentVersion) + && currentVersion != version.Value) + { + return; + } + + _pendingLayoutAnimations.Remove(presenter); + _layoutAnimationVersions.Remove(presenter); + _activeLayoutPresenters.Remove(presenter); + + if (Children.Contains(presenter)) + { + var hasInsertTransition = _pendingInsertAnimations.Contains(presenter) + || _activeInsertPresenters.Contains(presenter); + presenter.Opacity = 1.0; + presenter.IsHitTestVisible = !hasInsertTransition; + if (hasInsertTransition) + { + ResetControlComposition(presenter); + } + else + { + ResetPresenterComposition(presenter); + } + } + } + + private async void CompleteInsertAnimationAfterCommit( + Compositor compositor, + ContentPresenter presenter, + int version, + TimeSpan delay) + { + await compositor.RequestCommitAsync(); + await Task.Delay(delay); + Dispatcher.UIThread.Post(() => CompleteInsertAnimation(presenter, version), DispatcherPriority.Background); + } + + private int NextInsertAnimationVersion(ContentPresenter presenter) + { + var version = _insertAnimationVersions.TryGetValue(presenter, out var currentVersion) + ? currentVersion + 1 + : 1; + + _insertAnimationVersions[presenter] = version; + return version; + } + + private void CompleteInsertAnimation(ContentPresenter presenter, int? version = null) + { + if (version.HasValue + && _insertAnimationVersions.TryGetValue(presenter, out var currentVersion) + && currentVersion != version.Value) + { + return; + } + + _pendingInsertAnimations.Remove(presenter); + _insertAnimationVersions.Remove(presenter); + _activeInsertPresenters.Remove(presenter); + + if (Children.Contains(presenter)) + { + var hasLayoutTransition = _pendingLayoutAnimations.ContainsKey(presenter) + || _activeLayoutPresenters.Contains(presenter); + presenter.Opacity = 1.0; + presenter.IsHitTestVisible = !hasLayoutTransition; + if (hasLayoutTransition) + { + ResetPresenterOpacity(presenter); + } + else + { + ResetPresenterComposition(presenter); + } + } + } + + private void StartRemovalAnimation(ContentPresenter presenter) + { + if (!_removalPresenterBounds.ContainsKey(presenter) || !_activeRemovalPresenters.Add(presenter)) + { + return; + } + + presenter.Opacity = 1.0; + presenter.IsHitTestVisible = false; + + var visual = ElementComposition.GetElementVisual(presenter); + Compositor? compositor = null; + if (visual is { Compositor: { } visualCompositor } && presenter.IsAttachedToVisualTree()) + { + compositor = visualCompositor; + visual.StopAnimation(nameof(CompositionVisual.Opacity)); + + var opacity = visualCompositor.CreateScalarKeyFrameAnimation(); + opacity.Target = nameof(CompositionVisual.Opacity); + opacity.Duration = LayoutTransitionDuration; + opacity.StopBehavior = AnimationStopBehavior.SetToFinalValue; + opacity.InsertExpressionKeyFrame(0.0f, "this.StartingValue", s_layoutTransitionEasing); + opacity.InsertKeyFrame(1.0f, 0.0f, s_layoutTransitionEasing); + + visual.StartAnimation(nameof(CompositionVisual.Opacity), opacity); + } + + if (compositor is null) + { + RemoveRemovalPresenterAfterDelay(presenter, GetTransitionCompletionDelay()); + return; + } + + RemoveRemovalPresenterAfterCommit(compositor, presenter, GetTransitionCompletionDelay()); + } + + private async void RemoveRemovalPresenterAfterCommit( + Compositor compositor, + ContentPresenter presenter, + TimeSpan delay) + { + await compositor.RequestCommitAsync(); + await Task.Delay(delay); + Dispatcher.UIThread.Post(() => RemoveRemovalPresenter(presenter), DispatcherPriority.Background); + } + + private async void RemoveRemovalPresenterAfterDelay(ContentPresenter presenter, TimeSpan delay) + { + await Task.Delay(delay); + Dispatcher.UIThread.Post(() => RemoveRemovalPresenter(presenter), DispatcherPriority.Background); + } + + private void RemoveRemovalPresenter(ContentPresenter presenter) + { + if (!_removalPresenterBounds.ContainsKey(presenter) + && !_pendingRemovalPresenters.Contains(presenter) + && !_activeRemovalPresenters.Contains(presenter)) + { + return; + } + + _pendingRemovalPresenters.Remove(presenter); + _activeRemovalPresenters.Remove(presenter); + _removalPresenterBounds.Remove(presenter); + + if (presenter.Content is IDockable dockable + && _removalPresentersByDockable.TryGetValue(dockable, out var removalPresenter) + && ReferenceEquals(removalPresenter, presenter)) + { + _removalPresentersByDockable.Remove(dockable); + } + + ResetPresenterComposition(presenter); + Children.Remove(presenter); + presenter.Content = null; + presenter.DataContext = null; + presenter.Opacity = 1.0; + presenter.IsHitTestVisible = true; + } + + private void CancelLayoutTransitions(bool removeRemovalPresenters) + { + _hasPendingConnectedAnimationPost = false; + _connectedStartBounds.Clear(); + ClearScopedLayoutAction(); + + var controls = new HashSet(ReferenceEqualityComparer.Instance); + foreach (var control in _pendingConnectedAnimations.Keys) + { + controls.Add(control); + } + + foreach (var control in _activeConnectedControls) + { + controls.Add(control); + } + + foreach (var splitter in _splitters.Values) + { + controls.Add(splitter); + } + + _pendingConnectedAnimations.Clear(); + _pendingLayoutAnimations.Clear(); + _pendingInsertAnimations.Clear(); + + var presenters = new HashSet(ReferenceEqualityComparer.Instance); + + foreach (var presenter in _presenters.Values) + { + presenters.Add(presenter); + } + + foreach (var presenter in _activeInsertPresenters) + { + presenters.Add(presenter); + } + + foreach (var presenter in _activeLayoutPresenters) + { + presenters.Add(presenter); + } + + foreach (var presenter in _removalPresenterBounds.Keys) + { + presenters.Add(presenter); + } + + foreach (var presenter in _pendingRemovalPresenters) + { + presenters.Add(presenter); + } + + foreach (var presenter in _activeRemovalPresenters) + { + presenters.Add(presenter); + } + + foreach (var presenter in presenters) + { + CompletePresenterTransitionState(presenter); + } + + foreach (var control in controls) + { + CompleteConnectedControlState(control); + } + + if (removeRemovalPresenters) + { + RemoveRemovalPresenters(); + } + } + + private void CompletePresenterTransitionState(ContentPresenter presenter) + { + _enteringPresenters.Remove(presenter); + _pendingInsertAnimations.Remove(presenter); + _activeInsertPresenters.Remove(presenter); + _insertAnimationVersions.Remove(presenter); + _pendingLayoutAnimations.Remove(presenter); + _layoutAnimationVersions.Remove(presenter); + _activeLayoutPresenters.Remove(presenter); + + ResetPresenterComposition(presenter); + + if (Children.Contains(presenter)) + { + presenter.Opacity = 1.0; + presenter.IsHitTestVisible = true; + } + } + + private void CompleteConnectedControlState(Control control) + { + ClearConnectedControlState(control); + ResetControlComposition(control); + } + + private void TrackLayoutActionDockables(NotifyCollectionChangedEventArgs e) + { + if (!UseLayoutTransitions || !_hasCompletedArrange) + { + return; + } + + var previousCount = _layoutActionDockables.Count; + TrackLayoutActionDockables(e.NewItems); + TrackLayoutActionDockables(e.OldItems); + + if (_layoutActionDockables.Count > previousCount) + { + _hasScopedLayoutAction = true; + } + } + + private void TrackStructuralLayoutActionDockables() + { + if (!UseLayoutTransitions + || !_hasCompletedArrange + || IsScopedLayoutActionActive() + || Dock is not { } dock) + { + return; + } + + EnsureConnectedStartBounds(); + + var previousDockables = new List(); + CaptureCurrentPresenterDockables(previousDockables); + if (previousDockables.Count == 0) + { + return; + } + + var nextDockables = new List(); + CaptureLeafDockables(dock, nextDockables); + + var previousCount = _layoutActionDockables.Count; + TrackChangedDockables(previousDockables, nextDockables); + + if (_layoutActionDockables.Count > previousCount) + { + _hasScopedLayoutAction = true; + } + } + + private void CaptureCurrentPresenterDockables(ICollection dockables) + { + foreach (var child in Children) + { + if (child is not ContentPresenter presenter + || presenter.Content is not IDockable dockable + || !_presenters.TryGetValue(dockable, out var activePresenter) + || !ReferenceEquals(activePresenter, presenter)) + { + continue; + } + + dockables.Add(dockable); + } + } + + private void CaptureLeafDockables(IDockable dockable, ICollection dockables) + { + switch (dockable) + { + case IProportionalDock proportionalDock: + { + if (proportionalDock.VisibleDockables is null) + { + return; + } + + foreach (var child in proportionalDock.VisibleDockables) + { + CaptureLeafDockables(child, dockables); + } + + return; + } + case IProportionalDockSplitter: + return; + default: + dockables.Add(dockable); + return; + } + } + + private void TrackChangedDockables(IList previousDockables, IList nextDockables) + { + if (TrackSingleMovedDockable(previousDockables, nextDockables)) + { + return; + } + + for (var index = 0; index < previousDockables.Count; index++) + { + var dockable = previousDockables[index]; + var nextIndex = IndexOfReference(nextDockables, dockable); + if (nextIndex < 0 || nextIndex != index) + { + _layoutActionDockables.Add(dockable); + } + } + + for (var index = 0; index < nextDockables.Count; index++) + { + var dockable = nextDockables[index]; + var previousIndex = IndexOfReference(previousDockables, dockable); + if (previousIndex < 0 || previousIndex != index) + { + _layoutActionDockables.Add(dockable); + } + } + } + + private bool TrackSingleMovedDockable(IList previousDockables, IList nextDockables) + { + if (previousDockables.Count != nextDockables.Count || previousDockables.Count < 3) + { + return false; + } + + IDockable? movedDockable = null; + for (var previousIndex = 0; previousIndex < previousDockables.Count; previousIndex++) + { + var dockable = previousDockables[previousIndex]; + var nextIndex = IndexOfReference(nextDockables, dockable); + if (nextIndex < 0) + { + return false; + } + + if (nextIndex == previousIndex) + { + continue; + } + + if (!MatchesAfterMove(previousDockables, nextDockables, previousIndex, nextIndex)) + { + continue; + } + + if (movedDockable is not null) + { + return false; + } + + movedDockable = dockable; + } + + if (movedDockable is null) + { + return false; + } + + _layoutActionDockables.Add(movedDockable); + return true; + } + + private static bool MatchesAfterMove( + IList previousDockables, + IList nextDockables, + int previousIndex, + int nextIndex) + { + for (var index = 0; index < nextDockables.Count; index++) + { + var candidateIndex = GetMovedSequenceSourceIndex(index, previousIndex, nextIndex); + if (!ReferenceEquals(previousDockables[candidateIndex], nextDockables[index])) + { + return false; + } + } + + return true; + } + + private static int GetMovedSequenceSourceIndex(int nextIndex, int previousIndex, int movedNextIndex) + { + if (nextIndex == movedNextIndex) + { + return previousIndex; + } + + if (previousIndex < movedNextIndex && nextIndex >= previousIndex && nextIndex < movedNextIndex) + { + return nextIndex + 1; + } + + if (previousIndex > movedNextIndex && nextIndex > movedNextIndex && nextIndex <= previousIndex) + { + return nextIndex - 1; + } + + return nextIndex; + } + + private static int IndexOfReference(IList dockables, IDockable dockable) + { + for (var index = 0; index < dockables.Count; index++) + { + if (ReferenceEquals(dockables[index], dockable)) + { + return index; + } + } + + return -1; + } + + private void TrackLayoutActionDockables(System.Collections.IList? dockables) + { + if (dockables is null) + { + return; + } + + foreach (var item in dockables) + { + if (item is IDockable dockable) + { + TrackLayoutActionDockable(dockable); + } + } + } + + private void TrackLayoutActionDockable(IDockable dockable) + { + switch (dockable) + { + case IProportionalDock proportionalDock: + { + if (proportionalDock.VisibleDockables is null) + { + return; + } + + foreach (var child in proportionalDock.VisibleDockables) + { + TrackLayoutActionDockable(child); + } + + return; + } + case IProportionalDockSplitter: + return; + default: + _layoutActionDockables.Add(dockable); + return; + } + } + + private void ClearScopedLayoutAction() + { + _layoutActionDockables.Clear(); + _hasScopedLayoutAction = false; + } + + private void EnsureConnectedStartBounds() + { + if (_connectedStartBounds.Count == 0) + { + CaptureConnectedStartBounds(); + } + } + + private void ClearConnectedControlState(Control control) + { + _pendingConnectedAnimations.Remove(control); + _activeConnectedControls.Remove(control); + _connectedAnimationVersions.Remove(control); + } + + private static void ResetPresenterComposition(ContentPresenter presenter) + { + ResetControlComposition(presenter, resetOpacity: true); + } + + private static void ResetPresenterOpacity(ContentPresenter presenter) + { + var visual = ElementComposition.GetElementVisual(presenter); + if (visual is null) + { + return; + } + + visual.StopAnimation(nameof(CompositionVisual.Opacity)); + visual.Opacity = 1.0f; + } + + private static void ResetControlComposition(Control control) + { + ResetControlComposition(control, resetOpacity: false); + } + + private static void ResetControlComposition(Control control, bool resetOpacity) + { + var visual = ElementComposition.GetElementVisual(control); + if (visual is null) + { + return; + } + + if (resetOpacity) + { + visual.StopAnimation(nameof(CompositionVisual.Opacity)); + visual.Opacity = 1.0f; + } + + visual.StopAnimation(nameof(CompositionVisual.Offset)); + visual.StopAnimation(nameof(CompositionVisual.Scale)); + visual.StopAnimation(nameof(CompositionVisual.Size)); + visual.CenterPoint = new Vector3D(0.0, 0.0, 0.0); + visual.Scale = new Vector3D(1.0, 1.0, 1.0); + + var bounds = control.Bounds; + if (HasVisibleSize(bounds)) + { + visual.Offset = new Vector3D(bounds.X, bounds.Y, visual.Offset.Z); + visual.Size = new Vector(bounds.Width, bounds.Height); + } + } + + private void CaptureConnectedStartBounds() + { + _connectedStartBounds.Clear(); + + if (!UseLayoutTransitions || !_hasCompletedArrange) + { + return; + } + + foreach (var presenter in _presenters.Values) + { + CaptureConnectedStartBounds(presenter); + } + + foreach (var splitter in _splitters.Values) + { + CaptureConnectedStartBounds(splitter); + } + + foreach (var kvp in _removalPresenterBounds) + { + if (HasVisibleSize(kvp.Value)) + { + _connectedStartBounds[kvp.Key] = kvp.Value; + } + } + } + + private void CaptureConnectedStartBounds(Control control) + { + var bounds = control.Bounds; + if (HasVisibleSize(bounds)) + { + _connectedStartBounds[control] = bounds; + } + } + + private double GetTotalSplitterThickness(IList visibleDockables) + { + var total = 0.0; + + for (var i = 0; i < visibleDockables.Count; i++) + { + if (visibleDockables[i] is IProportionalDockSplitter splitter + && ShouldUseSplitter(visibleDockables, i) + && _splitters.TryGetValue(splitter, out var splitterControl)) + { + total += splitterControl.Thickness; + } + } + + return total; + } + + private void AssignProportions(IProportionalDock dock, Size size, double splitterThickness) + { + if (dock.VisibleDockables is not { } visibleDockables) + { + return; + } + + var dockables = new List(); + foreach (var dockable in visibleDockables) + { + if (dockable is not IProportionalDockSplitter) + { + dockables.Add(dockable); + } + } + + if (dockables.Count == 0) + { + return; + } + + _isAssigningProportions = true; + try + { + var availableLength = Math.Max(1.0, GetLength(size, dock.Orientation) - splitterThickness); + var hasCollapsed = false; + var assignedTotal = 0.0; + var unassignedCount = 0; + var targets = new Dictionary(ReferenceEqualityComparer.Instance); + + foreach (var dockable in dockables) + { + if (IsCollapsed(dockable)) + { + hasCollapsed = true; + if (IsValidProportion(dockable.Proportion) && dockable.Proportion > 0) + { + dockable.CollapsedProportion = dockable.Proportion; + } + + targets[dockable] = 0.0; + continue; + } + + var target = IsValidProportion(dockable.CollapsedProportion) + ? dockable.CollapsedProportion + : dockable.Proportion; + + if (IsValidProportion(target)) + { + assignedTotal += target; + targets[dockable] = target; + } + else + { + unassignedCount++; + targets[dockable] = double.NaN; + } + } + + if (unassignedCount > 0) + { + var remaining = Math.Max(0, 1.0 - assignedTotal); + var proportion = remaining / unassignedCount; + foreach (var dockable in dockables) + { + if (!IsCollapsed(dockable) && !IsValidProportion(targets[dockable])) + { + targets[dockable] = proportion; + } + } + } + + NormalizeActiveProportions(dockables, targets); + + foreach (var dockable in dockables) + { + var target = ClampProportion(dockable, dock.Orientation, availableLength, targets[dockable]); + SetDockableProportion(dockable, target, !IsCollapsed(dockable) && !hasCollapsed); + } + } + finally + { + _isAssigningProportions = false; + } + } private static void NormalizeActiveProportions(IList dockables, IDictionary targets) { @@ -974,6 +2502,42 @@ private static bool AreClose(double left, double right) return Math.Abs(left - right) < 1e-10; } + private static bool AreClose(Rect left, Rect right) + { + return AreClose(left.X, right.X) + && AreClose(left.Y, right.Y) + && AreClose(left.Width, right.Width) + && AreClose(left.Height, right.Height); + } + + private static bool HasVisibleSize(Rect bounds) + { + return bounds.Width > 0 + && bounds.Height > 0 + && !double.IsNaN(bounds.Width) + && !double.IsNaN(bounds.Height) + && !double.IsInfinity(bounds.Width) + && !double.IsInfinity(bounds.Height); + } + + private TimeSpan GetTransitionCompletionDelay() + { + return LayoutTransitionDuration + s_transitionCompletionSlack; + } + + private readonly struct ConnectedAnimation + { + public ConnectedAnimation(Rect from, Rect to) + { + From = from; + To = to; + } + + public Rect From { get; } + + public Rect To { get; } + } + private static AvaloniaOrientation ToAvaloniaOrientation(DockOrientation orientation) { return orientation == DockOrientation.Vertical ? AvaloniaOrientation.Vertical : AvaloniaOrientation.Horizontal; diff --git a/tests/Dock.Avalonia.HeadlessTests/FlatProportionalDockPanelTests.cs b/tests/Dock.Avalonia.HeadlessTests/FlatProportionalDockPanelTests.cs index 8d585c1c2..aa5f70d77 100644 --- a/tests/Dock.Avalonia.HeadlessTests/FlatProportionalDockPanelTests.cs +++ b/tests/Dock.Avalonia.HeadlessTests/FlatProportionalDockPanelTests.cs @@ -1,12 +1,19 @@ +using System; +using System.Collections.ObjectModel; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using Avalonia; +using Avalonia.Controls; using Avalonia.Controls.Presenters; using Avalonia.Headless.XUnit; +using Avalonia.Rendering.Composition; +using Avalonia.Threading; using Dock.Avalonia.Controls; using Dock.Model.Avalonia.Controls; using Dock.Model.Controls; using Dock.Model.Core; +using Dock.Settings; using Xunit; namespace Dock.Avalonia.HeadlessTests; @@ -50,6 +57,9 @@ public void FlatProportionalDockPanel_Flattens_Nested_ProportionalDocks() Assert.DoesNotContain(panel.Children, child => child is ProportionalDockControl); Assert.Contains(surfaces, surface => ReferenceEquals(surface.DataContext, root)); Assert.Contains(surfaces, surface => ReferenceEquals(surface.DataContext, inner)); + Assert.All(surfaces, surface => Assert.True(DockProperties.GetIsDockTarget(surface))); + Assert.All(surfaces, surface => Assert.Same(surface, DockProperties.GetDockAdornerHost(surface))); + Assert.All(surfaces, surface => Assert.False(DockProperties.GetShowDockIndicatorOnly(surface))); var leftPresenter = presenters.Single(presenter => ReferenceEquals(presenter.Content, left)); var innerSurface = surfaces.Single(surface => ReferenceEquals(surface.DataContext, inner)); @@ -85,4 +95,1528 @@ public void FlatProportionalDockPanel_ResizeSplitter_Updates_ModelProportions() Assert.Equal(0.35, left.Proportion, 2); Assert.Equal(0.65, right.Proportion, 2); } + + [AvaloniaFact] + public void FlatProportionalDockPanel_LayoutTransitions_Default_And_CanSet() + { + var panel = new FlatProportionalDockPanel(); + + Assert.True(panel.UseLayoutTransitions); + Assert.Equal(TimeSpan.FromMilliseconds(240), panel.LayoutTransitionDuration); + + panel.UseLayoutTransitions = false; + panel.LayoutTransitionDuration = TimeSpan.FromMilliseconds(80); + + Assert.False(panel.UseLayoutTransitions); + Assert.Equal(TimeSpan.FromMilliseconds(80), panel.LayoutTransitionDuration); + } + + [AvaloniaFact] + public void FlatProportionalDockPanel_Reuses_Existing_Visuals_When_Layout_Rebuilds() + { + var left = new DocumentDock { Id = "Left", Proportion = 0.25, CollapsedProportion = 0.25 }; + var right = new DocumentDock { Id = "Right", Proportion = 0.75, CollapsedProportion = 0.75 }; + var splitter = new ProportionalDockSplitter { Id = "Splitter" }; + var root = new ProportionalDock + { + Id = "Root", + Orientation = Orientation.Horizontal, + VisibleDockables = new List { left, splitter, right } + }; + var panel = new FlatProportionalDockPanel { Dock = root }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var leftPresenter = panel.Children + .OfType() + .Single(presenter => ReferenceEquals(presenter.Content, left)); + var leftBounds = leftPresenter.Bounds; + + root.VisibleDockables = new List { right, splitter, left }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var movedLeftPresenter = panel.Children + .OfType() + .Single(presenter => ReferenceEquals(presenter.Content, left)); + Dispatcher.UIThread.RunJobs(); + + Assert.Same(leftPresenter, movedLeftPresenter); + Assert.NotEqual(leftBounds.X, movedLeftPresenter.Bounds.X); + Assert.Equal(1.0, movedLeftPresenter.Opacity); + Assert.True(movedLeftPresenter.IsHitTestVisible); + } + + [AvaloniaFact] + public void FlatProportionalDockPanel_Rebuild_Keeps_Reused_Presenter_Attached() + { + var left = new DocumentDock { Id = "Left", Proportion = 0.25, CollapsedProportion = 0.25 }; + var right = new DocumentDock { Id = "Right", Proportion = 0.75, CollapsedProportion = 0.75 }; + var splitter = new ProportionalDockSplitter { Id = "Splitter" }; + var root = new ProportionalDock + { + Id = "Root", + Orientation = Orientation.Horizontal, + VisibleDockables = new List { left, splitter, right } + }; + var panel = new FlatProportionalDockPanel { Dock = root }; + var window = ShowPanel(panel); + + try + { + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var leftPresenter = GetLivePresenters(panel) + .Single(presenter => ReferenceEquals(presenter.Content, left)); + var detached = 0; + var attached = 0; + leftPresenter.DetachedFromVisualTree += (_, _) => detached++; + leftPresenter.AttachedToVisualTree += (_, _) => attached++; + + root.VisibleDockables = new List { right, splitter, left }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + Dispatcher.UIThread.RunJobs(); + + Assert.Same(leftPresenter, GetLivePresenters(panel) + .Single(presenter => ReferenceEquals(presenter.Content, left))); + Assert.Equal(0, detached); + Assert.Equal(0, attached); + Assert.Equal(1.0, leftPresenter.Opacity); + } + finally + { + window.Close(); + Dispatcher.UIThread.RunJobs(); + } + } + + [AvaloniaFact] + public void FlatProportionalDockPanel_Rebuild_Reuses_And_Moves_Splitter_Visual() + { + var left = new DocumentDock { Id = "Left", Proportion = 0.25, CollapsedProportion = 0.25 }; + var right = new DocumentDock { Id = "Right", Proportion = 0.75, CollapsedProportion = 0.75 }; + var splitter = new ProportionalDockSplitter { Id = "Splitter" }; + var root = new ProportionalDock + { + Id = "Root", + Orientation = Orientation.Horizontal, + VisibleDockables = new List { left, splitter, right } + }; + var panel = new FlatProportionalDockPanel { Dock = root }; + var window = ShowPanel(panel); + + try + { + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var splitterControl = panel.Children + .OfType() + .Single(control => ReferenceEquals(control.Splitter, splitter)); + var previousBounds = splitterControl.Bounds; + + root.VisibleDockables = new List { right, splitter, left }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + Dispatcher.UIThread.RunJobs(); + + var movedSplitterControl = panel.Children + .OfType() + .Single(control => ReferenceEquals(control.Splitter, splitter)); + + Assert.Same(splitterControl, movedSplitterControl); + Assert.NotEqual(previousBounds.X, movedSplitterControl.Bounds.X); + } + finally + { + window.Close(); + Dispatcher.UIThread.RunJobs(); + } + } + + [AvaloniaFact] + public async Task FlatProportionalDockPanel_Moved_Dockable_Animates_Live_Presenter() + { + var left = new DocumentDock { Id = "Left", Proportion = 0.5, CollapsedProportion = 0.5 }; + var right = new DocumentDock { Id = "Right", Proportion = 0.5, CollapsedProportion = 0.5 }; + var visibleDockables = new ObservableCollection { left, right }; + var root = new ProportionalDock + { + Id = "Root", + Orientation = Orientation.Horizontal, + VisibleDockables = visibleDockables + }; + var panel = new FlatProportionalDockPanel + { + Dock = root, + LayoutTransitionDuration = TimeSpan.FromMilliseconds(20) + }; + var window = ShowPanel(panel); + + try + { + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var leftPresenter = GetLivePresenters(panel) + .Single(presenter => ReferenceEquals(presenter.Content, left)); + var rightPresenter = GetLivePresenters(panel) + .Single(presenter => ReferenceEquals(presenter.Content, right)); + var previousBounds = leftPresenter.Bounds; + + visibleDockables.Insert(2, left); + visibleDockables.RemoveAt(0); + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + Assert.NotEqual(previousBounds.X, leftPresenter.Bounds.X); + Assert.Equal(1.0, leftPresenter.Opacity); + Assert.False(leftPresenter.IsHitTestVisible); + Assert.Equal(1.0, rightPresenter.Opacity); + + Dispatcher.UIThread.RunJobs(); + + Assert.False(leftPresenter.IsHitTestVisible); + + await Task.Delay(120); + Dispatcher.UIThread.RunJobs(); + + Assert.Equal(1.0, leftPresenter.Opacity); + Assert.True(leftPresenter.IsHitTestVisible); + } + finally + { + window.Close(); + Dispatcher.UIThread.RunJobs(); + } + } + + [AvaloniaFact] + public async Task FlatProportionalDockPanel_Collection_Move_Animates_Moved_Presenter_Only() + { + var left = new DocumentDock { Id = "Left", Proportion = 0.5, CollapsedProportion = 0.5 }; + var right = new DocumentDock { Id = "Right", Proportion = 0.5, CollapsedProportion = 0.5 }; + var visibleDockables = new ObservableCollection { left, right }; + var root = new ProportionalDock + { + Id = "Root", + Orientation = Orientation.Horizontal, + VisibleDockables = visibleDockables + }; + var panel = new FlatProportionalDockPanel + { + Dock = root, + LayoutTransitionDuration = TimeSpan.FromMilliseconds(40) + }; + var window = ShowPanel(panel); + + try + { + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var leftPresenter = GetLivePresenters(panel) + .Single(presenter => ReferenceEquals(presenter.Content, left)); + var rightPresenter = GetLivePresenters(panel) + .Single(presenter => ReferenceEquals(presenter.Content, right)); + var previousLeftBounds = leftPresenter.Bounds; + var previousRightBounds = rightPresenter.Bounds; + + visibleDockables.Move(0, 1); + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + Assert.NotEqual(previousLeftBounds.X, leftPresenter.Bounds.X); + Assert.NotEqual(previousRightBounds.X, rightPresenter.Bounds.X); + Assert.False(leftPresenter.IsHitTestVisible); + Assert.True(rightPresenter.IsHitTestVisible); + Assert.Equal(1.0, rightPresenter.Opacity); + + Dispatcher.UIThread.RunJobs(); + + Assert.False(leftPresenter.IsHitTestVisible); + Assert.True(rightPresenter.IsHitTestVisible); + + await Task.Delay(120); + Dispatcher.UIThread.RunJobs(); + + Assert.True(leftPresenter.IsHitTestVisible); + Assert.True(rightPresenter.IsHitTestVisible); + } + finally + { + window.Close(); + Dispatcher.UIThread.RunJobs(); + } + } + + [AvaloniaFact] + public async Task FlatProportionalDockPanel_Direct_Move_Animates_Moved_Presenter_Only() + { + var left = new DocumentDock { Id = "Left", Proportion = 1.0 / 3.0, CollapsedProportion = 1.0 / 3.0 }; + var middle = new DocumentDock { Id = "Middle", Proportion = 1.0 / 3.0, CollapsedProportion = 1.0 / 3.0 }; + var right = new DocumentDock { Id = "Right", Proportion = 1.0 / 3.0, CollapsedProportion = 1.0 / 3.0 }; + var root = new ProportionalDock + { + Id = "Root", + Orientation = Orientation.Horizontal, + VisibleDockables = new List { left, middle, right } + }; + var panel = new FlatProportionalDockPanel + { + Dock = root, + LayoutTransitionDuration = TimeSpan.FromMilliseconds(40) + }; + var window = ShowPanel(panel); + + try + { + panel.Measure(new Size(990, 600)); + panel.Arrange(new Rect(0, 0, 990, 600)); + + var leftPresenter = GetLivePresenters(panel) + .Single(presenter => ReferenceEquals(presenter.Content, left)); + var middlePresenter = GetLivePresenters(panel) + .Single(presenter => ReferenceEquals(presenter.Content, middle)); + var rightPresenter = GetLivePresenters(panel) + .Single(presenter => ReferenceEquals(presenter.Content, right)); + var previousLeftBounds = leftPresenter.Bounds; + var previousMiddleBounds = middlePresenter.Bounds; + var previousRightBounds = rightPresenter.Bounds; + var middleVisual = ElementComposition.GetElementVisual(middlePresenter); + var rightVisual = ElementComposition.GetElementVisual(rightPresenter); + + Assert.NotNull(middleVisual); + Assert.NotNull(rightVisual); + + root.VisibleDockables = new List { middle, right, left }; + + panel.Measure(new Size(990, 600)); + panel.Arrange(new Rect(0, 0, 990, 600)); + + Assert.NotEqual(previousLeftBounds.X, leftPresenter.Bounds.X); + Assert.NotEqual(previousMiddleBounds.X, middlePresenter.Bounds.X); + Assert.NotEqual(previousRightBounds.X, rightPresenter.Bounds.X); + Assert.False(leftPresenter.IsHitTestVisible); + Assert.True(middlePresenter.IsHitTestVisible); + Assert.True(rightPresenter.IsHitTestVisible); + Assert.Equal(middlePresenter.Bounds.X, middleVisual.Offset.X, 3); + Assert.Equal(rightPresenter.Bounds.X, rightVisual.Offset.X, 3); + + Dispatcher.UIThread.RunJobs(); + + Assert.False(leftPresenter.IsHitTestVisible); + Assert.True(middlePresenter.IsHitTestVisible); + Assert.True(rightPresenter.IsHitTestVisible); + + await Task.Delay(120); + Dispatcher.UIThread.RunJobs(); + + Assert.True(leftPresenter.IsHitTestVisible); + Assert.True(middlePresenter.IsHitTestVisible); + Assert.True(rightPresenter.IsHitTestVisible); + } + finally + { + window.Close(); + Dispatcher.UIThread.RunJobs(); + } + } + + [AvaloniaFact] + public async Task FlatProportionalDockPanel_Retargeted_Move_Keeps_Presenter_Disabled_Until_Latest_Transition_Completes() + { + var left = new DocumentDock { Id = "Left", Proportion = 0.5, CollapsedProportion = 0.5 }; + var right = new DocumentDock { Id = "Right", Proportion = 0.5, CollapsedProportion = 0.5 }; + var root = new ProportionalDock + { + Id = "Root", + Orientation = Orientation.Horizontal, + VisibleDockables = new List { left, right } + }; + var panel = new FlatProportionalDockPanel + { + Dock = root, + LayoutTransitionDuration = TimeSpan.FromMilliseconds(80) + }; + var window = ShowPanel(panel); + + try + { + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var leftPresenter = GetLivePresenters(panel) + .Single(presenter => ReferenceEquals(presenter.Content, left)); + + root.VisibleDockables = new List { right, left }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + Dispatcher.UIThread.RunJobs(); + + Assert.False(leftPresenter.IsHitTestVisible); + + await Task.Delay(50); + Dispatcher.UIThread.RunJobs(); + + root.VisibleDockables = new List { left, right }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + Dispatcher.UIThread.RunJobs(); + + Assert.False(leftPresenter.IsHitTestVisible); + + await Task.Delay(60); + Dispatcher.UIThread.RunJobs(); + + Assert.False(leftPresenter.IsHitTestVisible); + + await Task.Delay(70); + Dispatcher.UIThread.RunJobs(); + + var visual = ElementComposition.GetElementVisual(leftPresenter); + + Assert.NotNull(visual); + Assert.True(leftPresenter.IsHitTestVisible); + Assert.Equal(leftPresenter.Bounds.X, visual.Offset.X, 3); + Assert.Equal(leftPresenter.Bounds.Y, visual.Offset.Y, 3); + Assert.Equal(leftPresenter.Bounds.Width, visual.Size.X, 3); + Assert.Equal(leftPresenter.Bounds.Height, visual.Size.Y, 3); + Assert.Equal(1.0, visual.Scale.X, 3); + Assert.Equal(1.0, visual.Scale.Y, 3); + } + finally + { + window.Close(); + Dispatcher.UIThread.RunJobs(); + } + } + + [AvaloniaFact] + public void FlatProportionalDockPanel_Coalesced_Move_Back_To_Original_Bounds_Cancels_Pending_Animation() + { + var left = new DocumentDock { Id = "Left", Proportion = 0.5, CollapsedProportion = 0.5 }; + var right = new DocumentDock { Id = "Right", Proportion = 0.5, CollapsedProportion = 0.5 }; + var root = new ProportionalDock + { + Id = "Root", + Orientation = Orientation.Horizontal, + VisibleDockables = new List { left, right } + }; + var panel = new FlatProportionalDockPanel + { + Dock = root, + LayoutTransitionDuration = TimeSpan.FromMilliseconds(100) + }; + var window = ShowPanel(panel); + + try + { + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var leftPresenter = GetLivePresenters(panel) + .Single(presenter => ReferenceEquals(presenter.Content, left)); + var originalBounds = leftPresenter.Bounds; + + root.VisibleDockables = new List { right, left }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + Assert.False(leftPresenter.IsHitTestVisible); + + root.VisibleDockables = new List { left, right }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + Assert.Equal(originalBounds, leftPresenter.Bounds); + Assert.True(leftPresenter.IsHitTestVisible); + + Dispatcher.UIThread.RunJobs(); + + var visual = ElementComposition.GetElementVisual(leftPresenter); + + Assert.NotNull(visual); + Assert.True(leftPresenter.IsHitTestVisible); + Assert.Equal(leftPresenter.Bounds.X, visual.Offset.X, 3); + Assert.Equal(leftPresenter.Bounds.Y, visual.Offset.Y, 3); + Assert.Equal(leftPresenter.Bounds.Width, visual.Size.X, 3); + Assert.Equal(leftPresenter.Bounds.Height, visual.Size.Y, 3); + } + finally + { + window.Close(); + Dispatcher.UIThread.RunJobs(); + } + } + + [AvaloniaFact] + public async Task FlatProportionalDockPanel_Queued_Move_Targets_Arranged_Bounds_Not_Stale_Composition_Offset() + { + var left = new DocumentDock { Id = "Left", Proportion = 0.5, CollapsedProportion = 0.5 }; + var right = new DocumentDock { Id = "Right", Proportion = 0.5, CollapsedProportion = 0.5 }; + var root = new ProportionalDock + { + Id = "Root", + Orientation = Orientation.Horizontal, + VisibleDockables = new List { left, right } + }; + var panel = new FlatProportionalDockPanel + { + Dock = root, + LayoutTransitionDuration = TimeSpan.FromMilliseconds(1000) + }; + var window = ShowPanel(panel); + + try + { + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var leftPresenter = GetLivePresenters(panel) + .Single(presenter => ReferenceEquals(presenter.Content, left)); + var visual = ElementComposition.GetElementVisual(leftPresenter); + + Assert.NotNull(visual); + + root.VisibleDockables = new List { right, left }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + visual.Offset = new Vector3D(123.0, 45.0, 0.0); + + for (var attempt = 0; attempt < 10 && AreClose(visual.Offset.X, 123.0); attempt++) + { + Dispatcher.UIThread.RunJobs(); + await Task.Delay(20); + } + + Dispatcher.UIThread.RunJobs(); + + Assert.False(leftPresenter.IsHitTestVisible); + Assert.NotEqual(123.0, visual.Offset.X, 3); + Assert.NotEqual(45.0, visual.Offset.Y, 3); + } + finally + { + window.Close(); + Dispatcher.UIThread.RunJobs(); + } + } + + [AvaloniaFact] + public void FlatProportionalDockPanel_Moved_Dockable_Between_Parents_Animates_Live_Presenter_With_Size_Delta() + { + var left = new DocumentDock { Id = "Left", Proportion = 1.0, CollapsedProportion = 1.0 }; + var right = new DocumentDock { Id = "Right", Proportion = 1.0, CollapsedProportion = 1.0 }; + var leftItems = new ObservableCollection { left }; + var rightItems = new ObservableCollection { right }; + var leftDock = new ProportionalDock + { + Id = "LeftDock", + Orientation = Orientation.Horizontal, + Proportion = 0.3, + CollapsedProportion = 0.3, + VisibleDockables = leftItems + }; + var rightDock = new ProportionalDock + { + Id = "RightDock", + Orientation = Orientation.Horizontal, + Proportion = 0.7, + CollapsedProportion = 0.7, + VisibleDockables = rightItems + }; + var splitter = new ProportionalDockSplitter { Id = "Splitter" }; + var root = new ProportionalDock + { + Id = "Root", + Orientation = Orientation.Horizontal, + VisibleDockables = new List { leftDock, splitter, rightDock } + }; + var panel = new FlatProportionalDockPanel { Dock = root }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var leftPresenter = GetLivePresenters(panel) + .Single(presenter => ReferenceEquals(presenter.Content, left)); + var previousBounds = leftPresenter.Bounds; + + rightItems.Add(left); + leftItems.Remove(left); + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + Assert.NotEqual(previousBounds.X, leftPresenter.Bounds.X); + Assert.NotEqual(previousBounds.Width, leftPresenter.Bounds.Width); + Assert.Equal(1.0, leftPresenter.Opacity); + Assert.False(leftPresenter.IsHitTestVisible); + } + + [AvaloniaFact] + public void FlatProportionalDockPanel_Unchanged_Rebuild_Does_Not_Create_Exit_Presenter() + { + var left = new DocumentDock { Id = "Left", Proportion = 0.25, CollapsedProportion = 0.25 }; + var right = new DocumentDock { Id = "Right", Proportion = 0.75, CollapsedProportion = 0.75 }; + var splitter = new ProportionalDockSplitter { Id = "Splitter" }; + var root = new ProportionalDock + { + Id = "Root", + Orientation = Orientation.Horizontal, + VisibleDockables = new List { left, splitter, right } + }; + var panel = new FlatProportionalDockPanel { Dock = root }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var leftPresenter = GetLivePresenters(panel) + .Single(presenter => ReferenceEquals(presenter.Content, left)); + + root.VisibleDockables = new List { left, splitter, right }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + Assert.Same(leftPresenter, GetLivePresenters(panel) + .Single(presenter => ReferenceEquals(presenter.Content, left))); + Assert.Equal(1.0, leftPresenter.Opacity); + Assert.True(leftPresenter.IsHitTestVisible); + } + + [AvaloniaFact] + public void FlatProportionalDockPanel_Removed_Dockable_Keeps_Exit_Presenter() + { + var left = new DocumentDock { Id = "Left", Proportion = 0.25, CollapsedProportion = 0.25 }; + var right = new DocumentDock { Id = "Right", Proportion = 0.75, CollapsedProportion = 0.75 }; + var splitter = new ProportionalDockSplitter { Id = "Splitter" }; + var root = new ProportionalDock + { + Id = "Root", + Orientation = Orientation.Horizontal, + VisibleDockables = new List { left, splitter, right } + }; + var panel = new FlatProportionalDockPanel { Dock = root }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var rightPresenter = GetLivePresenters(panel) + .Single(presenter => ReferenceEquals(presenter.Content, right)); + var previousBounds = rightPresenter.Bounds; + + root.VisibleDockables = new List { left }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var exitingPresenter = GetLivePresenters(panel) + .Single(presenter => ReferenceEquals(presenter.Content, right)); + + Assert.Same(rightPresenter, exitingPresenter); + Assert.Same(right, exitingPresenter.Content); + Assert.Equal(previousBounds, exitingPresenter.Bounds); + Assert.False(exitingPresenter.IsHitTestVisible); + } + + [AvaloniaFact] + public async Task FlatProportionalDockPanel_Removed_Dockable_Keeps_Remaining_Presenter_Interactive() + { + var left = new DocumentDock { Id = "Left", Proportion = 0.5, CollapsedProportion = 0.5 }; + var right = new DocumentDock { Id = "Right", Proportion = 0.5, CollapsedProportion = 0.5 }; + var splitter = new ProportionalDockSplitter { Id = "Splitter" }; + var root = new ProportionalDock + { + Id = "Root", + Orientation = Orientation.Horizontal, + VisibleDockables = new List { left, splitter, right } + }; + var panel = new FlatProportionalDockPanel + { + Dock = root, + LayoutTransitionDuration = TimeSpan.FromMilliseconds(20) + }; + var window = ShowPanel(panel); + + try + { + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var leftPresenter = GetLivePresenters(panel) + .Single(presenter => ReferenceEquals(presenter.Content, left)); + var previousBounds = leftPresenter.Bounds; + var visual = ElementComposition.GetElementVisual(leftPresenter); + + Assert.NotNull(visual); + + root.VisibleDockables = new List { left }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + Assert.NotEqual(previousBounds.Width, leftPresenter.Bounds.Width); + Assert.True(leftPresenter.IsHitTestVisible); + Assert.Equal(1.0, leftPresenter.Opacity); + Assert.Equal(leftPresenter.Bounds.X, visual.Offset.X, 3); + Assert.Equal(leftPresenter.Bounds.Y, visual.Offset.Y, 3); + Assert.Equal(leftPresenter.Bounds.Width, visual.Size.X, 3); + Assert.Equal(leftPresenter.Bounds.Height, visual.Size.Y, 3); + Assert.Same(right, GetLivePresenters(panel) + .Single(presenter => ReferenceEquals(presenter.Content, right)).Content); + + Dispatcher.UIThread.RunJobs(); + + Assert.True(leftPresenter.IsHitTestVisible); + + await Task.Delay(120); + Dispatcher.UIThread.RunJobs(); + + Assert.True(leftPresenter.IsHitTestVisible); + Assert.DoesNotContain(GetLivePresenters(panel), presenter => ReferenceEquals(presenter.Content, right)); + } + finally + { + window.Close(); + Dispatcher.UIThread.RunJobs(); + } + } + + [AvaloniaFact] + public async Task FlatProportionalDockPanel_Reinserted_Dockable_Cancels_Exit_And_Reuses_Presenter() + { + var left = new DocumentDock { Id = "Left", Proportion = 0.5, CollapsedProportion = 0.5 }; + var right = new DocumentDock { Id = "Right", Proportion = 0.5, CollapsedProportion = 0.5 }; + var splitter = new ProportionalDockSplitter { Id = "Splitter" }; + var root = new ProportionalDock + { + Id = "Root", + Orientation = Orientation.Horizontal, + VisibleDockables = new List { left, splitter, right } + }; + var panel = new FlatProportionalDockPanel + { + Dock = root, + LayoutTransitionDuration = TimeSpan.FromMilliseconds(100) + }; + var window = ShowPanel(panel); + + try + { + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var rightPresenter = GetLivePresenters(panel) + .Single(presenter => ReferenceEquals(presenter.Content, right)); + + left.Proportion = 0.7; + left.CollapsedProportion = 0.7; + right.Proportion = 0.3; + right.CollapsedProportion = 0.3; + root.VisibleDockables = new List { left, splitter, right }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + Dispatcher.UIThread.RunJobs(); + + Assert.False(rightPresenter.IsHitTestVisible); + Assert.Same(rightPresenter, GetLivePresenters(panel) + .Single(presenter => ReferenceEquals(presenter.Content, right))); + + root.VisibleDockables = new List { left, splitter, right }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + Dispatcher.UIThread.RunJobs(); + + var reinsertedRightPresenter = GetLivePresenters(panel) + .Single(presenter => ReferenceEquals(presenter.Content, right)); + + Assert.Same(rightPresenter, reinsertedRightPresenter); + Assert.Equal(1.0, reinsertedRightPresenter.Opacity); + Assert.False(reinsertedRightPresenter.IsHitTestVisible); + + await Task.Delay(220); + Dispatcher.UIThread.RunJobs(); + + Assert.Same(rightPresenter, GetLivePresenters(panel) + .Single(presenter => ReferenceEquals(presenter.Content, right))); + Assert.True(reinsertedRightPresenter.IsHitTestVisible); + } + finally + { + window.Close(); + Dispatcher.UIThread.RunJobs(); + } + } + + [AvaloniaFact] + public async Task FlatProportionalDockPanel_Inserted_Dockable_Animates_Live_Presenter() + { + var left = new DocumentDock { Id = "Left", Proportion = 1.0, CollapsedProportion = 1.0 }; + var right = new DocumentDock { Id = "Right", Proportion = 0.5, CollapsedProportion = 0.5 }; + var splitter = new ProportionalDockSplitter { Id = "Splitter" }; + var root = new ProportionalDock + { + Id = "Root", + Orientation = Orientation.Horizontal, + VisibleDockables = new List { left } + }; + var panel = new FlatProportionalDockPanel + { + Dock = root, + LayoutTransitionDuration = TimeSpan.FromMilliseconds(20) + }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + root.VisibleDockables = new List { left, splitter, right }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var rightPresenter = GetLivePresenters(panel) + .Single(presenter => ReferenceEquals(presenter.Content, right)); + var leftPresenter = GetLivePresenters(panel) + .Single(presenter => ReferenceEquals(presenter.Content, left)); + Assert.Equal(1.0, leftPresenter.Opacity); + Assert.Equal(0.0, rightPresenter.Opacity); + Assert.False(rightPresenter.IsHitTestVisible); + + Dispatcher.UIThread.RunJobs(); + + Assert.Equal(1.0, rightPresenter.Opacity); + Assert.True(rightPresenter.IsHitTestVisible); + + await Task.Delay(60); + Dispatcher.UIThread.RunJobs(); + + Assert.True(rightPresenter.IsHitTestVisible); + } + + [AvaloniaFact] + public async Task FlatProportionalDockPanel_Collection_Insert_Does_Not_Animate_Existing_Sibling() + { + var left = new DocumentDock { Id = "Left", Proportion = 1.0, CollapsedProportion = 1.0 }; + var right = new DocumentDock { Id = "Right", Proportion = 0.5, CollapsedProportion = 0.5 }; + var splitter = new ProportionalDockSplitter { Id = "Splitter" }; + var visibleDockables = new ObservableCollection { left }; + var root = new ProportionalDock + { + Id = "Root", + Orientation = Orientation.Horizontal, + VisibleDockables = visibleDockables + }; + var panel = new FlatProportionalDockPanel + { + Dock = root, + LayoutTransitionDuration = TimeSpan.FromMilliseconds(40) + }; + var window = ShowPanel(panel); + + try + { + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var leftPresenter = GetLivePresenters(panel) + .Single(presenter => ReferenceEquals(presenter.Content, left)); + var previousBounds = leftPresenter.Bounds; + + visibleDockables.Add(splitter); + visibleDockables.Add(right); + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var rightPresenter = GetLivePresenters(panel) + .Single(presenter => ReferenceEquals(presenter.Content, right)); + + Assert.NotEqual(previousBounds.Width, leftPresenter.Bounds.Width); + Assert.True(leftPresenter.IsHitTestVisible); + Assert.Equal(1.0, leftPresenter.Opacity); + Assert.Equal(0.0, rightPresenter.Opacity); + Assert.False(rightPresenter.IsHitTestVisible); + + Dispatcher.UIThread.RunJobs(); + + Assert.True(leftPresenter.IsHitTestVisible); + + await Task.Delay(120); + Dispatcher.UIThread.RunJobs(); + + Assert.True(leftPresenter.IsHitTestVisible); + Assert.True(rightPresenter.IsHitTestVisible); + } + finally + { + window.Close(); + Dispatcher.UIThread.RunJobs(); + } + } + + [AvaloniaFact] + public void FlatProportionalDockPanel_Scoped_Insert_Normalizes_Previous_Excluded_Active_Animation() + { + var left = new DocumentDock { Id = "Left", Proportion = 0.5, CollapsedProportion = 0.5 }; + var right = new DocumentDock { Id = "Right", Proportion = 0.5, CollapsedProportion = 0.5 }; + var center = new DocumentDock { Id = "Center", Proportion = 0.33, CollapsedProportion = 0.33 }; + var splitter = new ProportionalDockSplitter { Id = "Splitter" }; + var visibleDockables = new ObservableCollection { left, right }; + var root = new ProportionalDock + { + Id = "Root", + Orientation = Orientation.Horizontal, + VisibleDockables = visibleDockables + }; + var panel = new FlatProportionalDockPanel + { + Dock = root, + LayoutTransitionDuration = TimeSpan.FromMilliseconds(1000) + }; + var window = ShowPanel(panel); + + try + { + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var leftPresenter = GetLivePresenters(panel) + .Single(presenter => ReferenceEquals(presenter.Content, left)); + + visibleDockables.Move(0, 1); + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + Dispatcher.UIThread.RunJobs(); + + Assert.False(leftPresenter.IsHitTestVisible); + + visibleDockables.Insert(1, splitter); + visibleDockables.Insert(2, center); + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var centerPresenter = GetLivePresenters(panel) + .Single(presenter => ReferenceEquals(presenter.Content, center)); + var visual = ElementComposition.GetElementVisual(leftPresenter); + + Assert.NotNull(visual); + Assert.True(leftPresenter.IsHitTestVisible); + Assert.Equal(1.0, leftPresenter.Opacity); + Assert.Equal(leftPresenter.Bounds.X, visual.Offset.X, 3); + Assert.Equal(leftPresenter.Bounds.Y, visual.Offset.Y, 3); + Assert.Equal(leftPresenter.Bounds.Width, visual.Size.X, 3); + Assert.Equal(leftPresenter.Bounds.Height, visual.Size.Y, 3); + Assert.Equal(0.0, centerPresenter.Opacity); + Assert.False(centerPresenter.IsHitTestVisible); + } + finally + { + window.Close(); + Dispatcher.UIThread.RunJobs(); + } + } + + [AvaloniaFact] + public async Task FlatProportionalDockPanel_Active_Insert_Retargets_Layout_Without_Early_HitTesting() + { + var left = new DocumentDock { Id = "Left", Proportion = 1.0, CollapsedProportion = 1.0 }; + var right = new DocumentDock { Id = "Right", Proportion = 0.5, CollapsedProportion = 0.5 }; + var splitter = new ProportionalDockSplitter { Id = "Splitter" }; + var root = new ProportionalDock + { + Id = "Root", + Orientation = Orientation.Horizontal, + VisibleDockables = new List { left } + }; + var panel = new FlatProportionalDockPanel + { + Dock = root, + LayoutTransitionDuration = TimeSpan.FromMilliseconds(100) + }; + var window = ShowPanel(panel); + + try + { + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + root.VisibleDockables = new List { left, splitter, right }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + Dispatcher.UIThread.RunJobs(); + + var rightPresenter = GetLivePresenters(panel) + .Single(presenter => ReferenceEquals(presenter.Content, right)); + var insertedBounds = rightPresenter.Bounds; + + Assert.False(rightPresenter.IsHitTestVisible); + + await Task.Delay(70); + Dispatcher.UIThread.RunJobs(); + + root.VisibleDockables = new List { right, splitter, left }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + Dispatcher.UIThread.RunJobs(); + + Assert.NotEqual(insertedBounds.X, rightPresenter.Bounds.X); + Assert.False(rightPresenter.IsHitTestVisible); + + await Task.Delay(70); + Dispatcher.UIThread.RunJobs(); + + Assert.False(rightPresenter.IsHitTestVisible); + + await Task.Delay(140); + Dispatcher.UIThread.RunJobs(); + + var visual = ElementComposition.GetElementVisual(rightPresenter); + + Assert.NotNull(visual); + Assert.True(rightPresenter.IsHitTestVisible); + Assert.Equal(rightPresenter.Bounds.X, visual.Offset.X, 3); + Assert.Equal(rightPresenter.Bounds.Y, visual.Offset.Y, 3); + Assert.Equal(rightPresenter.Bounds.Width, visual.Size.X, 3); + Assert.Equal(rightPresenter.Bounds.Height, visual.Size.Y, 3); + Assert.Equal(1.0f, visual.Opacity, 3); + } + finally + { + window.Close(); + Dispatcher.UIThread.RunJobs(); + } + } + + [AvaloniaFact] + public async Task FlatProportionalDockPanel_Pending_Insert_Retargets_Layout_Without_Flash() + { + var left = new DocumentDock { Id = "Left", Proportion = 1.0, CollapsedProportion = 1.0 }; + var right = new DocumentDock { Id = "Right", Proportion = 0.5, CollapsedProportion = 0.5 }; + var splitter = new ProportionalDockSplitter { Id = "Splitter" }; + var root = new ProportionalDock + { + Id = "Root", + Orientation = Orientation.Horizontal, + VisibleDockables = new List { left } + }; + var panel = new FlatProportionalDockPanel + { + Dock = root, + LayoutTransitionDuration = TimeSpan.FromMilliseconds(100) + }; + var window = ShowPanel(panel); + + try + { + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + root.VisibleDockables = new List { left, splitter, right }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var rightPresenter = GetLivePresenters(panel) + .Single(presenter => ReferenceEquals(presenter.Content, right)); + var insertedBounds = rightPresenter.Bounds; + + Assert.Equal(0.0, rightPresenter.Opacity); + Assert.False(rightPresenter.IsHitTestVisible); + + root.VisibleDockables = new List { right, splitter, left }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + Assert.NotEqual(insertedBounds.X, rightPresenter.Bounds.X); + Assert.Equal(0.0, rightPresenter.Opacity); + Assert.False(rightPresenter.IsHitTestVisible); + + Dispatcher.UIThread.RunJobs(); + + Assert.False(rightPresenter.IsHitTestVisible); + + await Task.Delay(240); + Dispatcher.UIThread.RunJobs(); + + var visual = ElementComposition.GetElementVisual(rightPresenter); + + Assert.NotNull(visual); + Assert.True(rightPresenter.IsHitTestVisible); + Assert.Equal(1.0, rightPresenter.Opacity); + Assert.Equal(rightPresenter.Bounds.X, visual.Offset.X, 3); + Assert.Equal(rightPresenter.Bounds.Y, visual.Offset.Y, 3); + Assert.Equal(rightPresenter.Bounds.Width, visual.Size.X, 3); + Assert.Equal(rightPresenter.Bounds.Height, visual.Size.Y, 3); + } + finally + { + window.Close(); + Dispatcher.UIThread.RunJobs(); + } + } + + [AvaloniaFact] + public async Task FlatProportionalDockPanel_Pending_Insert_Retarget_Starts_Connected_Layout_Animation() + { + var left = new DocumentDock { Id = "Left", Proportion = 1.0, CollapsedProportion = 1.0 }; + var right = new DocumentDock { Id = "Right", Proportion = 0.5, CollapsedProportion = 0.5 }; + var splitter = new ProportionalDockSplitter { Id = "Splitter" }; + var root = new ProportionalDock + { + Id = "Root", + Orientation = Orientation.Horizontal, + VisibleDockables = new List { left } + }; + var panel = new FlatProportionalDockPanel + { + Dock = root, + LayoutTransitionDuration = TimeSpan.FromMilliseconds(1000) + }; + var window = ShowPanel(panel); + + try + { + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + root.VisibleDockables = new List { left, splitter, right }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var rightPresenter = GetLivePresenters(panel) + .Single(presenter => ReferenceEquals(presenter.Content, right)); + + root.VisibleDockables = new List { right, splitter, left }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var visual = ElementComposition.GetElementVisual(rightPresenter); + + Assert.NotNull(visual); + + visual.Offset = new Vector3D(123.0, 45.0, 0.0); + + for (var attempt = 0; attempt < 10 && AreClose(visual.Offset.X, 123.0); attempt++) + { + Dispatcher.UIThread.RunJobs(); + await Task.Delay(20); + } + + Dispatcher.UIThread.RunJobs(); + + Assert.False(rightPresenter.IsHitTestVisible); + Assert.NotEqual(123.0, visual.Offset.X, 3); + Assert.NotEqual(45.0, visual.Offset.Y, 3); + } + finally + { + window.Close(); + Dispatcher.UIThread.RunJobs(); + } + } + + [AvaloniaFact] + public async Task FlatProportionalDockPanel_Inserted_Dockable_Keeps_Existing_Presenter_Interactive() + { + var left = new DocumentDock { Id = "Left", Proportion = 1.0, CollapsedProportion = 1.0 }; + var right = new DocumentDock { Id = "Right", Proportion = 0.5, CollapsedProportion = 0.5 }; + var splitter = new ProportionalDockSplitter { Id = "Splitter" }; + var root = new ProportionalDock + { + Id = "Root", + Orientation = Orientation.Horizontal, + VisibleDockables = new List { left } + }; + var panel = new FlatProportionalDockPanel + { + Dock = root, + LayoutTransitionDuration = TimeSpan.FromMilliseconds(20) + }; + var window = ShowPanel(panel); + + try + { + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var leftPresenter = GetLivePresenters(panel) + .Single(presenter => ReferenceEquals(presenter.Content, left)); + var previousBounds = leftPresenter.Bounds; + var visual = ElementComposition.GetElementVisual(leftPresenter); + + Assert.NotNull(visual); + + root.VisibleDockables = new List { left, splitter, right }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var rightPresenter = GetLivePresenters(panel) + .Single(presenter => ReferenceEquals(presenter.Content, right)); + + Assert.NotEqual(previousBounds.Width, leftPresenter.Bounds.Width); + Assert.True(leftPresenter.IsHitTestVisible); + Assert.Equal(1.0, leftPresenter.Opacity); + Assert.Equal(leftPresenter.Bounds.X, visual.Offset.X, 3); + Assert.Equal(leftPresenter.Bounds.Y, visual.Offset.Y, 3); + Assert.Equal(leftPresenter.Bounds.Width, visual.Size.X, 3); + Assert.Equal(leftPresenter.Bounds.Height, visual.Size.Y, 3); + Assert.Equal(0.0, rightPresenter.Opacity); + Assert.False(rightPresenter.IsHitTestVisible); + + Dispatcher.UIThread.RunJobs(); + + Assert.True(leftPresenter.IsHitTestVisible); + + await Task.Delay(120); + Dispatcher.UIThread.RunJobs(); + + Assert.True(leftPresenter.IsHitTestVisible); + Assert.True(rightPresenter.IsHitTestVisible); + } + finally + { + window.Close(); + Dispatcher.UIThread.RunJobs(); + } + } + + [AvaloniaFact] + public void FlatProportionalDockPanel_LayoutResize_Animates_Size_Not_Content_Scale() + { + var left = new DocumentDock { Id = "Left", Proportion = 0.5, CollapsedProportion = 0.5 }; + var right = new DocumentDock { Id = "Right", Proportion = 0.5, CollapsedProportion = 0.5 }; + var splitter = new ProportionalDockSplitter { Id = "Splitter" }; + var root = new ProportionalDock + { + Id = "Root", + Orientation = Orientation.Horizontal, + VisibleDockables = new List { left, splitter, right } + }; + var panel = new FlatProportionalDockPanel + { + Dock = root, + LayoutTransitionDuration = TimeSpan.FromMilliseconds(100) + }; + var window = ShowPanel(panel); + + try + { + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var leftPresenter = GetLivePresenters(panel) + .Single(presenter => ReferenceEquals(presenter.Content, left)); + var previousBounds = leftPresenter.Bounds; + + left.Proportion = 0.7; + left.CollapsedProportion = 0.7; + right.Proportion = 0.3; + right.CollapsedProportion = 0.3; + root.VisibleDockables = new List { left, splitter, right }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + Dispatcher.UIThread.RunJobs(); + + var visual = ElementComposition.GetElementVisual(leftPresenter); + + Assert.NotEqual(previousBounds.Width, leftPresenter.Bounds.Width); + Assert.NotNull(visual); + Assert.Equal(1.0, visual.Scale.X, 3); + Assert.Equal(1.0, visual.Scale.Y, 3); + Assert.Equal(1.0, visual.Scale.Z, 3); + Assert.False(leftPresenter.IsHitTestVisible); + } + finally + { + window.Close(); + Dispatcher.UIThread.RunJobs(); + } + } + + [AvaloniaFact] + public void FlatProportionalDockPanel_Disabling_Transitions_Cancels_Active_Layout_Animation() + { + var left = new DocumentDock { Id = "Left", Proportion = 0.5, CollapsedProportion = 0.5 }; + var right = new DocumentDock { Id = "Right", Proportion = 0.5, CollapsedProportion = 0.5 }; + var visibleDockables = new ObservableCollection { left, right }; + var root = new ProportionalDock + { + Id = "Root", + Orientation = Orientation.Horizontal, + VisibleDockables = visibleDockables + }; + var panel = new FlatProportionalDockPanel + { + Dock = root, + LayoutTransitionDuration = TimeSpan.FromMilliseconds(100) + }; + var window = ShowPanel(panel); + + try + { + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var leftPresenter = GetLivePresenters(panel) + .Single(presenter => ReferenceEquals(presenter.Content, left)); + + visibleDockables.Insert(2, left); + visibleDockables.RemoveAt(0); + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + Dispatcher.UIThread.RunJobs(); + + Assert.False(leftPresenter.IsHitTestVisible); + + panel.UseLayoutTransitions = false; + Dispatcher.UIThread.RunJobs(); + + Assert.True(leftPresenter.IsHitTestVisible); + Assert.Equal(1.0, leftPresenter.Opacity); + } + finally + { + window.Close(); + Dispatcher.UIThread.RunJobs(); + } + } + + [AvaloniaFact] + public void FlatProportionalDockPanel_Disabling_Transitions_Normalizes_Composition_Offset() + { + var left = new DocumentDock { Id = "Left", Proportion = 0.5, CollapsedProportion = 0.5 }; + var right = new DocumentDock { Id = "Right", Proportion = 0.5, CollapsedProportion = 0.5 }; + var root = new ProportionalDock + { + Id = "Root", + Orientation = Orientation.Horizontal, + VisibleDockables = new List { left, right } + }; + var panel = new FlatProportionalDockPanel + { + Dock = root, + LayoutTransitionDuration = TimeSpan.FromMilliseconds(100) + }; + var window = ShowPanel(panel); + + try + { + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var rightPresenter = GetLivePresenters(panel) + .Single(presenter => ReferenceEquals(presenter.Content, right)); + var visual = ElementComposition.GetElementVisual(rightPresenter); + + Assert.NotNull(visual); + + visual.Offset = new Vector3D(12.0, 34.0, 0.0); + + panel.UseLayoutTransitions = false; + Dispatcher.UIThread.RunJobs(); + + Assert.Equal(rightPresenter.Bounds.X, visual.Offset.X, 3); + Assert.Equal(rightPresenter.Bounds.Y, visual.Offset.Y, 3); + } + finally + { + window.Close(); + Dispatcher.UIThread.RunJobs(); + } + } + + [AvaloniaFact] + public void FlatProportionalDockPanel_Disabling_Transitions_Normalizes_Splitter_Composition_Offset() + { + var left = new DocumentDock { Id = "Left", Proportion = 0.5, CollapsedProportion = 0.5 }; + var right = new DocumentDock { Id = "Right", Proportion = 0.5, CollapsedProportion = 0.5 }; + var splitter = new ProportionalDockSplitter { Id = "Splitter" }; + var root = new ProportionalDock + { + Id = "Root", + Orientation = Orientation.Horizontal, + VisibleDockables = new List { left, splitter, right } + }; + var panel = new FlatProportionalDockPanel + { + Dock = root, + LayoutTransitionDuration = TimeSpan.FromMilliseconds(100) + }; + var window = ShowPanel(panel); + + try + { + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var splitterControl = panel.Children + .OfType() + .Single(control => ReferenceEquals(control.Splitter, splitter)); + var visual = ElementComposition.GetElementVisual(splitterControl); + + Assert.NotNull(visual); + + visual.Offset = new Vector3D(12.0, 34.0, 0.0); + + panel.UseLayoutTransitions = false; + Dispatcher.UIThread.RunJobs(); + + Assert.Equal(splitterControl.Bounds.X, visual.Offset.X, 3); + Assert.Equal(splitterControl.Bounds.Y, visual.Offset.Y, 3); + } + finally + { + window.Close(); + Dispatcher.UIThread.RunJobs(); + } + } + + [AvaloniaFact] + public void FlatProportionalDockPanel_Disabling_Transitions_Removes_Pending_Exit_Presenter() + { + var left = new DocumentDock { Id = "Left", Proportion = 0.5, CollapsedProportion = 0.5 }; + var right = new DocumentDock { Id = "Right", Proportion = 0.5, CollapsedProportion = 0.5 }; + var splitter = new ProportionalDockSplitter { Id = "Splitter" }; + var root = new ProportionalDock + { + Id = "Root", + Orientation = Orientation.Horizontal, + VisibleDockables = new List { left, splitter, right } + }; + var panel = new FlatProportionalDockPanel + { + Dock = root, + LayoutTransitionDuration = TimeSpan.FromMilliseconds(100) + }; + var window = ShowPanel(panel); + + try + { + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + root.VisibleDockables = new List { left }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + Dispatcher.UIThread.RunJobs(); + + Assert.Contains(GetLivePresenters(panel), presenter => ReferenceEquals(presenter.Content, right)); + + panel.UseLayoutTransitions = false; + Dispatcher.UIThread.RunJobs(); + + Assert.DoesNotContain(GetLivePresenters(panel), presenter => ReferenceEquals(presenter.Content, right)); + } + finally + { + window.Close(); + Dispatcher.UIThread.RunJobs(); + } + } + + [AvaloniaFact] + public async Task FlatProportionalDockPanel_Inserted_Dockable_Live_Presenter_Remains_Disabled_Until_Transition_Completes() + { + var left = new DocumentDock { Id = "Left", Proportion = 1.0, CollapsedProportion = 1.0 }; + var right = new DocumentDock { Id = "Right", Proportion = 0.5, CollapsedProportion = 0.5 }; + var splitter = new ProportionalDockSplitter { Id = "Splitter" }; + var root = new ProportionalDock + { + Id = "Root", + Orientation = Orientation.Horizontal, + VisibleDockables = new List { left } + }; + var panel = new FlatProportionalDockPanel + { + Dock = root, + LayoutTransitionDuration = TimeSpan.FromMilliseconds(20) + }; + var window = ShowPanel(panel); + + try + { + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + root.VisibleDockables = new List { left, splitter, right }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var rightPresenter = GetLivePresenters(panel) + .Single(presenter => ReferenceEquals(presenter.Content, right)); + Assert.Equal(0.0, rightPresenter.Opacity); + Assert.False(rightPresenter.IsHitTestVisible); + + Dispatcher.UIThread.RunJobs(); + + Assert.Equal(0.0, rightPresenter.Opacity); + Assert.False(rightPresenter.IsHitTestVisible); + + await Task.Delay(120); + Dispatcher.UIThread.RunJobs(); + + Assert.Equal(1.0, rightPresenter.Opacity); + Assert.True(rightPresenter.IsHitTestVisible); + } + finally + { + window.Close(); + Dispatcher.UIThread.RunJobs(); + } + } + + private static IReadOnlyList GetLivePresenters(FlatProportionalDockPanel panel) + { + return panel.Children + .OfType() + .ToList(); + } + + private static Window ShowPanel(FlatProportionalDockPanel panel) + { + var window = new Window + { + Width = 1000, + Height = 600, + Content = panel + }; + + window.Show(); + Dispatcher.UIThread.RunJobs(); + return window; + } + + private static bool AreClose(double left, double right) + { + return Math.Abs(left - right) < 1e-10; + } } From 1d1dde809135b63b3ee0e137844d1aba99c70623 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Mon, 29 Jun 2026 20:21:38 +0200 Subject: [PATCH 07/31] Refine flat dock target motion --- .../Controls/FlatDockTarget.axaml | 269 +++++++++++++++++ .../DockFluentFlatTheme.axaml | 1 + src/Dock.Avalonia/Controls/DockTargetBase.cs | 21 +- .../Controls/DockTargetMotion.cs | 270 ++++++++++++++++++ .../DockTargetTests.cs | 30 ++ .../ThemeDensityControlSizingTests.cs | 95 ++++++ 6 files changed, 683 insertions(+), 3 deletions(-) create mode 100644 src/Dock.Avalonia.Themes.Fluent/Controls/FlatDockTarget.axaml create mode 100644 src/Dock.Avalonia/Controls/DockTargetMotion.cs diff --git a/src/Dock.Avalonia.Themes.Fluent/Controls/FlatDockTarget.axaml b/src/Dock.Avalonia.Themes.Fluent/Controls/FlatDockTarget.axaml new file mode 100644 index 000000000..c697cef6d --- /dev/null +++ b/src/Dock.Avalonia.Themes.Fluent/Controls/FlatDockTarget.axaml @@ -0,0 +1,269 @@ + + + + + + + 2 + 10 + 1 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Dock.Avalonia.Themes.Fluent/DockFluentFlatTheme.axaml b/src/Dock.Avalonia.Themes.Fluent/DockFluentFlatTheme.axaml index 88dc9ee81..8bb83d728 100644 --- a/src/Dock.Avalonia.Themes.Fluent/DockFluentFlatTheme.axaml +++ b/src/Dock.Avalonia.Themes.Fluent/DockFluentFlatTheme.axaml @@ -4,6 +4,7 @@ xmlns:fluent="using:Dock.Avalonia.Themes.Fluent" x:Class="Dock.Avalonia.Themes.Fluent.DockFluentFlatTheme"> + + + + + + + diff --git a/tests/Dock.Controls.Flat.UnitTests/App.axaml b/tests/Dock.Controls.Flat.UnitTests/App.axaml new file mode 100644 index 000000000..d5f802f00 --- /dev/null +++ b/tests/Dock.Controls.Flat.UnitTests/App.axaml @@ -0,0 +1,7 @@ + + + + + diff --git a/tests/Dock.Controls.Flat.UnitTests/App.axaml.cs b/tests/Dock.Controls.Flat.UnitTests/App.axaml.cs new file mode 100644 index 000000000..9d7cd92d1 --- /dev/null +++ b/tests/Dock.Controls.Flat.UnitTests/App.axaml.cs @@ -0,0 +1,16 @@ +using Avalonia; +using Avalonia.Markup.Xaml; + +namespace Dock.Controls.Flat.UnitTests; + +public partial class App : Application +{ + public override void Initialize() + { +#if DOCK_USE_GENERATED_APP_INITIALIZE_COMPONENT + InitializeComponent(); +#else + AvaloniaXamlLoader.Load(this); +#endif + } +} diff --git a/tests/Dock.Controls.Flat.UnitTests/Dock.Controls.Flat.UnitTests.csproj b/tests/Dock.Controls.Flat.UnitTests/Dock.Controls.Flat.UnitTests.csproj new file mode 100644 index 000000000..eab291eb8 --- /dev/null +++ b/tests/Dock.Controls.Flat.UnitTests/Dock.Controls.Flat.UnitTests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + Library + False + True + False + True + enable + + + + + + + + + + + + + + + + diff --git a/tests/Dock.Controls.Flat.UnitTests/FlatProportionalPanelTests.cs b/tests/Dock.Controls.Flat.UnitTests/FlatProportionalPanelTests.cs new file mode 100644 index 000000000..95765316b --- /dev/null +++ b/tests/Dock.Controls.Flat.UnitTests/FlatProportionalPanelTests.cs @@ -0,0 +1,208 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Presenters; +using Avalonia.Headless.XUnit; +using Avalonia.Layout; +using Xunit; + +namespace Dock.Controls.Flat.UnitTests; + +public class FlatProportionalPanelTests +{ + [AvaloniaFact] + public void FlatProportionalPanel_Flattens_InterfaceTree() + { + var left = new TestItem("Left", 0.25); + var top = new TestItem("Top", 0.6); + var bottom = new TestItem("Bottom", 0.4); + var rootSplitter = new TestSplitter("RootSplitter"); + var innerSplitter = new TestSplitter("InnerSplitter"); + var inner = new TestDock( + "Inner", + Orientation.Vertical, + 0.75, + new IFlatProportionalItem[] { top, innerSplitter, bottom }); + var root = new TestDock( + "Root", + Orientation.Horizontal, + 1.0, + new IFlatProportionalItem[] { left, rootSplitter, inner }); + var panel = new FlatProportionalPanel { Root = root }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var presenters = panel.Children.OfType().ToList(); + var splitters = panel.Children.OfType().ToList(); + var surfaces = panel.Children.OfType().ToList(); + + Assert.Equal(3, presenters.Count); + Assert.Equal(2, splitters.Count); + Assert.Equal(2, surfaces.Count); + Assert.Contains(presenters, presenter => ReferenceEquals(presenter.Content, left.Content)); + Assert.Contains(presenters, presenter => ReferenceEquals(presenter.Content, top.Content)); + Assert.Contains(presenters, presenter => ReferenceEquals(presenter.Content, bottom.Content)); + } + + [AvaloniaFact] + public void ResizeSplitter_Updates_Adjacent_ItemProportions() + { + var left = new TestItem("Left", 0.25); + var right = new TestItem("Right", 0.75); + var splitter = new TestSplitter("Splitter"); + var root = new TestDock( + "Root", + Orientation.Horizontal, + 1.0, + new IFlatProportionalItem[] { left, splitter, right }); + var panel = new FlatProportionalPanel { Root = root }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var splitterControl = panel.Children + .OfType() + .Single(control => ReferenceEquals(control.Splitter, splitter)); + + panel.ResizeSplitter(splitterControl, 100); + + Assert.Equal(0.35, left.Proportion, 2); + Assert.Equal(0.65, right.Proportion, 2); + Assert.Equal(left.Proportion, left.CollapsedProportion); + Assert.Equal(right.Proportion, right.CollapsedProportion); + } + + [AvaloniaFact] + public void CollectionChange_Rebuilds_FlatChildren() + { + var left = new TestItem("Left", 0.5); + var right = new TestItem("Right", 0.5); + var splitter = new TestSplitter("Splitter"); + var items = new ObservableCollection { left, splitter, right }; + var root = new TestDock("Root", Orientation.Horizontal, 1.0, items); + var panel = new FlatProportionalPanel + { + Root = root, + UseLayoutTransitions = false + }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + items.Remove(right); + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var presenters = panel.Children.OfType().ToList(); + + Assert.Single(presenters); + Assert.Same(left.Content, presenters[0].Content); + } + + [AvaloniaFact] + public void Rebuild_Reuses_Visuals_By_ItemKey() + { + var firstLeft = new TestItem("Left", 0.5); + var firstRight = new TestItem("Right", 0.5); + var firstRoot = new TestDock( + "Root", + Orientation.Horizontal, + 1.0, + new IFlatProportionalItem[] { firstLeft, new TestSplitter("Splitter"), firstRight }); + var panel = new FlatProportionalPanel + { + Root = firstRoot, + UseLayoutTransitions = false + }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var firstPresenter = panel.Children + .OfType() + .Single(presenter => ReferenceEquals(presenter.Content, firstLeft.Content)); + var secondLeft = new TestItem("Left", 0.4); + var secondRight = new TestItem("Right", 0.6); + var secondRoot = new TestDock( + "Root", + Orientation.Horizontal, + 1.0, + new IFlatProportionalItem[] { secondLeft, new TestSplitter("Splitter"), secondRight }); + + panel.Root = secondRoot; + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var reusedPresenter = panel.Children + .OfType() + .Single(presenter => ReferenceEquals(presenter.Content, secondLeft.Content)); + + Assert.Same(firstPresenter, reusedPresenter); + } + + private class TestItem : IFlatProportionalItem + { + public TestItem(string id, double proportion) + { + Key = id; + Content = new TextBlock { Text = id }; + Proportion = proportion; + CollapsedProportion = proportion; + } + + public object Key { get; } + + public object? Content { get; } + + public double Proportion { get; set; } + + public double CollapsedProportion { get; set; } + + public double MinWidth => 0; + + public double MinHeight => 0; + + public double MaxWidth => double.PositiveInfinity; + + public double MaxHeight => double.PositiveInfinity; + + public bool IsCollapsable => false; + + public bool IsEmpty => false; + } + + private sealed class TestDock : TestItem, IFlatProportionalDock + { + private readonly IList _visibleItems; + + public TestDock( + string id, + Orientation orientation, + double proportion, + IList visibleItems) + : base(id, proportion) + { + Orientation = orientation; + _visibleItems = visibleItems; + } + + public Orientation Orientation { get; } + + public IList? VisibleItems => _visibleItems; + } + + private sealed class TestSplitter : TestItem, IFlatProportionalSplitter + { + public TestSplitter(string id) + : base(id, 0) + { + } + + public bool CanResize => true; + + public bool ResizePreview => false; + } +} diff --git a/tests/Dock.Controls.Flat.UnitTests/TestAppBuilder.cs b/tests/Dock.Controls.Flat.UnitTests/TestAppBuilder.cs new file mode 100644 index 000000000..4f1a15b45 --- /dev/null +++ b/tests/Dock.Controls.Flat.UnitTests/TestAppBuilder.cs @@ -0,0 +1,13 @@ +using Avalonia; +using Avalonia.Headless; + +[assembly: AvaloniaTestApplication(typeof(Dock.Controls.Flat.UnitTests.TestAppBuilder))] + +namespace Dock.Controls.Flat.UnitTests; + +public class TestAppBuilder +{ + public static AppBuilder BuildAvaloniaApp() => + AppBuilder.Configure() + .UseHeadless(new AvaloniaHeadlessPlatformOptions()); +} From fa1f1aea04b029ec0325ff74bb7436e30520d895 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 30 Jun 2026 00:10:50 +0200 Subject: [PATCH 11/31] Wire flat controls into Dock Avalonia --- .../Dock.Avalonia.Themes.Fluent.csproj | 1 + .../Dock.Avalonia.Themes.Simple.csproj | 1 + .../Controls/FlatProportionalDockPanel.cs | 2499 +---------------- .../Controls/FlatProportionalDockSplitter.cs | 267 +- .../FlatProportionalSplitterPreviewAdorner.cs | 68 - src/Dock.Avalonia/Dock.Avalonia.csproj | 1 + .../Internal/DockFlatProportionalAdapter.cs | 316 +++ .../Dock.Avalonia.HeadlessTests.csproj | 1 + 8 files changed, 348 insertions(+), 2806 deletions(-) delete mode 100644 src/Dock.Avalonia/Controls/FlatProportionalSplitterPreviewAdorner.cs create mode 100644 src/Dock.Avalonia/Internal/DockFlatProportionalAdapter.cs diff --git a/src/Dock.Avalonia.Themes.Fluent/Dock.Avalonia.Themes.Fluent.csproj b/src/Dock.Avalonia.Themes.Fluent/Dock.Avalonia.Themes.Fluent.csproj index 837637611..4f1dd9b46 100644 --- a/src/Dock.Avalonia.Themes.Fluent/Dock.Avalonia.Themes.Fluent.csproj +++ b/src/Dock.Avalonia.Themes.Fluent/Dock.Avalonia.Themes.Fluent.csproj @@ -27,6 +27,7 @@ + diff --git a/src/Dock.Avalonia.Themes.Simple/Dock.Avalonia.Themes.Simple.csproj b/src/Dock.Avalonia.Themes.Simple/Dock.Avalonia.Themes.Simple.csproj index a7ec81ce3..62504034c 100644 --- a/src/Dock.Avalonia.Themes.Simple/Dock.Avalonia.Themes.Simple.csproj +++ b/src/Dock.Avalonia.Themes.Simple/Dock.Avalonia.Themes.Simple.csproj @@ -36,6 +36,7 @@ + diff --git a/src/Dock.Avalonia/Controls/FlatProportionalDockPanel.cs b/src/Dock.Avalonia/Controls/FlatProportionalDockPanel.cs index 566cc9e35..1bc71c7fb 100644 --- a/src/Dock.Avalonia/Controls/FlatProportionalDockPanel.cs +++ b/src/Dock.Avalonia/Controls/FlatProportionalDockPanel.cs @@ -1,70 +1,24 @@ // Copyright (c) Wiesław Šoltés. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for details. -using System; -using System.Collections.Generic; -using System.Collections.Specialized; -using System.ComponentModel; -using System.Threading.Tasks; using Avalonia; -using Avalonia.Animation.Easings; using Avalonia.Controls; -using Avalonia.Controls.Presenters; using Avalonia.Data; using Avalonia.Media; -using Avalonia.Rendering.Composition; -using Avalonia.Rendering.Composition.Animations; -using Avalonia.Threading; -using Avalonia.VisualTree; +using Dock.Avalonia.Internal; +using Dock.Controls.Flat; using Dock.Model.Controls; using Dock.Model.Core; using Dock.Settings; -using AvaloniaOrientation = Avalonia.Layout.Orientation; -using DockOrientation = Dock.Model.Core.Orientation; namespace Dock.Avalonia.Controls; /// -/// Presents a proportional dock tree as a flat set of direct child visuals. +/// Presents a Dock proportional model tree through the reusable flat proportional panel. /// -public class FlatProportionalDockPanel : Panel +public class FlatProportionalDockPanel : FlatProportionalPanel { - private static readonly CubicEaseOut s_layoutTransitionEasing = new(); - private static readonly TimeSpan s_transitionCompletionSlack = TimeSpan.FromMilliseconds(16); - - private readonly Dictionary _dockSurfaces = new(ReferenceEqualityComparer.Instance); - private readonly Dictionary _presenters = new(ReferenceEqualityComparer.Instance); - private readonly Dictionary _splitters = new(ReferenceEqualityComparer.Instance); - private readonly Dictionary _dockBounds = new(ReferenceEqualityComparer.Instance); - private readonly Dictionary _connectedStartBounds = new(ReferenceEqualityComparer.Instance); - private readonly Dictionary _pendingConnectedAnimations = new(ReferenceEqualityComparer.Instance); - private readonly Dictionary _connectedAnimationVersions = new(ReferenceEqualityComparer.Instance); - private readonly HashSet _activeConnectedControls = new(ReferenceEqualityComparer.Instance); - private readonly HashSet _enteringPresenters = new(ReferenceEqualityComparer.Instance); - private readonly HashSet _pendingInsertAnimations = new(ReferenceEqualityComparer.Instance); - private readonly Dictionary _insertAnimationVersions = new(ReferenceEqualityComparer.Instance); - private readonly HashSet _activeInsertPresenters = new(ReferenceEqualityComparer.Instance); - private readonly Dictionary _pendingLayoutAnimations = new(ReferenceEqualityComparer.Instance); - private readonly Dictionary _layoutAnimationVersions = new(ReferenceEqualityComparer.Instance); - private readonly HashSet _activeLayoutPresenters = new(ReferenceEqualityComparer.Instance); - private readonly Dictionary _removalPresenterBounds = new(ReferenceEqualityComparer.Instance); - private readonly Dictionary _removalPresentersByDockable = new(ReferenceEqualityComparer.Instance); - private readonly HashSet _pendingRemovalPresenters = new(ReferenceEqualityComparer.Instance); - private readonly HashSet _activeRemovalPresenters = new(ReferenceEqualityComparer.Instance); - private readonly HashSet _layoutActionDockables = new(ReferenceEqualityComparer.Instance); - private readonly HashSet _arrangedChildren = new(ReferenceEqualityComparer.Instance); - private readonly HashSet _propertySubscriptions = new(ReferenceEqualityComparer.Instance); - private readonly HashSet _collectionSubscriptions = new(ReferenceEqualityComparer.Instance); - private Dictionary? _reusableDockSurfaces; - private Dictionary? _reusablePresenters; - private Dictionary? _reusableSplitters; - private bool _isRebuilding; - private bool _isAssigningProportions; - private bool _hasCompletedArrange; - private bool _hasPendingConnectedAnimationPost; - private bool _hasPendingRebuild; - private bool _suppressLayoutTransitions; - private bool _hasScopedLayoutAction; + private readonly DockFlatProportionalAdapter _adapter = new(); /// /// Defines the property. @@ -72,32 +26,6 @@ public class FlatProportionalDockPanel : Panel public static readonly StyledProperty DockProperty = AvaloniaProperty.Register(nameof(Dock)); - /// - /// Defines the property. - /// - public static readonly StyledProperty SplitterThicknessProperty = - AvaloniaProperty.Register(nameof(SplitterThickness), 4.0); - - /// - /// Defines the property. - /// - public static readonly StyledProperty MinimumProportionSizeProperty = - AvaloniaProperty.Register(nameof(MinimumProportionSize), 75.0); - - /// - /// Defines the property. - /// - public static readonly StyledProperty UseLayoutTransitionsProperty = - AvaloniaProperty.Register(nameof(UseLayoutTransitions), true); - - /// - /// Defines the property. - /// - public static readonly StyledProperty LayoutTransitionDurationProperty = - AvaloniaProperty.Register( - nameof(LayoutTransitionDuration), - TimeSpan.FromMilliseconds(240)); - /// /// Gets or sets the root proportional dock to present. /// @@ -107,42 +35,6 @@ public IProportionalDock? Dock set => SetValue(DockProperty, value); } - /// - /// Gets or sets the default thickness assigned to flat splitters. - /// - public double SplitterThickness - { - get => GetValue(SplitterThicknessProperty); - set => SetValue(SplitterThicknessProperty, value); - } - - /// - /// Gets or sets the minimum size a splitter keeps for each adjacent dockable. - /// - public double MinimumProportionSize - { - get => GetValue(MinimumProportionSizeProperty); - set => SetValue(MinimumProportionSizeProperty, value); - } - - /// - /// Gets or sets whether flat child bounds changes should animate on the compositor. - /// - public bool UseLayoutTransitions - { - get => GetValue(UseLayoutTransitionsProperty); - set => SetValue(UseLayoutTransitionsProperty, value); - } - - /// - /// Gets or sets the duration used for flat child bounds animations. - /// - public TimeSpan LayoutTransitionDuration - { - get => GetValue(LayoutTransitionDurationProperty); - set => SetValue(LayoutTransitionDurationProperty, value); - } - /// protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { @@ -150,285 +42,23 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang if (change.Property == DockProperty) { - RequestRebuildVisualTree(); - return; - } - - if (change.Property == SplitterThicknessProperty) - { - UpdateSplitterThickness(); - InvalidateMeasure(); - InvalidateArrange(); - return; - } - - if (change.Property == MinimumProportionSizeProperty) - { - InvalidateMeasure(); - InvalidateArrange(); - return; - } - - if (change.Property == UseLayoutTransitionsProperty) - { - if (change.NewValue is false) - { - CancelLayoutTransitions(removeRemovalPresenters: true); - } - - return; - } - - if (change.Property == LayoutTransitionDurationProperty - && LayoutTransitionDuration <= TimeSpan.Zero) - { - CancelLayoutTransitions(removeRemovalPresenters: true); - } - } - - /// - protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) - { - base.OnDetachedFromVisualTree(e); - - UnsubscribeLayout(); - CancelLayoutTransitions(removeRemovalPresenters: true); - _hasPendingRebuild = false; - } - - /// - protected override Size MeasureOverride(Size availableSize) - { - ExecutePendingRebuild(invalidateLayout: false); - - if (Dock is not { } dock) - { - return default; + Root = _adapter.GetDock(Dock); } - - MeasureDock(dock, availableSize); - MeasureRemovalPresenters(); - return NormalizeDesiredSize(availableSize); } /// - protected override Size ArrangeOverride(Size finalSize) - { - ExecutePendingRebuild(invalidateLayout: false); - TrackStructuralLayoutActionDockables(); - - _arrangedChildren.Clear(); - _dockBounds.Clear(); - - if (Dock is { } dock) - { - ArrangeDock(dock, new Rect(finalSize)); - } - - ArrangeRemovalPresenters(); - - foreach (var child in Children) - { - if (!_arrangedChildren.Contains(child)) - { - child.Arrange(default); - } - } - - _arrangedChildren.Clear(); - _hasCompletedArrange = true; - _suppressLayoutTransitions = false; - RequestConnectedAnimations(); - ClearScopedLayoutAction(); - - return finalSize; - } - - internal void ResizeSplitter(FlatProportionalDockSplitter splitterControl, double dragDelta) - { - if (splitterControl.OwnerDock is not { } ownerDock - || splitterControl.Splitter is not { } splitter - || ownerDock.VisibleDockables is not { } visibleDockables) - { - return; - } - - var splitterIndex = visibleDockables.IndexOf(splitter); - if (splitterIndex < 0) - { - return; - } - - var target = FindResizeSibling(visibleDockables, splitterIndex, -1); - var neighbor = FindResizeSibling(visibleDockables, splitterIndex, 1); - if (target is null || neighbor is null) - { - return; - } - - if (!_dockBounds.TryGetValue(ownerDock, out var ownerBounds)) - { - ownerBounds = new Rect(Bounds.Size); - } - - var availableSize = ownerDock.Orientation == DockOrientation.Vertical - ? ownerBounds.Height - : ownerBounds.Width; - - if (availableSize <= 0 || double.IsNaN(availableSize) || double.IsInfinity(availableSize)) - { - return; - } - - var targetProportion = ResolveValidProportion(target.Proportion, 0.5); - var neighborProportion = ResolveValidProportion(neighbor.Proportion, 0.5); - var deltaProportion = dragDelta / availableSize; - - if (targetProportion + deltaProportion < 0) - { - deltaProportion = -targetProportion; - } - - if (neighborProportion - deltaProportion < 0) - { - deltaProportion = neighborProportion; - } - - var nextTargetProportion = targetProportion + deltaProportion; - var nextNeighborProportion = neighborProportion - deltaProportion; - - ApplyResizeConstraints( - ownerDock.Orientation, - availableSize, - target, - neighbor, - ref nextTargetProportion, - ref nextNeighborProportion); - - ApplyResizeConstraints( - ownerDock.Orientation, - availableSize, - neighbor, - target, - ref nextNeighborProportion, - ref nextTargetProportion); - - SetDockableProportion(target, Math.Max(0, nextTargetProportion), updateCollapsedProportion: true); - SetDockableProportion(neighbor, Math.Max(0, nextNeighborProportion), updateCollapsedProportion: true); - - _suppressLayoutTransitions = true; - InvalidateMeasure(); - InvalidateArrange(); - } - - private void RequestRebuildVisualTree() - { - if (_isRebuilding) - { - return; - } - - _hasPendingRebuild = true; - InvalidateMeasure(); - InvalidateArrange(); - Dispatcher.UIThread.Post(() => ExecutePendingRebuild(invalidateLayout: true), DispatcherPriority.Render); - } - - private void ExecutePendingRebuild(bool invalidateLayout) - { - if (!_hasPendingRebuild || _isRebuilding) - { - return; - } - - _hasPendingRebuild = false; - RebuildVisualTree(invalidateLayout); - } - - private void RebuildVisualTree(bool invalidateLayout = true) - { - if (_isRebuilding) - { - return; - } - - _isRebuilding = true; - try - { - CaptureConnectedStartBounds(); - TrackStructuralLayoutActionDockables(); - UnsubscribeLayout(); - _reusableDockSurfaces = new Dictionary(_dockSurfaces, ReferenceEqualityComparer.Instance); - _reusablePresenters = new Dictionary(_presenters, ReferenceEqualityComparer.Instance); - _reusableSplitters = new Dictionary(_splitters, ReferenceEqualityComparer.Instance); - _dockSurfaces.Clear(); - _presenters.Clear(); - _splitters.Clear(); - _dockBounds.Clear(); - - if (Dock is { } dock) - { - AddDockSurfaces(dock); - AddDockVisuals(dock); - CreateRemovalPresenters(); - RemoveUnusedVisuals(); - AddRemovalPresenters(); - SubscribeLayout(dock); - } - else - { - RemoveUnusedVisuals(); - RemoveRemovalPresenters(); - } - } - finally - { - _reusableDockSurfaces = null; - _reusablePresenters = null; - _reusableSplitters = null; - _isRebuilding = false; - } - - if (invalidateLayout) - { - InvalidateMeasure(); - InvalidateArrange(); - } - } - - private void AddDockSurfaces(IProportionalDock dock) - { - var surface = CreateDockSurface(dock); - _dockSurfaces[dock] = surface; - EnsureSurfaceChild(surface); - - if (dock.VisibleDockables is null) - { - return; - } - - foreach (var dockable in dock.VisibleDockables) - { - if (dockable is IProportionalDock childDock) - { - AddDockSurfaces(childDock); - } - } - } - - private DockableControl CreateDockSurface(IProportionalDock dock) + protected override Control CreateDockSurface(IFlatProportionalDock dock) { - if (_reusableDockSurfaces?.Remove(dock, out var reusableSurface) == true) + if (dock is not DockFlatProportionalAdapter.DockFlatDockAdapter adapter) { - reusableSurface.DataContext = dock; - return reusableSurface; + return base.CreateDockSurface(dock); } var surface = new DockableControl { TrackingMode = TrackingMode.Visible, Background = Brushes.Transparent, - DataContext = dock, + DataContext = adapter.Dock, [DockProperties.IsDropAreaProperty] = true, [DockProperties.IsDockTargetProperty] = true }; @@ -441,2110 +71,19 @@ private DockableControl CreateDockSurface(IProportionalDock dock) return surface; } - private void AddDockVisuals(IProportionalDock dock) - { - if (dock.VisibleDockables is null) - { - return; - } - - foreach (var dockable in dock.VisibleDockables) - { - switch (dockable) - { - case IProportionalDockSplitter splitter: - AddSplitter(dock, splitter); - break; - case IProportionalDock childDock: - AddDockVisuals(childDock); - break; - default: - AddPresenter(dockable); - break; - } - } - } - - private void AddSplitter(IProportionalDock ownerDock, IProportionalDockSplitter splitter) - { - FlatProportionalDockSplitter? reusableSplitter = null; - var reused = _reusableSplitters is not null && _reusableSplitters.Remove(splitter, out reusableSplitter); - var control = reused - ? reusableSplitter! - : new FlatProportionalDockSplitter - { - DataContext = splitter - }; - - control.OwnerDock = ownerDock; - control.Splitter = splitter; - control.Orientation = ToAvaloniaOrientation(ownerDock.Orientation); - control.Thickness = SplitterThickness; - - if (control.DataContext is not IProportionalDockSplitter) - { - control.DataContext = splitter; - } - - if (!reused) - { - control.Bind(FlatProportionalDockSplitter.IsResizingEnabledProperty, new Binding(nameof(IProportionalDockSplitter.CanResize))); - control.Bind(FlatProportionalDockSplitter.PreviewResizeProperty, new Binding(nameof(IProportionalDockSplitter.ResizePreview))); - } - - _splitters[splitter] = control; - EnsureDockVisualChild(control); - } - - private void AddPresenter(IDockable dockable) - { - ContentPresenter? reusablePresenter = null; - var reused = _reusablePresenters is not null && _reusablePresenters.Remove(dockable, out reusablePresenter); - if (!reused && _removalPresentersByDockable.Remove(dockable, out reusablePresenter)) - { - reused = true; - _removalPresenterBounds.Remove(reusablePresenter); - _pendingRemovalPresenters.Remove(reusablePresenter); - _activeRemovalPresenters.Remove(reusablePresenter); - ResetPresenterComposition(reusablePresenter); - } - - var presenter = reused - ? reusablePresenter! - : new ContentPresenter(); - - presenter.Content = dockable; - presenter.DataContext = dockable; - - if (reused) - { - if (_activeInsertPresenters.Contains(presenter)) - { - _enteringPresenters.Remove(presenter); - presenter.Opacity = 1.0; - presenter.IsHitTestVisible = false; - } - else if (_pendingInsertAnimations.Contains(presenter)) - { - presenter.Opacity = 0.0; - presenter.IsHitTestVisible = false; - _enteringPresenters.Add(presenter); - } - else if (_activeLayoutPresenters.Contains(presenter) || _pendingLayoutAnimations.ContainsKey(presenter)) - { - _enteringPresenters.Remove(presenter); - presenter.Opacity = 1.0; - presenter.IsHitTestVisible = false; - } - else - { - _enteringPresenters.Remove(presenter); - presenter.Opacity = 1.0; - presenter.IsHitTestVisible = true; - } - } - else if (_hasCompletedArrange && UseLayoutTransitions) - { - presenter.Opacity = 0.0; - presenter.IsHitTestVisible = false; - _enteringPresenters.Add(presenter); - } - else - { - presenter.Opacity = 1.0; - presenter.IsHitTestVisible = true; - } - - _presenters[dockable] = presenter; - EnsureDockVisualChild(presenter); - } - - private void CreateRemovalPresenters() - { - if (!UseLayoutTransitions - || !_hasCompletedArrange - || _reusablePresenters is null - || _reusablePresenters.Count == 0) - { - return; - } - - var removedPresenters = new List>(_reusablePresenters); - foreach (var kvp in removedPresenters) - { - var dockable = kvp.Key; - var presenter = kvp.Value; - if (!_connectedStartBounds.TryGetValue(presenter, out var bounds) || !HasVisibleSize(bounds)) - { - continue; - } - - _reusablePresenters.Remove(dockable); - _pendingInsertAnimations.Remove(presenter); - _activeInsertPresenters.Remove(presenter); - _insertAnimationVersions.Remove(presenter); - _pendingLayoutAnimations.Remove(presenter); - _layoutAnimationVersions.Remove(presenter); - _activeLayoutPresenters.Remove(presenter); - - _removalPresenterBounds[presenter] = bounds; - _removalPresentersByDockable[dockable] = presenter; - _pendingRemovalPresenters.Add(presenter); - presenter.Opacity = 1.0; - presenter.IsHitTestVisible = false; - } - } - - private void AddRemovalPresenters() - { - foreach (var presenter in _removalPresenterBounds.Keys) - { - if (!Children.Contains(presenter)) - { - Children.Add(presenter); - } - } - } - - private void RemoveUnusedVisuals() - { - if (_reusableDockSurfaces is not null) - { - foreach (var surface in _reusableDockSurfaces.Values) - { - Children.Remove(surface); - surface.DataContext = null; - } - } - - if (_reusableSplitters is not null) - { - foreach (var splitter in _reusableSplitters.Values) - { - Children.Remove(splitter); - ClearConnectedControlState(splitter); - ResetControlComposition(splitter); - splitter.OwnerDock = null; - splitter.Splitter = null; - splitter.DataContext = null; - } - } - - if (_reusablePresenters is null) - { - return; - } - - foreach (var presenter in _reusablePresenters.Values) - { - Children.Remove(presenter); - _enteringPresenters.Remove(presenter); - _pendingInsertAnimations.Remove(presenter); - _activeInsertPresenters.Remove(presenter); - _insertAnimationVersions.Remove(presenter); - _pendingLayoutAnimations.Remove(presenter); - _layoutAnimationVersions.Remove(presenter); - _activeLayoutPresenters.Remove(presenter); - presenter.Content = null; - presenter.DataContext = null; - presenter.Opacity = 1.0; - presenter.IsHitTestVisible = true; - } - } - - private void RemoveRemovalPresenters() - { - var presenters = new List(_removalPresenterBounds.Keys); - foreach (var presenter in presenters) - { - RemoveRemovalPresenter(presenter); - } - } - - private void EnsureSurfaceChild(DockableControl surface) - { - if (Children.Contains(surface)) - { - return; - } - - var index = 0; - while (index < Children.Count && Children[index] is DockableControl) - { - index++; - } - - Children.Insert(index, surface); - } - - private void EnsureDockVisualChild(Control control) - { - if (Children.Contains(control)) - { - return; - } - - var index = Children.Count; - while (index > 0 && IsRemovalPresenter(Children[index - 1])) - { - index--; - } - - Children.Insert(index, control); - } - - private bool IsRemovalPresenter(Control control) - { - return control is ContentPresenter presenter && _removalPresenterBounds.ContainsKey(presenter); - } - - private void SubscribeLayout(IProportionalDock dock) - { - SubscribeDockable(dock); - - if (dock.VisibleDockables is INotifyCollectionChanged collectionChanged - && _collectionSubscriptions.Add(collectionChanged)) - { - collectionChanged.CollectionChanged += VisibleDockablesCollectionChanged; - } - - if (dock.VisibleDockables is null) - { - return; - } - - foreach (var dockable in dock.VisibleDockables) - { - SubscribeDockable(dockable); - - if (dockable is IProportionalDock childDock) - { - SubscribeLayout(childDock); - } - } - } - - private void SubscribeDockable(IDockable dockable) - { - if (dockable is INotifyPropertyChanged propertyChanged - && _propertySubscriptions.Add(propertyChanged)) - { - propertyChanged.PropertyChanged += DockablePropertyChanged; - } - } - - private void UnsubscribeLayout() + /// + protected override FlatProportionalSplitter CreateSplitter( + IFlatProportionalDock ownerDock, + IFlatProportionalSplitter splitter) { - foreach (var propertyChanged in _propertySubscriptions) - { - propertyChanged.PropertyChanged -= DockablePropertyChanged; - } - - foreach (var collectionChanged in _collectionSubscriptions) + return new FlatProportionalDockSplitter { - collectionChanged.CollectionChanged -= VisibleDockablesCollectionChanged; - } - - _propertySubscriptions.Clear(); - _collectionSubscriptions.Clear(); + DataContext = splitter + }; } - private void VisibleDockablesCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) - { - if (_isRebuilding || _isAssigningProportions) - { - return; - } - - TrackLayoutActionDockables(e); - RequestRebuildVisualTree(); - } - - private void DockablePropertyChanged(object? sender, PropertyChangedEventArgs e) - { - if (_isRebuilding || _isAssigningProportions) - { - return; - } - - if (string.IsNullOrEmpty(e.PropertyName) - || e.PropertyName == nameof(IDock.VisibleDockables) - || e.PropertyName == nameof(IProportionalDock.Orientation)) - { - RequestRebuildVisualTree(); - return; - } - - CaptureConnectedStartBounds(); - InvalidateMeasure(); - InvalidateArrange(); - } - - private void UpdateSplitterThickness() - { - foreach (var splitter in _splitters.Values) - { - splitter.Thickness = SplitterThickness; - } - } - - private void MeasureDock(IProportionalDock dock, Size availableSize) - { - if (_dockSurfaces.TryGetValue(dock, out var surface)) - { - surface.Measure(availableSize); - } - - if (dock.VisibleDockables is not { } visibleDockables || visibleDockables.Count == 0) - { - return; - } - - var splitterThickness = GetTotalSplitterThickness(visibleDockables); - AssignProportions(dock, availableSize, splitterThickness); - var availableLength = Math.Max(0, GetLength(availableSize, dock.Orientation) - splitterThickness); - var sumOfFractions = 0.0; - - for (var i = 0; i < visibleDockables.Count; i++) - { - var dockable = visibleDockables[i]; - if (dockable is IProportionalDockSplitter splitter) - { - MeasureSplitter(dock, visibleDockables, i, splitter, availableSize); - continue; - } - - if (IsCollapsed(dockable)) - { - MeasureCollapsed(dockable); - continue; - } - - var length = CalculateDimensionWithConstraints( - dockable, - dock.Orientation, - availableLength, - ResolveValidProportion(dockable.Proportion, 0), - ref sumOfFractions); - - var childSize = CreateChildSize(availableSize, dock.Orientation, length); - if (dockable is IProportionalDock childDock) - { - MeasureDock(childDock, childSize); - } - else if (_presenters.TryGetValue(dockable, out var presenter)) - { - presenter.Measure(childSize); - } - } - } - - private void MeasureSplitter( - IProportionalDock dock, - IList visibleDockables, - int index, - IProportionalDockSplitter splitter, - Size availableSize) - { - if (!_splitters.TryGetValue(splitter, out var splitterControl)) - { - return; - } - - splitterControl.Orientation = ToAvaloniaOrientation(dock.Orientation); - - if (!ShouldUseSplitter(visibleDockables, index)) - { - splitterControl.Measure(default); - return; - } - - var size = dock.Orientation == DockOrientation.Vertical - ? new Size(availableSize.Width, splitterControl.Thickness) - : new Size(splitterControl.Thickness, availableSize.Height); - - splitterControl.Measure(size); - } - - private void MeasureCollapsed(IDockable dockable) - { - switch (dockable) - { - case IProportionalDock dock: - MeasureDock(dock, default); - break; - default: - if (_presenters.TryGetValue(dockable, out var presenter)) - { - presenter.Measure(default); - } - break; - } - } - - private void ArrangeDock(IProportionalDock dock, Rect bounds) - { - _dockBounds[dock] = bounds; - - if (_dockSurfaces.TryGetValue(dock, out var surface)) - { - ArrangeChild(surface, bounds, useConnectedAnimation: false); - } - - if (dock.VisibleDockables is not { } visibleDockables || visibleDockables.Count == 0) - { - return; - } - - var splitterThickness = GetTotalSplitterThickness(visibleDockables); - AssignProportions(dock, bounds.Size, splitterThickness); - var availableLength = Math.Max(0, GetLength(bounds.Size, dock.Orientation) - splitterThickness); - var offset = 0.0; - var sumOfFractions = 0.0; - - for (var i = 0; i < visibleDockables.Count; i++) - { - var dockable = visibleDockables[i]; - - if (dockable is IProportionalDockSplitter splitter) - { - ArrangeSplitter(dock, visibleDockables, i, splitter, bounds, ref offset); - continue; - } - - if (IsCollapsed(dockable)) - { - continue; - } - - var length = CalculateDimensionWithConstraints( - dockable, - dock.Orientation, - availableLength, - ResolveValidProportion(dockable.Proportion, 0), - ref sumOfFractions); - - var childBounds = CreateChildRect(bounds, dock.Orientation, offset, length); - offset += length; - - if (dockable is IProportionalDock childDock) - { - ArrangeDock(childDock, childBounds); - } - else if (_presenters.TryGetValue(dockable, out var presenter)) - { - ArrangeChild(presenter, childBounds, useConnectedAnimation: true); - } - } - } - - private void ArrangeSplitter( - IProportionalDock dock, - IList visibleDockables, - int index, - IProportionalDockSplitter splitter, - Rect bounds, - ref double offset) - { - if (!_splitters.TryGetValue(splitter, out var splitterControl)) - { - return; - } - - splitterControl.Orientation = ToAvaloniaOrientation(dock.Orientation); - - if (!ShouldUseSplitter(visibleDockables, index)) - { - return; - } - - var thickness = splitterControl.Thickness; - var splitterBounds = CreateChildRect(bounds, dock.Orientation, offset, thickness); - offset += thickness; - ArrangeChild(splitterControl, splitterBounds, useConnectedAnimation: true); - } - - private void MeasureRemovalPresenters() - { - foreach (var kvp in _removalPresenterBounds) - { - kvp.Key.Measure(kvp.Value.Size); - } - } - - private void ArrangeRemovalPresenters() - { - foreach (var kvp in _removalPresenterBounds) - { - var presenter = kvp.Key; - var bounds = kvp.Value; - presenter.Measure(bounds.Size); - presenter.Arrange(bounds); - _arrangedChildren.Add(presenter); - } - } - - private void ArrangeChild(Control control, Rect bounds, bool useConnectedAnimation) - { - var previousBounds = _connectedStartBounds.TryGetValue(control, out var connectedStartBounds) - ? connectedStartBounds - : control.Bounds; - - control.Arrange(bounds); - _arrangedChildren.Add(control); - - if (useConnectedAnimation) - { - if (control is ContentPresenter presenter && _enteringPresenters.Remove(presenter)) - { - QueueInsertAnimation(presenter, bounds); - QueueConnectedAnimation(presenter, previousBounds, bounds); - return; - } - - QueueConnectedAnimation(control, previousBounds, bounds); - } - } - - private void QueueInsertAnimation(ContentPresenter presenter, Rect bounds) - { - if (!UseLayoutTransitions - || _suppressLayoutTransitions - || !_hasCompletedArrange - || LayoutTransitionDuration <= TimeSpan.Zero - || !HasVisibleSize(bounds)) - { - CompleteInsertAnimation(presenter); - return; - } - - _pendingInsertAnimations.Add(presenter); - } - - private void QueueConnectedAnimation(Control control, Rect previousBounds, Rect nextBounds) - { - if (!UseLayoutTransitions - || _suppressLayoutTransitions - || !_hasCompletedArrange - || LayoutTransitionDuration <= TimeSpan.Zero - || !HasVisibleSize(nextBounds)) - { - return; - } - - if (control is ContentPresenter presenter) - { - if (!ShouldAnimateLayoutChange(presenter)) - { - CompleteScopedExcludedPresenter(presenter); - return; - } - - var fromBounds = _pendingLayoutAnimations.TryGetValue(presenter, out var pendingLayoutAnimation) - ? pendingLayoutAnimation.From - : previousBounds; - - if (!HasVisibleSize(fromBounds)) - { - return; - } - - if (AreClose(fromBounds, nextBounds)) - { - _pendingLayoutAnimations.Remove(presenter); - if (_activeLayoutPresenters.Contains(presenter)) - { - CompleteLayoutAnimation(presenter); - return; - } - - if (!_pendingInsertAnimations.Contains(presenter) - && !_activeInsertPresenters.Contains(presenter) - && !_activeLayoutPresenters.Contains(presenter)) - { - presenter.Opacity = 1.0; - presenter.IsHitTestVisible = true; - } - - return; - } - - var isPendingInsert = _pendingInsertAnimations.Contains(presenter); - if (_activeLayoutPresenters.Contains(presenter)) - { - NextLayoutAnimationVersion(presenter); - } - - _pendingLayoutAnimations[presenter] = new ConnectedAnimation(fromBounds, nextBounds); - presenter.Opacity = isPendingInsert ? 0.0 : 1.0; - presenter.IsHitTestVisible = false; - return; - } - - if (IsScopedLayoutActionActive()) - { - CompleteScopedExcludedControl(control); - return; - } - - var connectedFromBounds = _pendingConnectedAnimations.TryGetValue(control, out var pendingConnectedAnimation) - ? pendingConnectedAnimation.From - : previousBounds; - - if (!HasVisibleSize(connectedFromBounds)) - { - return; - } - - if (AreClose(connectedFromBounds, nextBounds)) - { - _pendingConnectedAnimations.Remove(control); - if (_activeConnectedControls.Contains(control)) - { - CompleteConnectedAnimation(control); - } - - return; - } - - _pendingConnectedAnimations[control] = new ConnectedAnimation(connectedFromBounds, nextBounds); - } - - private void RequestConnectedAnimations() - { - _connectedStartBounds.Clear(); - - if ((_pendingConnectedAnimations.Count == 0 - && _pendingInsertAnimations.Count == 0 - && _pendingLayoutAnimations.Count == 0 - && _pendingRemovalPresenters.Count == 0) - || _hasPendingConnectedAnimationPost) - { - if (_pendingConnectedAnimations.Count == 0 - && _pendingInsertAnimations.Count == 0 - && _pendingLayoutAnimations.Count == 0 - && _pendingRemovalPresenters.Count == 0) - { - ClearScopedLayoutAction(); - } - - return; - } - - _hasPendingConnectedAnimationPost = true; - - if (this.IsAttachedToVisualTree() - && ElementComposition.GetElementVisual(this)?.Compositor is { } compositor) - { - compositor.RequestCompositionUpdate(StartPendingConnectedAnimations); - return; - } - - Dispatcher.UIThread.Post(StartPendingConnectedAnimations, DispatcherPriority.Render); - } - - private bool ShouldAnimateLayoutChange(ContentPresenter presenter) - { - if (IsScopedLayoutActionActive()) - { - return presenter.Content is IDockable dockable - && _layoutActionDockables.Contains(dockable) - && _connectedStartBounds.ContainsKey(presenter); - } - - return true; - } - - private bool IsScopedLayoutActionActive() - { - return _hasScopedLayoutAction && _layoutActionDockables.Count > 0; - } - - private void CompleteScopedExcludedPresenter(ContentPresenter presenter) - { - if (!IsScopedLayoutActionActive()) - { - return; - } - - if (_pendingLayoutAnimations.ContainsKey(presenter) - || _activeLayoutPresenters.Contains(presenter)) - { - CompleteLayoutAnimation(presenter); - return; - } - - if (!_pendingInsertAnimations.Contains(presenter) - && !_activeInsertPresenters.Contains(presenter)) - { - presenter.Opacity = 1.0; - presenter.IsHitTestVisible = true; - ResetPresenterComposition(presenter); - } - } - - private void CompleteScopedExcludedControl(Control control) - { - if (!IsScopedLayoutActionActive()) - { - return; - } - - if (_pendingConnectedAnimations.ContainsKey(control) - || _activeConnectedControls.Contains(control)) - { - CompleteConnectedAnimation(control); - return; - } - - ResetControlComposition(control); - } - - private void StartPendingConnectedAnimations() - { - _hasPendingConnectedAnimationPost = false; - - if (!UseLayoutTransitions || LayoutTransitionDuration <= TimeSpan.Zero) - { - CancelLayoutTransitions(removeRemovalPresenters: true); - return; - } - - var animations = new List>(_pendingConnectedAnimations); - var insertions = new List(_pendingInsertAnimations); - var layoutAnimations = new List>(_pendingLayoutAnimations); - var removals = new List(_pendingRemovalPresenters); - _pendingConnectedAnimations.Clear(); - _pendingInsertAnimations.Clear(); - _pendingLayoutAnimations.Clear(); - _pendingRemovalPresenters.Clear(); - ClearScopedLayoutAction(); - - foreach (var kvp in animations) - { - StartConnectedControlAnimation(kvp.Key, kvp.Value); - } - - foreach (var presenter in insertions) - { - StartInsertAnimation(presenter); - } - - foreach (var kvp in layoutAnimations) - { - StartLayoutAnimation(kvp.Key, kvp.Value); - } - - foreach (var presenter in removals) - { - StartRemovalAnimation(presenter); - } - } - - private bool StartConnectedAnimation(Control control, ConnectedAnimation animation) - { - return StartConnectedAnimation(control, animation, useScaleForSize: false, useCurrentCompositionStart: false); - } - - private bool StartConnectedAnimation( - Control control, - ConnectedAnimation animation, - bool useScaleForSize, - bool useCurrentCompositionStart) - { - if (!control.IsAttachedToVisualTree() - || !HasVisibleSize(animation.From) - || !HasVisibleSize(animation.To) - || AreClose(animation.From, animation.To)) - { - return false; - } - - var visual = ElementComposition.GetElementVisual(control); - if (visual is null) - { - return false; - } - - var compositor = visual.Compositor; - if (compositor is null) - { - return false; - } - - var to = animation.To; - var from = animation.From; - var finalOffset = new Vector3D(to.X, to.Y, visual.Offset.Z); - var startOffset = new Vector3D( - finalOffset.X + from.X - to.X, - finalOffset.Y + from.Y - to.Y, - finalOffset.Z); - var finalSize = new Vector(to.Width, to.Height); - var startSize = new Vector(from.Width, from.Height); - var startScale = new Vector3D( - to.Width > 0 ? from.Width / to.Width : 1.0, - to.Height > 0 ? from.Height / to.Height : 1.0, - 1.0); - var duration = LayoutTransitionDuration; - var hasSizeDelta = !AreClose(from.Width, to.Width) || !AreClose(from.Height, to.Height); - - if (!useCurrentCompositionStart) - { - visual.StopAnimation(nameof(CompositionVisual.Offset)); - visual.StopAnimation(nameof(CompositionVisual.Scale)); - visual.StopAnimation(nameof(CompositionVisual.Size)); - } - - visual.CenterPoint = new Vector3D(0.0, 0.0, 0.0); - if (!useCurrentCompositionStart) - { - visual.Offset = finalOffset; - visual.Size = finalSize; - visual.Scale = new Vector3D(1.0, 1.0, 1.0); - } - - var offset = compositor.CreateVector3DKeyFrameAnimation(); - offset.Target = nameof(CompositionVisual.Offset); - offset.Duration = duration; - offset.StopBehavior = AnimationStopBehavior.SetToFinalValue; - if (useCurrentCompositionStart) - { - offset.InsertExpressionKeyFrame(0.0f, "this.StartingValue", s_layoutTransitionEasing); - } - else - { - offset.InsertKeyFrame(0.0f, startOffset, s_layoutTransitionEasing); - } - - offset.InsertKeyFrame(1.0f, finalOffset, s_layoutTransitionEasing); - - var group = compositor.CreateAnimationGroup(); - group.Add(offset); - - if (useScaleForSize) - { - if (hasSizeDelta || useCurrentCompositionStart) - { - var scale = compositor.CreateVector3DKeyFrameAnimation(); - scale.Target = nameof(CompositionVisual.Scale); - scale.Duration = duration; - scale.StopBehavior = AnimationStopBehavior.SetToFinalValue; - if (useCurrentCompositionStart) - { - scale.InsertExpressionKeyFrame(0.0f, "this.StartingValue", s_layoutTransitionEasing); - } - else - { - scale.InsertKeyFrame(0.0f, startScale, s_layoutTransitionEasing); - } - - scale.InsertKeyFrame(1.0f, new Vector3D(1.0, 1.0, 1.0), s_layoutTransitionEasing); - - group.Add(scale); - } - - visual.StartAnimationGroup(group); - return true; - } - - if (hasSizeDelta || useCurrentCompositionStart) - { - var size = compositor.CreateVectorKeyFrameAnimation(); - size.Target = nameof(CompositionVisual.Size); - size.Duration = duration; - size.StopBehavior = AnimationStopBehavior.SetToFinalValue; - if (useCurrentCompositionStart) - { - size.InsertExpressionKeyFrame(0.0f, "this.StartingValue", s_layoutTransitionEasing); - } - else - { - size.InsertKeyFrame(0.0f, startSize, s_layoutTransitionEasing); - } - - size.InsertKeyFrame(1.0f, finalSize, s_layoutTransitionEasing); - - group.Add(size); - } - - visual.StartAnimationGroup(group); - return true; - } - - private void StartConnectedControlAnimation(Control control, ConnectedAnimation animation) - { - var useCurrentCompositionStart = _activeConnectedControls.Contains(control); - var version = NextConnectedAnimationVersion(control); - _activeConnectedControls.Add(control); - var compositor = ElementComposition.GetElementVisual(control)?.Compositor; - - if (compositor is null - || !StartConnectedAnimation(control, animation, useScaleForSize: false, useCurrentCompositionStart)) - { - CompleteConnectedAnimation(control, version); - return; - } - - CompleteConnectedAnimationAfterCommit(compositor, control, version, GetTransitionCompletionDelay()); - } - - private async void CompleteConnectedAnimationAfterCommit( - Compositor compositor, - Control control, - int version, - TimeSpan delay) - { - await compositor.RequestCommitAsync(); - await Task.Delay(delay); - Dispatcher.UIThread.Post(() => CompleteConnectedAnimation(control, version), DispatcherPriority.Background); - } - - private int NextConnectedAnimationVersion(Control control) - { - var version = _connectedAnimationVersions.TryGetValue(control, out var currentVersion) - ? currentVersion + 1 - : 1; - - _connectedAnimationVersions[control] = version; - return version; - } - - private void CompleteConnectedAnimation(Control control, int? version = null) - { - if (version.HasValue - && _connectedAnimationVersions.TryGetValue(control, out var currentVersion) - && currentVersion != version.Value) - { - return; - } - - ClearConnectedControlState(control); - - if (Children.Contains(control)) - { - ResetControlComposition(control); - } - } - - private void StartInsertAnimation(ContentPresenter presenter) - { - var version = NextInsertAnimationVersion(presenter); - _activeInsertPresenters.Add(presenter); - - if (!presenter.IsAttachedToVisualTree()) - { - CompleteInsertAnimation(presenter, version); - return; - } - - var visual = ElementComposition.GetElementVisual(presenter); - if (visual is null) - { - CompleteInsertAnimation(presenter, version); - return; - } - - var compositor = visual.Compositor; - if (compositor is null) - { - CompleteInsertAnimation(presenter, version); - return; - } - - presenter.Opacity = 1.0; - presenter.IsHitTestVisible = false; - visual.Opacity = 1.0f; - visual.StopAnimation(nameof(CompositionVisual.Opacity)); - - var opacity = compositor.CreateScalarKeyFrameAnimation(); - opacity.Target = nameof(CompositionVisual.Opacity); - opacity.Duration = LayoutTransitionDuration; - opacity.StopBehavior = AnimationStopBehavior.SetToFinalValue; - opacity.InsertKeyFrame(0.0f, 0.0f, s_layoutTransitionEasing); - opacity.InsertKeyFrame(1.0f, 1.0f, s_layoutTransitionEasing); - - visual.StartAnimation(nameof(CompositionVisual.Opacity), opacity); - - CompleteInsertAnimationAfterCommit(compositor, presenter, version, GetTransitionCompletionDelay()); - } - - private void StartLayoutAnimation(ContentPresenter presenter, ConnectedAnimation animation) - { - var useCurrentCompositionStart = _activeLayoutPresenters.Contains(presenter); - var version = NextLayoutAnimationVersion(presenter); - _activeLayoutPresenters.Add(presenter); - - if (!presenter.IsAttachedToVisualTree()) - { - CompleteLayoutAnimation(presenter, version); - return; - } - - presenter.Opacity = 1.0; - presenter.IsHitTestVisible = false; - var compositor = ElementComposition.GetElementVisual(presenter)?.Compositor; - - if (compositor is null - || !StartConnectedAnimation(presenter, animation, useScaleForSize: false, useCurrentCompositionStart)) - { - CompleteLayoutAnimation(presenter, version); - return; - } - - CompleteLayoutAnimationAfterCommit(compositor, presenter, version, GetTransitionCompletionDelay()); - } - - private async void CompleteLayoutAnimationAfterCommit( - Compositor compositor, - ContentPresenter presenter, - int version, - TimeSpan delay) - { - await compositor.RequestCommitAsync(); - await Task.Delay(delay); - Dispatcher.UIThread.Post(() => CompleteLayoutAnimation(presenter, version), DispatcherPriority.Background); - } - - private int NextLayoutAnimationVersion(ContentPresenter presenter) - { - var version = _layoutAnimationVersions.TryGetValue(presenter, out var currentVersion) - ? currentVersion + 1 - : 1; - - _layoutAnimationVersions[presenter] = version; - return version; - } - - private void CompleteLayoutAnimation(ContentPresenter presenter, int? version = null) - { - if (version.HasValue - && _layoutAnimationVersions.TryGetValue(presenter, out var currentVersion) - && currentVersion != version.Value) - { - return; - } - - _pendingLayoutAnimations.Remove(presenter); - _layoutAnimationVersions.Remove(presenter); - _activeLayoutPresenters.Remove(presenter); - - if (Children.Contains(presenter)) - { - var hasInsertTransition = _pendingInsertAnimations.Contains(presenter) - || _activeInsertPresenters.Contains(presenter); - presenter.Opacity = 1.0; - presenter.IsHitTestVisible = !hasInsertTransition; - if (hasInsertTransition) - { - ResetControlComposition(presenter); - } - else - { - ResetPresenterComposition(presenter); - } - } - } - - private async void CompleteInsertAnimationAfterCommit( - Compositor compositor, - ContentPresenter presenter, - int version, - TimeSpan delay) - { - await compositor.RequestCommitAsync(); - await Task.Delay(delay); - Dispatcher.UIThread.Post(() => CompleteInsertAnimation(presenter, version), DispatcherPriority.Background); - } - - private int NextInsertAnimationVersion(ContentPresenter presenter) - { - var version = _insertAnimationVersions.TryGetValue(presenter, out var currentVersion) - ? currentVersion + 1 - : 1; - - _insertAnimationVersions[presenter] = version; - return version; - } - - private void CompleteInsertAnimation(ContentPresenter presenter, int? version = null) - { - if (version.HasValue - && _insertAnimationVersions.TryGetValue(presenter, out var currentVersion) - && currentVersion != version.Value) - { - return; - } - - _pendingInsertAnimations.Remove(presenter); - _insertAnimationVersions.Remove(presenter); - _activeInsertPresenters.Remove(presenter); - - if (Children.Contains(presenter)) - { - var hasLayoutTransition = _pendingLayoutAnimations.ContainsKey(presenter) - || _activeLayoutPresenters.Contains(presenter); - presenter.Opacity = 1.0; - presenter.IsHitTestVisible = !hasLayoutTransition; - if (hasLayoutTransition) - { - ResetPresenterOpacity(presenter); - } - else - { - ResetPresenterComposition(presenter); - } - } - } - - private void StartRemovalAnimation(ContentPresenter presenter) - { - if (!_removalPresenterBounds.ContainsKey(presenter) || !_activeRemovalPresenters.Add(presenter)) - { - return; - } - - presenter.Opacity = 1.0; - presenter.IsHitTestVisible = false; - - var visual = ElementComposition.GetElementVisual(presenter); - Compositor? compositor = null; - if (visual is { Compositor: { } visualCompositor } && presenter.IsAttachedToVisualTree()) - { - compositor = visualCompositor; - visual.StopAnimation(nameof(CompositionVisual.Opacity)); - - var opacity = visualCompositor.CreateScalarKeyFrameAnimation(); - opacity.Target = nameof(CompositionVisual.Opacity); - opacity.Duration = LayoutTransitionDuration; - opacity.StopBehavior = AnimationStopBehavior.SetToFinalValue; - opacity.InsertExpressionKeyFrame(0.0f, "this.StartingValue", s_layoutTransitionEasing); - opacity.InsertKeyFrame(1.0f, 0.0f, s_layoutTransitionEasing); - - visual.StartAnimation(nameof(CompositionVisual.Opacity), opacity); - } - - if (compositor is null) - { - RemoveRemovalPresenterAfterDelay(presenter, GetTransitionCompletionDelay()); - return; - } - - RemoveRemovalPresenterAfterCommit(compositor, presenter, GetTransitionCompletionDelay()); - } - - private async void RemoveRemovalPresenterAfterCommit( - Compositor compositor, - ContentPresenter presenter, - TimeSpan delay) - { - await compositor.RequestCommitAsync(); - await Task.Delay(delay); - Dispatcher.UIThread.Post(() => RemoveRemovalPresenter(presenter), DispatcherPriority.Background); - } - - private async void RemoveRemovalPresenterAfterDelay(ContentPresenter presenter, TimeSpan delay) - { - await Task.Delay(delay); - Dispatcher.UIThread.Post(() => RemoveRemovalPresenter(presenter), DispatcherPriority.Background); - } - - private void RemoveRemovalPresenter(ContentPresenter presenter) - { - if (!_removalPresenterBounds.ContainsKey(presenter) - && !_pendingRemovalPresenters.Contains(presenter) - && !_activeRemovalPresenters.Contains(presenter)) - { - return; - } - - _pendingRemovalPresenters.Remove(presenter); - _activeRemovalPresenters.Remove(presenter); - _removalPresenterBounds.Remove(presenter); - - if (presenter.Content is IDockable dockable - && _removalPresentersByDockable.TryGetValue(dockable, out var removalPresenter) - && ReferenceEquals(removalPresenter, presenter)) - { - _removalPresentersByDockable.Remove(dockable); - } - - ResetPresenterComposition(presenter); - Children.Remove(presenter); - presenter.Content = null; - presenter.DataContext = null; - presenter.Opacity = 1.0; - presenter.IsHitTestVisible = true; - } - - private void CancelLayoutTransitions(bool removeRemovalPresenters) - { - _hasPendingConnectedAnimationPost = false; - _connectedStartBounds.Clear(); - ClearScopedLayoutAction(); - - var controls = new HashSet(ReferenceEqualityComparer.Instance); - foreach (var control in _pendingConnectedAnimations.Keys) - { - controls.Add(control); - } - - foreach (var control in _activeConnectedControls) - { - controls.Add(control); - } - - foreach (var splitter in _splitters.Values) - { - controls.Add(splitter); - } - - _pendingConnectedAnimations.Clear(); - _pendingLayoutAnimations.Clear(); - _pendingInsertAnimations.Clear(); - - var presenters = new HashSet(ReferenceEqualityComparer.Instance); - - foreach (var presenter in _presenters.Values) - { - presenters.Add(presenter); - } - - foreach (var presenter in _activeInsertPresenters) - { - presenters.Add(presenter); - } - - foreach (var presenter in _activeLayoutPresenters) - { - presenters.Add(presenter); - } - - foreach (var presenter in _removalPresenterBounds.Keys) - { - presenters.Add(presenter); - } - - foreach (var presenter in _pendingRemovalPresenters) - { - presenters.Add(presenter); - } - - foreach (var presenter in _activeRemovalPresenters) - { - presenters.Add(presenter); - } - - foreach (var presenter in presenters) - { - CompletePresenterTransitionState(presenter); - } - - foreach (var control in controls) - { - CompleteConnectedControlState(control); - } - - if (removeRemovalPresenters) - { - RemoveRemovalPresenters(); - } - } - - private void CompletePresenterTransitionState(ContentPresenter presenter) - { - _enteringPresenters.Remove(presenter); - _pendingInsertAnimations.Remove(presenter); - _activeInsertPresenters.Remove(presenter); - _insertAnimationVersions.Remove(presenter); - _pendingLayoutAnimations.Remove(presenter); - _layoutAnimationVersions.Remove(presenter); - _activeLayoutPresenters.Remove(presenter); - - ResetPresenterComposition(presenter); - - if (Children.Contains(presenter)) - { - presenter.Opacity = 1.0; - presenter.IsHitTestVisible = true; - } - } - - private void CompleteConnectedControlState(Control control) - { - ClearConnectedControlState(control); - ResetControlComposition(control); - } - - private void TrackLayoutActionDockables(NotifyCollectionChangedEventArgs e) - { - if (!UseLayoutTransitions || !_hasCompletedArrange) - { - return; - } - - var previousCount = _layoutActionDockables.Count; - TrackLayoutActionDockables(e.NewItems); - TrackLayoutActionDockables(e.OldItems); - - if (_layoutActionDockables.Count > previousCount) - { - _hasScopedLayoutAction = true; - } - } - - private void TrackStructuralLayoutActionDockables() - { - if (!UseLayoutTransitions - || !_hasCompletedArrange - || IsScopedLayoutActionActive() - || Dock is not { } dock) - { - return; - } - - EnsureConnectedStartBounds(); - - var previousDockables = new List(); - CaptureCurrentPresenterDockables(previousDockables); - if (previousDockables.Count == 0) - { - return; - } - - var nextDockables = new List(); - CaptureLeafDockables(dock, nextDockables); - - var previousCount = _layoutActionDockables.Count; - TrackChangedDockables(previousDockables, nextDockables); - - if (_layoutActionDockables.Count > previousCount) - { - _hasScopedLayoutAction = true; - } - } - - private void CaptureCurrentPresenterDockables(ICollection dockables) - { - foreach (var child in Children) - { - if (child is not ContentPresenter presenter - || presenter.Content is not IDockable dockable - || !_presenters.TryGetValue(dockable, out var activePresenter) - || !ReferenceEquals(activePresenter, presenter)) - { - continue; - } - - dockables.Add(dockable); - } - } - - private void CaptureLeafDockables(IDockable dockable, ICollection dockables) - { - switch (dockable) - { - case IProportionalDock proportionalDock: - { - if (proportionalDock.VisibleDockables is null) - { - return; - } - - foreach (var child in proportionalDock.VisibleDockables) - { - CaptureLeafDockables(child, dockables); - } - - return; - } - case IProportionalDockSplitter: - return; - default: - dockables.Add(dockable); - return; - } - } - - private void TrackChangedDockables(IList previousDockables, IList nextDockables) - { - if (TrackSingleMovedDockable(previousDockables, nextDockables)) - { - return; - } - - for (var index = 0; index < previousDockables.Count; index++) - { - var dockable = previousDockables[index]; - var nextIndex = IndexOfReference(nextDockables, dockable); - if (nextIndex < 0 || nextIndex != index) - { - _layoutActionDockables.Add(dockable); - } - } - - for (var index = 0; index < nextDockables.Count; index++) - { - var dockable = nextDockables[index]; - var previousIndex = IndexOfReference(previousDockables, dockable); - if (previousIndex < 0 || previousIndex != index) - { - _layoutActionDockables.Add(dockable); - } - } - } - - private bool TrackSingleMovedDockable(IList previousDockables, IList nextDockables) - { - if (previousDockables.Count != nextDockables.Count || previousDockables.Count < 3) - { - return false; - } - - IDockable? movedDockable = null; - for (var previousIndex = 0; previousIndex < previousDockables.Count; previousIndex++) - { - var dockable = previousDockables[previousIndex]; - var nextIndex = IndexOfReference(nextDockables, dockable); - if (nextIndex < 0) - { - return false; - } - - if (nextIndex == previousIndex) - { - continue; - } - - if (!MatchesAfterMove(previousDockables, nextDockables, previousIndex, nextIndex)) - { - continue; - } - - if (movedDockable is not null) - { - return false; - } - - movedDockable = dockable; - } - - if (movedDockable is null) - { - return false; - } - - _layoutActionDockables.Add(movedDockable); - return true; - } - - private static bool MatchesAfterMove( - IList previousDockables, - IList nextDockables, - int previousIndex, - int nextIndex) - { - for (var index = 0; index < nextDockables.Count; index++) - { - var candidateIndex = GetMovedSequenceSourceIndex(index, previousIndex, nextIndex); - if (!ReferenceEquals(previousDockables[candidateIndex], nextDockables[index])) - { - return false; - } - } - - return true; - } - - private static int GetMovedSequenceSourceIndex(int nextIndex, int previousIndex, int movedNextIndex) - { - if (nextIndex == movedNextIndex) - { - return previousIndex; - } - - if (previousIndex < movedNextIndex && nextIndex >= previousIndex && nextIndex < movedNextIndex) - { - return nextIndex + 1; - } - - if (previousIndex > movedNextIndex && nextIndex > movedNextIndex && nextIndex <= previousIndex) - { - return nextIndex - 1; - } - - return nextIndex; - } - - private static int IndexOfReference(IList dockables, IDockable dockable) - { - for (var index = 0; index < dockables.Count; index++) - { - if (ReferenceEquals(dockables[index], dockable)) - { - return index; - } - } - - return -1; - } - - private void TrackLayoutActionDockables(System.Collections.IList? dockables) - { - if (dockables is null) - { - return; - } - - foreach (var item in dockables) - { - if (item is IDockable dockable) - { - TrackLayoutActionDockable(dockable); - } - } - } - - private void TrackLayoutActionDockable(IDockable dockable) - { - switch (dockable) - { - case IProportionalDock proportionalDock: - { - if (proportionalDock.VisibleDockables is null) - { - return; - } - - foreach (var child in proportionalDock.VisibleDockables) - { - TrackLayoutActionDockable(child); - } - - return; - } - case IProportionalDockSplitter: - return; - default: - _layoutActionDockables.Add(dockable); - return; - } - } - - private void ClearScopedLayoutAction() - { - _layoutActionDockables.Clear(); - _hasScopedLayoutAction = false; - } - - private void EnsureConnectedStartBounds() - { - if (_connectedStartBounds.Count == 0) - { - CaptureConnectedStartBounds(); - } - } - - private void ClearConnectedControlState(Control control) - { - _pendingConnectedAnimations.Remove(control); - _activeConnectedControls.Remove(control); - _connectedAnimationVersions.Remove(control); - } - - private static void ResetPresenterComposition(ContentPresenter presenter) - { - ResetControlComposition(presenter, resetOpacity: true); - } - - private static void ResetPresenterOpacity(ContentPresenter presenter) - { - var visual = ElementComposition.GetElementVisual(presenter); - if (visual is null) - { - return; - } - - visual.StopAnimation(nameof(CompositionVisual.Opacity)); - visual.Opacity = 1.0f; - } - - private static void ResetControlComposition(Control control) - { - ResetControlComposition(control, resetOpacity: false); - } - - private static void ResetControlComposition(Control control, bool resetOpacity) - { - var visual = ElementComposition.GetElementVisual(control); - if (visual is null) - { - return; - } - - if (resetOpacity) - { - visual.StopAnimation(nameof(CompositionVisual.Opacity)); - visual.Opacity = 1.0f; - } - - visual.StopAnimation(nameof(CompositionVisual.Offset)); - visual.StopAnimation(nameof(CompositionVisual.Scale)); - visual.StopAnimation(nameof(CompositionVisual.Size)); - visual.CenterPoint = new Vector3D(0.0, 0.0, 0.0); - visual.Scale = new Vector3D(1.0, 1.0, 1.0); - - var bounds = control.Bounds; - if (HasVisibleSize(bounds)) - { - visual.Offset = new Vector3D(bounds.X, bounds.Y, visual.Offset.Z); - visual.Size = new Vector(bounds.Width, bounds.Height); - } - } - - private void CaptureConnectedStartBounds() - { - _connectedStartBounds.Clear(); - - if (!UseLayoutTransitions || !_hasCompletedArrange) - { - return; - } - - foreach (var presenter in _presenters.Values) - { - CaptureConnectedStartBounds(presenter); - } - - foreach (var splitter in _splitters.Values) - { - CaptureConnectedStartBounds(splitter); - } - - foreach (var kvp in _removalPresenterBounds) - { - if (HasVisibleSize(kvp.Value)) - { - _connectedStartBounds[kvp.Key] = kvp.Value; - } - } - } - - private void CaptureConnectedStartBounds(Control control) - { - var bounds = control.Bounds; - if (HasVisibleSize(bounds)) - { - _connectedStartBounds[control] = bounds; - } - } - - private double GetTotalSplitterThickness(IList visibleDockables) - { - var total = 0.0; - - for (var i = 0; i < visibleDockables.Count; i++) - { - if (visibleDockables[i] is IProportionalDockSplitter splitter - && ShouldUseSplitter(visibleDockables, i) - && _splitters.TryGetValue(splitter, out var splitterControl)) - { - total += splitterControl.Thickness; - } - } - - return total; - } - - private void AssignProportions(IProportionalDock dock, Size size, double splitterThickness) - { - if (dock.VisibleDockables is not { } visibleDockables) - { - return; - } - - var dockables = new List(); - foreach (var dockable in visibleDockables) - { - if (dockable is not IProportionalDockSplitter) - { - dockables.Add(dockable); - } - } - - if (dockables.Count == 0) - { - return; - } - - _isAssigningProportions = true; - try - { - var availableLength = Math.Max(1.0, GetLength(size, dock.Orientation) - splitterThickness); - var hasCollapsed = false; - var assignedTotal = 0.0; - var unassignedCount = 0; - var targets = new Dictionary(ReferenceEqualityComparer.Instance); - - foreach (var dockable in dockables) - { - if (IsCollapsed(dockable)) - { - hasCollapsed = true; - if (IsValidProportion(dockable.Proportion) && dockable.Proportion > 0) - { - dockable.CollapsedProportion = dockable.Proportion; - } - - targets[dockable] = 0.0; - continue; - } - - var target = IsValidProportion(dockable.CollapsedProportion) - ? dockable.CollapsedProportion - : dockable.Proportion; - - if (IsValidProportion(target)) - { - assignedTotal += target; - targets[dockable] = target; - } - else - { - unassignedCount++; - targets[dockable] = double.NaN; - } - } - - if (unassignedCount > 0) - { - var remaining = Math.Max(0, 1.0 - assignedTotal); - var proportion = remaining / unassignedCount; - foreach (var dockable in dockables) - { - if (!IsCollapsed(dockable) && !IsValidProportion(targets[dockable])) - { - targets[dockable] = proportion; - } - } - } - - NormalizeActiveProportions(dockables, targets); - - foreach (var dockable in dockables) - { - var target = ClampProportion(dockable, dock.Orientation, availableLength, targets[dockable]); - SetDockableProportion(dockable, target, !IsCollapsed(dockable) && !hasCollapsed); - } - } - finally - { - _isAssigningProportions = false; - } - } - - private static void NormalizeActiveProportions(IList dockables, IDictionary targets) - { - var total = 0.0; - foreach (var dockable in dockables) - { - if (!IsCollapsed(dockable)) - { - total += targets[dockable]; - } - } - - if (total <= 0 || Math.Abs(total - 1.0) < 1e-10) - { - return; - } - - var scale = 1.0 / total; - foreach (var dockable in dockables) - { - if (!IsCollapsed(dockable)) - { - targets[dockable] *= scale; - } - } - } - - private double ClampProportion(IDockable dockable, DockOrientation orientation, double availableLength, double proportion) - { - if (!IsValidProportion(proportion)) - { - return proportion; - } - - var min = GetMinimumLength(dockable, orientation); - var max = GetMaximumLength(dockable, orientation); - var minProportion = MinimumProportionSize > 0 ? MinimumProportionSize / availableLength : 0.0; - var maxProportion = double.PositiveInfinity; - - if (!double.IsNaN(min) && min > 0) - { - minProportion = Math.Max(minProportion, min / availableLength); - } - - if (!double.IsNaN(max) && !double.IsPositiveInfinity(max) && max > 0) - { - maxProportion = max / availableLength; - } - - if (maxProportion < minProportion) - { - maxProportion = minProportion; - } - - return Math.Clamp(proportion, minProportion, maxProportion); - } - - private void ApplyResizeConstraints( - DockOrientation orientation, - double availableSize, - IDockable primary, - IDockable secondary, - ref double primaryProportion, - ref double secondaryProportion) - { - var primaryConstraints = GetProportionConstraints(primary, orientation, availableSize); - var secondaryConstraints = GetProportionConstraints(secondary, orientation, availableSize); - - if (primaryProportion < primaryConstraints.Min) - { - var deficit = primaryConstraints.Min - primaryProportion; - primaryProportion = primaryConstraints.Min; - secondaryProportion = Math.Max(secondaryConstraints.Min, secondaryProportion - deficit); - } - else if (primaryProportion > primaryConstraints.Max) - { - var excess = primaryProportion - primaryConstraints.Max; - primaryProportion = primaryConstraints.Max; - secondaryProportion = Math.Min(secondaryConstraints.Max, secondaryProportion + excess); - } - } - - private (double Min, double Max) GetProportionConstraints(IDockable dockable, DockOrientation orientation, double availableSize) - { - var min = GetMinimumLength(dockable, orientation); - var max = GetMaximumLength(dockable, orientation); - var minProportion = MinimumProportionSize > 0 ? MinimumProportionSize / availableSize : 0.0; - var maxProportion = double.PositiveInfinity; - - if (!double.IsNaN(min) && min > 0) - { - minProportion = Math.Max(minProportion, min / availableSize); - } - - if (!double.IsNaN(max) && !double.IsPositiveInfinity(max) && max > 0) - { - maxProportion = max / availableSize; - } - - if (maxProportion < minProportion) - { - maxProportion = minProportion; - } - - return (minProportion, maxProportion); - } - - private static IDockable? FindResizeSibling(IList dockables, int splitterIndex, int direction) - { - for (var index = splitterIndex + direction; index >= 0 && index < dockables.Count; index += direction) - { - var dockable = dockables[index]; - if (dockable is IProportionalDockSplitter || IsCollapsed(dockable)) - { - continue; - } - - return dockable; - } - - return null; - } - - private static bool ShouldUseSplitter(IList dockables, int splitterIndex) - { - if (dockables[splitterIndex] is not IProportionalDockSplitter) - { - return false; - } - - var previous = FindAdjacentDockable(dockables, splitterIndex, -1); - var next = FindAdjacentDockable(dockables, splitterIndex, 1); - - return previous is not null - && next is not null - && !IsCollapsed(previous) - && !IsCollapsed(next); - } - - private static IDockable? FindAdjacentDockable(IList dockables, int splitterIndex, int direction) - { - var index = splitterIndex + direction; - if (index < 0 || index >= dockables.Count) - { - return null; - } - - var dockable = dockables[index]; - return dockable is IProportionalDockSplitter ? null : dockable; - } - - private static Size NormalizeDesiredSize(Size availableSize) - { - var width = double.IsInfinity(availableSize.Width) ? 0 : availableSize.Width; - var height = double.IsInfinity(availableSize.Height) ? 0 : availableSize.Height; - return new Size(width, height); - } - - private static Size CreateChildSize(Size availableSize, DockOrientation orientation, double length) - { - return orientation == DockOrientation.Vertical - ? new Size(availableSize.Width, length) - : new Size(length, availableSize.Height); - } - - private static Rect CreateChildRect(Rect bounds, DockOrientation orientation, double offset, double length) - { - return orientation == DockOrientation.Vertical - ? new Rect(bounds.X, bounds.Y + offset, bounds.Width, length) - : new Rect(bounds.X + offset, bounds.Y, length, bounds.Height); - } - - private static double CalculateDimensionWithConstraints( - IDockable dockable, - DockOrientation orientation, - double dimension, - double proportion, - ref double sumOfFractions) - { - var calculated = CalculateDimension(dimension, proportion, ref sumOfFractions); - var min = GetMinimumLength(dockable, orientation); - var max = GetMaximumLength(dockable, orientation); - - if (!double.IsNaN(min) && calculated < min) - { - calculated = min; - } - - if (!double.IsNaN(max) && !double.IsPositiveInfinity(max) && calculated > max) - { - calculated = max; - } - - return calculated; - } - - private static double CalculateDimension(double dimension, double proportion, ref double sumOfFractions) - { - var childDimension = dimension * proportion; - var flooredChildDimension = Math.Floor(childDimension); - sumOfFractions += childDimension - flooredChildDimension; - - var round = Math.Round(sumOfFractions, 1); - var clamp = Math.Clamp(Math.Floor(sumOfFractions), 1, double.MaxValue); - if (round - clamp >= 0) - { - sumOfFractions -= Math.Round(sumOfFractions); - return Math.Max(0, flooredChildDimension + 1); - } - - return Math.Max(0, flooredChildDimension); - } - - private static double GetLength(Size size, DockOrientation orientation) - { - return orientation == DockOrientation.Vertical ? size.Height : size.Width; - } - - private static double GetMinimumLength(IDockable dockable, DockOrientation orientation) - { - return orientation == DockOrientation.Vertical ? dockable.MinHeight : dockable.MinWidth; - } - - private static double GetMaximumLength(IDockable dockable, DockOrientation orientation) - { - return orientation == DockOrientation.Vertical ? dockable.MaxHeight : dockable.MaxWidth; - } - - private static bool IsCollapsed(IDockable dockable) - { - return dockable.IsCollapsable && dockable.IsEmpty; - } - - private static bool IsValidProportion(double value) - { - return !double.IsNaN(value) && !double.IsInfinity(value) && value >= 0; - } - - private static double ResolveValidProportion(double value, double fallback) - { - return IsValidProportion(value) ? value : fallback; - } - - private static void SetDockableProportion(IDockable dockable, double value, bool updateCollapsedProportion) - { - if (!AreClose(dockable.Proportion, value)) - { - dockable.Proportion = value; - } - - if (updateCollapsedProportion && !AreClose(dockable.CollapsedProportion, value)) - { - dockable.CollapsedProportion = value; - } - } - - private static bool AreClose(double left, double right) - { - if (double.IsNaN(left) && double.IsNaN(right)) - { - return true; - } - - return Math.Abs(left - right) < 1e-10; - } - - private static bool AreClose(Rect left, Rect right) - { - return AreClose(left.X, right.X) - && AreClose(left.Y, right.Y) - && AreClose(left.Width, right.Width) - && AreClose(left.Height, right.Height); - } - - private static bool HasVisibleSize(Rect bounds) - { - return bounds.Width > 0 - && bounds.Height > 0 - && !double.IsNaN(bounds.Width) - && !double.IsNaN(bounds.Height) - && !double.IsInfinity(bounds.Width) - && !double.IsInfinity(bounds.Height); - } - - private TimeSpan GetTransitionCompletionDelay() - { - return LayoutTransitionDuration + s_transitionCompletionSlack; - } - - private readonly struct ConnectedAnimation - { - public ConnectedAnimation(Rect from, Rect to) - { - From = from; - To = to; - } - - public Rect From { get; } - - public Rect To { get; } - } - - private static AvaloniaOrientation ToAvaloniaOrientation(DockOrientation orientation) + internal void ResizeSplitter(FlatProportionalDockSplitter splitterControl, double dragDelta) { - return orientation == DockOrientation.Vertical ? AvaloniaOrientation.Vertical : AvaloniaOrientation.Horizontal; + ResizeSplitter((FlatProportionalSplitter)splitterControl, dragDelta); } } diff --git a/src/Dock.Avalonia/Controls/FlatProportionalDockSplitter.cs b/src/Dock.Avalonia/Controls/FlatProportionalDockSplitter.cs index b739a5a5a..0be27198d 100644 --- a/src/Dock.Avalonia/Controls/FlatProportionalDockSplitter.cs +++ b/src/Dock.Avalonia/Controls/FlatProportionalDockSplitter.cs @@ -1,275 +1,26 @@ // Copyright (c) Wiesław Šoltés. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for details. -using Avalonia; -using Avalonia.Controls; -using Avalonia.Controls.Metadata; -using Avalonia.Controls.Primitives; -using Avalonia.Input; -using Avalonia.Layout; -using Avalonia.VisualTree; +using Dock.Avalonia.Internal; +using Dock.Controls.Flat; using Dock.Model.Controls; -using AvaloniaOrientation = Avalonia.Layout.Orientation; namespace Dock.Avalonia.Controls; /// /// Splitter used by to resize flattened proportional dock regions. /// -[PseudoClasses(":horizontal", ":vertical", ":preview")] -public class FlatProportionalDockSplitter : Thumb +public class FlatProportionalDockSplitter : FlatProportionalSplitter { - private Point _startPoint; - private bool _isMoving; - private FlatProportionalSplitterPreviewAdorner? _previewAdorner; - private AdornerLayer? _adornerLayer; - private double _startOffset; - - /// - /// Defines the property. - /// - public static readonly StyledProperty ThicknessProperty = - AvaloniaProperty.Register(nameof(Thickness), 4.0); - - /// - /// Defines the property. - /// - public static readonly StyledProperty IsResizingEnabledProperty = - AvaloniaProperty.Register(nameof(IsResizingEnabled), true); - - /// - /// Defines the property. - /// - public static readonly StyledProperty PreviewResizeProperty = - AvaloniaProperty.Register(nameof(PreviewResize)); - - /// - /// Defines the property. - /// - public static readonly StyledProperty OrientationProperty = - AvaloniaProperty.Register(nameof(Orientation)); - - /// - /// Gets or sets the splitter thickness. - /// - public double Thickness - { - get => GetValue(ThicknessProperty); - set => SetValue(ThicknessProperty, value); - } - - /// - /// Gets or sets a value indicating whether the splitter can resize neighboring dockables. - /// - public bool IsResizingEnabled - { - get => GetValue(IsResizingEnabledProperty); - set => SetValue(IsResizingEnabledProperty, value); - } - - /// - /// Gets or sets whether resize changes are previewed until pointer release. - /// - public bool PreviewResize - { - get => GetValue(PreviewResizeProperty); - set => SetValue(PreviewResizeProperty, value); - } - - /// - /// Gets or sets the orientation of the owning proportional dock. - /// - public AvaloniaOrientation Orientation - { - get => GetValue(OrientationProperty); - set => SetValue(OrientationProperty, value); - } - /// - /// Gets the model splitter represented by this control. + /// Gets the Dock model splitter represented by this control. /// - public IProportionalDockSplitter? Splitter { get; internal set; } + public new IProportionalDockSplitter? Splitter => + base.Splitter is DockFlatProportionalAdapter.DockFlatSplitterAdapter adapter ? adapter.Splitter : null; /// - /// Gets the proportional dock that owns . + /// Gets the Dock model proportional dock that owns . /// - public IProportionalDock? OwnerDock { get; internal set; } - - /// - protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) - { - base.OnPropertyChanged(change); - - if (change.Property == OrientationProperty - || change.Property == ThicknessProperty - || change.Property == IsResizingEnabledProperty) - { - UpdateVisualState(); - } - - if (change.Property == PreviewResizeProperty) - { - UpdatePreviewPseudoClass(); - } - } - - /// - protected override Size MeasureOverride(Size availableSize) - { - return Orientation == AvaloniaOrientation.Vertical - ? new Size(0, Thickness) - : new Size(Thickness, 0); - } - - /// - protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) - { - base.OnAttachedToVisualTree(e); - - UpdateVisualState(); - UpdatePreviewPseudoClass(); - } - - /// - protected override void OnPointerPressed(PointerPressedEventArgs e) - { - base.OnPointerPressed(e); - - if (!IsResizingEnabled || GetPanel() is not { } panel) - { - return; - } - - _startPoint = e.GetPosition(panel); - _isMoving = true; - UpdatePreviewPseudoClass(); - - if (!PreviewResize) - { - return; - } - - var position = this.TranslatePoint(new Point(), panel); - if (position is null) - { - return; - } - - _adornerLayer = AdornerLayer.GetAdornerLayer(panel); - if (_adornerLayer is null) - { - return; - } - - _startOffset = Orientation == AvaloniaOrientation.Vertical ? position.Value.Y : position.Value.X; - _previewAdorner = new FlatProportionalSplitterPreviewAdorner - { - Orientation = Orientation, - Thickness = Thickness, - Offset = _startOffset, - [AdornerLayer.AdornedElementProperty] = panel - }; - - ((ISetLogicalParent)_previewAdorner).SetParent(panel); - _adornerLayer.Children.Add(_previewAdorner); - } - - /// - protected override void OnPointerMoved(PointerEventArgs e) - { - base.OnPointerMoved(e); - - if (!_isMoving || !IsResizingEnabled || GetPanel() is not { } panel) - { - return; - } - - var point = e.GetPosition(panel); - var delta = point - _startPoint; - var axisDelta = Orientation == AvaloniaOrientation.Vertical ? delta.Y : delta.X; - - if (PreviewResize) - { - if (_previewAdorner is not null) - { - _previewAdorner.Offset = _startOffset + axisDelta; - _previewAdorner.InvalidateVisual(); - } - return; - } - - _startPoint = point; - panel.ResizeSplitter(this, axisDelta); - } - - /// - protected override void OnPointerReleased(PointerReleasedEventArgs e) - { - base.OnPointerReleased(e); - - if (_isMoving && IsResizingEnabled && GetPanel() is { } panel && PreviewResize) - { - var point = e.GetPosition(panel); - var delta = point - _startPoint; - panel.ResizeSplitter(this, Orientation == AvaloniaOrientation.Vertical ? delta.Y : delta.X); - } - - RemovePreviewAdorner(); - _isMoving = false; - UpdatePreviewPseudoClass(); - } - - /// - protected override void OnPointerCaptureLost(PointerCaptureLostEventArgs e) - { - base.OnPointerCaptureLost(e); - - RemovePreviewAdorner(); - _isMoving = false; - UpdatePreviewPseudoClass(); - } - - private FlatProportionalDockPanel? GetPanel() - { - return this.FindAncestorOfType(); - } - - private void UpdateVisualState() - { - if (Orientation == AvaloniaOrientation.Vertical) - { - Height = Thickness; - Width = double.NaN; - Cursor = IsResizingEnabled ? new Cursor(StandardCursorType.SizeNorthSouth) : new Cursor(StandardCursorType.Arrow); - PseudoClasses.Set(":vertical", true); - PseudoClasses.Set(":horizontal", false); - return; - } - - Width = Thickness; - Height = double.NaN; - Cursor = IsResizingEnabled ? new Cursor(StandardCursorType.SizeWestEast) : new Cursor(StandardCursorType.Arrow); - PseudoClasses.Set(":horizontal", true); - PseudoClasses.Set(":vertical", false); - } - - private void UpdatePreviewPseudoClass() - { - PseudoClasses.Set(":preview", PreviewResize && _isMoving); - } - - private void RemovePreviewAdorner() - { - if (_previewAdorner is null || _adornerLayer is null) - { - _previewAdorner = null; - _adornerLayer = null; - return; - } - - _adornerLayer.Children.Remove(_previewAdorner); - ((ISetLogicalParent)_previewAdorner).SetParent(null); - _previewAdorner = null; - _adornerLayer = null; - } + public new IProportionalDock? OwnerDock => + base.OwnerDock is DockFlatProportionalAdapter.DockFlatDockAdapter adapter ? adapter.Dock : null; } diff --git a/src/Dock.Avalonia/Controls/FlatProportionalSplitterPreviewAdorner.cs b/src/Dock.Avalonia/Controls/FlatProportionalSplitterPreviewAdorner.cs deleted file mode 100644 index 71623a77b..000000000 --- a/src/Dock.Avalonia/Controls/FlatProportionalSplitterPreviewAdorner.cs +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright (c) Wiesław Šoltés. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for details. - -using Avalonia; -using Avalonia.Controls; -using Avalonia.Media; -using System; -using AvaloniaOrientation = Avalonia.Layout.Orientation; - -namespace Dock.Avalonia.Controls; - -internal sealed class FlatProportionalSplitterPreviewAdorner : Control -{ - public static readonly StyledProperty OrientationProperty = - AvaloniaProperty.Register(nameof(Orientation)); - - public static readonly StyledProperty ThicknessProperty = - AvaloniaProperty.Register(nameof(Thickness), 4.0); - - public static readonly StyledProperty OffsetProperty = - AvaloniaProperty.Register(nameof(Offset)); - - public static readonly StyledProperty PreviewBrushProperty = - AvaloniaProperty.Register( - nameof(PreviewBrush), - new SolidColorBrush(Color.FromArgb(96, 0, 120, 212))); - - public AvaloniaOrientation Orientation - { - get => GetValue(OrientationProperty); - set => SetValue(OrientationProperty, value); - } - - public double Thickness - { - get => GetValue(ThicknessProperty); - set => SetValue(ThicknessProperty, value); - } - - public double Offset - { - get => GetValue(OffsetProperty); - set => SetValue(OffsetProperty, value); - } - - public IBrush? PreviewBrush - { - get => GetValue(PreviewBrushProperty); - set => SetValue(PreviewBrushProperty, value); - } - - public override void Render(DrawingContext context) - { - base.Render(context); - - if (PreviewBrush is not { } brush) - { - return; - } - - var thickness = Math.Max(1.0, Thickness); - var rect = Orientation == AvaloniaOrientation.Vertical - ? new Rect(0, Offset, Bounds.Width, thickness) - : new Rect(Offset, 0, thickness, Bounds.Height); - - context.FillRectangle(brush, rect); - } -} diff --git a/src/Dock.Avalonia/Dock.Avalonia.csproj b/src/Dock.Avalonia/Dock.Avalonia.csproj index 2083c5ae9..711a2df73 100644 --- a/src/Dock.Avalonia/Dock.Avalonia.csproj +++ b/src/Dock.Avalonia/Dock.Avalonia.csproj @@ -31,6 +31,7 @@ + diff --git a/src/Dock.Avalonia/Internal/DockFlatProportionalAdapter.cs b/src/Dock.Avalonia/Internal/DockFlatProportionalAdapter.cs new file mode 100644 index 000000000..5dd4a63ef --- /dev/null +++ b/src/Dock.Avalonia/Internal/DockFlatProportionalAdapter.cs @@ -0,0 +1,316 @@ +// 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; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.ComponentModel; +using Dock.Controls.Flat; +using Dock.Model.Controls; +using Dock.Model.Core; +using DockOrientation = Dock.Model.Core.Orientation; + +namespace Dock.Avalonia.Internal; + +internal sealed class DockFlatProportionalAdapter +{ + private readonly Dictionary _items = new(ReferenceEqualityComparer.Instance); + + public IFlatProportionalDock? GetDock(IProportionalDock? dock) + { + return dock is null ? null : (IFlatProportionalDock)GetItem(dock); + } + + private DockFlatItemAdapter GetItem(IDockable dockable) + { + if (_items.TryGetValue(dockable, out var item)) + { + return item; + } + + item = dockable switch + { + IProportionalDock proportionalDock => new DockFlatDockAdapter(this, proportionalDock), + IProportionalDockSplitter splitter => new DockFlatSplitterAdapter(splitter), + _ => new DockFlatItemAdapter(dockable) + }; + + _items[dockable] = item; + return item; + } + + private sealed class DockFlatItemList : IList, INotifyCollectionChanged, IDisposable + { + private readonly DockFlatProportionalAdapter _owner; + private readonly IList _items; + private readonly INotifyCollectionChanged? _collectionChanged; + + public DockFlatItemList(DockFlatProportionalAdapter owner, IList items) + { + _owner = owner; + _items = items; + _collectionChanged = items as INotifyCollectionChanged; + + if (_collectionChanged is not null) + { + _collectionChanged.CollectionChanged += OnCollectionChanged; + } + } + + public event NotifyCollectionChangedEventHandler? CollectionChanged; + + public int Count => _items.Count; + + public bool IsReadOnly => true; + + public IFlatProportionalItem this[int index] + { + get => _owner.GetItem(_items[index]); + set => throw new NotSupportedException(); + } + + public int IndexOf(IFlatProportionalItem item) + { + if (item is not DockFlatItemAdapter adapter) + { + return -1; + } + + return _items.IndexOf(adapter.Dockable); + } + + public bool Contains(IFlatProportionalItem item) + { + return IndexOf(item) >= 0; + } + + public void CopyTo(IFlatProportionalItem[] array, int arrayIndex) + { + for (var index = 0; index < _items.Count; index++) + { + array[arrayIndex + index] = _owner.GetItem(_items[index]); + } + } + + public IEnumerator GetEnumerator() + { + foreach (var dockable in _items) + { + yield return _owner.GetItem(dockable); + } + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + public void Insert(int index, IFlatProportionalItem item) + { + throw new NotSupportedException(); + } + + public void RemoveAt(int index) + { + throw new NotSupportedException(); + } + + public void Add(IFlatProportionalItem item) + { + throw new NotSupportedException(); + } + + public void Clear() + { + throw new NotSupportedException(); + } + + public bool Remove(IFlatProportionalItem item) + { + throw new NotSupportedException(); + } + + public void Dispose() + { + if (_collectionChanged is not null) + { + _collectionChanged.CollectionChanged -= OnCollectionChanged; + } + } + + private void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + CollectionChanged?.Invoke(this, Translate(e)); + } + + private NotifyCollectionChangedEventArgs Translate(NotifyCollectionChangedEventArgs e) + { + return e.Action switch + { + NotifyCollectionChangedAction.Add => new NotifyCollectionChangedEventArgs( + NotifyCollectionChangedAction.Add, + Wrap(e.NewItems), + e.NewStartingIndex), + NotifyCollectionChangedAction.Remove => new NotifyCollectionChangedEventArgs( + NotifyCollectionChangedAction.Remove, + Wrap(e.OldItems), + e.OldStartingIndex), + NotifyCollectionChangedAction.Replace => new NotifyCollectionChangedEventArgs( + NotifyCollectionChangedAction.Replace, + Wrap(e.NewItems), + Wrap(e.OldItems), + e.NewStartingIndex), + NotifyCollectionChangedAction.Move => new NotifyCollectionChangedEventArgs( + NotifyCollectionChangedAction.Move, + Wrap(e.NewItems), + e.NewStartingIndex, + e.OldStartingIndex), + _ => new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset) + }; + } + + private IList Wrap(IList? items) + { + var result = new List(); + if (items is null) + { + return result; + } + + foreach (var item in items) + { + if (item is IDockable dockable) + { + result.Add(_owner.GetItem(dockable)); + } + } + + return result; + } + } + + internal class DockFlatItemAdapter : IFlatProportionalItem, INotifyPropertyChanged + { + public DockFlatItemAdapter(IDockable dockable) + { + Dockable = dockable; + + if (dockable is INotifyPropertyChanged propertyChanged) + { + propertyChanged.PropertyChanged += OnDockablePropertyChanged; + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + + public IDockable Dockable { get; } + + public object Key => Dockable; + + public object? Content => Dockable; + + public double Proportion + { + get => Dockable.Proportion; + set => Dockable.Proportion = value; + } + + public double CollapsedProportion + { + get => Dockable.CollapsedProportion; + set => Dockable.CollapsedProportion = value; + } + + public double MinWidth => Dockable.MinWidth; + + public double MinHeight => Dockable.MinHeight; + + public double MaxWidth => Dockable.MaxWidth; + + public double MaxHeight => Dockable.MaxHeight; + + public bool IsCollapsable => Dockable.IsCollapsable; + + public bool IsEmpty => Dockable.IsEmpty; + + protected virtual string? MapPropertyName(string? propertyName) + { + return propertyName; + } + + private void OnDockablePropertyChanged(object? sender, PropertyChangedEventArgs e) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(MapPropertyName(e.PropertyName))); + } + } + + internal sealed class DockFlatDockAdapter : DockFlatItemAdapter, IFlatProportionalDock + { + private readonly DockFlatProportionalAdapter _owner; + private IList? _sourceVisibleItems; + private DockFlatItemList? _visibleItems; + + public DockFlatDockAdapter(DockFlatProportionalAdapter owner, IProportionalDock dock) + : base(dock) + { + _owner = owner; + Dock = dock; + } + + public IProportionalDock Dock { get; } + + public global::Avalonia.Layout.Orientation Orientation => Dock.Orientation == DockOrientation.Vertical + ? global::Avalonia.Layout.Orientation.Vertical + : global::Avalonia.Layout.Orientation.Horizontal; + + public IList? VisibleItems + { + get + { + if (Dock.VisibleDockables is null) + { + _sourceVisibleItems = null; + _visibleItems?.Dispose(); + _visibleItems = null; + return null; + } + + if (ReferenceEquals(_sourceVisibleItems, Dock.VisibleDockables) && _visibleItems is not null) + { + return _visibleItems; + } + + _visibleItems?.Dispose(); + _sourceVisibleItems = Dock.VisibleDockables; + _visibleItems = new DockFlatItemList(_owner, Dock.VisibleDockables); + return _visibleItems; + } + } + + protected override string? MapPropertyName(string? propertyName) + { + return propertyName switch + { + nameof(IDock.VisibleDockables) => nameof(VisibleItems), + nameof(IProportionalDock.Orientation) => nameof(Orientation), + _ => propertyName + }; + } + } + + internal sealed class DockFlatSplitterAdapter : DockFlatItemAdapter, IFlatProportionalSplitter + { + public DockFlatSplitterAdapter(IProportionalDockSplitter splitter) + : base(splitter) + { + Splitter = splitter; + } + + public IProportionalDockSplitter Splitter { get; } + + public bool CanResize => Splitter.CanResize; + + public bool ResizePreview => Splitter.ResizePreview; + } +} diff --git a/tests/Dock.Avalonia.HeadlessTests/Dock.Avalonia.HeadlessTests.csproj b/tests/Dock.Avalonia.HeadlessTests/Dock.Avalonia.HeadlessTests.csproj index e4fa18fa4..be19e7c9f 100644 --- a/tests/Dock.Avalonia.HeadlessTests/Dock.Avalonia.HeadlessTests.csproj +++ b/tests/Dock.Avalonia.HeadlessTests/Dock.Avalonia.HeadlessTests.csproj @@ -16,6 +16,7 @@ + From 2153d170071aa58028fd01dd8bdb15300b640c4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 30 Jun 2026 07:31:21 +0200 Subject: [PATCH 12/31] Address flat panel review feedback --- .../DockDeferredContentSample/App.axaml.cs | 2 + samples/DockReactiveUIFlatSample/App.axaml.cs | 2 + .../Controls/FlatProportionalDockPanel.cs | 44 +++- .../Internal/DockFlatProportionalAdapter.cs | 37 +++- .../FlatProportionalPanel.cs | 190 +++++++++++++++++- .../FlatProportionalPanelTests.cs | 155 +++++++++++++- 6 files changed, 411 insertions(+), 19 deletions(-) diff --git a/samples/DockDeferredContentSample/App.axaml.cs b/samples/DockDeferredContentSample/App.axaml.cs index db9542fec..10509d1ca 100644 --- a/samples/DockDeferredContentSample/App.axaml.cs +++ b/samples/DockDeferredContentSample/App.axaml.cs @@ -1,7 +1,9 @@ using System; using Avalonia; using Avalonia.Controls.ApplicationLifetimes; +#if DEBUG using Avalonia.Diagnostics; +#endif using Avalonia.Markup.Xaml; using Dock.Controls.DeferredContentControl; using DockDeferredContentSample.ViewModels; diff --git a/samples/DockReactiveUIFlatSample/App.axaml.cs b/samples/DockReactiveUIFlatSample/App.axaml.cs index fc57eb069..e31fa7c77 100644 --- a/samples/DockReactiveUIFlatSample/App.axaml.cs +++ b/samples/DockReactiveUIFlatSample/App.axaml.cs @@ -1,7 +1,9 @@ using System.Diagnostics.CodeAnalysis; using Avalonia; using Avalonia.Controls.ApplicationLifetimes; +#if DEBUG using Avalonia.Diagnostics; +#endif using Avalonia.Input; using Avalonia.Markup.Xaml; using Dock.Avalonia.Diagnostics.Controls; diff --git a/src/Dock.Avalonia/Controls/FlatProportionalDockPanel.cs b/src/Dock.Avalonia/Controls/FlatProportionalDockPanel.cs index 1bc71c7fb..20bef1152 100644 --- a/src/Dock.Avalonia/Controls/FlatProportionalDockPanel.cs +++ b/src/Dock.Avalonia/Controls/FlatProportionalDockPanel.cs @@ -18,7 +18,7 @@ namespace Dock.Avalonia.Controls; /// public class FlatProportionalDockPanel : FlatProportionalPanel { - private readonly DockFlatProportionalAdapter _adapter = new(); + private DockFlatProportionalAdapter? _adapter; /// /// Defines the property. @@ -42,10 +42,50 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang if (change.Property == DockProperty) { - Root = _adapter.GetDock(Dock); + SetDockRoot(Dock); } } + /// + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + + if (Root is null && Dock is not null) + { + SetDockRoot(Dock); + } + } + + /// + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + + DisposeAdapter(); + Root = null; + } + + private void SetDockRoot(IProportionalDock? dock) + { + DisposeAdapter(); + + if (dock is null) + { + Root = null; + return; + } + + _adapter = new DockFlatProportionalAdapter(); + Root = _adapter.GetDock(dock); + } + + private void DisposeAdapter() + { + _adapter?.Dispose(); + _adapter = null; + } + /// protected override Control CreateDockSurface(IFlatProportionalDock dock) { diff --git a/src/Dock.Avalonia/Internal/DockFlatProportionalAdapter.cs b/src/Dock.Avalonia/Internal/DockFlatProportionalAdapter.cs index 5dd4a63ef..7876b4540 100644 --- a/src/Dock.Avalonia/Internal/DockFlatProportionalAdapter.cs +++ b/src/Dock.Avalonia/Internal/DockFlatProportionalAdapter.cs @@ -13,7 +13,7 @@ namespace Dock.Avalonia.Internal; -internal sealed class DockFlatProportionalAdapter +internal sealed class DockFlatProportionalAdapter : IDisposable { private readonly Dictionary _items = new(ReferenceEqualityComparer.Instance); @@ -40,6 +40,16 @@ private DockFlatItemAdapter GetItem(IDockable dockable) return item; } + public void Dispose() + { + foreach (var item in _items.Values) + { + item.Dispose(); + } + + _items.Clear(); + } + private sealed class DockFlatItemList : IList, INotifyCollectionChanged, IDisposable { private readonly DockFlatProportionalAdapter _owner; @@ -190,15 +200,18 @@ private IList Wrap(IList? items) } } - internal class DockFlatItemAdapter : IFlatProportionalItem, INotifyPropertyChanged + internal class DockFlatItemAdapter : IFlatProportionalItem, INotifyPropertyChanged, IDisposable { + private readonly INotifyPropertyChanged? _dockablePropertyChanged; + public DockFlatItemAdapter(IDockable dockable) { Dockable = dockable; + _dockablePropertyChanged = dockable as INotifyPropertyChanged; - if (dockable is INotifyPropertyChanged propertyChanged) + if (_dockablePropertyChanged is not null) { - propertyChanged.PropertyChanged += OnDockablePropertyChanged; + _dockablePropertyChanged.PropertyChanged += OnDockablePropertyChanged; } } @@ -243,6 +256,14 @@ private void OnDockablePropertyChanged(object? sender, PropertyChangedEventArgs { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(MapPropertyName(e.PropertyName))); } + + public virtual void Dispose() + { + if (_dockablePropertyChanged is not null) + { + _dockablePropertyChanged.PropertyChanged -= OnDockablePropertyChanged; + } + } } internal sealed class DockFlatDockAdapter : DockFlatItemAdapter, IFlatProportionalDock @@ -297,6 +318,14 @@ public IList? VisibleItems _ => propertyName }; } + + public override void Dispose() + { + _visibleItems?.Dispose(); + _visibleItems = null; + _sourceVisibleItems = null; + base.Dispose(); + } } internal sealed class DockFlatSplitterAdapter : DockFlatItemAdapter, IFlatProportionalSplitter diff --git a/src/Dock.Controls.Flat/FlatProportionalPanel.cs b/src/Dock.Controls.Flat/FlatProportionalPanel.cs index c73bcef57..a16d704d1 100644 --- a/src/Dock.Controls.Flat/FlatProportionalPanel.cs +++ b/src/Dock.Controls.Flat/FlatProportionalPanel.cs @@ -192,6 +192,17 @@ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e _hasPendingRebuild = false; } + /// + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + + if (Root is not null) + { + RequestRebuildVisualTree(); + } + } + /// protected override Size MeasureOverride(Size availableSize) { @@ -204,7 +215,7 @@ protected override Size MeasureOverride(Size availableSize) MeasureDock(dock, availableSize); MeasureRemovalPresenters(); - return NormalizeDesiredSize(availableSize); + return NormalizeDesiredSize(availableSize, dock); } /// @@ -792,11 +803,33 @@ private void DockablePropertyChanged(object? sender, PropertyChangedEventArgs e) return; } + if (e.PropertyName == nameof(IFlatProportionalItem.Content) + && sender is IFlatProportionalItem item) + { + UpdateItemContent(item); + return; + } + CaptureConnectedStartBounds(); InvalidateMeasure(); InvalidateArrange(); } + private void UpdateItemContent(IFlatProportionalItem item) + { + if (_presenters.TryGetValue(GetItemKey(item), out var presenter)) + { + presenter.Content = item.Content; + presenter.DataContext = item.Content ?? item; + } + + if (item is IFlatProportionalDock + && _dockSurfaces.TryGetValue(GetItemKey(item), out var surface)) + { + surface.DataContext = item.Content ?? item; + } + } + private void UpdateSplitterThickness() { foreach (var splitter in _splitters.Values) @@ -1457,14 +1490,14 @@ private void StartInsertAnimation(ContentPresenter presenter) var visual = ElementComposition.GetElementVisual(presenter); if (visual is null) { - CompleteInsertAnimation(presenter, version); + CompleteInsertAnimationAfterDelay(presenter, version, GetTransitionCompletionDelay()); return; } var compositor = visual.Compositor; if (compositor is null) { - CompleteInsertAnimation(presenter, version); + CompleteInsertAnimationAfterDelay(presenter, version, GetTransitionCompletionDelay()); return; } @@ -1504,7 +1537,7 @@ private void StartLayoutAnimation(ContentPresenter presenter, ConnectedAnimation if (compositor is null || !StartConnectedAnimation(presenter, animation, useScaleForSize: false, useCurrentCompositionStart)) { - CompleteLayoutAnimation(presenter, version); + CompleteLayoutAnimationAfterDelay(presenter, version, GetTransitionCompletionDelay()); return; } @@ -1522,6 +1555,12 @@ private async void CompleteLayoutAnimationAfterCommit( Dispatcher.UIThread.Post(() => CompleteLayoutAnimation(presenter, version), DispatcherPriority.Background); } + private async void CompleteLayoutAnimationAfterDelay(ContentPresenter presenter, int version, TimeSpan delay) + { + await Task.Delay(delay); + Dispatcher.UIThread.Post(() => CompleteLayoutAnimation(presenter, version), DispatcherPriority.Background); + } + private int NextLayoutAnimationVersion(ContentPresenter presenter) { var version = _layoutAnimationVersions.TryGetValue(presenter, out var currentVersion) @@ -1573,6 +1612,12 @@ private async void CompleteInsertAnimationAfterCommit( Dispatcher.UIThread.Post(() => CompleteInsertAnimation(presenter, version), DispatcherPriority.Background); } + private async void CompleteInsertAnimationAfterDelay(ContentPresenter presenter, int version, TimeSpan delay) + { + await Task.Delay(delay); + Dispatcher.UIThread.Post(() => CompleteInsertAnimation(presenter, version), DispatcherPriority.Background); + } + private int NextInsertAnimationVersion(ContentPresenter presenter) { var version = _insertAnimationVersions.TryGetValue(presenter, out var currentVersion) @@ -2227,9 +2272,9 @@ private void AssignProportions(IFlatProportionalDock dock, Size size, double spl continue; } - var target = IsValidProportion(dockable.CollapsedProportion) - ? dockable.CollapsedProportion - : dockable.Proportion; + var target = IsValidProportion(dockable.Proportion) + ? dockable.Proportion + : dockable.CollapsedProportion; if (IsValidProportion(target)) { @@ -2420,13 +2465,138 @@ private static bool ShouldUseSplitter(IList dockables, in return dockable is IFlatProportionalSplitter ? null : dockable; } - private static Size NormalizeDesiredSize(Size availableSize) + private Size NormalizeDesiredSize(Size availableSize, IFlatProportionalDock dock) { - var width = double.IsInfinity(availableSize.Width) ? 0 : availableSize.Width; - var height = double.IsInfinity(availableSize.Height) ? 0 : availableSize.Height; + var desired = CalculateDesiredSize(dock); + var width = double.IsInfinity(availableSize.Width) ? desired.Width : availableSize.Width; + var height = double.IsInfinity(availableSize.Height) ? desired.Height : availableSize.Height; return new Size(width, height); } + private Size CalculateDesiredSize(IFlatProportionalItem item) + { + return item is IFlatProportionalDock dock + ? CalculateDockDesiredSize(dock) + : CalculateItemDesiredSize(item); + } + + private Size CalculateDockDesiredSize(IFlatProportionalDock dock) + { + var surfaceDesired = GetDockSurfaceDesiredSize(dock); + if (dock.VisibleItems is not { } visibleDockables || visibleDockables.Count == 0) + { + return surfaceDesired; + } + + var width = 0.0; + var height = 0.0; + + for (var i = 0; i < visibleDockables.Count; i++) + { + var dockable = visibleDockables[i]; + Size childDesired; + + if (dockable is IFlatProportionalSplitter splitter) + { + if (!ShouldUseSplitter(visibleDockables, i)) + { + continue; + } + + childDesired = CalculateSplitterDesiredSize(splitter, dock.Orientation); + } + else if (IsCollapsed(dockable)) + { + childDesired = default; + } + else + { + childDesired = CalculateDesiredSize(dockable); + } + + if (dock.Orientation == Avalonia.Layout.Orientation.Vertical) + { + width = Math.Max(width, childDesired.Width); + height += childDesired.Height; + } + else + { + width += childDesired.Width; + height = Math.Max(height, childDesired.Height); + } + } + + width = Math.Max(width, surfaceDesired.Width); + height = Math.Max(height, surfaceDesired.Height); + width = ApplyDesiredConstraints(width, dock.MinWidth, dock.MaxWidth); + height = ApplyDesiredConstraints(height, dock.MinHeight, dock.MaxHeight); + + return new Size(width, height); + } + + private Size CalculateItemDesiredSize(IFlatProportionalItem dockable) + { + var width = 0.0; + var height = 0.0; + + if (_presenters.TryGetValue(GetItemKey(dockable), out var presenter)) + { + width = GetFiniteDesiredDimension(presenter.DesiredSize.Width); + height = GetFiniteDesiredDimension(presenter.DesiredSize.Height); + } + + width = ApplyDesiredConstraints(width, dockable.MinWidth, dockable.MaxWidth); + height = ApplyDesiredConstraints(height, dockable.MinHeight, dockable.MaxHeight); + + return new Size(width, height); + } + + private Size GetDockSurfaceDesiredSize(IFlatProportionalDock dock) + { + if (!_dockSurfaces.TryGetValue(GetItemKey(dock), out var surface)) + { + return default; + } + + return new Size( + GetFiniteDesiredDimension(surface.DesiredSize.Width), + GetFiniteDesiredDimension(surface.DesiredSize.Height)); + } + + private Size CalculateSplitterDesiredSize(IFlatProportionalSplitter splitter, Avalonia.Layout.Orientation orientation) + { + var thickness = SplitterThickness; + if (_splitters.TryGetValue(GetItemKey(splitter), out var splitterControl)) + { + thickness = splitterControl.Thickness; + } + + return orientation == Avalonia.Layout.Orientation.Vertical + ? new Size(0, thickness) + : new Size(thickness, 0); + } + + private static double ApplyDesiredConstraints(double value, double minimum, double maximum) + { + var constrained = GetFiniteDesiredDimension(value); + if (!double.IsNaN(minimum) && !double.IsInfinity(minimum) && minimum > constrained) + { + constrained = minimum; + } + + if (!double.IsNaN(maximum) && !double.IsPositiveInfinity(maximum) && maximum < constrained) + { + constrained = Math.Max(0, maximum); + } + + return constrained; + } + + private static double GetFiniteDesiredDimension(double value) + { + return double.IsNaN(value) || double.IsInfinity(value) || value < 0 ? 0 : value; + } + private static Size CreateChildSize(Size availableSize, Avalonia.Layout.Orientation orientation, double length) { return orientation == Avalonia.Layout.Orientation.Vertical diff --git a/tests/Dock.Controls.Flat.UnitTests/FlatProportionalPanelTests.cs b/tests/Dock.Controls.Flat.UnitTests/FlatProportionalPanelTests.cs index 95765316b..cf2f6298a 100644 --- a/tests/Dock.Controls.Flat.UnitTests/FlatProportionalPanelTests.cs +++ b/tests/Dock.Controls.Flat.UnitTests/FlatProportionalPanelTests.cs @@ -1,11 +1,13 @@ using System.Collections.Generic; using System.Collections.ObjectModel; +using System.ComponentModel; using System.Linq; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Presenters; using Avalonia.Headless.XUnit; using Avalonia.Layout; +using Avalonia.Threading; using Xunit; namespace Dock.Controls.Flat.UnitTests; @@ -143,6 +145,127 @@ public void Rebuild_Reuses_Visuals_By_ItemKey() Assert.Same(firstPresenter, reusedPresenter); } + [AvaloniaFact] + public void Measure_Uses_LiveProportion_When_CollapsedProportion_IsStale() + { + var left = new ObservableTestItem("Left", 0.25) { CollapsedProportion = 0.25 }; + var right = new ObservableTestItem("Right", 0.75) { CollapsedProportion = 0.75 }; + var root = new TestDock( + "Root", + Orientation.Horizontal, + 1.0, + new IFlatProportionalItem[] { left, new TestSplitter("Splitter"), right }); + var panel = new FlatProportionalPanel { Root = root }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + left.UpdateProportion(0.4); + right.UpdateProportion(0.6); + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + Assert.Equal(0.4, left.Proportion, 2); + Assert.Equal(0.6, right.Proportion, 2); + Assert.Equal(left.Proportion, left.CollapsedProportion); + Assert.Equal(right.Proportion, right.CollapsedProportion); + } + + [AvaloniaFact] + public void Measure_Returns_ChildDesiredSize_For_UnboundedAxis() + { + var left = new TestItem("Left", 1.0) + { + Content = new Border + { + Width = 123, + Height = 45 + }, + MinWidthValue = 123, + MinHeightValue = 45 + }; + var root = new TestDock( + "Root", + Orientation.Horizontal, + 1.0, + new IFlatProportionalItem[] { left }); + var panel = new FlatProportionalPanel { Root = root }; + + panel.Measure(new Size(double.PositiveInfinity, 100)); + + Assert.True(panel.DesiredSize.Width >= 123); + Assert.Equal(100, panel.DesiredSize.Height); + } + + [AvaloniaFact] + public void ContentChange_Refreshes_ExistingPresenter() + { + var item = new ObservableTestItem("Item", 1.0); + var root = new TestDock( + "Root", + Orientation.Horizontal, + 1.0, + new IFlatProportionalItem[] { item }); + var panel = new FlatProportionalPanel { Root = root }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var nextContent = new TextBlock { Text = "Updated" }; + item.UpdateContent(nextContent); + + var presenter = panel.Children.OfType().Single(); + + Assert.Same(nextContent, presenter.Content); + Assert.Same(nextContent, presenter.DataContext); + } + + [AvaloniaFact] + public void Reattach_Resubscribes_VisibleItemsChanges() + { + var left = new TestItem("Left", 0.5); + var right = new TestItem("Right", 0.5); + var items = new ObservableCollection { left, new TestSplitter("Splitter"), right }; + var root = new TestDock("Root", Orientation.Horizontal, 1.0, items); + var panel = new FlatProportionalPanel + { + Root = root, + UseLayoutTransitions = false + }; + var window = new Window + { + Content = panel, + Width = 1000, + Height = 600 + }; + + try + { + window.Show(); + Dispatcher.UIThread.RunJobs(); + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + window.Content = null; + Dispatcher.UIThread.RunJobs(); + window.Content = panel; + Dispatcher.UIThread.RunJobs(); + + items.Remove(right); + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var presenter = panel.Children.OfType().Single(); + + Assert.Same(left.Content, presenter.Content); + } + finally + { + window.Close(); + Dispatcher.UIThread.RunJobs(); + } + } + private class TestItem : IFlatProportionalItem { public TestItem(string id, double proportion) @@ -155,15 +278,19 @@ public TestItem(string id, double proportion) public object Key { get; } - public object? Content { get; } + public object? Content { get; set; } public double Proportion { get; set; } public double CollapsedProportion { get; set; } - public double MinWidth => 0; + public double MinWidthValue { get; init; } + + public double MinHeightValue { get; init; } - public double MinHeight => 0; + public double MinWidth => MinWidthValue; + + public double MinHeight => MinHeightValue; public double MaxWidth => double.PositiveInfinity; @@ -205,4 +332,26 @@ public TestSplitter(string id) public bool ResizePreview => false; } + + private sealed class ObservableTestItem : TestItem, INotifyPropertyChanged + { + public ObservableTestItem(string id, double proportion) + : base(id, proportion) + { + } + + public event PropertyChangedEventHandler? PropertyChanged; + + public void UpdateContent(object? content) + { + Content = content; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Content))); + } + + public void UpdateProportion(double proportion) + { + Proportion = proportion; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Proportion))); + } + } } From 4347f75f4aee55dec06708c697dc048505e474a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 30 Jun 2026 07:42:39 +0200 Subject: [PATCH 13/31] Stabilize flat panel transition tests --- src/Dock.Controls.Flat/FlatProportionalPanel.cs | 3 ++- .../FlatProportionalDockPanelTests.cs | 7 +++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Dock.Controls.Flat/FlatProportionalPanel.cs b/src/Dock.Controls.Flat/FlatProportionalPanel.cs index a16d704d1..195ee8cc1 100644 --- a/src/Dock.Controls.Flat/FlatProportionalPanel.cs +++ b/src/Dock.Controls.Flat/FlatProportionalPanel.cs @@ -1104,7 +1104,8 @@ private void QueueConnectedAnimation(Control control, Rect previousBounds, Rect _pendingLayoutAnimations.Remove(presenter); if (_activeLayoutPresenters.Contains(presenter)) { - CompleteLayoutAnimation(presenter); + presenter.Opacity = 1.0; + presenter.IsHitTestVisible = false; return; } diff --git a/tests/Dock.Avalonia.HeadlessTests/FlatProportionalDockPanelTests.cs b/tests/Dock.Avalonia.HeadlessTests/FlatProportionalDockPanelTests.cs index d5160d7e8..5574f2375 100644 --- a/tests/Dock.Avalonia.HeadlessTests/FlatProportionalDockPanelTests.cs +++ b/tests/Dock.Avalonia.HeadlessTests/FlatProportionalDockPanelTests.cs @@ -345,8 +345,8 @@ public async Task FlatProportionalDockPanel_Collection_Move_Animates_Moved_Prese Assert.False(leftPresenter.IsHitTestVisible); Assert.True(rightPresenter.IsHitTestVisible); - await Task.Delay(120); - Dispatcher.UIThread.RunJobs(); + await WaitForAsync(() => leftPresenter.IsHitTestVisible + && rightPresenter.IsHitTestVisible); Assert.True(leftPresenter.IsHitTestVisible); Assert.True(rightPresenter.IsHitTestVisible); @@ -851,8 +851,7 @@ public async Task FlatProportionalDockPanel_Reinserted_Dockable_Cancels_Exit_And Assert.Equal(1.0, reinsertedRightPresenter.Opacity); Assert.False(reinsertedRightPresenter.IsHitTestVisible); - await Task.Delay(220); - Dispatcher.UIThread.RunJobs(); + await WaitForAsync(() => reinsertedRightPresenter.IsHitTestVisible); Assert.Same(rightPresenter, GetLivePresenters(panel) .Single(presenter => ReferenceEquals(presenter.Content, right))); From 411b24addd11b2572a278d0dfcad3a9bd9cbc3ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 30 Jun 2026 07:45:36 +0200 Subject: [PATCH 14/31] Avoid max clamp on unbounded flat measure --- .../FlatProportionalPanel.cs | 4 ++- .../FlatProportionalPanelTests.cs | 26 +++++++++++++++++-- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/Dock.Controls.Flat/FlatProportionalPanel.cs b/src/Dock.Controls.Flat/FlatProportionalPanel.cs index 195ee8cc1..a2fc08123 100644 --- a/src/Dock.Controls.Flat/FlatProportionalPanel.cs +++ b/src/Dock.Controls.Flat/FlatProportionalPanel.cs @@ -2344,7 +2344,9 @@ private static void NormalizeActiveProportions(IList dock private double ClampProportion(IFlatProportionalItem dockable, Avalonia.Layout.Orientation orientation, double availableLength, double proportion) { - if (!IsValidProportion(proportion)) + if (!IsValidProportion(proportion) + || !IsValidProportion(availableLength) + || availableLength <= 0) { return proportion; } diff --git a/tests/Dock.Controls.Flat.UnitTests/FlatProportionalPanelTests.cs b/tests/Dock.Controls.Flat.UnitTests/FlatProportionalPanelTests.cs index cf2f6298a..8f7373271 100644 --- a/tests/Dock.Controls.Flat.UnitTests/FlatProportionalPanelTests.cs +++ b/tests/Dock.Controls.Flat.UnitTests/FlatProportionalPanelTests.cs @@ -197,6 +197,24 @@ public void Measure_Returns_ChildDesiredSize_For_UnboundedAxis() Assert.Equal(100, panel.DesiredSize.Height); } + [AvaloniaFact] + public void Measure_With_UnboundedStackingAxis_Does_NotClamp_MaxConstraint_ToZero() + { + var left = new TestItem("Left", 0.4) { MaxWidthValue = 250 }; + var right = new TestItem("Right", 0.6); + var root = new TestDock( + "Root", + Orientation.Horizontal, + 1.0, + new IFlatProportionalItem[] { left, new TestSplitter("Splitter"), right }); + var panel = new FlatProportionalPanel { Root = root }; + + panel.Measure(new Size(double.PositiveInfinity, 100)); + + Assert.Equal(0.4, left.Proportion, 2); + Assert.Equal(0.6, right.Proportion, 2); + } + [AvaloniaFact] public void ContentChange_Refreshes_ExistingPresenter() { @@ -292,9 +310,13 @@ public TestItem(string id, double proportion) public double MinHeight => MinHeightValue; - public double MaxWidth => double.PositiveInfinity; + public double MaxWidthValue { get; init; } = double.PositiveInfinity; + + public double MaxHeightValue { get; init; } = double.PositiveInfinity; + + public double MaxWidth => MaxWidthValue; - public double MaxHeight => double.PositiveInfinity; + public double MaxHeight => MaxHeightValue; public bool IsCollapsable => false; From 920a8474de3fbb5050d55a4b7a505ac0d2610272 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 30 Jun 2026 07:55:12 +0200 Subject: [PATCH 15/31] Use condition waits in flat transition tests --- .../FlatProportionalDockPanelTests.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/Dock.Avalonia.HeadlessTests/FlatProportionalDockPanelTests.cs b/tests/Dock.Avalonia.HeadlessTests/FlatProportionalDockPanelTests.cs index 5574f2375..4c3e2f589 100644 --- a/tests/Dock.Avalonia.HeadlessTests/FlatProportionalDockPanelTests.cs +++ b/tests/Dock.Avalonia.HeadlessTests/FlatProportionalDockPanelTests.cs @@ -285,8 +285,7 @@ public async Task FlatProportionalDockPanel_Moved_Dockable_Animates_Live_Present Assert.False(leftPresenter.IsHitTestVisible); - await Task.Delay(120); - Dispatcher.UIThread.RunJobs(); + await WaitForAsync(() => leftPresenter.IsHitTestVisible); Assert.Equal(1.0, leftPresenter.Opacity); Assert.True(leftPresenter.IsHitTestVisible); @@ -1290,8 +1289,7 @@ public async Task FlatProportionalDockPanel_Inserted_Dockable_Keeps_Existing_Pre Assert.True(leftPresenter.IsHitTestVisible); - await Task.Delay(120); - Dispatcher.UIThread.RunJobs(); + await WaitForAsync(() => rightPresenter.IsHitTestVisible); Assert.True(leftPresenter.IsHitTestVisible); Assert.True(rightPresenter.IsHitTestVisible); From a37c9f977703afdb79dbd818969160fe79682ad2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 30 Jun 2026 07:59:44 +0200 Subject: [PATCH 16/31] Prune flat dock adapter cache --- .../Controls/FlatProportionalDockPanel.cs | 8 +++ .../Internal/DockFlatProportionalAdapter.cs | 37 +++++++++++++ .../FlatProportionalPanel.cs | 2 +- .../FlatProportionalDockPanelTests.cs | 31 +++++++++++ .../FlatProportionalPanelTests.cs | 55 ++++++++++++++++++- 5 files changed, 130 insertions(+), 3 deletions(-) diff --git a/src/Dock.Avalonia/Controls/FlatProportionalDockPanel.cs b/src/Dock.Avalonia/Controls/FlatProportionalDockPanel.cs index 20bef1152..b9a30e6ba 100644 --- a/src/Dock.Avalonia/Controls/FlatProportionalDockPanel.cs +++ b/src/Dock.Avalonia/Controls/FlatProportionalDockPanel.cs @@ -66,6 +66,14 @@ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e Root = null; } + /// + protected override Size ArrangeOverride(Size finalSize) + { + var result = base.ArrangeOverride(finalSize); + _adapter?.PruneUnreachable(Dock); + return result; + } + private void SetDockRoot(IProportionalDock? dock) { DisposeAdapter(); diff --git a/src/Dock.Avalonia/Internal/DockFlatProportionalAdapter.cs b/src/Dock.Avalonia/Internal/DockFlatProportionalAdapter.cs index 7876b4540..b8bca6735 100644 --- a/src/Dock.Avalonia/Internal/DockFlatProportionalAdapter.cs +++ b/src/Dock.Avalonia/Internal/DockFlatProportionalAdapter.cs @@ -22,6 +22,29 @@ internal sealed class DockFlatProportionalAdapter : IDisposable return dock is null ? null : (IFlatProportionalDock)GetItem(dock); } + public void PruneUnreachable(IDockable? root) + { + if (root is null) + { + Dispose(); + return; + } + + var reachable = new HashSet(ReferenceEqualityComparer.Instance); + CollectReachable(root, reachable); + + foreach (var item in new List>(_items)) + { + if (reachable.Contains(item.Key)) + { + continue; + } + + item.Value.Dispose(); + _items.Remove(item.Key); + } + } + private DockFlatItemAdapter GetItem(IDockable dockable) { if (_items.TryGetValue(dockable, out var item)) @@ -40,6 +63,20 @@ private DockFlatItemAdapter GetItem(IDockable dockable) return item; } + private static void CollectReachable(IDockable dockable, ISet reachable) + { + if (!reachable.Add(dockable) + || dockable is not IDock { VisibleDockables: { } visibleDockables }) + { + return; + } + + foreach (var child in visibleDockables) + { + CollectReachable(child, reachable); + } + } + public void Dispose() { foreach (var item in _items.Values) diff --git a/src/Dock.Controls.Flat/FlatProportionalPanel.cs b/src/Dock.Controls.Flat/FlatProportionalPanel.cs index a2fc08123..1e873c16f 100644 --- a/src/Dock.Controls.Flat/FlatProportionalPanel.cs +++ b/src/Dock.Controls.Flat/FlatProportionalPanel.cs @@ -487,7 +487,7 @@ private void AddSplitter(IFlatProportionalDock ownerDock, IFlatProportionalSplit control.Orientation = ownerDock.Orientation; control.Thickness = SplitterThickness; - if (control.DataContext is not IFlatProportionalSplitter) + if (!ReferenceEquals(control.DataContext, splitter)) { control.DataContext = splitter; } diff --git a/tests/Dock.Avalonia.HeadlessTests/FlatProportionalDockPanelTests.cs b/tests/Dock.Avalonia.HeadlessTests/FlatProportionalDockPanelTests.cs index 4c3e2f589..e58daf851 100644 --- a/tests/Dock.Avalonia.HeadlessTests/FlatProportionalDockPanelTests.cs +++ b/tests/Dock.Avalonia.HeadlessTests/FlatProportionalDockPanelTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.ObjectModel; using System.Collections.Generic; +using System.ComponentModel; using System.Linq; using System.Threading.Tasks; using Avalonia; @@ -10,6 +11,7 @@ using Avalonia.Rendering.Composition; using Avalonia.Threading; using Dock.Avalonia.Controls; +using Dock.Avalonia.Internal; using Dock.Model.Avalonia.Controls; using Dock.Model.Controls; using Dock.Model.Core; @@ -111,6 +113,35 @@ public void FlatProportionalDockPanel_LayoutTransitions_Default_And_CanSet() Assert.Equal(TimeSpan.FromMilliseconds(80), panel.LayoutTransitionDuration); } + [AvaloniaFact] + public void DockFlatProportionalAdapter_PruneUnreachable_Disposes_RemovedDockableAdapter() + { + var removed = new DocumentDock { Id = "Removed", Proportion = 1.0, CollapsedProportion = 1.0 }; + var root = new ProportionalDock + { + Id = "Root", + Orientation = Orientation.Horizontal, + VisibleDockables = new List { removed } + }; + using var adapter = new DockFlatProportionalAdapter(); + var flatRoot = adapter.GetDock(root); + var flatRemoved = Assert.IsAssignableFrom( + Assert.Single(flatRoot!.VisibleItems!)); + var changeCount = 0; + flatRemoved.PropertyChanged += (_, _) => changeCount++; + + removed.Proportion = 0.5; + + Assert.Equal(1, changeCount); + + changeCount = 0; + root.VisibleDockables = new List(); + adapter.PruneUnreachable(root); + removed.Proportion = 0.25; + + Assert.Equal(0, changeCount); + } + [AvaloniaFact] public void FlatProportionalDockPanel_Reuses_Existing_Visuals_When_Layout_Rebuilds() { diff --git a/tests/Dock.Controls.Flat.UnitTests/FlatProportionalPanelTests.cs b/tests/Dock.Controls.Flat.UnitTests/FlatProportionalPanelTests.cs index 8f7373271..44e779d21 100644 --- a/tests/Dock.Controls.Flat.UnitTests/FlatProportionalPanelTests.cs +++ b/tests/Dock.Controls.Flat.UnitTests/FlatProportionalPanelTests.cs @@ -145,6 +145,53 @@ public void Rebuild_Reuses_Visuals_By_ItemKey() Assert.Same(firstPresenter, reusedPresenter); } + [AvaloniaFact] + public void Rebuild_ReusedSplitter_Refreshes_DataContext() + { + var firstSplitter = new TestSplitter("Splitter") { CanResizeValue = false, ResizePreviewValue = true }; + var firstRoot = new TestDock( + "Root", + Orientation.Horizontal, + 1.0, + new IFlatProportionalItem[] + { + new TestItem("Left", 0.5), + firstSplitter, + new TestItem("Right", 0.5) + }); + var panel = new FlatProportionalPanel + { + Root = firstRoot, + UseLayoutTransitions = false + }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var firstSplitterControl = panel.Children.OfType().Single(); + var secondSplitter = new TestSplitter("Splitter") { CanResizeValue = true, ResizePreviewValue = false }; + var secondRoot = new TestDock( + "Root", + Orientation.Horizontal, + 1.0, + new IFlatProportionalItem[] + { + new TestItem("Left", 0.5), + secondSplitter, + new TestItem("Right", 0.5) + }); + + panel.Root = secondRoot; + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var reusedSplitterControl = panel.Children.OfType().Single(); + + Assert.Same(firstSplitterControl, reusedSplitterControl); + Assert.Same(secondSplitter, reusedSplitterControl.Splitter); + Assert.Same(secondSplitter, reusedSplitterControl.DataContext); + } + [AvaloniaFact] public void Measure_Uses_LiveProportion_When_CollapsedProportion_IsStale() { @@ -350,9 +397,13 @@ public TestSplitter(string id) { } - public bool CanResize => true; + public bool CanResizeValue { get; init; } = true; + + public bool ResizePreviewValue { get; init; } + + public bool CanResize => CanResizeValue; - public bool ResizePreview => false; + public bool ResizePreview => ResizePreviewValue; } private sealed class ObservableTestItem : TestItem, INotifyPropertyChanged From 29c5571406f3030a92d4b35a27a15fe17edc9b79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 30 Jun 2026 08:09:58 +0200 Subject: [PATCH 17/31] Stabilize pending insert transition test --- .../FlatProportionalDockPanelTests.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/Dock.Avalonia.HeadlessTests/FlatProportionalDockPanelTests.cs b/tests/Dock.Avalonia.HeadlessTests/FlatProportionalDockPanelTests.cs index e58daf851..f60309ada 100644 --- a/tests/Dock.Avalonia.HeadlessTests/FlatProportionalDockPanelTests.cs +++ b/tests/Dock.Avalonia.HeadlessTests/FlatProportionalDockPanelTests.cs @@ -1185,8 +1185,7 @@ public async Task FlatProportionalDockPanel_Pending_Insert_Retargets_Layout_With Assert.False(rightPresenter.IsHitTestVisible); - await Task.Delay(240); - Dispatcher.UIThread.RunJobs(); + await WaitForAsync(() => rightPresenter.IsHitTestVisible, timeoutMilliseconds: 2000); var visual = ElementComposition.GetElementVisual(rightPresenter); From d0218a84d4e6974d1446b44beedb95a67f37ff51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 30 Jun 2026 08:19:54 +0200 Subject: [PATCH 18/31] Remove timing checkpoint from retarget transition test --- .../FlatProportionalDockPanelTests.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/Dock.Avalonia.HeadlessTests/FlatProportionalDockPanelTests.cs b/tests/Dock.Avalonia.HeadlessTests/FlatProportionalDockPanelTests.cs index f60309ada..61e748d3b 100644 --- a/tests/Dock.Avalonia.HeadlessTests/FlatProportionalDockPanelTests.cs +++ b/tests/Dock.Avalonia.HeadlessTests/FlatProportionalDockPanelTests.cs @@ -507,11 +507,6 @@ public async Task FlatProportionalDockPanel_Retargeted_Move_Keeps_Presenter_Disa Assert.False(leftPresenter.IsHitTestVisible); - await Task.Delay(60); - Dispatcher.UIThread.RunJobs(); - - Assert.False(leftPresenter.IsHitTestVisible); - await WaitForAsync(() => leftPresenter.IsHitTestVisible); var visual = ElementComposition.GetElementVisual(leftPresenter); From 2aef0cf1577b69154325dfc0743aab1d61eb08f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 30 Jun 2026 08:41:30 +0200 Subject: [PATCH 19/31] Preserve flat dock proportions during collapse --- .../FlatProportionalPanel.cs | 84 +++++++++++++++++-- .../FlatProportionalPanelTests.cs | 74 +++++++++++++++- 2 files changed, 150 insertions(+), 8 deletions(-) diff --git a/src/Dock.Controls.Flat/FlatProportionalPanel.cs b/src/Dock.Controls.Flat/FlatProportionalPanel.cs index 1e873c16f..21d80b5f3 100644 --- a/src/Dock.Controls.Flat/FlatProportionalPanel.cs +++ b/src/Dock.Controls.Flat/FlatProportionalPanel.cs @@ -2253,11 +2253,17 @@ private void AssignProportions(IFlatProportionalDock dock, Size size, double spl _isAssigningProportions = true; try { - var availableLength = Math.Max(1.0, GetLength(size, dock.Orientation) - splitterThickness); + var availableLength = GetLength(size, dock.Orientation) - splitterThickness; + if (!CanAssignConstrainedProportions(dockables, dock.Orientation, availableLength)) + { + return; + } + var hasCollapsed = false; var assignedTotal = 0.0; var unassignedCount = 0; var targets = new Dictionary(ReferenceEqualityComparer.Instance); + var restoreCollapsedProportions = ShouldRestoreCollapsedProportions(dockables); foreach (var dockable in dockables) { @@ -2273,9 +2279,7 @@ private void AssignProportions(IFlatProportionalDock dock, Size size, double spl continue; } - var target = IsValidProportion(dockable.Proportion) - ? dockable.Proportion - : dockable.CollapsedProportion; + var target = GetTargetProportion(dockable, restoreCollapsedProportions); if (IsValidProportion(target)) { @@ -2306,8 +2310,14 @@ private void AssignProportions(IFlatProportionalDock dock, Size size, double spl foreach (var dockable in dockables) { - var target = ClampProportion(dockable, dock.Orientation, availableLength, targets[dockable]); - SetDockableProportion(dockable, target, !IsCollapsed(dockable) && !hasCollapsed); + var isCollapsed = IsCollapsed(dockable); + var target = targets[dockable]; + if (!isCollapsed) + { + target = ClampProportion(dockable, dock.Orientation, availableLength, target); + } + + SetDockableProportion(dockable, target, !isCollapsed && !hasCollapsed); } } finally @@ -2342,6 +2352,68 @@ private static void NormalizeActiveProportions(IList dock } } + private bool CanAssignConstrainedProportions( + IList dockables, + Avalonia.Layout.Orientation orientation, + double availableLength) + { + if (!IsValidProportion(availableLength) || availableLength <= 0) + { + return false; + } + + var requiredLength = 0.0; + for (var i = 0; i < dockables.Count; i++) + { + var dockable = dockables[i]; + if (IsCollapsed(dockable)) + { + continue; + } + + var minimum = GetMinimumLength(dockable, orientation); + var minimumLength = MinimumProportionSize > 0 ? MinimumProportionSize : 0.0; + if (!double.IsNaN(minimum) && minimum > 0) + { + minimumLength = Math.Max(minimumLength, minimum); + } + + requiredLength += minimumLength; + } + + return requiredLength <= 0 || availableLength >= requiredLength; + } + + private static bool ShouldRestoreCollapsedProportions(IList dockables) + { + for (var i = 0; i < dockables.Count; i++) + { + var dockable = dockables[i]; + if (!IsCollapsed(dockable) + && IsValidProportion(dockable.CollapsedProportion) + && dockable.CollapsedProportion > 0 + && (!IsValidProportion(dockable.Proportion) || dockable.Proportion <= 0)) + { + return true; + } + } + + return false; + } + + private static double GetTargetProportion(IFlatProportionalItem dockable, bool restoreCollapsedProportions) + { + var collapsedProportion = dockable.CollapsedProportion; + if (restoreCollapsedProportions && IsValidProportion(collapsedProportion) && collapsedProportion > 0) + { + return collapsedProportion; + } + + return IsValidProportion(dockable.Proportion) + ? dockable.Proportion + : collapsedProportion; + } + private double ClampProportion(IFlatProportionalItem dockable, Avalonia.Layout.Orientation orientation, double availableLength, double proportion) { if (!IsValidProportion(proportion) diff --git a/tests/Dock.Controls.Flat.UnitTests/FlatProportionalPanelTests.cs b/tests/Dock.Controls.Flat.UnitTests/FlatProportionalPanelTests.cs index 44e779d21..c0f2367d0 100644 --- a/tests/Dock.Controls.Flat.UnitTests/FlatProportionalPanelTests.cs +++ b/tests/Dock.Controls.Flat.UnitTests/FlatProportionalPanelTests.cs @@ -218,6 +218,66 @@ public void Measure_Uses_LiveProportion_When_CollapsedProportion_IsStale() Assert.Equal(right.Proportion, right.CollapsedProportion); } + [AvaloniaFact] + public void Measure_With_InsufficientStackingLength_Does_NotRewrite_Proportions() + { + var left = new TestItem("Left", 0.25); + var right = new TestItem("Right", 0.75); + var root = new TestDock( + "Root", + Orientation.Horizontal, + 1.0, + new IFlatProportionalItem[] { left, new TestSplitter("Splitter"), right }); + var panel = new FlatProportionalPanel { Root = root }; + + panel.Measure(new Size(100, 600)); + panel.Arrange(new Rect(0, 0, 100, 600)); + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + Assert.Equal(0.25, left.Proportion, 2); + Assert.Equal(0.75, right.Proportion, 2); + Assert.Equal(0.25, left.CollapsedProportion, 2); + Assert.Equal(0.75, right.CollapsedProportion, 2); + } + + [AvaloniaFact] + public void Measure_Restores_CollapsedProportions_When_CollapsibleItem_Reopens() + { + var left = new ObservableTestItem("Left", 0.5) + { + IsCollapsableValue = true + }; + var right = new TestItem("Right", 0.5); + var root = new TestDock( + "Root", + Orientation.Horizontal, + 1.0, + new IFlatProportionalItem[] { left, new TestSplitter("Splitter"), right }); + var panel = new FlatProportionalPanel { Root = root }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + left.UpdateIsEmpty(true); + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + Assert.Equal(0.0, left.Proportion, 2); + Assert.Equal(1.0, right.Proportion, 2); + Assert.Equal(0.5, left.CollapsedProportion, 2); + Assert.Equal(0.5, right.CollapsedProportion, 2); + + left.UpdateIsEmpty(false); + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + Assert.Equal(0.5, left.Proportion, 2); + Assert.Equal(0.5, right.Proportion, 2); + Assert.Equal(0.5, left.CollapsedProportion, 2); + Assert.Equal(0.5, right.CollapsedProportion, 2); + } + [AvaloniaFact] public void Measure_Returns_ChildDesiredSize_For_UnboundedAxis() { @@ -365,9 +425,13 @@ public TestItem(string id, double proportion) public double MaxHeight => MaxHeightValue; - public bool IsCollapsable => false; + public bool IsCollapsableValue { get; init; } - public bool IsEmpty => false; + public bool IsCollapsable => IsCollapsableValue; + + public bool IsEmptyValue { get; set; } + + public bool IsEmpty => IsEmptyValue; } private sealed class TestDock : TestItem, IFlatProportionalDock @@ -426,5 +490,11 @@ public void UpdateProportion(double proportion) Proportion = proportion; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Proportion))); } + + public void UpdateIsEmpty(bool isEmpty) + { + IsEmptyValue = isEmpty; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsEmpty))); + } } } From a70f0d26b119e987ec8ca7167663e920f80465f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 30 Jun 2026 08:41:36 +0200 Subject: [PATCH 20/31] Merge flat dock controls into Simple theme --- src/Dock.Avalonia.Themes.Simple/DockSimpleTheme.axaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Dock.Avalonia.Themes.Simple/DockSimpleTheme.axaml b/src/Dock.Avalonia.Themes.Simple/DockSimpleTheme.axaml index 6ecec382a..473ecc57e 100644 --- a/src/Dock.Avalonia.Themes.Simple/DockSimpleTheme.axaml +++ b/src/Dock.Avalonia.Themes.Simple/DockSimpleTheme.axaml @@ -31,6 +31,8 @@ + + From 655e1df12cd6d2033dddf620d8609e9c72f5a43f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 30 Jun 2026 08:59:41 +0200 Subject: [PATCH 21/31] Use temporary flat proportions for unsafe layout --- .../FlatProportionalPanel.cs | 39 ++++++++++++------- .../FlatProportionalPanelTests.cs | 33 ++++++++++++++++ 2 files changed, 59 insertions(+), 13 deletions(-) diff --git a/src/Dock.Controls.Flat/FlatProportionalPanel.cs b/src/Dock.Controls.Flat/FlatProportionalPanel.cs index 21d80b5f3..66abb5ef8 100644 --- a/src/Dock.Controls.Flat/FlatProportionalPanel.cs +++ b/src/Dock.Controls.Flat/FlatProportionalPanel.cs @@ -851,7 +851,7 @@ private void MeasureDock(IFlatProportionalDock dock, Size availableSize) } var splitterThickness = GetTotalSplitterThickness(visibleDockables); - AssignProportions(dock, availableSize, splitterThickness); + var proportions = AssignProportions(dock, availableSize, splitterThickness); var availableLength = Math.Max(0, GetLength(availableSize, dock.Orientation) - splitterThickness); var sumOfFractions = 0.0; @@ -874,7 +874,7 @@ private void MeasureDock(IFlatProportionalDock dock, Size availableSize) dockable, dock.Orientation, availableLength, - ResolveValidProportion(dockable.Proportion, 0), + GetLayoutProportion(proportions, dockable), ref sumOfFractions); var childSize = CreateChildSize(availableSize, dock.Orientation, length); @@ -947,7 +947,7 @@ private void ArrangeDock(IFlatProportionalDock dock, Rect bounds) } var splitterThickness = GetTotalSplitterThickness(visibleDockables); - AssignProportions(dock, bounds.Size, splitterThickness); + var proportions = AssignProportions(dock, bounds.Size, splitterThickness); var availableLength = Math.Max(0, GetLength(bounds.Size, dock.Orientation) - splitterThickness); var offset = 0.0; var sumOfFractions = 0.0; @@ -971,7 +971,7 @@ private void ArrangeDock(IFlatProportionalDock dock, Rect bounds) dockable, dock.Orientation, availableLength, - ResolveValidProportion(dockable.Proportion, 0), + GetLayoutProportion(proportions, dockable), ref sumOfFractions); var childBounds = CreateChildRect(bounds, dock.Orientation, offset, length); @@ -2229,11 +2229,12 @@ private double GetTotalSplitterThickness(IList visibleDoc return total; } - private void AssignProportions(IFlatProportionalDock dock, Size size, double splitterThickness) + private Dictionary AssignProportions(IFlatProportionalDock dock, Size size, double splitterThickness) { + var targets = new Dictionary(ReferenceEqualityComparer.Instance); if (dock.VisibleItems is not { } visibleDockables) { - return; + return targets; } var dockables = new List(); @@ -2247,22 +2248,17 @@ private void AssignProportions(IFlatProportionalDock dock, Size size, double spl if (dockables.Count == 0) { - return; + return targets; } _isAssigningProportions = true; try { var availableLength = GetLength(size, dock.Orientation) - splitterThickness; - if (!CanAssignConstrainedProportions(dockables, dock.Orientation, availableLength)) - { - return; - } - + var canWriteProportions = CanAssignConstrainedProportions(dockables, dock.Orientation, availableLength); var hasCollapsed = false; var assignedTotal = 0.0; var unassignedCount = 0; - var targets = new Dictionary(ReferenceEqualityComparer.Instance); var restoreCollapsedProportions = ShouldRestoreCollapsedProportions(dockables); foreach (var dockable in dockables) @@ -2308,6 +2304,11 @@ private void AssignProportions(IFlatProportionalDock dock, Size size, double spl NormalizeActiveProportions(dockables, targets); + if (!canWriteProportions) + { + return targets; + } + foreach (var dockable in dockables) { var isCollapsed = IsCollapsed(dockable); @@ -2317,8 +2318,11 @@ private void AssignProportions(IFlatProportionalDock dock, Size size, double spl target = ClampProportion(dockable, dock.Orientation, availableLength, target); } + targets[dockable] = target; SetDockableProportion(dockable, target, !isCollapsed && !hasCollapsed); } + + return targets; } finally { @@ -2414,6 +2418,15 @@ private static double GetTargetProportion(IFlatProportionalItem dockable, bool r : collapsedProportion; } + private static double GetLayoutProportion( + IReadOnlyDictionary proportions, + IFlatProportionalItem dockable) + { + return proportions.TryGetValue(dockable, out var proportion) && IsValidProportion(proportion) + ? proportion + : ResolveValidProportion(dockable.Proportion, 0); + } + private double ClampProportion(IFlatProportionalItem dockable, Avalonia.Layout.Orientation orientation, double availableLength, double proportion) { if (!IsValidProportion(proportion) diff --git a/tests/Dock.Controls.Flat.UnitTests/FlatProportionalPanelTests.cs b/tests/Dock.Controls.Flat.UnitTests/FlatProportionalPanelTests.cs index c0f2367d0..cdf1870ef 100644 --- a/tests/Dock.Controls.Flat.UnitTests/FlatProportionalPanelTests.cs +++ b/tests/Dock.Controls.Flat.UnitTests/FlatProportionalPanelTests.cs @@ -322,6 +322,39 @@ public void Measure_With_UnboundedStackingAxis_Does_NotClamp_MaxConstraint_ToZer Assert.Equal(0.6, right.Proportion, 2); } + [AvaloniaFact] + public void Measure_With_UnboundedStackingAxis_Uses_TemporaryProportions_For_UnsetItems() + { + var left = new TestItem("Left", double.NaN) + { + CollapsedProportion = double.NaN, + Content = new Border { Width = 120, Height = 45 } + }; + var right = new TestItem("Right", double.NaN) + { + CollapsedProportion = double.NaN, + Content = new Border { Width = 80, Height = 45 } + }; + var root = new TestDock( + "Root", + Orientation.Horizontal, + 1.0, + new IFlatProportionalItem[] { left, new TestSplitter("Splitter"), right }); + var panel = new FlatProportionalPanel { Root = root }; + + panel.Measure(new Size(double.PositiveInfinity, 100)); + + var presenters = panel.Children.OfType().ToList(); + + Assert.Equal(2, presenters.Count); + Assert.All(presenters, presenter => Assert.False(double.IsNaN(presenter.DesiredSize.Width))); + Assert.False(double.IsNaN(panel.DesiredSize.Width)); + Assert.True(double.IsNaN(left.Proportion)); + Assert.True(double.IsNaN(right.Proportion)); + Assert.True(double.IsNaN(left.CollapsedProportion)); + Assert.True(double.IsNaN(right.CollapsedProportion)); + } + [AvaloniaFact] public void ContentChange_Refreshes_ExistingPresenter() { From 0881cf232607fe99c9d1518f8904dbb37e843375 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 30 Jun 2026 09:23:45 +0200 Subject: [PATCH 22/31] Guard flat zero proportions on unbounded measure --- .../FlatProportionalPanel.cs | 10 +++++++ .../FlatProportionalPanelTests.cs | 29 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/Dock.Controls.Flat/FlatProportionalPanel.cs b/src/Dock.Controls.Flat/FlatProportionalPanel.cs index 66abb5ef8..3aef4998e 100644 --- a/src/Dock.Controls.Flat/FlatProportionalPanel.cs +++ b/src/Dock.Controls.Flat/FlatProportionalPanel.cs @@ -2725,7 +2725,17 @@ private static double CalculateDimensionWithConstraints( private static double CalculateDimension(double dimension, double proportion, ref double sumOfFractions) { + if (!IsValidProportion(proportion) || proportion <= 0 || double.IsNaN(dimension) || dimension <= 0) + { + return 0; + } + var childDimension = dimension * proportion; + if (double.IsPositiveInfinity(childDimension)) + { + return double.PositiveInfinity; + } + var flooredChildDimension = Math.Floor(childDimension); sumOfFractions += childDimension - flooredChildDimension; diff --git a/tests/Dock.Controls.Flat.UnitTests/FlatProportionalPanelTests.cs b/tests/Dock.Controls.Flat.UnitTests/FlatProportionalPanelTests.cs index cdf1870ef..53b57705d 100644 --- a/tests/Dock.Controls.Flat.UnitTests/FlatProportionalPanelTests.cs +++ b/tests/Dock.Controls.Flat.UnitTests/FlatProportionalPanelTests.cs @@ -355,6 +355,35 @@ public void Measure_With_UnboundedStackingAxis_Uses_TemporaryProportions_For_Uns Assert.True(double.IsNaN(right.CollapsedProportion)); } + [AvaloniaFact] + public void Measure_With_UnboundedStackingAxis_Handles_ZeroProportion() + { + var left = new TestItem("Left", 0.0) + { + Content = new Border { Width = 120, Height = 45 } + }; + var right = new TestItem("Right", 1.0) + { + Content = new Border { Width = 80, Height = 45 } + }; + var root = new TestDock( + "Root", + Orientation.Horizontal, + 1.0, + new IFlatProportionalItem[] { left, new TestSplitter("Splitter"), right }); + var panel = new FlatProportionalPanel { Root = root }; + + panel.Measure(new Size(double.PositiveInfinity, 100)); + + var presenters = panel.Children.OfType().ToList(); + + Assert.Equal(2, presenters.Count); + Assert.All(presenters, presenter => Assert.False(double.IsNaN(presenter.DesiredSize.Width))); + Assert.False(double.IsNaN(panel.DesiredSize.Width)); + Assert.Equal(0.0, left.Proportion); + Assert.Equal(1.0, right.Proportion); + } + [AvaloniaFact] public void ContentChange_Refreshes_ExistingPresenter() { From 0cb025e71e85b90780132beb820dcdef9188d687 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 30 Jun 2026 09:23:52 +0200 Subject: [PATCH 23/31] Move flat sample theme controls to view model --- samples/DockReactiveUIFlatSample/App.axaml.cs | 4 +- .../Services/DockThemeService.cs | 33 ++++++++++++++ .../ViewModels/IThemeService.cs | 16 +++++++ .../ViewModels/MainWindowViewModel.cs | 45 ++++++++++++++++++- .../Views/MainView.axaml | 22 ++++----- .../Views/MainView.axaml.cs | 43 +----------------- 6 files changed, 108 insertions(+), 55 deletions(-) create mode 100644 samples/DockReactiveUIFlatSample/Services/DockThemeService.cs create mode 100644 samples/DockReactiveUIFlatSample/ViewModels/IThemeService.cs diff --git a/samples/DockReactiveUIFlatSample/App.axaml.cs b/samples/DockReactiveUIFlatSample/App.axaml.cs index e31fa7c77..836215f44 100644 --- a/samples/DockReactiveUIFlatSample/App.axaml.cs +++ b/samples/DockReactiveUIFlatSample/App.axaml.cs @@ -10,6 +10,7 @@ using Dock.Avalonia.Diagnostics; using Dock.Avalonia.Themes; using Dock.Avalonia.Themes.Fluent; +using DockReactiveUIFlatSample.Services; using DockReactiveUIFlatSample.ViewModels; using DockReactiveUIFlatSample.Views; @@ -35,7 +36,8 @@ public override void OnFrameworkInitializationCompleted() { // DockManager.s_enableSplitToWindow = true; - var mainWindowViewModel = new MainWindowViewModel(); + var themeService = ThemeManager is null ? null : new DockThemeService(ThemeManager); + var mainWindowViewModel = new MainWindowViewModel(themeService); switch (ApplicationLifetime) { diff --git a/samples/DockReactiveUIFlatSample/Services/DockThemeService.cs b/samples/DockReactiveUIFlatSample/Services/DockThemeService.cs new file mode 100644 index 000000000..53e9d72ed --- /dev/null +++ b/samples/DockReactiveUIFlatSample/Services/DockThemeService.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using Avalonia; +using Avalonia.Styling; +using Dock.Avalonia.Themes; +using DockReactiveUIFlatSample.ViewModels; + +namespace DockReactiveUIFlatSample.Services; + +internal sealed class DockThemeService : IThemeService +{ + private readonly IDockThemeManager _themeManager; + + public DockThemeService(IDockThemeManager themeManager) + { + _themeManager = themeManager; + } + + public IReadOnlyList PresetNames => _themeManager.PresetNames; + + public int CurrentPresetIndex => _themeManager.CurrentPresetIndex; + + public bool IsDark => Application.Current?.RequestedThemeVariant == ThemeVariant.Dark; + + public void SwitchDark(bool isDark) + { + _themeManager.Switch(isDark ? 1 : 0); + } + + public void SwitchPreset(int index) + { + _themeManager.SwitchPreset(index); + } +} diff --git a/samples/DockReactiveUIFlatSample/ViewModels/IThemeService.cs b/samples/DockReactiveUIFlatSample/ViewModels/IThemeService.cs new file mode 100644 index 000000000..0a8edefab --- /dev/null +++ b/samples/DockReactiveUIFlatSample/ViewModels/IThemeService.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; + +namespace DockReactiveUIFlatSample.ViewModels; + +internal interface IThemeService +{ + IReadOnlyList PresetNames { get; } + + int CurrentPresetIndex { get; } + + bool IsDark { get; } + + void SwitchDark(bool isDark); + + void SwitchPreset(int index); +} diff --git a/samples/DockReactiveUIFlatSample/ViewModels/MainWindowViewModel.cs b/samples/DockReactiveUIFlatSample/ViewModels/MainWindowViewModel.cs index ed86e863e..113ac6a72 100644 --- a/samples/DockReactiveUIFlatSample/ViewModels/MainWindowViewModel.cs +++ b/samples/DockReactiveUIFlatSample/ViewModels/MainWindowViewModel.cs @@ -1,4 +1,6 @@ -using System.Diagnostics; +using System; +using System.Collections.Generic; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Windows.Input; using DockReactiveUIFlatSample.Models; @@ -13,8 +15,11 @@ namespace DockReactiveUIFlatSample.ViewModels; public class MainWindowViewModel : ReactiveObject { private readonly IFactory? _factory; + private readonly IThemeService? _themeService; private IRootDock? _layout; private string _globalStatus = "Global: (none)"; + private int _selectedPresetIndex = -1; + private bool _isDarkTheme; public IRootDock? Layout { @@ -28,10 +33,41 @@ public string GlobalStatus set => this.RaiseAndSetIfChanged(ref _globalStatus, value); } + public IReadOnlyList PresetNames { get; } + + public int SelectedPresetIndex + { + get => _selectedPresetIndex; + set + { + if (_selectedPresetIndex == value) + { + return; + } + + this.RaiseAndSetIfChanged(ref _selectedPresetIndex, value); + if (value >= 0) + { + _themeService?.SwitchPreset(value); + } + } + } + public ICommand NewLayout { get; } + public ICommand ToggleTheme { get; } + public MainWindowViewModel() + : this(null) { + } + + internal MainWindowViewModel(IThemeService? themeService) + { + _themeService = themeService; + PresetNames = themeService?.PresetNames ?? Array.Empty(); + _selectedPresetIndex = themeService?.CurrentPresetIndex ?? -1; + _isDarkTheme = themeService?.IsDark == true; _factory = new DockFactory(new DemoData()); DebugFactoryEvents(_factory); @@ -48,6 +84,13 @@ public MainWindowViewModel() : FormatGlobalStatus(_factory?.GlobalDockTrackingState ?? GlobalDockTrackingState.Empty); NewLayout = ReactiveCommand.Create(ResetLayout); + ToggleTheme = ReactiveCommand.Create(ToggleThemeVariant); + } + + private void ToggleThemeVariant() + { + _isDarkTheme = !_isDarkTheme; + _themeService?.SwitchDark(_isDarkTheme); } private void DebugFactoryEvents(IFactory factory) diff --git a/samples/DockReactiveUIFlatSample/Views/MainView.axaml b/samples/DockReactiveUIFlatSample/Views/MainView.axaml index 2d86c526f..2b34ab2d5 100644 --- a/samples/DockReactiveUIFlatSample/Views/MainView.axaml +++ b/samples/DockReactiveUIFlatSample/Views/MainView.axaml @@ -3,7 +3,6 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" - xmlns:dmc="using:Dock.Model.Controls" xmlns:vm="using:DockReactiveUIFlatSample.ViewModels" mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="550" @@ -26,11 +25,10 @@ - + + Margin="4"> - diff --git a/samples/DockReactiveUIFlatSample/Views/MainView.axaml.cs b/samples/DockReactiveUIFlatSample/Views/MainView.axaml.cs index 9d22a4b71..20e27101c 100644 --- a/samples/DockReactiveUIFlatSample/Views/MainView.axaml.cs +++ b/samples/DockReactiveUIFlatSample/Views/MainView.axaml.cs @@ -1,52 +1,11 @@ -using System.Diagnostics.CodeAnalysis; -using Avalonia; -using Avalonia.Controls; -using Avalonia.Markup.Xaml; -using Avalonia.Styling; +using Avalonia.Controls; namespace DockReactiveUIFlatSample.Views; -[RequiresUnreferencedCode("Requires unreferenced code for ThemeManager.")] -[RequiresDynamicCode("Requires unreferenced code for ThemeManager.")] public partial class MainView : UserControl { public MainView() { InitializeComponent(); - InitializeThemes(); - } -private void InitializeThemes() - { - var themeManager = App.ThemeManager; - - if (themeManager is null) - { - return; - } - - var dark = Application.Current?.RequestedThemeVariant == ThemeVariant.Dark; - var theme = this.Find public class FlatProportionalDockSplitter : FlatProportionalSplitter { + private IProportionalDockSplitter? _dataContextSplitter; + private INotifyPropertyChanged? _propertyChanged; + /// /// Gets the Dock model splitter represented by this control. /// @@ -23,4 +29,86 @@ public class FlatProportionalDockSplitter : FlatProportionalSplitter /// public new IProportionalDock? OwnerDock => base.OwnerDock is DockFlatProportionalAdapter.DockFlatDockAdapter adapter ? adapter.Dock : null; + + /// + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == DataContextProperty) + { + SetDataContextSplitter(change.NewValue as IProportionalDockSplitter); + } + } + + /// + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + SubscribeToDataContextSplitter(); + base.OnAttachedToVisualTree(e); + } + + /// + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + UnsubscribeFromDataContextSplitter(); + base.OnDetachedFromVisualTree(e); + } + + private void SetDataContextSplitter(IProportionalDockSplitter? splitter) + { + UnsubscribeFromDataContextSplitter(); + _dataContextSplitter = splitter; + + if (VisualRoot is not null) + { + SubscribeToDataContextSplitter(); + } + + UpdateFromDataContextSplitter(); + } + + private void SubscribeToDataContextSplitter() + { + if (_propertyChanged is not null + || _dataContextSplitter is not INotifyPropertyChanged propertyChanged) + { + return; + } + + _propertyChanged = propertyChanged; + _propertyChanged.PropertyChanged += OnDataContextSplitterPropertyChanged; + } + + private void UnsubscribeFromDataContextSplitter() + { + if (_propertyChanged is null) + { + return; + } + + _propertyChanged.PropertyChanged -= OnDataContextSplitterPropertyChanged; + _propertyChanged = null; + } + + private void OnDataContextSplitterPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (string.IsNullOrEmpty(e.PropertyName) + || e.PropertyName == nameof(IProportionalDockSplitter.CanResize) + || e.PropertyName == nameof(IProportionalDockSplitter.ResizePreview)) + { + UpdateFromDataContextSplitter(); + } + } + + private void UpdateFromDataContextSplitter() + { + if (_dataContextSplitter is null) + { + return; + } + + IsResizingEnabled = _dataContextSplitter.CanResize; + PreviewResize = _dataContextSplitter.ResizePreview; + } } diff --git a/src/Dock.Avalonia/Controls/FlatProportionalDockSurface.cs b/src/Dock.Avalonia/Controls/FlatProportionalDockSurface.cs new file mode 100644 index 000000000..976409bcb --- /dev/null +++ b/src/Dock.Avalonia/Controls/FlatProportionalDockSurface.cs @@ -0,0 +1,55 @@ +// 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.ComponentModel; +using Dock.Model.Controls; +using Dock.Model.Core; +using Dock.Settings; + +namespace Dock.Avalonia.Controls; + +internal sealed class FlatProportionalDockSurface : DockableControl +{ + private IProportionalDock? _dock; + private INotifyPropertyChanged? _propertyChanged; + + public void SetDock(IProportionalDock? dock) + { + if (ReferenceEquals(_dock, dock)) + { + return; + } + + if (_propertyChanged is not null) + { + _propertyChanged.PropertyChanged -= OnDockPropertyChanged; + } + + _dock = dock; + _propertyChanged = dock as INotifyPropertyChanged; + + if (_propertyChanged is not null) + { + _propertyChanged.PropertyChanged += OnDockPropertyChanged; + } + + DataContext = dock; + UpdateDropProperties(); + } + + private void OnDockPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (string.IsNullOrEmpty(e.PropertyName) + || e.PropertyName == nameof(IDockable.CanDrop) + || e.PropertyName == nameof(IDockable.DockGroup)) + { + UpdateDropProperties(); + } + } + + private void UpdateDropProperties() + { + DockProperties.SetIsDropEnabled(this, _dock?.CanDrop ?? false); + DockProperties.SetDockGroup(this, _dock?.DockGroup); + } +} diff --git a/src/Dock.Avalonia/Internal/DockDataTemplateHelper.cs b/src/Dock.Avalonia/Internal/DockDataTemplateHelper.cs index e207ca150..3d991de27 100644 --- a/src/Dock.Avalonia/Internal/DockDataTemplateHelper.cs +++ b/src/Dock.Avalonia/Internal/DockDataTemplateHelper.cs @@ -38,11 +38,7 @@ public static IEnumerable CreateDefaultDataTemplates(DockPresenta if (presentationMode == DockPresentationMode.Flat) { - yield return CreateDataTemplate(() => new FlatProportionalDockSplitter - { - [!FlatProportionalDockSplitter.IsResizingEnabledProperty] = new Binding(nameof(IProportionalDockSplitter.CanResize)), - [!FlatProportionalDockSplitter.PreviewResizeProperty] = new Binding(nameof(IProportionalDockSplitter.ResizePreview)) - }); + yield return CreateDataTemplate(() => new FlatProportionalDockSplitter()); } else { diff --git a/src/Dock.Controls.Flat/FlatProportionalPanel.cs b/src/Dock.Controls.Flat/FlatProportionalPanel.cs index da48a24fd..aee53046a 100644 --- a/src/Dock.Controls.Flat/FlatProportionalPanel.cs +++ b/src/Dock.Controls.Flat/FlatProportionalPanel.cs @@ -10,7 +10,6 @@ using Avalonia.Animation.Easings; using Avalonia.Controls; using Avalonia.Controls.Presenters; -using Avalonia.Data; using Avalonia.Media; using Avalonia.Rendering.Composition; using Avalonia.Rendering.Composition.Animations; @@ -426,7 +425,11 @@ private void RebuildVisualTree(bool invalidateLayout = true) private void AddDockSurfaces(IFlatProportionalDock dock) { var key = GetItemKey(dock); - var surface = CreateDockSurface(dock); + var surface = _reusableDockSurfaces?.Remove(key, out var reusableSurface) == true + ? reusableSurface + : CreateDockSurface(dock); + + UpdateDockSurface(surface, dock); _dockSurfaces[key] = surface; EnsureSurfaceChild(surface); @@ -451,19 +454,31 @@ private void AddDockSurfaces(IFlatProportionalDock dock) /// The surface control. protected virtual Control CreateDockSurface(IFlatProportionalDock dock) { - if (_reusableDockSurfaces?.Remove(GetItemKey(dock), out var reusableSurface) == true) - { - reusableSurface.DataContext = dock.Content ?? dock; - return reusableSurface; - } - return new Border { - Background = Brushes.Transparent, - DataContext = dock.Content ?? dock + Background = Brushes.Transparent }; } + /// + /// Updates a dock surface before it is added or reused for a proportional container. + /// + /// The surface control. + /// The proportional container item. + protected virtual void UpdateDockSurface(Control surface, IFlatProportionalDock dock) + { + surface.DataContext = dock.Content ?? dock; + } + + /// + /// Clears a dock surface that is no longer used by the current layout. + /// + /// The unused surface control. + protected virtual void ClearDockSurface(Control surface) + { + surface.DataContext = null; + } + private void AddDockVisuals(IFlatProportionalDock dock) { if (dock.VisibleItems is null) @@ -512,11 +527,7 @@ private void AddSplitter(IFlatProportionalDock ownerDock, IFlatProportionalSplit control.DataContext = splitter; } - if (!reused) - { - control.Bind(FlatProportionalSplitter.IsResizingEnabledProperty, new Binding(nameof(IFlatProportionalSplitter.CanResize))); - control.Bind(FlatProportionalSplitter.PreviewResizeProperty, new Binding(nameof(IFlatProportionalSplitter.ResizePreview))); - } + UpdateSplitter(control, splitter); _splitters[key] = control; EnsureDockVisualChild(control); @@ -536,6 +547,14 @@ protected virtual FlatProportionalSplitter CreateSplitter(IFlatProportionalDock }; } + private static void UpdateSplitter( + FlatProportionalSplitter control, + IFlatProportionalSplitter splitter) + { + control.IsResizingEnabled = splitter.CanResize; + control.PreviewResize = splitter.ResizePreview; + } + private void AddPresenter(IFlatProportionalItem dockable) { var key = GetItemKey(dockable); @@ -655,7 +674,7 @@ private void RemoveUnusedVisuals() foreach (var surface in _reusableDockSurfaces.Values) { Children.Remove(surface); - surface.DataContext = null; + ClearDockSurface(surface); } } @@ -832,6 +851,15 @@ private void DockablePropertyChanged(object? sender, PropertyChangedEventArgs e) return; } + if (sender is IFlatProportionalSplitter splitter + && (e.PropertyName == nameof(IFlatProportionalSplitter.CanResize) + || e.PropertyName == nameof(IFlatProportionalSplitter.ResizePreview)) + && _splitters.TryGetValue(GetItemKey(splitter), out var splitterControl)) + { + UpdateSplitter(splitterControl, splitter); + return; + } + CaptureConnectedStartBounds(); InvalidateMeasure(); InvalidateArrange(); diff --git a/tests/Dock.Avalonia.HeadlessTests/DockControlDataTemplateTests.cs b/tests/Dock.Avalonia.HeadlessTests/DockControlDataTemplateTests.cs index 6d8722341..1f2d118f9 100644 --- a/tests/Dock.Avalonia.HeadlessTests/DockControlDataTemplateTests.cs +++ b/tests/Dock.Avalonia.HeadlessTests/DockControlDataTemplateTests.cs @@ -5,6 +5,7 @@ using Avalonia.Controls; using Avalonia.Controls.Templates; using Avalonia.Headless.XUnit; +using Avalonia.Threading; using Dock.Avalonia.Controls; using Dock.Avalonia.Internal; using Dock.Controls.ProportionalStackPanel; @@ -129,6 +130,42 @@ public void DockDataTemplateHelper_FlatProportionalSplitterTemplate_CanCreateCon Assert.IsType(control); } + [AvaloniaFact] + public void DockDataTemplateHelper_FlatProportionalSplitterTemplate_Tracks_ModelProperties() + { + var templates = DockDataTemplateHelper.CreateDefaultDataTemplates(DockPresentationMode.Flat).ToList(); + var template = FindTemplateForType(templates); + Assert.NotNull(template); + var splitter = new ProportionalDockSplitter + { + CanResize = false, + ResizePreview = true + }; + var control = Assert.IsType(template!.Build(splitter)); + control.DataContext = splitter; + var window = new Window { Content = control }; + + try + { + window.Show(); + Dispatcher.UIThread.RunJobs(); + + Assert.False(control.IsResizingEnabled); + Assert.True(control.PreviewResize); + + splitter.CanResize = true; + splitter.ResizePreview = false; + + Assert.True(control.IsResizingEnabled); + Assert.False(control.PreviewResize); + } + finally + { + window.Close(); + Dispatcher.UIThread.RunJobs(); + } + } + [AvaloniaFact] public void DockDataTemplateHelper_GridSplitterTemplate_CanCreateControl() { diff --git a/tests/Dock.Avalonia.HeadlessTests/FlatProportionalDockPanelTests.cs b/tests/Dock.Avalonia.HeadlessTests/FlatProportionalDockPanelTests.cs index 049bc627a..e7526fef3 100644 --- a/tests/Dock.Avalonia.HeadlessTests/FlatProportionalDockPanelTests.cs +++ b/tests/Dock.Avalonia.HeadlessTests/FlatProportionalDockPanelTests.cs @@ -109,7 +109,7 @@ public void FlatProportionalDockPanel_DropSurface_Remains_Discoverable_Behind_Hi Assert.True(rightPresenter.IsHitTestVisible); var hit = DockHelpers.GetControl(panel, point, DockProperties.IsDropAreaProperty); - var surface = Assert.IsType(hit); + var surface = Assert.IsAssignableFrom(hit); Assert.Same(root, surface.DataContext); Assert.True(DockProperties.GetIsDockTarget(surface)); @@ -121,6 +121,33 @@ public void FlatProportionalDockPanel_DropSurface_Remains_Discoverable_Behind_Hi } } + [AvaloniaFact] + public void FlatProportionalDockPanel_DropSurface_Tracks_DockProperties() + { + var root = new ProportionalDock + { + Id = "Root", + CanDrop = false, + DockGroup = "Initial", + VisibleDockables = new List() + }; + var panel = new FlatProportionalDockPanel { Dock = root }; + + panel.Measure(new Size(1000, 600)); + panel.Arrange(new Rect(0, 0, 1000, 600)); + + var surface = panel.Children.OfType().Single(); + + Assert.False(DockProperties.GetIsDropEnabled(surface)); + Assert.Equal("Initial", DockProperties.GetDockGroup(surface)); + + root.CanDrop = true; + root.DockGroup = "Updated"; + + Assert.True(DockProperties.GetIsDropEnabled(surface)); + Assert.Equal("Updated", DockProperties.GetDockGroup(surface)); + } + [AvaloniaFact] public void FlatProportionalDockPanel_Structurally_Empty_Dock_Does_Not_Reserve_Space() { @@ -917,8 +944,10 @@ public async Task FlatProportionalDockPanel_Removed_Dockable_Keeps_Remaining_Pre Assert.True(leftPresenter.IsHitTestVisible); - await Task.Delay(120); - Dispatcher.UIThread.RunJobs(); + await WaitForAsync( + () => GetLivePresenters(panel).All( + presenter => !ReferenceEquals(presenter.Content, right)), + timeoutMilliseconds: 2000); Assert.True(leftPresenter.IsHitTestVisible); Assert.DoesNotContain(GetLivePresenters(panel), presenter => ReferenceEquals(presenter.Content, right)); @@ -1181,7 +1210,7 @@ public async Task FlatProportionalDockPanel_Active_Insert_Retargets_Layout_Witho var panel = new FlatProportionalDockPanel { Dock = root, - LayoutTransitionDuration = TimeSpan.FromMilliseconds(100) + LayoutTransitionDuration = TimeSpan.FromMilliseconds(1000) }; var window = ShowPanel(panel); @@ -1202,7 +1231,7 @@ public async Task FlatProportionalDockPanel_Active_Insert_Retargets_Layout_Witho Assert.False(rightPresenter.IsHitTestVisible); - await Task.Delay(70); + await Task.Delay(50); Dispatcher.UIThread.RunJobs(); root.VisibleDockables = new List { right, splitter, left }; @@ -1214,13 +1243,7 @@ public async Task FlatProportionalDockPanel_Active_Insert_Retargets_Layout_Witho Assert.NotEqual(insertedBounds.X, rightPresenter.Bounds.X); Assert.False(rightPresenter.IsHitTestVisible); - await Task.Delay(70); - Dispatcher.UIThread.RunJobs(); - - Assert.False(rightPresenter.IsHitTestVisible); - - await Task.Delay(140); - Dispatcher.UIThread.RunJobs(); + await WaitForAsync(() => rightPresenter.IsHitTestVisible, timeoutMilliseconds: 3000); var visual = ElementComposition.GetElementVisual(rightPresenter); diff --git a/tests/Dock.Controls.Flat.UnitTests/FlatProportionalPanelTests.cs b/tests/Dock.Controls.Flat.UnitTests/FlatProportionalPanelTests.cs index a50014942..3a6c56e38 100644 --- a/tests/Dock.Controls.Flat.UnitTests/FlatProportionalPanelTests.cs +++ b/tests/Dock.Controls.Flat.UnitTests/FlatProportionalPanelTests.cs @@ -222,6 +222,14 @@ public void Rebuild_ReusedSplitter_Refreshes_DataContext() Assert.Same(firstSplitterControl, reusedSplitterControl); Assert.Same(secondSplitter, reusedSplitterControl.Splitter); Assert.Same(secondSplitter, reusedSplitterControl.DataContext); + Assert.True(reusedSplitterControl.IsResizingEnabled); + Assert.False(reusedSplitterControl.PreviewResize); + + secondSplitter.CanResizeValue = false; + secondSplitter.ResizePreviewValue = true; + + Assert.False(reusedSplitterControl.IsResizingEnabled); + Assert.True(reusedSplitterControl.PreviewResize); } [AvaloniaFact] @@ -642,16 +650,37 @@ public TestDock( public IList? VisibleItems => _visibleItems; } - private sealed class TestSplitter : TestItem, IFlatProportionalSplitter + private sealed class TestSplitter : TestItem, IFlatProportionalSplitter, INotifyPropertyChanged { + private bool _canResize = true; + private bool _resizePreview; + public TestSplitter(string id) : base(id, 0) { } - public bool CanResizeValue { get; init; } = true; + public event PropertyChangedEventHandler? PropertyChanged; - public bool ResizePreviewValue { get; init; } + public bool CanResizeValue + { + get => _canResize; + set + { + _canResize = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CanResize))); + } + } + + public bool ResizePreviewValue + { + get => _resizePreview; + set + { + _resizePreview = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ResizePreview))); + } + } public bool CanResize => CanResizeValue; From f642af15e1f6076ffcb7b42ac299e7d38b3b084f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Wed, 15 Jul 2026 07:09:28 +0200 Subject: [PATCH 31/31] Preserve splitters across collapsed siblings --- .../Internal/SplitterCalculator.cs | 61 ++++++++++++------- .../ProportionalStackPanel.cs | 11 +--- .../Controls/ProportionalStackPanelTests.cs | 28 +++++++++ 3 files changed, 68 insertions(+), 32 deletions(-) diff --git a/src/Dock.Controls.ProportionalStackPanel/Internal/SplitterCalculator.cs b/src/Dock.Controls.ProportionalStackPanel/Internal/SplitterCalculator.cs index f8c9741e7..e4a90672b 100644 --- a/src/Dock.Controls.ProportionalStackPanel/Internal/SplitterCalculator.cs +++ b/src/Dock.Controls.ProportionalStackPanel/Internal/SplitterCalculator.cs @@ -18,53 +18,68 @@ internal static class SplitterCalculator public static double GetTotalSplitterThickness(Avalonia.Controls.Controls children, System.Func getIsCollapsed) { var totalThickness = 0.0; - var previousWasCollapsed = false; for (var i = 0; i < children.Count; i++) { var child = children[i]; var isSplitter = ProportionalStackPanelSplitter.IsSplitter(child, out var splitter); - if (isSplitter && splitter is not null) + if (isSplitter && splitter is not null && ShouldUseSplitter(i, children, getIsCollapsed)) { - // Skip splitters adjacent to collapsed elements - if (ShouldSkipSplitter(i, children, previousWasCollapsed, getIsCollapsed)) - { - continue; - } - totalThickness += splitter.Thickness; } - else - { - previousWasCollapsed = getIsCollapsed(child); - } } return double.IsNaN(totalThickness) ? 0 : totalThickness; } /// - /// Determines whether a splitter should be skipped based on adjacent collapsed elements. + /// Determines whether a splitter separates two live children. When collapsed children + /// or consecutive splitters occur between the live children, only the first splitter + /// after the preceding live child is used. /// /// The index of the splitter in the children collection. /// The collection of child controls. - /// Whether the previous element was collapsed. /// Function to determine if a control is collapsed. - /// True if the splitter should be skipped, false otherwise. - public static bool ShouldSkipSplitter(int splitterIndex, Avalonia.Controls.Controls children, bool previousWasCollapsed, System.Func getIsCollapsed) + /// true when the splitter should participate in layout; otherwise false. + public static bool ShouldUseSplitter( + int splitterIndex, + Avalonia.Controls.Controls children, + System.Func getIsCollapsed) { - // Skip if previous element was collapsed - if (previousWasCollapsed) + if (splitterIndex < 0 + || splitterIndex >= children.Count + || !ProportionalStackPanelSplitter.IsSplitter(children[splitterIndex], out _)) + { + return false; + } + + var hasPreviousLiveChild = false; + for (var i = splitterIndex - 1; i >= 0; i--) + { + var child = children[i]; + if (ProportionalStackPanelSplitter.IsSplitter(child, out _)) + { + return false; + } + + if (!getIsCollapsed(child)) + { + hasPreviousLiveChild = true; + break; + } + } + + if (!hasPreviousLiveChild) { - return true; + return false; } - // Skip if next element is collapsed - if (splitterIndex + 1 < children.Count) + for (var i = splitterIndex + 1; i < children.Count; i++) { - var nextChild = children[splitterIndex + 1]; - if (getIsCollapsed(nextChild)) + var child = children[i]; + if (!ProportionalStackPanelSplitter.IsSplitter(child, out _) + && !getIsCollapsed(child)) { return true; } diff --git a/src/Dock.Controls.ProportionalStackPanel/ProportionalStackPanel.cs b/src/Dock.Controls.ProportionalStackPanel/ProportionalStackPanel.cs index adbefeb59..a6c6eaf44 100644 --- a/src/Dock.Controls.ProportionalStackPanel/ProportionalStackPanel.cs +++ b/src/Dock.Controls.ProportionalStackPanel/ProportionalStackPanel.cs @@ -190,7 +190,6 @@ protected override Size MeasureOverride(Size constraint) AssignProportions(constraint, splitterThickness); - var needsNextSplitter = false; double sumOfFractions = 0; // Measure each of the Children @@ -238,11 +237,10 @@ protected override Size MeasureOverride(Size constraint) } } - needsNextSplitter = true; } else { - if (!needsNextSplitter) + if (!SplitterCalculator.ShouldUseSplitter(i, Children, GetIsCollapsed)) { var size = new Size(); control.Measure(size); @@ -250,7 +248,6 @@ protected override Size MeasureOverride(Size constraint) } control.Measure(remainingSize); - needsNextSplitter = false; } var desiredSize = control.DesiredSize; @@ -313,7 +310,6 @@ protected override Size ArrangeOverride(Size arrangeSize) AssignProportions(arrangeSize, splitterThickness); - var needsNextSplitter = false; double sumOfFractions = 0; for (var i = 0; i < Children.Count; i++) @@ -331,14 +327,11 @@ protected override Size ArrangeOverride(Size arrangeSize) continue; } - if (!isSplitter) - needsNextSplitter = true; - else if (isSplitter && !needsNextSplitter) + if (isSplitter && !SplitterCalculator.ShouldUseSplitter(i, Children, GetIsCollapsed)) { var rect = new Rect(); control.Arrange(rect); index++; - needsNextSplitter = false; continue; } diff --git a/tests/Dock.Avalonia.UnitTests/Controls/ProportionalStackPanelTests.cs b/tests/Dock.Avalonia.UnitTests/Controls/ProportionalStackPanelTests.cs index 6c669b47f..1b874ff03 100644 --- a/tests/Dock.Avalonia.UnitTests/Controls/ProportionalStackPanelTests.cs +++ b/tests/Dock.Avalonia.UnitTests/Controls/ProportionalStackPanelTests.cs @@ -94,6 +94,34 @@ public void Collapsed_Children_Do_Not_Reset_Other_CollapsedProportions() Assert.Equal(1.0, ProportionalStackPanel.GetProportion(right), 3); } + [AvaloniaFact] + public void Collapsed_Child_Between_Splitters_Preserves_One_Functional_Splitter() + { + var left = new Border(); + var firstSplitter = new ProportionalStackPanelSplitter(); + var collapsed = new Border(); + var secondSplitter = new ProportionalStackPanelSplitter(); + var right = new Border(); + ProportionalStackPanel.SetIsCollapsed(collapsed, true); + + var target = new ProportionalStackPanel + { + Width = 300, + Height = 100, + Orientation = Orientation.Horizontal, + Children = { left, firstSplitter, collapsed, secondSplitter, right } + }; + + target.Measure(Size.Infinity); + target.Arrange(new Rect(target.DesiredSize)); + + Assert.Equal(new Rect(0, 0, 148, 100), left.Bounds); + Assert.Equal(new Rect(148, 0, 4, 100), firstSplitter.Bounds); + Assert.Equal(default, collapsed.Bounds); + Assert.Equal(default, secondSplitter.Bounds); + Assert.Equal(new Rect(152, 0, 148, 100), right.Bounds); + } + public static IEnumerable GetBorderTestsData() { yield return [0.5, 604, 300, 300];