From d0e32d104c03dc921ca599aa17d6644a35a3cbc8 Mon Sep 17 00:00:00 2001 From: James Vaughan Date: Wed, 11 Mar 2026 01:25:35 -0400 Subject: [PATCH 1/7] Fixed regression for getting a ExecuteWithContext. --- .../ViewModels/ModelSystemEditorViewModel.cs | 6 +++--- src/XTMF2/Configuration/SystemConfiguration.cs | 5 +++-- src/XTMF2/RuntimeModules/ScriptedParameter.cs | 14 ++++++++++++++ 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs index 9c26f55..b8d90c0 100644 --- a/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs +++ b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs @@ -742,7 +742,7 @@ private static (Type Context, Type Return)? GetIFunction2Types(Type? nodeType) } /// - /// Creates a new node whose generic parameter + /// Creates a new node whose generic parameter /// matches the return type T that 's module implements /// via IFunction<T>. The new node is positioned to the right of /// and its Context hook is linked back to the source. @@ -754,7 +754,7 @@ public async Task CreateExecuteWithContextAsync(NodeViewModel sourceNode) var returnType = GetIFunctionReturnType(sourceNode.UnderlyingNode.Type); if (returnType is null) return; - var executeWithContextType = typeof(Execute<>).MakeGenericType(returnType); + var executeWithContextType = typeof(ExecuteWithContext<>).MakeGenericType(returnType); var nameDialog = new InputDialog( title: $"Add Execute With Context<{returnType.Name}> Module", @@ -778,7 +778,7 @@ public async Task CreateExecuteWithContextAsync(NodeViewModel sourceNode) } // Find the "Context" hook on the new ExecuteWithContext node and link it to sourceNode. - var contextHook = newNode!.Hooks.FirstOrDefault(h => h.Name == "Context"); + var contextHook = newNode!.Hooks.FirstOrDefault(h => h.Name == "Get Context"); if (contextHook is not null) { if (!Session.AddLink(User, newNode, contextHook, sourceNode.UnderlyingNode, out _, out var linkError)) diff --git a/src/XTMF2/Configuration/SystemConfiguration.cs b/src/XTMF2/Configuration/SystemConfiguration.cs index 586e271..9671d0c 100644 --- a/src/XTMF2/Configuration/SystemConfiguration.cs +++ b/src/XTMF2/Configuration/SystemConfiguration.cs @@ -114,11 +114,12 @@ public void LoadAssemblies(string path, string? exclusionFile = null) public void LoadAssemblies(string path, List toExclude) { var dirInfo = new DirectoryInfo(path); - foreach (var dllFile in dirInfo.EnumerateFiles("*.dll")) + var toLoad = dirInfo.EnumerateFiles("*.dll").Where(file => !toExclude.Contains(file.Name)).ToList(); + foreach (var dllFile in toLoad) { if (!toExclude.Contains(dllFile.Name)) { - LoadAssembly(AssemblyLoadContext.Default.LoadFromAssemblyPath(dllFile.FullName)); + LoadAssembly(Assembly.LoadFrom(dllFile.FullName)); } } } diff --git a/src/XTMF2/RuntimeModules/ScriptedParameter.cs b/src/XTMF2/RuntimeModules/ScriptedParameter.cs index ad3297d..fb7ebdf 100644 --- a/src/XTMF2/RuntimeModules/ScriptedParameter.cs +++ b/src/XTMF2/RuntimeModules/ScriptedParameter.cs @@ -65,5 +65,19 @@ private void ThrowGotNull() { throw new XTMFRuntimeException(this, $"Unable to get a {typeof(T).FullName} value from expression '{Expression.Representation}'!"); } + + public override bool RuntimeValidation(ref string? error) + { + if(!base.RuntimeValidation(ref error)) + { + return false; + } + if (Expression is null) + { + error = "Expression is not set!"; + return false; + } + return true; + } } } From 8934076a4646880381d83e978e9815d3492edddd Mon Sep 17 00:00:00 2001 From: James Vaughan Date: Wed, 11 Mar 2026 02:13:14 -0400 Subject: [PATCH 2/7] Adding moving between boundaries and ghost nodes --- src/XTMF2.GUI/Controls/ModelSystemCanvas.cs | 144 +++++- .../ViewModels/GhostNodeViewModel.cs | 117 +++++ .../ViewModels/ModelSystemEditorViewModel.cs | 70 ++- src/XTMF2/Editing/ModelSystemSession.cs | 436 +++++++++++++++--- src/XTMF2/ModelSystemConstruct/Boundary.cs | 98 +++- .../ModelSystemConstruct/FunctionTemplate.cs | 10 +- src/XTMF2/ModelSystemConstruct/GhostNode.cs | 204 ++++++++ src/XTMF2/ModelSystemConstruct/ModelSystem.cs | 22 +- src/XTMF2/ModelSystemConstruct/MultiLink.cs | 12 +- src/XTMF2/ModelSystemConstruct/Node.cs | 18 +- src/XTMF2/ModelSystemConstruct/SingleLink.cs | 7 +- src/XTMF2/ModelSystemConstruct/Start.cs | 9 +- 12 files changed, 1077 insertions(+), 70 deletions(-) create mode 100644 src/XTMF2.GUI/ViewModels/GhostNodeViewModel.cs create mode 100644 src/XTMF2/ModelSystemConstruct/GhostNode.cs diff --git a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs index f2090c7..96672b7 100644 --- a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs +++ b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs @@ -58,6 +58,11 @@ public sealed class ModelSystemCanvas : Control private static readonly IBrush LinkSelBrush = Brushes.OrangeRed; private static readonly IBrush PendingLinkBrush = new SolidColorBrush(Color.FromRgb(0x2E, 0xCC, 0x71)); private static readonly DashStyle PendingLinkDash = new DashStyle([6, 4], 0); + // Ghost node styling + private static readonly IBrush GhostNodeFill = new SolidColorBrush(Color.FromArgb(0x50, 0x2C, 0x3E, 0x50)); + private static readonly IBrush GhostNodeBorderBrush = new SolidColorBrush(Color.FromRgb(0x77, 0x88, 0x99)); + private static readonly IBrush GhostNodeSelBrush = Brushes.DodgerBlue; + private static readonly DashStyle GhostNodeDash = new DashStyle([6, 4], 0); // Parameter value row private static readonly IBrush ParamValueTextBrush = new SolidColorBrush(Color.FromRgb(0xFF, 0xE0, 0x82)); @@ -330,12 +335,14 @@ private void Attach() _vm.Starts.CollectionChanged += OnCollectionChanged; _vm.Links.CollectionChanged += OnCollectionChanged; _vm.CommentBlocks.CollectionChanged += OnCollectionChanged; + _vm.GhostNodes.CollectionChanged += OnCollectionChanged; _vm.PropertyChanged += OnViewModelPropertyChanged; foreach (var n in _vm.Nodes) ((INotifyPropertyChanged)n).PropertyChanged += OnElementPropertyChanged; foreach (var s in _vm.Starts) ((INotifyPropertyChanged)s).PropertyChanged += OnElementPropertyChanged; foreach (var l in _vm.Links) ((INotifyPropertyChanged)l).PropertyChanged += OnElementPropertyChanged; foreach (var c in _vm.CommentBlocks) ((INotifyPropertyChanged)c).PropertyChanged += OnElementPropertyChanged; + foreach (var g in _vm.GhostNodes) ((INotifyPropertyChanged)g).PropertyChanged += OnElementPropertyChanged; } private void Detach() @@ -345,12 +352,14 @@ private void Detach() _vm.Starts.CollectionChanged -= OnCollectionChanged; _vm.Links.CollectionChanged -= OnCollectionChanged; _vm.CommentBlocks.CollectionChanged -= OnCollectionChanged; + _vm.GhostNodes.CollectionChanged -= OnCollectionChanged; _vm.PropertyChanged -= OnViewModelPropertyChanged; foreach (var n in _vm.Nodes) ((INotifyPropertyChanged)n).PropertyChanged -= OnElementPropertyChanged; foreach (var s in _vm.Starts) ((INotifyPropertyChanged)s).PropertyChanged -= OnElementPropertyChanged; foreach (var l in _vm.Links) ((INotifyPropertyChanged)l).PropertyChanged -= OnElementPropertyChanged; foreach (var c in _vm.CommentBlocks) ((INotifyPropertyChanged)c).PropertyChanged -= OnElementPropertyChanged; + foreach (var g in _vm.GhostNodes) ((INotifyPropertyChanged)g).PropertyChanged -= OnElementPropertyChanged; } private void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) @@ -460,6 +469,7 @@ public override void Render(DrawingContext ctx) RenderCommentBlocks(ctx); RenderLinks(ctx); RenderNodes(ctx); + RenderGhostNodes(ctx); RenderStarts(ctx); RenderPendingLink(ctx); RenderSelectionRect(ctx); @@ -596,6 +606,13 @@ private void RenderLinks(DrawingContext ctx) if (midXInHSpan) borderY = p1.Y <= destCenter.Y ? dRect.Y : dRect.Bottom; } + else if (link.Destination is GhostNodeViewModel ghostDestH) + { + var dRect = new Rect(ghostDestH.X, ghostDestH.Y, ghostDestH.Width, ghostDestH.Height); + midXInHSpan = midX >= dRect.X && midX <= dRect.Right; + if (midXInHSpan) + borderY = p1.Y <= destCenter.Y ? dRect.Y : dRect.Bottom; + } if (midXInHSpan) { @@ -635,6 +652,13 @@ private void RenderLinks(DrawingContext ctx) if (midYInVSpan) borderX = p1.X <= destCenter.X ? dRect.X : dRect.Right; } + else if (link.Destination is GhostNodeViewModel ghostDestV) + { + var dRect = new Rect(ghostDestV.X, ghostDestV.Y, ghostDestV.Width, ghostDestV.Height); + midYInVSpan = midY >= dRect.Y && midY <= dRect.Bottom; + if (midYInVSpan) + borderX = p1.X <= destCenter.X ? dRect.X : dRect.Right; + } if (midYInVSpan) { @@ -692,6 +716,12 @@ private void RenderLinks(DrawingContext ctx) return ClipLineToRect(other, rect); } + if (element is GhostNodeViewModel gnvm) + { + var rect = new Rect(gnvm.X, gnvm.Y, gnvm.Width, gnvm.Height); + return ClipLineToRect(other, rect); + } + if (element is StartViewModel) { var center = new Point(element.CenterX, element.CenterY); @@ -1165,6 +1195,46 @@ private void RenderNodes(DrawingContext ctx) /// Updates based on which node (if any) the /// pointer currently sits over, and invalidates the visual when the value changes. /// + private void RenderGhostNodes(DrawingContext ctx) + { + foreach (var ghost in _vm!.GhostNodes) + { + double rw = ghost.Width; + double rh = ghost.Height; + var rect = new Rect(ghost.X, ghost.Y, rw, rh); + + // Fill is semi-transparent; border is dashed. + var borderBrush = ghost.IsSelected ? GhostNodeSelBrush : GhostNodeBorderBrush; + var border = new Pen(borderBrush, NodeBorderThickness, dashStyle: GhostNodeDash); + + ctx.DrawRectangle(GhostNodeFill, border, rect, NodeCornerRadius, NodeCornerRadius); + + // Ghost icon prefix ("⊙ ") to distinguish from real nodes at a glance. + var labelText = "\u2299 " + ghost.Name; + var ft = MakeText(labelText, NodeFontSize, NodeTextBrush); + var tx = ghost.X + (rw - ft.Width) / 2; + var ty = ghost.Y + (NodeHeaderHeight - ft.Height) / 2; + + using (ctx.PushClip(new Rect(ghost.X + 4, ghost.Y, rw - 8, NodeHeaderHeight))) + ctx.DrawText(ft, new Point(tx, ty)); + + // Resize grip dots (bottom-right corner). + { + double dotR = 2.0; + double bx = ghost.X + rw; + double by = ghost.Y + rh; + for (int d = 0; d < 3; d++) + { + double offset = 4.0 + d * 4.0; + ctx.DrawEllipse(ResizeHandleBrush, null, + new Point(bx - offset + dotR, by - dotR), dotR, dotR); + ctx.DrawEllipse(ResizeHandleBrush, null, + new Point(bx - dotR, by - offset + dotR), dotR, dotR); + } + } + } + } + private void RenderStarts(DrawingContext ctx) { foreach (var start in _vm!.Starts) @@ -1455,7 +1525,7 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) // Always commit any open inline edit first. if (_editingParamNode is not null) CommitParamEdit(); - if (hit is NodeViewModel or CommentBlockViewModel) + if (hit is NodeViewModel or CommentBlockViewModel or GhostNodeViewModel) { // On the very first Ctrl+click, absorb the existing primary selection into the set. if (_multiSelection.Count == 0 && _vm.SelectedElement is not null @@ -1623,6 +1693,7 @@ protected override void OnPointerMoved(PointerEventArgs e) if (el is NodeViewModel gnvm) gnvm.MoveTo(nx, ny); else if (el is StartViewModel gsvm) gsvm.MoveTo(nx, ny); else if (el is CommentBlockViewModel gcvm) gcvm.MoveTo(nx, ny); + else if (el is GhostNodeViewModel ggvm) ggvm.MoveTo(nx, ny); } } else @@ -1633,6 +1704,7 @@ protected override void OnPointerMoved(PointerEventArgs e) if (_dragging is NodeViewModel nvm) nvm.MoveTo(newX, newY); if (_dragging is StartViewModel svm) svm.MoveTo(newX, newY); if (_dragging is CommentBlockViewModel cvm) cvm.MoveTo(newX, newY); + if (_dragging is GhostNodeViewModel gvm) gvm.MoveTo(newX, newY); } InvalidateAndMeasure(); @@ -1736,6 +1808,16 @@ protected override void OnPointerReleased(PointerReleasedEventArgs e) firstHit ??= comment; } } + foreach (var ghost in _vm.GhostNodes) + { + var gr = new Rect(ghost.X, ghost.Y, ghost.Width, ghost.Height); + if (finalRect.Intersects(gr)) + { + _multiSelection.Add(ghost); + ghost.IsSelected = true; + firstHit ??= ghost; + } + } if (firstHit is not null) _vm.SelectedElement = firstHit; } @@ -1898,6 +1980,49 @@ private void ShowContextMenu(ICanvasElement? element, LinkViewModel? link) } } + // ── Create Ghost Node + Move to Boundary (regular nodes) ─────────────────── + if (element is NodeViewModel ghostSourceNode) + { + var capturedGhostSource = ghostSourceNode; + + var ghostItem = new MenuItem { Header = "Create Ghost Node" }; + ghostItem.Click += (_, _) => + { + double rw = NodeRenderWidth(capturedGhostSource); + double rh = NodeRenderHeight(capturedGhostSource); + const double gap = 30.0; + int gx = (int)(capturedGhostSource.X + rw + gap); + int gy = (int)capturedGhostSource.Y; + int gw = (int)rw; + int gh = (int)rh; + vm.CreateGhostNode(capturedGhostSource, gx, gy, gw, gh); + }; + + var moveNodeItem = new MenuItem { Header = "Move to Boundary…" }; + moveNodeItem.Click += async (_, _) => + { + await vm.MoveNodeToBoundaryAsync(capturedGhostSource); + InvalidateAndMeasure(); + }; + + menu.Items.Add(new Separator()); + menu.Items.Add(ghostItem); + menu.Items.Add(moveNodeItem); + } + + // ── Move to Boundary (ghost nodes) ──────────────────────────────────── + if (element is GhostNodeViewModel capturedGhost) + { + var moveGhostItem = new MenuItem { Header = "Move to Boundary…" }; + moveGhostItem.Click += async (_, _) => + { + await vm.MoveGhostNodeToBoundaryAsync(capturedGhost); + InvalidateAndMeasure(); + }; + menu.Items.Add(new Separator()); + menu.Items.Add(moveGhostItem); + } + menu.Items.Add(deleteItem); ContextMenu = menu; @@ -2029,6 +2154,16 @@ private void OnInlineEditorLostFocus(object? sender, Avalonia.Interactivity.Rout if (handle.Contains(pos)) return comment; } + foreach (var ghost in _vm.GhostNodes) + { + var handle = new Rect( + ghost.X + ghost.Width - ResizeHandleSize, + ghost.Y + ghost.Height - ResizeHandleSize, + ResizeHandleSize, + ResizeHandleSize); + if (handle.Contains(pos)) + return ghost; + } return null; } @@ -2149,6 +2284,13 @@ private static Rect InlineMinimizeButtonRect(NodeViewModel node) return node; } + // Ghost nodes + foreach (var ghost in _vm.GhostNodes) + { + if (new Rect(ghost.X, ghost.Y, ghost.Width, ghost.Height).Contains(pos)) + return ghost; + } + // Comment blocks (background layer) if (testComments) { diff --git a/src/XTMF2.GUI/ViewModels/GhostNodeViewModel.cs b/src/XTMF2.GUI/ViewModels/GhostNodeViewModel.cs new file mode 100644 index 0000000..cb8648f --- /dev/null +++ b/src/XTMF2.GUI/ViewModels/GhostNodeViewModel.cs @@ -0,0 +1,117 @@ +/* + Copyright 2026 University of Toronto + + This file is part of XTMF2. + + XTMF2 is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + XTMF2 is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with XTMF2. If not, see . +*/ +using System; +using System.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel; +using XTMF2; +using XTMF2.Editing; +using XTMF2.ModelSystemConstruct; + +namespace XTMF2.GUI.ViewModels; + +/// +/// Wraps a for display on the model system canvas. +/// Ghost nodes are rendered as rectangular boxes with a dashed border and no hooks. +/// They always mirror the name of their referenced node. +/// +public sealed partial class GhostNodeViewModel : ObservableObject, ICanvasElement +{ + /// The underlying ghost node model object. + public GhostNode UnderlyingGhostNode { get; } + + private readonly ModelSystemSession _session; + private readonly User _user; + + // ── Coordinates read directly from the underlying model ────────────── + /// + public double X => (double)UnderlyingGhostNode.Location.X; + + /// + public double Y => (double)UnderlyingGhostNode.Location.Y; + + /// Rendered width; falls back to 120 when the model value is 0. + public double Width => UnderlyingGhostNode.Location.Width is 0 ? 120.0 : (double)UnderlyingGhostNode.Location.Width; + + /// Rendered height; falls back to 50 when the model value is 0. + public double Height => UnderlyingGhostNode.Location.Height is 0 ? 50.0 : (double)UnderlyingGhostNode.Location.Height; + + /// + public double CenterX => X + Width / 2.0; + + /// + public double CenterY => Y + Height / 2.0; + + [ObservableProperty] private string _name = string.Empty; + [ObservableProperty] private bool _isSelected; + + public GhostNodeViewModel(GhostNode ghostNode, ModelSystemSession session, User user) + { + UnderlyingGhostNode = ghostNode; + _session = session; + _user = user; + _name = ghostNode.Name ?? string.Empty; + + ((INotifyPropertyChanged)ghostNode).PropertyChanged += OnModelPropertyChanged; + } + + private void OnModelPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(GhostNode.Name): + Name = UnderlyingGhostNode.Name ?? string.Empty; + break; + case nameof(GhostNode.Location): + OnPropertyChanged(nameof(X)); + OnPropertyChanged(nameof(Y)); + OnPropertyChanged(nameof(Width)); + OnPropertyChanged(nameof(Height)); + OnPropertyChanged(nameof(CenterX)); + OnPropertyChanged(nameof(CenterY)); + break; + } + } + + /// + /// Move the ghost node to a new canvas position, persisting via the session + /// (supports undo/redo). + /// + public void MoveTo(double x, double y) + { + var loc = UnderlyingGhostNode.Location; + var w = loc.Width is 0 ? 120f : loc.Width; + var h = loc.Height is 0 ? 50f : loc.Height; + _session.SetNodeLocation(_user, UnderlyingGhostNode, + new Rectangle((float)x, (float)y, w, h), out _); + } + + /// + /// Resize the ghost node, persisting via the session. + /// Width is clamped to a minimum of 120; height to a minimum of 28. + /// + public void ResizeTo(double w, double h) + { + const float minW = 120f; + const float minH = 28f; + var loc = UnderlyingGhostNode.Location; + _session.SetNodeLocation(_user, UnderlyingGhostNode, + new Rectangle(loc.X, loc.Y, Math.Max(minW, (float)w), Math.Max(minH, (float)h)), + out _); + } +} diff --git a/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs index b8d90c0..cd81ba1 100644 --- a/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs +++ b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs @@ -123,6 +123,9 @@ public string CurrentBoundaryLabel /// Observable wrappers around . public ObservableCollection CommentBlocks { get; } = new(); + /// Observable wrappers around . + public ObservableCollection GhostNodes { get; } = new(); + /// Observable view-models for the model system's variable list. public ObservableCollection ModelSystemVariables { get; } = new(); @@ -337,6 +340,7 @@ private void BuildFromBoundary(Boundary boundary) foreach (var start in boundary.Starts) Starts.Add(new StartViewModel(start, Session, User)); foreach (var link in boundary.Links) TryAddLinkViewModel(link); foreach (var cb in boundary.CommentBlocks) CommentBlocks.Add(new CommentBlockViewModel(cb, Session, User)); + foreach (var ghost in boundary.GhostNodes) GhostNodes.Add(new GhostNodeViewModel(ghost, Session, User)); } // Cached reference to the current boundary's Boundaries collection so we can @@ -349,6 +353,7 @@ private void SubscribeToBoundary(Boundary boundary) ((INotifyCollectionChanged)boundary.Starts).CollectionChanged += OnStartsChanged; ((INotifyCollectionChanged)boundary.Links).CollectionChanged += OnLinksChanged; ((INotifyCollectionChanged)boundary.CommentBlocks).CollectionChanged += OnCommentBlocksChanged; + ((INotifyCollectionChanged)boundary.GhostNodes).CollectionChanged += OnGhostNodesChanged; // Keep the same wrapper instance so we can correctly remove the handler later. _subscribedChildBoundaries = boundary.Boundaries; @@ -361,6 +366,7 @@ private void UnsubscribeFromBoundary(Boundary boundary) ((INotifyCollectionChanged)boundary.Starts).CollectionChanged -= OnStartsChanged; ((INotifyCollectionChanged)boundary.Links).CollectionChanged -= OnLinksChanged; ((INotifyCollectionChanged)boundary.CommentBlocks).CollectionChanged -= OnCommentBlocksChanged; + ((INotifyCollectionChanged)boundary.GhostNodes).CollectionChanged -= OnGhostNodesChanged; if (_subscribedChildBoundaries is not null) { @@ -390,6 +396,7 @@ public void SwitchToBoundary(Boundary boundary) Starts.Clear(); Links.Clear(); CommentBlocks.Clear(); + GhostNodes.Clear(); _currentBoundary = boundary; OnPropertyChanged(nameof(CurrentBoundary)); @@ -492,6 +499,20 @@ private void OnCommentBlocksChanged(object? sender, NotifyCollectionChangedEvent } } + private void OnGhostNodesChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (e.NewItems is not null) + foreach (GhostNode g in e.NewItems) + GhostNodes.Add(new GhostNodeViewModel(g, Session, User)); + + if (e.OldItems is not null) + foreach (GhostNode g in e.OldItems) + { + var vm = GhostNodes.FirstOrDefault(v => v.UnderlyingGhostNode == g); + if (vm is not null) GhostNodes.Remove(vm); + } + } + private void TryAddLinkViewModel(Link link) { var originElement = ResolveElement(link.Origin); @@ -544,9 +565,14 @@ private void OnMultiLinkDestinationsChanged( private ICanvasElement? ResolveElement(Node? node) { if (node is null) return null; + if (node is GhostNode ghost) + return GhostNodes.FirstOrDefault(g => g.UnderlyingGhostNode == ghost); if (node is Start start) return Starts.FirstOrDefault(s => s.UnderlyingStart == start); - return Nodes.FirstOrDefault(n => n.UnderlyingNode == node); + var directVm = Nodes.FirstOrDefault(n => n.UnderlyingNode == node); + if (directVm is not null) return directVm; + // The real node lives in another boundary — use a ghost referencing it if one is visible here. + return GhostNodes.FirstOrDefault(g => g.UnderlyingGhostNode.ReferencedNode == node); } // ── Commands ────────────────────────────────────────────────────────── @@ -786,6 +812,48 @@ public async Task CreateExecuteWithContextAsync(NodeViewModel sourceNode) } } + /// + /// Creates a ghost node referencing on the current boundary, + /// placed at the specified canvas coordinates. + /// Called directly by the canvas (not a RelayCommand because it requires typed parameters). + /// + internal void CreateGhostNode(NodeViewModel nvm, int x, int y, int w, int h) + { + var location = new Rectangle(x, y, w, h); + if (!Session.AddGhostNode(User, _currentBoundary, nvm.UnderlyingNode, location, + out _, out var error)) + ShowToast(error?.Message ?? "Failed to create ghost node.", isError: true, durationMs: 4000); + } + + /// + /// Shows a boundary picker and moves the given regular node (and its outgoing links) to the + /// chosen boundary. + /// + internal async Task MoveNodeToBoundaryAsync(NodeViewModel nvm) + { + if (ParentWindow is null) return; + var dialog = new Views.BoundaryPickerDialog(GetAllBoundaries(GlobalBoundary), _currentBoundary); + await dialog.ShowDialog(ParentWindow); + if (dialog.Result != Views.BoundaryPickerResult.Navigate || dialog.SelectedBoundary is null) return; + if (ReferenceEquals(dialog.SelectedBoundary, nvm.UnderlyingNode.ContainedWithin)) return; + if (!Session.MoveNodeToBoundary(User, nvm.UnderlyingNode, dialog.SelectedBoundary, out var error)) + ShowToast(error?.Message ?? "Failed to move node.", isError: true, durationMs: 4000); + } + + /// + /// Shows a boundary picker and moves the given ghost node reference to the chosen boundary. + /// + internal async Task MoveGhostNodeToBoundaryAsync(GhostNodeViewModel gvm) + { + if (ParentWindow is null) return; + var dialog = new Views.BoundaryPickerDialog(GetAllBoundaries(GlobalBoundary), _currentBoundary); + await dialog.ShowDialog(ParentWindow); + if (dialog.Result != Views.BoundaryPickerResult.Navigate || dialog.SelectedBoundary is null) return; + if (ReferenceEquals(dialog.SelectedBoundary, gvm.UnderlyingGhostNode.ContainedWithin)) return; + if (!Session.MoveGhostNodeToBoundary(User, gvm.UnderlyingGhostNode, dialog.SelectedBoundary, out var error)) + ShowToast(error?.Message ?? "Failed to move ghost node.", isError: true, durationMs: 4000); + } + /// /// Attempt to create a link from to . /// If any compatible hooks are found, either uses the sole hook automatically or diff --git a/src/XTMF2/Editing/ModelSystemSession.cs b/src/XTMF2/Editing/ModelSystemSession.cs index 0fa5449..628e72c 100644 --- a/src/XTMF2/Editing/ModelSystemSession.cs +++ b/src/XTMF2/Editing/ModelSystemSession.cs @@ -796,6 +796,252 @@ void Remove() /// The node to be removed. /// An error message if the operation fails. /// True if the operation succeeds, false otherwise with an error message. + /// + /// Add a ghost node — a visual alias for — + /// to at . + /// Ghost nodes mirror their referenced node's name, have no hooks, and are + /// rendered with a dashed outline. When the real node is deleted all ghost + /// nodes referencing it are automatically deleted as well. + /// + public bool AddGhostNode(User user, Boundary boundary, Node referencedNode, Rectangle location, + [NotNullWhen(true)] out GhostNode? ghostNode, + [NotNullWhen(false)] out CommandError? error) + { + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(boundary); + ArgumentNullException.ThrowIfNull(referencedNode); + + lock (_sessionLock) + { + if (!_session.HasAccess(user)) + { + ghostNode = null; + error = new CommandError("The user does not have access to this project.", true); + return false; + } + + var ghost = new GhostNode(referencedNode, boundary, location); + if (!boundary.AddGhostNode(ghost, out error)) + { + ghostNode = null; + return false; + } + ghostNode = ghost; + + Buffer.AddUndo(new Command(() => + { + return (boundary.RemoveGhostNode(ghost, out var e), e); + }, () => + { + return (boundary.AddGhostNode(ghost, out var e), e); + })); + return true; + } + } + + /// + /// Remove a ghost node from its boundary, also removing any incoming links. + /// + public bool RemoveGhostNode(User user, GhostNode ghostNode, + [NotNullWhen(false)] out CommandError? error) + { + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(ghostNode); + + lock (_sessionLock) + { + if (!_session.HasAccess(user)) + { + error = new CommandError("The user does not have access to this project.", true); + return false; + } + + var boundary = ghostNode.ContainedWithin!; + var incomingLinks = GetLinksGoingTo(ghostNode); + var multiLinkInfo = BuildMultiLinkRestoreInfo(incomingLinks, ghostNode); + + RemoveIncomingLinks(incomingLinks, ghostNode, multiLinkInfo); + + if (boundary.RemoveGhostNode(ghostNode, out error)) + { + Buffer.AddUndo(new Command(() => + { + if (boundary.AddGhostNode(ghostNode, out var e)) + { + RestoreIncomingLinks(incomingLinks, ghostNode, multiLinkInfo); + return (true, null); + } + return (false, e); + }, () => + { + RemoveIncomingLinks(incomingLinks, ghostNode, multiLinkInfo); + return (boundary.RemoveGhostNode(ghostNode, out var e), e); + })); + return true; + } + else + { + RestoreIncomingLinks(incomingLinks, ghostNode, multiLinkInfo); + return false; + } + } + } + + /// + /// Moves a regular node (and all of its outgoing links) from its current boundary to + /// . The operation is undoable. + /// + public bool MoveNodeToBoundary(User user, Node node, Boundary targetBoundary, + [NotNullWhen(false)] out CommandError? error) + { + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(node); + ArgumentNullException.ThrowIfNull(targetBoundary); + + lock (_sessionLock) + { + if (!_session.HasAccess(user)) + { + error = new CommandError("The user does not have access to this project.", true); + return false; + } + + var oldBoundary = node.ContainedWithin!; + if (ReferenceEquals(oldBoundary, targetBoundary)) { error = null; return true; } + + // Outgoing links live in the origin node's boundary and must follow the node. + var outgoingLinks = oldBoundary.Links.Where(l => l.Origin == node).ToList(); + + // Collect hidden nodes: destination nodes of outgoing links whose location is + // Rectangle.Hidden (i.e. inlined parameter nodes). These must travel with the node. + var hiddenNodes = outgoingLinks + .SelectMany(l => l is SingleLink sl + ? (sl.Destination is not null ? new[] { sl.Destination } : Array.Empty()) + : (l is MultiLink ml ? ml.Destinations.ToArray() : Array.Empty())) + .Where(n => n.Location.Equals(Rectangle.Hidden) && ReferenceEquals(n.ContainedWithin, oldBoundary)) + .Distinct() + .ToList(); + + if (!oldBoundary.RemoveNode(node, out error)) return false; + + foreach (var link in outgoingLinks) + oldBoundary.RemoveLink(link, out _); + + foreach (var hidden in hiddenNodes) + oldBoundary.RemoveNode(hidden, out _); + + node.UpdateContainedWithin(targetBoundary); + foreach (var hidden in hiddenNodes) + hidden.UpdateContainedWithin(targetBoundary); + + if (!targetBoundary.AddNode(node, out error)) + { + // Roll back hidden nodes and links before returning. + foreach (var hidden in hiddenNodes) + { + hidden.UpdateContainedWithin(oldBoundary); + oldBoundary.AddNode(hidden, out _); + } + node.UpdateContainedWithin(oldBoundary); + oldBoundary.AddNode(node, out _); + foreach (var link in outgoingLinks) + oldBoundary.AddLink(link, out _); + return false; + } + + foreach (var hidden in hiddenNodes) + targetBoundary.AddNode(hidden, out _); + + foreach (var link in outgoingLinks) + targetBoundary.AddLink(link, out _); + + Buffer.AddUndo(new Command(() => + { + foreach (var link in outgoingLinks) targetBoundary.RemoveLink(link, out _); + foreach (var hidden in hiddenNodes) + { + targetBoundary.RemoveNode(hidden, out _); + hidden.UpdateContainedWithin(oldBoundary); + oldBoundary.AddNode(hidden, out _); + } + targetBoundary.RemoveNode(node, out _); + node.UpdateContainedWithin(oldBoundary); + oldBoundary.AddNode(node, out _); + foreach (var link in outgoingLinks) oldBoundary.AddLink(link, out _); + return (true, null); + }, () => + { + foreach (var link in outgoingLinks) oldBoundary.RemoveLink(link, out _); + foreach (var hidden in hiddenNodes) + { + oldBoundary.RemoveNode(hidden, out _); + hidden.UpdateContainedWithin(targetBoundary); + } + oldBoundary.RemoveNode(node, out _); + node.UpdateContainedWithin(targetBoundary); + targetBoundary.AddNode(node, out _); + foreach (var hidden in hiddenNodes) targetBoundary.AddNode(hidden, out _); + foreach (var link in outgoingLinks) targetBoundary.AddLink(link, out _); + return (true, null); + })); + + error = null; + return true; + } + } + + /// + /// Moves a ghost node from its current boundary to . + /// The operation is undoable. + /// + public bool MoveGhostNodeToBoundary(User user, GhostNode ghostNode, Boundary targetBoundary, + [NotNullWhen(false)] out CommandError? error) + { + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(ghostNode); + ArgumentNullException.ThrowIfNull(targetBoundary); + + lock (_sessionLock) + { + if (!_session.HasAccess(user)) + { + error = new CommandError("The user does not have access to this project.", true); + return false; + } + + var oldBoundary = ghostNode.ContainedWithin!; + if (ReferenceEquals(oldBoundary, targetBoundary)) { error = null; return true; } + + if (!oldBoundary.RemoveGhostNode(ghostNode, out error)) return false; + + ghostNode.UpdateContainedWithin(targetBoundary); + + if (!targetBoundary.AddGhostNode(ghostNode, out error)) + { + ghostNode.UpdateContainedWithin(oldBoundary); + oldBoundary.AddGhostNode(ghostNode, out _); + return false; + } + + Buffer.AddUndo(new Command(() => + { + targetBoundary.RemoveGhostNode(ghostNode, out _); + ghostNode.UpdateContainedWithin(oldBoundary); + oldBoundary.AddGhostNode(ghostNode, out _); + return (true, null); + }, () => + { + oldBoundary.RemoveGhostNode(ghostNode, out _); + ghostNode.UpdateContainedWithin(targetBoundary); + targetBoundary.AddGhostNode(ghostNode, out _); + return (true, null); + })); + + error = null; + return true; + } + } + public bool RemoveNode(User user, Node node, [NotNullWhen(false)] out CommandError? error) { ArgumentNullException.ThrowIfNull(user); @@ -818,73 +1064,52 @@ public bool RemoveNode(User user, Node node, [NotNullWhen(false)] out CommandErr // For multi-links in the incoming set, record the exact destination indices // that refer to 'node' so they can be faithfully restored on undo. - var multiLinkRestoreInfo = new Dictionary>(); - foreach (var link in incomingLinks) - { - if (link is MultiLink ml) + var multiLinkRestoreInfo = BuildMultiLinkRestoreInfo(incomingLinks, node); + + // Cascade: collect all ghost nodes referencing this node, plus their links. + var ghostsOfNode = GetAllGhostNodesOf(node); + // Per ghost: (ghost, incomingLinksToGhost, multiLinkRestoreInfo for ghost) + var ghostCascadeData = ghostsOfNode + .Select(g => { - var list = new List<(int Index, Node Dest)>(); - var dests = ml.Destinations; - for (int i = 0; i < dests.Count; i++) - { - if (dests[i] == node) - list.Add((i, dests[i])); - } - multiLinkRestoreInfo[ml] = list; - } - } + var gLinks = GetLinksGoingTo(g); + return (Ghost: g, Links: gLinks, MultiInfo: BuildMultiLinkRestoreInfo(gLinks, g)); + }) + .ToList(); // Remove all incoming links (or just the relevant destination entries). void RemoveIncoming() { - foreach (var link in incomingLinks) - { - if (link is SingleLink) - { - link.Origin!.ContainedWithin!.RemoveLink(link, out _); - } - else if (link is MultiLink ml) - { - // Remove back-to-front so indices remain valid during removal. - var list = multiLinkRestoreInfo[ml]; - for (int i = list.Count - 1; i >= 0; i--) - ml.RemoveDestination(list[i].Index); - - // If the multi-link is now empty, remove the link object itself. - if (ml.Destinations.Count == 0) - ml.Origin!.ContainedWithin!.RemoveLink(ml, out _); - } - } + RemoveIncomingLinks(incomingLinks, node, multiLinkRestoreInfo); } // Restore all incoming links (inverse of RemoveIncoming). void RestoreIncoming() { - foreach (var link in incomingLinks) + RestoreIncomingLinks(incomingLinks, node, multiLinkRestoreInfo); + } + + void RemoveGhostCascade() + { + foreach (var (ghost, gLinks, gMultiInfo) in ghostCascadeData) { - if (link is SingleLink) - { - link.Origin!.ContainedWithin!.AddLink(link, out _); - } - else if (link is MultiLink ml) - { - // If the link object was fully removed, re-add it first. - if (!ml.Origin!.ContainedWithin!.Links.Contains(ml)) - ml.Origin.ContainedWithin.AddLink(ml, out _); + RemoveIncomingLinks(gLinks, ghost, gMultiInfo); + ghost.ContainedWithin!.RemoveGhostNode(ghost, out _); + } + } - // Re-insert destination entries in original order (front-to-back). - var list = multiLinkRestoreInfo[ml]; - for (int i = 0; i < list.Count; i++) - { - if(!ml.AddDestination(list[i].Dest, list[i].Index, out var e)) - return; - } - } + void RestoreGhostCascade() + { + foreach (var (ghost, gLinks, gMultiInfo) in ghostCascadeData) + { + ghost.ContainedWithin!.AddGhostNode(ghost, out _); + RestoreIncomingLinks(gLinks, ghost, gMultiInfo); } } - // Remove incoming links, then outgoing links, then the node itself. + // Remove incoming links, then ghost cascade, then outgoing links, then the node itself. RemoveIncoming(); + RemoveGhostCascade(); foreach (var link in outgoingLinks) boundary.RemoveLink(link, out _); @@ -897,12 +1122,13 @@ void RestoreIncoming() { Buffer.AddUndo(new Command(() => { - // Undo: restore node first, then its outgoing links, then all incoming links. + // Undo: restore node first, then its outgoing links, then all incoming links, then ghosts. if (boundary.AddNode(node, out var e)) { foreach (var link in outgoingLinks) boundary.AddLink(link, out e); RestoreIncoming(); + RestoreGhostCascade(); if (variableIndex >= 0) { var restoreIdx = Math.Min(variableIndex, ModelSystem.Variables.Count); @@ -915,6 +1141,7 @@ void RestoreIncoming() { // Redo: same sequence as the original removal. RemoveIncoming(); + RemoveGhostCascade(); foreach (var link in outgoingLinks) boundary.RemoveLink(link, out _); ModelSystem.Variables.Remove(node); @@ -924,9 +1151,10 @@ void RestoreIncoming() } else { - // Node removal failed; roll back the link removals (and variable removal). + // Node removal failed; roll back the link removals and ghost cascade. foreach (var link in outgoingLinks) boundary.AddLink(link, out _); + RestoreGhostCascade(); RestoreIncoming(); if (variableIndex >= 0) { @@ -1117,6 +1345,108 @@ private List GetLinksGoingTo(Node destNode) return ret; } + /// + /// Builds a restore-info dictionary for MultiLink entries that point to + /// in . + /// + private static Dictionary> BuildMultiLinkRestoreInfo( + List incomingLinks, Node destNode) + { + var info = new Dictionary>(); + foreach (var link in incomingLinks) + { + if (link is MultiLink ml) + { + var list = new List<(int Index, Node Dest)>(); + var dests = ml.Destinations; + for (int i = 0; i < dests.Count; i++) + { + if (dests[i] == destNode) + list.Add((i, dests[i])); + } + info[ml] = list; + } + } + return info; + } + + /// + /// Removes incoming links that target from their + /// respective boundaries, using pre-computed multi-link restore info. + /// + private static void RemoveIncomingLinks( + List incomingLinks, Node destNode, + Dictionary> multiLinkInfo) + { + foreach (var link in incomingLinks) + { + if (link is SingleLink) + { + link.Origin!.ContainedWithin!.RemoveLink(link, out _); + } + else if (link is MultiLink ml && multiLinkInfo.TryGetValue(ml, out var list)) + { + // Remove back-to-front so indices remain valid. + for (int i = list.Count - 1; i >= 0; i--) + ml.RemoveDestination(list[i].Index); + + if (ml.Destinations.Count == 0) + ml.Origin!.ContainedWithin!.RemoveLink(ml, out _); + } + } + } + + /// + /// Restores incoming links that were previously removed by + /// . + /// + private static void RestoreIncomingLinks( + List incomingLinks, Node destNode, + Dictionary> multiLinkInfo) + { + foreach (var link in incomingLinks) + { + if (link is SingleLink) + { + link.Origin!.ContainedWithin!.AddLink(link, out _); + } + else if (link is MultiLink ml && multiLinkInfo.TryGetValue(ml, out var list)) + { + // Re-add the link object if it was fully removed. + if (!ml.Origin!.ContainedWithin!.Links.Contains(ml)) + ml.Origin.ContainedWithin.AddLink(ml, out _); + + // Re-insert destinations in original order. + for (int i = 0; i < list.Count; i++) + { + if (!ml.AddDestination(list[i].Dest, list[i].Index, out _)) + return; + } + } + } + } + + /// + /// Returns every anywhere in the model system that + /// references . + /// + private List GetAllGhostNodesOf(Node realNode) + { + var result = new List(); + var stack = new Stack(); + stack.Push(ModelSystem.GlobalBoundary); + while (stack.Count > 0) + { + var current = stack.Pop(); + foreach (var child in current.Boundaries) + stack.Push(child); + foreach (var ghost in current.GhostNodes) + if (ghost.ReferencedNode == realNode) + result.Add(ghost); + } + return result; + } + /// /// Set the value of a parameter /// diff --git a/src/XTMF2/ModelSystemConstruct/Boundary.cs b/src/XTMF2/ModelSystemConstruct/Boundary.cs index bb7cdf3..73c1f9c 100644 --- a/src/XTMF2/ModelSystemConstruct/Boundary.cs +++ b/src/XTMF2/ModelSystemConstruct/Boundary.cs @@ -52,6 +52,7 @@ public sealed class Boundary : INotifyPropertyChanged private const string LinksProperty = "Links"; private const string CommentBlocksProperty = "CommentBlocks"; private const string FunctionTemplateProperty = "FunctionTemplates"; + private const string GhostNodesProperty = "GhostNodes"; /// /// This lock must be obtained before changing any local settings. @@ -63,12 +64,20 @@ public sealed class Boundary : INotifyPropertyChanged private readonly ObservableCollection _links = new ObservableCollection(); private readonly ObservableCollection _commentBlocks = new ObservableCollection(); private readonly ObservableCollection _functionTemplates = new ObservableCollection(); + private readonly ObservableCollection _ghostNodes = new ObservableCollection(); /// /// Get readonly access to the links contained in this boundary. /// public ReadOnlyObservableCollection Links => new ReadOnlyObservableCollection(_links); + /// + /// Get readonly access to the ghost nodes contained in this boundary. + /// Ghost nodes are visual aliases that point to real nodes which may reside + /// on a different boundary. + /// + public ReadOnlyObservableCollection GhostNodes => new ReadOnlyObservableCollection(_ghostNodes); + /// /// Create a new boundary, optionally with a parent /// @@ -611,6 +620,15 @@ internal void Save(ref int index, Dictionary nodeDictionary, Dictiona functionTemplate.Save(ref index, nodeDictionary, typeDictionary, writer); } writer.WriteEndArray(); + // Ghost nodes are written last so that all referenced nodes already have + // indices in nodeDictionary (pre-assigned by PreAssignNodeIndices). + writer.WritePropertyName(GhostNodesProperty); + writer.WriteStartArray(); + foreach (var ghost in _ghostNodes) + { + ghost.SaveObject(nodeDictionary, writer); + } + writer.WriteEndArray(); writer.WriteEndObject(); } } @@ -626,6 +644,63 @@ internal bool RemoveNode(Node node, [NotNullWhen(false)]out CommandError? error) return true; } + /// + /// Add a ghost node to this boundary. + /// + internal bool AddGhostNode(GhostNode ghostNode, [NotNullWhen(false)] out CommandError? error) + { + if (ghostNode is null) throw new ArgumentNullException(nameof(ghostNode)); + lock (_writeLock) + { + if (_ghostNodes.Contains(ghostNode)) + { + error = new CommandError("The ghost node already exists in the boundary!"); + return false; + } + _ghostNodes.Add(ghostNode); + } + error = null; + return true; + } + + /// + /// Remove a ghost node from this boundary. + /// + internal bool RemoveGhostNode(GhostNode ghostNode, [NotNullWhen(false)] out CommandError? error) + { + if (ghostNode is null) throw new ArgumentNullException(nameof(ghostNode)); + lock (_writeLock) + { + if (!_ghostNodes.Remove(ghostNode)) + { + error = new CommandError("Unable to find the ghost node to remove from the boundary!"); + return false; + } + } + error = null; + return true; + } + + /// + /// Pre-assigns sequential indices for all nodes (starts, modules, function template + /// internals) and ghost nodes in this boundary and all descendant boundaries. + /// This allows to be called after all indices are known, + /// making it possible to write links whose destinations include ghost nodes. + /// + internal void PreAssignNodeIndices(ref int index, Dictionary nodeDictionary) + { + foreach (var start in _starts) + if (!nodeDictionary.ContainsKey(start)) nodeDictionary[start] = index++; + foreach (var module in _modules) + if (!nodeDictionary.ContainsKey(module)) nodeDictionary[module] = index++; + foreach (var child in _boundaries) + child.PreAssignNodeIndices(ref index, nodeDictionary); + foreach (var ft in _functionTemplates) + ft.InternalModules.PreAssignNodeIndices(ref index, nodeDictionary); + foreach (var ghost in _ghostNodes) + if (!nodeDictionary.ContainsKey(ghost)) nodeDictionary[ghost] = index++; + } + /// /// This invocation should only occur with a link that was generated /// by this boundary previously! @@ -657,6 +732,7 @@ internal bool AddLink(Link link, [NotNullWhen(false)] out CommandError? e) internal bool Load(ModuleRepository modules, Dictionary typeLookup, Dictionary node, List<(Node toAssignTo, string parameterExpression)> scriptedParameters, + List<(Boundary ContainedIn, int RefIndex, int SelfIndex, Rectangle Location)> deferredGhostNodes, ref Utf8JsonReader reader, [NotNullWhen(false)] ref string? error) { if (reader.TokenType != JsonTokenType.StartObject) @@ -735,7 +811,7 @@ internal bool Load(ModuleRepository modules, Dictionary typeLookup, D if (reader.TokenType != JsonTokenType.Comment) { var boundary = new Boundary(this); - if (!boundary.Load(modules, typeLookup, node, scriptedParameters, ref reader, ref error)) + if (!boundary.Load(modules, typeLookup, node, scriptedParameters, deferredGhostNodes, ref reader, ref error)) { return false; } @@ -789,7 +865,7 @@ internal bool Load(ModuleRepository modules, Dictionary typeLookup, D { if(reader.TokenType != JsonTokenType.Comment) { - if(!FunctionTemplate.Load(modules, typeLookup, node, scriptedParameters, ref reader, this, out var template, ref error)) + if(!FunctionTemplate.Load(modules, typeLookup, node, scriptedParameters, deferredGhostNodes, ref reader, this, out var template, ref error)) { return false; } @@ -797,6 +873,24 @@ internal bool Load(ModuleRepository modules, Dictionary typeLookup, D } } } + else if (reader.ValueTextEquals(GhostNodesProperty)) + { + if (!reader.Read() || reader.TokenType != JsonTokenType.StartArray) + { + return Helper.FailWith(out error, "Unexpected token when starting to read Ghost Nodes for a boundary."); + } + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + if (reader.TokenType != JsonTokenType.Comment) + { + // Defer resolution until all nodes across all boundaries are loaded. + if (!GhostNode.LoadDeferred(ref reader, this, deferredGhostNodes, ref error)) + { + return false; + } + } + } + } else { return Helper.FailWith(out error, $"Unexpected value when reading boundary {reader.GetString()}"); diff --git a/src/XTMF2/ModelSystemConstruct/FunctionTemplate.cs b/src/XTMF2/ModelSystemConstruct/FunctionTemplate.cs index 84efe2b..70b3374 100644 --- a/src/XTMF2/ModelSystemConstruct/FunctionTemplate.cs +++ b/src/XTMF2/ModelSystemConstruct/FunctionTemplate.cs @@ -101,6 +101,14 @@ internal void Save(ref int index, Dictionary nodeDictionary, Dictiona /// True if the operation succeeded, false otherwise with an error message. internal static bool Load(ModuleRepository modules, Dictionary typeLookup, Dictionary node, List<(Node toAssignTo, string parameterExpression)> scriptedParameters, ref Utf8JsonReader reader, Boundary parent, [NotNullWhen(true)] out FunctionTemplate? template, [NotNullWhen(false)] ref string? error) + { + List<(Boundary ContainedIn, int RefIndex, int SelfIndex, Rectangle Location)> deferredGhostNodes = new(); + return Load(modules, typeLookup, node, scriptedParameters, deferredGhostNodes, ref reader, parent, out template, ref error); + } + + internal static bool Load(ModuleRepository modules, Dictionary typeLookup, Dictionary node, List<(Node toAssignTo, string parameterExpression)> scriptedParameters, + List<(Boundary ContainedIn, int RefIndex, int SelfIndex, Rectangle Location)> deferredGhostNodes, + ref Utf8JsonReader reader, Boundary parent, [NotNullWhen(true)] out FunctionTemplate? template, [NotNullWhen(false)] ref string? error) { template = null; string? name = null; @@ -123,7 +131,7 @@ internal static bool Load(ModuleRepository modules, Dictionary typeLo else if(reader.ValueTextEquals(nameof(InternalModules))) { reader.Read(); - if(!innerModules.Load(modules, typeLookup, node, scriptedParameters, ref reader, ref error)) + if(!innerModules.Load(modules, typeLookup, node, scriptedParameters, deferredGhostNodes, ref reader, ref error)) { return false; } diff --git a/src/XTMF2/ModelSystemConstruct/GhostNode.cs b/src/XTMF2/ModelSystemConstruct/GhostNode.cs new file mode 100644 index 0000000..b25c2c9 --- /dev/null +++ b/src/XTMF2/ModelSystemConstruct/GhostNode.cs @@ -0,0 +1,204 @@ +/* + Copyright 2026 University of Toronto + + This file is part of XTMF2. + + XTMF2 is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + XTMF2 is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with XTMF2. If not, see . +*/ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; + +namespace XTMF2.ModelSystemConstruct +{ + /// + /// A visual alias for another that may reside on a different + /// . + /// + /// Ghost nodes always mirror the name of their referenced node, expose no hooks, + /// and are rendered on the canvas with a dashed outline. They can be link + /// destinations (links drawn to them are displayed on the canvas) but + /// they never act as link origins. When the real node is deleted all ghost nodes + /// that reference it are automatically removed. + /// + /// + public sealed class GhostNode : Node + { + // ── JSON property names (only what is unique to GhostNode) ─────── + internal const string ReferencedNodeProperty = "ReferencedNode"; + + /// The real node that this ghost node visually represents. + public Node ReferencedNode { get; } + + /// + /// Creates a ghost node that mirrors , + /// placed at inside . + /// + internal GhostNode(Node referencedNode, Boundary containedWithin, Rectangle location) + : base(referencedNode.Name, null!, containedWithin, Array.Empty(), location) + { + ReferencedNode = referencedNode; + // Track name changes on the referenced node. + ((INotifyPropertyChanged)referencedNode).PropertyChanged += OnReferencedNodePropertyChanged; + } + + private void OnReferencedNodePropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Name)) + { + Name = ReferencedNode.Name; + // The Name setter in Node already fires PropertyChanged for Name. + } + } + + /// + /// Ghost nodes do not write themselves in a boundary's Nodes array. + /// They are indexed here (so links can reference them) and serialised + /// by in a dedicated GhostNodes array. + /// + internal override void Save(ref int index, Dictionary nodeDictionary, + Dictionary typeDictionary, Utf8JsonWriter writer) + { + // Index this ghost node so links can reference it by index. + // The actual JSON object is written separately by Boundary.SaveGhostNodes. + if (!nodeDictionary.TryGetValue(this, out _)) + nodeDictionary[this] = index++; + } + + /// + /// Writes the standalone JSON object for this ghost node. + /// Called by during save after all regular nodes and child + /// boundaries have been assigned their indices. + /// + internal void SaveObject(Dictionary nodeDictionary, Utf8JsonWriter writer) + { + writer.WriteStartObject(); + writer.WriteNumber(ReferencedNodeProperty, nodeDictionary[ReferencedNode]); + writer.WriteNumber(XProperty, Location.X); + writer.WriteNumber(YProperty, Location.Y); + writer.WriteNumber(WidthProperty, Location.Width); + writer.WriteNumber(HeightProperty, Location.Height); + writer.WriteNumber(IndexProperty, nodeDictionary[this]); + writer.WriteEndObject(); + } + + /// + /// Reads a ghost node entry from JSON and appends a deferred-resolution record. + /// The ghost node is fully constructed later by once all + /// node indices are available. + /// + internal static bool LoadDeferred( + ref Utf8JsonReader reader, + Boundary boundary, + List<(Boundary ContainedIn, int RefIndex, int SelfIndex, Rectangle Location)> deferreds, + [NotNullWhen(false)] ref string? error) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + error = "Expected a start object when loading a ghost node."; + return false; + } + + int refIndex = -1, selfIndex = -1; + Rectangle location = new Rectangle(); + + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.TokenType == JsonTokenType.Comment) continue; + if (reader.TokenType != JsonTokenType.PropertyName) + { + error = "Invalid token when loading a ghost node."; + return false; + } + + if (reader.ValueTextEquals(ReferencedNodeProperty)) + { + reader.Read(); + refIndex = reader.GetInt32(); + } + else if (reader.ValueTextEquals(XProperty)) + { + reader.Read(); + location = new Rectangle(reader.GetSingle(), location.Y, location.Width, location.Height); + } + else if (reader.ValueTextEquals(YProperty)) + { + reader.Read(); + location = new Rectangle(location.X, reader.GetSingle(), location.Width, location.Height); + } + else if (reader.ValueTextEquals(WidthProperty)) + { + reader.Read(); + location = new Rectangle(location.X, location.Y, reader.GetSingle(), location.Height); + } + else if (reader.ValueTextEquals(HeightProperty)) + { + reader.Read(); + location = new Rectangle(location.X, location.Y, location.Width, reader.GetSingle()); + } + else if (reader.ValueTextEquals(IndexProperty)) + { + reader.Read(); + selfIndex = reader.GetInt32(); + } + else + { + // Skip unknown fields for forward compatibility. + reader.Read(); + } + } + + if (refIndex < 0) + { + error = "Ghost node is missing its referenced node index."; + return false; + } + if (selfIndex < 0) + { + error = "Ghost node is missing its own index."; + return false; + } + + deferreds.Add((boundary, refIndex, selfIndex, location)); + return true; + } + + /// + /// Resolves a previously deferred ghost node, creating the object and + /// inserting it into the global dictionary. + /// + internal static bool Resolve( + Dictionary nodes, + Boundary containedIn, + int refIndex, + int selfIndex, + Rectangle location, + [NotNullWhen(true)] out GhostNode? ghost, + [NotNullWhen(false)] ref string? error) + { + if (!nodes.TryGetValue(refIndex, out var refNode)) + { + ghost = null; + error = $"Ghost node references unknown node index {refIndex}."; + return false; + } + + ghost = new GhostNode(refNode, containedIn, location); + nodes[selfIndex] = ghost; + return true; + } + } +} diff --git a/src/XTMF2/ModelSystemConstruct/ModelSystem.cs b/src/XTMF2/ModelSystemConstruct/ModelSystem.cs index 300235f..7b2b40c 100644 --- a/src/XTMF2/ModelSystemConstruct/ModelSystem.cs +++ b/src/XTMF2/ModelSystemConstruct/ModelSystem.cs @@ -211,6 +211,9 @@ private Dictionary WriteBoundaries(Utf8JsonWriter writer, Dictionary< writer.WritePropertyName(BoundariesProperty); writer.WriteStartArray(); Dictionary nodeDictionary = new Dictionary(); + // Pre-assign indices for ALL nodes (including ghost nodes that may reference + // siblings) so that link serialisation can reference any node by index. + GlobalBoundary.PreAssignNodeIndices(ref index, nodeDictionary); GlobalBoundary.Save(ref index, nodeDictionary, typeDictionary, writer); writer.WriteEndArray(); return nodeDictionary; @@ -335,6 +338,7 @@ internal static bool Load(ProjectSession session, ModelSystemHeader modelSystemH var typeLookup = new Dictionary(); var nodes = new Dictionary(); List<(Node toAssignTo, string parameterExpression)> scriptedParameters = new(); + List<(Boundary ContainedIn, int RefIndex, int SelfIndex, Rectangle Location)> deferredGhostNodes = new(); while (reader.Read()) { if (reader.TokenType == JsonTokenType.PropertyName) @@ -348,7 +352,7 @@ internal static bool Load(ProjectSession session, ModelSystemHeader modelSystemH } else if (reader.ValueTextEquals(BoundariesProperty)) { - if (!LoadBoundaries(modules, typeLookup, nodes, scriptedParameters, ref reader, modelSystem.GlobalBoundary, ref error)) + if (!LoadBoundaries(modules, typeLookup, nodes, scriptedParameters, deferredGhostNodes, ref reader, modelSystem.GlobalBoundary, ref error)) { return null; } @@ -363,6 +367,16 @@ internal static bool Load(ProjectSession session, ModelSystemHeader modelSystemH // Unknown properties are silently skipped for forward compatibility. } } + // Resolve deferred ghost nodes now that all boundaries and nodes are loaded. + foreach (var (containedIn, refIndex, selfIndex, location) in deferredGhostNodes) + { + if (!GhostNode.Resolve(nodes, containedIn, refIndex, selfIndex, location, out var ghost, ref error)) + { + // Non-fatal: skip ghost nodes that can't be resolved (e.g. referenced node was removed). + continue; + } + containedIn.AddGhostNode(ghost!, out _); + } // Now that all of the modules have been loaded we can process the scripted parameters foreach (var (toAssignTo, parameterExpression) in scriptedParameters) { @@ -462,7 +476,9 @@ private static bool LoadTypes(Dictionary typeLookup, ref Utf8JsonRead } private static bool LoadBoundaries(ModuleRepository modules, Dictionary typeLookup, Dictionary nodes, - List<(Node toAssignTo, string parameterExpression)> scriptedParameters, ref Utf8JsonReader reader, Boundary global, [NotNullWhen(false)] ref string? error) + List<(Node toAssignTo, string parameterExpression)> scriptedParameters, + List<(Boundary ContainedIn, int RefIndex, int SelfIndex, Rectangle Location)> deferredGhostNodes, + ref Utf8JsonReader reader, Boundary global, [NotNullWhen(false)] ref string? error) { if (!reader.Read() || reader.TokenType != JsonTokenType.StartArray) { @@ -474,7 +490,7 @@ private static bool LoadBoundaries(ModuleRepository modules, Dictionary moduleDictionary, Utf8JsonWrit internal override bool Construct(ref string? error) { - var moduleCount = _Destinations.Count(d => !d.IsDisabled); + // Count enabled destinations, resolving ghost nodes to their real targets. + var moduleCount = _Destinations.Count(d => + { + var effective = d is GhostNode gn ? gn.ReferencedNode : d; + return !effective.IsDisabled; + }); if(OriginHook!.Cardinality == HookCardinality.AtLeastOne) { if (moduleCount <= 0) @@ -101,9 +106,10 @@ internal override bool Construct(ref string? error) int index = 0; for (int i = 0; i < _Destinations.Count; i++) { - if (!_Destinations[i].IsDisabled) + var effectiveDest = _Destinations[i] is GhostNode gn ? gn.ReferencedNode : _Destinations[i]; + if (!effectiveDest.IsDisabled) { - OriginHook.Install(Origin!, _Destinations[i], index++); + OriginHook.Install(Origin!, effectiveDest, index++); } } } diff --git a/src/XTMF2/ModelSystemConstruct/Node.cs b/src/XTMF2/ModelSystemConstruct/Node.cs index b467bf1..471cbf1 100644 --- a/src/XTMF2/ModelSystemConstruct/Node.cs +++ b/src/XTMF2/ModelSystemConstruct/Node.cs @@ -38,6 +38,15 @@ public class Node : INotifyPropertyChanged /// public Boundary ContainedWithin { get; protected set; } + /// + /// Transfers this node to a different containing boundary. + /// Only called by during move operations. + /// + internal void UpdateContainedWithin(Boundary newBoundary) + { + ContainedWithin = newBoundary; + } + protected const string NameProperty = "Name"; protected const string DescriptionProperty = "Description"; protected const string XProperty = "X"; @@ -371,7 +380,12 @@ internal bool GetLink(NodeHook hook, out Link? link) internal virtual void Save(ref int index, Dictionary moduleDictionary, Dictionary typeDictionary, Utf8JsonWriter writer) { - moduleDictionary.Add(this, index); + // Support pre-indexed nodes (PreAssignNodeIndices was called before Save). + if (!moduleDictionary.TryGetValue(this, out var myIndex)) + { + myIndex = index++; + moduleDictionary[this] = myIndex; + } writer.WriteStartObject(); writer.WriteString(NameProperty, Name); writer.WriteString(DescriptionProperty, Description); @@ -380,7 +394,7 @@ internal virtual void Save(ref int index, Dictionary moduleDictionary writer.WriteNumber(YProperty, Location.Y); writer.WriteNumber(WidthProperty, Location.Width); writer.WriteNumber(HeightProperty, Location.Height); - writer.WriteNumber(IndexProperty, index++); + writer.WriteNumber(IndexProperty, myIndex); if (ParameterValue is not null) { ParameterValue.Save(writer); diff --git a/src/XTMF2/ModelSystemConstruct/SingleLink.cs b/src/XTMF2/ModelSystemConstruct/SingleLink.cs index 68a01aa..8aefb59 100644 --- a/src/XTMF2/ModelSystemConstruct/SingleLink.cs +++ b/src/XTMF2/ModelSystemConstruct/SingleLink.cs @@ -58,10 +58,13 @@ internal override void Save(Dictionary moduleDictionary, Utf8JsonWrit internal override bool Construct(ref string? error) { + // Resolve ghost-node destinations to their real node at runtime. + var effectiveDest = Destination is GhostNode gn ? gn.ReferencedNode : Destination!; + // if not optional if (OriginHook!.Cardinality == HookCardinality.Single) { - if (Destination!.IsDisabled) + if (effectiveDest.IsDisabled) { error = "A link destined for a disabled module was not optional."; return false; @@ -75,7 +78,7 @@ internal override bool Construct(ref string? error) if (!IsDisabled) { // The index doesn't matter for this type - OriginHook.Install(Origin!, Destination!, 0); + OriginHook.Install(Origin!, effectiveDest, 0); } return true; } diff --git a/src/XTMF2/ModelSystemConstruct/Start.cs b/src/XTMF2/ModelSystemConstruct/Start.cs index 49e1faf..c7eb435 100644 --- a/src/XTMF2/ModelSystemConstruct/Start.cs +++ b/src/XTMF2/ModelSystemConstruct/Start.cs @@ -43,11 +43,16 @@ public Start(ModuleRepository modules, string startName, Boundary boundary, stri internal override void Save(ref int index, Dictionary moduleDictionary, Dictionary typeDictionary, Utf8JsonWriter writer) { - moduleDictionary.Add(this, index); + // Support pre-indexed nodes (PreAssignNodeIndices was called before Save). + if (!moduleDictionary.TryGetValue(this, out var myIndex)) + { + myIndex = index++; + moduleDictionary[this] = myIndex; + } writer.WriteStartObject(); writer.WriteString(NameProperty, Name); writer.WriteString(DescriptionProperty, Description); - writer.WriteNumber(IndexProperty, index++); + writer.WriteNumber(IndexProperty, myIndex); writer.WriteNumber(XProperty, Location.X); writer.WriteNumber(YProperty, Location.Y); writer.WriteEndObject(); From 9e81ba826eb7dd1e7aa4eb273c7cf8bf3d0aca72 Mon Sep 17 00:00:00 2001 From: James Vaughan Date: Sat, 28 Mar 2026 13:57:23 -0400 Subject: [PATCH 3/7] Resolve selection not clearing. --- src/XTMF2.GUI/Controls/ModelSystemCanvas.cs | 10 +- src/XTMF2/ModelSystemConstruct/GhostNode.cs | 299 ++++++++++---------- 2 files changed, 158 insertions(+), 151 deletions(-) diff --git a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs index 96672b7..9114f3c 100644 --- a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs +++ b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs @@ -2315,7 +2315,15 @@ private void ClearMultiSelection() foreach (var el in _multiSelection) el.IsSelected = false; _multiSelection.Clear(); - _vm?.SelectedElement = null; + // Also clear IsSelected on the primary selected element (which may not be in + // _multiSelection when using single-select). This must happen before zeroing + // SelectedElement so callers that immediately invoke SelectElementCommand or + // SelectLinkCommand don't skip the IsSelected reset (those commands guard on + // SelectedElement being non-null, but it will already be null after this method). + if (_vm?.SelectedElement is { } primary) + primary.IsSelected = false; + if (_vm is not null) + _vm.SelectedElement = null; } /// Returns a that always has non-negative width and height, diff --git a/src/XTMF2/ModelSystemConstruct/GhostNode.cs b/src/XTMF2/ModelSystemConstruct/GhostNode.cs index b25c2c9..acb7203 100644 --- a/src/XTMF2/ModelSystemConstruct/GhostNode.cs +++ b/src/XTMF2/ModelSystemConstruct/GhostNode.cs @@ -22,183 +22,182 @@ You should have received a copy of the GNU General Public License using System.Diagnostics.CodeAnalysis; using System.Text.Json; -namespace XTMF2.ModelSystemConstruct +namespace XTMF2.ModelSystemConstruct; + +/// +/// A visual alias for another that may reside on a different +/// . +/// +/// Ghost nodes always mirror the name of their referenced node, expose no hooks, +/// and are rendered on the canvas with a dashed outline. They can be link +/// destinations (links drawn to them are displayed on the canvas) but +/// they never act as link origins. When the real node is deleted all ghost nodes +/// that reference it are automatically removed. +/// +/// +public sealed class GhostNode : Node { + // ── JSON property names (only what is unique to GhostNode) ─────── + internal const string ReferencedNodeProperty = "ReferencedNode"; + + /// The real node that this ghost node visually represents. + public Node ReferencedNode { get; } + /// - /// A visual alias for another that may reside on a different - /// . - /// - /// Ghost nodes always mirror the name of their referenced node, expose no hooks, - /// and are rendered on the canvas with a dashed outline. They can be link - /// destinations (links drawn to them are displayed on the canvas) but - /// they never act as link origins. When the real node is deleted all ghost nodes - /// that reference it are automatically removed. - /// + /// Creates a ghost node that mirrors , + /// placed at inside . /// - public sealed class GhostNode : Node + internal GhostNode(Node referencedNode, Boundary containedWithin, Rectangle location) + : base(referencedNode.Name, null!, containedWithin, Array.Empty(), location) { - // ── JSON property names (only what is unique to GhostNode) ─────── - internal const string ReferencedNodeProperty = "ReferencedNode"; - - /// The real node that this ghost node visually represents. - public Node ReferencedNode { get; } - - /// - /// Creates a ghost node that mirrors , - /// placed at inside . - /// - internal GhostNode(Node referencedNode, Boundary containedWithin, Rectangle location) - : base(referencedNode.Name, null!, containedWithin, Array.Empty(), location) - { - ReferencedNode = referencedNode; - // Track name changes on the referenced node. - ((INotifyPropertyChanged)referencedNode).PropertyChanged += OnReferencedNodePropertyChanged; - } + ReferencedNode = referencedNode; + // Track name changes on the referenced node. + ((INotifyPropertyChanged)referencedNode).PropertyChanged += OnReferencedNodePropertyChanged; + } - private void OnReferencedNodePropertyChanged(object? sender, PropertyChangedEventArgs e) + private void OnReferencedNodePropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Name)) { - if (e.PropertyName == nameof(Name)) - { - Name = ReferencedNode.Name; - // The Name setter in Node already fires PropertyChanged for Name. - } + Name = ReferencedNode.Name; + // The Name setter in Node already fires PropertyChanged for Name. } + } - /// - /// Ghost nodes do not write themselves in a boundary's Nodes array. - /// They are indexed here (so links can reference them) and serialised - /// by in a dedicated GhostNodes array. - /// - internal override void Save(ref int index, Dictionary nodeDictionary, - Dictionary typeDictionary, Utf8JsonWriter writer) - { - // Index this ghost node so links can reference it by index. - // The actual JSON object is written separately by Boundary.SaveGhostNodes. - if (!nodeDictionary.TryGetValue(this, out _)) - nodeDictionary[this] = index++; - } + /// + /// Ghost nodes do not write themselves in a boundary's Nodes array. + /// They are indexed here (so links can reference them) and serialised + /// by in a dedicated GhostNodes array. + /// + internal override void Save(ref int index, Dictionary nodeDictionary, + Dictionary typeDictionary, Utf8JsonWriter writer) + { + // Index this ghost node so links can reference it by index. + // The actual JSON object is written separately by Boundary.SaveGhostNodes. + if (!nodeDictionary.TryGetValue(this, out _)) + nodeDictionary[this] = index++; + } + + /// + /// Writes the standalone JSON object for this ghost node. + /// Called by during save after all regular nodes and child + /// boundaries have been assigned their indices. + /// + internal void SaveObject(Dictionary nodeDictionary, Utf8JsonWriter writer) + { + writer.WriteStartObject(); + writer.WriteNumber(ReferencedNodeProperty, nodeDictionary[ReferencedNode]); + writer.WriteNumber(XProperty, Location.X); + writer.WriteNumber(YProperty, Location.Y); + writer.WriteNumber(WidthProperty, Location.Width); + writer.WriteNumber(HeightProperty, Location.Height); + writer.WriteNumber(IndexProperty, nodeDictionary[this]); + writer.WriteEndObject(); + } - /// - /// Writes the standalone JSON object for this ghost node. - /// Called by during save after all regular nodes and child - /// boundaries have been assigned their indices. - /// - internal void SaveObject(Dictionary nodeDictionary, Utf8JsonWriter writer) + /// + /// Reads a ghost node entry from JSON and appends a deferred-resolution record. + /// The ghost node is fully constructed later by once all + /// node indices are available. + /// + internal static bool LoadDeferred( + ref Utf8JsonReader reader, + Boundary boundary, + List<(Boundary ContainedIn, int RefIndex, int SelfIndex, Rectangle Location)> deferreds, + [NotNullWhen(false)] ref string? error) + { + if (reader.TokenType != JsonTokenType.StartObject) { - writer.WriteStartObject(); - writer.WriteNumber(ReferencedNodeProperty, nodeDictionary[ReferencedNode]); - writer.WriteNumber(XProperty, Location.X); - writer.WriteNumber(YProperty, Location.Y); - writer.WriteNumber(WidthProperty, Location.Width); - writer.WriteNumber(HeightProperty, Location.Height); - writer.WriteNumber(IndexProperty, nodeDictionary[this]); - writer.WriteEndObject(); + error = "Expected a start object when loading a ghost node."; + return false; } - /// - /// Reads a ghost node entry from JSON and appends a deferred-resolution record. - /// The ghost node is fully constructed later by once all - /// node indices are available. - /// - internal static bool LoadDeferred( - ref Utf8JsonReader reader, - Boundary boundary, - List<(Boundary ContainedIn, int RefIndex, int SelfIndex, Rectangle Location)> deferreds, - [NotNullWhen(false)] ref string? error) + int refIndex = -1, selfIndex = -1; + Rectangle location = new Rectangle(); + + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) { - if (reader.TokenType != JsonTokenType.StartObject) + if (reader.TokenType == JsonTokenType.Comment) continue; + if (reader.TokenType != JsonTokenType.PropertyName) { - error = "Expected a start object when loading a ghost node."; + error = "Invalid token when loading a ghost node."; return false; } - int refIndex = -1, selfIndex = -1; - Rectangle location = new Rectangle(); - - while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + if (reader.ValueTextEquals(ReferencedNodeProperty)) { - if (reader.TokenType == JsonTokenType.Comment) continue; - if (reader.TokenType != JsonTokenType.PropertyName) - { - error = "Invalid token when loading a ghost node."; - return false; - } - - if (reader.ValueTextEquals(ReferencedNodeProperty)) - { - reader.Read(); - refIndex = reader.GetInt32(); - } - else if (reader.ValueTextEquals(XProperty)) - { - reader.Read(); - location = new Rectangle(reader.GetSingle(), location.Y, location.Width, location.Height); - } - else if (reader.ValueTextEquals(YProperty)) - { - reader.Read(); - location = new Rectangle(location.X, reader.GetSingle(), location.Width, location.Height); - } - else if (reader.ValueTextEquals(WidthProperty)) - { - reader.Read(); - location = new Rectangle(location.X, location.Y, reader.GetSingle(), location.Height); - } - else if (reader.ValueTextEquals(HeightProperty)) - { - reader.Read(); - location = new Rectangle(location.X, location.Y, location.Width, reader.GetSingle()); - } - else if (reader.ValueTextEquals(IndexProperty)) - { - reader.Read(); - selfIndex = reader.GetInt32(); - } - else - { - // Skip unknown fields for forward compatibility. - reader.Read(); - } + reader.Read(); + refIndex = reader.GetInt32(); } - - if (refIndex < 0) + else if (reader.ValueTextEquals(XProperty)) { - error = "Ghost node is missing its referenced node index."; - return false; + reader.Read(); + location = new Rectangle(reader.GetSingle(), location.Y, location.Width, location.Height); } - if (selfIndex < 0) + else if (reader.ValueTextEquals(YProperty)) { - error = "Ghost node is missing its own index."; - return false; + reader.Read(); + location = new Rectangle(location.X, reader.GetSingle(), location.Width, location.Height); + } + else if (reader.ValueTextEquals(WidthProperty)) + { + reader.Read(); + location = new Rectangle(location.X, location.Y, reader.GetSingle(), location.Height); + } + else if (reader.ValueTextEquals(HeightProperty)) + { + reader.Read(); + location = new Rectangle(location.X, location.Y, location.Width, reader.GetSingle()); + } + else if (reader.ValueTextEquals(IndexProperty)) + { + reader.Read(); + selfIndex = reader.GetInt32(); + } + else + { + // Skip unknown fields for forward compatibility. + reader.Read(); } - - deferreds.Add((boundary, refIndex, selfIndex, location)); - return true; } - /// - /// Resolves a previously deferred ghost node, creating the object and - /// inserting it into the global dictionary. - /// - internal static bool Resolve( - Dictionary nodes, - Boundary containedIn, - int refIndex, - int selfIndex, - Rectangle location, - [NotNullWhen(true)] out GhostNode? ghost, - [NotNullWhen(false)] ref string? error) + if (refIndex < 0) { - if (!nodes.TryGetValue(refIndex, out var refNode)) - { - ghost = null; - error = $"Ghost node references unknown node index {refIndex}."; - return false; - } + error = "Ghost node is missing its referenced node index."; + return false; + } + if (selfIndex < 0) + { + error = "Ghost node is missing its own index."; + return false; + } + + deferreds.Add((boundary, refIndex, selfIndex, location)); + return true; + } - ghost = new GhostNode(refNode, containedIn, location); - nodes[selfIndex] = ghost; - return true; + /// + /// Resolves a previously deferred ghost node, creating the object and + /// inserting it into the global dictionary. + /// + internal static bool Resolve( + Dictionary nodes, + Boundary containedIn, + int refIndex, + int selfIndex, + Rectangle location, + [NotNullWhen(true)] out GhostNode? ghost, + [NotNullWhen(false)] ref string? error) + { + if (!nodes.TryGetValue(refIndex, out var refNode)) + { + ghost = null; + error = $"Ghost node references unknown node index {refIndex}."; + return false; } + + ghost = new GhostNode(refNode, containedIn, location); + nodes[selfIndex] = ghost; + return true; } } From 5e6d711a3cc636f48e367401c9af2e19a0750e3e Mon Sep 17 00:00:00 2001 From: James Vaughan Date: Sat, 28 Mar 2026 14:26:01 -0400 Subject: [PATCH 4/7] Add Boundary Select keyboard shortcut and filter --- .../Views/BoundaryPickerDialog.axaml | 19 +++++-- .../Views/BoundaryPickerDialog.axaml.cs | 57 ++++++++++++++++++- .../Views/ModelSystemEditorView.axaml | 3 +- 3 files changed, 69 insertions(+), 10 deletions(-) diff --git a/src/XTMF2.GUI/Views/BoundaryPickerDialog.axaml b/src/XTMF2.GUI/Views/BoundaryPickerDialog.axaml index abbebbb..3adf293 100644 --- a/src/XTMF2.GUI/Views/BoundaryPickerDialog.axaml +++ b/src/XTMF2.GUI/Views/BoundaryPickerDialog.axaml @@ -12,24 +12,31 @@ MinHeight="300" WindowStartupLocation="CenterOwner"> - + + Margin="0,0,0,8"/> + + + - + SelectionMode="Single" + DoubleTapped="BoundaryListBox_DoubleTapped"> - - _allBrowseItems = new(); // ── Result ──────────────────────────────────────────────────────────── /// How the dialog was closed. @@ -108,19 +110,68 @@ public BoundaryPickerDialog( preSelect = item; } + _allBrowseItems = new List(BrowseItems); + // Pre-select the current boundary once the window is open and the list is rendered. - if (preSelect is not null) + Opened += (_, _) => { - Opened += (_, _) => + if (preSelect is not null) { SelectedBrowseItem = preSelect; BoundaryListBox.ScrollIntoView(preSelect); - }; + } + SearchBox.Focus(); + }; + + Activated += (_, _) => SearchBox.Focus(); + + SearchBox.TextChanged += SearchBox_TextChanged; + SearchBox.KeyDown += SearchBox_KeyDown; + } + + // ── Search ──────────────────────────────────────────────────────────── + + private void SearchBox_TextChanged(object? sender, TextChangedEventArgs e) + { + var query = SearchBox.Text?.Trim() ?? string.Empty; + BrowseItems.Clear(); + foreach (var item in _allBrowseItems) + { + if (string.IsNullOrEmpty(query) || + item.Boundary.Name.Contains(query, StringComparison.OrdinalIgnoreCase)) + { + BrowseItems.Add(item); + } + } + // Keep first visible item selected so the user can immediately press Enter. + if (BrowseItems.Count > 0 && (SelectedBrowseItem is null || !BrowseItems.Contains(SelectedBrowseItem))) + SelectedBrowseItem = BrowseItems[0]; + else if (BrowseItems.Count == 0) + SelectedBrowseItem = null; + } + + private void SearchBox_KeyDown(object? sender, KeyEventArgs e) + { + if (e.Key == Key.Enter && BrowseItems.Count > 0) + { + SelectedBrowseItem = BrowseItems[0]; + Result = BoundaryPickerResult.Navigate; + SelectedBoundary = SelectedBrowseItem.Boundary; + Close(); + e.Handled = true; } } // ── Button handlers ─────────────────────────────────────────────────── + private void BoundaryListBox_DoubleTapped(object? sender, Avalonia.Input.TappedEventArgs e) + { + if (SelectedBrowseItem is null) return; + Result = BoundaryPickerResult.Navigate; + SelectedBoundary = SelectedBrowseItem.Boundary; + Close(); + } + private void Navigate_Click(object? sender, RoutedEventArgs e) { if (SelectedBrowseItem is null) return; diff --git a/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml b/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml index c082841..68b2602 100644 --- a/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml +++ b/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml @@ -15,6 +15,7 @@ + @@ -39,7 +40,7 @@ Width="200" VerticalAlignment="Center" Margin="0,0,8,0" - ToolTip.Tip="Navigate to a boundary"> + ToolTip.Tip="Navigate to a boundary (Ctrl+B)"> From 913f66c6be8b1efed59a621b5d58fd3e900b0464 Mon Sep 17 00:00:00 2001 From: James Vaughan Date: Sat, 28 Mar 2026 16:22:53 -0400 Subject: [PATCH 5/7] Added menu item to launch the boundary selection dialog. --- src/XTMF2.GUI/MainWindow.axaml | 5 +++++ src/XTMF2.GUI/MainWindow.axaml.cs | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/src/XTMF2.GUI/MainWindow.axaml b/src/XTMF2.GUI/MainWindow.axaml index 5d4b954..3acfc11 100644 --- a/src/XTMF2.GUI/MainWindow.axaml +++ b/src/XTMF2.GUI/MainWindow.axaml @@ -29,6 +29,11 @@ + + + diff --git a/src/XTMF2.GUI/MainWindow.axaml.cs b/src/XTMF2.GUI/MainWindow.axaml.cs index 644bb22..609cc9c 100644 --- a/src/XTMF2.GUI/MainWindow.axaml.cs +++ b/src/XTMF2.GUI/MainWindow.axaml.cs @@ -354,6 +354,7 @@ private void OnDocumentDockPropertyChanged(object? sender, PropertyChangedEventA UndoCommand.NotifyCanExecuteChanged(); RedoCommand.NotifyCanExecuteChanged(); + NavigateBoundaryCommand.NotifyCanExecuteChanged(); } /// Refreshes undo/redo can-execute state when the active editor's state changes. @@ -375,6 +376,10 @@ or nameof(ModelSystemEditorViewModel.CanRedo)) private void Redo() => _activeEditorVm?.RedoCommand.Execute(null); private bool CanExecuteRedo() => _activeEditorVm?.CanRedo ?? false; + [RelayCommand(CanExecute = nameof(CanExecuteNavigateBoundary))] + private void NavigateBoundary() => _activeEditorVm?.BrowseBoundariesCommand.Execute(null); + private bool CanExecuteNavigateBoundary() => _activeEditorVm is not null; + private void Exit_Click(object? sender, RoutedEventArgs e) => Close(); private void Window_KeyDown(object? sender, KeyEventArgs e) From 679025c7510b7f03410a5de84ef58924503bbde6 Mon Sep 17 00:00:00 2001 From: James Vaughan Date: Mon, 30 Mar 2026 11:21:30 -0400 Subject: [PATCH 6/7] Added Starts to the multi select --- src/XTMF2.GUI/Controls/ModelSystemCanvas.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs index 9114f3c..2cf8384 100644 --- a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs +++ b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs @@ -1818,6 +1818,16 @@ protected override void OnPointerReleased(PointerReleasedEventArgs e) firstHit ??= ghost; } } + foreach (var start in _vm.Starts) + { + var sr = new Rect(start.X, start.Y, start.Diameter, start.Diameter); + if (finalRect.Intersects(sr)) + { + _multiSelection.Add(start); + start.IsSelected = true; + firstHit ??= start; + } + } if (firstHit is not null) _vm.SelectedElement = firstHit; } From 9598f9ffd1c82200e3b94390bd0aadda23d122ea Mon Sep 17 00:00:00 2001 From: James Vaughan Date: Mon, 30 Mar 2026 11:33:08 -0400 Subject: [PATCH 7/7] Fix GhostNode resizing --- src/XTMF2.GUI/Controls/ModelSystemCanvas.cs | 4 ++++ src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs index 2cf8384..ad84954 100644 --- a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs +++ b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs @@ -1008,12 +1008,14 @@ private double NodeRenderHeight(NodeViewModel node) private double ElementRenderWidth(ICanvasElement el) => el is NodeViewModel nvm ? NodeRenderWidth(nvm) : el is CommentBlockViewModel cvm ? cvm.Width + : el is GhostNodeViewModel gnvm ? gnvm.Width : 0; /// Returns the rendered height of any resizable canvas element. private double ElementRenderHeight(ICanvasElement el) => el is NodeViewModel nvm ? NodeRenderHeight(nvm) : el is CommentBlockViewModel cvm ? cvm.Height + : el is GhostNodeViewModel gnvm ? gnvm.Height : 0; private void RenderNodes(DrawingContext ctx) @@ -1638,6 +1640,8 @@ protected override void OnPointerMoved(PointerEventArgs e) resizingNode.ResizeTo(_resizeStartW + dw, _resizeStartH + dh); else if (_resizing is CommentBlockViewModel resizingComment) resizingComment.ResizeTo(_resizeStartW + dw, _resizeStartH + dh); + else if (_resizing is GhostNodeViewModel resizingGhost) + resizingGhost.ResizeTo(_resizeStartW + dw, _resizeStartH + dh); InvalidateAndMeasure(); e.Handled = true; return; diff --git a/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs index cd81ba1..a06c83e 100644 --- a/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs +++ b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs @@ -338,9 +338,11 @@ private void BuildFromBoundary(Boundary boundary) { foreach (var node in boundary.Modules) Nodes.Add(new NodeViewModel(node, Session, User)); foreach (var start in boundary.Starts) Starts.Add(new StartViewModel(start, Session, User)); + // Ghost nodes must be populated before links so that ResolveElement can find + // GhostNodeViewModel instances when a link destination is a GhostNode. + foreach (var ghost in boundary.GhostNodes) GhostNodes.Add(new GhostNodeViewModel(ghost, Session, User)); foreach (var link in boundary.Links) TryAddLinkViewModel(link); foreach (var cb in boundary.CommentBlocks) CommentBlocks.Add(new CommentBlockViewModel(cb, Session, User)); - foreach (var ghost in boundary.GhostNodes) GhostNodes.Add(new GhostNodeViewModel(ghost, Session, User)); } // Cached reference to the current boundary's Boundaries collection so we can