From 893de06b47bfe1cc6717e73ffbeddce5b3688acf Mon Sep 17 00:00:00 2001
From: Coasterpete <83011124+Coasterpete@users.noreply.github.com>
Date: Sun, 19 Jul 2026 21:36:44 -0400
Subject: [PATCH] M164:Workspace selector and composition switching
---
Quantum.Editor.Avalonia/MainWindow.axaml | 23 ++-
Quantum.Editor.Avalonia/MainWindow.axaml.cs | 154 ++++++++++++++----
.../Docking/DockLayoutPersistenceService.cs | 16 +-
.../Services/Docking/EditorDockFactory.cs | 96 ++---------
.../Services/Docking/EditorDockingAdapter.cs | 12 +-
.../Docking/IWorkspaceDockLayoutBuilder.cs | 16 ++
.../Workspaces/WorkspaceComposition.cs | 154 ++++++++++++++++++
.../Services/Workspaces/WorkspaceProfile.cs | 12 ++
.../Workspaces/WorkspaceProfileCatalog.cs | 23 ++-
.../Workspaces/WorkspaceSelectorModel.cs | 45 +++++
.../Editor/DockingInfrastructureTests.cs | 20 ++-
.../WorkspaceProfileInfrastructureTests.cs | 83 +++++++++-
README.md | 4 +-
...orkspace-selector-composition-switching.md | 101 ++++++++++++
14 files changed, 616 insertions(+), 143 deletions(-)
create mode 100644 Quantum.Editor.Avalonia/Services/Docking/IWorkspaceDockLayoutBuilder.cs
create mode 100644 Quantum.Editor.Avalonia/Services/Workspaces/WorkspaceComposition.cs
create mode 100644 Quantum.Editor.Avalonia/Services/Workspaces/WorkspaceSelectorModel.cs
create mode 100644 docs/editor/m164-workspace-selector-composition-switching.md
diff --git a/Quantum.Editor.Avalonia/MainWindow.axaml b/Quantum.Editor.Avalonia/MainWindow.axaml
index 5df7aaa..9302466 100644
--- a/Quantum.Editor.Avalonia/MainWindow.axaml
+++ b/Quantum.Editor.Avalonia/MainWindow.axaml
@@ -68,13 +68,7 @@
-
+
@@ -88,7 +82,7 @@
BorderBrush="#2B3948"
BorderThickness="0,0,0,1"
Padding="10,7">
-
+
@@ -103,9 +97,20 @@
Content="Frames"
IsChecked="True"
IsCheckedChanged="OnShowFramesChanged" />
+
+
+
+
diff --git a/Quantum.Editor.Avalonia/MainWindow.axaml.cs b/Quantum.Editor.Avalonia/MainWindow.axaml.cs
index e24bd64..8d6d2ec 100644
--- a/Quantum.Editor.Avalonia/MainWindow.axaml.cs
+++ b/Quantum.Editor.Avalonia/MainWindow.axaml.cs
@@ -24,7 +24,11 @@ public partial class MainWindow : Window
private readonly EditorWorkspace workspace;
private readonly WorkspaceProfileManager workspaceProfiles;
- private readonly EditorDockingAdapter docking;
+ private readonly WorkspaceSelectorModel workspaceSelector;
+ private readonly IReadOnlyDictionary paneContexts;
+ private readonly Dictionary dockingByWorkspace = new();
+ private EditorDockingAdapter docking;
+ private bool synchronizingWorkspaceSelector;
private readonly RoutePaneControl RoutePane;
private readonly ViewportPaneControl ViewportPane;
private readonly InspectorPaneControl InspectorPane;
@@ -72,20 +76,24 @@ public MainWindow(
DiagnosticsPane = new DiagnosticsPaneControl();
WirePaneInteractions();
- DockPaneRegistry paneRegistry = DockPaneRegistry.CreateDefaultTrack();
+ paneContexts = new Dictionary(StringComparer.Ordinal)
+ {
+ [WorkspacePaneIds.Route] = RoutePane,
+ [WorkspacePaneIds.Viewport] = ViewportPane,
+ [WorkspacePaneIds.Inspector] = InspectorPane,
+ [WorkspacePaneIds.MathPlots] = MathPlotsPane,
+ [WorkspacePaneIds.Diagnostics] = DiagnosticsPane
+ };
+ workspaceSelector = new WorkspaceSelectorModel(this.workspaceProfiles);
docking = new EditorDockingAdapter(
- paneRegistry,
- new Dictionary(StringComparer.Ordinal)
- {
- [WorkspacePaneIds.Route] = RoutePane,
- [WorkspacePaneIds.Viewport] = ViewportPane,
- [WorkspacePaneIds.Inspector] = InspectorPane,
- [WorkspacePaneIds.MathPlots] = MathPlotsPane,
- [WorkspacePaneIds.Diagnostics] = DiagnosticsPane
- },
+ this.workspaceProfiles.CurrentProfile.Composition,
+ ResolvePaneContexts(this.workspaceProfiles.CurrentProfile.Composition),
layoutPersistence);
+ dockingByWorkspace.Add(this.workspaceProfiles.CurrentProfileId, docking);
DockHost.Factory = docking.Factory;
DockHost.Layout = docking.Layout;
+ InitializeWorkspaceSelector();
+ PopulatePaneMenu();
this.workspace.WorkspaceChanged += OnWorkspaceChanged;
this.workspace.StationCursorChanged += OnStationCursorChanged;
@@ -104,6 +112,8 @@ public MainWindow(
public WorkspaceProfileManager WorkspaceProfiles => workspaceProfiles;
+ public WorkspaceSelectorModel WorkspaceSelector => workspaceSelector;
+
public EditorDockingAdapter Docking => docking;
private static EditorWorkspace CreateWorkspace()
@@ -131,33 +141,97 @@ private void OnWorkspaceChanged(object? sender, EventArgs eventArgs)
private void OnCurrentProfileChanged(object? sender, EventArgs eventArgs)
{
+ ActivateWorkspaceComposition(workspaceProfiles.CurrentProfile);
ApplyWorkspaceProfile(
workspaceProfiles.CurrentProfile,
- applyPaneVisibilityDefaults: true);
+ applyPaneVisibilityDefaults: !docking.RestoredSavedLayout);
+ SynchronizeWorkspaceSelector();
UpdateWorkspaceView();
}
+ private void ActivateWorkspaceComposition(WorkspaceProfile profile)
+ {
+ docking.TrySaveLayout();
+ if (!dockingByWorkspace.TryGetValue(profile.Id, out EditorDockingAdapter? nextDocking))
+ {
+ nextDocking = new EditorDockingAdapter(
+ profile.Composition,
+ ResolvePaneContexts(profile.Composition),
+ new DockLayoutPersistenceService(
+ DockLayoutPersistenceService.GetDefaultLayoutFilePath(profile.Id)));
+ dockingByWorkspace.Add(profile.Id, nextDocking);
+ }
+
+ docking = nextDocking;
+ DockHost.Factory = docking.Factory;
+ DockHost.Layout = docking.Layout;
+ PopulatePaneMenu();
+ }
+
+ private IReadOnlyDictionary ResolvePaneContexts(
+ WorkspaceComposition composition)
+ {
+ return composition.Panes.Panes.ToDictionary(
+ pane => pane.Id,
+ pane => paneContexts.TryGetValue(pane.Id, out object? context)
+ ? context
+ : throw new InvalidOperationException(
+ $"Workspace pane '{pane.Id}' has no registered frontend content."),
+ StringComparer.Ordinal);
+ }
+
+ private void InitializeWorkspaceSelector()
+ {
+ WorkspaceSelectorComboBox.ItemsSource = workspaceSelector.Items
+ .Select(item => new ComboBoxItem
+ {
+ Content = item.DisplayText,
+ IsEnabled = item.IsEnabled,
+ Tag = item.Id
+ })
+ .ToArray();
+ SynchronizeWorkspaceSelector();
+ }
+
+ private void SynchronizeWorkspaceSelector()
+ {
+ synchronizingWorkspaceSelector = true;
+ WorkspaceSelectorComboBox.SelectedItem =
+ WorkspaceSelectorComboBox.Items
+ .OfType()
+ .Single(item => item.Tag is WorkspaceProfileId id &&
+ id == workspaceProfiles.CurrentProfileId);
+ synchronizingWorkspaceSelector = false;
+ }
+
+ private void PopulatePaneMenu()
+ {
+ PanesMenu.ItemsSource = docking.Registry.Panes
+ .Select(pane =>
+ {
+ var menuItem = new MenuItem
+ {
+ Header = pane.Title,
+ Tag = pane.Id
+ };
+ menuItem.Click += OnShowPaneClick;
+ return menuItem;
+ })
+ .ToArray();
+ }
+
private void ApplyWorkspaceProfile(
WorkspaceProfile profile,
bool applyPaneVisibilityDefaults)
{
if (applyPaneVisibilityDefaults)
{
- 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));
+ foreach (DockPaneRegistration pane in docking.Registry.Panes)
+ {
+ docking.SetPaneVisible(
+ pane.Id,
+ profile.IsPaneVisibleByDefault(pane.Id));
+ }
}
bool showFileCommands = profile.HasCommandGroup(WorkspaceCommandGroupIds.File);
@@ -401,6 +475,24 @@ private void OnProjectionChanged(object? sender, SelectionChangedEventArgs event
};
}
+ private void OnWorkspaceSelectorChanged(object? sender, SelectionChangedEventArgs eventArgs)
+ {
+ if (synchronizingWorkspaceSelector)
+ {
+ return;
+ }
+
+ if (WorkspaceSelectorComboBox.SelectedItem is ComboBoxItem
+ {
+ Tag: WorkspaceProfileId profileId
+ } && workspaceSelector.TryActivate(profileId))
+ {
+ return;
+ }
+
+ SynchronizeWorkspaceSelector();
+ }
+
private void OnShowFramesChanged(object? sender, RoutedEventArgs eventArgs)
{
if (ViewportPane != null && ShowFramesCheckBox != null)
@@ -416,7 +508,10 @@ private void OnMenuShowFramesClick(object? sender, RoutedEventArgs eventArgs)
private void OnShowEngineeringPlotsClick(object? sender, RoutedEventArgs eventArgs)
{
- docking.ShowPane(WorkspacePaneIds.MathPlots);
+ if (docking.Registry.Contains(WorkspacePaneIds.MathPlots))
+ {
+ docking.ShowPane(WorkspacePaneIds.MathPlots);
+ }
}
private void OnShowPaneClick(object? sender, RoutedEventArgs eventArgs)
@@ -430,7 +525,8 @@ private void OnShowPaneClick(object? sender, RoutedEventArgs eventArgs)
private void OnResetLayoutClick(object? sender, RoutedEventArgs eventArgs)
{
DockHost.Layout = docking.ResetLayout();
- workspace.SetStatus("Docking layout reset to the default Track workspace.");
+ workspace.SetStatus(
+ $"Docking layout reset to the default {workspaceProfiles.CurrentProfile.DisplayName} workspace.");
}
private void OnEngineeringPlotStationChanged(
diff --git a/Quantum.Editor.Avalonia/Services/Docking/DockLayoutPersistenceService.cs b/Quantum.Editor.Avalonia/Services/Docking/DockLayoutPersistenceService.cs
index 88012a5..ad630ef 100644
--- a/Quantum.Editor.Avalonia/Services/Docking/DockLayoutPersistenceService.cs
+++ b/Quantum.Editor.Avalonia/Services/Docking/DockLayoutPersistenceService.cs
@@ -2,6 +2,7 @@
using Dock.Model.Controls;
using Dock.Model.Core;
using Dock.Serializer;
+using Quantum.Editor.Avalonia.Services.Workspaces;
namespace Quantum.Editor.Avalonia.Services.Docking;
@@ -44,13 +45,26 @@ internal DockLayoutPersistenceService(
public static string GetDefaultLayoutFilePath()
{
+ return GetDefaultLayoutFilePath(WorkspaceProfileId.Track);
+ }
+
+ public static string GetDefaultLayoutFilePath(WorkspaceProfileId workspaceId)
+ {
+ if (workspaceId.IsEmpty)
+ {
+ throw new ArgumentException("A workspace identifier is required.", nameof(workspaceId));
+ }
+
string localApplicationData = Environment.GetFolderPath(
Environment.SpecialFolder.LocalApplicationData);
+ string fileName = workspaceId == WorkspaceProfileId.Track
+ ? LayoutFileName
+ : workspaceId.Value + "-docking-layout.json";
return Path.Combine(
localApplicationData,
"QuantumCoasterWorks",
"Editor",
- LayoutFileName);
+ fileName);
}
public DockLayoutLoadStatus TryLoadLayout(out IRootDock? layout)
diff --git a/Quantum.Editor.Avalonia/Services/Docking/EditorDockFactory.cs b/Quantum.Editor.Avalonia/Services/Docking/EditorDockFactory.cs
index e717dab..38daba9 100644
--- a/Quantum.Editor.Avalonia/Services/Docking/EditorDockFactory.cs
+++ b/Quantum.Editor.Avalonia/Services/Docking/EditorDockFactory.cs
@@ -8,19 +8,21 @@
namespace Quantum.Editor.Avalonia.Services.Docking;
///
-/// Dock.Avalonia model factory for the current Track workspace composition.
+/// Dock.Avalonia model factory for a registered workspace composition.
///
-internal sealed class EditorDockFactory : Factory
+internal sealed class EditorDockFactory : Factory, IWorkspaceDockLayoutBuilder
{
+ private readonly WorkspaceComposition composition;
private readonly DockPaneRegistry registry;
private readonly IReadOnlyDictionary paneContexts;
private readonly Dictionary panes = new(StringComparer.Ordinal);
public EditorDockFactory(
- DockPaneRegistry registry,
+ WorkspaceComposition composition,
IReadOnlyDictionary paneContexts)
{
- this.registry = registry ?? throw new ArgumentNullException(nameof(registry));
+ this.composition = composition ?? throw new ArgumentNullException(nameof(composition));
+ registry = composition.Panes;
this.paneContexts = paneContexts ?? throw new ArgumentNullException(nameof(paneContexts));
HideToolsOnClose = true;
}
@@ -30,85 +32,12 @@ public EditorDockFactory(
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
+ foreach (DockPaneRegistration pane in registry.Panes)
{
- 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)
- };
+ CreatePane(pane.Id);
+ }
- 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;
+ return composition.CreateLayout(this);
}
public override void InitLayout(IDockable layout)
@@ -182,4 +111,9 @@ private IDockable CreatePane(string paneId)
panes.Add(paneId, result);
return result;
}
+
+ IList IWorkspaceDockLayoutBuilder.CreateDockableList(
+ params IDockable[] dockables) => CreateList(dockables);
+
+ IRootDock IWorkspaceDockLayoutBuilder.CreateWorkspaceRootDock() => CreateRootDock();
}
diff --git a/Quantum.Editor.Avalonia/Services/Docking/EditorDockingAdapter.cs b/Quantum.Editor.Avalonia/Services/Docking/EditorDockingAdapter.cs
index c9b93b4..81efbe3 100644
--- a/Quantum.Editor.Avalonia/Services/Docking/EditorDockingAdapter.cs
+++ b/Quantum.Editor.Avalonia/Services/Docking/EditorDockingAdapter.cs
@@ -1,5 +1,6 @@
using Dock.Model.Controls;
using Dock.Model.Core;
+using Quantum.Editor.Avalonia.Services.Workspaces;
namespace Quantum.Editor.Avalonia.Services.Docking;
@@ -12,22 +13,25 @@ public sealed class EditorDockingAdapter
private readonly DockLayoutPersistenceService? persistence;
public EditorDockingAdapter(
- DockPaneRegistry registry,
+ WorkspaceComposition composition,
IReadOnlyDictionary paneContexts,
DockLayoutPersistenceService? persistence = null)
{
- Registry = registry ?? throw new ArgumentNullException(nameof(registry));
+ Composition = composition ?? throw new ArgumentNullException(nameof(composition));
+ Registry = composition.Panes;
ArgumentNullException.ThrowIfNull(paneContexts);
- ValidatePaneContexts(registry, paneContexts);
+ ValidatePaneContexts(Registry, paneContexts);
this.persistence = persistence;
- factory = new EditorDockFactory(registry, paneContexts);
+ factory = new EditorDockFactory(composition, paneContexts);
Layout = CreateInitialLayout();
IsInitialized = true;
}
public DockPaneRegistry Registry { get; }
+ public WorkspaceComposition Composition { get; }
+
public IFactory Factory => factory;
public IRootDock Layout { get; private set; }
diff --git a/Quantum.Editor.Avalonia/Services/Docking/IWorkspaceDockLayoutBuilder.cs b/Quantum.Editor.Avalonia/Services/Docking/IWorkspaceDockLayoutBuilder.cs
new file mode 100644
index 0000000..38ebd8c
--- /dev/null
+++ b/Quantum.Editor.Avalonia/Services/Docking/IWorkspaceDockLayoutBuilder.cs
@@ -0,0 +1,16 @@
+using Dock.Model.Controls;
+using Dock.Model.Core;
+
+namespace Quantum.Editor.Avalonia.Services.Docking;
+
+///
+/// Minimal layout-building surface supplied to a workspace composition.
+///
+public interface IWorkspaceDockLayoutBuilder
+{
+ IDockable GetPane(string paneId);
+
+ IList CreateDockableList(params IDockable[] dockables);
+
+ IRootDock CreateWorkspaceRootDock();
+}
diff --git a/Quantum.Editor.Avalonia/Services/Workspaces/WorkspaceComposition.cs b/Quantum.Editor.Avalonia/Services/Workspaces/WorkspaceComposition.cs
new file mode 100644
index 0000000..99eb298
--- /dev/null
+++ b/Quantum.Editor.Avalonia/Services/Workspaces/WorkspaceComposition.cs
@@ -0,0 +1,154 @@
+using Dock.Avalonia.Controls;
+using Dock.Model.Controls;
+using Dock.Model.Core;
+using Dock.Model.Mvvm.Controls;
+using Quantum.Editor.Avalonia.Services.Docking;
+
+namespace Quantum.Editor.Avalonia.Services.Workspaces;
+
+///
+/// Frontend-only pane registry and default docking layout owned by a workspace profile.
+///
+public sealed class WorkspaceComposition
+{
+ private readonly Func createDefaultLayout;
+
+ public WorkspaceComposition(
+ DockPaneRegistry panes,
+ Func createDefaultLayout)
+ {
+ Panes = panes ?? throw new ArgumentNullException(nameof(panes));
+ this.createDefaultLayout = createDefaultLayout ??
+ throw new ArgumentNullException(nameof(createDefaultLayout));
+ }
+
+ public DockPaneRegistry Panes { get; }
+
+ public static WorkspaceComposition CreateTrack()
+ {
+ return new WorkspaceComposition(
+ DockPaneRegistry.CreateDefaultTrack(),
+ CreateDefaultTrackLayout);
+ }
+
+ public static WorkspaceComposition CreateComingSoon(
+ WorkspaceProfileId workspaceId,
+ string displayName)
+ {
+ if (workspaceId.IsEmpty)
+ {
+ throw new ArgumentException("A workspace identifier is required.", nameof(workspaceId));
+ }
+
+ if (string.IsNullOrWhiteSpace(displayName))
+ {
+ throw new ArgumentException("A workspace display name is required.", nameof(displayName));
+ }
+
+ return new WorkspaceComposition(
+ new DockPaneRegistry(),
+ builder => CreateComingSoonLayout(builder, workspaceId, displayName.Trim()));
+ }
+
+ internal IRootDock CreateLayout(IWorkspaceDockLayoutBuilder builder)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ return createDefaultLayout(builder) ??
+ throw new InvalidOperationException("A workspace composition returned no docking layout.");
+ }
+
+ private static IRootDock CreateDefaultTrackLayout(IWorkspaceDockLayoutBuilder builder)
+ {
+ IDockable route = builder.GetPane(WorkspacePaneIds.Route);
+ IDockable viewport = builder.GetPane(WorkspacePaneIds.Viewport);
+ IDockable inspector = builder.GetPane(WorkspacePaneIds.Inspector);
+ IDockable mathPlots = builder.GetPane(WorkspacePaneIds.MathPlots);
+ IDockable diagnostics = builder.GetPane(WorkspacePaneIds.Diagnostics);
+
+ var routeHost = new ToolDock
+ {
+ Id = DockingLayoutIds.RouteHost,
+ Title = "Route",
+ Alignment = Alignment.Left,
+ Proportion = 0.22,
+ ActiveDockable = route,
+ VisibleDockables = builder.CreateDockableList(route)
+ };
+ var viewportHost = new DocumentDock
+ {
+ Id = DockingLayoutIds.ViewportHost,
+ Title = "Viewport",
+ IsCollapsable = false,
+ CanCreateDocument = false,
+ EnableWindowDrag = false,
+ Proportion = 0.55,
+ ActiveDockable = viewport,
+ VisibleDockables = builder.CreateDockableList(viewport)
+ };
+ var inspectorHost = new ToolDock
+ {
+ Id = DockingLayoutIds.InspectorHost,
+ Title = "Inspector",
+ Alignment = Alignment.Right,
+ Proportion = 0.23,
+ ActiveDockable = inspector,
+ VisibleDockables = builder.CreateDockableList(inspector)
+ };
+ var top = new ProportionalDock
+ {
+ Id = DockingLayoutIds.Top,
+ Title = "Track workbench",
+ Orientation = Orientation.Horizontal,
+ Proportion = 0.64,
+ ActiveDockable = viewportHost,
+ VisibleDockables = builder.CreateDockableList(
+ 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 = builder.CreateDockableList(mathPlots, diagnostics)
+ };
+ var main = new ProportionalDock
+ {
+ Id = DockingLayoutIds.Main,
+ Title = "Track workspace",
+ Orientation = Orientation.Vertical,
+ ActiveDockable = top,
+ VisibleDockables = builder.CreateDockableList(
+ top,
+ new ProportionalDockSplitter(),
+ bottomHost)
+ };
+
+ IRootDock root = builder.CreateWorkspaceRootDock();
+ root.Id = DockingLayoutIds.Root;
+ root.Title = "Track workspace";
+ root.IsCollapsable = false;
+ root.DefaultDockable = main;
+ root.ActiveDockable = main;
+ root.VisibleDockables = builder.CreateDockableList(main);
+ return root;
+ }
+
+ private static IRootDock CreateComingSoonLayout(
+ IWorkspaceDockLayoutBuilder builder,
+ WorkspaceProfileId workspaceId,
+ string displayName)
+ {
+ IRootDock root = builder.CreateWorkspaceRootDock();
+ root.Id = workspaceId.Value + "-root";
+ root.Title = displayName + " workspace (Coming Soon)";
+ root.IsCollapsable = false;
+ root.VisibleDockables = builder.CreateDockableList();
+ return root;
+ }
+}
diff --git a/Quantum.Editor.Avalonia/Services/Workspaces/WorkspaceProfile.cs b/Quantum.Editor.Avalonia/Services/Workspaces/WorkspaceProfile.cs
index 9b2d662..316f9d8 100644
--- a/Quantum.Editor.Avalonia/Services/Workspaces/WorkspaceProfile.cs
+++ b/Quantum.Editor.Avalonia/Services/Workspaces/WorkspaceProfile.cs
@@ -14,6 +14,7 @@ public sealed class WorkspaceProfile
public WorkspaceProfile(
WorkspaceProfileId id,
string displayName,
+ WorkspaceComposition composition,
string? icon = null,
IEnumerable? availablePanes = null,
IEnumerable? defaultVisiblePanes = null,
@@ -34,6 +35,7 @@ public WorkspaceProfile(
Id = id;
DisplayName = displayName.Trim();
+ Composition = composition ?? throw new ArgumentNullException(nameof(composition));
Icon = icon;
IsAvailable = isAvailable;
IsVisible = isVisible;
@@ -59,12 +61,22 @@ public WorkspaceProfile(
DefaultPaneVisibility = new ReadOnlyDictionary(
panes.ToDictionary(pane => pane, defaultVisiblePaneSet.Contains, StringComparer.Ordinal));
OverlayDefaults = CopyOverlayDefaults(overlayDefaults);
+
+ string? unregisteredPane = panes.FirstOrDefault(pane => !Composition.Panes.Contains(pane));
+ if (unregisteredPane != null)
+ {
+ throw new ArgumentException(
+ $"Available pane '{unregisteredPane}' is not registered by the workspace composition.",
+ nameof(availablePanes));
+ }
}
public WorkspaceProfileId Id { get; }
public string DisplayName { get; }
+ public WorkspaceComposition Composition { get; }
+
public string? Icon { get; }
///
diff --git a/Quantum.Editor.Avalonia/Services/Workspaces/WorkspaceProfileCatalog.cs b/Quantum.Editor.Avalonia/Services/Workspaces/WorkspaceProfileCatalog.cs
index 929fa35..c0e67fd 100644
--- a/Quantum.Editor.Avalonia/Services/Workspaces/WorkspaceProfileCatalog.cs
+++ b/Quantum.Editor.Avalonia/Services/Workspaces/WorkspaceProfileCatalog.cs
@@ -37,7 +37,7 @@ public static WorkspaceProfileCatalog CreateDefault()
var result = new WorkspaceProfileCatalog();
result.Register(CreateTrackProfile(), makeDefault: true);
result.Register(CreateFutureProfile(WorkspaceProfileId.Train, "Train", "train"));
- result.Register(CreateFutureProfile(WorkspaceProfileId.Support, "Support", "support"));
+ result.Register(CreateFutureProfile(WorkspaceProfileId.Support, "Supports", "support"));
result.Register(CreateFutureProfile(WorkspaceProfileId.Terrain, "Terrain", "terrain"));
result.Register(CreateFutureProfile(WorkspaceProfileId.Simulation, "Simulation", "simulation"));
return result;
@@ -92,6 +92,23 @@ public WorkspaceProfile Get(WorkspaceProfileId id)
return profile;
}
+ public bool TryGetComposition(
+ WorkspaceProfileId id,
+ out WorkspaceComposition composition)
+ {
+ if (TryGet(id, out WorkspaceProfile profile))
+ {
+ composition = profile.Composition;
+ return true;
+ }
+
+ composition = null!;
+ return false;
+ }
+
+ public WorkspaceComposition GetComposition(WorkspaceProfileId id) =>
+ Get(id).Composition;
+
private static WorkspaceProfile CreateTrackProfile()
{
string[] panes =
@@ -106,6 +123,7 @@ private static WorkspaceProfile CreateTrackProfile()
return new WorkspaceProfile(
WorkspaceProfileId.Track,
"Track",
+ WorkspaceComposition.CreateTrack(),
icon: "track",
availablePanes: panes,
defaultVisiblePanes: panes,
@@ -129,8 +147,9 @@ private static WorkspaceProfile CreateFutureProfile(
return new WorkspaceProfile(
id,
displayName,
+ WorkspaceComposition.CreateComingSoon(id, displayName),
icon,
isAvailable: false,
- isVisible: false);
+ isVisible: true);
}
}
diff --git a/Quantum.Editor.Avalonia/Services/Workspaces/WorkspaceSelectorModel.cs b/Quantum.Editor.Avalonia/Services/Workspaces/WorkspaceSelectorModel.cs
new file mode 100644
index 0000000..219ac4e
--- /dev/null
+++ b/Quantum.Editor.Avalonia/Services/Workspaces/WorkspaceSelectorModel.cs
@@ -0,0 +1,45 @@
+namespace Quantum.Editor.Avalonia.Services.Workspaces;
+
+///
+/// Testable selector projection over the workspace profile registry.
+///
+public sealed class WorkspaceSelectorModel
+{
+ private readonly WorkspaceProfileManager profiles;
+
+ public WorkspaceSelectorModel(WorkspaceProfileManager profiles)
+ {
+ this.profiles = profiles ?? throw new ArgumentNullException(nameof(profiles));
+ Items = profiles.Catalog.VisibleProfiles
+ .Select(profile => new WorkspaceSelectorItem(profile))
+ .ToArray();
+ }
+
+ public IReadOnlyList Items { get; }
+
+ public WorkspaceSelectorItem SelectedItem =>
+ Items.Single(item => item.Id == profiles.CurrentProfileId);
+
+ public bool TryActivate(WorkspaceProfileId id) => profiles.TrySwitchTo(id);
+}
+
+public sealed class WorkspaceSelectorItem
+{
+ internal WorkspaceSelectorItem(WorkspaceProfile profile)
+ {
+ Id = profile.Id;
+ DisplayName = profile.DisplayName;
+ IsEnabled = profile.IsAvailable;
+ DisplayText = IsEnabled
+ ? DisplayName
+ : DisplayName + " (Coming Soon)";
+ }
+
+ public WorkspaceProfileId Id { get; }
+
+ public string DisplayName { get; }
+
+ public string DisplayText { get; }
+
+ public bool IsEnabled { get; }
+}
diff --git a/Quantum.Tests/Editor/DockingInfrastructureTests.cs b/Quantum.Tests/Editor/DockingInfrastructureTests.cs
index 96e06ac..4b557e5 100644
--- a/Quantum.Tests/Editor/DockingInfrastructureTests.cs
+++ b/Quantum.Tests/Editor/DockingInfrastructureTests.cs
@@ -28,12 +28,14 @@ public void Registry_RegistersTrackPanesAndKeepsViewportNonCloseable()
[Fact]
public void Adapter_InitializesDockFactoryWithRegisteredFrontendContexts()
{
- DockPaneRegistry registry = DockPaneRegistry.CreateDefaultTrack();
+ WorkspaceComposition composition = WorkspaceComposition.CreateTrack();
+ DockPaneRegistry registry = composition.Panes;
IReadOnlyDictionary contexts = CreateContexts(registry);
- var adapter = new EditorDockingAdapter(registry, contexts);
+ var adapter = new EditorDockingAdapter(composition, contexts);
Assert.True(adapter.IsInitialized);
+ Assert.Same(composition, adapter.Composition);
Assert.Equal(DockingLayoutIds.Root, adapter.Layout.Id);
Assert.Same(adapter.Factory, adapter.Layout.Factory);
foreach (DockPaneRegistration registration in registry.Panes)
@@ -285,17 +287,21 @@ public void ResetLayout_DiscardsSavedLayoutAndRecreatesDefaultWithoutReplacingCo
private static EditorDockingAdapter CreateAdapter()
{
- DockPaneRegistry registry = DockPaneRegistry.CreateDefaultTrack();
- return new EditorDockingAdapter(registry, CreateContexts(registry));
+ WorkspaceComposition composition = WorkspaceProfileCatalog.CreateDefault()
+ .GetComposition(WorkspaceProfileId.Track);
+ return new EditorDockingAdapter(
+ composition,
+ CreateContexts(composition.Panes));
}
private static EditorDockingAdapter CreateAdapter(
DockLayoutPersistenceService persistence,
out IReadOnlyDictionary contexts)
{
- DockPaneRegistry registry = DockPaneRegistry.CreateDefaultTrack();
- contexts = CreateContexts(registry);
- return new EditorDockingAdapter(registry, contexts, persistence);
+ WorkspaceComposition composition = WorkspaceProfileCatalog.CreateDefault()
+ .GetComposition(WorkspaceProfileId.Track);
+ contexts = CreateContexts(composition.Panes);
+ return new EditorDockingAdapter(composition, contexts, persistence);
}
private static IReadOnlyDictionary CreateContexts(DockPaneRegistry registry) =>
diff --git a/Quantum.Tests/Editor/WorkspaceProfileInfrastructureTests.cs b/Quantum.Tests/Editor/WorkspaceProfileInfrastructureTests.cs
index e98418a..d07a69e 100644
--- a/Quantum.Tests/Editor/WorkspaceProfileInfrastructureTests.cs
+++ b/Quantum.Tests/Editor/WorkspaceProfileInfrastructureTests.cs
@@ -11,8 +11,7 @@ public void Catalog_RegistersAndLooksUpProfileByStableIdentifier()
var profile = new WorkspaceProfile(
new WorkspaceProfileId("custom"),
"Custom",
- availablePanes: new[] { WorkspacePaneIds.Viewport },
- defaultVisiblePanes: new[] { WorkspacePaneIds.Viewport });
+ WorkspaceComposition.CreateComingSoon(new WorkspaceProfileId("custom"), "Custom"));
catalog.Register(profile);
@@ -24,7 +23,7 @@ public void Catalog_RegistersAndLooksUpProfileByStableIdentifier()
}
[Fact]
- public void DefaultCatalog_SelectsCompleteTrackWorkspaceAndHidesFutureDefinitions()
+ public void DefaultCatalog_SelectsCompleteTrackWorkspaceAndExposesFutureDefinitions()
{
WorkspaceProfileCatalog catalog = WorkspaceProfileCatalog.CreateDefault();
@@ -42,11 +41,27 @@ public void DefaultCatalog_SelectsCompleteTrackWorkspaceAndHidesFutureDefinition
Assert.Equal(5, catalog.Profiles.Count);
Assert.Collection(catalog.AvailableProfiles, profile => Assert.Equal(WorkspaceProfileId.Track, profile.Id));
- Assert.Collection(catalog.VisibleProfiles, profile => Assert.Equal(WorkspaceProfileId.Track, profile.Id));
+ Assert.Equal(5, catalog.VisibleProfiles.Count);
Assert.False(catalog.Get(WorkspaceProfileId.Train).IsAvailable);
- Assert.False(catalog.Get(WorkspaceProfileId.Support).IsVisible);
+ Assert.True(catalog.Get(WorkspaceProfileId.Support).IsVisible);
Assert.False(catalog.Get(WorkspaceProfileId.Terrain).IsAvailable);
- Assert.False(catalog.Get(WorkspaceProfileId.Simulation).IsVisible);
+ Assert.True(catalog.Get(WorkspaceProfileId.Simulation).IsVisible);
+ }
+
+ [Fact]
+ public void Catalog_LooksUpCompositionThroughRegisteredProfile()
+ {
+ WorkspaceProfileCatalog catalog = WorkspaceProfileCatalog.CreateDefault();
+ WorkspaceProfile track = catalog.Get(WorkspaceProfileId.Track);
+
+ Assert.Same(track.Composition, catalog.GetComposition(WorkspaceProfileId.Track));
+ Assert.True(catalog.TryGetComposition(
+ WorkspaceProfileId.Track,
+ out WorkspaceComposition composition));
+ Assert.Same(track.Composition, composition);
+ Assert.False(catalog.TryGetComposition(
+ new WorkspaceProfileId("missing"),
+ out _));
}
[Fact]
@@ -65,13 +80,21 @@ public void Manager_UsesCatalogDefaultProfile()
public void Manager_SwitchesOnlyToAvailableRegisteredProfilesAndNotifiesOnce()
{
var catalog = new WorkspaceProfileCatalog();
- var track = new WorkspaceProfile(WorkspaceProfileId.Track, "Track");
- var alternate = new WorkspaceProfile(new WorkspaceProfileId("alternate"), "Alternate");
+ var track = new WorkspaceProfile(
+ WorkspaceProfileId.Track,
+ "Track",
+ WorkspaceComposition.CreateComingSoon(WorkspaceProfileId.Track, "Track"));
+ var alternateId = new WorkspaceProfileId("alternate");
+ var alternate = new WorkspaceProfile(
+ alternateId,
+ "Alternate",
+ WorkspaceComposition.CreateComingSoon(alternateId, "Alternate"));
var unavailable = new WorkspaceProfile(
WorkspaceProfileId.Train,
"Train",
+ WorkspaceComposition.CreateComingSoon(WorkspaceProfileId.Train, "Train"),
isAvailable: false,
- isVisible: false);
+ isVisible: true);
catalog.Register(track, makeDefault: true);
catalog.Register(alternate);
catalog.Register(unavailable);
@@ -96,4 +119,46 @@ public void Manager_SwitchesOnlyToAvailableRegisteredProfilesAndNotifiesOnce()
Assert.Same(track, manager.CurrentProfile);
Assert.Equal(2, notifications);
}
+
+ [Fact]
+ public void Selector_InitializesWithTrackAndFourDisabledComingSoonWorkspaces()
+ {
+ var manager = new WorkspaceProfileManager(WorkspaceProfileCatalog.CreateDefault());
+
+ var selector = new WorkspaceSelectorModel(manager);
+
+ Assert.Equal(WorkspaceProfileId.Track, selector.SelectedItem.Id);
+ Assert.Equal(
+ new[]
+ {
+ WorkspaceProfileId.Track,
+ WorkspaceProfileId.Train,
+ WorkspaceProfileId.Support,
+ WorkspaceProfileId.Terrain,
+ WorkspaceProfileId.Simulation
+ },
+ selector.Items.Select(item => item.Id));
+ Assert.True(selector.Items[0].IsEnabled);
+ Assert.Equal("Track", selector.Items[0].DisplayText);
+ Assert.All(
+ selector.Items.Skip(1),
+ item =>
+ {
+ Assert.False(item.IsEnabled);
+ Assert.EndsWith(" (Coming Soon)", item.DisplayText);
+ });
+ }
+
+ [Fact]
+ public void Selector_ActivatesTrackAndRejectsDisabledPlaceholderWorkspace()
+ {
+ var manager = new WorkspaceProfileManager(WorkspaceProfileCatalog.CreateDefault());
+ var selector = new WorkspaceSelectorModel(manager);
+
+ Assert.True(selector.TryActivate(WorkspaceProfileId.Track));
+ Assert.Equal(WorkspaceProfileId.Track, selector.SelectedItem.Id);
+
+ Assert.False(selector.TryActivate(WorkspaceProfileId.Train));
+ Assert.Equal(WorkspaceProfileId.Track, selector.SelectedItem.Id);
+ }
}
diff --git a/README.md b/README.md
index f089d0b..dd57d8c 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), [M161 workspace profile infrastructure](docs/editor/m161-workspace-profile-infrastructure.md), [M162 docking infrastructure](docs/editor/m162-docking-infrastructure.md), and [M163 docking layout persistence](docs/editor/m163-docking-layout-persistence.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), [M162 docking infrastructure](docs/editor/m162-docking-infrastructure.md), [M163 docking layout persistence](docs/editor/m163-docking-layout-persistence.md), and [M164 workspace selection and composition switching](docs/editor/m164-workspace-selector-composition-switching.md).
- `Quantum.Tests` contains automated tests and contract fixtures.
- `Assets` contains the current Unity debug visualizer/prototype assets.
@@ -123,6 +123,8 @@ M162 replaces the fixed workbench grid with frontend-only Dock.Avalonia composit
M163 saves that frontend docking graph when the editor closes and restores it on the next startup, including pane positions, proportions, floating windows, tab groups, active tabs, and hidden panes. Missing, invalid, or incompatible files fall back to the M162 default Track layout. `View > Reset Layout` discards the saved arrangement and recreates the default without replacing the current document. See [`docs/editor/m163-docking-layout-persistence.md`](docs/editor/m163-docking-layout-persistence.md).
+M164 adds a toolbar workspace selector and moves default docking composition ownership into registered workspace profiles. Track remains the only enabled workspace and keeps its M163 layout and persistence behavior; Train, Supports, Terrain, and Simulation are visible as disabled `Coming Soon` entries. See [`docs/editor/m164-workspace-selector-composition-switching.md`](docs/editor/m164-workspace-selector-composition-switching.md).
+
From a fresh checkout:
```powershell
diff --git a/docs/editor/m164-workspace-selector-composition-switching.md b/docs/editor/m164-workspace-selector-composition-switching.md
new file mode 100644
index 0000000..55b39be
--- /dev/null
+++ b/docs/editor/m164-workspace-selector-composition-switching.md
@@ -0,0 +1,101 @@
+# Milestone 164 Workspace Selector and Composition Switching
+
+M164 adds a workspace selector to the Avalonia editor shell and makes docking
+composition selection registry-driven. Track remains the only functional
+workspace. The milestone supplies the switching framework for later editor
+vertical slices without implementing any Train, Supports, Terrain, or
+Simulation editing behavior.
+
+## Workspace selector
+
+The main toolbar now contains a Workspace dropdown populated from
+`WorkspaceProfileCatalog.VisibleProfiles`. It initially lists, in registration
+order:
+
+- Track;
+- Train (Coming Soon);
+- Supports (Coming Soon);
+- Terrain (Coming Soon); and
+- Simulation (Coming Soon).
+
+Track is selected and enabled. The other entries remain visible but disabled,
+and `WorkspaceProfileManager` continues to reject programmatic attempts to
+activate an unavailable profile. `WorkspaceSelectorModel` is a small,
+Avalonia-frontend projection that keeps selector initialization and activation
+rules independently testable.
+
+## Profile-owned docking composition
+
+Each `WorkspaceProfile` now owns a `WorkspaceComposition`. A composition
+contains its pane registry and a function that creates its default Dock.Avalonia
+root graph through a minimal layout-builder boundary. `EditorDockFactory` no
+longer constructs a Track-specific graph: it creates the panes registered by
+the active composition and asks that composition for its layout.
+
+The Track composition owns the unchanged M162/M163 default arrangement:
+
+- Route on the left;
+- the non-closeable Viewport in the center;
+- Inspector on the right; and
+- Math Plots active beside Diagnostics in the bottom tool group.
+
+The four unavailable profiles own empty frontend placeholder compositions.
+Those layouts are not activated by the current selector, but they allow a later
+milestone to replace a profile's placeholder with its real pane registry and
+default graph without adding workspace-specific branches to the shell or dock
+factory. The `View > Panes` menu is also populated from the active pane registry.
+
+## Switching and persistence
+
+When an available registered profile changes, `MainWindow` obtains the next
+layout from that profile's composition, updates the `DockControl`, and applies
+the profile's pane, command-group, and overlay defaults. Docking adapters are
+kept per workspace during the session, so switching does not discard an
+in-memory layout. The active layout is saved before switching and on window
+close.
+
+Layout persistence is namespaced by workspace identifier. Track deliberately
+retains the M163 filename:
+
+```text
+/QuantumCoasterWorks/Editor/track-docking-layout.json
+```
+
+This preserves existing Track layouts without migration. A future workspace
+uses `-docking-layout.json`, preventing one workspace's docking
+graph from being restored into another composition. Missing or invalid Track
+state still falls back to the unchanged default Track graph, and Reset Layout
+still leaves the active editor document open.
+
+## Registration path
+
+A later workspace milestone can register a new vertical slice by supplying a
+profile with:
+
+- its stable `WorkspaceProfileId`, display metadata, and availability;
+- a `WorkspaceComposition` containing its pane registrations and default layout;
+- its pane content controls at the Avalonia composition root; and
+- its command groups and overlay defaults.
+
+The selector, activation path, pane menu, docking factory, layout cache, and
+per-workspace persistence require no workspace-specific switch statement.
+
+## Preserved frontend and backend boundaries
+
+M164 changes only `Quantum.Editor.Avalonia` and its tests/documentation. The
+existing `EditorWorkspace` instance and active Track document remain alive
+across composition changes. Track compilation, engineering snapshots,
+selection, authoring, undo/redo, document persistence, and viewport behavior
+are unchanged.
+
+No changes were made to `Quantum.Core`, `Quantum.Track`, `Quantum.Math`, the
+authoring graph, `EngineeringSnapshot`, or the document model. No Train,
+Supports, Terrain, or Simulation editor, tools, or document types were added.
+
+## Tests
+
+`WorkspaceProfileInfrastructureTests` covers selector initialization,
+registration, Track activation, disabled placeholder profiles, and composition
+lookup. `DockingInfrastructureTests` continues to cover the default Track
+composition and M163 layout persistence, fallback, reset, and pane lifecycle
+behavior.