diff --git a/docfx/articles/dock-active-document.md b/docfx/articles/dock-active-document.md index d5cc34562..8a185e082 100644 --- a/docfx/articles/dock-active-document.md +++ b/docfx/articles/dock-active-document.md @@ -1,9 +1,14 @@ # Finding the Active Document Only one dockable (document or tool) can have focus at a time, even when multiple -`IDocumentDock` instances or floating windows are present. The root dock that owns -the focused dockable stores it in `FocusedDockable`, and the factory reports focus -changes through `IFactory.FocusedDockableChanged`. +`IDocumentDock` instances or floating windows are present. + +Use factory global tracking for cross-window scenarios: + +- `IFactory.CurrentDockable` +- `IFactory.CurrentRootDock` +- `IFactory.CurrentDockWindow` +- `IFactory.GlobalDockTrackingChanged` If you already have the root layout for a window, read `FocusedDockable` directly: @@ -11,27 +16,32 @@ If you already have the root layout for a window, read `FocusedDockable` directl var currentDocument = Layout?.FocusedDockable as IDocument; ``` -To track focus across multiple windows, subscribe to the factory event and cache -the latest dockable: +To track focus across multiple windows, subscribe to global tracking: ```csharp IDockable? focusedDockable = null; -factory.FocusedDockableChanged += (_, e) => +factory.GlobalDockTrackingChanged += (_, e) => { - focusedDockable = e.Dockable; - Console.WriteLine($"Focused dockable: {e.Dockable?.Title}"); + focusedDockable = e.Current.Dockable; + Console.WriteLine($"Focused dockable: {e.Current.Dockable?.Title}"); }; var currentDocument = focusedDockable as IDocument; ``` -If you need the root dock that owns the focused dockable: +If you need the owning root/window: + +```csharp +var focusedRoot = factory.CurrentRootDock; +var focusedWindow = factory.CurrentDockWindow; +``` + +You can also use helper extensions: ```csharp -var focusedRoot = focusedDockable is { } dockable - ? factory.FindRoot(dockable, root => root.IsFocusableRoot) - : null; +var currentDocument = factory.GetCurrentDocument(); +var activeRoot = factory.GetActiveRoot(); ``` ```csharp @@ -42,6 +52,4 @@ if (focusedDockable is { } dockable) } ``` -The focused dockable can be `null` when nothing is focused. In multi-window -layouts, each root keeps its last focused dockable, so use the event to determine -the current focus. +The focused dockable can be `null` when nothing is focused. diff --git a/docfx/articles/dock-api-scenarios.md b/docfx/articles/dock-api-scenarios.md index e751c47d1..f23b546ae 100644 --- a/docfx/articles/dock-api-scenarios.md +++ b/docfx/articles/dock-api-scenarios.md @@ -53,7 +53,7 @@ dockControl.Factory = factory; dockControl.Layout = layout; ``` -These factories expose methods such as `AddDockable`, `MoveDockable` or `FloatDockable` and raise events like `ActiveDockableChanged` so you can react to changes. +These factories expose methods such as `AddDockable`, `MoveDockable` or `FloatDockable` and raise events like `ActiveDockableChanged`. For cross-window state, use `GlobalDockTrackingChanged` and `CurrentDockable`/`CurrentRootDock`. Use the MVVM, ReactiveUI or XAML samples as references for complete implementations. diff --git a/docfx/articles/dock-events.md b/docfx/articles/dock-events.md index adcd52fbe..277abd692 100644 --- a/docfx/articles/dock-events.md +++ b/docfx/articles/dock-events.md @@ -3,8 +3,9 @@ Dock exposes a large set of runtime events through `FactoryBase` so that applications can react to changes in the layout. The other guides only briefly mention these hooks. This document lists the most commonly used events and shows how to subscribe to them. Each event uses a dedicated arguments type that exposes the affected -dockable or window. Some events (such as `DockableClosing` and `WindowClosing`) -support cancellation via a `Cancel` flag. +dockable or window. Focus/active dockable args also include `RootDock`, +`Window`, and `HostWindow` for multi-window context. Some events (such as +`DockableClosing` and `WindowClosing`) support cancellation via a `Cancel` flag. ## Common events @@ -14,6 +15,7 @@ support cancellation via a `Cancel` flag. | ----- | ----------- | | `ActiveDockableChanged` | Fired when the active dockable within a dock changes. | | `FocusedDockableChanged` | Triggered when focus moves to another dockable. | +| `GlobalDockTrackingChanged` | Fired when global dock/root/window context changes. | | `DockableAdded` | Raised after a dockable is inserted into a dock. | | `DockableRemoved` | Raised after a dockable has been removed. | | `DockableClosing` | Fired before a dockable is closed so it can be cancelled. | @@ -48,7 +50,9 @@ Create a factory instance and attach handlers before initializing the layout: var factory = new Factory(); factory.ActiveDockableChanged += (_, args) => - Console.WriteLine($"Active dockable: {args.Dockable?.Title}"); + Console.WriteLine($"Active dockable: {args.Dockable?.Title} (root={args.RootDock?.Id}, window={args.Window?.Id})"); +factory.GlobalDockTrackingChanged += (_, args) => + Console.WriteLine($"Global context: {args.Current.Dockable?.Title} / {args.Current.RootDock?.Id} / {args.Current.Window?.Id}"); factory.DockableAdded += (_, args) => Console.WriteLine($"Added: {args.Dockable?.Title}"); factory.DockableDocked += (_, args) => diff --git a/docfx/articles/dock-global-tracking.md b/docfx/articles/dock-global-tracking.md new file mode 100644 index 000000000..1ab522197 --- /dev/null +++ b/docfx/articles/dock-global-tracking.md @@ -0,0 +1,76 @@ +# Global Dock Tracking + +This document defines the global tracking model introduced for multi-window focus and active document resolution. + +## Problem + +Per-dock events (`ActiveDockableChanged`, `FocusedDockableChanged`) previously exposed only a dockable reference. In multi-window layouts this made it hard to reliably identify: + +- which root dock currently owns focus +- which floating dock window is currently active +- where global overlays (busy/dialog) should render + +## Expected behavior + +Global tracking should always resolve the current dock context across all windows: + +- Current dockable (document/tool) +- Current root dock +- Current dock window (when floating) +- Current host window + +## API + +`IFactory` now exposes global state: + +- `GlobalDockTrackingState GlobalDockTrackingState` +- `IDockable? CurrentDockable` +- `IRootDock? CurrentRootDock` +- `IDockWindow? CurrentDockWindow` +- `IHostWindow? CurrentHostWindow` + +`IFactory` now exposes a global state event: + +- `GlobalDockTrackingChanged` + +`ActiveDockableChangedEventArgs` and `FocusedDockableChangedEventArgs` now include: + +- `RootDock` +- `Window` +- `HostWindow` + +## Tracking rules + +State is updated from window/focus/activation transitions: + +1. `FocusedDockableChanged` updates global state to the focused dockable context. +2. `WindowActivated` updates global state to that window plus its focused/active dockable. +3. `DockableActivated` updates global state only when the activation belongs to the tracked root (or no root is tracked yet). +4. `WindowDeactivated` clears state when the deactivated window is the tracked window. +5. `WindowClosed` and `WindowRemoved` clear state when they affect the tracked window. + +`ActiveDockableChanged` and `DockableActivated` still raise normally, but global state only updates when the change belongs to the already tracked root (or no root is tracked yet). This avoids background roots overriding global context. + +## Active document helpers + +`FactoryExtensions` now uses global tracking first: + +- `GetActiveRoot()` -> `CurrentRootDock` fallback to legacy `IsActive` scan. +- `GetCurrentDocument()` -> `CurrentDockable` fallback to focused dockable on active root. +- `CloseFocusedDockable()` closes `CurrentDockable` first. + +## Overlay host resolution improvements + +`IHostOverlayServicesProvider` now supports dockable-aware resolution: + +```csharp +IHostOverlayServices GetServices(IScreen screen, IDockable dockable); +``` + +This allows busy/dialog/confirmation overlays to resolve by the dockable's current root/window, with `screen` used as fallback. + +`RoutableDocumentBase` and `RoutableToolBase` use this dockable-aware overload by default. + +## Sample status bar + +`DockMvvmSample` and `DockReactiveUISample` include a status bar bound to global tracking state and updated through `GlobalDockTrackingChanged`. diff --git a/docfx/articles/dock-overlay-services-reference.md b/docfx/articles/dock-overlay-services-reference.md index bf0d8ac3e..0d18286b5 100644 --- a/docfx/articles/dock-overlay-services-reference.md +++ b/docfx/articles/dock-overlay-services-reference.md @@ -45,6 +45,7 @@ Use this interface as the backing type for view models or overlay bindings inste ### Contracts - `IHostServiceResolver` (namespace `Dock.Model.ReactiveUI.Services.Overlays.Hosting`) resolves services scoped to the current `IScreen`. - `IHostOverlayServicesProvider` (namespace `Dock.Model.ReactiveUI.Services.Overlays.Hosting`) returns the `IHostOverlayServices` instance for a host screen. +- `IHostOverlayServicesProvider.GetServices(IScreen, IDockable)` resolves overlays using dockable context first, then falls back to host screen resolution. ### Default resolver `OwnerChainHostServiceResolver` (namespace `Dock.Model.ReactiveUI.Services.Overlays.Hosting`) resolves services in this order: @@ -59,6 +60,15 @@ This keeps overlays bound to the correct host window when dockables are moved or ### Provider behavior `HostOverlayServicesProvider` (namespace `Dock.Model.ReactiveUI.Services.Overlays.Hosting`) uses the resolver and falls back to a cached per-screen instance when no host service is found. This keeps overlays functional in screens that are not attached to a dock layout yet. +Dockable-aware resolution: + +```csharp +var overlays = provider.GetServices(hostScreen, dockable); +var busy = overlays.Busy; +``` + +Use this overload for busy/dialog actions that should render in the dockable's current window/root, especially when documents can move between floating windows. + ## Overlay controls integration Overlay controls bind directly to the service contracts: - `BusyOverlayControl`: `BusyService`, `GlobalBusyService`. diff --git a/docfx/articles/toc.yml b/docfx/articles/toc.yml index 8d6f7ec0f..7dc671bff 100644 --- a/docfx/articles/toc.yml +++ b/docfx/articles/toc.yml @@ -32,6 +32,8 @@ items: - name: Active document href: dock-active-document.md + - name: Global dock tracking + href: dock-global-tracking.md - name: Docking groups href: dock-docking-groups.md - name: MDI document layout diff --git a/samples/DockMvvmSample/ViewModels/MainWindowViewModel.cs b/samples/DockMvvmSample/ViewModels/MainWindowViewModel.cs index 9ba141d18..e6b4b5c99 100644 --- a/samples/DockMvvmSample/ViewModels/MainWindowViewModel.cs +++ b/samples/DockMvvmSample/ViewModels/MainWindowViewModel.cs @@ -12,6 +12,7 @@ public class MainWindowViewModel : ObservableObject { private readonly IFactory? _factory; private IRootDock? _layout; + private string _globalStatus = "Global: (none)"; public IRootDock? Layout { @@ -19,6 +20,12 @@ public IRootDock? Layout set => SetProperty(ref _layout, value); } + public string GlobalStatus + { + get => _globalStatus; + set => SetProperty(ref _globalStatus, value); + } + public ICommand NewLayout { get; } public MainWindowViewModel() @@ -33,6 +40,9 @@ public MainWindowViewModel() _factory?.InitLayout(layout); } Layout = layout; + GlobalStatus = layout is null + ? "Global: (none)" + : FormatGlobalStatus(_factory?.GlobalDockTrackingState ?? GlobalDockTrackingState.Empty); if (Layout is { } root) { @@ -85,12 +95,18 @@ private void DebugFactoryEvents(IFactory factory) { factory.ActiveDockableChanged += (_, args) => { - Debug.WriteLine($"[ActiveDockableChanged] Title='{args.Dockable?.Title}'"); + 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}'"); + 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) => @@ -206,4 +222,13 @@ private void DebugFactoryEvents(IFactory factory) Debug.WriteLine($"[DockableDeactivated] Title='{args.Dockable?.Title}'"); }; } + + 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/DockMvvmSample/Views/MainView.axaml b/samples/DockMvvmSample/Views/MainView.axaml index bd6560f9c..52ef877a2 100644 --- a/samples/DockMvvmSample/Views/MainView.axaml +++ b/samples/DockMvvmSample/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:dm="using:Dock.Model.Core" xmlns:dmc="using:Dock.Model.Controls" xmlns:vm="using:DockMvvmSample.ViewModels" mc:Ignorable="d" @@ -16,7 +15,7 @@ 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 - + @@ -71,11 +70,18 @@ - - - + + + diff --git a/samples/DockReactiveUISample/ViewModels/MainWindowViewModel.cs b/samples/DockReactiveUISample/ViewModels/MainWindowViewModel.cs index c6bc919f8..a7e9ac18b 100644 --- a/samples/DockReactiveUISample/ViewModels/MainWindowViewModel.cs +++ b/samples/DockReactiveUISample/ViewModels/MainWindowViewModel.cs @@ -14,6 +14,7 @@ public class MainWindowViewModel : ReactiveObject { private readonly IFactory? _factory; private IRootDock? _layout; + private string _globalStatus = "Global: (none)"; public IRootDock? Layout { @@ -21,6 +22,12 @@ public IRootDock? Layout set => this.RaiseAndSetIfChanged(ref _layout, value); } + public string GlobalStatus + { + get => _globalStatus; + set => this.RaiseAndSetIfChanged(ref _globalStatus, value); + } + public ICommand NewLayout { get; } public MainWindowViewModel() @@ -36,6 +43,9 @@ public MainWindowViewModel() layout.Navigate.Execute("Home"); } Layout = layout; + GlobalStatus = layout is null + ? "Global: (none)" + : FormatGlobalStatus(_factory?.GlobalDockTrackingState ?? GlobalDockTrackingState.Empty); NewLayout = ReactiveCommand.Create(ResetLayout); } @@ -44,12 +54,18 @@ private void DebugFactoryEvents(IFactory factory) { factory.ActiveDockableChanged += (_, args) => { - Debug.WriteLine($"[ActiveDockableChanged] Title='{args.Dockable?.Title}'"); + 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}'"); + 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) => @@ -194,4 +210,13 @@ public void ResetLayout() 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/DockReactiveUISample/Views/MainView.axaml b/samples/DockReactiveUISample/Views/MainView.axaml index 6f5754daf..bb1b31622 100644 --- a/samples/DockReactiveUISample/Views/MainView.axaml +++ b/samples/DockReactiveUISample/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:dm="using:Dock.Model.Core" xmlns:dmc="using:Dock.Model.Controls" xmlns:vm="using:DockReactiveUISample.ViewModels" mc:Ignorable="d" @@ -16,7 +15,7 @@ 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 - + @@ -65,11 +64,18 @@ - - - + + + diff --git a/src/Dock.Model.ReactiveUI.Services/Navigation/Controls/RoutableDocumentBase.cs b/src/Dock.Model.ReactiveUI.Services/Navigation/Controls/RoutableDocumentBase.cs index 8818612af..aaed52dbc 100644 --- a/src/Dock.Model.ReactiveUI.Services/Navigation/Controls/RoutableDocumentBase.cs +++ b/src/Dock.Model.ReactiveUI.Services/Navigation/Controls/RoutableDocumentBase.cs @@ -80,6 +80,6 @@ private IHostOverlayServices GetOverlayServices() throw new InvalidOperationException("Overlay services provider is not available."); } - return _overlayServicesProvider.GetServices(this); + return _overlayServicesProvider.GetServices(this, this); } } diff --git a/src/Dock.Model.ReactiveUI.Services/Navigation/Controls/RoutableToolBase.cs b/src/Dock.Model.ReactiveUI.Services/Navigation/Controls/RoutableToolBase.cs index 9d855edcd..a5778f505 100644 --- a/src/Dock.Model.ReactiveUI.Services/Navigation/Controls/RoutableToolBase.cs +++ b/src/Dock.Model.ReactiveUI.Services/Navigation/Controls/RoutableToolBase.cs @@ -80,6 +80,6 @@ private IHostOverlayServices GetOverlayServices() throw new InvalidOperationException("Overlay services provider is not available."); } - return _overlayServicesProvider.GetServices(this); + return _overlayServicesProvider.GetServices(this, this); } } diff --git a/src/Dock.Model.ReactiveUI.Services/Navigation/Services/DockNavigationHelpers.cs b/src/Dock.Model.ReactiveUI.Services/Navigation/Services/DockNavigationHelpers.cs index 793835c6f..d9a96f545 100644 --- a/src/Dock.Model.ReactiveUI.Services/Navigation/Services/DockNavigationHelpers.cs +++ b/src/Dock.Model.ReactiveUI.Services/Navigation/Services/DockNavigationHelpers.cs @@ -34,6 +34,36 @@ public static IHostOverlayServices GetOverlayServices( return provider.GetServices(hostScreen); } + /// + /// Resolves overlay services for a specific dockable with host-screen fallback. + /// + /// The host screen fallback. + /// The dockable used to resolve current window context. + /// The overlay services provider. + /// The overlay services instance. + public static IHostOverlayServices GetOverlayServices( + IScreen hostScreen, + IDockable dockable, + IHostOverlayServicesProvider provider) + { + if (hostScreen is null) + { + throw new ArgumentNullException(nameof(hostScreen)); + } + + if (dockable is null) + { + throw new ArgumentNullException(nameof(dockable)); + } + + if (provider is null) + { + throw new ArgumentNullException(nameof(provider)); + } + + return provider.GetServices(hostScreen, dockable); + } + /// /// Gets the busy service for the specified host screen. /// @@ -45,6 +75,19 @@ public static IDockBusyService GetBusyService( IHostOverlayServicesProvider provider) => GetOverlayServices(hostScreen, provider).Busy; + /// + /// Gets the busy service for the specified dockable context. + /// + /// The host screen fallback. + /// The dockable used to resolve current window context. + /// The overlay services provider. + /// The busy service instance. + public static IDockBusyService GetBusyService( + IScreen hostScreen, + IDockable dockable, + IHostOverlayServicesProvider provider) + => GetOverlayServices(hostScreen, dockable, provider).Busy; + /// /// Gets the dialog service for the specified host screen. /// @@ -56,6 +99,19 @@ public static IDockDialogService GetDialogService( IHostOverlayServicesProvider provider) => GetOverlayServices(hostScreen, provider).Dialogs; + /// + /// Gets the dialog service for the specified dockable context. + /// + /// The host screen fallback. + /// The dockable used to resolve current window context. + /// The overlay services provider. + /// The dialog service instance. + public static IDockDialogService GetDialogService( + IScreen hostScreen, + IDockable dockable, + IHostOverlayServicesProvider provider) + => GetOverlayServices(hostScreen, dockable, provider).Dialogs; + /// /// Gets the confirmation service for the specified host screen. /// @@ -67,6 +123,19 @@ public static IDockConfirmationService GetConfirmationService( IHostOverlayServicesProvider provider) => GetOverlayServices(hostScreen, provider).Confirmations; + /// + /// Gets the confirmation service for the specified dockable context. + /// + /// The host screen fallback. + /// The dockable used to resolve current window context. + /// The overlay services provider. + /// The confirmation service instance. + public static IDockConfirmationService GetConfirmationService( + IScreen hostScreen, + IDockable dockable, + IHostOverlayServicesProvider provider) + => GetOverlayServices(hostScreen, dockable, provider).Confirmations; + /// /// Attempts to navigate back using the host screen router. /// diff --git a/src/Dock.Model.ReactiveUI.Services/Overlays/Hosting/HostOverlayServicesProvider.cs b/src/Dock.Model.ReactiveUI.Services/Overlays/Hosting/HostOverlayServicesProvider.cs index fc8b9c2e2..c33215d17 100644 --- a/src/Dock.Model.ReactiveUI.Services/Overlays/Hosting/HostOverlayServicesProvider.cs +++ b/src/Dock.Model.ReactiveUI.Services/Overlays/Hosting/HostOverlayServicesProvider.cs @@ -1,5 +1,6 @@ using System; using System.Runtime.CompilerServices; +using Dock.Model.Core; using Dock.Model.Services; using ReactiveUI; @@ -53,6 +54,23 @@ public IHostOverlayServices GetServices(IScreen screen) return _fallbackCache.GetValue(screen, _ => _fallbackFactory()); } + /// + public IHostOverlayServices GetServices(IScreen screen, IDockable dockable) + { + if (screen is null) + { + throw new ArgumentNullException(nameof(screen)); + } + + if (dockable is null) + { + throw new ArgumentNullException(nameof(dockable)); + } + + var resolved = ResolveFromDockable(dockable); + return resolved ?? GetServices(screen); + } + internal bool TryGetCached(IScreen screen, out IHostOverlayServices services) { if (screen is null) @@ -69,4 +87,52 @@ internal bool TryGetCached(IScreen screen, out IHostOverlayServices services) services = null!; return false; } + + private IHostOverlayServices? ResolveFromDockable(IDockable dockable) + { + if (dockable is IScreen dockableScreen) + { + var resolved = _resolver.Resolve(dockableScreen); + if (resolved is not null) + { + _fallbackCache.Remove(dockableScreen); + return resolved; + } + } + + var factory = FindFactory(dockable); + if (factory is null) + { + return null; + } + + var root = factory.FindRoot(dockable, _ => true); + if (root is IHostOverlayServices hostServices) + { + return hostServices; + } + + if (root?.Window?.Layout is IHostOverlayServices layoutServices) + { + return layoutServices; + } + + return null; + } + + private static IFactory? FindFactory(IDockable dockable) + { + var current = dockable; + while (current is not null) + { + if (current.Factory is IFactory factory) + { + return factory; + } + + current = current.Owner; + } + + return null; + } } diff --git a/src/Dock.Model.ReactiveUI.Services/Overlays/Hosting/IHostOverlayServicesProvider.cs b/src/Dock.Model.ReactiveUI.Services/Overlays/Hosting/IHostOverlayServicesProvider.cs index 9723236f1..d44e259c7 100644 --- a/src/Dock.Model.ReactiveUI.Services/Overlays/Hosting/IHostOverlayServicesProvider.cs +++ b/src/Dock.Model.ReactiveUI.Services/Overlays/Hosting/IHostOverlayServicesProvider.cs @@ -1,3 +1,4 @@ +using Dock.Model.Core; using Dock.Model.Services; using ReactiveUI; @@ -14,4 +15,12 @@ public interface IHostOverlayServicesProvider /// The host screen. /// The overlay services instance. IHostOverlayServices GetServices(IScreen screen); + + /// + /// Gets overlay services for a specific dockable with host-screen fallback. + /// + /// The host screen used for fallback resolution. + /// The dockable used to resolve current window/root context. + /// The overlay services instance. + IHostOverlayServices GetServices(IScreen screen, IDockable dockable); } diff --git a/src/Dock.Model/Core/DockTrackingChangeReason.cs b/src/Dock.Model/Core/DockTrackingChangeReason.cs new file mode 100644 index 000000000..54aa80c1b --- /dev/null +++ b/src/Dock.Model/Core/DockTrackingChangeReason.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. + +namespace Dock.Model.Core; + +/// +/// Indicates why global dock tracking state changed. +/// +public enum DockTrackingChangeReason +{ + /// + /// Unknown reason. + /// + Unknown = 0, + + /// + /// The active dockable changed. + /// + ActiveDockableChanged = 1, + + /// + /// The focused dockable changed. + /// + FocusedDockableChanged = 2, + + /// + /// A window was activated. + /// + WindowActivated = 3, + + /// + /// A window was deactivated. + /// + WindowDeactivated = 4, + + /// + /// A dockable was activated. + /// + DockableActivated = 5, + + /// + /// A dockable was deactivated. + /// + DockableDeactivated = 6, + + /// + /// A window was closed. + /// + WindowClosed = 7, + + /// + /// A window was removed. + /// + WindowRemoved = 8 +} diff --git a/src/Dock.Model/Core/Events/ActiveDockableChangedEventArgs.cs b/src/Dock.Model/Core/Events/ActiveDockableChangedEventArgs.cs index 1857ada44..afcc45fbd 100644 --- a/src/Dock.Model/Core/Events/ActiveDockableChangedEventArgs.cs +++ b/src/Dock.Model/Core/Events/ActiveDockableChangedEventArgs.cs @@ -1,5 +1,6 @@ // Copyright (c) Wiesław Šoltés. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for details. +using Dock.Model.Controls; using System; namespace Dock.Model.Core.Events; @@ -14,12 +15,40 @@ public class ActiveDockableChangedEventArgs : EventArgs /// public IDockable? Dockable { get; } + /// + /// Gets active dockable root dock. + /// + public IRootDock? RootDock { get; } + + /// + /// Gets active dockable window. + /// + public IDockWindow? Window { get; } + + /// + /// Gets active dockable host window. + /// + public IHostWindow? HostWindow => Window?.Host; + /// /// Initializes new instance of the class. /// /// The active dockable. public ActiveDockableChangedEventArgs(IDockable? dockable) + : this(dockable, null, null) + { + } + + /// + /// Initializes new instance of the class. + /// + /// The active dockable. + /// The root dock that owns the active dockable. + /// The window that owns the active dockable root. + public ActiveDockableChangedEventArgs(IDockable? dockable, IRootDock? rootDock, IDockWindow? window) { Dockable = dockable; + RootDock = rootDock; + Window = window; } } diff --git a/src/Dock.Model/Core/Events/FocusedDockableChangedEventArgs.cs b/src/Dock.Model/Core/Events/FocusedDockableChangedEventArgs.cs index 61e838123..d6b2d005a 100644 --- a/src/Dock.Model/Core/Events/FocusedDockableChangedEventArgs.cs +++ b/src/Dock.Model/Core/Events/FocusedDockableChangedEventArgs.cs @@ -1,5 +1,6 @@ // Copyright (c) Wiesław Šoltés. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for details. +using Dock.Model.Controls; using System; namespace Dock.Model.Core.Events; @@ -14,12 +15,40 @@ public class FocusedDockableChangedEventArgs : EventArgs /// public IDockable? Dockable { get; } + /// + /// Gets focused dockable root dock. + /// + public IRootDock? RootDock { get; } + + /// + /// Gets focused dockable window. + /// + public IDockWindow? Window { get; } + + /// + /// Gets focused dockable host window. + /// + public IHostWindow? HostWindow => Window?.Host; + /// /// Initializes new instance of the class. /// /// The focused dockable. public FocusedDockableChangedEventArgs(IDockable? dockable) + : this(dockable, null, null) + { + } + + /// + /// Initializes new instance of the class. + /// + /// The focused dockable. + /// The root dock that owns the focused dockable. + /// The window that owns the focused dockable root. + public FocusedDockableChangedEventArgs(IDockable? dockable, IRootDock? rootDock, IDockWindow? window) { Dockable = dockable; + RootDock = rootDock; + Window = window; } } diff --git a/src/Dock.Model/Core/Events/GlobalDockTrackingChangedEventArgs.cs b/src/Dock.Model/Core/Events/GlobalDockTrackingChangedEventArgs.cs new file mode 100644 index 000000000..75fbcba3f --- /dev/null +++ b/src/Dock.Model/Core/Events/GlobalDockTrackingChangedEventArgs.cs @@ -0,0 +1,42 @@ +// 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; + +namespace Dock.Model.Core.Events; + +/// +/// Global dock tracking changed event arguments. +/// +public class GlobalDockTrackingChangedEventArgs : EventArgs +{ + /// + /// Gets previous global tracking state. + /// + public GlobalDockTrackingState Previous { get; } + + /// + /// Gets current global tracking state. + /// + public GlobalDockTrackingState Current { get; } + + /// + /// Gets tracking change reason. + /// + public DockTrackingChangeReason Reason { get; } + + /// + /// Initializes new instance of the class. + /// + /// The previous tracking state. + /// The current tracking state. + /// The change reason. + public GlobalDockTrackingChangedEventArgs( + GlobalDockTrackingState previous, + GlobalDockTrackingState current, + DockTrackingChangeReason reason) + { + Previous = previous ?? throw new ArgumentNullException(nameof(previous)); + Current = current ?? throw new ArgumentNullException(nameof(current)); + Reason = reason; + } +} diff --git a/src/Dock.Model/Core/FactoryExtensions.cs b/src/Dock.Model/Core/FactoryExtensions.cs index b5da70634..b63792bd2 100644 --- a/src/Dock.Model/Core/FactoryExtensions.cs +++ b/src/Dock.Model/Core/FactoryExtensions.cs @@ -8,7 +8,7 @@ namespace Dock.Model.Core; /// /// Helper methods for . /// -internal static class FactoryExtensions +public static class FactoryExtensions { /// /// Finds the root dock that currently has focus. @@ -17,6 +17,16 @@ internal static class FactoryExtensions /// The active if found, otherwise null. public static IRootDock? GetActiveRoot(this IFactory factory) { + if (factory is null) + { + return null; + } + + if (factory.CurrentRootDock is { } trackedRoot) + { + return trackedRoot; + } + return factory .Find(d => d is IRootDock root && root.IsActive) .OfType() @@ -30,7 +40,13 @@ internal static class FactoryExtensions /// The active or null if no document is focused. public static IDocument? GetCurrentDocument(this IFactory factory) { - return factory.GetActiveRoot()?.FocusedDockable as IDocument; + if (factory is null) + { + return null; + } + + return factory.CurrentDockable as IDocument + ?? factory.GetActiveRoot()?.FocusedDockable as IDocument; } /// @@ -39,10 +55,35 @@ internal static class FactoryExtensions /// The dock factory. public static void CloseFocusedDockable(this IFactory factory) { - var dockable = factory.GetActiveRoot()?.FocusedDockable; + if (factory is null) + { + return; + } + + var dockable = factory.CurrentDockable ?? factory.GetActiveRoot()?.FocusedDockable; if (dockable is { }) { factory.CloseDockable(dockable); } } + + /// + /// Gets currently tracked dock window. + /// + /// The dock factory. + /// The current dock window or null. + public static IDockWindow? GetCurrentDockWindow(this IFactory factory) + { + return factory?.CurrentDockWindow; + } + + /// + /// Gets currently tracked host window. + /// + /// The dock factory. + /// The current host window or null. + public static IHostWindow? GetCurrentHostWindow(this IFactory factory) + { + return factory?.CurrentHostWindow; + } } diff --git a/src/Dock.Model/Core/GlobalDockTrackingState.cs b/src/Dock.Model/Core/GlobalDockTrackingState.cs new file mode 100644 index 000000000..03e7e2a40 --- /dev/null +++ b/src/Dock.Model/Core/GlobalDockTrackingState.cs @@ -0,0 +1,69 @@ +// 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 Dock.Model.Controls; + +namespace Dock.Model.Core; + +/// +/// Describes current global dock/window focus context. +/// +public sealed class GlobalDockTrackingState +{ + private readonly WeakReference? _dockable; + private readonly WeakReference? _rootDock; + private readonly WeakReference? _window; + + /// + /// Gets an empty tracking state. + /// + public static GlobalDockTrackingState Empty { get; } = new(null, null, null); + + /// + /// Gets the currently tracked dockable. + /// + public IDockable? Dockable => TryGet(_dockable); + + /// + /// Gets the root dock that owns . + /// + public IRootDock? RootDock => TryGet(_rootDock); + + /// + /// Gets the currently tracked dock window. + /// + public IDockWindow? Window => TryGet(_window); + + /// + /// Gets the currently tracked host window. + /// + public IHostWindow? HostWindow => Window?.Host; + + /// + /// Initializes a new instance of the class. + /// + /// The tracked dockable. + /// The tracked root dock. + /// The tracked window. + public GlobalDockTrackingState(IDockable? dockable, IRootDock? rootDock, IDockWindow? window) + { + _dockable = CreateWeakReference(dockable); + _rootDock = CreateWeakReference(rootDock); + _window = CreateWeakReference(window); + } + + private static WeakReference? CreateWeakReference(T? target) where T : class + { + return target is null ? null : new WeakReference(target); + } + + private static T? TryGet(WeakReference? reference) where T : class + { + if (reference is null) + { + return null; + } + + return reference.TryGetTarget(out var target) ? target : null; + } +} diff --git a/src/Dock.Model/Core/IFactory.Events.cs b/src/Dock.Model/Core/IFactory.Events.cs index cc37647ef..92d5a617b 100644 --- a/src/Dock.Model/Core/IFactory.Events.cs +++ b/src/Dock.Model/Core/IFactory.Events.cs @@ -20,6 +20,11 @@ public partial interface IFactory /// event EventHandler? FocusedDockableChanged; + /// + /// Global dock tracking changed event handler. + /// + event EventHandler? GlobalDockTrackingChanged; + /// /// Dockable init event handler. /// diff --git a/src/Dock.Model/Core/IFactory.cs b/src/Dock.Model/Core/IFactory.cs index 457f8c6ac..616dbc242 100644 --- a/src/Dock.Model/Core/IFactory.cs +++ b/src/Dock.Model/Core/IFactory.cs @@ -61,6 +61,31 @@ public partial interface IFactory /// IList HostWindows { get; } + /// + /// Gets current global dock tracking state. + /// + GlobalDockTrackingState GlobalDockTrackingState { get; } + + /// + /// Gets currently tracked dockable across all roots/windows. + /// + IDockable? CurrentDockable { get; } + + /// + /// Gets currently tracked root dock across all windows. + /// + IRootDock? CurrentRootDock { get; } + + /// + /// Gets currently tracked dock window across all roots/windows. + /// + IDockWindow? CurrentDockWindow { get; } + + /// + /// Gets currently tracked host window across all roots/windows. + /// + IHostWindow? CurrentHostWindow { get; } + /// /// When true closing a tool hides it instead of removing it. /// diff --git a/src/Dock.Model/FactoryBase.Events.cs b/src/Dock.Model/FactoryBase.Events.cs index a478f5ce4..6cb5380a3 100644 --- a/src/Dock.Model/FactoryBase.Events.cs +++ b/src/Dock.Model/FactoryBase.Events.cs @@ -17,6 +17,9 @@ public abstract partial class FactoryBase /// public event EventHandler? FocusedDockableChanged; + /// + public event EventHandler? GlobalDockTrackingChanged; + /// public event EventHandler? DockableInit; @@ -95,13 +98,53 @@ public abstract partial class FactoryBase /// public virtual void OnActiveDockableChanged(IDockable? dockable) { - ActiveDockableChanged?.Invoke(this, new ActiveDockableChangedEventArgs(dockable)); + var rootDock = ResolveRootDock(dockable); + var window = rootDock?.Window; + + if (dockable is not null) + { + if (rootDock is not null + && (CurrentRootDock is null || ReferenceEquals(CurrentRootDock, rootDock))) + { + UpdateGlobalDockTracking(dockable, rootDock, window, DockTrackingChangeReason.ActiveDockableChanged); + } + } + else if (CurrentRootDock is { } currentRoot) + { + // Null active notifications are ambiguous without source context. + // Keep tracking anchored to the current root instead of clearing it. + var currentWindow = CurrentDockWindow ?? currentRoot.Window; + var currentDockable = currentRoot.FocusedDockable ?? currentRoot.ActiveDockable; + UpdateGlobalDockTracking(currentDockable, currentRoot, currentWindow, DockTrackingChangeReason.ActiveDockableChanged); + } + + ActiveDockableChanged?.Invoke(this, new ActiveDockableChangedEventArgs(dockable, rootDock, window)); } /// public virtual void OnFocusedDockableChanged(IDockable? dockable) { - FocusedDockableChanged?.Invoke(this, new FocusedDockableChangedEventArgs(dockable)); + var rootDock = ResolveRootDock(dockable); + var window = rootDock?.Window; + + if (dockable is not null) + { + if (rootDock is not null + && (CurrentRootDock is null || ReferenceEquals(CurrentRootDock, rootDock))) + { + UpdateGlobalDockTracking(dockable, rootDock, window, DockTrackingChangeReason.FocusedDockableChanged); + } + } + else if (CurrentRootDock is { } currentRoot) + { + // Null focus notifications are ambiguous without source context. + // Keep tracking anchored to the current root instead of clearing it. + var currentWindow = CurrentDockWindow ?? currentRoot.Window; + var currentDockable = currentRoot.FocusedDockable ?? currentRoot.ActiveDockable; + UpdateGlobalDockTracking(currentDockable, currentRoot, currentWindow, DockTrackingChangeReason.FocusedDockableChanged); + } + + FocusedDockableChanged?.Invoke(this, new FocusedDockableChangedEventArgs(dockable, rootDock, window)); } /// @@ -194,6 +237,7 @@ public virtual void OnWindowAdded(IDockWindow? window) /// public virtual void OnWindowRemoved(IDockWindow? window) { + ClearGlobalDockTrackingForWindow(window, DockTrackingChangeReason.WindowRemoved); WindowRemoved?.Invoke(this, new WindowRemovedEventArgs(window)); NotifyWindowRemoved(window); } @@ -238,6 +282,7 @@ public virtual bool OnWindowClosing(IDockWindow? window) /// public virtual void OnWindowClosed(IDockWindow? window) { + ClearGlobalDockTrackingForWindow(window, DockTrackingChangeReason.WindowClosed); WindowClosed?.Invoke(this, new WindowClosedEventArgs(window)); NotifyWindowClosed(window); } @@ -274,24 +319,61 @@ public virtual void OnWindowMoveDragEnd(IDockWindow? window) /// public virtual void OnWindowActivated(IDockWindow? window) { + var rootDock = window?.Layout; + var dockable = rootDock?.FocusedDockable ?? rootDock?.ActiveDockable; + UpdateGlobalDockTracking(dockable, rootDock, window, DockTrackingChangeReason.WindowActivated); WindowActivated?.Invoke(this, new WindowActivatedEventArgs(window)); } /// public virtual void OnDockableActivated(IDockable? dockable) { + var rootDock = ResolveRootDock(dockable); + var window = rootDock?.Window; + + if (dockable is not null) + { + if (rootDock is not null + && (CurrentRootDock is null || ReferenceEquals(CurrentRootDock, rootDock))) + { + UpdateGlobalDockTracking(dockable, rootDock, window, DockTrackingChangeReason.DockableActivated); + } + } + DockableActivated?.Invoke(this, new DockableActivatedEventArgs(dockable)); } /// public virtual void OnWindowDeactivated(IDockWindow? window) { + if (window is not null && ReferenceEquals(CurrentDockWindow, window)) + { + UpdateGlobalDockTracking(null, null, null, DockTrackingChangeReason.WindowDeactivated); + } + WindowDeactivated?.Invoke(this, new WindowDeactivatedEventArgs(window)); } /// public virtual void OnDockableDeactivated(IDockable? dockable) { + if (dockable is not null && ReferenceEquals(CurrentDockable, dockable)) + { + var rootDock = CurrentRootDock ?? ResolveRootDock(dockable); + var nextDockable = rootDock?.FocusedDockable; + if (ReferenceEquals(nextDockable, dockable)) + { + nextDockable = rootDock?.ActiveDockable; + } + + if (ReferenceEquals(nextDockable, dockable)) + { + nextDockable = null; + } + + UpdateGlobalDockTracking(nextDockable, rootDock, CurrentDockWindow, DockTrackingChangeReason.DockableDeactivated); + } + DockableDeactivated?.Invoke(this, new DockableDeactivatedEventArgs(dockable)); } } diff --git a/src/Dock.Model/FactoryBase.Tracking.cs b/src/Dock.Model/FactoryBase.Tracking.cs new file mode 100644 index 000000000..d8c3546b3 --- /dev/null +++ b/src/Dock.Model/FactoryBase.Tracking.cs @@ -0,0 +1,133 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. +using Dock.Model.Controls; +using Dock.Model.Core; +using Dock.Model.Core.Events; + +namespace Dock.Model; + +/// +/// Factory base class. +/// +public abstract partial class FactoryBase +{ + private GlobalDockTrackingState _globalDockTrackingState = GlobalDockTrackingState.Empty; + + /// + public GlobalDockTrackingState GlobalDockTrackingState => _globalDockTrackingState; + + /// + public IDockable? CurrentDockable => _globalDockTrackingState.Dockable; + + /// + public IRootDock? CurrentRootDock => _globalDockTrackingState.RootDock; + + /// + public IDockWindow? CurrentDockWindow => _globalDockTrackingState.Window; + + /// + public IHostWindow? CurrentHostWindow => _globalDockTrackingState.HostWindow; + + /// + /// Updates global dock tracking state. + /// + /// The current dockable. + /// The current root dock. + /// The current window. + /// Tracking change reason. + protected virtual void UpdateGlobalDockTracking( + IDockable? dockable, + IRootDock? rootDock, + IDockWindow? window, + DockTrackingChangeReason reason) + { + var resolvedRoot = rootDock ?? ResolveRootDock(dockable) ?? window?.Layout; + var resolvedWindow = window ?? resolvedRoot?.Window; + var resolvedDockable = dockable; + + if (resolvedDockable is null && reason == DockTrackingChangeReason.WindowActivated) + { + resolvedDockable = resolvedRoot?.FocusedDockable ?? resolvedRoot?.ActiveDockable; + } + + var next = new GlobalDockTrackingState(resolvedDockable, resolvedRoot, resolvedWindow); + if (IsGlobalDockTrackingEqual(_globalDockTrackingState, next)) + { + return; + } + + var previous = _globalDockTrackingState; + _globalDockTrackingState = next; + OnGlobalDockTrackingChanged(previous, next, reason); + } + + /// + /// Clears global dock tracking state when the tracked window is closed or removed. + /// + /// Window that is closing or being removed. + /// Tracking change reason. + protected virtual void ClearGlobalDockTrackingForWindow(IDockWindow? window, DockTrackingChangeReason reason) + { + if (window is null) + { + return; + } + + var trackedWindow = _globalDockTrackingState.Window; + var trackedRoot = _globalDockTrackingState.RootDock; + + if (!ReferenceEquals(trackedWindow, window) && !ReferenceEquals(trackedRoot, window.Layout)) + { + return; + } + + UpdateGlobalDockTracking(null, null, null, reason); + } + + /// + /// Raises global dock tracking changed event. + /// + /// The previous tracking state. + /// The current tracking state. + /// Tracking change reason. + protected virtual void OnGlobalDockTrackingChanged( + GlobalDockTrackingState previous, + GlobalDockTrackingState current, + DockTrackingChangeReason reason) + { + GlobalDockTrackingChanged?.Invoke(this, new GlobalDockTrackingChangedEventArgs(previous, current, reason)); + } + + /// + /// Resolves root dock for the given dockable. + /// + /// The dockable. + /// The root dock or null. + protected static IRootDock? ResolveRootDock(IDockable? dockable) + { + if (dockable is IRootDock rootDock) + { + return rootDock; + } + + var current = dockable; + while (current is not null) + { + if (current is IRootDock root) + { + return root; + } + + current = current.Owner; + } + + return null; + } + + private static bool IsGlobalDockTrackingEqual(GlobalDockTrackingState previous, GlobalDockTrackingState current) + { + return ReferenceEquals(previous.Dockable, current.Dockable) + && ReferenceEquals(previous.RootDock, current.RootDock) + && ReferenceEquals(previous.Window, current.Window); + } +} diff --git a/tests/Dock.Avalonia.HeadlessTests/GlobalDockTrackingTests.cs b/tests/Dock.Avalonia.HeadlessTests/GlobalDockTrackingTests.cs new file mode 100644 index 000000000..c25c3aab4 --- /dev/null +++ b/tests/Dock.Avalonia.HeadlessTests/GlobalDockTrackingTests.cs @@ -0,0 +1,112 @@ +using System.Collections.Generic; +using Avalonia.Headless.XUnit; +using Dock.Avalonia.Controls; +using Dock.Model.Avalonia; +using Dock.Model.Avalonia.Controls; +using Dock.Model.Controls; +using Dock.Model.Core; +using Xunit; + +namespace Dock.Avalonia.HeadlessTests; + +public class GlobalDockTrackingTests +{ + [AvaloniaFact] + public void ManagedWindowDock_Switch_Updates_Global_Tracking_Context() + { + var factory = new Factory(); + var first = CreateContext(factory, "A"); + var second = CreateContext(factory, "B"); + var managedDock = new ManagedWindowDock { Factory = factory }; + + managedDock.AddWindow(first.ManagedDocument); + var reasons = new List(); + factory.GlobalDockTrackingChanged += (_, args) => reasons.Add(args.Reason); + + managedDock.AddWindow(second.ManagedDocument); + + Assert.Equal(2, reasons.Count); + Assert.Equal(DockTrackingChangeReason.WindowDeactivated, reasons[0]); + Assert.Equal(DockTrackingChangeReason.WindowActivated, reasons[1]); + Assert.Same(second.Dockable, factory.CurrentDockable); + Assert.Same(second.Root, factory.CurrentRootDock); + Assert.Same(second.Window, factory.CurrentDockWindow); + } + + [AvaloniaFact] + public void ManagedWindowDock_Switch_Keeps_Guard_Against_Background_SetActiveDockable() + { + var factory = new Factory(); + var first = CreateContext(factory, "A"); + var second = CreateContext(factory, "B"); + var managedDock = new ManagedWindowDock { Factory = factory }; + + managedDock.AddWindow(first.ManagedDocument); + managedDock.AddWindow(second.ManagedDocument); + Assert.Same(second.Dockable, factory.CurrentDockable); + + factory.SetActiveDockable(first.Dockable); + + Assert.Same(second.Dockable, factory.CurrentDockable); + Assert.Same(second.Root, factory.CurrentRootDock); + Assert.Same(second.Window, factory.CurrentDockWindow); + } + + [AvaloniaFact] + public void ManagedWindowDock_Setting_Active_Window_To_Null_Clears_Global_Tracking() + { + var factory = new Factory(); + var context = CreateContext(factory, "A"); + var managedDock = new ManagedWindowDock { Factory = factory }; + managedDock.AddWindow(context.ManagedDocument); + Assert.Same(context.Dockable, factory.CurrentDockable); + + var reasons = new List(); + factory.GlobalDockTrackingChanged += (_, args) => reasons.Add(args.Reason); + + managedDock.ActiveDockable = null; + + Assert.Single(reasons); + Assert.Equal(DockTrackingChangeReason.WindowDeactivated, reasons[0]); + Assert.Null(factory.CurrentDockable); + Assert.Null(factory.CurrentRootDock); + Assert.Null(factory.CurrentDockWindow); + } + + private static TrackingContext CreateContext(Factory factory, string idSuffix) + { + var root = factory.CreateRootDock(); + root.Id = $"root-{idSuffix}"; + + var dock = factory.CreateDocumentDock(); + dock.Id = $"dock-{idSuffix}"; + + var dockable = factory.CreateDocument(); + dockable.Id = $"doc-{idSuffix}"; + dockable.Title = $"Document-{idSuffix}"; + dockable.Owner = dock; + + dock.VisibleDockables = factory.CreateList(dockable); + dock.Owner = root; + dock.ActiveDockable = dockable; + dock.FocusedDockable = dockable; + + root.VisibleDockables = factory.CreateList(dock); + root.ActiveDockable = dockable; + root.FocusedDockable = dockable; + + var window = factory.CreateDockWindow(); + window.Id = $"window-{idSuffix}"; + window.Layout = root; + root.Window = window; + + var managedDocument = new ManagedDockWindowDocument(window); + return new TrackingContext(root, window, dockable, managedDocument); + } + + private sealed record TrackingContext( + IRootDock Root, + IDockWindow Window, + IDockable Dockable, + ManagedDockWindowDocument ManagedDocument); +} diff --git a/tests/Dock.Model.Avalonia.UnitTests/FactoryTests.cs b/tests/Dock.Model.Avalonia.UnitTests/FactoryTests.cs index ecf9cc71b..ba5350636 100644 --- a/tests/Dock.Model.Avalonia.UnitTests/FactoryTests.cs +++ b/tests/Dock.Model.Avalonia.UnitTests/FactoryTests.cs @@ -4,6 +4,7 @@ using Dock.Model.Avalonia; using Dock.Model.Avalonia.Core; using Dock.Model.Avalonia.Controls; +using Dock.Model.Controls; using Dock.Model.Core; using Xunit; @@ -264,6 +265,100 @@ public void OnDockableDeactivated_Raises_Event() Assert.Same(dockable, raisedDockable); } + [AvaloniaFact] + public void OnActiveDockableChanged_Includes_Root_And_Window_Context() + { + var factory = new TestFactory(); + var context = CreateDockableContext(factory); + + IRootDock? raisedRoot = null; + IDockWindow? raisedWindow = null; + + factory.ActiveDockableChanged += (_, args) => + { + raisedRoot = args.RootDock; + raisedWindow = args.Window; + }; + + factory.OnActiveDockableChanged(context.Dockable); + + Assert.Same(context.Root, raisedRoot); + Assert.Same(context.Window, raisedWindow); + } + + [AvaloniaFact] + public void GlobalDockTrackingChanged_Tracks_Window_And_Dockable() + { + var factory = new TestFactory(); + var context = CreateDockableContext(factory); + var eventRaised = false; + GlobalDockTrackingState? current = null; + + factory.GlobalDockTrackingChanged += (_, args) => + { + eventRaised = true; + current = args.Current; + }; + + factory.OnWindowActivated(context.Window); + + Assert.True(eventRaised); + Assert.NotNull(current); + Assert.Same(context.Dockable, current!.Dockable); + Assert.Same(context.Root, current.RootDock); + Assert.Same(context.Window, current.Window); + Assert.Same(context.Dockable, factory.CurrentDockable); + Assert.Same(context.Root, factory.CurrentRootDock); + Assert.Same(context.Window, factory.CurrentDockWindow); + } + + [AvaloniaFact] + public void OnWindowRemoved_Clears_Current_Global_Tracking() + { + var factory = new TestFactory(); + var context = CreateDockableContext(factory); + + factory.OnWindowActivated(context.Window); + Assert.Same(context.Window, factory.CurrentDockWindow); + + factory.OnWindowRemoved(context.Window); + + Assert.Null(factory.CurrentDockable); + Assert.Null(factory.CurrentRootDock); + Assert.Null(factory.CurrentDockWindow); + Assert.Null(factory.CurrentHostWindow); + } + + [AvaloniaFact] + public void ActiveDockableChanged_From_Different_Root_Does_Not_Override_Current_Global_State() + { + var factory = new TestFactory(); + var first = CreateDockableContext(factory); + var second = CreateDockableContext(factory); + + factory.OnWindowActivated(first.Window); + factory.OnActiveDockableChanged(second.Dockable); + + Assert.Same(first.Dockable, factory.CurrentDockable); + Assert.Same(first.Root, factory.CurrentRootDock); + Assert.Same(first.Window, factory.CurrentDockWindow); + } + + [AvaloniaFact] + public void SetActiveDockable_From_Different_Root_Does_Not_Override_Current_Global_State() + { + var factory = new TestFactory(); + var first = CreateDockableContext(factory); + var second = CreateDockableContext(factory); + + factory.OnWindowActivated(first.Window); + factory.SetActiveDockable(second.Dockable); + + Assert.Same(first.Dockable, factory.CurrentDockable); + Assert.Same(first.Root, factory.CurrentRootDock); + Assert.Same(first.Window, factory.CurrentDockWindow); + } + [AvaloniaFact] public void ActivateWindow_Triggers_WindowActivated_Event() { @@ -293,6 +388,27 @@ public void ActivateWindow_Triggers_WindowActivated_Event() Assert.True(eventRaised); Assert.Same(window, raisedWindow); } + + private static (IRootDock Root, IDockWindow Window, IDock Dock, IDockable Dockable) CreateDockableContext(TestFactory factory) + { + var root = factory.CreateRootDock(); + var window = factory.CreateDockWindow(); + var dock = factory.CreateDocumentDock(); + var dockable = factory.CreateDocument(); + + dock.VisibleDockables = factory.CreateList(dockable); + dock.Owner = root; + dock.ActiveDockable = dockable; + dock.FocusedDockable = dockable; + dockable.Owner = dock; + root.VisibleDockables = factory.CreateList(dock); + root.ActiveDockable = dock; + root.FocusedDockable = dockable; + window.Layout = root; + root.Window = window; + + return (root, window, dock, dockable); + } } public class TestFactory : Factory diff --git a/tests/Dock.Model.Mvvm.UnitTests/FactoryTests.cs b/tests/Dock.Model.Mvvm.UnitTests/FactoryTests.cs index 73cb9095b..00c8f2546 100644 --- a/tests/Dock.Model.Mvvm.UnitTests/FactoryTests.cs +++ b/tests/Dock.Model.Mvvm.UnitTests/FactoryTests.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; +using Dock.Model.Controls; using Dock.Model.Core; using Dock.Model.Mvvm.Controls; using Dock.Model.Mvvm.Core; @@ -216,6 +217,129 @@ public void OnDockableDeactivated_Raises_Event() Assert.Same(dockable, raisedDockable); } + [Fact] + public void OnActiveDockableChanged_Includes_Root_And_Window_Context() + { + var factory = new TestFactory(); + var context = CreateDockableContext(factory); + + IRootDock? raisedRoot = null; + IDockWindow? raisedWindow = null; + + factory.ActiveDockableChanged += (_, args) => + { + raisedRoot = args.RootDock; + raisedWindow = args.Window; + }; + + factory.OnActiveDockableChanged(context.Dockable); + + Assert.Same(context.Root, raisedRoot); + Assert.Same(context.Window, raisedWindow); + } + + [Fact] + public void GlobalDockTrackingChanged_Tracks_Window_And_Dockable() + { + var factory = new TestFactory(); + var context = CreateDockableContext(factory); + var eventRaised = false; + GlobalDockTrackingState? current = null; + + factory.GlobalDockTrackingChanged += (_, args) => + { + eventRaised = true; + current = args.Current; + }; + + factory.OnWindowActivated(context.Window); + + Assert.True(eventRaised); + Assert.NotNull(current); + Assert.Same(context.Dockable, current!.Dockable); + Assert.Same(context.Root, current.RootDock); + Assert.Same(context.Window, current.Window); + Assert.Same(context.Dockable, factory.CurrentDockable); + Assert.Same(context.Root, factory.CurrentRootDock); + Assert.Same(context.Window, factory.CurrentDockWindow); + } + + [Fact] + public void OnWindowRemoved_Clears_Current_Global_Tracking() + { + var factory = new TestFactory(); + var context = CreateDockableContext(factory); + + factory.OnWindowActivated(context.Window); + Assert.Same(context.Window, factory.CurrentDockWindow); + + factory.OnWindowRemoved(context.Window); + + Assert.Null(factory.CurrentDockable); + Assert.Null(factory.CurrentRootDock); + Assert.Null(factory.CurrentDockWindow); + Assert.Null(factory.CurrentHostWindow); + } + + [Fact] + public void ActiveDockableChanged_From_Different_Root_Does_Not_Override_Current_Global_State() + { + var factory = new TestFactory(); + var first = CreateDockableContext(factory); + var second = CreateDockableContext(factory); + + factory.OnWindowActivated(first.Window); + factory.OnActiveDockableChanged(second.Dockable); + + Assert.Same(first.Dockable, factory.CurrentDockable); + Assert.Same(first.Root, factory.CurrentRootDock); + Assert.Same(first.Window, factory.CurrentDockWindow); + } + + [Fact] + public void ActiveDockableChanged_Null_Does_Not_Clear_Current_Global_State() + { + var factory = new TestFactory(); + var context = CreateDockableContext(factory); + + factory.OnWindowActivated(context.Window); + factory.OnActiveDockableChanged(null); + + Assert.Same(context.Dockable, factory.CurrentDockable); + Assert.Same(context.Root, factory.CurrentRootDock); + Assert.Same(context.Window, factory.CurrentDockWindow); + } + + [Fact] + public void FocusedDockableChanged_From_Different_Root_Does_Not_Override_Current_Global_State() + { + var factory = new TestFactory(); + var first = CreateDockableContext(factory); + var second = CreateDockableContext(factory); + + factory.OnWindowActivated(first.Window); + factory.OnFocusedDockableChanged(second.Dockable); + + Assert.Same(first.Dockable, factory.CurrentDockable); + Assert.Same(first.Root, factory.CurrentRootDock); + Assert.Same(first.Window, factory.CurrentDockWindow); + } + + [Fact] + public void SetActiveDockable_From_Different_Root_Does_Not_Override_Current_Global_State() + { + var factory = new TestFactory(); + var first = CreateDockableContext(factory); + var second = CreateDockableContext(factory); + + factory.OnWindowActivated(first.Window); + factory.SetActiveDockable(second.Dockable); + + Assert.Same(first.Dockable, factory.CurrentDockable); + Assert.Same(first.Root, factory.CurrentRootDock); + Assert.Same(first.Window, factory.CurrentDockWindow); + } + [Fact] public void ActivateWindow_Triggers_WindowActivated_Event() { @@ -269,6 +393,27 @@ public void CreateSplitViewDock_Default_Values() Assert.Null(actual.PaneDockable); Assert.Null(actual.ContentDockable); } + + private static (IRootDock Root, IDockWindow Window, IDock Dock, IDockable Dockable) CreateDockableContext(TestFactory factory) + { + var root = factory.CreateRootDock(); + var window = factory.CreateDockWindow(); + var dock = factory.CreateDocumentDock(); + var dockable = factory.CreateDocument(); + + dock.VisibleDockables = factory.CreateList(dockable); + dock.Owner = root; + dock.ActiveDockable = dockable; + dock.FocusedDockable = dockable; + dockable.Owner = dock; + root.VisibleDockables = factory.CreateList(dock); + root.ActiveDockable = dock; + root.FocusedDockable = dockable; + window.Layout = root; + root.Window = window; + + return (root, window, dock, dockable); + } } public class TestFactory : Factory diff --git a/tests/Dock.Model.Mvvm.UnitTests/GlobalDockTrackingTests.cs b/tests/Dock.Model.Mvvm.UnitTests/GlobalDockTrackingTests.cs new file mode 100644 index 000000000..c01cc8312 --- /dev/null +++ b/tests/Dock.Model.Mvvm.UnitTests/GlobalDockTrackingTests.cs @@ -0,0 +1,551 @@ +using Dock.Model.Controls; +using Dock.Model.Core; +using Dock.Model.Core.Events; +using Dock.Model.Mvvm.Core; +using Xunit; + +namespace Dock.Model.Mvvm.UnitTests; + +public class GlobalDockTrackingTests +{ + [Fact] + public void Initial_State_Is_Empty() + { + var factory = new TrackingTestFactory(); + + Assert.Null(factory.CurrentDockable); + Assert.Null(factory.CurrentRootDock); + Assert.Null(factory.CurrentDockWindow); + Assert.Null(factory.CurrentHostWindow); + Assert.Null(factory.GlobalDockTrackingState.Dockable); + Assert.Null(factory.GlobalDockTrackingState.RootDock); + Assert.Null(factory.GlobalDockTrackingState.Window); + Assert.Null(factory.GlobalDockTrackingState.HostWindow); + } + + [Fact] + public void WindowActivated_Updates_State_And_Raises_Reason() + { + var factory = new TrackingTestFactory(); + var context = CreateContext(factory, "A"); + var host = new TestHostWindow { Window = context.Window }; + context.Window.Host = host; + + GlobalDockTrackingChangedEventArgs? raised = null; + factory.GlobalDockTrackingChanged += (_, args) => raised = args; + + factory.OnWindowActivated(context.Window); + + Assert.NotNull(raised); + Assert.Equal(DockTrackingChangeReason.WindowActivated, raised!.Reason); + Assert.Null(raised.Previous.Dockable); + Assert.Same(context.Dockable1, raised.Current.Dockable); + Assert.Same(context.Root, raised.Current.RootDock); + Assert.Same(context.Window, raised.Current.Window); + Assert.Same(host, raised.Current.HostWindow); + Assert.Same(context.Dockable1, factory.CurrentDockable); + Assert.Same(context.Root, factory.CurrentRootDock); + Assert.Same(context.Window, factory.CurrentDockWindow); + Assert.Same(host, factory.CurrentHostWindow); + } + + [Fact] + public void WindowActivated_With_Same_Context_Does_Not_Raise_Duplicate_Event() + { + var factory = new TrackingTestFactory(); + var context = CreateContext(factory, "A"); + var raised = 0; + factory.GlobalDockTrackingChanged += (_, _) => raised++; + + factory.OnWindowActivated(context.Window); + factory.OnWindowActivated(context.Window); + + Assert.Equal(1, raised); + } + + [Fact] + public void DockableActivated_From_Tracked_Root_Updates_State_With_Reason() + { + var factory = new TrackingTestFactory(); + var context = CreateContext(factory, "A"); + factory.OnWindowActivated(context.Window); + + GlobalDockTrackingChangedEventArgs? raised = null; + factory.GlobalDockTrackingChanged += (_, args) => raised = args; + + factory.OnDockableActivated(context.Dockable2); + + Assert.NotNull(raised); + Assert.Equal(DockTrackingChangeReason.DockableActivated, raised!.Reason); + Assert.Same(context.Dockable1, raised.Previous.Dockable); + Assert.Same(context.Dockable2, raised.Current.Dockable); + Assert.Same(context.Dockable2, factory.CurrentDockable); + Assert.Same(context.Root, factory.CurrentRootDock); + Assert.Same(context.Window, factory.CurrentDockWindow); + } + + [Fact] + public void DockableActivated_From_Different_Root_Does_Not_Change_State() + { + var factory = new TrackingTestFactory(); + var first = CreateContext(factory, "A"); + var second = CreateContext(factory, "B"); + factory.OnWindowActivated(first.Window); + + var raised = 0; + factory.GlobalDockTrackingChanged += (_, _) => raised++; + + factory.OnDockableActivated(second.Dockable1); + + Assert.Equal(0, raised); + Assert.Same(first.Dockable1, factory.CurrentDockable); + Assert.Same(first.Root, factory.CurrentRootDock); + Assert.Same(first.Window, factory.CurrentDockWindow); + } + + [Fact] + public void ActiveDockableChanged_With_Unresolved_Root_Does_Not_Change_State() + { + var factory = new TrackingTestFactory(); + var dockable = factory.CreateDocument(); + + var raised = 0; + factory.GlobalDockTrackingChanged += (_, _) => raised++; + + factory.OnActiveDockableChanged(dockable); + + Assert.Equal(0, raised); + Assert.Null(factory.CurrentDockable); + Assert.Null(factory.CurrentRootDock); + Assert.Null(factory.CurrentDockWindow); + } + + [Fact] + public void DockableActivated_With_Unresolved_Root_Does_Not_Change_State() + { + var factory = new TrackingTestFactory(); + var dockable = factory.CreateDocument(); + + var raised = 0; + factory.GlobalDockTrackingChanged += (_, _) => raised++; + + factory.OnDockableActivated(dockable); + + Assert.Equal(0, raised); + Assert.Null(factory.CurrentDockable); + Assert.Null(factory.CurrentRootDock); + Assert.Null(factory.CurrentDockWindow); + } + + [Fact] + public void ActiveDockableChanged_From_Tracked_Root_Updates_State_With_Reason() + { + var factory = new TrackingTestFactory(); + var context = CreateContext(factory, "A"); + factory.OnWindowActivated(context.Window); + + GlobalDockTrackingChangedEventArgs? raised = null; + factory.GlobalDockTrackingChanged += (_, args) => raised = args; + + factory.OnActiveDockableChanged(context.Dockable2); + + Assert.NotNull(raised); + Assert.Equal(DockTrackingChangeReason.ActiveDockableChanged, raised!.Reason); + Assert.Same(context.Dockable1, raised.Previous.Dockable); + Assert.Same(context.Dockable2, raised.Current.Dockable); + Assert.Same(context.Dockable2, factory.CurrentDockable); + Assert.Same(context.Root, factory.CurrentRootDock); + Assert.Same(context.Window, factory.CurrentDockWindow); + } + + [Fact] + public void FocusedDockableChanged_From_Tracked_Root_Updates_State_With_Reason() + { + var factory = new TrackingTestFactory(); + var context = CreateContext(factory, "A"); + factory.OnWindowActivated(context.Window); + + GlobalDockTrackingChangedEventArgs? raised = null; + factory.GlobalDockTrackingChanged += (_, args) => raised = args; + + factory.OnFocusedDockableChanged(context.Dockable2); + + Assert.NotNull(raised); + Assert.Equal(DockTrackingChangeReason.FocusedDockableChanged, raised!.Reason); + Assert.Same(context.Dockable1, raised.Previous.Dockable); + Assert.Same(context.Dockable2, raised.Current.Dockable); + Assert.Same(context.Dockable2, factory.CurrentDockable); + Assert.Same(context.Root, factory.CurrentRootDock); + Assert.Same(context.Window, factory.CurrentDockWindow); + } + + [Fact] + public void ActiveDockableChanged_Null_Without_Current_Root_Does_Not_Raise_Global_Event() + { + var factory = new TrackingTestFactory(); + var raised = 0; + factory.GlobalDockTrackingChanged += (_, _) => raised++; + + factory.OnActiveDockableChanged(null); + + Assert.Equal(0, raised); + Assert.Null(factory.CurrentDockable); + Assert.Null(factory.CurrentRootDock); + Assert.Null(factory.CurrentDockWindow); + } + + [Fact] + public void FocusedDockableChanged_Null_Without_Current_Root_Does_Not_Raise_Global_Event() + { + var factory = new TrackingTestFactory(); + var raised = 0; + factory.GlobalDockTrackingChanged += (_, _) => raised++; + + factory.OnFocusedDockableChanged(null); + + Assert.Equal(0, raised); + Assert.Null(factory.CurrentDockable); + Assert.Null(factory.CurrentRootDock); + Assert.Null(factory.CurrentDockWindow); + } + + [Fact] + public void FocusedDockableChanged_Null_Reanchors_To_Current_Root() + { + var factory = new TrackingTestFactory(); + var context = CreateContext(factory, "A"); + factory.OnWindowActivated(context.Window); + context.Root.FocusedDockable = context.Dockable2; + + GlobalDockTrackingChangedEventArgs? raised = null; + factory.GlobalDockTrackingChanged += (_, args) => raised = args; + + factory.OnFocusedDockableChanged(null); + + Assert.NotNull(raised); + Assert.Equal(DockTrackingChangeReason.FocusedDockableChanged, raised!.Reason); + Assert.Same(context.Dockable1, raised.Previous.Dockable); + Assert.Same(context.Dockable2, raised.Current.Dockable); + Assert.Same(context.Dockable2, factory.CurrentDockable); + Assert.Same(context.Root, factory.CurrentRootDock); + Assert.Same(context.Window, factory.CurrentDockWindow); + } + + [Fact] + public void WindowDeactivated_For_NonTracked_Window_Does_Not_Change_State() + { + var factory = new TrackingTestFactory(); + var first = CreateContext(factory, "A"); + var second = CreateContext(factory, "B"); + factory.OnWindowActivated(first.Window); + + var raised = 0; + factory.GlobalDockTrackingChanged += (_, _) => raised++; + + factory.OnWindowDeactivated(second.Window); + + Assert.Equal(0, raised); + Assert.Same(first.Dockable1, factory.CurrentDockable); + Assert.Same(first.Root, factory.CurrentRootDock); + Assert.Same(first.Window, factory.CurrentDockWindow); + } + + [Fact] + public void WindowDeactivated_For_Tracked_Window_Clears_State_With_Reason() + { + var factory = new TrackingTestFactory(); + var context = CreateContext(factory, "A"); + factory.OnWindowActivated(context.Window); + + GlobalDockTrackingChangedEventArgs? raised = null; + factory.GlobalDockTrackingChanged += (_, args) => raised = args; + + factory.OnWindowDeactivated(context.Window); + + Assert.NotNull(raised); + Assert.Equal(DockTrackingChangeReason.WindowDeactivated, raised!.Reason); + Assert.Same(context.Dockable1, raised.Previous.Dockable); + Assert.Null(raised.Current.Dockable); + Assert.Null(raised.Current.RootDock); + Assert.Null(raised.Current.Window); + Assert.Null(factory.CurrentDockable); + Assert.Null(factory.CurrentRootDock); + Assert.Null(factory.CurrentDockWindow); + Assert.Null(factory.CurrentHostWindow); + } + + [Fact] + public void WindowClosed_For_NonTracked_Window_Does_Not_Change_State() + { + var factory = new TrackingTestFactory(); + var first = CreateContext(factory, "A"); + var second = CreateContext(factory, "B"); + factory.OnWindowActivated(first.Window); + + var raised = 0; + factory.GlobalDockTrackingChanged += (_, _) => raised++; + + factory.OnWindowClosed(second.Window); + + Assert.Equal(0, raised); + Assert.Same(first.Dockable1, factory.CurrentDockable); + Assert.Same(first.Root, factory.CurrentRootDock); + Assert.Same(first.Window, factory.CurrentDockWindow); + } + + [Fact] + public void WindowClosed_For_Tracked_Window_Clears_State_With_Reason() + { + var factory = new TrackingTestFactory(); + var context = CreateContext(factory, "A"); + factory.OnWindowActivated(context.Window); + + GlobalDockTrackingChangedEventArgs? raised = null; + factory.GlobalDockTrackingChanged += (_, args) => raised = args; + + factory.OnWindowClosed(context.Window); + + Assert.NotNull(raised); + Assert.Equal(DockTrackingChangeReason.WindowClosed, raised!.Reason); + Assert.Same(context.Dockable1, raised.Previous.Dockable); + Assert.Null(raised.Current.Dockable); + Assert.Null(raised.Current.RootDock); + Assert.Null(raised.Current.Window); + Assert.Null(factory.CurrentDockable); + Assert.Null(factory.CurrentRootDock); + Assert.Null(factory.CurrentDockWindow); + } + + [Fact] + public void WindowRemoved_For_NonTracked_Window_Does_Not_Change_State() + { + var factory = new TrackingTestFactory(); + var first = CreateContext(factory, "A"); + var second = CreateContext(factory, "B"); + factory.OnWindowActivated(first.Window); + + var raised = 0; + factory.GlobalDockTrackingChanged += (_, _) => raised++; + + factory.OnWindowRemoved(second.Window); + + Assert.Equal(0, raised); + Assert.Same(first.Dockable1, factory.CurrentDockable); + Assert.Same(first.Root, factory.CurrentRootDock); + Assert.Same(first.Window, factory.CurrentDockWindow); + } + + [Fact] + public void WindowRemoved_For_Tracked_Window_Clears_State_With_Reason() + { + var factory = new TrackingTestFactory(); + var context = CreateContext(factory, "A"); + factory.OnWindowActivated(context.Window); + + GlobalDockTrackingChangedEventArgs? raised = null; + factory.GlobalDockTrackingChanged += (_, args) => raised = args; + + factory.OnWindowRemoved(context.Window); + + Assert.NotNull(raised); + Assert.Equal(DockTrackingChangeReason.WindowRemoved, raised!.Reason); + Assert.Same(context.Dockable1, raised.Previous.Dockable); + Assert.Null(raised.Current.Dockable); + Assert.Null(raised.Current.RootDock); + Assert.Null(raised.Current.Window); + Assert.Null(factory.CurrentDockable); + Assert.Null(factory.CurrentRootDock); + Assert.Null(factory.CurrentDockWindow); + } + + [Fact] + public void DockableDeactivated_For_Tracked_Dockable_Falls_Back_To_Focused_Dockable() + { + var factory = new TrackingTestFactory(); + var context = CreateContext(factory, "A"); + factory.OnWindowActivated(context.Window); + context.Root.FocusedDockable = context.Dockable2; + + GlobalDockTrackingChangedEventArgs? raised = null; + factory.GlobalDockTrackingChanged += (_, args) => raised = args; + + factory.OnDockableDeactivated(context.Dockable1); + + Assert.NotNull(raised); + Assert.Equal(DockTrackingChangeReason.DockableDeactivated, raised!.Reason); + Assert.Same(context.Dockable1, raised.Previous.Dockable); + Assert.Same(context.Dockable2, raised.Current.Dockable); + Assert.Same(context.Dockable2, factory.CurrentDockable); + Assert.Same(context.Root, factory.CurrentRootDock); + Assert.Same(context.Window, factory.CurrentDockWindow); + } + + [Fact] + public void DockableDeactivated_For_Tracked_Dockable_Falls_Back_To_Active_Dockable() + { + var factory = new TrackingTestFactory(); + var context = CreateContext(factory, "A"); + factory.OnWindowActivated(context.Window); + context.Root.FocusedDockable = context.Dockable1; + context.Root.ActiveDockable = context.Dockable2; + + GlobalDockTrackingChangedEventArgs? raised = null; + factory.GlobalDockTrackingChanged += (_, args) => raised = args; + + factory.OnDockableDeactivated(context.Dockable1); + + Assert.NotNull(raised); + Assert.Equal(DockTrackingChangeReason.DockableDeactivated, raised!.Reason); + Assert.Same(context.Dockable1, raised.Previous.Dockable); + Assert.Same(context.Dockable2, raised.Current.Dockable); + Assert.Same(context.Dockable2, factory.CurrentDockable); + Assert.Same(context.Root, factory.CurrentRootDock); + Assert.Same(context.Window, factory.CurrentDockWindow); + } + + [Fact] + public void DockableDeactivated_For_Tracked_Dockable_Clears_Current_Dockable_When_No_Fallback() + { + var factory = new TrackingTestFactory(); + var context = CreateContext(factory, "A"); + factory.OnWindowActivated(context.Window); + context.Root.FocusedDockable = context.Dockable1; + context.Root.ActiveDockable = context.Dockable1; + + GlobalDockTrackingChangedEventArgs? raised = null; + factory.GlobalDockTrackingChanged += (_, args) => raised = args; + + factory.OnDockableDeactivated(context.Dockable1); + + Assert.NotNull(raised); + Assert.Equal(DockTrackingChangeReason.DockableDeactivated, raised!.Reason); + Assert.Same(context.Dockable1, raised.Previous.Dockable); + Assert.Null(raised.Current.Dockable); + Assert.Same(context.Root, raised.Current.RootDock); + Assert.Same(context.Window, raised.Current.Window); + Assert.Null(factory.CurrentDockable); + Assert.Same(context.Root, factory.CurrentRootDock); + Assert.Same(context.Window, factory.CurrentDockWindow); + } + + [Fact] + public void DockableDeactivated_For_Untracked_Dockable_Does_Not_Change_State() + { + var factory = new TrackingTestFactory(); + var first = CreateContext(factory, "A"); + var second = CreateContext(factory, "B"); + factory.OnWindowActivated(first.Window); + + var raised = 0; + factory.GlobalDockTrackingChanged += (_, _) => raised++; + + factory.OnDockableDeactivated(second.Dockable1); + + Assert.Equal(0, raised); + Assert.Same(first.Dockable1, factory.CurrentDockable); + Assert.Same(first.Root, factory.CurrentRootDock); + Assert.Same(first.Window, factory.CurrentDockWindow); + } + + private static TrackingContext CreateContext(TrackingTestFactory factory, string idSuffix) + { + var root = factory.CreateRootDock(); + root.Id = $"root-{idSuffix}"; + + var window = factory.CreateDockWindow(); + window.Id = $"window-{idSuffix}"; + + var dock = factory.CreateDocumentDock(); + dock.Id = $"dock-{idSuffix}"; + + var dockable1 = factory.CreateDocument(); + dockable1.Id = $"doc-{idSuffix}-1"; + dockable1.Title = $"Document-{idSuffix}-1"; + + var dockable2 = factory.CreateDocument(); + dockable2.Id = $"doc-{idSuffix}-2"; + dockable2.Title = $"Document-{idSuffix}-2"; + + dock.VisibleDockables = factory.CreateList(dockable1, dockable2); + dock.Owner = root; + dock.ActiveDockable = dockable1; + dock.FocusedDockable = dockable1; + dockable1.Owner = dock; + dockable2.Owner = dock; + + root.VisibleDockables = factory.CreateList(dock); + root.ActiveDockable = dock; + root.FocusedDockable = dockable1; + + window.Layout = root; + root.Window = window; + + return new TrackingContext(root, window, dock, dockable1, dockable2); + } + + private sealed record TrackingContext( + IRootDock Root, + IDockWindow Window, + IDock Dock, + IDockable Dockable1, + IDockable Dockable2); + + private sealed class TrackingTestFactory : Factory + { + } + + private sealed class TestHostWindow : IHostWindow + { + public IHostWindowState? HostWindowState => null; + + public bool IsTracked { get; set; } + + public IDockWindow? Window { get; set; } + + public void Present(bool isDialog) + { + } + + public void Exit() + { + } + + public void SetPosition(double x, double y) + { + } + + public void GetPosition(out double x, out double y) + { + x = 0; + y = 0; + } + + public void SetSize(double width, double height) + { + } + + public void GetSize(out double width, out double height) + { + width = 0; + height = 0; + } + + public void SetWindowState(DockWindowState windowState) + { + } + + public DockWindowState GetWindowState() => DockWindowState.Normal; + + public void SetTitle(string? title) + { + } + + public void SetLayout(IDock layout) + { + } + + public void SetActive() + { + } + } +} diff --git a/tests/Dock.Model.Prism.UnitTests/FactoryTests.cs b/tests/Dock.Model.Prism.UnitTests/FactoryTests.cs index 03b754566..1a200908b 100644 --- a/tests/Dock.Model.Prism.UnitTests/FactoryTests.cs +++ b/tests/Dock.Model.Prism.UnitTests/FactoryTests.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; +using Dock.Model.Controls; using Dock.Model.Core; using Dock.Model.Prism.Controls; using Dock.Model.Prism.Core; @@ -216,6 +217,100 @@ public void OnDockableDeactivated_Raises_Event() Assert.Same(dockable, raisedDockable); } + [Fact] + public void OnActiveDockableChanged_Includes_Root_And_Window_Context() + { + var factory = new TestFactory(); + var context = CreateDockableContext(factory); + + IRootDock? raisedRoot = null; + IDockWindow? raisedWindow = null; + + factory.ActiveDockableChanged += (_, args) => + { + raisedRoot = args.RootDock; + raisedWindow = args.Window; + }; + + factory.OnActiveDockableChanged(context.Dockable); + + Assert.Same(context.Root, raisedRoot); + Assert.Same(context.Window, raisedWindow); + } + + [Fact] + public void GlobalDockTrackingChanged_Tracks_Window_And_Dockable() + { + var factory = new TestFactory(); + var context = CreateDockableContext(factory); + var eventRaised = false; + GlobalDockTrackingState? current = null; + + factory.GlobalDockTrackingChanged += (_, args) => + { + eventRaised = true; + current = args.Current; + }; + + factory.OnWindowActivated(context.Window); + + Assert.True(eventRaised); + Assert.NotNull(current); + Assert.Same(context.Dockable, current!.Dockable); + Assert.Same(context.Root, current.RootDock); + Assert.Same(context.Window, current.Window); + Assert.Same(context.Dockable, factory.CurrentDockable); + Assert.Same(context.Root, factory.CurrentRootDock); + Assert.Same(context.Window, factory.CurrentDockWindow); + } + + [Fact] + public void OnWindowRemoved_Clears_Current_Global_Tracking() + { + var factory = new TestFactory(); + var context = CreateDockableContext(factory); + + factory.OnWindowActivated(context.Window); + Assert.Same(context.Window, factory.CurrentDockWindow); + + factory.OnWindowRemoved(context.Window); + + Assert.Null(factory.CurrentDockable); + Assert.Null(factory.CurrentRootDock); + Assert.Null(factory.CurrentDockWindow); + Assert.Null(factory.CurrentHostWindow); + } + + [Fact] + public void ActiveDockableChanged_From_Different_Root_Does_Not_Override_Current_Global_State() + { + var factory = new TestFactory(); + var first = CreateDockableContext(factory); + var second = CreateDockableContext(factory); + + factory.OnWindowActivated(first.Window); + factory.OnActiveDockableChanged(second.Dockable); + + Assert.Same(first.Dockable, factory.CurrentDockable); + Assert.Same(first.Root, factory.CurrentRootDock); + Assert.Same(first.Window, factory.CurrentDockWindow); + } + + [Fact] + public void SetActiveDockable_From_Different_Root_Does_Not_Override_Current_Global_State() + { + var factory = new TestFactory(); + var first = CreateDockableContext(factory); + var second = CreateDockableContext(factory); + + factory.OnWindowActivated(first.Window); + factory.SetActiveDockable(second.Dockable); + + Assert.Same(first.Dockable, factory.CurrentDockable); + Assert.Same(first.Root, factory.CurrentRootDock); + Assert.Same(first.Window, factory.CurrentDockWindow); + } + [Fact] public void ActivateWindow_Triggers_WindowActivated_Event() { @@ -245,6 +340,27 @@ public void ActivateWindow_Triggers_WindowActivated_Event() Assert.True(eventRaised); Assert.Same(window, raisedWindow); } + + private static (IRootDock Root, IDockWindow Window, IDock Dock, IDockable Dockable) CreateDockableContext(TestFactory factory) + { + var root = factory.CreateRootDock(); + var window = factory.CreateDockWindow(); + var dock = factory.CreateDocumentDock(); + var dockable = factory.CreateDocument(); + + dock.VisibleDockables = factory.CreateList(dockable); + dock.Owner = root; + dock.ActiveDockable = dockable; + dock.FocusedDockable = dockable; + dockable.Owner = dock; + root.VisibleDockables = factory.CreateList(dock); + root.ActiveDockable = dock; + root.FocusedDockable = dockable; + window.Layout = root; + root.Window = window; + + return (root, window, dock, dockable); + } } public class TestFactory : Factory diff --git a/tests/Dock.Model.ReactiveProperty.UnitTests/FactoryTests.cs b/tests/Dock.Model.ReactiveProperty.UnitTests/FactoryTests.cs index dccd9b3e0..4fbdc4c03 100644 --- a/tests/Dock.Model.ReactiveProperty.UnitTests/FactoryTests.cs +++ b/tests/Dock.Model.ReactiveProperty.UnitTests/FactoryTests.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; +using Dock.Model.Controls; using Dock.Model.Core; using Dock.Model.ReactiveProperty.Controls; using Dock.Model.ReactiveProperty.Core; @@ -216,6 +217,100 @@ public void OnDockableDeactivated_Raises_Event() Assert.Same(dockable, raisedDockable); } + [Fact] + public void OnActiveDockableChanged_Includes_Root_And_Window_Context() + { + var factory = new TestFactory(); + var context = CreateDockableContext(factory); + + IRootDock? raisedRoot = null; + IDockWindow? raisedWindow = null; + + factory.ActiveDockableChanged += (_, args) => + { + raisedRoot = args.RootDock; + raisedWindow = args.Window; + }; + + factory.OnActiveDockableChanged(context.Dockable); + + Assert.Same(context.Root, raisedRoot); + Assert.Same(context.Window, raisedWindow); + } + + [Fact] + public void GlobalDockTrackingChanged_Tracks_Window_And_Dockable() + { + var factory = new TestFactory(); + var context = CreateDockableContext(factory); + var eventRaised = false; + GlobalDockTrackingState? current = null; + + factory.GlobalDockTrackingChanged += (_, args) => + { + eventRaised = true; + current = args.Current; + }; + + factory.OnWindowActivated(context.Window); + + Assert.True(eventRaised); + Assert.NotNull(current); + Assert.Same(context.Dockable, current!.Dockable); + Assert.Same(context.Root, current.RootDock); + Assert.Same(context.Window, current.Window); + Assert.Same(context.Dockable, factory.CurrentDockable); + Assert.Same(context.Root, factory.CurrentRootDock); + Assert.Same(context.Window, factory.CurrentDockWindow); + } + + [Fact] + public void OnWindowRemoved_Clears_Current_Global_Tracking() + { + var factory = new TestFactory(); + var context = CreateDockableContext(factory); + + factory.OnWindowActivated(context.Window); + Assert.Same(context.Window, factory.CurrentDockWindow); + + factory.OnWindowRemoved(context.Window); + + Assert.Null(factory.CurrentDockable); + Assert.Null(factory.CurrentRootDock); + Assert.Null(factory.CurrentDockWindow); + Assert.Null(factory.CurrentHostWindow); + } + + [Fact] + public void ActiveDockableChanged_From_Different_Root_Does_Not_Override_Current_Global_State() + { + var factory = new TestFactory(); + var first = CreateDockableContext(factory); + var second = CreateDockableContext(factory); + + factory.OnWindowActivated(first.Window); + factory.OnActiveDockableChanged(second.Dockable); + + Assert.Same(first.Dockable, factory.CurrentDockable); + Assert.Same(first.Root, factory.CurrentRootDock); + Assert.Same(first.Window, factory.CurrentDockWindow); + } + + [Fact] + public void SetActiveDockable_From_Different_Root_Does_Not_Override_Current_Global_State() + { + var factory = new TestFactory(); + var first = CreateDockableContext(factory); + var second = CreateDockableContext(factory); + + factory.OnWindowActivated(first.Window); + factory.SetActiveDockable(second.Dockable); + + Assert.Same(first.Dockable, factory.CurrentDockable); + Assert.Same(first.Root, factory.CurrentRootDock); + Assert.Same(first.Window, factory.CurrentDockWindow); + } + [Fact] public void ActivateWindow_Triggers_WindowActivated_Event() { @@ -245,6 +340,27 @@ public void ActivateWindow_Triggers_WindowActivated_Event() Assert.True(eventRaised); Assert.Same(window, raisedWindow); } + + private static (IRootDock Root, IDockWindow Window, IDock Dock, IDockable Dockable) CreateDockableContext(TestFactory factory) + { + var root = factory.CreateRootDock(); + var window = factory.CreateDockWindow(); + var dock = factory.CreateDocumentDock(); + var dockable = factory.CreateDocument(); + + dock.VisibleDockables = factory.CreateList(dockable); + dock.Owner = root; + dock.ActiveDockable = dockable; + dock.FocusedDockable = dockable; + dockable.Owner = dock; + root.VisibleDockables = factory.CreateList(dock); + root.ActiveDockable = dock; + root.FocusedDockable = dockable; + window.Layout = root; + root.Window = window; + + return (root, window, dock, dockable); + } } public class TestFactory : Factory diff --git a/tests/Dock.Model.ReactiveUI.UnitTests/DockWindowLifecycleServiceTests.cs b/tests/Dock.Model.ReactiveUI.UnitTests/DockWindowLifecycleServiceTests.cs index 0267bde70..fdfa2e304 100644 --- a/tests/Dock.Model.ReactiveUI.UnitTests/DockWindowLifecycleServiceTests.cs +++ b/tests/Dock.Model.ReactiveUI.UnitTests/DockWindowLifecycleServiceTests.cs @@ -66,6 +66,11 @@ public IHostOverlayServices GetServices(IScreen hostScreen) { return _services; } + + public IHostOverlayServices GetServices(IScreen hostScreen, IDockable dockable) + { + return _services; + } } [Fact] diff --git a/tests/Dock.Model.ReactiveUI.UnitTests/FactoryTests.cs b/tests/Dock.Model.ReactiveUI.UnitTests/FactoryTests.cs index 03399ddf5..e5643d3b4 100644 --- a/tests/Dock.Model.ReactiveUI.UnitTests/FactoryTests.cs +++ b/tests/Dock.Model.ReactiveUI.UnitTests/FactoryTests.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; +using Dock.Model.Controls; using Dock.Model.Core; using Dock.Model.ReactiveUI.Controls; using Dock.Model.ReactiveUI.Core; @@ -245,6 +246,150 @@ public void OnDockableDeactivated_Raises_Event() Assert.True(eventRaised); Assert.Same(dockable, raisedDockable); } + + [Fact] + public void OnActiveDockableChanged_Includes_Root_And_Window_Context() + { + var factory = new TestFactory(); + var context = CreateDockableContext(factory); + + IRootDock? raisedRoot = null; + IDockWindow? raisedWindow = null; + + factory.ActiveDockableChanged += (_, args) => + { + raisedRoot = args.RootDock; + raisedWindow = args.Window; + }; + + factory.OnActiveDockableChanged(context.Dockable); + + Assert.Same(context.Root, raisedRoot); + Assert.Same(context.Window, raisedWindow); + } + + [Fact] + public void GlobalDockTrackingChanged_Tracks_Window_And_Dockable() + { + var factory = new TestFactory(); + var context = CreateDockableContext(factory); + var eventRaised = false; + GlobalDockTrackingState? current = null; + + factory.GlobalDockTrackingChanged += (_, args) => + { + eventRaised = true; + current = args.Current; + }; + + factory.OnWindowActivated(context.Window); + + Assert.True(eventRaised); + Assert.NotNull(current); + Assert.Same(context.Dockable, current!.Dockable); + Assert.Same(context.Root, current.RootDock); + Assert.Same(context.Window, current.Window); + Assert.Same(context.Dockable, factory.CurrentDockable); + Assert.Same(context.Root, factory.CurrentRootDock); + Assert.Same(context.Window, factory.CurrentDockWindow); + } + + [Fact] + public void OnWindowRemoved_Clears_Current_Global_Tracking() + { + var factory = new TestFactory(); + var context = CreateDockableContext(factory); + + factory.OnWindowActivated(context.Window); + Assert.Same(context.Window, factory.CurrentDockWindow); + + factory.OnWindowRemoved(context.Window); + + Assert.Null(factory.CurrentDockable); + Assert.Null(factory.CurrentRootDock); + Assert.Null(factory.CurrentDockWindow); + Assert.Null(factory.CurrentHostWindow); + } + + [Fact] + public void ActiveDockableChanged_From_Different_Root_Does_Not_Override_Current_Global_State() + { + var factory = new TestFactory(); + var first = CreateDockableContext(factory); + var second = CreateDockableContext(factory); + + factory.OnWindowActivated(first.Window); + factory.OnActiveDockableChanged(second.Dockable); + + Assert.Same(first.Dockable, factory.CurrentDockable); + Assert.Same(first.Root, factory.CurrentRootDock); + Assert.Same(first.Window, factory.CurrentDockWindow); + } + + [Fact] + public void ActiveDockableChanged_Null_Does_Not_Clear_Current_Global_State() + { + var factory = new TestFactory(); + var context = CreateDockableContext(factory); + + factory.OnWindowActivated(context.Window); + factory.OnActiveDockableChanged(null); + + Assert.Same(context.Dockable, factory.CurrentDockable); + Assert.Same(context.Root, factory.CurrentRootDock); + Assert.Same(context.Window, factory.CurrentDockWindow); + } + + [Fact] + public void FocusedDockableChanged_From_Different_Root_Does_Not_Override_Current_Global_State() + { + var factory = new TestFactory(); + var first = CreateDockableContext(factory); + var second = CreateDockableContext(factory); + + factory.OnWindowActivated(first.Window); + factory.OnFocusedDockableChanged(second.Dockable); + + Assert.Same(first.Dockable, factory.CurrentDockable); + Assert.Same(first.Root, factory.CurrentRootDock); + Assert.Same(first.Window, factory.CurrentDockWindow); + } + + [Fact] + public void SetActiveDockable_From_Different_Root_Does_Not_Override_Current_Global_State() + { + var factory = new TestFactory(); + var first = CreateDockableContext(factory); + var second = CreateDockableContext(factory); + + factory.OnWindowActivated(first.Window); + factory.SetActiveDockable(second.Dockable); + + Assert.Same(first.Dockable, factory.CurrentDockable); + Assert.Same(first.Root, factory.CurrentRootDock); + Assert.Same(first.Window, factory.CurrentDockWindow); + } + + private static (IRootDock Root, IDockWindow Window, IDock Dock, IDockable Dockable) CreateDockableContext(TestFactory factory) + { + var root = factory.CreateRootDock(); + var window = factory.CreateDockWindow(); + var dock = factory.CreateDocumentDock(); + var dockable = factory.CreateDocument(); + + dock.VisibleDockables = factory.CreateList(dockable); + dock.Owner = root; + dock.ActiveDockable = dockable; + dock.FocusedDockable = dockable; + dockable.Owner = dock; + root.VisibleDockables = factory.CreateList(dock); + root.ActiveDockable = dock; + root.FocusedDockable = dockable; + window.Layout = root; + root.Window = window; + + return (root, window, dock, dockable); + } } public class TestFactory : Factory