From 5b7a5e29968c7243b0453440029d95efda06bf1d Mon Sep 17 00:00:00 2001 From: Coasterpete <83011124+Coasterpete@users.noreply.github.com> Date: Sun, 19 Jul 2026 19:34:42 -0400 Subject: [PATCH] M162: Add frontend docking infrastructure --- Quantum.Editor.Avalonia/App.axaml | 2 + Quantum.Editor.Avalonia/MainWindow.axaml | 59 ++----- Quantum.Editor.Avalonia/MainWindow.axaml.cs | 98 ++++++++---- .../Quantum.Editor.Avalonia.csproj | 3 + .../Services/Docking/DockPaneRegistration.cs | 30 ++++ .../Services/Docking/DockPaneRegistry.cs | 57 +++++++ .../Services/Docking/DockingLayoutIds.cs | 15 ++ .../Services/Docking/EditorDockFactory.cs | 148 ++++++++++++++++++ .../Services/Docking/EditorDockingAdapter.cs | 109 +++++++++++++ .../Editor/DockingInfrastructureTests.cs | 146 +++++++++++++++++ README.md | 4 +- THIRD_PARTY_NOTICES.md | 9 ++ docs/editor/m162-docking-infrastructure.md | 69 ++++++++ 13 files changed, 672 insertions(+), 77 deletions(-) create mode 100644 Quantum.Editor.Avalonia/Services/Docking/DockPaneRegistration.cs create mode 100644 Quantum.Editor.Avalonia/Services/Docking/DockPaneRegistry.cs create mode 100644 Quantum.Editor.Avalonia/Services/Docking/DockingLayoutIds.cs create mode 100644 Quantum.Editor.Avalonia/Services/Docking/EditorDockFactory.cs create mode 100644 Quantum.Editor.Avalonia/Services/Docking/EditorDockingAdapter.cs create mode 100644 Quantum.Tests/Editor/DockingInfrastructureTests.cs create mode 100644 docs/editor/m162-docking-infrastructure.md diff --git a/Quantum.Editor.Avalonia/App.axaml b/Quantum.Editor.Avalonia/App.axaml index 1de3cff..1c8e617 100644 --- a/Quantum.Editor.Avalonia/App.axaml +++ b/Quantum.Editor.Avalonia/App.axaml @@ -2,8 +2,10 @@ x:Class="Quantum.Editor.Avalonia.App" xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:dockTheme="using:Dock.Avalonia.Themes.Fluent" RequestedThemeVariant="Dark"> + diff --git a/Quantum.Editor.Avalonia/MainWindow.axaml b/Quantum.Editor.Avalonia/MainWindow.axaml index 45377e7..48e1cbe 100644 --- a/Quantum.Editor.Avalonia/MainWindow.axaml +++ b/Quantum.Editor.Avalonia/MainWindow.axaml @@ -2,7 +2,7 @@ x:Class="Quantum.Editor.Avalonia.MainWindow" xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" - xmlns:controls="using:Quantum.Editor.Avalonia.Controls" + xmlns:dock="using:Dock.Avalonia.Controls" Title="Quantum CoasterWorks Editor" Width="1480" Height="920" @@ -42,7 +42,7 @@ - + + + + + + + + + @@ -106,50 +114,15 @@ - - - - - - - - - - - - - - - - - - - - - - - - + (StringComparer.Ordinal) + { + [WorkspacePaneIds.Route] = RoutePane, + [WorkspacePaneIds.Viewport] = ViewportPane, + [WorkspacePaneIds.Inspector] = InspectorPane, + [WorkspacePaneIds.MathPlots] = MathPlotsPane, + [WorkspacePaneIds.Diagnostics] = DiagnosticsPane + }); + DockHost.Factory = docking.Factory; + DockHost.Layout = docking.Layout; + this.workspace.WorkspaceChanged += OnWorkspaceChanged; this.workspace.StationCursorChanged += OnStationCursorChanged; this.workspace.SectionHighlightChanged += OnSectionHighlightChanged; @@ -57,6 +85,8 @@ public MainWindow( public WorkspaceProfileManager WorkspaceProfiles => workspaceProfiles; + public EditorDockingAdapter Docking => docking; + private static EditorWorkspace CreateWorkspace() { var result = new EditorWorkspace(); @@ -64,6 +94,17 @@ private static EditorWorkspace CreateWorkspace() return result; } + private void WirePaneInteractions() + { + RoutePane.NodeSelected += OnGraphNodeSelected; + RoutePane.SectionPointerChanged += OnSectionPointerChanged; + ViewportPane.SectionPointerChanged += OnSectionPointerChanged; + ViewportPane.SampleSelected += OnViewportSampleSelected; + MathPlotsPane.SectionPointerChanged += OnSectionPointerChanged; + MathPlotsPane.StationChanged += OnEngineeringPlotStationChanged; + MathPlotsPane.StationSelected += OnEngineeringPlotStationSelected; + } + private void OnWorkspaceChanged(object? sender, EventArgs eventArgs) { UpdateWorkspaceView(); @@ -77,38 +118,21 @@ private void OnCurrentProfileChanged(object? sender, EventArgs eventArgs) private void ApplyWorkspaceProfile(WorkspaceProfile profile) { - bool showRoute = profile.IsPaneVisibleByDefault(WorkspacePaneIds.Route); - bool showViewport = profile.IsPaneVisibleByDefault(WorkspacePaneIds.Viewport); - bool showInspector = profile.IsPaneVisibleByDefault(WorkspacePaneIds.Inspector); - bool showMathPlots = profile.IsPaneVisibleByDefault(WorkspacePaneIds.MathPlots); - bool showDiagnostics = profile.IsPaneVisibleByDefault(WorkspacePaneIds.Diagnostics); - bool showBottomPanel = showMathPlots || showDiagnostics; - - RoutePane.IsVisible = showRoute; - ViewportPane.IsVisible = showViewport; - InspectorPane.IsVisible = showInspector; - RouteSplitter.IsVisible = showRoute && showViewport; - InspectorSplitter.IsVisible = showViewport && showInspector; - MathPlotsTab.IsVisible = showMathPlots; - DiagnosticsTab.IsVisible = showDiagnostics; - BottomPanelRegion.IsVisible = showBottomPanel; - BottomPanelSplitter.IsVisible = showBottomPanel; - if (showBottomPanel && - BottomWorkspaceTabs.SelectedItem is Control selectedTab && - !selectedTab.IsVisible) - { - BottomWorkspaceTabs.SelectedItem = showMathPlots ? MathPlotsTab : DiagnosticsTab; - } - - ContentPaneGrid.ColumnDefinitions[0].Width = new GridLength(showRoute ? 320 : 0); - ContentPaneGrid.ColumnDefinitions[1].Width = new GridLength(showRoute && showViewport ? 5 : 0); - ContentPaneGrid.ColumnDefinitions[2].Width = showViewport - ? new GridLength(1, GridUnitType.Star) - : new GridLength(0); - ContentPaneGrid.ColumnDefinitions[3].Width = new GridLength(showViewport && showInspector ? 5 : 0); - ContentPaneGrid.ColumnDefinitions[4].Width = new GridLength(showInspector ? 340 : 0); - WorkbenchGrid.RowDefinitions[3].Height = new GridLength(showBottomPanel ? 5 : 0); - WorkbenchGrid.RowDefinitions[4].Height = new GridLength(showBottomPanel ? 300 : 0); + docking.SetPaneVisible( + WorkspacePaneIds.Route, + profile.IsPaneVisibleByDefault(WorkspacePaneIds.Route)); + docking.SetPaneVisible( + WorkspacePaneIds.Viewport, + profile.IsPaneVisibleByDefault(WorkspacePaneIds.Viewport)); + docking.SetPaneVisible( + WorkspacePaneIds.Inspector, + profile.IsPaneVisibleByDefault(WorkspacePaneIds.Inspector)); + docking.SetPaneVisible( + WorkspacePaneIds.MathPlots, + profile.IsPaneVisibleByDefault(WorkspacePaneIds.MathPlots)); + docking.SetPaneVisible( + WorkspacePaneIds.Diagnostics, + profile.IsPaneVisibleByDefault(WorkspacePaneIds.Diagnostics)); bool showFileCommands = profile.HasCommandGroup(WorkspaceCommandGroupIds.File); bool showEditCommands = profile.HasCommandGroup(WorkspaceCommandGroupIds.Edit); @@ -361,7 +385,15 @@ private void OnMenuShowFramesClick(object? sender, RoutedEventArgs eventArgs) private void OnShowEngineeringPlotsClick(object? sender, RoutedEventArgs eventArgs) { - BottomWorkspaceTabs.SelectedIndex = 0; + docking.ShowPane(WorkspacePaneIds.MathPlots); + } + + private void OnShowPaneClick(object? sender, RoutedEventArgs eventArgs) + { + if (sender is MenuItem { Tag: string paneId }) + { + docking.ShowPane(paneId); + } } private void OnEngineeringPlotStationChanged( diff --git a/Quantum.Editor.Avalonia/Quantum.Editor.Avalonia.csproj b/Quantum.Editor.Avalonia/Quantum.Editor.Avalonia.csproj index 011611c..d42e81c 100644 --- a/Quantum.Editor.Avalonia/Quantum.Editor.Avalonia.csproj +++ b/Quantum.Editor.Avalonia/Quantum.Editor.Avalonia.csproj @@ -12,6 +12,9 @@ + + + diff --git a/Quantum.Editor.Avalonia/Services/Docking/DockPaneRegistration.cs b/Quantum.Editor.Avalonia/Services/Docking/DockPaneRegistration.cs new file mode 100644 index 0000000..5d08543 --- /dev/null +++ b/Quantum.Editor.Avalonia/Services/Docking/DockPaneRegistration.cs @@ -0,0 +1,30 @@ +namespace Quantum.Editor.Avalonia.Services.Docking; + +/// +/// Frontend-only metadata for one dockable editor pane. +/// +public sealed class DockPaneRegistration +{ + public DockPaneRegistration(string id, string title, bool canClose = true) + { + if (string.IsNullOrWhiteSpace(id)) + { + throw new ArgumentException("A dock pane identifier cannot be empty.", nameof(id)); + } + + if (string.IsNullOrWhiteSpace(title)) + { + throw new ArgumentException("A dock pane title cannot be empty.", nameof(title)); + } + + Id = id.Trim(); + Title = title.Trim(); + CanClose = canClose; + } + + public string Id { get; } + + public string Title { get; } + + public bool CanClose { get; } +} diff --git a/Quantum.Editor.Avalonia/Services/Docking/DockPaneRegistry.cs b/Quantum.Editor.Avalonia/Services/Docking/DockPaneRegistry.cs new file mode 100644 index 0000000..1b479bd --- /dev/null +++ b/Quantum.Editor.Avalonia/Services/Docking/DockPaneRegistry.cs @@ -0,0 +1,57 @@ +using Quantum.Editor.Avalonia.Services.Workspaces; + +namespace Quantum.Editor.Avalonia.Services.Docking; + +/// +/// Registers the panes understood by the Avalonia docking composition root. +/// +public sealed class DockPaneRegistry +{ + private readonly Dictionary panesById = + new(StringComparer.Ordinal); + private readonly List panes = new(); + + public IReadOnlyList Panes => panes.AsReadOnly(); + + public static DockPaneRegistry CreateDefaultTrack() + { + var result = new DockPaneRegistry(); + result.Register(new DockPaneRegistration(WorkspacePaneIds.Route, "Route")); + result.Register(new DockPaneRegistration( + WorkspacePaneIds.Viewport, + "Viewport", + canClose: false)); + result.Register(new DockPaneRegistration(WorkspacePaneIds.Inspector, "Inspector")); + result.Register(new DockPaneRegistration(WorkspacePaneIds.MathPlots, "Math Plots")); + result.Register(new DockPaneRegistration(WorkspacePaneIds.Diagnostics, "Diagnostics")); + return result; + } + + public void Register(DockPaneRegistration pane) + { + ArgumentNullException.ThrowIfNull(pane); + if (panesById.ContainsKey(pane.Id)) + { + throw new InvalidOperationException($"Dock pane '{pane.Id}' is already registered."); + } + + panesById.Add(pane.Id, pane); + panes.Add(pane); + } + + public bool Contains(string paneId) => + !string.IsNullOrWhiteSpace(paneId) && panesById.ContainsKey(paneId); + + public bool TryGet(string paneId, out DockPaneRegistration pane) => + panesById.TryGetValue(paneId, out pane!); + + public DockPaneRegistration Get(string paneId) + { + if (!panesById.TryGetValue(paneId, out DockPaneRegistration? pane)) + { + throw new KeyNotFoundException($"Dock pane '{paneId}' is not registered."); + } + + return pane; + } +} diff --git a/Quantum.Editor.Avalonia/Services/Docking/DockingLayoutIds.cs b/Quantum.Editor.Avalonia/Services/Docking/DockingLayoutIds.cs new file mode 100644 index 0000000..ce250a6 --- /dev/null +++ b/Quantum.Editor.Avalonia/Services/Docking/DockingLayoutIds.cs @@ -0,0 +1,15 @@ +namespace Quantum.Editor.Avalonia.Services.Docking; + +/// +/// Stable identifiers for the frontend-only default docking graph. +/// +public static class DockingLayoutIds +{ + public const string Root = "track-root"; + public const string Main = "track-main"; + public const string Top = "track-top"; + public const string RouteHost = "track-route-host"; + public const string ViewportHost = "track-viewport-host"; + public const string InspectorHost = "track-inspector-host"; + public const string BottomHost = "track-bottom-host"; +} diff --git a/Quantum.Editor.Avalonia/Services/Docking/EditorDockFactory.cs b/Quantum.Editor.Avalonia/Services/Docking/EditorDockFactory.cs new file mode 100644 index 0000000..8a240c6 --- /dev/null +++ b/Quantum.Editor.Avalonia/Services/Docking/EditorDockFactory.cs @@ -0,0 +1,148 @@ +using Dock.Avalonia.Controls; +using Dock.Model.Controls; +using Dock.Model.Core; +using Dock.Model.Mvvm; +using Dock.Model.Mvvm.Controls; +using Quantum.Editor.Avalonia.Services.Workspaces; + +namespace Quantum.Editor.Avalonia.Services.Docking; + +/// +/// Dock.Avalonia model factory for the current Track workspace composition. +/// +internal sealed class EditorDockFactory : Factory +{ + private readonly DockPaneRegistry registry; + private readonly IReadOnlyDictionary paneContexts; + private readonly Dictionary panes = new(StringComparer.Ordinal); + + public EditorDockFactory( + DockPaneRegistry registry, + IReadOnlyDictionary paneContexts) + { + this.registry = registry ?? throw new ArgumentNullException(nameof(registry)); + this.paneContexts = paneContexts ?? throw new ArgumentNullException(nameof(paneContexts)); + HideToolsOnClose = true; + } + + public IDockable GetPane(string paneId) => panes[paneId]; + + public override IRootDock CreateLayout() + { + panes.Clear(); + + IDockable route = CreatePane(WorkspacePaneIds.Route); + IDockable viewport = CreatePane(WorkspacePaneIds.Viewport); + IDockable inspector = CreatePane(WorkspacePaneIds.Inspector); + IDockable mathPlots = CreatePane(WorkspacePaneIds.MathPlots); + IDockable diagnostics = CreatePane(WorkspacePaneIds.Diagnostics); + + var routeHost = new ToolDock + { + Id = DockingLayoutIds.RouteHost, + Title = "Route", + Alignment = Alignment.Left, + Proportion = 0.22, + ActiveDockable = route, + VisibleDockables = CreateList(route) + }; + var viewportHost = new DocumentDock + { + Id = DockingLayoutIds.ViewportHost, + Title = "Viewport", + IsCollapsable = false, + CanCreateDocument = false, + EnableWindowDrag = false, + Proportion = 0.55, + ActiveDockable = viewport, + VisibleDockables = CreateList(viewport) + }; + var inspectorHost = new ToolDock + { + Id = DockingLayoutIds.InspectorHost, + Title = "Inspector", + Alignment = Alignment.Right, + Proportion = 0.23, + ActiveDockable = inspector, + VisibleDockables = CreateList(inspector) + }; + var top = new ProportionalDock + { + Id = DockingLayoutIds.Top, + Title = "Track workbench", + Orientation = Orientation.Horizontal, + Proportion = 0.64, + ActiveDockable = viewportHost, + VisibleDockables = CreateList( + routeHost, + new ProportionalDockSplitter(), + viewportHost, + new ProportionalDockSplitter(), + inspectorHost) + }; + var bottomHost = new ToolDock + { + Id = DockingLayoutIds.BottomHost, + Title = "Engineering", + Alignment = Alignment.Bottom, + Proportion = 0.36, + ActiveDockable = mathPlots, + VisibleDockables = CreateList(mathPlots, diagnostics) + }; + var main = new ProportionalDock + { + Id = DockingLayoutIds.Main, + Title = "Track workspace", + Orientation = Orientation.Vertical, + ActiveDockable = top, + VisibleDockables = CreateList( + top, + new ProportionalDockSplitter(), + bottomHost) + }; + + var root = (RootDock)CreateRootDock(); + root.Id = DockingLayoutIds.Root; + root.Title = "Track workspace"; + root.IsCollapsable = false; + root.DefaultDockable = main; + root.ActiveDockable = main; + root.VisibleDockables = CreateList(main); + return root; + } + + public override void InitLayout(IDockable layout) + { + ContextLocator = registry.Panes.ToDictionary( + pane => pane.Id, + pane => new Func(() => paneContexts[pane.Id]), + StringComparer.Ordinal); + DockableLocator = registry.Panes.ToDictionary( + pane => pane.Id, + pane => new Func(() => panes[pane.Id]), + StringComparer.Ordinal); + HostWindowLocator = new Dictionary> + { + [nameof(IDockWindow)] = () => new HostWindow() + }; + + base.InitLayout(layout); + } + + private IDockable CreatePane(string paneId) + { + DockPaneRegistration registration = registry.Get(paneId); + var result = new Tool + { + Id = registration.Id, + Title = registration.Title, + CanClose = registration.CanClose, + CanPin = false, + CanDrag = true, + CanFloat = true, + CanDockAsDocument = true + }; + panes.Add(paneId, result); + return result; + } +} diff --git a/Quantum.Editor.Avalonia/Services/Docking/EditorDockingAdapter.cs b/Quantum.Editor.Avalonia/Services/Docking/EditorDockingAdapter.cs new file mode 100644 index 0000000..0c7d0c0 --- /dev/null +++ b/Quantum.Editor.Avalonia/Services/Docking/EditorDockingAdapter.cs @@ -0,0 +1,109 @@ +using Dock.Model.Controls; +using Dock.Model.Core; + +namespace Quantum.Editor.Avalonia.Services.Docking; + +/// +/// Isolates Dock.Avalonia initialization and pane lifecycle from the editor shell. +/// +public sealed class EditorDockingAdapter +{ + private readonly EditorDockFactory factory; + + public EditorDockingAdapter( + DockPaneRegistry registry, + IReadOnlyDictionary paneContexts) + { + Registry = registry ?? throw new ArgumentNullException(nameof(registry)); + ArgumentNullException.ThrowIfNull(paneContexts); + ValidatePaneContexts(registry, paneContexts); + + factory = new EditorDockFactory(registry, paneContexts); + Layout = factory.CreateLayout(); + factory.InitLayout(Layout); + IsInitialized = true; + } + + public DockPaneRegistry Registry { get; } + + public IFactory Factory => factory; + + public IRootDock Layout { get; } + + public bool IsInitialized { get; } + + public IDockable GetPane(string paneId) + { + Registry.Get(paneId); + return factory.GetPane(paneId); + } + + public bool IsPaneOpen(string paneId) + { + IDockable pane = GetPane(paneId); + return !IsHidden(pane) && pane.Owner is not null; + } + + public void ShowPane(string paneId) + { + IDockable pane = GetPane(paneId); + if (IsHidden(pane)) + { + factory.RestoreDockable(pane); + } + + factory.SetActiveDockable(pane); + if (pane.Owner is IDock owner) + { + factory.SetFocusedDockable(owner, pane); + } + } + + public bool TryClosePane(string paneId) + { + DockPaneRegistration registration = Registry.Get(paneId); + if (!registration.CanClose || !IsPaneOpen(paneId)) + { + return false; + } + + factory.CloseDockable(GetPane(paneId)); + return !IsPaneOpen(paneId); + } + + public void SetPaneVisible(string paneId, bool isVisible) + { + if (isVisible) + { + if (!IsPaneOpen(paneId)) + { + ShowPane(paneId); + } + + return; + } + + if (IsPaneOpen(paneId)) + { + factory.HideDockable(GetPane(paneId)); + } + } + + private static bool IsHidden(IDockable pane) => + pane.Owner is IRootDock root && root.HiddenDockables?.Contains(pane) == true; + + private static void ValidatePaneContexts( + DockPaneRegistry registry, + IReadOnlyDictionary paneContexts) + { + foreach (DockPaneRegistration pane in registry.Panes) + { + if (!paneContexts.TryGetValue(pane.Id, out object? context) || context is null) + { + throw new ArgumentException( + $"No frontend content was supplied for dock pane '{pane.Id}'.", + nameof(paneContexts)); + } + } + } +} diff --git a/Quantum.Tests/Editor/DockingInfrastructureTests.cs b/Quantum.Tests/Editor/DockingInfrastructureTests.cs new file mode 100644 index 0000000..c802608 --- /dev/null +++ b/Quantum.Tests/Editor/DockingInfrastructureTests.cs @@ -0,0 +1,146 @@ +using Dock.Model.Core; +using Quantum.Editor.Avalonia.Services.Docking; +using Quantum.Editor.Avalonia.Services.Workspaces; + +namespace Quantum.Tests; + +public sealed class DockingInfrastructureTests +{ + [Fact] + public void Registry_RegistersTrackPanesAndKeepsViewportNonCloseable() + { + DockPaneRegistry registry = DockPaneRegistry.CreateDefaultTrack(); + + Assert.Collection( + registry.Panes, + pane => AssertPane(pane, WorkspacePaneIds.Route, "Route", canClose: true), + pane => AssertPane(pane, WorkspacePaneIds.Viewport, "Viewport", canClose: false), + pane => AssertPane(pane, WorkspacePaneIds.Inspector, "Inspector", canClose: true), + pane => AssertPane(pane, WorkspacePaneIds.MathPlots, "Math Plots", canClose: true), + pane => AssertPane(pane, WorkspacePaneIds.Diagnostics, "Diagnostics", canClose: true)); + Assert.Throws(() => + registry.Register(new DockPaneRegistration(WorkspacePaneIds.Route, "Duplicate"))); + } + + [Fact] + public void Adapter_InitializesDockFactoryWithRegisteredFrontendContexts() + { + DockPaneRegistry registry = DockPaneRegistry.CreateDefaultTrack(); + IReadOnlyDictionary contexts = CreateContexts(registry); + + var adapter = new EditorDockingAdapter(registry, contexts); + + Assert.True(adapter.IsInitialized); + Assert.Equal(DockingLayoutIds.Root, adapter.Layout.Id); + Assert.Same(adapter.Factory, adapter.Layout.Factory); + foreach (DockPaneRegistration registration in registry.Panes) + { + IDockable pane = adapter.GetPane(registration.Id); + Assert.Same(contexts[registration.Id], pane.Context); + Assert.Same(adapter.Factory, pane.Factory); + Assert.True(adapter.IsPaneOpen(registration.Id)); + } + } + + [Fact] + public void DefaultTrackLayout_PreservesFivePaneWorkbenchComposition() + { + EditorDockingAdapter adapter = CreateAdapter(); + + Assert.Equal( + DockingLayoutIds.RouteHost, + adapter.GetPane(WorkspacePaneIds.Route).Owner?.Id); + Assert.Equal( + DockingLayoutIds.ViewportHost, + adapter.GetPane(WorkspacePaneIds.Viewport).Owner?.Id); + Assert.Equal( + DockingLayoutIds.InspectorHost, + adapter.GetPane(WorkspacePaneIds.Inspector).Owner?.Id); + Assert.Equal( + DockingLayoutIds.BottomHost, + adapter.GetPane(WorkspacePaneIds.MathPlots).Owner?.Id); + Assert.Equal( + DockingLayoutIds.BottomHost, + adapter.GetPane(WorkspacePaneIds.Diagnostics).Owner?.Id); + + IDock bottomHost = Assert.IsAssignableFrom( + adapter.GetPane(WorkspacePaneIds.MathPlots).Owner); + Assert.Equal(2, bottomHost.VisibleDockables?.Count); + Assert.Same(adapter.GetPane(WorkspacePaneIds.MathPlots), bottomHost.ActiveDockable); + Assert.False(adapter.GetPane(WorkspacePaneIds.Viewport).CanClose); + Assert.All( + new[] + { + WorkspacePaneIds.Route, + WorkspacePaneIds.Inspector, + WorkspacePaneIds.MathPlots, + WorkspacePaneIds.Diagnostics + }, + paneId => Assert.True(adapter.GetPane(paneId).CanClose)); + } + + [Fact] + public void ClosedToolPane_CanBeReopenedInItsPreviousDock() + { + EditorDockingAdapter adapter = CreateAdapter(); + IDockable diagnostics = adapter.GetPane(WorkspacePaneIds.Diagnostics); + + Assert.True(adapter.TryClosePane(WorkspacePaneIds.Diagnostics)); + Assert.False(adapter.IsPaneOpen(WorkspacePaneIds.Diagnostics)); + + adapter.ShowPane(WorkspacePaneIds.Diagnostics); + + Assert.True(adapter.IsPaneOpen(WorkspacePaneIds.Diagnostics)); + Assert.Equal(DockingLayoutIds.BottomHost, diagnostics.Owner?.Id); + IDock bottomHost = Assert.IsAssignableFrom(diagnostics.Owner); + Assert.Same(diagnostics, bottomHost.ActiveDockable); + } + + [Fact] + public void ApplyingDefaultVisibleState_KeepsEveryPaneOpenAndMathPlotsActive() + { + EditorDockingAdapter adapter = CreateAdapter(); + + foreach (DockPaneRegistration pane in adapter.Registry.Panes) + { + adapter.SetPaneVisible(pane.Id, isVisible: true); + } + + Assert.All(adapter.Registry.Panes, pane => Assert.True(adapter.IsPaneOpen(pane.Id))); + IDock bottomHost = Assert.IsAssignableFrom( + adapter.GetPane(WorkspacePaneIds.MathPlots).Owner); + Assert.Same(adapter.GetPane(WorkspacePaneIds.MathPlots), bottomHost.ActiveDockable); + } + + [Fact] + public void PrimaryViewport_CannotBeClosedByPaneLifecycleCommand() + { + EditorDockingAdapter adapter = CreateAdapter(); + + Assert.False(adapter.TryClosePane(WorkspacePaneIds.Viewport)); + Assert.True(adapter.IsPaneOpen(WorkspacePaneIds.Viewport)); + } + + private static EditorDockingAdapter CreateAdapter() + { + DockPaneRegistry registry = DockPaneRegistry.CreateDefaultTrack(); + return new EditorDockingAdapter(registry, CreateContexts(registry)); + } + + private static IReadOnlyDictionary CreateContexts(DockPaneRegistry registry) => + registry.Panes.ToDictionary( + pane => pane.Id, + _ => (object)new object(), + StringComparer.Ordinal); + + private static void AssertPane( + DockPaneRegistration pane, + string id, + string title, + bool canClose) + { + Assert.Equal(id, pane.Id); + Assert.Equal(title, pane.Title); + Assert.Equal(canClose, pane.CanClose); + } +} diff --git a/README.md b/README.md index e6be1c6..b0323d5 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Quantum CoasterWorks is an early-stage coaster design and simulation backend. Th - `Quantum.Core`, `Quantum.Math`, `Quantum.Splines`, `Quantum.Track`, `Quantum.FVD`, `Quantum.Physics`, and `Quantum.IO` contain backend/domain logic. - `Quantum.Debug` contains backend diagnostics and command-line tooling. -- `Quantum.Editor.Avalonia` contains a standalone editor workbench for Track Layout Package V2 files, backend compilation, document/outliner/inspector workflows, undo/redo, an Avalonia-drawn technical viewport, the snapshot-driven [M159 Interactive Math Plot Workspace](docs/editor/m159-interactive-math-plots.md), and [M161 workspace profile infrastructure](docs/editor/m161-workspace-profile-infrastructure.md). +- `Quantum.Editor.Avalonia` contains a standalone editor workbench for Track Layout Package V2 files, backend compilation, document/outliner/inspector workflows, undo/redo, an Avalonia-drawn technical viewport, the snapshot-driven [M159 Interactive Math Plot Workspace](docs/editor/m159-interactive-math-plots.md), [M161 workspace profile infrastructure](docs/editor/m161-workspace-profile-infrastructure.md), and [M162 docking infrastructure](docs/editor/m162-docking-infrastructure.md). - `Quantum.Tests` contains automated tests and contract fixtures. - `Assets` contains the current Unity debug visualizer/prototype assets. @@ -119,6 +119,8 @@ M160 keeps this layout and behavior intact while extracting the Route, Viewport, M161 adds profile registration, lookup, default selection, and switching infrastructure around that composition. Track remains the only available workspace and preserves the M160 workbench exactly; Train, Support, Terrain, and Simulation are hidden definitions pending meaningful vertical slices. See [`docs/editor/m161-workspace-profile-infrastructure.md`](docs/editor/m161-workspace-profile-infrastructure.md). +M162 replaces the fixed workbench grid with frontend-only Dock.Avalonia composition. The same five Track panes now resize, dock, tab, and float; closeable panes can be restored from `View > Panes`, while the primary Viewport is non-closeable by default. Layout persistence and additional workspaces remain deferred. See [`docs/editor/m162-docking-infrastructure.md`](docs/editor/m162-docking-infrastructure.md). + From a fresh checkout: ```powershell diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 489ea2a..665ce8a 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -18,6 +18,15 @@ None currently. - Project page: https://gsharker.github.io/G-Shark - Current use: spline/NURBS evaluation through `Quantum.Splines` +### Dock + +- Packages: `Dock.Avalonia`, `Dock.Avalonia.Themes.Fluent`, and `Dock.Model.Mvvm` +- Version: `12.0.0.2` +- License: MIT, per NuGet package metadata +- NuGet: https://www.nuget.org/packages/Dock.Avalonia/12.0.0.2 +- Source repository: https://github.com/wieslawsoltes/Dock +- Current use: dockable pane composition in `Quantum.Editor.Avalonia` + The optional Unity debug/prototype visualizer may use a local copied DLL at `Assets/Plugins/Quantum/GShark.dll`. That file is generated/copied by `tools/copy-quantum-unity-dlls.ps1` from restored outputs and is intentionally ignored rather than committed. ## Project-Built DLLs diff --git a/docs/editor/m162-docking-infrastructure.md b/docs/editor/m162-docking-infrastructure.md new file mode 100644 index 0000000..621b9e8 --- /dev/null +++ b/docs/editor/m162-docking-infrastructure.md @@ -0,0 +1,69 @@ +# Milestone 162 Docking Infrastructure + +M162 replaces the fixed M160/M161 workbench grid with a Dock.Avalonia layout. +The Track workspace remains the default and only visible workspace, with the +same Route, Viewport, Inspector, Math Plots, and Diagnostics pane controls and +the same editor coordination owned by `MainWindow`. + +## Docking integration + +The Avalonia frontend references Dock.Avalonia `12.0.0.2`, its Fluent theme, +and the matching MVVM model package. All library-specific code is contained in +`Quantum.Editor.Avalonia.Services.Docking`: + +- `DockPaneRegistry` registers the five stable M161 pane identifiers, titles, + and close behavior. +- `EditorDockFactory` creates and initializes the Dock.Avalonia model graph, + supplies the existing pane controls as frontend contexts, enables floating + host windows, and hides closed tools so they can be restored. +- `EditorDockingAdapter` is the shell-facing boundary for pane lookup, + visibility, close, and reopen operations. +- `DockingLayoutIds` identifies only the frontend docking hosts; these IDs do + not enter workspace or backend contracts. + +The default layout mirrors the previous workbench: Route on the left, +Viewport in the center, Inspector on the right, and a bottom tab group with +Math Plots active beside Diagnostics. Proportional splitters provide resizing. +Dock.Avalonia supplies drag docking, tabbing, and floating windows. The +Viewport can move, dock, tab, and float, but its close action is disabled by +default. + +## Pane lifecycle + +Route, Inspector, Math Plots, and Diagnostics are closeable. Closing one moves +it into Dock.Avalonia's hidden-tool collection while retaining its previous +dock. The `View > Panes` menu restores and focuses any registered pane; the +existing `View > Math Plots` command now uses the same path. + +This milestone does not serialize the docking graph. Every editor launch +creates the documented default Track arrangement. Layout persistence remains +reserved for a later milestone. + +## Preserved editor behavior + +M162 reuses the exact M160 pane control instances. `MainWindow` still: + +- projects the active document, selection, viewport snapshot, and engineering + snapshot into those controls; +- routes authoring, viewport, and Math Plot interactions into the existing + `EditorWorkspace` APIs; +- synchronizes station cursor and section highlighting across panes; and +- owns document commands, compilation updates, undo/redo, and persistence UI. + +No changes were made to `EditorWorkspace`, `EngineeringSnapshot`, the +authoring graph, document model, compilation, engineering calculations, or any +backend project. Docking packages are referenced only by +`Quantum.Editor.Avalonia`. + +## Deliberate exclusions + +- saved or restored user layouts; +- workspace selector UI; +- Train, Support, Terrain, or Simulation workspaces; +- placeholder panes; and +- backend or Track Workspace docking abstractions. + +`Quantum.Tests/Editor/DockingInfrastructureTests.cs` covers pane registration, +adapter/factory initialization, the default Track composition, closed-pane +restoration, and the non-closeable primary Viewport contract. Existing editor +and backend tests continue to cover the preserved behavior.