diff --git a/src/Dock.Avalonia/Controls/DockableControl.cs b/src/Dock.Avalonia/Controls/DockableControl.cs index aea2e5210..5eb78105c 100644 --- a/src/Dock.Avalonia/Controls/DockableControl.cs +++ b/src/Dock.Avalonia/Controls/DockableControl.cs @@ -158,6 +158,11 @@ private void SetBoundsTracking(Rect bounds) return; } + if (TrackingMode == TrackingMode.Pinned && this.FindAncestorOfType() is not null) + { + return; + } + var x = bounds.X; var y = bounds.Y; var width = bounds.Width; diff --git a/src/Dock.Avalonia/Controls/PinnedDockControl.axaml.cs b/src/Dock.Avalonia/Controls/PinnedDockControl.axaml.cs index 83433fa53..2ca35d394 100644 --- a/src/Dock.Avalonia/Controls/PinnedDockControl.axaml.cs +++ b/src/Dock.Avalonia/Controls/PinnedDockControl.axaml.cs @@ -1,6 +1,7 @@ // 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.Linq; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Metadata; @@ -23,6 +24,8 @@ namespace Dock.Avalonia.Controls; [TemplatePart("PART_PinnedDockSplitter", typeof(GridSplitter))] public class PinnedDockControl : TemplatedControl { + private const double BoundsEpsilon = 0.5; + /// /// Define the property. /// @@ -42,6 +45,10 @@ public Alignment PinnedDockAlignment private GridSplitter? _pinnedDockSplitter; private PinnedDockWindow? _window; private Window? _ownerWindow; + private IDockable? _lastPinnedDockable; + private double _lastPinnedWidth = double.NaN; + private double _lastPinnedHeight = double.NaN; + private bool _isResizingPinnedDock; static PinnedDockControl() { @@ -109,28 +116,35 @@ private void UpdateGrid() default: throw new ArgumentOutOfRangeException(); } + + ApplyPinnedDockSize(); } /// protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { base.OnApplyTemplate(e); + LayoutUpdated -= OnLayoutUpdated; + this.AttachedToVisualTree -= OnAttached; + this.DetachedFromVisualTree -= OnDetached; + DetachSplitterHandlers(); _pinnedDockGrid = e.NameScope.Get("PART_PinnedDockGrid"); _pinnedDock = e.NameScope.Get("PART_PinnedDock"); _pinnedDockSplitter = e.NameScope.Find("PART_PinnedDockSplitter"); + AttachSplitterHandlers(); UpdateGrid(); - if (DockSettings.UsePinnedDockWindow) - { - LayoutUpdated += OnLayoutUpdated; - this.AttachedToVisualTree += OnAttached; - this.DetachedFromVisualTree += OnDetached; - } + LayoutUpdated += OnLayoutUpdated; + this.AttachedToVisualTree += OnAttached; + this.DetachedFromVisualTree += OnDetached; } private void OnAttached(object? sender, VisualTreeAttachmentEventArgs e) { - UpdateWindow(); + if (DockSettings.UsePinnedDockWindow) + { + UpdateWindow(); + } } private void OnDetached(object? sender, VisualTreeAttachmentEventArgs e) @@ -139,10 +153,13 @@ private void OnDetached(object? sender, VisualTreeAttachmentEventArgs e) LayoutUpdated -= OnLayoutUpdated; this.AttachedToVisualTree -= OnAttached; this.DetachedFromVisualTree -= OnDetached; + DetachSplitterHandlers(); } private void OnLayoutUpdated(object? sender, EventArgs e) { + ApplyPinnedDockSize(); + UpdatePinnedDockableBounds(); UpdateWindow(); } @@ -227,6 +244,224 @@ private void CloseWindow() } + private void ApplyPinnedDockSize() + { + if (_isResizingPinnedDock) + { + return; + } + + if (_pinnedDockGrid is null || DataContext is not IRootDock rootDock) + { + return; + } + + var dockable = GetPinnedDockable(rootDock); + if (dockable is null) + { + return; + } + + dockable.GetPinnedBounds(out _, out _, out var width, out var height); + + switch (PinnedDockAlignment) + { + case Alignment.Unset: + case Alignment.Left: + if (!IsValidSize(width)) + { + return; + } + if (IsColumnSizeApplied(_pinnedDockGrid.ColumnDefinitions, 0, width)) + { + return; + } + SetColumnSize(_pinnedDockGrid.ColumnDefinitions, 0, width); + _lastPinnedDockable = dockable; + _lastPinnedWidth = width; + break; + case Alignment.Right: + if (!IsValidSize(width)) + { + return; + } + if (IsColumnSizeApplied(_pinnedDockGrid.ColumnDefinitions, 2, width)) + { + return; + } + SetColumnSize(_pinnedDockGrid.ColumnDefinitions, 2, width); + _lastPinnedDockable = dockable; + _lastPinnedWidth = width; + break; + case Alignment.Top: + if (!IsValidSize(height)) + { + return; + } + if (IsRowSizeApplied(_pinnedDockGrid.RowDefinitions, 0, height)) + { + return; + } + SetRowSize(_pinnedDockGrid.RowDefinitions, 0, height); + _lastPinnedDockable = dockable; + _lastPinnedHeight = height; + break; + case Alignment.Bottom: + if (!IsValidSize(height)) + { + return; + } + if (IsRowSizeApplied(_pinnedDockGrid.RowDefinitions, 2, height)) + { + return; + } + SetRowSize(_pinnedDockGrid.RowDefinitions, 2, height); + _lastPinnedDockable = dockable; + _lastPinnedHeight = height; + break; + default: + break; + } + } + + private void UpdatePinnedDockableBounds() + { + if (_pinnedDock is null || DataContext is not IRootDock rootDock) + { + return; + } + + var dockable = GetPinnedDockable(rootDock); + if (dockable is null) + { + return; + } + + dockable.GetPinnedBounds(out _, out _, out var storedWidth, out var storedHeight); + + var hasStoredBounds = IsValidSize(storedWidth) && IsValidSize(storedHeight); + if (!_isResizingPinnedDock && hasStoredBounds) + { + return; + } + + var width = _pinnedDock.Bounds.Width; + var height = _pinnedDock.Bounds.Height; + + if (!IsValidSize(width) || !IsValidSize(height)) + { + return; + } + + if (AreClose(width, storedWidth) && AreClose(height, storedHeight)) + { + return; + } + + dockable.SetPinnedBounds(0, 0, width, height); + _lastPinnedDockable = dockable; + _lastPinnedWidth = width; + _lastPinnedHeight = height; + } + + private static IDockable? GetPinnedDockable(IRootDock rootDock) + { + return rootDock.PinnedDock?.VisibleDockables?.FirstOrDefault(); + } + + private static void SetColumnSize(ColumnDefinitions definitions, int index, double size) + { + if (index < 0 || index >= definitions.Count) + { + return; + } + + definitions[index].Width = new GridLength(size, GridUnitType.Pixel); + } + + private static void SetRowSize(RowDefinitions definitions, int index, double size) + { + if (index < 0 || index >= definitions.Count) + { + return; + } + + definitions[index].Height = new GridLength(size, GridUnitType.Pixel); + } + + private static bool IsColumnSizeApplied(ColumnDefinitions definitions, int index, double size) + { + if (index < 0 || index >= definitions.Count) + { + return false; + } + + var length = definitions[index].Width; + return length.IsAbsolute && AreClose(length.Value, size); + } + + private static bool IsRowSizeApplied(RowDefinitions definitions, int index, double size) + { + if (index < 0 || index >= definitions.Count) + { + return false; + } + + var length = definitions[index].Height; + return length.IsAbsolute && AreClose(length.Value, size); + } + + private static bool IsValidSize(double size) + { + return !double.IsNaN(size) && !double.IsInfinity(size) && size > 0; + } + + private static bool AreClose(double left, double right) + { + return Math.Abs(left - right) <= BoundsEpsilon; + } + + private void AttachSplitterHandlers() + { + if (_pinnedDockSplitter is null) + { + return; + } + + _pinnedDockSplitter.DragStarted += OnPinnedDockSplitterDragStarted; + _pinnedDockSplitter.DragDelta += OnPinnedDockSplitterDragDelta; + _pinnedDockSplitter.DragCompleted += OnPinnedDockSplitterDragCompleted; + } + + private void DetachSplitterHandlers() + { + if (_pinnedDockSplitter is null) + { + return; + } + + _pinnedDockSplitter.DragStarted -= OnPinnedDockSplitterDragStarted; + _pinnedDockSplitter.DragDelta -= OnPinnedDockSplitterDragDelta; + _pinnedDockSplitter.DragCompleted -= OnPinnedDockSplitterDragCompleted; + } + + private void OnPinnedDockSplitterDragStarted(object? sender, VectorEventArgs e) + { + _isResizingPinnedDock = true; + UpdatePinnedDockableBounds(); + } + + private void OnPinnedDockSplitterDragDelta(object? sender, VectorEventArgs e) + { + UpdatePinnedDockableBounds(); + } + + private void OnPinnedDockSplitterDragCompleted(object? sender, VectorEventArgs e) + { + UpdatePinnedDockableBounds(); + _isResizingPinnedDock = false; + } + private void OwnerWindow_PositionChanged(object? sender, PixelPointEventArgs e) { CloseWindow(); diff --git a/src/Dock.Controls.ProportionalStackPanel/Internal/ProportionManager.cs b/src/Dock.Controls.ProportionalStackPanel/Internal/ProportionManager.cs index 3c4988734..f068413c8 100644 --- a/src/Dock.Controls.ProportionalStackPanel/Internal/ProportionManager.cs +++ b/src/Dock.Controls.ProportionalStackPanel/Internal/ProportionManager.cs @@ -110,12 +110,14 @@ private void NormalizeProportions() private void ApplyProportions() { + var hasCollapsedChildren = _childInfos.Any(info => info.IsCollapsed); + foreach (var info in _childInfos) { var clampedProportion = _constraintHandler.ClampProportion(info.Control, info.TargetProportion); ProportionalStackPanel.SetProportion(info.Control, clampedProportion); - if (!info.IsCollapsed) + if (!info.IsCollapsed && !hasCollapsedChildren) { ProportionalStackPanel.SetCollapsedProportion(info.Control, clampedProportion); } @@ -137,4 +139,4 @@ public ChildInfo(Control control) TargetProportion = double.NaN; } } -} \ No newline at end of file +} diff --git a/src/Dock.Model/FactoryBase.Dockable.cs b/src/Dock.Model/FactoryBase.Dockable.cs index 82ad02300..1f665eacc 100644 --- a/src/Dock.Model/FactoryBase.Dockable.cs +++ b/src/Dock.Model/FactoryBase.Dockable.cs @@ -560,6 +560,28 @@ private Alignment GetPinnedDockableAlignment(IDockable dockable, IRootDock rootD return Alignment.Unset; } + private void UpdatePinnedBoundsFromVisible(IDockable dockable, IDock owner) + { + dockable.GetVisibleBounds(out _, out _, out var width, out var height); + + if (!IsValidSize(width) || !IsValidSize(height)) + { + owner.GetVisibleBounds(out _, out _, out width, out height); + } + + if (!IsValidSize(width) || !IsValidSize(height)) + { + return; + } + + dockable.SetPinnedBounds(0, 0, width, height); + } + + private static bool IsValidSize(double value) + { + return !double.IsNaN(value) && !double.IsInfinity(value) && value > 0; + } + /// public virtual void PinDockable(IDockable dockable) { @@ -589,6 +611,7 @@ public virtual void PinDockable(IDockable dockable) if (isVisible && !isPinned) { // Pin dockable. + UpdatePinnedBoundsFromVisible(dockable, toolDock); switch (alignment) { diff --git a/tests/Dock.Avalonia.HeadlessTests/DockableControlTrackingTests.cs b/tests/Dock.Avalonia.HeadlessTests/DockableControlTrackingTests.cs new file mode 100644 index 000000000..89c368fd0 --- /dev/null +++ b/tests/Dock.Avalonia.HeadlessTests/DockableControlTrackingTests.cs @@ -0,0 +1,105 @@ +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using Avalonia.Headless.XUnit; +using Dock.Avalonia.Controls; +using Dock.Model.Avalonia.Controls; +using Dock.Model.Core; +using Xunit; + +namespace Dock.Avalonia.HeadlessTests; + +public class DockableControlTrackingTests +{ + [AvaloniaFact] + public void DockableControl_UpdatesPinnedBounds_WhenNotInToolPinItemControl() + { + var tool = new Tool(); + var dockableControl = new DockableControl + { + TrackingMode = TrackingMode.Pinned, + Width = 120, + Height = 80 + }; + + var host = new Grid + { + Width = 200, + Height = 200, + Children = { dockableControl } + }; + + var window = new Window + { + Width = 300, + Height = 300, + Content = host + }; + + try + { + dockableControl.DataContext = tool; + window.Show(); + window.UpdateLayout(); + dockableControl.InvalidateMeasure(); + window.UpdateLayout(); + + tool.GetPinnedBounds(out _, out _, out var width, out var height); + Assert.Equal(120, width, 3); + Assert.Equal(80, height, 3); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void DockableControl_DoesNotUpdatePinnedBounds_WhenInsideToolPinItemControl() + { + var tool = new Tool(); + var dockableControl = new DockableControl + { + TrackingMode = TrackingMode.Pinned, + Width = 120, + Height = 80 + }; + + var pinItem = new ToolPinItemControl + { + Width = 200, + Height = 200, + Template = new FuncControlTemplate((parent, scope) => + new Grid + { + Width = 200, + Height = 200, + Children = { dockableControl } + }) + }; + + var window = new Window + { + Width = 300, + Height = 300, + Content = pinItem + }; + + try + { + dockableControl.DataContext = tool; + window.Show(); + pinItem.ApplyTemplate(); + window.UpdateLayout(); + dockableControl.InvalidateMeasure(); + window.UpdateLayout(); + + tool.GetPinnedBounds(out _, out _, out var width, out var height); + Assert.True(double.IsNaN(width)); + Assert.True(double.IsNaN(height)); + } + finally + { + window.Close(); + } + } +} diff --git a/tests/Dock.Avalonia.HeadlessTests/FactoryPinBoundsTests.cs b/tests/Dock.Avalonia.HeadlessTests/FactoryPinBoundsTests.cs new file mode 100644 index 000000000..0c7f7947b --- /dev/null +++ b/tests/Dock.Avalonia.HeadlessTests/FactoryPinBoundsTests.cs @@ -0,0 +1,62 @@ +using Avalonia.Headless.XUnit; +using Dock.Model.Avalonia; +using Dock.Model.Avalonia.Controls; +using Dock.Model.Core; +using Xunit; + +namespace Dock.Avalonia.HeadlessTests; + +public class FactoryPinBoundsTests +{ + [AvaloniaFact] + public void PinDockable_UsesDockableVisibleBounds_WhenValid() + { + var factory = new Factory(); + var root = new RootDock + { + VisibleDockables = factory.CreateList(), + LeftPinnedDockables = factory.CreateList() + }; + root.Factory = factory; + + var toolDock = new ToolDock { Alignment = Alignment.Left, VisibleDockables = factory.CreateList() }; + factory.AddDockable(root, toolDock); + var tool = new Tool(); + factory.AddDockable(toolDock, tool); + + tool.SetVisibleBounds(0, 0, 320, 240); + toolDock.SetVisibleBounds(0, 0, 640, 480); + + factory.PinDockable(tool); + + tool.GetPinnedBounds(out _, out _, out var width, out var height); + Assert.Equal(320, width, 3); + Assert.Equal(240, height, 3); + } + + [AvaloniaFact] + public void PinDockable_UsesOwnerVisibleBounds_WhenDockableInvalid() + { + var factory = new Factory(); + var root = new RootDock + { + VisibleDockables = factory.CreateList(), + LeftPinnedDockables = factory.CreateList() + }; + root.Factory = factory; + + var toolDock = new ToolDock { Alignment = Alignment.Left, VisibleDockables = factory.CreateList() }; + factory.AddDockable(root, toolDock); + var tool = new Tool(); + factory.AddDockable(toolDock, tool); + + tool.SetVisibleBounds(0, 0, double.NaN, double.NaN); + toolDock.SetVisibleBounds(0, 0, 600, 400); + + factory.PinDockable(tool); + + tool.GetPinnedBounds(out _, out _, out var width, out var height); + Assert.Equal(600, width, 3); + Assert.Equal(400, height, 3); + } +} diff --git a/tests/Dock.Avalonia.HeadlessTests/PinnedDockControlTests.cs b/tests/Dock.Avalonia.HeadlessTests/PinnedDockControlTests.cs new file mode 100644 index 000000000..17b1cfae6 --- /dev/null +++ b/tests/Dock.Avalonia.HeadlessTests/PinnedDockControlTests.cs @@ -0,0 +1,129 @@ +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.VisualTree; +using Dock.Avalonia.Controls; +using Dock.Model.Avalonia; +using Dock.Model.Avalonia.Controls; +using Dock.Model.Core; +using Dock.Settings; +using Xunit; + +namespace Dock.Avalonia.HeadlessTests; + +public class PinnedDockControlTests +{ + [AvaloniaFact] + public void PinnedDockControl_ReappliesStoredWidth_WhenColumnSizeChanged() + { + var previousUsePinnedWindow = DockSettings.UsePinnedDockWindow; + DockSettings.UsePinnedDockWindow = false; + + try + { + var (window, control, _, grid) = CreatePinnedDockControl(200, 150); + try + { + Assert.True(grid.ColumnDefinitions[0].Width.IsAbsolute); + Assert.Equal(200, grid.ColumnDefinitions[0].Width.Value, 3); + + grid.ColumnDefinitions[0].Width = new GridLength(100, GridUnitType.Pixel); + grid.InvalidateMeasure(); + control.UpdateLayout(); + + Assert.Equal(200, grid.ColumnDefinitions[0].Width.Value, 3); + } + finally + { + window.Close(); + } + } + finally + { + DockSettings.UsePinnedDockWindow = previousUsePinnedWindow; + } + } + + [AvaloniaFact] + public void PinnedDockControl_DragResize_UpdatesPinnedBounds() + { + var previousUsePinnedWindow = DockSettings.UsePinnedDockWindow; + DockSettings.UsePinnedDockWindow = false; + + try + { + var (window, control, tool, grid) = CreatePinnedDockControl(200, 150); + try + { + tool.GetPinnedBounds(out _, out _, out var width, out _); + Assert.Equal(200, width, 3); + + InvokePrivateHandler(control, "OnPinnedDockSplitterDragStarted"); + + grid.ColumnDefinitions[0].Width = new GridLength(300, GridUnitType.Pixel); + grid.InvalidateMeasure(); + control.UpdateLayout(); + + InvokePrivateHandler(control, "OnPinnedDockSplitterDragCompleted"); + + tool.GetPinnedBounds(out _, out _, out var updatedWidth, out _); + Assert.Equal(300, updatedWidth, 3); + } + finally + { + window.Close(); + } + } + finally + { + DockSettings.UsePinnedDockWindow = previousUsePinnedWindow; + } + } + + private static (Window window, PinnedDockControl control, Tool tool, Grid grid) CreatePinnedDockControl(double width, double height) + { + var factory = new Factory(); + var root = new RootDock { VisibleDockables = factory.CreateList() }; + root.Factory = factory; + + var pinnedDock = new ToolDock + { + Alignment = Alignment.Left, + IsEmpty = false, + VisibleDockables = factory.CreateList() + }; + root.PinnedDock = pinnedDock; + + var tool = new Tool(); + pinnedDock.VisibleDockables.Add(tool); + tool.SetPinnedBounds(0, 0, width, height); + + var control = new PinnedDockControl { DataContext = root }; + var window = new Window + { + Width = 800, + Height = 600, + Content = control + }; + + window.Show(); + control.ApplyTemplate(); + window.UpdateLayout(); + control.UpdateLayout(); + + var grid = control.GetVisualDescendants() + .OfType() + .FirstOrDefault(candidate => candidate.Name == "PART_PinnedDockGrid"); + Assert.NotNull(grid); + + return (window, control, tool, grid!); + } + + private static void InvokePrivateHandler(PinnedDockControl control, string methodName) + { + var method = typeof(PinnedDockControl).GetMethod(methodName, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.NotNull(method); + method!.Invoke(control, new object?[] { null, null }); + } +} diff --git a/tests/Dock.Avalonia.UnitTests/Controls/ProportionalStackPanelTests.cs b/tests/Dock.Avalonia.UnitTests/Controls/ProportionalStackPanelTests.cs index f321b3b0f..6c669b47f 100644 --- a/tests/Dock.Avalonia.UnitTests/Controls/ProportionalStackPanelTests.cs +++ b/tests/Dock.Avalonia.UnitTests/Controls/ProportionalStackPanelTests.cs @@ -60,6 +60,40 @@ public void Lays_Out_Children_Vertical() Assert.Equal(new Rect(0, 152, 100, 148), target.Children[2].Bounds); } + [AvaloniaFact] + public void Collapsed_Children_Do_Not_Reset_Other_CollapsedProportions() + { + var target = new ProportionalStackPanel() + { + Width = 300, + Height = 100, + Orientation = Orientation.Horizontal + }; + + var left = new Border { [ProportionalStackPanel.ProportionProperty] = 0.4 }; + var splitter = new ProportionalStackPanelSplitter(); + var right = new Border { [ProportionalStackPanel.ProportionProperty] = 0.6 }; + + target.Children.Add(left); + target.Children.Add(splitter); + target.Children.Add(right); + + target.Measure(Size.Infinity); + target.Arrange(new Rect(target.DesiredSize)); + + var stored = ProportionalStackPanel.GetCollapsedProportion(right); + Assert.False(double.IsNaN(stored)); + + ProportionalStackPanel.SetIsCollapsed(left, true); + + target.Measure(Size.Infinity); + target.Arrange(new Rect(target.DesiredSize)); + + var after = ProportionalStackPanel.GetCollapsedProportion(right); + Assert.Equal(stored, after, 3); + Assert.Equal(1.0, ProportionalStackPanel.GetProportion(right), 3); + } + public static IEnumerable GetBorderTestsData() { yield return [0.5, 604, 300, 300];