diff --git a/docfx/articles/dock-settings.md b/docfx/articles/dock-settings.md index d968bb44b..fae27cc5f 100644 --- a/docfx/articles/dock-settings.md +++ b/docfx/articles/dock-settings.md @@ -85,6 +85,25 @@ property instead. is dropped as a global dock target (for example, dropping into a different window). The default value is `0.5`. +`DockSettings.GlobalDockingPreset` controls how global docking decisions are made. +The default value is `DockGlobalDockingPreset.GlobalFirst`. + +- `DockGlobalDockingPreset.LocalFirst`: + - Uses legacy local-first operation selection. + - Resolves global target from the immediate drop context. +- `DockGlobalDockingPreset.GlobalFirst`: + - Uses global-first operation selection when a global target is active. + - Resolves global target to the outermost global target in the owner chain. + +Example: + +```csharp +DockSettings.GlobalDockingPreset = DockGlobalDockingPreset.GlobalFirst; +DockSettings.GlobalDockingProportion = 0.33; +``` + +Set these before docking interactions start (typically during app startup). + ## Floating window owner `DockSettings.UseOwnerForFloatingWindows` keeps floating windows above the main window by setting it as their owner. This is applied when `IDockWindow.OwnerMode` is `DockWindowOwnerMode.Default` and can be overridden per window. @@ -235,6 +254,9 @@ AppBuilder.Configure() | `CommandBarMergingEnabled` | `DockSettings.CommandBarMergingEnabled` | Enable command bar merging. | | `CommandBarMergingScope` | `DockSettings.CommandBarMergingScope` | Merge scope for command bars. | +`GlobalDockingPreset` is currently configured directly on `DockSettings` +rather than through `DockSettingsOptions`. + ## Hide on close `FactoryBase` exposes two properties that control whether closing a tool or diff --git a/docfx/articles/dock-targets.md b/docfx/articles/dock-targets.md index 5657c1091..60f9d38dd 100644 --- a/docfx/articles/dock-targets.md +++ b/docfx/articles/dock-targets.md @@ -57,6 +57,8 @@ The default themes define template parts like `PART_TopIndicator` and `PART_TopS To customize global targets, edit the `GlobalDockTarget` template. Its indicator panels use `DockSettings.GlobalDockingProportion` to size the split areas. +Global docking behavior can be switched between legacy local-first and modern global-first modes using `DockSettings.GlobalDockingPreset`. + ## Adaptive global targets `IRootDock.EnableAdaptiveGlobalDockTargets` reduces global docking indicators to only show options that change the layout. Enable it when you want to minimize drop choices in large dashboards: diff --git a/src/Dock.Avalonia/Internal/DockControlState.cs b/src/Dock.Avalonia/Internal/DockControlState.cs index 97eb892ea..798d84ad2 100644 --- a/src/Dock.Avalonia/Internal/DockControlState.cs +++ b/src/Dock.Avalonia/Internal/DockControlState.cs @@ -21,6 +21,9 @@ internal class DockDragContext public bool DoDragDrop { get; set; } public Point TargetPoint { get; set; } public Visual? TargetDockControl { get; set; } + public DockOperation ResolvedOperation { get; set; } + public bool UseGlobalOperation { get; set; } + public bool HasResolvedOperation { get; set; } public PixelPoint DragOffset { get; set; } @@ -32,6 +35,7 @@ public void Start(Control dragControl, Point point) DoDragDrop = false; TargetPoint = default; TargetDockControl = null; + ClearResolvedOperation(); } public void End() @@ -42,6 +46,14 @@ public void End() DoDragDrop = false; TargetPoint = default; TargetDockControl = null; + ClearResolvedOperation(); + } + + public void ClearResolvedOperation() + { + ResolvedOperation = DockOperation.None; + UseGlobalOperation = false; + HasResolvedOperation = false; } } @@ -50,6 +62,13 @@ public void End() /// internal class DockControlState : DockManagerState, IDockControlState { + private readonly record struct DockOperationResolution( + DockOperation LocalOperation, + DockOperation GlobalOperation, + bool UseGlobalOperation, + DockOperation SelectedOperation, + bool IsValid); + private readonly DockDragContext _context = new(); private readonly DragPreviewHelper _dragPreviewHelper = new(); @@ -117,14 +136,29 @@ private void Enter(Point point, DragAction dragAction, Visual relativeTo) AddAdorners(isLocalValid, isGlobalValid); } - private void Over(Point point, DragAction dragAction, Control dropControl, Visual relativeTo) + private DockOperationResolution Over(Point point, DragAction dragAction, Control dropControl, Visual relativeTo) + { + var resolution = ResolveDockOperation(point, dragAction, dropControl, relativeTo, updateAdornerState: true); + _context.ResolvedOperation = resolution.SelectedOperation; + _context.UseGlobalOperation = resolution.UseGlobalOperation; + _context.HasResolvedOperation = resolution.SelectedOperation != DockOperation.None; + return resolution; + } + + private DockOperationResolution ResolveDockOperation( + Point point, + DragAction dragAction, + Control dropControl, + Visual relativeTo, + bool updateAdornerState) { var localOperation = DockOperation.Fill; var globalOperation = DockOperation.None; - var hasLocalAdorner = LocalAdornerHelper.Adorner is DockTarget; + var hasLocalAdorner = false; if (LocalAdornerHelper.Adorner is DockTarget dockTarget) { + hasLocalAdorner = true; localOperation = dockTarget.GetDockOperation(point, dropControl, relativeTo, dragAction, ValidateLocal, IsDockTargetVisible); } @@ -137,39 +171,37 @@ private void Over(Point point, DragAction dragAction, Control dropControl, Visua hasLocalAdorner, localOperation, globalOperation); - if (useGlobalOperation) - { - ValidateGlobal(point, globalOperation, dragAction, relativeTo); - } - else + var selectedOperation = useGlobalOperation ? globalOperation : localOperation; + var isValid = useGlobalOperation + ? ValidateGlobal(point, globalOperation, dragAction, relativeTo) + : ValidateLocal(point, localOperation, dragAction, relativeTo); + + if (updateAdornerState) { - ValidateLocal(point, localOperation, dragAction, relativeTo); + LocalAdornerHelper.SetGlobalDockActive(useGlobalOperation); } - LocalAdornerHelper.SetGlobalDockActive(useGlobalOperation); + return new DockOperationResolution( + localOperation, + globalOperation, + useGlobalOperation, + selectedOperation, + isValid); } - private void Drop(Point point, DragAction dragAction, Control dropControl, Visual relativeTo) + private void Drop( + Point point, + DragAction dragAction, + Control dropControl, + Visual relativeTo, + bool useGlobalOperation, + DockOperation selectedOperation) { - var localOperation = DockOperation.Fill; - var globalOperation = DockOperation.None; - var hasLocalAdorner = LocalAdornerHelper.Adorner is DockTarget; - - if (LocalAdornerHelper.Adorner is DockTarget dockTarget) + if (selectedOperation == DockOperation.None) { - localOperation = dockTarget.GetDockOperation(point, dropControl, relativeTo, dragAction, ValidateLocal, IsDockTargetVisible); - } - - if (GlobalAdornerHelper.Adorner is GlobalDockTarget globalDockTarget) - { - globalOperation = globalDockTarget.GetDockOperation(point, dropControl, relativeTo, dragAction, ValidateGlobal, IsDockTargetVisible); + return; } - var useGlobalOperation = GlobalDocking.ShouldUseGlobalOperation( - hasLocalAdorner, - localOperation, - globalOperation); - RemoveAdorners(); if (_context.DragControl is null || DropControl is null) @@ -191,7 +223,7 @@ private void Drop(Point point, DragAction dragAction, Control dropControl, Visua var targetRoot = targetDock.Factory?.FindRoot(targetDock, _ => true); // Validate before executing global docking; if validation fails, fall back to floating when possible. - if (!ValidateGlobal(point, globalOperation, dragAction, relativeTo)) + if (!ValidateGlobal(point, selectedOperation, dragAction, relativeTo)) { if (CanFloatDockable(sourceDockable)) { @@ -214,7 +246,7 @@ private void Drop(Point point, DragAction dragAction, Control dropControl, Visua // return; // } - Execute(point, globalOperation, dragAction, relativeTo, sourceDockable, targetDock); + Execute(point, selectedOperation, dragAction, relativeTo, sourceDockable, targetDock); GlobalDocking.TryApplyGlobalDockingProportion( sourceDockable, @@ -251,13 +283,14 @@ private void Drop(Point point, DragAction dragAction, Control dropControl, Visua return; } - Execute(point, localOperation, dragAction, relativeTo, sourceDockable, target); + Execute(point, selectedOperation, dragAction, relativeTo, sourceDockable, target); } } } private void Leave() { + _context.ClearResolvedOperation(); RemoveAdorners(); } @@ -468,7 +501,21 @@ public void Process(Point point, Vector delta, EventType eventType, DragAction d var isDropEnabled = dropControl.GetValue(DockProperties.IsDropEnabledProperty); if (isDropEnabled) { - Drop(_context.TargetPoint, dragAction, dropControl, _context.TargetDockControl); + var useGlobalOperation = _context.UseGlobalOperation; + var selectedOperation = _context.ResolvedOperation; + if (!_context.HasResolvedOperation) + { + var resolution = ResolveDockOperation( + _context.TargetPoint, + dragAction, + dropControl, + _context.TargetDockControl, + updateAdornerState: false); + useGlobalOperation = resolution.UseGlobalOperation; + selectedOperation = resolution.SelectedOperation; + } + + Drop(_context.TargetPoint, dragAction, dropControl, _context.TargetDockControl, useGlobalOperation, selectedOperation); executed = true; LogDragState($"Drop executed on '{dropControl.GetType().Name}' with action '{dragAction}'."); } @@ -594,11 +641,12 @@ public void Process(Point point, Vector delta, EventType eventType, DragAction d var isDropEnabled = dropControl.GetValue(DockProperties.IsDropEnabledProperty); if (isDropEnabled) { + DockOperationResolution resolution; if (DropControl == dropControl) { _context.TargetPoint = targetPoint; _context.TargetDockControl = targetDockControl; - Over(targetPoint, dragAction, dropControl, targetDockControl); + resolution = Over(targetPoint, dragAction, dropControl, targetDockControl); LogDragState($"Dragging over '{dropControl.GetType().Name}' at {targetPoint}."); } else @@ -614,46 +662,22 @@ public void Process(Point point, Vector delta, EventType eventType, DragAction d _context.TargetPoint = targetPoint; _context.TargetDockControl = targetDockControl; Enter(targetPoint, dragAction, targetDockControl); + resolution = Over(targetPoint, dragAction, dropControl, targetDockControl); LogDragState($"New drop control '{dropControl.GetType().Name}' at {targetPoint}."); } - var globalOperation = GlobalAdornerHelper.Adorner is GlobalDockTarget globalDockTarget - ? globalDockTarget.GetDockOperation(targetPoint, dropControl, targetDockControl, dragAction, ValidateGlobal, IsDockTargetVisible) - : DockOperation.None; - - var hasLocalAdorner = false; - DockOperation localOperation; - if (LocalAdornerHelper.Adorner is DockTarget localDockTarget) - { - hasLocalAdorner = true; - localOperation = localDockTarget.GetDockOperation( - targetPoint, dropControl, targetDockControl, dragAction, ValidateLocal, IsDockTargetVisible); - } - else - { - localOperation = DockOperation.Fill; - } - var useGlobalOperation = GlobalDocking.ShouldUseGlobalOperation( - hasLocalAdorner, - localOperation, - globalOperation); - - LogDragState($"Operations resolved: global={globalOperation}, local={localOperation}."); - - if (useGlobalOperation) - { - var valid = ValidateGlobal(targetPoint, globalOperation, dragAction, targetDockControl); - preview = valid ? "Dock" : "None"; - LogDragState($"Global validation {(valid ? "succeeded" : "failed")} for operation {globalOperation}."); - } - else - { - var valid = ValidateLocal(targetPoint, localOperation, dragAction, targetDockControl); - preview = valid - ? localOperation == DockOperation.Window ? "Float" : "Dock" - : "None"; - LogDragState($"Local validation {(valid ? "succeeded" : "failed")} for operation {localOperation}."); - } + LogDragState($"Operations resolved: global={resolution.GlobalOperation}, local={resolution.LocalOperation}, selected={resolution.SelectedOperation}, useGlobal={resolution.UseGlobalOperation}."); + + preview = resolution.IsValid + ? (resolution.UseGlobalOperation + ? "Dock" + : resolution.SelectedOperation == DockOperation.Window + ? "Float" + : "Dock") + : "None"; + LogDragState( + $"{(resolution.UseGlobalOperation ? "Global" : "Local")} validation " + + $"{(resolution.IsValid ? "succeeded" : "failed")} for operation {resolution.SelectedOperation}."); } else { @@ -664,6 +688,7 @@ public void Process(Point point, Vector delta, EventType eventType, DragAction d DropControl = null; _context.TargetPoint = default; _context.TargetDockControl = null; + _context.ClearResolvedOperation(); LogDragState("Cleared drop control due to disabled target."); } } @@ -674,6 +699,7 @@ public void Process(Point point, Vector delta, EventType eventType, DragAction d DropControl = null; _context.TargetPoint = default; _context.TargetDockControl = null; + _context.ClearResolvedOperation(); LogDragState($"No valid drop target at current position (local={point}, screen={screenPoint}); cleared drop context."); var canFloat = _context.DragControl?.DataContext is IDockable sourceDockable && CanFloatDockable(sourceDockable); preview = canFloat ? "Float" : "None"; diff --git a/src/Dock.Avalonia/Internal/Services/GlobalDockingService.cs b/src/Dock.Avalonia/Internal/Services/GlobalDockingService.cs index a889463eb..aa45ad299 100644 --- a/src/Dock.Avalonia/Internal/Services/GlobalDockingService.cs +++ b/src/Dock.Avalonia/Internal/Services/GlobalDockingService.cs @@ -4,6 +4,7 @@ using Avalonia.VisualTree; using Dock.Avalonia.Controls; using Dock.Model.Core; +using Dock.Settings; namespace Dock.Avalonia.Internal; @@ -13,14 +14,29 @@ internal sealed class GlobalDockingService : IGlobalDockingService public IDock? ResolveGlobalTargetDock(Control? dropControl) { + if (DockSettings.GlobalDockingPreset == DockGlobalDockingPreset.LocalFirst) + { + if (dropControl?.DataContext is IDockable legacyDockable) + { + return legacyDockable as IDock ?? legacyDockable.Owner as IDock; + } + + if (dropControl?.FindAncestorOfType()?.Layout?.ActiveDockable is IDock legacyActiveDock) + { + return legacyActiveDock; + } + + return null; + } + if (dropControl?.DataContext is IDockable dockable) { - return dockable as IDock ?? dockable.Owner as IDock; + return ResolveOutermostGlobalTargetDock(dockable) ?? dockable as IDock ?? dockable.Owner as IDock; } if (dropControl?.FindAncestorOfType()?.Layout?.ActiveDockable is IDock activeDock) { - return activeDock; + return ResolveOutermostGlobalTargetDock(activeDock) ?? activeDock; } return null; @@ -33,12 +49,17 @@ public bool ShouldUseGlobalOperation(bool hasLocalAdorner, DockOperation localOp return false; } - if (!hasLocalAdorner) + if (DockSettings.GlobalDockingPreset == DockGlobalDockingPreset.LocalFirst) { - return true; + if (!hasLocalAdorner) + { + return true; + } + + return localOperation == DockOperation.Window; } - return localOperation == DockOperation.Window; + return true; } public bool TryApplyGlobalDockingProportion(IDockable sourceDockable, IDockable? sourceRoot, IDockable? targetRoot, double proportion) @@ -52,4 +73,21 @@ public bool TryApplyGlobalDockingProportion(IDockable sourceDockable, IDockable? sourceDockable.Owner.CollapsedProportion = proportion; return true; } + + private static IDock? ResolveOutermostGlobalTargetDock(IDockable? dockable) + { + var current = dockable; + IDock? resolved = null; + while (current is not null) + { + if (current is IDock dock && current is IGlobalTarget) + { + resolved = dock; + } + + current = current.Owner; + } + + return resolved; + } } diff --git a/src/Dock.Avalonia/Polyfills.cs b/src/Dock.Avalonia/Polyfills.cs new file mode 100644 index 000000000..7470a3aca --- /dev/null +++ b/src/Dock.Avalonia/Polyfills.cs @@ -0,0 +1,8 @@ +#if NETSTANDARD2_0 +namespace System.Runtime.CompilerServices +{ + internal static class IsExternalInit + { + } +} +#endif diff --git a/src/Dock.Settings/DockSettings.cs b/src/Dock.Settings/DockSettings.cs index d02450c75..fcee4bc90 100644 --- a/src/Dock.Settings/DockSettings.cs +++ b/src/Dock.Settings/DockSettings.cs @@ -90,6 +90,11 @@ public static class DockSettings /// public static double GlobalDockingProportion = 0.5; + /// + /// Configures global docking behavior as a combined preset. + /// + public static DockGlobalDockingPreset GlobalDockingPreset = DockGlobalDockingPreset.GlobalFirst; + /// /// Enables verbose diagnostics logging for docking workflows. /// @@ -216,6 +221,22 @@ public static bool ShouldUseOwnerForFloatingWindows() } } +/// +/// Defines combined global docking behavior presets. +/// +public enum DockGlobalDockingPreset +{ + /// + /// Local-first behavior with drop-context global target resolution. + /// + LocalFirst, + + /// + /// Global-first behavior with outermost global target resolution. + /// + GlobalFirst +} + /// /// Defines which dockable contributes command bars to a host window. /// diff --git a/tests/Dock.Avalonia.HeadlessTests/DockControlStateTests.cs b/tests/Dock.Avalonia.HeadlessTests/DockControlStateTests.cs index 77ff1f066..48683930a 100644 --- a/tests/Dock.Avalonia.HeadlessTests/DockControlStateTests.cs +++ b/tests/Dock.Avalonia.HeadlessTests/DockControlStateTests.cs @@ -65,7 +65,7 @@ public void GlobalDockOperationSelector_UsesGlobal_WhenNoLocalAdorner() } [AvaloniaFact] - public void GlobalDockOperationSelector_UsesLocal_WhenLocalOperationIsExplicit() + public void GlobalDockOperationSelector_UsesGlobal_WhenLocalOperationIsExplicit() { var service = new GlobalDockingService(); @@ -74,7 +74,7 @@ public void GlobalDockOperationSelector_UsesLocal_WhenLocalOperationIsExplicit() localOperation: DockOperation.Top, globalOperation: DockOperation.Right); - Assert.False(useGlobal); + Assert.True(useGlobal); } [AvaloniaFact] @@ -165,7 +165,7 @@ public void GlobalDockingProportionService_TryApply_ReturnsFalse_WhenSourceOwner } [AvaloniaFact] - public void GlobalDockTargetResolver_UsesOwnerDock_FromDropDataContextDockable() + public void GlobalDockTargetResolver_UsesOutermostGlobalTarget_FromDropDataContextDockable() { var service = new GlobalDockingService(); var factory = new Factory(); @@ -209,11 +209,11 @@ public void GlobalDockTargetResolver_UsesOwnerDock_FromDropDataContextDockable() var dropControl = new Border { DataContext = document }; var targetDock = service.ResolveGlobalTargetDock(dropControl); - Assert.Same(documentDock, targetDock); + Assert.Same(rootLayout, targetDock); } [AvaloniaFact] - public void GlobalDockTargetResolver_UsesDropDock_FromDropDataContextDock() + public void GlobalDockTargetResolver_UsesOutermostGlobalTarget_FromDropDataContextDock() { var service = new GlobalDockingService(); var factory = new Factory(); @@ -239,6 +239,46 @@ public void GlobalDockTargetResolver_UsesDropDock_FromDropDataContextDock() var dropControl = new Border { DataContext = documentDock }; var targetDock = service.ResolveGlobalTargetDock(dropControl); - Assert.Same(documentDock, targetDock); + Assert.Same(rootLayout, targetDock); + } + + [AvaloniaFact] + public void GlobalDockTargetResolver_UsesOutermostGlobalTarget_WhenNested() + { + var service = new GlobalDockingService(); + var factory = new Factory(); + var root = new RootDock + { + VisibleDockables = factory.CreateList(), + Factory = factory + }; + + var rootLayout = new ProportionalDock + { + Orientation = Orientation.Horizontal, + VisibleDockables = factory.CreateList() + }; + factory.AddDockable(root, rootLayout); + + var middleColumnLayout = new ProportionalDock + { + Orientation = Orientation.Vertical, + VisibleDockables = factory.CreateList() + }; + factory.AddDockable(rootLayout, middleColumnLayout); + + var documentDock = new DocumentDock + { + VisibleDockables = factory.CreateList() + }; + factory.AddDockable(middleColumnLayout, documentDock); + + var document = new Document { Id = "Document1", Title = "Document 1" }; + factory.AddDockable(documentDock, document); + + var dropControl = new Border { DataContext = document }; + var targetDock = service.ResolveGlobalTargetDock(dropControl); + + Assert.Same(rootLayout, targetDock); } } diff --git a/tests/Dock.Avalonia.HeadlessTests/WindowStateGlobalTargetResolutionTests.cs b/tests/Dock.Avalonia.HeadlessTests/WindowStateGlobalTargetResolutionTests.cs index 49126d510..517bd0069 100644 --- a/tests/Dock.Avalonia.HeadlessTests/WindowStateGlobalTargetResolutionTests.cs +++ b/tests/Dock.Avalonia.HeadlessTests/WindowStateGlobalTargetResolutionTests.cs @@ -132,7 +132,7 @@ public void HostWindowState_ValidateGlobal_UsesDropContextTarget_WithoutDockCont { var targetDock = GlobalDockingService.ResolveGlobalTargetDock(dropControl); Assert.NotNull(targetDock); - Assert.Same(targetDocument.Owner, targetDock); + Assert.Same(targetDocument.Owner?.Owner, targetDock); Assert.NotNull(dropControl.GetVisualRoot()); Assert.NotNull(root.FocusedDockable); Assert.Same(sourceDocument, root.FocusedDockable); @@ -179,7 +179,7 @@ public void ManagedHostWindowState_ValidateGlobal_UsesDropContextTarget_WithoutD { var targetDock = GlobalDockingService.ResolveGlobalTargetDock(dropControl); Assert.NotNull(targetDock); - Assert.Same(targetDocument.Owner, targetDock); + Assert.Same(targetDocument.Owner?.Owner, targetDock); Assert.NotNull(dropControl.GetVisualRoot()); Assert.NotNull(root.FocusedDockable); Assert.Same(sourceDocument, root.FocusedDockable);